routers.ts 42 KB

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