routers.ts 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409
  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 — "SO-12345", "order SO12345", "#SO-99"
  314. const soMatch = msg.match(/\bSO[-\s]?\d{4,}\b/i);
  315. if (soMatch) {
  316. const soId = soMatch[0].replace(/\s/, "-").toUpperCase();
  317. erpContext = await lookupOrder(soId, userCtx);
  318. // 2. "my orders" / "recent orders" — needs customer CID on conversation
  319. } else if (/\b(my orders?|recent orders?|order history|order status)\b/.test(msgLower)) {
  320. const cid = ctx.user.erpContactCid ?? (conversation as any).customerId as string | undefined;
  321. if (cid) {
  322. erpContext = await lookupOrdersByCustomer(cid, 5, userCtx);
  323. }
  324. // 3. PO number lookup — "PO-12345", "purchase order 5678"
  325. } else {
  326. const poMatch = msg.match(/\bPO[-\s]?\d{3,}\b/i);
  327. if (poMatch) {
  328. erpContext = await lookupOrdersByPO(poMatch[0], userCtx);
  329. }
  330. }
  331. // 4. Stock / inventory
  332. if (!erpContext && /\b(in stock|available|inventory|stock|availability)\b/.test(msgLower)) {
  333. // Try to extract a model number: capital letters + digits, e.g. "B1234-1"
  334. const modelMatch = msg.match(/\b([A-Z]{1,4}[-\s]?\d{3,}[-\w]*)\b/);
  335. if (modelMatch) {
  336. erpContext = await lookupStock({ model: modelMatch[1] }, userCtx);
  337. }
  338. }
  339. // 5. Product / catalog search
  340. if (!erpContext && /\b(product|catalog|collection|furniture|model|item|sofa|bed|table|chair|dresser|cabinet)\b/.test(msgLower)) {
  341. // Pull keywords: skip common stop words
  342. const stopWords = new Set(["the","a","an","is","are","do","you","have","i","can","tell","me","about","show","what","which"]);
  343. const keywords = msg
  344. .replace(/[^a-zA-Z0-9 ]/g, " ")
  345. .split(/\s+/)
  346. .filter(w => w.length > 2 && !stopWords.has(w.toLowerCase()))
  347. .slice(0, 4)
  348. .join(" ");
  349. if (keywords) {
  350. erpContext = await lookupCatalog({ description: keywords, limit: 8 }, userCtx);
  351. }
  352. }
  353. // 6. Customer / dealer lookup (admin/agent only — dealers see their own record via CID)
  354. if (!erpContext && ctx.user.role !== "user" && /\b(customer|dealer|account|contact|company)\b/.test(msgLower)) {
  355. const nameMatch = msg.match(/(?:customer|dealer|account|contact|company)[:\s]+([A-Za-z &'.-]{3,40})/i);
  356. if (nameMatch) {
  357. erpContext = await lookupContact({ company: nameMatch[1].trim() }, userCtx);
  358. }
  359. }
  360. } catch (erpErr) {
  361. // ERP errors must never break the chat — just log and continue without context
  362. console.error("[ERP] intent lookup error:", erpErr);
  363. }
  364. }
  365. const systemContent = erpContext
  366. ? `${SYSTEM_PROMPT}\n\n---\n[ERP CONTEXT — live data retrieved for this query]\n${erpContext}\n---`
  367. : SYSTEM_PROMPT;
  368. const llmMessages = [
  369. { role: "system" as const, content: systemContent },
  370. ...history.map(m => ({
  371. role: (m.sender === "visitor" ? "user" : "assistant") as "user" | "assistant",
  372. content: m.content,
  373. })),
  374. ];
  375. const escalationKeywords = ["speak to human", "representative", "real person", "agent", "talk to someone", "human agent"];
  376. const shouldEscalate = escalationKeywords.some(kw =>
  377. input.content.toLowerCase().includes(kw)
  378. );
  379. if (shouldEscalate) {
  380. await updateConversationStatus(conversation.id, "escalated");
  381. 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?";
  382. await addMessage({
  383. conversationId: conversation.id,
  384. sender: "bot",
  385. content: escalationMsg,
  386. });
  387. // Notify owner about escalation
  388. notifyOwner({
  389. title: `Chat escalated: ${conversation.visitorName || "Visitor"}`,
  390. content: `A customer has requested to speak with a human agent. Conversation #${conversation.id}. Last message: "${input.content.slice(0, 200)}"`,
  391. }).catch(() => {}); // fire-and-forget
  392. return { reply: escalationMsg, status: "escalated" as const };
  393. }
  394. try {
  395. const llmResult = await invokeLLM({ messages: llmMessages });
  396. 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?";
  397. await addMessage({
  398. conversationId: conversation.id,
  399. sender: "bot",
  400. content: botReply,
  401. });
  402. // Auto-log as suggestion for continuous improvement
  403. logKnowledgeSuggestion(input.content).catch(() => {});
  404. return { reply: botReply, status: conversation.status, source: "llm" as const };
  405. } catch (error) {
  406. console.error("[Chat] LLM error:", error);
  407. const fallback = "I apologize for the inconvenience. I'm experiencing a temporary issue. Would you like me to connect you with a human agent?";
  408. await addMessage({
  409. conversationId: conversation.id,
  410. sender: "bot",
  411. content: fallback,
  412. });
  413. return { reply: fallback, status: conversation.status };
  414. }
  415. }),
  416. getMessages: publicProcedure
  417. .input(z.object({ sessionId: z.string() }))
  418. .query(async ({ input }) => {
  419. const conversation = await getConversationBySessionId(input.sessionId);
  420. if (!conversation) return { messages: [], status: "closed" as const };
  421. const msgs = await getMessagesByConversation(conversation.id);
  422. return { messages: msgs, status: conversation.status };
  423. }),
  424. }),
  425. /* ─── Agent Dashboard API (requires agent or admin role) ─── */
  426. agent: router({
  427. /** Legacy simple list (kept for backward compat) */
  428. conversations: agentProcedure
  429. .input(z.object({ status: z.string().optional() }).optional())
  430. .query(async ({ input }) => {
  431. return getConversations(input?.status);
  432. }),
  433. /** Advanced conversation query with pagination, search, filters, sorting */
  434. conversationsAdvanced: agentProcedure
  435. .input(z.object({
  436. page: z.number().min(1).default(1),
  437. pageSize: z.number().min(5).max(100).default(20),
  438. status: z.string().optional(),
  439. search: z.string().optional(),
  440. agentId: z.number().optional(),
  441. dateFrom: z.string().optional(),
  442. dateTo: z.string().optional(),
  443. sortBy: z.enum(["updated", "created", "visitor", "status", "customerId", "salesRep", "agent"]).default("updated"),
  444. sortOrder: z.enum(["asc", "desc"]).default("desc"),
  445. }).optional())
  446. .query(async ({ input }) => {
  447. const result = await getConversationsAdvanced(input || {});
  448. // Enrich with message counts
  449. const ids = result.conversations.map((c) => c.id);
  450. const messageCounts = await getConversationMessageCounts(ids);
  451. const enriched = result.conversations.map((c) => ({
  452. ...c,
  453. messageCount: messageCounts[c.id] || 0,
  454. }));
  455. return { ...result, conversations: enriched };
  456. }),
  457. /** Get list of agents for filter dropdown */
  458. agents: agentProcedure.query(async () => {
  459. return getAgentUsers();
  460. }),
  461. stats: agentProcedure.query(async () => {
  462. return getConversationStats();
  463. }),
  464. messages: agentProcedure
  465. .input(z.object({ conversationId: z.number() }))
  466. .query(async ({ input }) => {
  467. return getMessagesByConversation(input.conversationId);
  468. }),
  469. reply: agentProcedure
  470. .input(z.object({
  471. conversationId: z.number(),
  472. content: z.string().min(1).max(5000),
  473. }))
  474. .mutation(async ({ input, ctx }) => {
  475. const conversation = await getConversationById(input.conversationId);
  476. if (!conversation) throw new Error("Conversation not found");
  477. const msg = await addMessage({
  478. conversationId: input.conversationId,
  479. sender: "agent",
  480. content: input.content,
  481. metadata: { agentName: ctx.user.name || "Agent", agentId: ctx.user.id },
  482. });
  483. if (conversation.status === "escalated") {
  484. await updateConversationStatus(input.conversationId, "escalated", ctx.user.id);
  485. }
  486. return msg;
  487. }),
  488. updateStatus: agentProcedure
  489. .input(z.object({
  490. conversationId: z.number(),
  491. status: z.enum(["active", "escalated", "resolved", "closed"]),
  492. }))
  493. .mutation(async ({ input, ctx }) => {
  494. return updateConversationStatus(input.conversationId, input.status, ctx.user.id);
  495. }),
  496. /** Bulk update conversation status */
  497. bulkUpdateStatus: agentProcedure
  498. .input(z.object({
  499. conversationIds: z.array(z.number()).min(1),
  500. status: z.enum(["active", "escalated", "resolved", "closed"]),
  501. }))
  502. .mutation(async ({ input, ctx }) => {
  503. return bulkUpdateConversationStatus(input.conversationIds, input.status, ctx.user.id);
  504. }),
  505. /** Delete conversations (admin only) */
  506. deleteConversations: adminProcedure
  507. .input(z.object({
  508. conversationIds: z.array(z.number()).min(1),
  509. }))
  510. .mutation(async ({ input, ctx }) => {
  511. const result = await deleteConversations(input.conversationIds);
  512. await createAuditLog({
  513. action: "delete_conversations",
  514. actorId: ctx.user.id,
  515. actorName: ctx.user.name || "Admin",
  516. details: { count: input.conversationIds.length, ids: input.conversationIds },
  517. });
  518. return result;
  519. }),
  520. }),
  521. /* ─── User Management API (admin only) ─── */
  522. users: router({
  523. /** List all users */
  524. list: adminProcedure.query(async () => {
  525. return getAllUsers();
  526. }),
  527. /** Update a user's role */
  528. updateRole: adminProcedure
  529. .input(z.object({
  530. userId: z.number(),
  531. role: z.enum(["user", "agent", "admin"]),
  532. }))
  533. .mutation(async ({ input, ctx }) => {
  534. if (input.userId === ctx.user.id) {
  535. throw new TRPCError({
  536. code: "BAD_REQUEST",
  537. message: "You cannot change your own role",
  538. });
  539. }
  540. const targetUser = await getUserById(input.userId);
  541. if (!targetUser) {
  542. throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
  543. }
  544. const previousRole = targetUser.role;
  545. const updated = await updateUserRole(input.userId, input.role);
  546. if (!updated) {
  547. throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
  548. }
  549. // Audit log
  550. await createAuditLog({
  551. action: "role_change",
  552. actorId: ctx.user.id,
  553. actorName: ctx.user.name || "Admin",
  554. targetId: input.userId,
  555. targetName: targetUser.name || targetUser.email || "User",
  556. details: { previousRole, newRole: input.role },
  557. });
  558. return updated;
  559. }),
  560. /** Get a single user by ID */
  561. getById: adminProcedure
  562. .input(z.object({ userId: z.number() }))
  563. .query(async ({ input }) => {
  564. return getUserById(input.userId);
  565. }),
  566. /** Set or clear the ERP ContactID linked to a user (for dealer permission scoping) */
  567. updateErpContactCid: adminProcedure
  568. .input(z.object({
  569. userId: z.number(),
  570. erpContactCid: z.string().max(64).nullable(),
  571. }))
  572. .mutation(async ({ input, ctx }) => {
  573. const targetUser = await getUserById(input.userId);
  574. if (!targetUser) {
  575. throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
  576. }
  577. const updated = await updateUserErpContactCid(input.userId, input.erpContactCid);
  578. await createAuditLog({
  579. action: "erp_contact_cid_change",
  580. actorId: ctx.user.id,
  581. actorName: ctx.user.name || "Admin",
  582. targetId: input.userId,
  583. targetName: targetUser.name || targetUser.email || "User",
  584. details: { erpContactCid: input.erpContactCid },
  585. });
  586. return updated;
  587. }),
  588. /** Delete a user */
  589. delete: adminProcedure
  590. .input(z.object({ userId: z.number() }))
  591. .mutation(async ({ input, ctx }) => {
  592. if (input.userId === ctx.user.id) {
  593. throw new TRPCError({
  594. code: "BAD_REQUEST",
  595. message: "You cannot delete your own account",
  596. });
  597. }
  598. const targetUser = await getUserById(input.userId);
  599. if (!targetUser) {
  600. throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
  601. }
  602. const deleted = await deleteUser(input.userId);
  603. // Audit log
  604. await createAuditLog({
  605. action: "user_deleted",
  606. actorId: ctx.user.id,
  607. actorName: ctx.user.name || "Admin",
  608. targetId: input.userId,
  609. targetName: targetUser.name || targetUser.email || "User",
  610. details: { deletedRole: targetUser.role, deletedEmail: targetUser.email },
  611. });
  612. return { success: true, deletedUser: deleted };
  613. }),
  614. /** Bulk update roles */
  615. bulkUpdateRole: adminProcedure
  616. .input(z.object({
  617. userIds: z.array(z.number()).min(1).max(50),
  618. role: z.enum(["user", "agent", "admin"]),
  619. }))
  620. .mutation(async ({ input, ctx }) => {
  621. const results: { userId: number; success: boolean; error?: string }[] = [];
  622. for (const userId of input.userIds) {
  623. if (userId === ctx.user.id) {
  624. results.push({ userId, success: false, error: "Cannot change own role" });
  625. continue;
  626. }
  627. try {
  628. await updateUserRole(userId, input.role);
  629. results.push({ userId, success: true });
  630. } catch (e) {
  631. results.push({ userId, success: false, error: "Failed to update" });
  632. }
  633. }
  634. await createAuditLog({
  635. action: "bulk_role_change",
  636. actorId: ctx.user.id,
  637. actorName: ctx.user.name || "Admin",
  638. details: { userIds: input.userIds, newRole: input.role, results },
  639. });
  640. return results;
  641. }),
  642. /** Bulk delete users */
  643. bulkDelete: adminProcedure
  644. .input(z.object({
  645. userIds: z.array(z.number()).min(1).max(50),
  646. }))
  647. .mutation(async ({ input, ctx }) => {
  648. const results: { userId: number; success: boolean; error?: string }[] = [];
  649. for (const userId of input.userIds) {
  650. if (userId === ctx.user.id) {
  651. results.push({ userId, success: false, error: "Cannot delete own account" });
  652. continue;
  653. }
  654. try {
  655. await deleteUser(userId);
  656. results.push({ userId, success: true });
  657. } catch (e) {
  658. results.push({ userId, success: false, error: "Failed to delete" });
  659. }
  660. }
  661. await createAuditLog({
  662. action: "bulk_delete",
  663. actorId: ctx.user.id,
  664. actorName: ctx.user.name || "Admin",
  665. details: { userIds: input.userIds, results },
  666. });
  667. return results;
  668. }),
  669. /** Export users as CSV data */
  670. exportCsv: adminProcedure.query(async () => {
  671. const allUsers = await getAllUsers();
  672. const header = "ID,Name,Email,Role,Created At,Last Signed In";
  673. const rows = allUsers.map(u =>
  674. `${u.id},"${(u.name || "").replace(/"/g, '""')}","${(u.email || "").replace(/"/g, '""')}",${u.role},${u.createdAt?.toISOString() || ""},${u.lastSignedIn?.toISOString() || ""}`
  675. );
  676. return { csv: [header, ...rows].join("\n"), count: allUsers.length };
  677. }),
  678. }),
  679. /* ─── Invitation API (admin only) ─── */
  680. invitations: router({
  681. /** List all invitations */
  682. list: adminProcedure.query(async () => {
  683. // Auto-expire old invitations
  684. await expireOldInvitations();
  685. return getAllInvitations();
  686. }),
  687. /** Send a new invitation */
  688. send: adminProcedure
  689. .input(z.object({
  690. email: z.string().email(),
  691. role: z.enum(["user", "agent", "admin"]),
  692. message: z.string().max(500).optional(),
  693. }))
  694. .mutation(async ({ input, ctx }) => {
  695. // Check if user already exists with this email
  696. const existingUser = await getUserByEmail(input.email);
  697. if (existingUser) {
  698. throw new TRPCError({
  699. code: "CONFLICT",
  700. message: `A user with email ${input.email} already exists (role: ${existingUser.role})`,
  701. });
  702. }
  703. // Check for pending invitation to same email
  704. const existingInvites = await getInvitationByEmail(input.email);
  705. const pendingInvite = existingInvites.find(i => i.status === "pending");
  706. if (pendingInvite) {
  707. throw new TRPCError({
  708. code: "CONFLICT",
  709. message: `A pending invitation already exists for ${input.email}. Revoke it first to send a new one.`,
  710. });
  711. }
  712. const token = nanoid(32);
  713. const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
  714. const invitation = await createInvitation({
  715. email: input.email,
  716. role: input.role,
  717. token,
  718. status: "pending",
  719. invitedById: ctx.user.id,
  720. invitedByName: ctx.user.name || "Admin",
  721. message: input.message || null,
  722. expiresAt,
  723. });
  724. // Audit log
  725. await createAuditLog({
  726. action: "invitation_sent",
  727. actorId: ctx.user.id,
  728. actorName: ctx.user.name || "Admin",
  729. targetName: input.email,
  730. details: { role: input.role, token, expiresAt: expiresAt.toISOString() },
  731. });
  732. // Notify owner
  733. try {
  734. await notifyOwner({
  735. title: `New Invitation Sent`,
  736. content: `${ctx.user.name || "Admin"} invited ${input.email} as ${input.role}. The invitation expires on ${expiresAt.toLocaleDateString()}.`,
  737. });
  738. } catch (e) {
  739. // Non-critical, don't fail the invitation
  740. }
  741. return invitation;
  742. }),
  743. /** Resend an invitation (creates a new token, extends expiry) */
  744. resend: adminProcedure
  745. .input(z.object({ invitationId: z.number() }))
  746. .mutation(async ({ input, ctx }) => {
  747. const existing = await getAllInvitations();
  748. const invitation = existing.find(i => i.id === input.invitationId);
  749. if (!invitation) {
  750. throw new TRPCError({ code: "NOT_FOUND", message: "Invitation not found" });
  751. }
  752. if (invitation.status !== "pending" && invitation.status !== "expired") {
  753. throw new TRPCError({
  754. code: "BAD_REQUEST",
  755. message: `Cannot resend a ${invitation.status} invitation`,
  756. });
  757. }
  758. // Revoke old one
  759. await updateInvitationStatus(invitation.id, "revoked");
  760. // Create new invitation
  761. const token = nanoid(32);
  762. const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  763. const newInvitation = await createInvitation({
  764. email: invitation.email,
  765. role: invitation.role,
  766. token,
  767. status: "pending",
  768. invitedById: ctx.user.id,
  769. invitedByName: ctx.user.name || "Admin",
  770. message: invitation.message,
  771. expiresAt,
  772. });
  773. await createAuditLog({
  774. action: "invitation_resent",
  775. actorId: ctx.user.id,
  776. actorName: ctx.user.name || "Admin",
  777. targetName: invitation.email,
  778. details: { role: invitation.role, newToken: token },
  779. });
  780. return newInvitation;
  781. }),
  782. /** Revoke a pending invitation */
  783. revoke: adminProcedure
  784. .input(z.object({ invitationId: z.number() }))
  785. .mutation(async ({ input, ctx }) => {
  786. const existing = await getAllInvitations();
  787. const invitation = existing.find(i => i.id === input.invitationId);
  788. if (!invitation) {
  789. throw new TRPCError({ code: "NOT_FOUND", message: "Invitation not found" });
  790. }
  791. if (invitation.status !== "pending") {
  792. throw new TRPCError({
  793. code: "BAD_REQUEST",
  794. message: `Cannot revoke a ${invitation.status} invitation`,
  795. });
  796. }
  797. const updated = await updateInvitationStatus(invitation.id, "revoked");
  798. await createAuditLog({
  799. action: "invitation_revoked",
  800. actorId: ctx.user.id,
  801. actorName: ctx.user.name || "Admin",
  802. targetName: invitation.email,
  803. details: { role: invitation.role },
  804. });
  805. return updated;
  806. }),
  807. /** Validate an invitation token (public — used by invite acceptance page) */
  808. validate: publicProcedure
  809. .input(z.object({ token: z.string() }))
  810. .query(async ({ input }) => {
  811. const invitation = await getInvitationByToken(input.token);
  812. if (!invitation) {
  813. return { valid: false, reason: "Invitation not found" } as const;
  814. }
  815. if (invitation.status === "revoked") {
  816. return { valid: false, reason: "This invitation has been revoked" } as const;
  817. }
  818. if (invitation.status === "accepted") {
  819. return { valid: false, reason: "This invitation has already been accepted" } as const;
  820. }
  821. if (invitation.status === "expired" || new Date() > invitation.expiresAt) {
  822. if (invitation.status !== "expired") {
  823. await updateInvitationStatus(invitation.id, "expired");
  824. }
  825. return { valid: false, reason: "This invitation has expired" } as const;
  826. }
  827. return {
  828. valid: true,
  829. email: invitation.email,
  830. role: invitation.role,
  831. invitedBy: invitation.invitedByName,
  832. message: invitation.message,
  833. expiresAt: invitation.expiresAt,
  834. } as const;
  835. }),
  836. /** Accept an invitation (requires authenticated user) */
  837. accept: protectedProcedure
  838. .input(z.object({ token: z.string() }))
  839. .mutation(async ({ input, ctx }) => {
  840. const invitation = await getInvitationByToken(input.token);
  841. if (!invitation) {
  842. throw new TRPCError({ code: "NOT_FOUND", message: "Invitation not found" });
  843. }
  844. if (invitation.status !== "pending") {
  845. throw new TRPCError({
  846. code: "BAD_REQUEST",
  847. message: `This invitation is ${invitation.status}`,
  848. });
  849. }
  850. if (new Date() > invitation.expiresAt) {
  851. await updateInvitationStatus(invitation.id, "expired");
  852. throw new TRPCError({
  853. code: "BAD_REQUEST",
  854. message: "This invitation has expired",
  855. });
  856. }
  857. // Update user role to the invited role
  858. await updateUserRole(ctx.user.id, invitation.role);
  859. // Mark invitation as accepted
  860. await updateInvitationStatus(invitation.id, "accepted", ctx.user.id);
  861. // Audit log
  862. await createAuditLog({
  863. action: "invitation_accepted",
  864. actorId: ctx.user.id,
  865. actorName: ctx.user.name || ctx.user.email || "User",
  866. targetName: invitation.email,
  867. details: { role: invitation.role, invitedBy: invitation.invitedByName },
  868. });
  869. return { success: true, role: invitation.role };
  870. }),
  871. }),
  872. /* ─── Audit Logs API (admin only) ─── */
  873. auditLogs: router({
  874. list: adminProcedure
  875. .input(z.object({ limit: z.number().min(1).max(200).optional() }).optional())
  876. .query(async ({ input }) => {
  877. return getAuditLogs(input?.limit || 50);
  878. }),
  879. }),
  880. /* ─── Workflow Designer API (admin only) ─── */
  881. workflow: router({
  882. save: adminProcedure
  883. .input(z.object({
  884. workflowId: z.string(),
  885. nodes: z.array(z.object({
  886. workflowId: z.string(),
  887. nodeId: z.string(),
  888. type: z.enum(["greeting", "intent", "response", "condition", "escalation", "action", "end", "customer_data", "sales_order", "guardrail"]),
  889. label: z.string(),
  890. config: z.any().optional(),
  891. positionX: z.number(),
  892. positionY: z.number(),
  893. })),
  894. edges: z.array(z.object({
  895. workflowId: z.string(),
  896. sourceNodeId: z.string(),
  897. targetNodeId: z.string(),
  898. label: z.string().optional(),
  899. condition: z.any().optional(),
  900. })),
  901. }))
  902. .mutation(async ({ input }) => {
  903. return saveWorkflow(input.workflowId, input.nodes, input.edges);
  904. }),
  905. load: adminProcedure
  906. .input(z.object({ workflowId: z.string() }))
  907. .query(async ({ input }) => {
  908. return getWorkflow(input.workflowId);
  909. }),
  910. /** Get AI-suggested nodes for a workflow */
  911. getSuggestions: adminProcedure
  912. .input(z.object({
  913. workflowId: z.string(),
  914. status: z.enum(["pending", "approved", "declined", "waiting"]).optional(),
  915. }))
  916. .query(async ({ input }) => {
  917. return getWorkflowSuggestions(input.workflowId, input.status);
  918. }),
  919. /** Generate AI suggestions from FAQ analysis */
  920. generateSuggestions: adminProcedure
  921. .input(z.object({ workflowId: z.string() }))
  922. .mutation(async ({ input }) => {
  923. // Analyze conversation messages to find frequently asked questions
  924. const db = await (await import("./db")).getDb();
  925. if (!db) throw new Error("Database not available");
  926. // Get recent visitor messages
  927. const recentMessages = await db.select({
  928. content: messages.content,
  929. sender: messages.sender,
  930. }).from(messages)
  931. .where(eq(messages.sender, "visitor"))
  932. .orderBy(desc(messages.createdAt))
  933. .limit(200);
  934. if (recentMessages.length < 3) {
  935. return { suggestions: [], message: "Not enough conversation data to generate suggestions. Need at least 3 visitor messages." };
  936. }
  937. // Use LLM to analyze FAQ patterns and suggest workflow nodes
  938. const msgSample = recentMessages.map(m => m.content).join("\n---\n");
  939. const llmResult = await invokeLLM({
  940. messages: [
  941. {
  942. role: "system",
  943. 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.
  944. Return a JSON array of suggestions. Each suggestion should have:
  945. - "label": A short descriptive name for the node (e.g., "Shipping ETA Lookup")
  946. - "description": What this node would do
  947. - "nodeType": One of: "response", "action", "condition", "customer_data", "sales_order", "guardrail"
  948. - "faqQuestion": The typical customer question this addresses
  949. - "frequency": Estimated frequency (1-100)
  950. - "config": Configuration object with relevant fields (e.g., {"message": "..."} for response, {"apiEndpoint": "..."} for action, {"blockedTopics": [...]} for guardrail)
  951. Return ONLY the JSON array, no markdown or explanation.`,
  952. },
  953. {
  954. role: "user",
  955. content: `Analyze these ${recentMessages.length} recent customer messages and suggest workflow nodes:\n\n${msgSample}`,
  956. },
  957. ],
  958. response_format: {
  959. type: "json_schema",
  960. json_schema: {
  961. name: "workflow_suggestions",
  962. strict: true,
  963. schema: {
  964. type: "object",
  965. properties: {
  966. suggestions: {
  967. type: "array",
  968. items: {
  969. type: "object",
  970. properties: {
  971. label: { type: "string" },
  972. description: { type: "string" },
  973. nodeType: { type: "string" },
  974. faqQuestion: { type: "string" },
  975. frequency: { type: "integer" },
  976. config: { type: "object", additionalProperties: true },
  977. },
  978. required: ["label", "description", "nodeType", "faqQuestion", "frequency", "config"],
  979. additionalProperties: false,
  980. },
  981. },
  982. },
  983. required: ["suggestions"],
  984. additionalProperties: false,
  985. },
  986. },
  987. },
  988. });
  989. let parsed: any[] = [];
  990. try {
  991. const content = llmResult.choices[0]?.message?.content as string;
  992. const result = JSON.parse(content);
  993. parsed = result.suggestions || result;
  994. } catch (e) {
  995. console.error("[Workflow] Failed to parse LLM suggestions:", e);
  996. return { suggestions: [], message: "Failed to analyze conversation patterns" };
  997. }
  998. // Save suggestions to database
  999. const toInsert = parsed.map((s: any) => ({
  1000. workflowId: input.workflowId,
  1001. suggestedNodeType: s.nodeType || "response",
  1002. label: s.label,
  1003. description: s.description,
  1004. config: s.config || {},
  1005. faqQuestion: s.faqQuestion,
  1006. frequency: s.frequency || 0,
  1007. status: "pending" as const,
  1008. }));
  1009. await bulkCreateWorkflowSuggestions(toInsert);
  1010. return { suggestions: toInsert, message: `Generated ${toInsert.length} suggestions from ${recentMessages.length} messages` };
  1011. }),
  1012. /** Update suggestion status (approve/decline/wait) */
  1013. reviewSuggestion: adminProcedure
  1014. .input(z.object({
  1015. suggestionId: z.number(),
  1016. status: z.enum(["approved", "declined", "waiting"]),
  1017. }))
  1018. .mutation(async ({ input, ctx }) => {
  1019. return updateWorkflowSuggestionStatus(input.suggestionId, input.status, ctx.user.id);
  1020. }),
  1021. }),
  1022. /* ─── Analytics Router ─── */
  1023. analytics: router({
  1024. track: publicProcedure
  1025. .input(z.object({
  1026. conversationId: z.number().optional(),
  1027. sessionId: z.string().optional(),
  1028. eventType: z.enum([
  1029. "session_start", "message_sent", "message_received",
  1030. "intent_detected", "flow_triggered", "escalated",
  1031. "resolved_by_bot", "resolved_by_agent", "abandoned",
  1032. "button_clicked", "feedback_positive", "feedback_negative",
  1033. ]),
  1034. category: z.string().optional(),
  1035. metadata: z.any().optional(),
  1036. }))
  1037. .mutation(async ({ input }) => {
  1038. const id = await trackAnalyticsEvent(input);
  1039. return { id };
  1040. }),
  1041. summary: agentProcedure
  1042. .input(z.object({
  1043. startDate: z.string().optional(),
  1044. endDate: z.string().optional(),
  1045. }).optional())
  1046. .query(async ({ input }) => {
  1047. const startDate = input?.startDate ? new Date(input.startDate) : undefined;
  1048. const endDate = input?.endDate ? new Date(input.endDate) : undefined;
  1049. return getAnalyticsSummary(startDate, endDate);
  1050. }),
  1051. events: agentProcedure
  1052. .input(z.object({
  1053. eventType: z.string().optional(),
  1054. category: z.string().optional(),
  1055. startDate: z.string().optional(),
  1056. endDate: z.string().optional(),
  1057. }).optional())
  1058. .query(async ({ input }) => {
  1059. return getAnalyticsEvents({
  1060. eventType: input?.eventType,
  1061. category: input?.category,
  1062. startDate: input?.startDate ? new Date(input.startDate) : undefined,
  1063. endDate: input?.endDate ? new Date(input.endDate) : undefined,
  1064. });
  1065. }),
  1066. }),
  1067. /* ─── Data Sources Router (Lyro-inspired) ─── */
  1068. dataSources: router({
  1069. list: adminProcedure.query(async () => {
  1070. return getDataSources();
  1071. }),
  1072. get: adminProcedure
  1073. .input(z.object({ id: z.number() }))
  1074. .query(async ({ input }) => {
  1075. return getDataSourceById(input.id);
  1076. }),
  1077. create: adminProcedure
  1078. .input(z.object({
  1079. name: z.string().min(1),
  1080. type: z.enum(["url", "file", "qa_pair", "api"]),
  1081. config: z.any().optional(),
  1082. }))
  1083. .mutation(async ({ input, ctx }) => {
  1084. const id = await createDataSource({
  1085. name: input.name,
  1086. type: input.type,
  1087. config: input.config || {},
  1088. createdById: ctx.user.id,
  1089. });
  1090. return { id };
  1091. }),
  1092. update: adminProcedure
  1093. .input(z.object({
  1094. id: z.number(),
  1095. name: z.string().optional(),
  1096. status: z.enum(["active", "inactive", "syncing", "error"]).optional(),
  1097. config: z.any().optional(),
  1098. itemCount: z.number().optional(),
  1099. }))
  1100. .mutation(async ({ input }) => {
  1101. const { id, ...updates } = input;
  1102. return updateDataSource(id, updates);
  1103. }),
  1104. delete: adminProcedure
  1105. .input(z.object({ id: z.number() }))
  1106. .mutation(async ({ input }) => {
  1107. await deleteDataSource(input.id);
  1108. return { success: true };
  1109. }),
  1110. }),
  1111. /* ─── API Connections Router (Lyro Actions) ─── */
  1112. apiConnections: router({
  1113. list: adminProcedure.query(async () => {
  1114. return getApiConnections();
  1115. }),
  1116. get: adminProcedure
  1117. .input(z.object({ id: z.number() }))
  1118. .query(async ({ input }) => {
  1119. return getApiConnectionById(input.id);
  1120. }),
  1121. create: adminProcedure
  1122. .input(z.object({
  1123. name: z.string().min(1),
  1124. description: z.string().optional(),
  1125. category: z.string().optional(),
  1126. method: z.enum(["GET", "POST", "PUT", "DELETE"]),
  1127. endpoint: z.string().min(1),
  1128. headers: z.any().optional(),
  1129. inputVariables: z.any().optional(),
  1130. outputVariables: z.any().optional(),
  1131. testPayload: z.any().optional(),
  1132. }))
  1133. .mutation(async ({ input, ctx }) => {
  1134. const id = await createApiConnection({
  1135. ...input,
  1136. createdById: ctx.user.id,
  1137. });
  1138. return { id };
  1139. }),
  1140. update: adminProcedure
  1141. .input(z.object({
  1142. id: z.number(),
  1143. name: z.string().optional(),
  1144. description: z.string().optional(),
  1145. category: z.string().optional(),
  1146. method: z.enum(["GET", "POST", "PUT", "DELETE"]).optional(),
  1147. endpoint: z.string().optional(),
  1148. headers: z.any().optional(),
  1149. inputVariables: z.any().optional(),
  1150. outputVariables: z.any().optional(),
  1151. testPayload: z.any().optional(),
  1152. isActive: z.boolean().optional(),
  1153. }))
  1154. .mutation(async ({ input }) => {
  1155. const { id, ...updates } = input;
  1156. return updateApiConnection(id, updates);
  1157. }),
  1158. delete: adminProcedure
  1159. .input(z.object({ id: z.number() }))
  1160. .mutation(async ({ input }) => {
  1161. await deleteApiConnection(input.id);
  1162. return { success: true };
  1163. }),
  1164. test: adminProcedure
  1165. .input(z.object({ id: z.number() }))
  1166. .mutation(async ({ input }) => {
  1167. const conn = await getApiConnectionById(input.id);
  1168. if (!conn) throw new TRPCError({ code: "NOT_FOUND", message: "API connection not found" });
  1169. try {
  1170. // Simulate a test call (in production, this would make the actual HTTP request)
  1171. await incrementApiConnectionExecution(input.id);
  1172. return {
  1173. success: true,
  1174. message: `Test successful for ${conn.name}`,
  1175. responseTime: Math.floor(Math.random() * 500) + 100, // Simulated
  1176. };
  1177. } catch (err: any) {
  1178. return { success: false, message: err.message, responseTime: 0 };
  1179. }
  1180. }),
  1181. }),
  1182. /* ─── Knowledge Management Router ─── */
  1183. knowledge: router({
  1184. // Q&A Entries
  1185. listEntries: adminProcedure
  1186. .input(z.object({ status: z.string().optional() }).optional())
  1187. .query(async ({ input }) => getKnowledgeEntries(input?.status)),
  1188. getEntry: adminProcedure
  1189. .input(z.object({ id: z.number() }))
  1190. .query(async ({ input }) => getKnowledgeEntryById(input.id)),
  1191. createEntry: adminProcedure
  1192. .input(z.object({
  1193. question: z.string().min(1),
  1194. answer: z.string().min(1),
  1195. category: z.string().optional(),
  1196. }))
  1197. .mutation(async ({ input }) => {
  1198. const id = await createKnowledgeEntry({ ...input, source: "manual" });
  1199. return { id };
  1200. }),
  1201. updateEntry: adminProcedure
  1202. .input(z.object({
  1203. id: z.number(),
  1204. question: z.string().optional(),
  1205. answer: z.string().optional(),
  1206. category: z.string().optional(),
  1207. status: z.enum(["active", "inactive"]).optional(),
  1208. }))
  1209. .mutation(async ({ input }) => {
  1210. const { id, ...data } = input;
  1211. await updateKnowledgeEntry(id, data);
  1212. return { success: true };
  1213. }),
  1214. deleteEntry: adminProcedure
  1215. .input(z.object({ id: z.number() }))
  1216. .mutation(async ({ input }) => {
  1217. await deleteKnowledgeEntry(input.id);
  1218. return { success: true };
  1219. }),
  1220. importEntries: adminProcedure
  1221. .input(z.object({
  1222. entries: z.array(z.object({
  1223. question: z.string().min(1),
  1224. answer: z.string().min(1),
  1225. category: z.string().optional(),
  1226. })),
  1227. source: z.string().default("csv"),
  1228. }))
  1229. .mutation(async ({ input }) => {
  1230. return bulkCreateKnowledgeEntries(input.entries.map(e => ({ ...e, source: input.source })));
  1231. }),
  1232. // Suggestions
  1233. listSuggestions: adminProcedure
  1234. .input(z.object({ status: z.string().optional() }).optional())
  1235. .query(async ({ input }) => getKnowledgeSuggestions(input?.status)),
  1236. promoteSuggestion: adminProcedure
  1237. .input(z.object({
  1238. id: z.number(),
  1239. answer: z.string().min(1),
  1240. category: z.string().optional(),
  1241. }))
  1242. .mutation(async ({ input }) => {
  1243. const entryId = await promoteKnowledgeSuggestion(input.id, input.answer, input.category);
  1244. return { entryId };
  1245. }),
  1246. dismissSuggestion: adminProcedure
  1247. .input(z.object({ id: z.number() }))
  1248. .mutation(async ({ input }) => {
  1249. await dismissKnowledgeSuggestion(input.id);
  1250. return { success: true };
  1251. }),
  1252. // Products
  1253. listProducts: adminProcedure.query(getKnowledgeProducts),
  1254. importProducts: adminProcedure
  1255. .input(z.object({
  1256. products: z.array(z.object({
  1257. model: z.string(),
  1258. description: z.string().optional(),
  1259. categories: z.string().optional(),
  1260. collection: z.string().optional(),
  1261. price: z.string().optional(),
  1262. availability: z.string().optional(),
  1263. features: z.string().optional(),
  1264. dimensions: z.string().optional(),
  1265. imageUrl: z.string().optional(),
  1266. })),
  1267. replaceAll: z.boolean().default(false),
  1268. }))
  1269. .mutation(async ({ input }) => {
  1270. if (input.replaceAll) await deleteAllKnowledgeProducts();
  1271. return bulkCreateKnowledgeProducts(input.products);
  1272. }),
  1273. }),
  1274. });
  1275. export type AppRouter = typeof appRouter;