routers.ts 46 KB

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