routers.ts 54 KB

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