| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416 |
- import { z } from "zod";
- import { nanoid } from "nanoid";
- import { TRPCError } from "@trpc/server";
- import { COOKIE_NAME } from "@shared/const";
- import { getSessionCookieOptions } from "./_core/cookies";
- import { systemRouter } from "./_core/systemRouter";
- import { publicProcedure, protectedProcedure, agentProcedure, adminProcedure, router } from "./_core/trpc";
- import { invokeLLM } from "./_core/llm";
- import { notifyOwner } from "./_core/notification";
- import bcrypt from "bcryptjs";
- import {
- createConversation, getConversations, getConversationsAdvanced, getConversationById,
- getConversationBySessionId, updateConversationStatus, getConversationStats,
- addMessage, getMessagesByConversation,
- getConversationMessageCounts, getAgentUsers,
- bulkUpdateConversationStatus, deleteConversations,
- saveWorkflow, getWorkflow,
- getWorkflowSuggestions, updateWorkflowSuggestionStatus, bulkCreateWorkflowSuggestions,
- getAllUsers, updateUserRole, updateUserErpContactCid, getUserById, deleteUser, getUserByEmail,
- getUserByEmailWithPassword, createUserWithPassword, updateUserPassword,
- createPasswordResetToken, getPasswordResetToken, markPasswordResetTokenUsed,
- createInvitation, getAllInvitations, getInvitationByToken, updateInvitationStatus,
- expireOldInvitations, getInvitationByEmail,
- createAuditLog, getAuditLogs,
- trackAnalyticsEvent, getAnalyticsEvents, getAnalyticsSummary,
- createDataSource, getDataSources, getDataSourceById, updateDataSource, deleteDataSource,
- createApiConnection, getApiConnections, getApiConnectionById, updateApiConnection, deleteApiConnection, incrementApiConnectionExecution,
- searchKnowledge, incrementKnowledgeUseCount, logKnowledgeSuggestion,
- getKnowledgeEntries, getKnowledgeEntryById, createKnowledgeEntry,
- updateKnowledgeEntry, deleteKnowledgeEntry, bulkCreateKnowledgeEntries,
- getKnowledgeSuggestions, promoteKnowledgeSuggestion, dismissKnowledgeSuggestion,
- getKnowledgeProducts, bulkCreateKnowledgeProducts, deleteAllKnowledgeProducts,
- } from "./db";
- import { detectFlowIntent, executeFlow } from "./flowEngine";
- import { messages } from "../drizzle/schema";
- import { eq, desc } from "drizzle-orm";
- import { sdk } from "./_core/sdk";
- import { ENV } from "./_core/env";
- import {
- lookupOrder,
- lookupOrdersByCustomer,
- lookupOrdersByPO,
- lookupCatalog,
- lookupStock,
- lookupContact,
- } from "./erpTools";
- /* ─── Homelegance chatbot system prompt ─── */
- 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.
- About Homelegance:
- - Homelegance is a leading wholesale furniture manufacturer and distributor
- - They offer living room, bedroom, dining room, and accent furniture
- - Their customers are primarily furniture retailers and dealers (B2B)
- - They have collections ranging from traditional to contemporary styles
- Your capabilities:
- 1. **Product Discovery**: Help users find furniture by category, style, collection, or room type
- 2. **Order Status**: Help dealers check order status (ask for order number)
- 3. **Dealer Locator**: Help find authorized Homelegance dealers by location
- 4. **Warranty & Returns**: Answer questions about warranty policies and return procedures
- 5. **General FAQ**: Answer common questions about Homelegance products and services
- Guidelines:
- - Be warm, professional, and concise
- - When users ask about products, suggest specific categories and collections
- - For order inquiries, always ask for the order number
- - If you cannot help with something, offer to connect them with a human agent
- - Keep responses under 150 words unless detailed information is needed
- - Use markdown formatting for lists and emphasis when helpful`;
- export const appRouter = router({
- system: systemRouter,
- auth: router({
- me: publicProcedure.query(opts => opts.ctx.user),
- logout: publicProcedure.mutation(({ ctx }) => {
- const cookieOptions = getSessionCookieOptions(ctx.req);
- ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
- return { success: true } as const;
- }),
- /** Register a new user with email/password */
- register: publicProcedure
- .input(z.object({
- email: z.string().email(),
- password: z.string().min(8).max(128),
- name: z.string().min(1).max(100),
- }))
- .mutation(async ({ input, ctx }) => {
- const existing = await getUserByEmailWithPassword(input.email);
- if (existing) {
- throw new TRPCError({
- code: "CONFLICT",
- message: "An account with this email already exists",
- });
- }
- const passwordHash = await bcrypt.hash(input.password, 12);
- const user = await createUserWithPassword({
- email: input.email,
- name: input.name,
- passwordHash,
- });
- if (!user) {
- throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to create account" });
- }
- // Create session
- const sessionToken = await sdk.createSessionToken(user.openId, {
- name: user.name || "",
- expiresInMs: 30 * 24 * 60 * 60 * 1000, // 30 days
- });
- const cookieOptions = getSessionCookieOptions(ctx.req);
- ctx.res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: 30 * 24 * 60 * 60 * 1000 });
- return { success: true, user: { id: user.id, name: user.name, email: user.email, role: user.role } };
- }),
- /** Login with email/password */
- login: publicProcedure
- .input(z.object({
- email: z.string().email(),
- password: z.string().min(1),
- }))
- .mutation(async ({ input, ctx }) => {
- const user = await getUserByEmailWithPassword(input.email);
- if (!user || !user.passwordHash) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "Invalid email or password",
- });
- }
- const isValid = await bcrypt.compare(input.password, user.passwordHash);
- if (!isValid) {
- throw new TRPCError({
- code: "UNAUTHORIZED",
- message: "Invalid email or password",
- });
- }
- // Create session
- const sessionToken = await sdk.createSessionToken(user.openId, {
- name: user.name || "",
- expiresInMs: 30 * 24 * 60 * 60 * 1000,
- });
- const cookieOptions = getSessionCookieOptions(ctx.req);
- ctx.res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: 30 * 24 * 60 * 60 * 1000 });
- return { success: true, user: { id: user.id, name: user.name, email: user.email, role: user.role } };
- }),
- /** Request password reset — generates a token */
- forgotPassword: publicProcedure
- .input(z.object({ email: z.string().email() }))
- .mutation(async ({ input }) => {
- const user = await getUserByEmailWithPassword(input.email);
- // Always return success to prevent email enumeration
- if (!user || !user.passwordHash) {
- return { success: true, message: "If an account with that email exists, a reset link has been generated." };
- }
- const token = nanoid(32);
- const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
- await createPasswordResetToken({
- userId: user.id,
- token,
- expiresAt,
- });
- // In production, you would send an email here.
- // For demo, we return the token (in production, NEVER return the token)
- try {
- await notifyOwner({
- title: "Password Reset Requested",
- content: `Password reset requested for ${input.email}. Reset link: /reset-password/${token}`,
- });
- } catch (e) { /* non-critical */ }
- return { success: true, message: "If an account with that email exists, a reset link has been generated." };
- }),
- /** Validate a password reset token */
- validateResetToken: publicProcedure
- .input(z.object({ token: z.string() }))
- .query(async ({ input }) => {
- const resetToken = await getPasswordResetToken(input.token);
- if (!resetToken) {
- return { valid: false, reason: "Invalid reset link" } as const;
- }
- if (resetToken.usedAt) {
- return { valid: false, reason: "This reset link has already been used" } as const;
- }
- if (new Date() > resetToken.expiresAt) {
- return { valid: false, reason: "This reset link has expired" } as const;
- }
- const user = await getUserById(resetToken.userId);
- return { valid: true, email: user?.email || "" } as const;
- }),
- /** Reset password using a valid token */
- resetPassword: publicProcedure
- .input(z.object({
- token: z.string(),
- newPassword: z.string().min(8).max(128),
- }))
- .mutation(async ({ input }) => {
- const resetToken = await getPasswordResetToken(input.token);
- if (!resetToken) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Invalid reset link" });
- }
- if (resetToken.usedAt) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has already been used" });
- }
- if (new Date() > resetToken.expiresAt) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired" });
- }
- const passwordHash = await bcrypt.hash(input.newPassword, 12);
- await updateUserPassword(resetToken.userId, passwordHash);
- await markPasswordResetTokenUsed(resetToken.id);
- return { success: true, message: "Password has been reset successfully" };
- }),
- }),
- /* ─── Chat API (public — used by the chatbot widget) ─── */
- chat: router({
- startSession: publicProcedure
- .input(z.object({
- visitorName: z.string().optional(),
- visitorEmail: z.string().email().optional(),
- }).optional())
- .mutation(async ({ input }) => {
- const sessionId = nanoid(16);
- const conversation = await createConversation({
- sessionId,
- visitorName: input?.visitorName ?? "Visitor",
- visitorEmail: input?.visitorEmail,
- status: "active",
- });
- await addMessage({
- conversationId: conversation.id,
- sender: "bot",
- content: "Welcome to Homelegance! I'm **Ellie**, your AI furniture assistant.",
- metadata: {
- quickReplies: ["🔥 Hot Deals", "📦 Order Status", "🛋️ Product Catalog"],
- },
- });
- return { sessionId, conversationId: conversation.id };
- }),
- sendMessage: publicProcedure
- .input(z.object({
- sessionId: z.string(),
- content: z.string().min(1).max(2000),
- }))
- .mutation(async ({ input, ctx }) => {
- const conversation = await getConversationBySessionId(input.sessionId);
- if (!conversation) throw new Error("Conversation not found");
- await addMessage({
- conversationId: conversation.id,
- sender: "visitor",
- content: input.content,
- });
- if (conversation.status === "escalated") {
- // Notify owner/agents about new message in escalated conversation
- notifyOwner({
- title: `New message from ${conversation.visitorName || "Visitor"}`,
- content: `Customer message in escalated conversation #${conversation.id}: "${input.content.slice(0, 200)}${input.content.length > 200 ? "..." : ""}"`,
- }).catch(() => {}); // fire-and-forget
- return {
- reply: null,
- status: "escalated" as const,
- message: "Your conversation has been transferred to a human agent. They will respond shortly.",
- };
- }
- // ── Knowledge Base First ──────────────────────────────────────────────
- // Search Q&A knowledge base before calling LLM.
- let knowledgeAnswer: string | null = null;
- try {
- const kbMatch = await searchKnowledge(input.content);
- if (kbMatch) {
- knowledgeAnswer = kbMatch.answer;
- // Fire-and-forget: increment use count
- incrementKnowledgeUseCount(kbMatch.id).catch(() => {});
- }
- } catch (kbErr) {
- console.error("[KB] search error:", kbErr);
- }
- if (knowledgeAnswer) {
- await addMessage({ conversationId: conversation.id, sender: "bot", content: knowledgeAnswer });
- return { reply: knowledgeAnswer, status: conversation.status, source: "knowledge" as const };
- }
- // ── Workflow Flow Engine ──────────────────────────────────────────────
- // Check if user message triggers a live Support Flow
- let flowResult: { content: string; shouldEscalate?: boolean; flowId: string } | null = null;
- try {
- const matchedFlowId = detectFlowIntent(input.content);
- if (matchedFlowId) {
- flowResult = await executeFlow(matchedFlowId, input.content);
- }
- } catch (flowErr) {
- console.error("[Flow] engine error:", flowErr);
- }
- if (flowResult) {
- trackAnalyticsEvent({
- conversationId: conversation.id,
- sessionId: input.sessionId,
- eventType: "flow_triggered",
- category: flowResult.flowId,
- }).catch(() => {});
- if (flowResult.shouldEscalate) {
- await updateConversationStatus(conversation.id, "escalated");
- await addMessage({ conversationId: conversation.id, sender: "bot", content: flowResult.content });
- return { reply: flowResult.content, status: "escalated" as const, source: "flow" as const };
- }
- await addMessage({ conversationId: conversation.id, sender: "bot", content: flowResult.content });
- return { reply: flowResult.content, status: conversation.status, source: "flow" as const };
- }
- const history = await getMessagesByConversation(conversation.id);
- // ── ERP intent detection & context injection ──────────────────────────
- // Detect what the visitor is asking about and fetch live ERP data.
- // The result is appended to the system prompt so Claude has real data.
- let erpContext = "";
- if (ENV.erpApiKey) {
- try {
- const msg = input.content;
- const msgLower = msg.toLowerCase();
- // Build user context for permission-scoped ERP queries.
- // Dealers (role="user") are automatically scoped to their own CID by the bridge.
- const userCtx = {
- role: ctx.user?.role ?? "user",
- erpContactCid: ctx.user?.erpContactCid ?? null,
- };
- // 1. Single order lookup
- // Format A: "SO: A8487", "SO# B123" — SO is a label, alphanumeric ID follows
- // Format B: "SO-12345", "SO 12345" — SO is part of the digits-only ID
- const soLabelMatch = msg.match(/\bSO[-:\s#]+([A-Z]\d{3,})\b/i);
- const soPrefixMatch = msg.match(/\bSO[-\s]?\d{4,}\b/i);
- const soId = soLabelMatch
- ? soLabelMatch[1].toUpperCase()
- : soPrefixMatch
- ? soPrefixMatch[0].replace(/\s/, "-").toUpperCase()
- : null;
- if (soId) {
- erpContext = await lookupOrder(soId, userCtx);
- // 2. "my orders" / "recent orders" — needs customer CID on conversation
- } else if (/\b(my orders?|recent orders?|order history|order status)\b/.test(msgLower)) {
- const cid = ctx.user?.erpContactCid ?? (conversation as any).customerId as string | undefined;
- if (cid) {
- erpContext = await lookupOrdersByCustomer(cid, 5, userCtx);
- }
- // 3. PO number lookup — "PO-12345", "purchase order 5678"
- } else {
- const poMatch = msg.match(/\bPO[-\s]?\d{3,}\b/i);
- if (poMatch) {
- erpContext = await lookupOrdersByPO(poMatch[0], userCtx);
- }
- }
- // 4. Stock / inventory
- if (!erpContext && /\b(in stock|available|inventory|stock|availability)\b/.test(msgLower)) {
- // Try to extract a model number: capital letters + digits, e.g. "B1234-1"
- const modelMatch = msg.match(/\b([A-Z]{1,4}[-\s]?\d{3,}[-\w]*)\b/);
- if (modelMatch) {
- erpContext = await lookupStock({ model: modelMatch[1] }, userCtx);
- }
- }
- // 5. Product / catalog search
- if (!erpContext && /\b(product|catalog|collection|furniture|model|item|sofa|bed|table|chair|dresser|cabinet)\b/.test(msgLower)) {
- // Pull keywords: skip common stop words
- const stopWords = new Set(["the","a","an","is","are","do","you","have","i","can","tell","me","about","show","what","which"]);
- const keywords = msg
- .replace(/[^a-zA-Z0-9 ]/g, " ")
- .split(/\s+/)
- .filter(w => w.length > 2 && !stopWords.has(w.toLowerCase()))
- .slice(0, 4)
- .join(" ");
- if (keywords) {
- erpContext = await lookupCatalog({ description: keywords, limit: 8 }, userCtx);
- }
- }
- // 6. Customer / dealer lookup (admin/agent only — dealers see their own record via CID)
- if (!erpContext && ctx.user?.role !== "user" && /\b(customer|dealer|account|contact|company)\b/.test(msgLower)) {
- const nameMatch = msg.match(/(?:customer|dealer|account|contact|company)[:\s]+([A-Za-z &'.-]{3,40})/i);
- if (nameMatch) {
- erpContext = await lookupContact({ company: nameMatch[1].trim() }, userCtx);
- }
- }
- } catch (erpErr) {
- // ERP errors must never break the chat — just log and continue without context
- console.error("[ERP] intent lookup error:", erpErr);
- }
- }
- const systemContent = erpContext
- ? `${SYSTEM_PROMPT}\n\n---\n[ERP CONTEXT — live data retrieved for this query]\n${erpContext}\n---`
- : SYSTEM_PROMPT;
- const llmMessages = [
- { role: "system" as const, content: systemContent },
- ...history.map(m => ({
- role: (m.sender === "visitor" ? "user" : "assistant") as "user" | "assistant",
- content: m.content,
- })),
- ];
- const escalationKeywords = ["speak to human", "representative", "real person", "agent", "talk to someone", "human agent"];
- const shouldEscalate = escalationKeywords.some(kw =>
- input.content.toLowerCase().includes(kw)
- );
- if (shouldEscalate) {
- await updateConversationStatus(conversation.id, "escalated");
- 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?";
- await addMessage({
- conversationId: conversation.id,
- sender: "bot",
- content: escalationMsg,
- });
- // Notify owner about escalation
- notifyOwner({
- title: `Chat escalated: ${conversation.visitorName || "Visitor"}`,
- content: `A customer has requested to speak with a human agent. Conversation #${conversation.id}. Last message: "${input.content.slice(0, 200)}"`,
- }).catch(() => {}); // fire-and-forget
- return { reply: escalationMsg, status: "escalated" as const };
- }
- try {
- const llmResult = await invokeLLM({ messages: llmMessages });
- 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?";
- await addMessage({
- conversationId: conversation.id,
- sender: "bot",
- content: botReply,
- });
- // Auto-log as suggestion for continuous improvement
- logKnowledgeSuggestion(input.content).catch(() => {});
- return { reply: botReply, status: conversation.status, source: "llm" as const };
- } catch (error) {
- console.error("[Chat] LLM error:", error);
- const fallback = "I apologize for the inconvenience. I'm experiencing a temporary issue. Would you like me to connect you with a human agent?";
- await addMessage({
- conversationId: conversation.id,
- sender: "bot",
- content: fallback,
- });
- return { reply: fallback, status: conversation.status };
- }
- }),
- getMessages: publicProcedure
- .input(z.object({ sessionId: z.string() }))
- .query(async ({ input }) => {
- const conversation = await getConversationBySessionId(input.sessionId);
- if (!conversation) return { messages: [], status: "closed" as const };
- const msgs = await getMessagesByConversation(conversation.id);
- return { messages: msgs, status: conversation.status };
- }),
- }),
- /* ─── Agent Dashboard API (requires agent or admin role) ─── */
- agent: router({
- /** Legacy simple list (kept for backward compat) */
- conversations: agentProcedure
- .input(z.object({ status: z.string().optional() }).optional())
- .query(async ({ input }) => {
- return getConversations(input?.status);
- }),
- /** Advanced conversation query with pagination, search, filters, sorting */
- conversationsAdvanced: agentProcedure
- .input(z.object({
- page: z.number().min(1).default(1),
- pageSize: z.number().min(5).max(100).default(20),
- status: z.string().optional(),
- search: z.string().optional(),
- agentId: z.number().optional(),
- dateFrom: z.string().optional(),
- dateTo: z.string().optional(),
- sortBy: z.enum(["updated", "created", "visitor", "status", "customerId", "salesRep", "agent"]).default("updated"),
- sortOrder: z.enum(["asc", "desc"]).default("desc"),
- }).optional())
- .query(async ({ input }) => {
- const result = await getConversationsAdvanced(input || {});
- // Enrich with message counts
- const ids = result.conversations.map((c) => c.id);
- const messageCounts = await getConversationMessageCounts(ids);
- const enriched = result.conversations.map((c) => ({
- ...c,
- messageCount: messageCounts[c.id] || 0,
- }));
- return { ...result, conversations: enriched };
- }),
- /** Get list of agents for filter dropdown */
- agents: agentProcedure.query(async () => {
- return getAgentUsers();
- }),
- stats: agentProcedure.query(async () => {
- return getConversationStats();
- }),
- messages: agentProcedure
- .input(z.object({ conversationId: z.number() }))
- .query(async ({ input }) => {
- return getMessagesByConversation(input.conversationId);
- }),
- reply: agentProcedure
- .input(z.object({
- conversationId: z.number(),
- content: z.string().min(1).max(5000),
- }))
- .mutation(async ({ input, ctx }) => {
- const conversation = await getConversationById(input.conversationId);
- if (!conversation) throw new Error("Conversation not found");
- const msg = await addMessage({
- conversationId: input.conversationId,
- sender: "agent",
- content: input.content,
- metadata: { agentName: ctx.user.name || "Agent", agentId: ctx.user.id },
- });
- if (conversation.status === "escalated") {
- await updateConversationStatus(input.conversationId, "escalated", ctx.user.id);
- }
- return msg;
- }),
- updateStatus: agentProcedure
- .input(z.object({
- conversationId: z.number(),
- status: z.enum(["active", "escalated", "resolved", "closed"]),
- }))
- .mutation(async ({ input, ctx }) => {
- return updateConversationStatus(input.conversationId, input.status, ctx.user.id);
- }),
- /** Bulk update conversation status */
- bulkUpdateStatus: agentProcedure
- .input(z.object({
- conversationIds: z.array(z.number()).min(1),
- status: z.enum(["active", "escalated", "resolved", "closed"]),
- }))
- .mutation(async ({ input, ctx }) => {
- return bulkUpdateConversationStatus(input.conversationIds, input.status, ctx.user.id);
- }),
- /** Delete conversations (admin only) */
- deleteConversations: adminProcedure
- .input(z.object({
- conversationIds: z.array(z.number()).min(1),
- }))
- .mutation(async ({ input, ctx }) => {
- const result = await deleteConversations(input.conversationIds);
- await createAuditLog({
- action: "delete_conversations",
- actorId: ctx.user.id,
- actorName: ctx.user.name || "Admin",
- details: { count: input.conversationIds.length, ids: input.conversationIds },
- });
- return result;
- }),
- }),
- /* ─── User Management API (admin only) ─── */
- users: router({
- /** List all users */
- list: adminProcedure.query(async () => {
- return getAllUsers();
- }),
- /** Update a user's role */
- updateRole: adminProcedure
- .input(z.object({
- userId: z.number(),
- role: z.enum(["user", "agent", "admin"]),
- }))
- .mutation(async ({ input, ctx }) => {
- if (input.userId === ctx.user.id) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "You cannot change your own role",
- });
- }
- const targetUser = await getUserById(input.userId);
- if (!targetUser) {
- throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
- }
- const previousRole = targetUser.role;
- const updated = await updateUserRole(input.userId, input.role);
- if (!updated) {
- throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
- }
- // Audit log
- await createAuditLog({
- action: "role_change",
- actorId: ctx.user.id,
- actorName: ctx.user.name || "Admin",
- targetId: input.userId,
- targetName: targetUser.name || targetUser.email || "User",
- details: { previousRole, newRole: input.role },
- });
- return updated;
- }),
- /** Get a single user by ID */
- getById: adminProcedure
- .input(z.object({ userId: z.number() }))
- .query(async ({ input }) => {
- return getUserById(input.userId);
- }),
- /** Set or clear the ERP ContactID linked to a user (for dealer permission scoping) */
- updateErpContactCid: adminProcedure
- .input(z.object({
- userId: z.number(),
- erpContactCid: z.string().max(64).nullable(),
- }))
- .mutation(async ({ input, ctx }) => {
- const targetUser = await getUserById(input.userId);
- if (!targetUser) {
- throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
- }
- const updated = await updateUserErpContactCid(input.userId, input.erpContactCid);
- await createAuditLog({
- action: "erp_contact_cid_change",
- actorId: ctx.user.id,
- actorName: ctx.user.name || "Admin",
- targetId: input.userId,
- targetName: targetUser.name || targetUser.email || "User",
- details: { erpContactCid: input.erpContactCid },
- });
- return updated;
- }),
- /** Delete a user */
- delete: adminProcedure
- .input(z.object({ userId: z.number() }))
- .mutation(async ({ input, ctx }) => {
- if (input.userId === ctx.user.id) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "You cannot delete your own account",
- });
- }
- const targetUser = await getUserById(input.userId);
- if (!targetUser) {
- throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
- }
- const deleted = await deleteUser(input.userId);
- // Audit log
- await createAuditLog({
- action: "user_deleted",
- actorId: ctx.user.id,
- actorName: ctx.user.name || "Admin",
- targetId: input.userId,
- targetName: targetUser.name || targetUser.email || "User",
- details: { deletedRole: targetUser.role, deletedEmail: targetUser.email },
- });
- return { success: true, deletedUser: deleted };
- }),
- /** Bulk update roles */
- bulkUpdateRole: adminProcedure
- .input(z.object({
- userIds: z.array(z.number()).min(1).max(50),
- role: z.enum(["user", "agent", "admin"]),
- }))
- .mutation(async ({ input, ctx }) => {
- const results: { userId: number; success: boolean; error?: string }[] = [];
- for (const userId of input.userIds) {
- if (userId === ctx.user.id) {
- results.push({ userId, success: false, error: "Cannot change own role" });
- continue;
- }
- try {
- await updateUserRole(userId, input.role);
- results.push({ userId, success: true });
- } catch (e) {
- results.push({ userId, success: false, error: "Failed to update" });
- }
- }
- await createAuditLog({
- action: "bulk_role_change",
- actorId: ctx.user.id,
- actorName: ctx.user.name || "Admin",
- details: { userIds: input.userIds, newRole: input.role, results },
- });
- return results;
- }),
- /** Bulk delete users */
- bulkDelete: adminProcedure
- .input(z.object({
- userIds: z.array(z.number()).min(1).max(50),
- }))
- .mutation(async ({ input, ctx }) => {
- const results: { userId: number; success: boolean; error?: string }[] = [];
- for (const userId of input.userIds) {
- if (userId === ctx.user.id) {
- results.push({ userId, success: false, error: "Cannot delete own account" });
- continue;
- }
- try {
- await deleteUser(userId);
- results.push({ userId, success: true });
- } catch (e) {
- results.push({ userId, success: false, error: "Failed to delete" });
- }
- }
- await createAuditLog({
- action: "bulk_delete",
- actorId: ctx.user.id,
- actorName: ctx.user.name || "Admin",
- details: { userIds: input.userIds, results },
- });
- return results;
- }),
- /** Export users as CSV data */
- exportCsv: adminProcedure.query(async () => {
- const allUsers = await getAllUsers();
- const header = "ID,Name,Email,Role,Created At,Last Signed In";
- const rows = allUsers.map(u =>
- `${u.id},"${(u.name || "").replace(/"/g, '""')}","${(u.email || "").replace(/"/g, '""')}",${u.role},${u.createdAt?.toISOString() || ""},${u.lastSignedIn?.toISOString() || ""}`
- );
- return { csv: [header, ...rows].join("\n"), count: allUsers.length };
- }),
- }),
- /* ─── Invitation API (admin only) ─── */
- invitations: router({
- /** List all invitations */
- list: adminProcedure.query(async () => {
- // Auto-expire old invitations
- await expireOldInvitations();
- return getAllInvitations();
- }),
- /** Send a new invitation */
- send: adminProcedure
- .input(z.object({
- email: z.string().email(),
- role: z.enum(["user", "agent", "admin"]),
- message: z.string().max(500).optional(),
- }))
- .mutation(async ({ input, ctx }) => {
- // Check if user already exists with this email
- const existingUser = await getUserByEmail(input.email);
- if (existingUser) {
- throw new TRPCError({
- code: "CONFLICT",
- message: `A user with email ${input.email} already exists (role: ${existingUser.role})`,
- });
- }
- // Check for pending invitation to same email
- const existingInvites = await getInvitationByEmail(input.email);
- const pendingInvite = existingInvites.find(i => i.status === "pending");
- if (pendingInvite) {
- throw new TRPCError({
- code: "CONFLICT",
- message: `A pending invitation already exists for ${input.email}. Revoke it first to send a new one.`,
- });
- }
- const token = nanoid(32);
- const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
- const invitation = await createInvitation({
- email: input.email,
- role: input.role,
- token,
- status: "pending",
- invitedById: ctx.user.id,
- invitedByName: ctx.user.name || "Admin",
- message: input.message || null,
- expiresAt,
- });
- // Audit log
- await createAuditLog({
- action: "invitation_sent",
- actorId: ctx.user.id,
- actorName: ctx.user.name || "Admin",
- targetName: input.email,
- details: { role: input.role, token, expiresAt: expiresAt.toISOString() },
- });
- // Notify owner
- try {
- await notifyOwner({
- title: `New Invitation Sent`,
- content: `${ctx.user.name || "Admin"} invited ${input.email} as ${input.role}. The invitation expires on ${expiresAt.toLocaleDateString()}.`,
- });
- } catch (e) {
- // Non-critical, don't fail the invitation
- }
- return invitation;
- }),
- /** Resend an invitation (creates a new token, extends expiry) */
- resend: adminProcedure
- .input(z.object({ invitationId: z.number() }))
- .mutation(async ({ input, ctx }) => {
- const existing = await getAllInvitations();
- const invitation = existing.find(i => i.id === input.invitationId);
- if (!invitation) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Invitation not found" });
- }
- if (invitation.status !== "pending" && invitation.status !== "expired") {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: `Cannot resend a ${invitation.status} invitation`,
- });
- }
- // Revoke old one
- await updateInvitationStatus(invitation.id, "revoked");
- // Create new invitation
- const token = nanoid(32);
- const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
- const newInvitation = await createInvitation({
- email: invitation.email,
- role: invitation.role,
- token,
- status: "pending",
- invitedById: ctx.user.id,
- invitedByName: ctx.user.name || "Admin",
- message: invitation.message,
- expiresAt,
- });
- await createAuditLog({
- action: "invitation_resent",
- actorId: ctx.user.id,
- actorName: ctx.user.name || "Admin",
- targetName: invitation.email,
- details: { role: invitation.role, newToken: token },
- });
- return newInvitation;
- }),
- /** Revoke a pending invitation */
- revoke: adminProcedure
- .input(z.object({ invitationId: z.number() }))
- .mutation(async ({ input, ctx }) => {
- const existing = await getAllInvitations();
- const invitation = existing.find(i => i.id === input.invitationId);
- if (!invitation) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Invitation not found" });
- }
- if (invitation.status !== "pending") {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: `Cannot revoke a ${invitation.status} invitation`,
- });
- }
- const updated = await updateInvitationStatus(invitation.id, "revoked");
- await createAuditLog({
- action: "invitation_revoked",
- actorId: ctx.user.id,
- actorName: ctx.user.name || "Admin",
- targetName: invitation.email,
- details: { role: invitation.role },
- });
- return updated;
- }),
- /** Validate an invitation token (public — used by invite acceptance page) */
- validate: publicProcedure
- .input(z.object({ token: z.string() }))
- .query(async ({ input }) => {
- const invitation = await getInvitationByToken(input.token);
- if (!invitation) {
- return { valid: false, reason: "Invitation not found" } as const;
- }
- if (invitation.status === "revoked") {
- return { valid: false, reason: "This invitation has been revoked" } as const;
- }
- if (invitation.status === "accepted") {
- return { valid: false, reason: "This invitation has already been accepted" } as const;
- }
- if (invitation.status === "expired" || new Date() > invitation.expiresAt) {
- if (invitation.status !== "expired") {
- await updateInvitationStatus(invitation.id, "expired");
- }
- return { valid: false, reason: "This invitation has expired" } as const;
- }
- return {
- valid: true,
- email: invitation.email,
- role: invitation.role,
- invitedBy: invitation.invitedByName,
- message: invitation.message,
- expiresAt: invitation.expiresAt,
- } as const;
- }),
- /** Accept an invitation (requires authenticated user) */
- accept: protectedProcedure
- .input(z.object({ token: z.string() }))
- .mutation(async ({ input, ctx }) => {
- const invitation = await getInvitationByToken(input.token);
- if (!invitation) {
- throw new TRPCError({ code: "NOT_FOUND", message: "Invitation not found" });
- }
- if (invitation.status !== "pending") {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: `This invitation is ${invitation.status}`,
- });
- }
- if (new Date() > invitation.expiresAt) {
- await updateInvitationStatus(invitation.id, "expired");
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "This invitation has expired",
- });
- }
- // Update user role to the invited role
- await updateUserRole(ctx.user.id, invitation.role);
- // Mark invitation as accepted
- await updateInvitationStatus(invitation.id, "accepted", ctx.user.id);
- // Audit log
- await createAuditLog({
- action: "invitation_accepted",
- actorId: ctx.user.id,
- actorName: ctx.user.name || ctx.user.email || "User",
- targetName: invitation.email,
- details: { role: invitation.role, invitedBy: invitation.invitedByName },
- });
- return { success: true, role: invitation.role };
- }),
- }),
- /* ─── Audit Logs API (admin only) ─── */
- auditLogs: router({
- list: adminProcedure
- .input(z.object({ limit: z.number().min(1).max(200).optional() }).optional())
- .query(async ({ input }) => {
- return getAuditLogs(input?.limit || 50);
- }),
- }),
- /* ─── Workflow Designer API (admin only) ─── */
- workflow: router({
- save: adminProcedure
- .input(z.object({
- workflowId: z.string(),
- nodes: z.array(z.object({
- workflowId: z.string(),
- nodeId: z.string(),
- type: z.enum(["greeting", "intent", "response", "condition", "escalation", "action", "end", "customer_data", "sales_order", "guardrail"]),
- label: z.string(),
- config: z.any().optional(),
- positionX: z.number(),
- positionY: z.number(),
- })),
- edges: z.array(z.object({
- workflowId: z.string(),
- sourceNodeId: z.string(),
- targetNodeId: z.string(),
- label: z.string().optional(),
- condition: z.any().optional(),
- })),
- }))
- .mutation(async ({ input }) => {
- return saveWorkflow(input.workflowId, input.nodes, input.edges);
- }),
- load: adminProcedure
- .input(z.object({ workflowId: z.string() }))
- .query(async ({ input }) => {
- return getWorkflow(input.workflowId);
- }),
- /** Get AI-suggested nodes for a workflow */
- getSuggestions: adminProcedure
- .input(z.object({
- workflowId: z.string(),
- status: z.enum(["pending", "approved", "declined", "waiting"]).optional(),
- }))
- .query(async ({ input }) => {
- return getWorkflowSuggestions(input.workflowId, input.status);
- }),
- /** Generate AI suggestions from FAQ analysis */
- generateSuggestions: adminProcedure
- .input(z.object({ workflowId: z.string() }))
- .mutation(async ({ input }) => {
- // Analyze conversation messages to find frequently asked questions
- const db = await (await import("./db")).getDb();
- if (!db) throw new Error("Database not available");
- // Get recent visitor messages
- const recentMessages = await db.select({
- content: messages.content,
- sender: messages.sender,
- }).from(messages)
- .where(eq(messages.sender, "visitor"))
- .orderBy(desc(messages.createdAt))
- .limit(200);
- if (recentMessages.length < 3) {
- return { suggestions: [], message: "Not enough conversation data to generate suggestions. Need at least 3 visitor messages." };
- }
- // Use LLM to analyze FAQ patterns and suggest workflow nodes
- const msgSample = recentMessages.map(m => m.content).join("\n---\n");
- const llmResult = await invokeLLM({
- messages: [
- {
- role: "system",
- 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.
- Return a JSON array of suggestions. Each suggestion should have:
- - "label": A short descriptive name for the node (e.g., "Shipping ETA Lookup")
- - "description": What this node would do
- - "nodeType": One of: "response", "action", "condition", "customer_data", "sales_order", "guardrail"
- - "faqQuestion": The typical customer question this addresses
- - "frequency": Estimated frequency (1-100)
- - "config": Configuration object with relevant fields (e.g., {"message": "..."} for response, {"apiEndpoint": "..."} for action, {"blockedTopics": [...]} for guardrail)
- Return ONLY the JSON array, no markdown or explanation.`,
- },
- {
- role: "user",
- content: `Analyze these ${recentMessages.length} recent customer messages and suggest workflow nodes:\n\n${msgSample}`,
- },
- ],
- response_format: {
- type: "json_schema",
- json_schema: {
- name: "workflow_suggestions",
- strict: true,
- schema: {
- type: "object",
- properties: {
- suggestions: {
- type: "array",
- items: {
- type: "object",
- properties: {
- label: { type: "string" },
- description: { type: "string" },
- nodeType: { type: "string" },
- faqQuestion: { type: "string" },
- frequency: { type: "integer" },
- config: { type: "object", additionalProperties: true },
- },
- required: ["label", "description", "nodeType", "faqQuestion", "frequency", "config"],
- additionalProperties: false,
- },
- },
- },
- required: ["suggestions"],
- additionalProperties: false,
- },
- },
- },
- });
- let parsed: any[] = [];
- try {
- const content = llmResult.choices[0]?.message?.content as string;
- const result = JSON.parse(content);
- parsed = result.suggestions || result;
- } catch (e) {
- console.error("[Workflow] Failed to parse LLM suggestions:", e);
- return { suggestions: [], message: "Failed to analyze conversation patterns" };
- }
- // Save suggestions to database
- const toInsert = parsed.map((s: any) => ({
- workflowId: input.workflowId,
- suggestedNodeType: s.nodeType || "response",
- label: s.label,
- description: s.description,
- config: s.config || {},
- faqQuestion: s.faqQuestion,
- frequency: s.frequency || 0,
- status: "pending" as const,
- }));
- await bulkCreateWorkflowSuggestions(toInsert);
- return { suggestions: toInsert, message: `Generated ${toInsert.length} suggestions from ${recentMessages.length} messages` };
- }),
- /** Update suggestion status (approve/decline/wait) */
- reviewSuggestion: adminProcedure
- .input(z.object({
- suggestionId: z.number(),
- status: z.enum(["approved", "declined", "waiting"]),
- }))
- .mutation(async ({ input, ctx }) => {
- return updateWorkflowSuggestionStatus(input.suggestionId, input.status, ctx.user.id);
- }),
- }),
- /* ─── Analytics Router ─── */
- analytics: router({
- track: publicProcedure
- .input(z.object({
- conversationId: z.number().optional(),
- sessionId: z.string().optional(),
- eventType: z.enum([
- "session_start", "message_sent", "message_received",
- "intent_detected", "flow_triggered", "escalated",
- "resolved_by_bot", "resolved_by_agent", "abandoned",
- "button_clicked", "feedback_positive", "feedback_negative",
- ]),
- category: z.string().optional(),
- metadata: z.any().optional(),
- }))
- .mutation(async ({ input }) => {
- const id = await trackAnalyticsEvent(input);
- return { id };
- }),
- summary: agentProcedure
- .input(z.object({
- startDate: z.string().optional(),
- endDate: z.string().optional(),
- }).optional())
- .query(async ({ input }) => {
- const startDate = input?.startDate ? new Date(input.startDate) : undefined;
- const endDate = input?.endDate ? new Date(input.endDate) : undefined;
- return getAnalyticsSummary(startDate, endDate);
- }),
- events: agentProcedure
- .input(z.object({
- eventType: z.string().optional(),
- category: z.string().optional(),
- startDate: z.string().optional(),
- endDate: z.string().optional(),
- }).optional())
- .query(async ({ input }) => {
- return getAnalyticsEvents({
- eventType: input?.eventType,
- category: input?.category,
- startDate: input?.startDate ? new Date(input.startDate) : undefined,
- endDate: input?.endDate ? new Date(input.endDate) : undefined,
- });
- }),
- }),
- /* ─── Data Sources Router (Lyro-inspired) ─── */
- dataSources: router({
- list: adminProcedure.query(async () => {
- return getDataSources();
- }),
- get: adminProcedure
- .input(z.object({ id: z.number() }))
- .query(async ({ input }) => {
- return getDataSourceById(input.id);
- }),
- create: adminProcedure
- .input(z.object({
- name: z.string().min(1),
- type: z.enum(["url", "file", "qa_pair", "api"]),
- config: z.any().optional(),
- }))
- .mutation(async ({ input, ctx }) => {
- const id = await createDataSource({
- name: input.name,
- type: input.type,
- config: input.config || {},
- createdById: ctx.user.id,
- });
- return { id };
- }),
- update: adminProcedure
- .input(z.object({
- id: z.number(),
- name: z.string().optional(),
- status: z.enum(["active", "inactive", "syncing", "error"]).optional(),
- config: z.any().optional(),
- itemCount: z.number().optional(),
- }))
- .mutation(async ({ input }) => {
- const { id, ...updates } = input;
- return updateDataSource(id, updates);
- }),
- delete: adminProcedure
- .input(z.object({ id: z.number() }))
- .mutation(async ({ input }) => {
- await deleteDataSource(input.id);
- return { success: true };
- }),
- }),
- /* ─── API Connections Router (Lyro Actions) ─── */
- apiConnections: router({
- list: adminProcedure.query(async () => {
- return getApiConnections();
- }),
- get: adminProcedure
- .input(z.object({ id: z.number() }))
- .query(async ({ input }) => {
- return getApiConnectionById(input.id);
- }),
- create: adminProcedure
- .input(z.object({
- name: z.string().min(1),
- description: z.string().optional(),
- category: z.string().optional(),
- method: z.enum(["GET", "POST", "PUT", "DELETE"]),
- endpoint: z.string().min(1),
- headers: z.any().optional(),
- inputVariables: z.any().optional(),
- outputVariables: z.any().optional(),
- testPayload: z.any().optional(),
- }))
- .mutation(async ({ input, ctx }) => {
- const id = await createApiConnection({
- ...input,
- createdById: ctx.user.id,
- });
- return { id };
- }),
- update: adminProcedure
- .input(z.object({
- id: z.number(),
- name: z.string().optional(),
- description: z.string().optional(),
- category: z.string().optional(),
- method: z.enum(["GET", "POST", "PUT", "DELETE"]).optional(),
- endpoint: z.string().optional(),
- headers: z.any().optional(),
- inputVariables: z.any().optional(),
- outputVariables: z.any().optional(),
- testPayload: z.any().optional(),
- isActive: z.boolean().optional(),
- }))
- .mutation(async ({ input }) => {
- const { id, ...updates } = input;
- return updateApiConnection(id, updates);
- }),
- delete: adminProcedure
- .input(z.object({ id: z.number() }))
- .mutation(async ({ input }) => {
- await deleteApiConnection(input.id);
- return { success: true };
- }),
- test: adminProcedure
- .input(z.object({ id: z.number() }))
- .mutation(async ({ input }) => {
- const conn = await getApiConnectionById(input.id);
- if (!conn) throw new TRPCError({ code: "NOT_FOUND", message: "API connection not found" });
- try {
- // Simulate a test call (in production, this would make the actual HTTP request)
- await incrementApiConnectionExecution(input.id);
- return {
- success: true,
- message: `Test successful for ${conn.name}`,
- responseTime: Math.floor(Math.random() * 500) + 100, // Simulated
- };
- } catch (err: any) {
- return { success: false, message: err.message, responseTime: 0 };
- }
- }),
- }),
- /* ─── Knowledge Management Router ─── */
- knowledge: router({
- // Q&A Entries
- listEntries: adminProcedure
- .input(z.object({ status: z.string().optional() }).optional())
- .query(async ({ input }) => getKnowledgeEntries(input?.status)),
- getEntry: adminProcedure
- .input(z.object({ id: z.number() }))
- .query(async ({ input }) => getKnowledgeEntryById(input.id)),
- createEntry: adminProcedure
- .input(z.object({
- question: z.string().min(1),
- answer: z.string().min(1),
- category: z.string().optional(),
- }))
- .mutation(async ({ input }) => {
- const id = await createKnowledgeEntry({ ...input, source: "manual" });
- return { id };
- }),
- updateEntry: adminProcedure
- .input(z.object({
- id: z.number(),
- question: z.string().optional(),
- answer: z.string().optional(),
- category: z.string().optional(),
- status: z.enum(["active", "inactive"]).optional(),
- }))
- .mutation(async ({ input }) => {
- const { id, ...data } = input;
- await updateKnowledgeEntry(id, data);
- return { success: true };
- }),
- deleteEntry: adminProcedure
- .input(z.object({ id: z.number() }))
- .mutation(async ({ input }) => {
- await deleteKnowledgeEntry(input.id);
- return { success: true };
- }),
- importEntries: adminProcedure
- .input(z.object({
- entries: z.array(z.object({
- question: z.string().min(1),
- answer: z.string().min(1),
- category: z.string().optional(),
- })),
- source: z.string().default("csv"),
- }))
- .mutation(async ({ input }) => {
- return bulkCreateKnowledgeEntries(input.entries.map(e => ({ ...e, source: input.source })));
- }),
- // Suggestions
- listSuggestions: adminProcedure
- .input(z.object({ status: z.string().optional() }).optional())
- .query(async ({ input }) => getKnowledgeSuggestions(input?.status)),
- promoteSuggestion: adminProcedure
- .input(z.object({
- id: z.number(),
- answer: z.string().min(1),
- category: z.string().optional(),
- }))
- .mutation(async ({ input }) => {
- const entryId = await promoteKnowledgeSuggestion(input.id, input.answer, input.category);
- return { entryId };
- }),
- dismissSuggestion: adminProcedure
- .input(z.object({ id: z.number() }))
- .mutation(async ({ input }) => {
- await dismissKnowledgeSuggestion(input.id);
- return { success: true };
- }),
- // Products
- listProducts: adminProcedure.query(getKnowledgeProducts),
- importProducts: adminProcedure
- .input(z.object({
- products: z.array(z.object({
- model: z.string(),
- description: z.string().optional(),
- categories: z.string().optional(),
- collection: z.string().optional(),
- price: z.string().optional(),
- availability: z.string().optional(),
- features: z.string().optional(),
- dimensions: z.string().optional(),
- imageUrl: z.string().optional(),
- })),
- replaceAll: z.boolean().default(false),
- }))
- .mutation(async ({ input }) => {
- if (input.replaceAll) await deleteAllKnowledgeProducts();
- return bulkCreateKnowledgeProducts(input.products);
- }),
- }),
- });
- export type AppRouter = typeof appRouter;
|