routers.ts 59 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533
  1. import { z } from "zod";
  2. import { nanoid } from "nanoid";
  3. import { TRPCError } from "@trpc/server";
  4. import { COOKIE_NAME } from "@shared/const";
  5. import { getSessionCookieOptions } from "./_core/cookies";
  6. import { systemRouter } from "./_core/systemRouter";
  7. import { publicProcedure, protectedProcedure, agentProcedure, adminProcedure, router } from "./_core/trpc";
  8. import { invokeLLM } from "./_core/llm";
  9. import { notifyOwner } from "./_core/notification";
  10. import bcrypt from "bcryptjs";
  11. import {
  12. createConversation, getConversations, getConversationsAdvanced, getConversationById,
  13. getConversationBySessionId, updateConversationStatus, getConversationStats,
  14. addMessage, getMessagesByConversation,
  15. getConversationMessageCounts, getAgentUsers,
  16. bulkUpdateConversationStatus, deleteConversations,
  17. saveWorkflow, getWorkflow,
  18. getWorkflowSuggestions, updateWorkflowSuggestionStatus, bulkCreateWorkflowSuggestions,
  19. getAllUsers, updateUserRole, updateUserErpContactCid, getUserById, deleteUser, getUserByEmail,
  20. getUserByEmailWithPassword, createUserWithPassword, updateUserPassword,
  21. createPasswordResetToken, getPasswordResetToken, markPasswordResetTokenUsed,
  22. createInvitation, getAllInvitations, getInvitationByToken, updateInvitationStatus,
  23. expireOldInvitations, getInvitationByEmail,
  24. createAuditLog, getAuditLogs,
  25. trackAnalyticsEvent, getAnalyticsEvents, getAnalyticsSummary, getIntentStats, getResponseTimeStats,
  26. createDataSource, getDataSources, getDataSourceById, updateDataSource, deleteDataSource,
  27. createApiConnection, getApiConnections, getApiConnectionById, updateApiConnection, deleteApiConnection, incrementApiConnectionExecution,
  28. rateConversation,
  29. searchKnowledge, incrementKnowledgeUseCount, logKnowledgeSuggestion,
  30. getKnowledgeEntries, getKnowledgeEntryById, createKnowledgeEntry,
  31. updateKnowledgeEntry, deleteKnowledgeEntry, bulkCreateKnowledgeEntries,
  32. getKnowledgeSuggestions, promoteKnowledgeSuggestion, dismissKnowledgeSuggestion,
  33. getKnowledgeProducts, bulkCreateKnowledgeProducts, deleteAllKnowledgeProducts,
  34. } from "./db";
  35. import { detectFlowIntent, executeFlow } from "./flowEngine";
  36. import { runApiConnection } from "./apiConnectionRunner";
  37. import { messages } from "../drizzle/schema";
  38. import { eq, desc } from "drizzle-orm";
  39. import { sdk } from "./_core/sdk";
  40. import { ENV } from "./_core/env";
  41. import {
  42. lookupOrder,
  43. lookupOrdersByCustomer,
  44. lookupOrdersByPO,
  45. lookupTrackingByCustomer,
  46. lookupCatalog,
  47. lookupStock,
  48. lookupContact,
  49. lookupProductImages,
  50. } from "./erpTools";
  51. /* ─── Homelegance chatbot system prompt ─── */
  52. const SYSTEM_PROMPT = `You are **Ellie**, the Homelegance AI Assistant — a warm, knowledgeable furniture expert helping visitors on homelegance.com. Always introduce yourself as Ellie when greeting new visitors.
  53. About Homelegance:
  54. - Homelegance is a leading wholesale furniture manufacturer and distributor
  55. - They offer living room, bedroom, dining room, and accent furniture
  56. - Their customers are primarily furniture retailers and dealers (B2B)
  57. - They have collections ranging from traditional to contemporary styles
  58. Your capabilities:
  59. 1. **Product Discovery**: Help users find furniture by category, style, collection, or room type
  60. 2. **Order Status**: Help dealers check order status (ask for order number)
  61. 3. **Dealer Locator**: Help find authorized Homelegance dealers by location
  62. 4. **Warranty & Returns**: Answer questions about warranty policies and return procedures
  63. 5. **General FAQ**: Answer common questions about Homelegance products and services
  64. Guidelines:
  65. - Be warm, professional, and concise
  66. - When users ask about products, suggest specific categories and collections
  67. - For order inquiries, always ask for the order number
  68. - If you cannot help with something, offer to connect them with a human agent
  69. - Keep responses under 150 words unless detailed information is needed
  70. - Use markdown formatting for lists and emphasis when helpful`;
  71. export const appRouter = router({
  72. system: systemRouter,
  73. auth: router({
  74. me: publicProcedure.query(opts => opts.ctx.user),
  75. logout: publicProcedure.mutation(({ ctx }) => {
  76. const cookieOptions = getSessionCookieOptions(ctx.req);
  77. ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
  78. return { success: true } as const;
  79. }),
  80. /** Register a new user with email/password */
  81. register: publicProcedure
  82. .input(z.object({
  83. email: z.string().email(),
  84. password: z.string().min(8).max(128),
  85. name: z.string().min(1).max(100),
  86. }))
  87. .mutation(async ({ input, ctx }) => {
  88. const existing = await getUserByEmailWithPassword(input.email);
  89. if (existing) {
  90. throw new TRPCError({
  91. code: "CONFLICT",
  92. message: "An account with this email already exists",
  93. });
  94. }
  95. const passwordHash = await bcrypt.hash(input.password, 12);
  96. const user = await createUserWithPassword({
  97. email: input.email,
  98. name: input.name,
  99. passwordHash,
  100. });
  101. if (!user) {
  102. throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create account" });
  103. }
  104. // Create session
  105. const sessionToken = await sdk.createSessionToken(user.openId, {
  106. name: user.name || "",
  107. expiresInMs: 30 * 24 * 60 * 60 * 1000, // 30 days
  108. });
  109. const cookieOptions = getSessionCookieOptions(ctx.req);
  110. ctx.res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: 30 * 24 * 60 * 60 * 1000 });
  111. return { success: true, user: { id: user.id, name: user.name, email: user.email, role: user.role } };
  112. }),
  113. /** Login with email/password */
  114. login: publicProcedure
  115. .input(z.object({
  116. email: z.string().email(),
  117. password: z.string().min(1),
  118. }))
  119. .mutation(async ({ input, ctx }) => {
  120. const user = await getUserByEmailWithPassword(input.email);
  121. if (!user || !user.passwordHash) {
  122. throw new TRPCError({
  123. code: "UNAUTHORIZED",
  124. message: "Invalid email or password",
  125. });
  126. }
  127. const isValid = await bcrypt.compare(input.password, user.passwordHash);
  128. if (!isValid) {
  129. throw new TRPCError({
  130. code: "UNAUTHORIZED",
  131. message: "Invalid email or password",
  132. });
  133. }
  134. // Create session
  135. const sessionToken = await sdk.createSessionToken(user.openId, {
  136. name: user.name || "",
  137. expiresInMs: 30 * 24 * 60 * 60 * 1000,
  138. });
  139. const cookieOptions = getSessionCookieOptions(ctx.req);
  140. ctx.res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: 30 * 24 * 60 * 60 * 1000 });
  141. return { success: true, user: { id: user.id, name: user.name, email: user.email, role: user.role } };
  142. }),
  143. /** Request password reset — generates a token */
  144. forgotPassword: publicProcedure
  145. .input(z.object({ email: z.string().email() }))
  146. .mutation(async ({ input }) => {
  147. const user = await getUserByEmailWithPassword(input.email);
  148. // Always return success to prevent email enumeration
  149. if (!user || !user.passwordHash) {
  150. return { success: true, message: "If an account with that email exists, a reset link has been generated." };
  151. }
  152. const token = nanoid(32);
  153. const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
  154. await createPasswordResetToken({
  155. userId: user.id,
  156. token,
  157. expiresAt,
  158. });
  159. // In production, you would send an email here.
  160. // For demo, we return the token (in production, NEVER return the token)
  161. try {
  162. await notifyOwner({
  163. title: "Password Reset Requested",
  164. content: `Password reset requested for ${input.email}. Reset link: /reset-password/${token}`,
  165. });
  166. } catch (e) { /* non-critical */ }
  167. return { success: true, message: "If an account with that email exists, a reset link has been generated." };
  168. }),
  169. /** Validate a password reset token */
  170. validateResetToken: publicProcedure
  171. .input(z.object({ token: z.string() }))
  172. .query(async ({ input }) => {
  173. const resetToken = await getPasswordResetToken(input.token);
  174. if (!resetToken) {
  175. return { valid: false, reason: "Invalid reset link" } as const;
  176. }
  177. if (resetToken.usedAt) {
  178. return { valid: false, reason: "This reset link has already been used" } as const;
  179. }
  180. if (new Date() > resetToken.expiresAt) {
  181. return { valid: false, reason: "This reset link has expired" } as const;
  182. }
  183. const user = await getUserById(resetToken.userId);
  184. return { valid: true, email: user?.email || "" } as const;
  185. }),
  186. /** Reset password using a valid token */
  187. resetPassword: publicProcedure
  188. .input(z.object({
  189. token: z.string(),
  190. newPassword: z.string().min(8).max(128),
  191. }))
  192. .mutation(async ({ input }) => {
  193. const resetToken = await getPasswordResetToken(input.token);
  194. if (!resetToken) {
  195. throw new TRPCError({ code: "NOT_FOUND", message: "Invalid reset link" });
  196. }
  197. if (resetToken.usedAt) {
  198. throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has already been used" });
  199. }
  200. if (new Date() > resetToken.expiresAt) {
  201. throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired" });
  202. }
  203. const passwordHash = await bcrypt.hash(input.newPassword, 12);
  204. await updateUserPassword(resetToken.userId, passwordHash);
  205. await markPasswordResetTokenUsed(resetToken.id);
  206. return { success: true, message: "Password has been reset successfully" };
  207. }),
  208. }),
  209. /* ─── Chat API (public — used by the chatbot widget) ─── */
  210. chat: router({
  211. startSession: publicProcedure
  212. .input(z.object({
  213. visitorName: z.string().optional(),
  214. visitorEmail: z.string().email().optional(),
  215. }).optional())
  216. .mutation(async ({ input, ctx }) => {
  217. const sessionId = nanoid(16);
  218. const conversation = await createConversation({
  219. sessionId,
  220. visitorName: ctx.user?.name || input?.visitorName || "Visitor",
  221. visitorEmail: ctx.user?.email || input?.visitorEmail,
  222. customerId: ctx.user?.erpContactCid ?? undefined,
  223. status: "active",
  224. });
  225. await addMessage({
  226. conversationId: conversation.id,
  227. sender: "bot",
  228. content: "Welcome to Homelegance! I'm **Ellie**, your AI furniture assistant.",
  229. metadata: {
  230. quickReplies: ["🔥 Hot Deals", "📦 Order Status", "🛋️ Product Catalog"],
  231. },
  232. });
  233. trackAnalyticsEvent({
  234. conversationId: conversation.id,
  235. sessionId,
  236. eventType: "session_start",
  237. }).catch(() => {});
  238. return { sessionId, conversationId: conversation.id };
  239. }),
  240. sendMessage: publicProcedure
  241. .input(z.object({
  242. sessionId: z.string(),
  243. content: z.string().min(1).max(2000),
  244. }))
  245. .mutation(async ({ input, ctx }) => {
  246. const conversation = await getConversationBySessionId(input.sessionId);
  247. if (!conversation) throw new Error("Conversation not found");
  248. await addMessage({
  249. conversationId: conversation.id,
  250. sender: "visitor",
  251. content: input.content,
  252. });
  253. if (conversation.status === "escalated") {
  254. // Notify owner/agents about new message in escalated conversation
  255. notifyOwner({
  256. title: `New message from ${conversation.visitorName || "Visitor"}`,
  257. content: `Customer message in escalated conversation #${conversation.id}: "${input.content.slice(0, 200)}${input.content.length > 200 ? "..." : ""}"`,
  258. }).catch(() => {}); // fire-and-forget
  259. return {
  260. reply: null,
  261. status: "escalated" as const,
  262. message: "Your conversation has been transferred to a human agent. They will respond shortly.",
  263. };
  264. }
  265. // ── Knowledge Base First ──────────────────────────────────────────────
  266. // Search Q&A knowledge base before calling LLM.
  267. let knowledgeAnswer: string | null = null;
  268. try {
  269. const kbMatch = await searchKnowledge(input.content);
  270. if (kbMatch) {
  271. knowledgeAnswer = kbMatch.answer;
  272. // Fire-and-forget: increment use count
  273. incrementKnowledgeUseCount(kbMatch.id).catch(() => {});
  274. }
  275. } catch (kbErr) {
  276. console.error("[KB] search error:", kbErr);
  277. }
  278. if (knowledgeAnswer) {
  279. await addMessage({ conversationId: conversation.id, sender: "bot", content: knowledgeAnswer });
  280. return { reply: knowledgeAnswer, status: conversation.status, source: "knowledge" as const };
  281. }
  282. // ── Workflow Flow Engine ──────────────────────────────────────────────
  283. // Check if user message triggers a live Support Flow.
  284. // Skip for authenticated dealers with erpContactCid — ERP lookup will handle it.
  285. const skipFlow = !!(ctx.user?.erpContactCid);
  286. let flowResult: { content: string; shouldEscalate?: boolean; flowId: string } | null = null;
  287. try {
  288. const matchedFlowId = skipFlow ? null : detectFlowIntent(input.content);
  289. if (matchedFlowId) {
  290. flowResult = await executeFlow(matchedFlowId, input.content);
  291. }
  292. } catch (flowErr) {
  293. console.error("[Flow] engine error:", flowErr);
  294. }
  295. if (flowResult) {
  296. trackAnalyticsEvent({
  297. conversationId: conversation.id,
  298. sessionId: input.sessionId,
  299. eventType: "flow_triggered",
  300. category: flowResult.flowId,
  301. }).catch(() => {});
  302. if (flowResult.shouldEscalate) {
  303. await updateConversationStatus(conversation.id, "escalated");
  304. await addMessage({ conversationId: conversation.id, sender: "bot", content: flowResult.content });
  305. return { reply: flowResult.content, status: "escalated" as const, source: "flow" as const };
  306. }
  307. await addMessage({ conversationId: conversation.id, sender: "bot", content: flowResult.content });
  308. return { reply: flowResult.content, status: conversation.status, source: "flow" as const };
  309. }
  310. const history = await getMessagesByConversation(conversation.id);
  311. // ── ERP intent detection & context injection ──────────────────────────
  312. // Detect what the visitor is asking about and fetch live ERP data.
  313. // The result is appended to the system prompt so Claude has real data.
  314. let erpContext = "";
  315. if (ENV.erpApiKey) {
  316. try {
  317. const msg = input.content;
  318. const msgLower = msg.toLowerCase();
  319. // Build user context for permission-scoped ERP queries.
  320. // Dealers (role="user") are automatically scoped to their own CID by the bridge.
  321. const userCtx = {
  322. role: ctx.user?.role ?? "user",
  323. erpContactCid: ctx.user?.erpContactCid ?? null,
  324. };
  325. // 1. Single order lookup
  326. // Format A: "SO: A8487", "SO# B123" — SO is a label, alphanumeric ID follows
  327. // Format B: "SO-12345", "SO 12345" — SO is part of the digits-only ID
  328. const soLabelMatch = msg.match(/\bSO[-:\s#]+([A-Z]\d{3,})\b/i);
  329. const soPrefixMatch = msg.match(/\bSO[-\s]?\d{4,}\b/i);
  330. const soId = soLabelMatch
  331. ? soLabelMatch[1].toUpperCase()
  332. : soPrefixMatch
  333. ? soPrefixMatch[0].replace(/\s/, "-").toUpperCase()
  334. : null;
  335. if (soId) {
  336. erpContext = await lookupOrder(soId, userCtx);
  337. // 2. "my orders" / "recent orders" — needs customer CID on conversation
  338. } else if (/\b(my orders?|recent orders?|order history|order status|help with my order|check.*order|view.*order|see.*order|show.*order)\b/.test(msgLower)) {
  339. const cid = ctx.user?.erpContactCid ?? (conversation as any).customerId as string | undefined;
  340. if (cid) {
  341. erpContext = await lookupOrdersByCustomer(cid, 5, userCtx);
  342. } else {
  343. erpContext = "NOTE: This user has not been linked to an ERP dealer account. Their erpContactCid is not set. Let them know they should contact support to link their dealer account before order history can be retrieved.";
  344. }
  345. // 3. Shipment tracking — "track my shipment", "tracking number", "where is my package"
  346. } else if (/\b(track|tracking|shipment|shipped|where.*order|delivery status|carrier|ship.*status)\b/.test(msgLower)) {
  347. const cid = ctx.user?.erpContactCid ?? (conversation as any).customerId as string | undefined;
  348. if (cid) {
  349. erpContext = await lookupTrackingByCustomer(cid, 5, userCtx);
  350. } else {
  351. erpContext = "NOTE: This user has not been linked to an ERP dealer account. Their erpContactCid is not set. Ask for their order number to look up tracking.";
  352. }
  353. // 4. PO number lookup — "PO-12345", "purchase order 5678"
  354. } else {
  355. const poMatch = msg.match(/\bPO[-\s]?\d{3,}\b/i);
  356. if (poMatch) {
  357. erpContext = await lookupOrdersByPO(poMatch[0], userCtx);
  358. }
  359. }
  360. // 5. Stock / inventory
  361. if (!erpContext && /\b(in stock|available|inventory|stock|availability)\b/.test(msgLower)) {
  362. // Match model numbers: letter-first (B1234-1) or digit-first (2259W-5, 1436W-6)
  363. const modelMatch = msg.match(/\b(\d{3,}(?:-\d+(?:[*]\d*)?)+|[A-Z]{1,4}\d{2,}[-\w*]*|\d{2,}[A-Z]\d*[-\w*]*)/i);
  364. if (modelMatch) {
  365. erpContext = await lookupStock({ model: modelMatch[1] }, userCtx);
  366. }
  367. }
  368. // 6. Product / catalog search
  369. if (!erpContext && /\b(product|catalog|collection|furniture|model|item|sofa|bed|table|chair|dresser|cabinet)\b/.test(msgLower)) {
  370. // Pull keywords: skip common stop words
  371. const stopWords = new Set(["the","a","an","is","are","do","you","have","i","can","tell","me","about","show","what","which"]);
  372. const keywords = msg
  373. .replace(/[^a-zA-Z0-9 ]/g, " ")
  374. .split(/\s+/)
  375. .filter(w => w.length > 2 && !stopWords.has(w.toLowerCase()))
  376. .slice(0, 4)
  377. .join(" ");
  378. if (keywords) {
  379. erpContext = await lookupCatalog({ description: keywords, limit: 8 }, userCtx);
  380. }
  381. }
  382. // 7. Product images — "show image/photo/picture of [model]" or "image for 1436W-6"
  383. if (!erpContext && /\b(image|images|photo|photos|picture|pictures|what does .* look like|show me)\b/.test(msgLower)) {
  384. const modelMatch = msg.match(/\b(\d{3,}(?:-\d+(?:[*]\d*)?)+|[A-Z]{1,4}\d{2,}[-\w*]*|\d{2,}[A-Z]\d*[-\w*]*)/i);
  385. if (modelMatch) {
  386. erpContext = await lookupProductImages(modelMatch[1], userCtx);
  387. }
  388. }
  389. // 8. Customer / dealer lookup (admin/agent only — dealers see their own record via CID)
  390. if (!erpContext && ctx.user?.role !== "user" && /\b(customer|dealer|account|contact|company)\b/.test(msgLower)) {
  391. const nameMatch = msg.match(/(?:customer|dealer|account|contact|company)[:\s]+([A-Za-z &'.-]{3,40})/i);
  392. if (nameMatch) {
  393. erpContext = await lookupContact({ company: nameMatch[1].trim() }, userCtx);
  394. }
  395. }
  396. } catch (erpErr) {
  397. console.error("[ERP] intent lookup error:", erpErr);
  398. erpContext = "NOTE: ERP data lookup failed (service temporarily unavailable). Do not mention specific order details. Let the user know order lookup is temporarily unavailable and suggest they try again shortly or contact their sales rep.";
  399. }
  400. }
  401. const systemContent = erpContext
  402. ? `${SYSTEM_PROMPT}\n\n---\n[ERP CONTEXT — live data retrieved for this query]\nIMPORTANT: The following real-time ERP data has been fetched for this user. Present it directly in your response. Do NOT ask for an order number or any information already shown below.\n${erpContext}\n---`
  403. : SYSTEM_PROMPT;
  404. const llmMessages = [
  405. { role: "system" as const, content: systemContent },
  406. ...history.map(m => ({
  407. role: (m.sender === "visitor" ? "user" : "assistant") as "user" | "assistant",
  408. content: m.content,
  409. })),
  410. ];
  411. const escalationKeywords = ["speak to human", "representative", "real person", "agent", "talk to someone", "human agent"];
  412. const shouldEscalate = escalationKeywords.some(kw =>
  413. input.content.toLowerCase().includes(kw)
  414. );
  415. if (shouldEscalate) {
  416. await updateConversationStatus(conversation.id, "escalated");
  417. const escalationMsg = "I understand you'd like to speak with a team member. I'm connecting you now — a Homelegance representative will be with you shortly. In the meantime, is there anything else I can help with?";
  418. await addMessage({
  419. conversationId: conversation.id,
  420. sender: "bot",
  421. content: escalationMsg,
  422. });
  423. // Notify owner about escalation
  424. notifyOwner({
  425. title: `Chat escalated: ${conversation.visitorName || "Visitor"}`,
  426. content: `A customer has requested to speak with a human agent. Conversation #${conversation.id}. Last message: "${input.content.slice(0, 200)}"`,
  427. }).catch(() => {}); // fire-and-forget
  428. return { reply: escalationMsg, status: "escalated" as const };
  429. }
  430. try {
  431. const llmResult = await invokeLLM({ messages: llmMessages });
  432. const botReply = llmResult.choices[0]?.message?.content as string || "I apologize, I'm having trouble processing your request. Would you like to speak with a team member?";
  433. await addMessage({
  434. conversationId: conversation.id,
  435. sender: "bot",
  436. content: botReply,
  437. });
  438. // Auto-log as suggestion for continuous improvement
  439. logKnowledgeSuggestion(input.content).catch(() => {});
  440. // Log intent for hot-topic analytics (fire-and-forget)
  441. trackAnalyticsEvent({
  442. conversationId: conversation.id,
  443. sessionId: input.sessionId,
  444. eventType: "intent_detected",
  445. category: "unclassified",
  446. metadata: { snippet: input.content.slice(0, 120) },
  447. }).catch(() => {});
  448. return { reply: botReply, status: conversation.status, source: "llm" as const };
  449. } catch (error) {
  450. console.error("[Chat] LLM error:", error);
  451. const fallback = "I apologize for the inconvenience. I'm experiencing a temporary issue. Would you like me to connect you with a human agent?";
  452. await addMessage({
  453. conversationId: conversation.id,
  454. sender: "bot",
  455. content: fallback,
  456. });
  457. return { reply: fallback, status: conversation.status };
  458. }
  459. }),
  460. rateSatisfaction: publicProcedure
  461. .input(z.object({
  462. sessionId: z.string(),
  463. rating: z.number().int().min(1).max(5),
  464. comment: z.string().max(500).optional(),
  465. }))
  466. .mutation(async ({ input }) => {
  467. return rateConversation(input.sessionId, input.rating, input.comment);
  468. }),
  469. getMessages: publicProcedure
  470. .input(z.object({ sessionId: z.string() }))
  471. .query(async ({ input }) => {
  472. const conversation = await getConversationBySessionId(input.sessionId);
  473. if (!conversation) return { messages: [], status: "closed" as const };
  474. const msgs = await getMessagesByConversation(conversation.id);
  475. return { messages: msgs, status: conversation.status, csatRating: conversation.csatRating };
  476. }),
  477. }),
  478. /* ─── Agent Dashboard API (requires agent or admin role) ─── */
  479. agent: router({
  480. /** Legacy simple list (kept for backward compat) */
  481. conversations: agentProcedure
  482. .input(z.object({ status: z.string().optional() }).optional())
  483. .query(async ({ input }) => {
  484. return getConversations(input?.status);
  485. }),
  486. /** Advanced conversation query with pagination, search, filters, sorting */
  487. conversationsAdvanced: agentProcedure
  488. .input(z.object({
  489. page: z.number().min(1).default(1),
  490. pageSize: z.number().min(5).max(100).default(20),
  491. status: z.string().optional(),
  492. search: z.string().optional(),
  493. agentId: z.number().optional(),
  494. dateFrom: z.string().optional(),
  495. dateTo: z.string().optional(),
  496. sortBy: z.enum(["updated", "created", "visitor", "status", "customerId", "salesRep", "agent"]).default("updated"),
  497. sortOrder: z.enum(["asc", "desc"]).default("desc"),
  498. }).optional())
  499. .query(async ({ input }) => {
  500. const result = await getConversationsAdvanced(input || {});
  501. // Enrich with message counts
  502. const ids = result.conversations.map((c) => c.id);
  503. const messageCounts = await getConversationMessageCounts(ids);
  504. const enriched = result.conversations.map((c) => ({
  505. ...c,
  506. messageCount: messageCounts[c.id] || 0,
  507. }));
  508. return { ...result, conversations: enriched };
  509. }),
  510. /** Get list of agents for filter dropdown */
  511. agents: agentProcedure.query(async () => {
  512. return getAgentUsers();
  513. }),
  514. stats: agentProcedure.query(async () => {
  515. return getConversationStats();
  516. }),
  517. messages: agentProcedure
  518. .input(z.object({ conversationId: z.number() }))
  519. .query(async ({ input }) => {
  520. return getMessagesByConversation(input.conversationId);
  521. }),
  522. reply: agentProcedure
  523. .input(z.object({
  524. conversationId: z.number(),
  525. content: z.string().min(1).max(5000),
  526. }))
  527. .mutation(async ({ input, ctx }) => {
  528. const conversation = await getConversationById(input.conversationId);
  529. if (!conversation) throw new Error("Conversation not found");
  530. const msg = await addMessage({
  531. conversationId: input.conversationId,
  532. sender: "agent",
  533. content: input.content,
  534. metadata: { agentName: ctx.user.name || "Agent", agentId: ctx.user.id },
  535. });
  536. if (conversation.status === "escalated") {
  537. await updateConversationStatus(input.conversationId, "escalated", ctx.user.id);
  538. }
  539. return msg;
  540. }),
  541. updateStatus: agentProcedure
  542. .input(z.object({
  543. conversationId: z.number(),
  544. status: z.enum(["active", "escalated", "resolved", "closed"]),
  545. }))
  546. .mutation(async ({ input, ctx }) => {
  547. const conv = await updateConversationStatus(input.conversationId, input.status, ctx.user.id);
  548. if (input.status === "resolved" || input.status === "escalated") {
  549. const eventType = input.status === "resolved" ? "resolved_by_agent" : "escalated";
  550. trackAnalyticsEvent({
  551. conversationId: input.conversationId,
  552. sessionId: conv?.sessionId ?? undefined,
  553. eventType: eventType as any,
  554. }).catch(() => {});
  555. }
  556. return conv;
  557. }),
  558. /** Bulk update conversation status */
  559. bulkUpdateStatus: agentProcedure
  560. .input(z.object({
  561. conversationIds: z.array(z.number()).min(1),
  562. status: z.enum(["active", "escalated", "resolved", "closed"]),
  563. }))
  564. .mutation(async ({ input, ctx }) => {
  565. return bulkUpdateConversationStatus(input.conversationIds, input.status, ctx.user.id);
  566. }),
  567. /** Delete conversations (admin only) */
  568. deleteConversations: adminProcedure
  569. .input(z.object({
  570. conversationIds: z.array(z.number()).min(1),
  571. }))
  572. .mutation(async ({ input, ctx }) => {
  573. const result = await deleteConversations(input.conversationIds);
  574. await createAuditLog({
  575. action: "delete_conversations",
  576. actorId: ctx.user.id,
  577. actorName: ctx.user.name || "Admin",
  578. details: { count: input.conversationIds.length, ids: input.conversationIds },
  579. });
  580. return result;
  581. }),
  582. }),
  583. /* ─── User Management API (admin only) ─── */
  584. users: router({
  585. /** List all users */
  586. list: adminProcedure.query(async () => {
  587. return getAllUsers();
  588. }),
  589. /** Update a user's role */
  590. updateRole: adminProcedure
  591. .input(z.object({
  592. userId: z.number(),
  593. role: z.enum(["user", "agent", "admin"]),
  594. }))
  595. .mutation(async ({ input, ctx }) => {
  596. if (input.userId === ctx.user.id) {
  597. throw new TRPCError({
  598. code: "BAD_REQUEST",
  599. message: "You cannot change your own role",
  600. });
  601. }
  602. const targetUser = await getUserById(input.userId);
  603. if (!targetUser) {
  604. throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
  605. }
  606. const previousRole = targetUser.role;
  607. const updated = await updateUserRole(input.userId, input.role);
  608. if (!updated) {
  609. throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
  610. }
  611. // Audit log
  612. await createAuditLog({
  613. action: "role_change",
  614. actorId: ctx.user.id,
  615. actorName: ctx.user.name || "Admin",
  616. targetId: input.userId,
  617. targetName: targetUser.name || targetUser.email || "User",
  618. details: { previousRole, newRole: input.role },
  619. });
  620. return updated;
  621. }),
  622. /** Get a single user by ID */
  623. getById: adminProcedure
  624. .input(z.object({ userId: z.number() }))
  625. .query(async ({ input }) => {
  626. return getUserById(input.userId);
  627. }),
  628. /** Set or clear the ERP ContactID linked to a user (for dealer permission scoping) */
  629. updateErpContactCid: adminProcedure
  630. .input(z.object({
  631. userId: z.number(),
  632. erpContactCid: z.string().max(64).nullable(),
  633. }))
  634. .mutation(async ({ input, ctx }) => {
  635. const targetUser = await getUserById(input.userId);
  636. if (!targetUser) {
  637. throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
  638. }
  639. const updated = await updateUserErpContactCid(input.userId, input.erpContactCid);
  640. await createAuditLog({
  641. action: "erp_contact_cid_change",
  642. actorId: ctx.user.id,
  643. actorName: ctx.user.name || "Admin",
  644. targetId: input.userId,
  645. targetName: targetUser.name || targetUser.email || "User",
  646. details: { erpContactCid: input.erpContactCid },
  647. });
  648. return updated;
  649. }),
  650. /** Delete a user */
  651. delete: adminProcedure
  652. .input(z.object({ userId: z.number() }))
  653. .mutation(async ({ input, ctx }) => {
  654. if (input.userId === ctx.user.id) {
  655. throw new TRPCError({
  656. code: "BAD_REQUEST",
  657. message: "You cannot delete your own account",
  658. });
  659. }
  660. const targetUser = await getUserById(input.userId);
  661. if (!targetUser) {
  662. throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
  663. }
  664. const deleted = await deleteUser(input.userId);
  665. // Audit log
  666. await createAuditLog({
  667. action: "user_deleted",
  668. actorId: ctx.user.id,
  669. actorName: ctx.user.name || "Admin",
  670. targetId: input.userId,
  671. targetName: targetUser.name || targetUser.email || "User",
  672. details: { deletedRole: targetUser.role, deletedEmail: targetUser.email },
  673. });
  674. return { success: true, deletedUser: deleted };
  675. }),
  676. /** Bulk update roles */
  677. bulkUpdateRole: adminProcedure
  678. .input(z.object({
  679. userIds: z.array(z.number()).min(1).max(50),
  680. role: z.enum(["user", "agent", "admin"]),
  681. }))
  682. .mutation(async ({ input, ctx }) => {
  683. const results: { userId: number; success: boolean; error?: string }[] = [];
  684. for (const userId of input.userIds) {
  685. if (userId === ctx.user.id) {
  686. results.push({ userId, success: false, error: "Cannot change own role" });
  687. continue;
  688. }
  689. try {
  690. await updateUserRole(userId, input.role);
  691. results.push({ userId, success: true });
  692. } catch (e) {
  693. results.push({ userId, success: false, error: "Failed to update" });
  694. }
  695. }
  696. await createAuditLog({
  697. action: "bulk_role_change",
  698. actorId: ctx.user.id,
  699. actorName: ctx.user.name || "Admin",
  700. details: { userIds: input.userIds, newRole: input.role, results },
  701. });
  702. return results;
  703. }),
  704. /** Bulk delete users */
  705. bulkDelete: adminProcedure
  706. .input(z.object({
  707. userIds: z.array(z.number()).min(1).max(50),
  708. }))
  709. .mutation(async ({ input, ctx }) => {
  710. const results: { userId: number; success: boolean; error?: string }[] = [];
  711. for (const userId of input.userIds) {
  712. if (userId === ctx.user.id) {
  713. results.push({ userId, success: false, error: "Cannot delete own account" });
  714. continue;
  715. }
  716. try {
  717. await deleteUser(userId);
  718. results.push({ userId, success: true });
  719. } catch (e) {
  720. results.push({ userId, success: false, error: "Failed to delete" });
  721. }
  722. }
  723. await createAuditLog({
  724. action: "bulk_delete",
  725. actorId: ctx.user.id,
  726. actorName: ctx.user.name || "Admin",
  727. details: { userIds: input.userIds, results },
  728. });
  729. return results;
  730. }),
  731. /** Export users as CSV data */
  732. exportCsv: adminProcedure.query(async () => {
  733. const allUsers = await getAllUsers();
  734. const header = "ID,Name,Email,Role,Created At,Last Signed In";
  735. const rows = allUsers.map(u =>
  736. `${u.id},"${(u.name || "").replace(/"/g, '""')}","${(u.email || "").replace(/"/g, '""')}",${u.role},${u.createdAt?.toISOString() || ""},${u.lastSignedIn?.toISOString() || ""}`
  737. );
  738. return { csv: [header, ...rows].join("\n"), count: allUsers.length };
  739. }),
  740. }),
  741. /* ─── Invitation API (admin only) ─── */
  742. invitations: router({
  743. /** List all invitations */
  744. list: adminProcedure.query(async () => {
  745. // Auto-expire old invitations
  746. await expireOldInvitations();
  747. return getAllInvitations();
  748. }),
  749. /** Send a new invitation */
  750. send: adminProcedure
  751. .input(z.object({
  752. email: z.string().email(),
  753. role: z.enum(["user", "agent", "admin"]),
  754. message: z.string().max(500).optional(),
  755. }))
  756. .mutation(async ({ input, ctx }) => {
  757. // Check if user already exists with this email
  758. const existingUser = await getUserByEmail(input.email);
  759. if (existingUser) {
  760. throw new TRPCError({
  761. code: "CONFLICT",
  762. message: `A user with email ${input.email} already exists (role: ${existingUser.role})`,
  763. });
  764. }
  765. // Check for pending invitation to same email
  766. const existingInvites = await getInvitationByEmail(input.email);
  767. const pendingInvite = existingInvites.find(i => i.status === "pending");
  768. if (pendingInvite) {
  769. throw new TRPCError({
  770. code: "CONFLICT",
  771. message: `A pending invitation already exists for ${input.email}. Revoke it first to send a new one.`,
  772. });
  773. }
  774. const token = nanoid(32);
  775. const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
  776. const invitation = await createInvitation({
  777. email: input.email,
  778. role: input.role,
  779. token,
  780. status: "pending",
  781. invitedById: ctx.user.id,
  782. invitedByName: ctx.user.name || "Admin",
  783. message: input.message || null,
  784. expiresAt,
  785. });
  786. // Audit log
  787. await createAuditLog({
  788. action: "invitation_sent",
  789. actorId: ctx.user.id,
  790. actorName: ctx.user.name || "Admin",
  791. targetName: input.email,
  792. details: { role: input.role, token, expiresAt: expiresAt.toISOString() },
  793. });
  794. // Notify owner
  795. try {
  796. await notifyOwner({
  797. title: `New Invitation Sent`,
  798. content: `${ctx.user.name || "Admin"} invited ${input.email} as ${input.role}. The invitation expires on ${expiresAt.toLocaleDateString()}.`,
  799. });
  800. } catch (e) {
  801. // Non-critical, don't fail the invitation
  802. }
  803. return invitation;
  804. }),
  805. /** Resend an invitation (creates a new token, extends expiry) */
  806. resend: adminProcedure
  807. .input(z.object({ invitationId: z.number() }))
  808. .mutation(async ({ input, ctx }) => {
  809. const existing = await getAllInvitations();
  810. const invitation = existing.find(i => i.id === input.invitationId);
  811. if (!invitation) {
  812. throw new TRPCError({ code: "NOT_FOUND", message: "Invitation not found" });
  813. }
  814. if (invitation.status !== "pending" && invitation.status !== "expired") {
  815. throw new TRPCError({
  816. code: "BAD_REQUEST",
  817. message: `Cannot resend a ${invitation.status} invitation`,
  818. });
  819. }
  820. // Revoke old one
  821. await updateInvitationStatus(invitation.id, "revoked");
  822. // Create new invitation
  823. const token = nanoid(32);
  824. const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  825. const newInvitation = await createInvitation({
  826. email: invitation.email,
  827. role: invitation.role,
  828. token,
  829. status: "pending",
  830. invitedById: ctx.user.id,
  831. invitedByName: ctx.user.name || "Admin",
  832. message: invitation.message,
  833. expiresAt,
  834. });
  835. await createAuditLog({
  836. action: "invitation_resent",
  837. actorId: ctx.user.id,
  838. actorName: ctx.user.name || "Admin",
  839. targetName: invitation.email,
  840. details: { role: invitation.role, newToken: token },
  841. });
  842. return newInvitation;
  843. }),
  844. /** Revoke a pending invitation */
  845. revoke: adminProcedure
  846. .input(z.object({ invitationId: z.number() }))
  847. .mutation(async ({ input, ctx }) => {
  848. const existing = await getAllInvitations();
  849. const invitation = existing.find(i => i.id === input.invitationId);
  850. if (!invitation) {
  851. throw new TRPCError({ code: "NOT_FOUND", message: "Invitation not found" });
  852. }
  853. if (invitation.status !== "pending") {
  854. throw new TRPCError({
  855. code: "BAD_REQUEST",
  856. message: `Cannot revoke a ${invitation.status} invitation`,
  857. });
  858. }
  859. const updated = await updateInvitationStatus(invitation.id, "revoked");
  860. await createAuditLog({
  861. action: "invitation_revoked",
  862. actorId: ctx.user.id,
  863. actorName: ctx.user.name || "Admin",
  864. targetName: invitation.email,
  865. details: { role: invitation.role },
  866. });
  867. return updated;
  868. }),
  869. /** Validate an invitation token (public — used by invite acceptance page) */
  870. validate: publicProcedure
  871. .input(z.object({ token: z.string() }))
  872. .query(async ({ input }) => {
  873. const invitation = await getInvitationByToken(input.token);
  874. if (!invitation) {
  875. return { valid: false, reason: "Invitation not found" } as const;
  876. }
  877. if (invitation.status === "revoked") {
  878. return { valid: false, reason: "This invitation has been revoked" } as const;
  879. }
  880. if (invitation.status === "accepted") {
  881. return { valid: false, reason: "This invitation has already been accepted" } as const;
  882. }
  883. if (invitation.status === "expired" || new Date() > invitation.expiresAt) {
  884. if (invitation.status !== "expired") {
  885. await updateInvitationStatus(invitation.id, "expired");
  886. }
  887. return { valid: false, reason: "This invitation has expired" } as const;
  888. }
  889. return {
  890. valid: true,
  891. email: invitation.email,
  892. role: invitation.role,
  893. invitedBy: invitation.invitedByName,
  894. message: invitation.message,
  895. expiresAt: invitation.expiresAt,
  896. } as const;
  897. }),
  898. /** Accept an invitation (requires authenticated user) */
  899. accept: protectedProcedure
  900. .input(z.object({ token: z.string() }))
  901. .mutation(async ({ input, ctx }) => {
  902. const invitation = await getInvitationByToken(input.token);
  903. if (!invitation) {
  904. throw new TRPCError({ code: "NOT_FOUND", message: "Invitation not found" });
  905. }
  906. if (invitation.status !== "pending") {
  907. throw new TRPCError({
  908. code: "BAD_REQUEST",
  909. message: `This invitation is ${invitation.status}`,
  910. });
  911. }
  912. if (new Date() > invitation.expiresAt) {
  913. await updateInvitationStatus(invitation.id, "expired");
  914. throw new TRPCError({
  915. code: "BAD_REQUEST",
  916. message: "This invitation has expired",
  917. });
  918. }
  919. // Update user role to the invited role
  920. await updateUserRole(ctx.user.id, invitation.role);
  921. // Mark invitation as accepted
  922. await updateInvitationStatus(invitation.id, "accepted", ctx.user.id);
  923. // Audit log
  924. await createAuditLog({
  925. action: "invitation_accepted",
  926. actorId: ctx.user.id,
  927. actorName: ctx.user.name || ctx.user.email || "User",
  928. targetName: invitation.email,
  929. details: { role: invitation.role, invitedBy: invitation.invitedByName },
  930. });
  931. return { success: true, role: invitation.role };
  932. }),
  933. }),
  934. /* ─── Audit Logs API (admin only) ─── */
  935. auditLogs: router({
  936. list: adminProcedure
  937. .input(z.object({ limit: z.number().min(1).max(200).optional() }).optional())
  938. .query(async ({ input }) => {
  939. return getAuditLogs(input?.limit || 50);
  940. }),
  941. }),
  942. /* ─── Workflow Designer API (admin only) ─── */
  943. workflow: router({
  944. save: adminProcedure
  945. .input(z.object({
  946. workflowId: z.string(),
  947. nodes: z.array(z.object({
  948. workflowId: z.string(),
  949. nodeId: z.string(),
  950. type: z.enum(["greeting", "intent", "response", "condition", "escalation", "action", "end", "customer_data", "sales_order", "guardrail"]),
  951. label: z.string(),
  952. config: z.any().optional(),
  953. positionX: z.number(),
  954. positionY: z.number(),
  955. })),
  956. edges: z.array(z.object({
  957. workflowId: z.string(),
  958. sourceNodeId: z.string(),
  959. targetNodeId: z.string(),
  960. label: z.string().optional(),
  961. condition: z.any().optional(),
  962. })),
  963. }))
  964. .mutation(async ({ input }) => {
  965. return saveWorkflow(input.workflowId, input.nodes, input.edges);
  966. }),
  967. load: adminProcedure
  968. .input(z.object({ workflowId: z.string() }))
  969. .query(async ({ input }) => {
  970. return getWorkflow(input.workflowId);
  971. }),
  972. /** Get AI-suggested nodes for a workflow */
  973. getSuggestions: adminProcedure
  974. .input(z.object({
  975. workflowId: z.string(),
  976. status: z.enum(["pending", "approved", "declined", "waiting"]).optional(),
  977. }))
  978. .query(async ({ input }) => {
  979. return getWorkflowSuggestions(input.workflowId, input.status);
  980. }),
  981. /** Generate AI suggestions from FAQ analysis */
  982. generateSuggestions: adminProcedure
  983. .input(z.object({ workflowId: z.string() }))
  984. .mutation(async ({ input }) => {
  985. // Analyze conversation messages to find frequently asked questions
  986. const db = await (await import("./db")).getDb();
  987. if (!db) throw new Error("Database not available");
  988. // Get recent visitor messages
  989. const recentMessages = await db.select({
  990. content: messages.content,
  991. sender: messages.sender,
  992. }).from(messages)
  993. .where(eq(messages.sender, "visitor"))
  994. .orderBy(desc(messages.createdAt))
  995. .limit(200);
  996. if (recentMessages.length < 3) {
  997. return { suggestions: [], message: "Not enough conversation data to generate suggestions. Need at least 3 visitor messages." };
  998. }
  999. // Use LLM to analyze FAQ patterns and suggest workflow nodes
  1000. const msgSample = recentMessages.map(m => m.content).join("\n---\n");
  1001. const llmResult = await invokeLLM({
  1002. messages: [
  1003. {
  1004. role: "system",
  1005. content: `You are a workflow optimization assistant for Homelegance, a furniture company. Analyze customer messages and identify the top 3-5 most frequently asked question patterns that could benefit from dedicated workflow nodes. For each pattern, suggest a workflow node.
  1006. Return a JSON array of suggestions. Each suggestion should have:
  1007. - "label": A short descriptive name for the node (e.g., "Shipping ETA Lookup")
  1008. - "description": What this node would do
  1009. - "nodeType": One of: "response", "action", "condition", "customer_data", "sales_order", "guardrail"
  1010. - "faqQuestion": The typical customer question this addresses
  1011. - "frequency": Estimated frequency (1-100)
  1012. - "config": Configuration object with relevant fields (e.g., {"message": "..."} for response, {"apiEndpoint": "..."} for action, {"blockedTopics": [...]} for guardrail)
  1013. Return ONLY the JSON array, no markdown or explanation.`,
  1014. },
  1015. {
  1016. role: "user",
  1017. content: `Analyze these ${recentMessages.length} recent customer messages and suggest workflow nodes:\n\n${msgSample}`,
  1018. },
  1019. ],
  1020. response_format: {
  1021. type: "json_schema",
  1022. json_schema: {
  1023. name: "workflow_suggestions",
  1024. strict: true,
  1025. schema: {
  1026. type: "object",
  1027. properties: {
  1028. suggestions: {
  1029. type: "array",
  1030. items: {
  1031. type: "object",
  1032. properties: {
  1033. label: { type: "string" },
  1034. description: { type: "string" },
  1035. nodeType: { type: "string" },
  1036. faqQuestion: { type: "string" },
  1037. frequency: { type: "integer" },
  1038. config: { type: "object", additionalProperties: true },
  1039. },
  1040. required: ["label", "description", "nodeType", "faqQuestion", "frequency", "config"],
  1041. additionalProperties: false,
  1042. },
  1043. },
  1044. },
  1045. required: ["suggestions"],
  1046. additionalProperties: false,
  1047. },
  1048. },
  1049. },
  1050. });
  1051. let parsed: any[] = [];
  1052. try {
  1053. const content = llmResult.choices[0]?.message?.content as string;
  1054. const result = JSON.parse(content);
  1055. parsed = result.suggestions || result;
  1056. } catch (e) {
  1057. console.error("[Workflow] Failed to parse LLM suggestions:", e);
  1058. return { suggestions: [], message: "Failed to analyze conversation patterns" };
  1059. }
  1060. // Save suggestions to database
  1061. const toInsert = parsed.map((s: any) => ({
  1062. workflowId: input.workflowId,
  1063. suggestedNodeType: s.nodeType || "response",
  1064. label: s.label,
  1065. description: s.description,
  1066. config: s.config || {},
  1067. faqQuestion: s.faqQuestion,
  1068. frequency: s.frequency || 0,
  1069. status: "pending" as const,
  1070. }));
  1071. await bulkCreateWorkflowSuggestions(toInsert);
  1072. return { suggestions: toInsert, message: `Generated ${toInsert.length} suggestions from ${recentMessages.length} messages` };
  1073. }),
  1074. /** Update suggestion status (approve/decline/wait) */
  1075. reviewSuggestion: adminProcedure
  1076. .input(z.object({
  1077. suggestionId: z.number(),
  1078. status: z.enum(["approved", "declined", "waiting"]),
  1079. }))
  1080. .mutation(async ({ input, ctx }) => {
  1081. return updateWorkflowSuggestionStatus(input.suggestionId, input.status, ctx.user.id);
  1082. }),
  1083. }),
  1084. /* ─── Analytics Router ─── */
  1085. analytics: router({
  1086. track: publicProcedure
  1087. .input(z.object({
  1088. conversationId: z.number().optional(),
  1089. sessionId: z.string().optional(),
  1090. eventType: z.enum([
  1091. "session_start", "message_sent", "message_received",
  1092. "intent_detected", "flow_triggered", "escalated",
  1093. "resolved_by_bot", "resolved_by_agent", "abandoned",
  1094. "button_clicked", "feedback_positive", "feedback_negative",
  1095. ]),
  1096. category: z.string().optional(),
  1097. metadata: z.any().optional(),
  1098. }))
  1099. .mutation(async ({ input }) => {
  1100. const id = await trackAnalyticsEvent(input);
  1101. return { id };
  1102. }),
  1103. summary: agentProcedure
  1104. .input(z.object({
  1105. startDate: z.string().optional(),
  1106. endDate: z.string().optional(),
  1107. }).optional())
  1108. .query(async ({ input }) => {
  1109. const startDate = input?.startDate ? new Date(input.startDate) : undefined;
  1110. const endDate = input?.endDate ? new Date(input.endDate) : undefined;
  1111. return getAnalyticsSummary(startDate, endDate);
  1112. }),
  1113. events: agentProcedure
  1114. .input(z.object({
  1115. eventType: z.string().optional(),
  1116. category: z.string().optional(),
  1117. startDate: z.string().optional(),
  1118. endDate: z.string().optional(),
  1119. }).optional())
  1120. .query(async ({ input }) => {
  1121. return getAnalyticsEvents({
  1122. eventType: input?.eventType,
  1123. category: input?.category,
  1124. startDate: input?.startDate ? new Date(input.startDate) : undefined,
  1125. endDate: input?.endDate ? new Date(input.endDate) : undefined,
  1126. });
  1127. }),
  1128. intentStats: agentProcedure
  1129. .input(z.object({ startDate: z.string().optional(), endDate: z.string().optional() }).optional())
  1130. .query(async ({ input }) => {
  1131. return getIntentStats(
  1132. input?.startDate ? new Date(input.startDate) : undefined,
  1133. input?.endDate ? new Date(input.endDate) : undefined,
  1134. );
  1135. }),
  1136. responseTime: agentProcedure
  1137. .input(z.object({ startDate: z.string().optional(), endDate: z.string().optional() }).optional())
  1138. .query(async ({ input }) => {
  1139. return getResponseTimeStats(
  1140. input?.startDate ? new Date(input.startDate) : undefined,
  1141. input?.endDate ? new Date(input.endDate) : undefined,
  1142. );
  1143. }),
  1144. }),
  1145. /* ─── Data Sources Router (Lyro-inspired) ─── */
  1146. dataSources: router({
  1147. list: adminProcedure.query(async () => {
  1148. return getDataSources();
  1149. }),
  1150. get: adminProcedure
  1151. .input(z.object({ id: z.number() }))
  1152. .query(async ({ input }) => {
  1153. return getDataSourceById(input.id);
  1154. }),
  1155. create: adminProcedure
  1156. .input(z.object({
  1157. name: z.string().min(1),
  1158. type: z.enum(["url", "file", "qa_pair", "api"]),
  1159. config: z.any().optional(),
  1160. }))
  1161. .mutation(async ({ input, ctx }) => {
  1162. const id = await createDataSource({
  1163. name: input.name,
  1164. type: input.type,
  1165. config: input.config || {},
  1166. createdById: ctx.user.id,
  1167. });
  1168. return { id };
  1169. }),
  1170. update: adminProcedure
  1171. .input(z.object({
  1172. id: z.number(),
  1173. name: z.string().optional(),
  1174. status: z.enum(["active", "inactive", "syncing", "error"]).optional(),
  1175. config: z.any().optional(),
  1176. itemCount: z.number().optional(),
  1177. }))
  1178. .mutation(async ({ input }) => {
  1179. const { id, ...updates } = input;
  1180. return updateDataSource(id, updates);
  1181. }),
  1182. delete: adminProcedure
  1183. .input(z.object({ id: z.number() }))
  1184. .mutation(async ({ input }) => {
  1185. await deleteDataSource(input.id);
  1186. return { success: true };
  1187. }),
  1188. }),
  1189. /* ─── API Connections Router (Lyro Actions) ─── */
  1190. apiConnections: router({
  1191. list: adminProcedure.query(async () => {
  1192. return getApiConnections();
  1193. }),
  1194. get: adminProcedure
  1195. .input(z.object({ id: z.number() }))
  1196. .query(async ({ input }) => {
  1197. return getApiConnectionById(input.id);
  1198. }),
  1199. create: adminProcedure
  1200. .input(z.object({
  1201. name: z.string().min(1),
  1202. description: z.string().optional(),
  1203. category: z.string().optional(),
  1204. method: z.enum(["GET", "POST", "PUT", "DELETE"]),
  1205. endpoint: z.string().min(1),
  1206. headers: z.any().optional(),
  1207. inputVariables: z.any().optional(),
  1208. outputVariables: z.any().optional(),
  1209. testPayload: z.any().optional(),
  1210. }))
  1211. .mutation(async ({ input, ctx }) => {
  1212. const id = await createApiConnection({
  1213. ...input,
  1214. createdById: ctx.user.id,
  1215. });
  1216. return { id };
  1217. }),
  1218. update: adminProcedure
  1219. .input(z.object({
  1220. id: z.number(),
  1221. name: z.string().optional(),
  1222. description: z.string().optional(),
  1223. category: z.string().optional(),
  1224. method: z.enum(["GET", "POST", "PUT", "DELETE"]).optional(),
  1225. endpoint: z.string().optional(),
  1226. headers: z.any().optional(),
  1227. inputVariables: z.any().optional(),
  1228. outputVariables: z.any().optional(),
  1229. testPayload: z.any().optional(),
  1230. isActive: z.boolean().optional(),
  1231. }))
  1232. .mutation(async ({ input }) => {
  1233. const { id, ...updates } = input;
  1234. return updateApiConnection(id, updates);
  1235. }),
  1236. delete: adminProcedure
  1237. .input(z.object({ id: z.number() }))
  1238. .mutation(async ({ input }) => {
  1239. await deleteApiConnection(input.id);
  1240. return { success: true };
  1241. }),
  1242. test: adminProcedure
  1243. .input(z.object({ id: z.number() }))
  1244. .mutation(async ({ input }) => {
  1245. const conn = await getApiConnectionById(input.id);
  1246. if (!conn) throw new TRPCError({ code: "NOT_FOUND", message: "API connection not found" });
  1247. const start = Date.now();
  1248. const result = await runApiConnection(input.id);
  1249. return {
  1250. success: result.success,
  1251. message: result.success ? `Test successful for ${conn.name}` : (result.error ?? "Unknown error"),
  1252. responseTime: Date.now() - start,
  1253. };
  1254. }),
  1255. run: adminProcedure
  1256. .input(z.object({ id: z.number() }))
  1257. .mutation(async ({ input }) => {
  1258. const result = await runApiConnection(input.id);
  1259. if (!result.success) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error });
  1260. return result;
  1261. }),
  1262. }),
  1263. /* ─── Knowledge Management Router ─── */
  1264. knowledge: router({
  1265. // Q&A Entries
  1266. listEntries: adminProcedure
  1267. .input(z.object({ status: z.string().optional() }).optional())
  1268. .query(async ({ input }) => getKnowledgeEntries(input?.status)),
  1269. getEntry: adminProcedure
  1270. .input(z.object({ id: z.number() }))
  1271. .query(async ({ input }) => getKnowledgeEntryById(input.id)),
  1272. createEntry: adminProcedure
  1273. .input(z.object({
  1274. question: z.string().min(1),
  1275. answer: z.string().min(1),
  1276. category: z.string().optional(),
  1277. }))
  1278. .mutation(async ({ input }) => {
  1279. const id = await createKnowledgeEntry({ ...input, source: "manual" });
  1280. return { id };
  1281. }),
  1282. updateEntry: adminProcedure
  1283. .input(z.object({
  1284. id: z.number(),
  1285. question: z.string().optional(),
  1286. answer: z.string().optional(),
  1287. category: z.string().optional(),
  1288. status: z.enum(["active", "inactive"]).optional(),
  1289. }))
  1290. .mutation(async ({ input }) => {
  1291. const { id, ...data } = input;
  1292. await updateKnowledgeEntry(id, data);
  1293. return { success: true };
  1294. }),
  1295. deleteEntry: adminProcedure
  1296. .input(z.object({ id: z.number() }))
  1297. .mutation(async ({ input }) => {
  1298. await deleteKnowledgeEntry(input.id);
  1299. return { success: true };
  1300. }),
  1301. importEntries: adminProcedure
  1302. .input(z.object({
  1303. entries: z.array(z.object({
  1304. question: z.string().min(1),
  1305. answer: z.string().min(1),
  1306. category: z.string().optional(),
  1307. })),
  1308. source: z.string().default("csv"),
  1309. }))
  1310. .mutation(async ({ input }) => {
  1311. return bulkCreateKnowledgeEntries(input.entries.map(e => ({ ...e, source: input.source })));
  1312. }),
  1313. // Suggestions
  1314. listSuggestions: adminProcedure
  1315. .input(z.object({ status: z.string().optional() }).optional())
  1316. .query(async ({ input }) => getKnowledgeSuggestions(input?.status)),
  1317. promoteSuggestion: adminProcedure
  1318. .input(z.object({
  1319. id: z.number(),
  1320. answer: z.string().min(1),
  1321. category: z.string().optional(),
  1322. }))
  1323. .mutation(async ({ input }) => {
  1324. const entryId = await promoteKnowledgeSuggestion(input.id, input.answer, input.category);
  1325. return { entryId };
  1326. }),
  1327. dismissSuggestion: adminProcedure
  1328. .input(z.object({ id: z.number() }))
  1329. .mutation(async ({ input }) => {
  1330. await dismissKnowledgeSuggestion(input.id);
  1331. return { success: true };
  1332. }),
  1333. // Products
  1334. listProducts: adminProcedure.query(getKnowledgeProducts),
  1335. importProducts: adminProcedure
  1336. .input(z.object({
  1337. products: z.array(z.object({
  1338. model: z.string(),
  1339. description: z.string().optional(),
  1340. categories: z.string().optional(),
  1341. collection: z.string().optional(),
  1342. price: z.string().optional(),
  1343. availability: z.string().optional(),
  1344. features: z.string().optional(),
  1345. dimensions: z.string().optional(),
  1346. imageUrl: z.string().optional(),
  1347. })),
  1348. replaceAll: z.boolean().default(false),
  1349. }))
  1350. .mutation(async ({ input }) => {
  1351. if (input.replaceAll) await deleteAllKnowledgeProducts();
  1352. return bulkCreateKnowledgeProducts(input.products);
  1353. }),
  1354. }),
  1355. /* ─── Playground Router ─── */
  1356. playground: router({
  1357. chat: agentProcedure
  1358. .input(z.object({
  1359. sessionId: z.string().optional(),
  1360. content: z.string().min(1).max(4000),
  1361. systemPrompt: z.string().max(8000).optional(),
  1362. model: z.enum([
  1363. "claude-haiku-4-5-20251001",
  1364. "claude-sonnet-4-6",
  1365. "claude-opus-4-7",
  1366. ]).default("claude-sonnet-4-6"),
  1367. temperature: z.number().min(0).max(1).optional(),
  1368. history: z.array(z.object({
  1369. role: z.enum(["user", "assistant"]),
  1370. content: z.string(),
  1371. })).default([]),
  1372. }))
  1373. .mutation(async ({ input }) => {
  1374. const systemContent = input.systemPrompt ?? SYSTEM_PROMPT;
  1375. const llmMessages = [
  1376. { role: "system" as const, content: systemContent },
  1377. ...input.history,
  1378. { role: "user" as const, content: input.content },
  1379. ];
  1380. const result = await invokeLLM({
  1381. messages: llmMessages,
  1382. model: input.model,
  1383. ...(input.temperature != null ? { temperature: input.temperature } : {}),
  1384. });
  1385. const reply = result.choices[0]?.message?.content as string
  1386. ?? "Sorry, I could not generate a response.";
  1387. return { reply, model: input.model };
  1388. }),
  1389. }),
  1390. });
  1391. export type AppRouter = typeof appRouter;