routers.ts 52 KB

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