db.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
  1. import { eq, desc, asc, and, sql, like, or, lt, gte, lte, isNotNull, inArray } from "drizzle-orm";
  2. import { drizzle } from "drizzle-orm/postgres-js";
  3. import {
  4. InsertUser, users,
  5. conversations, InsertConversation, Conversation,
  6. messages, InsertMessage,
  7. workflowNodes, InsertWorkflowNode,
  8. workflowEdges, InsertWorkflowEdge,
  9. workflowSuggestions, InsertWorkflowSuggestion,
  10. invitations, InsertInvitation,
  11. auditLogs, InsertAuditLog,
  12. passwordResetTokens, InsertPasswordResetToken,
  13. analyticsEvents, InsertAnalyticsEvent,
  14. dataSources, InsertDataSource,
  15. apiConnections, InsertApiConnection,
  16. knowledgeEntries, InsertKnowledgeEntry,
  17. knowledgeSuggestions, InsertKnowledgeSuggestion,
  18. knowledgeProducts, InsertKnowledgeProduct,
  19. } from "../drizzle/schema";
  20. import { ENV } from './_core/env';
  21. let _db: ReturnType<typeof drizzle> | null = null;
  22. export async function getDb() {
  23. if (!_db && process.env.DATABASE_URL) {
  24. try {
  25. // Set search_path so unqualified queries resolve to the chatbot schema
  26. _db = drizzle(process.env.DATABASE_URL, {
  27. connection: { options: "-c search_path=chatbot,public" },
  28. });
  29. } catch (error) {
  30. console.warn("[Database] Failed to connect:", error);
  31. _db = null;
  32. }
  33. }
  34. return _db;
  35. }
  36. /* ─── User helpers ─── */
  37. export async function upsertUser(user: InsertUser): Promise<void> {
  38. if (!user.openId) {
  39. throw new Error("User openId is required for upsert");
  40. }
  41. const db = await getDb();
  42. if (!db) { console.warn("[Database] Cannot upsert user: database not available"); return; }
  43. try {
  44. const values: InsertUser = { openId: user.openId };
  45. const updateSet: Record<string, unknown> = {};
  46. const textFields = ["name", "email", "loginMethod"] as const;
  47. type TextField = (typeof textFields)[number];
  48. const assignNullable = (field: TextField) => {
  49. const value = user[field];
  50. if (value === undefined) return;
  51. const normalized = value ?? null;
  52. values[field] = normalized;
  53. updateSet[field] = normalized;
  54. };
  55. textFields.forEach(assignNullable);
  56. if (user.lastSignedIn !== undefined) { values.lastSignedIn = user.lastSignedIn; updateSet.lastSignedIn = user.lastSignedIn; }
  57. if (user.role !== undefined) { values.role = user.role; updateSet.role = user.role; }
  58. else if (user.openId === ENV.ownerOpenId) { values.role = 'admin'; updateSet.role = 'admin'; }
  59. if (!values.lastSignedIn) { values.lastSignedIn = new Date(); }
  60. if (Object.keys(updateSet).length === 0) { updateSet.lastSignedIn = new Date(); }
  61. await db.insert(users).values(values).onConflictDoUpdate({ target: users.openId, set: updateSet });
  62. } catch (error) { console.error("[Database] Failed to upsert user:", error); throw error; }
  63. }
  64. export async function getUserByOpenId(openId: string) {
  65. const db = await getDb();
  66. if (!db) { console.warn("[Database] Cannot get user: database not available"); return undefined; }
  67. const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
  68. return result.length > 0 ? result[0] : undefined;
  69. }
  70. /* ─── User management helpers ─── */
  71. export async function getAllUsers() {
  72. const db = await getDb();
  73. if (!db) return [];
  74. return db.select({
  75. id: users.id,
  76. openId: users.openId,
  77. name: users.name,
  78. email: users.email,
  79. role: users.role,
  80. erpContactCid: users.erpContactCid,
  81. createdAt: users.createdAt,
  82. lastSignedIn: users.lastSignedIn,
  83. }).from(users).orderBy(desc(users.lastSignedIn));
  84. }
  85. export async function updateUserRole(userId: number, role: "user" | "agent" | "admin") {
  86. const db = await getDb();
  87. if (!db) throw new Error("Database not available");
  88. await db.update(users).set({ role }).where(eq(users.id, userId));
  89. const result = await db.select().from(users).where(eq(users.id, userId)).limit(1);
  90. return result[0];
  91. }
  92. export async function updateUserErpContactCid(userId: number, erpContactCid: string | null) {
  93. const db = await getDb();
  94. if (!db) throw new Error("Database not available");
  95. await db.update(users).set({ erpContactCid }).where(eq(users.id, userId));
  96. const result = await db.select().from(users).where(eq(users.id, userId)).limit(1);
  97. return result[0];
  98. }
  99. export async function getUserById(userId: number) {
  100. const db = await getDb();
  101. if (!db) return undefined;
  102. const result = await db.select().from(users).where(eq(users.id, userId)).limit(1);
  103. return result[0];
  104. }
  105. export async function deleteUser(userId: number) {
  106. const db = await getDb();
  107. if (!db) throw new Error("Database not available");
  108. // Get user before deleting
  109. const user = await getUserById(userId);
  110. if (!user) return null;
  111. await db.delete(users).where(eq(users.id, userId));
  112. return user;
  113. }
  114. export async function getUserByEmail(email: string) {
  115. const db = await getDb();
  116. if (!db) return undefined;
  117. const result = await db.select().from(users).where(eq(users.email, email)).limit(1);
  118. return result[0];
  119. }
  120. export async function getUserByEmailWithPassword(email: string) {
  121. const db = await getDb();
  122. if (!db) return undefined;
  123. const result = await db.select().from(users).where(eq(users.email, email)).limit(1);
  124. return result[0]; // includes passwordHash
  125. }
  126. export async function createUserWithPassword(data: {
  127. email: string;
  128. name: string;
  129. passwordHash: string;
  130. role?: "user" | "agent" | "admin";
  131. }) {
  132. const db = await getDb();
  133. if (!db) throw new Error("Database not available");
  134. const openId = `local_${data.email}`; // local users get a synthetic openId
  135. await db.insert(users).values({
  136. openId,
  137. email: data.email,
  138. name: data.name,
  139. passwordHash: data.passwordHash,
  140. loginMethod: "email",
  141. role: data.role || "user",
  142. lastSignedIn: new Date(),
  143. });
  144. const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
  145. return result[0];
  146. }
  147. export async function updateUserPassword(userId: number, passwordHash: string) {
  148. const db = await getDb();
  149. if (!db) throw new Error("Database not available");
  150. await db.update(users).set({ passwordHash }).where(eq(users.id, userId));
  151. }
  152. /* ─── Password reset token helpers ─── */
  153. export async function createPasswordResetToken(data: InsertPasswordResetToken) {
  154. const db = await getDb();
  155. if (!db) throw new Error("Database not available");
  156. await db.insert(passwordResetTokens).values(data);
  157. const result = await db.select().from(passwordResetTokens).where(eq(passwordResetTokens.token, data.token)).limit(1);
  158. return result[0];
  159. }
  160. export async function getPasswordResetToken(token: string) {
  161. const db = await getDb();
  162. if (!db) return undefined;
  163. const result = await db.select().from(passwordResetTokens).where(eq(passwordResetTokens.token, token)).limit(1);
  164. return result[0];
  165. }
  166. export async function markPasswordResetTokenUsed(tokenId: number) {
  167. const db = await getDb();
  168. if (!db) throw new Error("Database not available");
  169. await db.update(passwordResetTokens).set({ usedAt: new Date() }).where(eq(passwordResetTokens.id, tokenId));
  170. }
  171. /* ─── Invitation helpers ─── */
  172. export async function createInvitation(data: InsertInvitation) {
  173. const db = await getDb();
  174. if (!db) throw new Error("Database not available");
  175. await db.insert(invitations).values(data);
  176. const result = await db.select().from(invitations).where(eq(invitations.token, data.token)).limit(1);
  177. return result[0];
  178. }
  179. export async function getAllInvitations() {
  180. const db = await getDb();
  181. if (!db) return [];
  182. return db.select().from(invitations).orderBy(desc(invitations.createdAt));
  183. }
  184. export async function getInvitationByToken(token: string) {
  185. const db = await getDb();
  186. if (!db) return undefined;
  187. const result = await db.select().from(invitations).where(eq(invitations.token, token)).limit(1);
  188. return result[0];
  189. }
  190. export async function getInvitationByEmail(email: string) {
  191. const db = await getDb();
  192. if (!db) return [];
  193. return db.select().from(invitations)
  194. .where(eq(invitations.email, email))
  195. .orderBy(desc(invitations.createdAt));
  196. }
  197. export async function updateInvitationStatus(
  198. id: number,
  199. status: "pending" | "accepted" | "expired" | "revoked",
  200. acceptedByUserId?: number,
  201. ) {
  202. const db = await getDb();
  203. if (!db) throw new Error("Database not available");
  204. const updateData: Record<string, unknown> = { status };
  205. if (status === "accepted") {
  206. updateData.acceptedAt = new Date();
  207. if (acceptedByUserId) updateData.acceptedByUserId = acceptedByUserId;
  208. }
  209. await db.update(invitations).set(updateData).where(eq(invitations.id, id));
  210. const result = await db.select().from(invitations).where(eq(invitations.id, id)).limit(1);
  211. return result[0];
  212. }
  213. export async function expireOldInvitations() {
  214. const db = await getDb();
  215. if (!db) return 0;
  216. const result = await db.update(invitations)
  217. .set({ status: "expired" })
  218. .where(
  219. and(
  220. eq(invitations.status, "pending"),
  221. lt(invitations.expiresAt, new Date()),
  222. )
  223. );
  224. return 0; // PostgreSQL: affected row count not available without RETURNING
  225. }
  226. /* ─── Audit log helpers ─── */
  227. export async function createAuditLog(data: InsertAuditLog) {
  228. const db = await getDb();
  229. if (!db) { console.warn("[Database] Cannot create audit log: database not available"); return; }
  230. await db.insert(auditLogs).values(data);
  231. }
  232. export async function getAuditLogs(limit = 50) {
  233. const db = await getDb();
  234. if (!db) return [];
  235. return db.select().from(auditLogs).orderBy(desc(auditLogs.createdAt)).limit(limit);
  236. }
  237. /* ─── Conversation helpers ─── */
  238. export async function createConversation(data: InsertConversation) {
  239. const db = await getDb();
  240. if (!db) throw new Error("Database not available");
  241. await db.insert(conversations).values(data);
  242. const result = await db.select().from(conversations).where(eq(conversations.sessionId, data.sessionId)).limit(1);
  243. return result[0];
  244. }
  245. export async function getConversations(status?: string) {
  246. const db = await getDb();
  247. if (!db) return [];
  248. if (status) {
  249. return db.select().from(conversations)
  250. .where(eq(conversations.status, status as any))
  251. .orderBy(desc(conversations.updatedAt));
  252. }
  253. return db.select().from(conversations).orderBy(desc(conversations.updatedAt));
  254. }
  255. /** Advanced conversation query with pagination, search, agent filter, date range, and sorting */
  256. export async function getConversationsAdvanced(params: {
  257. page?: number;
  258. pageSize?: number;
  259. status?: string;
  260. search?: string;
  261. agentId?: number;
  262. dateFrom?: string;
  263. dateTo?: string;
  264. sortBy?: string;
  265. sortOrder?: "asc" | "desc";
  266. }) {
  267. const db = await getDb();
  268. if (!db) return { conversations: [], total: 0, page: 1, pageSize: 20, totalPages: 0 };
  269. const page = params.page || 1;
  270. const pageSize = params.pageSize || 20;
  271. const offset = (page - 1) * pageSize;
  272. // Build conditions
  273. const conditions = [];
  274. if (params.status) {
  275. conditions.push(eq(conversations.status, params.status as any));
  276. }
  277. if (params.agentId) {
  278. conditions.push(eq(conversations.assignedAgentId, params.agentId));
  279. }
  280. if (params.search) {
  281. const searchTerm = `%${params.search}%`;
  282. conditions.push(
  283. or(
  284. like(conversations.visitorName, searchTerm),
  285. like(conversations.visitorEmail, searchTerm),
  286. like(conversations.sessionId, searchTerm),
  287. like(conversations.customerId, searchTerm),
  288. like(conversations.salesRep, searchTerm),
  289. )!
  290. );
  291. }
  292. if (params.dateFrom) {
  293. conditions.push(gte(conversations.createdAt, new Date(params.dateFrom)));
  294. }
  295. if (params.dateTo) {
  296. const endDate = new Date(params.dateTo);
  297. endDate.setHours(23, 59, 59, 999);
  298. conditions.push(lte(conversations.createdAt, endDate));
  299. }
  300. const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
  301. // Sort
  302. const sortFn = params.sortOrder === "asc" ? asc : desc;
  303. let orderByCol;
  304. switch (params.sortBy) {
  305. case "created": orderByCol = sortFn(conversations.createdAt); break;
  306. case "visitor": orderByCol = sortFn(conversations.visitorName); break;
  307. case "status": orderByCol = sortFn(conversations.status); break;
  308. case "customerId": orderByCol = sortFn(conversations.customerId); break;
  309. case "salesRep": orderByCol = sortFn(conversations.salesRep); break;
  310. case "agent": orderByCol = sortFn(users.name); break;
  311. default: orderByCol = sortFn(conversations.updatedAt); break;
  312. }
  313. // Count
  314. const countResult = whereClause
  315. ? await db.select({ count: sql<number>`COUNT(*)` }).from(conversations).where(whereClause)
  316. : await db.select({ count: sql<number>`COUNT(*)` }).from(conversations);
  317. const total = Number(countResult[0]?.count || 0);
  318. const totalPages = Math.ceil(total / pageSize);
  319. // Fetch page with agent name join
  320. const baseQuery = db.select({
  321. id: conversations.id,
  322. sessionId: conversations.sessionId,
  323. visitorName: conversations.visitorName,
  324. visitorEmail: conversations.visitorEmail,
  325. customerId: conversations.customerId,
  326. salesRep: conversations.salesRep,
  327. status: conversations.status,
  328. assignedAgentId: conversations.assignedAgentId,
  329. metadata: conversations.metadata,
  330. createdAt: conversations.createdAt,
  331. updatedAt: conversations.updatedAt,
  332. agentName: users.name,
  333. })
  334. .from(conversations)
  335. .leftJoin(users, eq(conversations.assignedAgentId, users.id));
  336. const rows = whereClause
  337. ? await baseQuery.where(whereClause).orderBy(orderByCol).limit(pageSize).offset(offset)
  338. : await baseQuery.orderBy(orderByCol).limit(pageSize).offset(offset);
  339. return { conversations: rows, total, page, pageSize, totalPages };
  340. }
  341. /** Get all agents (users with agent or admin role) for filter dropdown */
  342. export async function getAgentUsers() {
  343. const db = await getDb();
  344. if (!db) return [];
  345. return db.select({
  346. id: users.id,
  347. name: users.name,
  348. email: users.email,
  349. role: users.role,
  350. }).from(users).where(
  351. or(
  352. eq(users.role, "agent"),
  353. eq(users.role, "admin"),
  354. )!
  355. ).orderBy(users.name);
  356. }
  357. /** Get message count per conversation */
  358. export async function getConversationMessageCounts(conversationIds: number[]) {
  359. const db = await getDb();
  360. if (!db || conversationIds.length === 0) return {};
  361. const result = await db.select({
  362. conversationId: messages.conversationId,
  363. count: sql<number>`COUNT(*)`,
  364. }).from(messages)
  365. .where(sql`${messages.conversationId} IN (${sql.join(conversationIds.map(id => sql`${id}`), sql`, `)})`)
  366. .groupBy(messages.conversationId);
  367. const counts: Record<number, number> = {};
  368. for (const row of result) {
  369. counts[row.conversationId] = Number(row.count);
  370. }
  371. return counts;
  372. }
  373. /** Bulk update conversation status */
  374. export async function bulkUpdateConversationStatus(
  375. ids: number[],
  376. status: "active" | "escalated" | "resolved" | "closed",
  377. agentId?: number
  378. ) {
  379. const db = await getDb();
  380. if (!db) throw new Error("Database not available");
  381. if (ids.length === 0) return { updated: 0 };
  382. const updateData: Record<string, unknown> = { status };
  383. if (agentId !== undefined) updateData.assignedAgentId = agentId;
  384. await db.update(conversations).set(updateData).where(inArray(conversations.id, ids));
  385. return { updated: ids.length };
  386. }
  387. /** Delete conversations */
  388. export async function deleteConversations(ids: number[]) {
  389. const db = await getDb();
  390. if (!db) throw new Error("Database not available");
  391. if (ids.length === 0) return { deleted: 0 };
  392. // Delete messages first, then conversations — both in a single statement each
  393. await db.delete(messages).where(inArray(messages.conversationId, ids));
  394. await db.delete(conversations).where(inArray(conversations.id, ids));
  395. return { deleted: ids.length };
  396. }
  397. export async function getConversationById(id: number) {
  398. const db = await getDb();
  399. if (!db) return undefined;
  400. const result = await db.select().from(conversations).where(eq(conversations.id, id)).limit(1);
  401. return result[0];
  402. }
  403. export async function getConversationBySessionId(sessionId: string) {
  404. const db = await getDb();
  405. if (!db) return undefined;
  406. const result = await db.select().from(conversations).where(eq(conversations.sessionId, sessionId)).limit(1);
  407. return result[0];
  408. }
  409. export async function updateConversationStatus(id: number, status: "active" | "escalated" | "resolved" | "closed", agentId?: number) {
  410. const db = await getDb();
  411. if (!db) throw new Error("Database not available");
  412. const updateData: Record<string, unknown> = { status };
  413. if (agentId !== undefined) updateData.assignedAgentId = agentId;
  414. // Record when a conversation is escalated for response-time analytics
  415. if (status === "escalated") updateData.escalatedAt = new Date();
  416. await db.update(conversations).set(updateData).where(eq(conversations.id, id));
  417. return getConversationById(id);
  418. }
  419. export async function rateConversation(sessionId: string, rating: number, comment?: string) {
  420. const db = await getDb();
  421. if (!db) throw new Error("Database not available");
  422. await db.update(conversations)
  423. .set({ csatRating: rating, csatComment: comment ?? null })
  424. .where(eq(conversations.sessionId, sessionId));
  425. return { success: true };
  426. }
  427. export async function getConversationStats() {
  428. const db = await getDb();
  429. if (!db) return { total: 0, active: 0, escalated: 0, resolved: 0, closed: 0 };
  430. const result = await db.select({
  431. status: conversations.status,
  432. count: sql<number>`COUNT(*)`,
  433. }).from(conversations).groupBy(conversations.status);
  434. const stats = { total: 0, active: 0, escalated: 0, resolved: 0, closed: 0 };
  435. for (const row of result) {
  436. const count = Number(row.count);
  437. stats[row.status as keyof typeof stats] = count;
  438. stats.total += count;
  439. }
  440. return stats;
  441. }
  442. /* ─── Message helpers ─── */
  443. export async function addMessage(data: InsertMessage) {
  444. const db = await getDb();
  445. if (!db) throw new Error("Database not available");
  446. const [inserted] = await db.insert(messages).values(data).returning({ id: messages.id });
  447. const convUpdate: Record<string, unknown> = { updatedAt: new Date() };
  448. // Record first agent reply timestamp for response-time analytics (COALESCE — set only once)
  449. if (data.sender === "agent") {
  450. convUpdate.firstAgentReplyAt = sql`COALESCE("first_agent_reply_at", NOW())`;
  451. }
  452. await db.update(conversations).set(convUpdate).where(eq(conversations.id, data.conversationId));
  453. return { id: inserted.id, ...data };
  454. }
  455. export async function getMessagesByConversation(conversationId: number) {
  456. const db = await getDb();
  457. if (!db) return [];
  458. return db.select().from(messages)
  459. .where(eq(messages.conversationId, conversationId))
  460. .orderBy(messages.createdAt);
  461. }
  462. /* ─── Workflow helpers ─── */
  463. export async function saveWorkflow(workflowId: string, nodes: InsertWorkflowNode[], edges: InsertWorkflowEdge[]) {
  464. const db = await getDb();
  465. if (!db) throw new Error("Database not available");
  466. await db.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
  467. await db.delete(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
  468. if (nodes.length > 0) await db.insert(workflowNodes).values(nodes);
  469. if (edges.length > 0) await db.insert(workflowEdges).values(edges);
  470. return { workflowId, nodeCount: nodes.length, edgeCount: edges.length };
  471. }
  472. export async function getWorkflow(workflowId: string) {
  473. const db = await getDb();
  474. if (!db) return { nodes: [], edges: [] };
  475. const nodes = await db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
  476. const edges = await db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
  477. return { nodes, edges };
  478. }
  479. /* ─── Workflow suggestion helpers ─── */
  480. export async function createWorkflowSuggestion(data: InsertWorkflowSuggestion) {
  481. const db = await getDb();
  482. if (!db) throw new Error("Database not available");
  483. await db.insert(workflowSuggestions).values(data);
  484. return data;
  485. }
  486. export async function getWorkflowSuggestions(workflowId: string, status?: string) {
  487. const db = await getDb();
  488. if (!db) return [];
  489. const conditions = [eq(workflowSuggestions.workflowId, workflowId)];
  490. if (status) conditions.push(eq(workflowSuggestions.status, status as any));
  491. return db.select().from(workflowSuggestions).where(and(...conditions)).orderBy(desc(workflowSuggestions.frequency));
  492. }
  493. export async function updateWorkflowSuggestionStatus(id: number, status: "approved" | "declined" | "waiting", reviewedById: number) {
  494. const db = await getDb();
  495. if (!db) throw new Error("Database not available");
  496. await db.update(workflowSuggestions)
  497. .set({ status, reviewedById, reviewedAt: new Date() })
  498. .where(eq(workflowSuggestions.id, id));
  499. return { id, status };
  500. }
  501. export async function bulkCreateWorkflowSuggestions(suggestions: InsertWorkflowSuggestion[]) {
  502. const db = await getDb();
  503. if (!db) throw new Error("Database not available");
  504. if (suggestions.length === 0) return { created: 0 };
  505. await db.insert(workflowSuggestions).values(suggestions);
  506. return { created: suggestions.length };
  507. }
  508. /* ─── Analytics helpers ─── */
  509. export async function trackAnalyticsEvent(event: Omit<InsertAnalyticsEvent, "id" | "createdAt">) {
  510. const db = await getDb();
  511. if (!db) return null;
  512. const [inserted] = await db.insert(analyticsEvents).values(event as any).returning({ id: analyticsEvents.id });
  513. return inserted?.id ?? null;
  514. }
  515. export async function getAnalyticsEvents(filters?: {
  516. eventType?: string;
  517. category?: string;
  518. startDate?: Date;
  519. endDate?: Date;
  520. }) {
  521. const db = await getDb();
  522. if (!db) return [];
  523. const conditions: any[] = [];
  524. if (filters?.eventType) conditions.push(eq(analyticsEvents.eventType, filters.eventType as any));
  525. if (filters?.category) conditions.push(eq(analyticsEvents.category, filters.category));
  526. if (filters?.startDate) conditions.push(gte(analyticsEvents.createdAt, filters.startDate));
  527. if (filters?.endDate) conditions.push(lte(analyticsEvents.createdAt, filters.endDate));
  528. const query = db.select().from(analyticsEvents);
  529. if (conditions.length > 0) {
  530. return query.where(and(...conditions)).orderBy(desc(analyticsEvents.createdAt)).limit(1000);
  531. }
  532. return query.orderBy(desc(analyticsEvents.createdAt)).limit(1000);
  533. }
  534. export async function getAnalyticsSummary(startDate?: Date, endDate?: Date) {
  535. const db = await getDb();
  536. if (!db) return null;
  537. const conditions: any[] = [];
  538. if (startDate) conditions.push(gte(analyticsEvents.createdAt, startDate));
  539. if (endDate) conditions.push(lte(analyticsEvents.createdAt, endDate));
  540. const baseQuery = conditions.length > 0
  541. ? db.select().from(analyticsEvents).where(and(...conditions))
  542. : db.select().from(analyticsEvents);
  543. const allEvents = await baseQuery;
  544. const totalSessions = allEvents.filter(e => e.eventType === "session_start").length;
  545. const resolvedByBot = allEvents.filter(e => e.eventType === "resolved_by_bot").length;
  546. const resolvedByAgent = allEvents.filter(e => e.eventType === "resolved_by_agent").length;
  547. const escalated = allEvents.filter(e => e.eventType === "escalated").length;
  548. const abandoned = allEvents.filter(e => e.eventType === "abandoned").length;
  549. const messagesSent = allEvents.filter(e => e.eventType === "message_sent").length;
  550. const messagesReceived = allEvents.filter(e => e.eventType === "message_received").length;
  551. const buttonClicks = allEvents.filter(e => e.eventType === "button_clicked").length;
  552. const positiveFeedback = allEvents.filter(e => e.eventType === "feedback_positive").length;
  553. const negativeFeedback = allEvents.filter(e => e.eventType === "feedback_negative").length;
  554. // Category breakdown
  555. const categories = ["orders", "shipping", "returning", "cancelling"];
  556. const categoryBreakdown = categories.map(cat => ({
  557. category: cat,
  558. count: allEvents.filter(e => e.category === cat).length,
  559. resolved: allEvents.filter(e => e.category === cat && (e.eventType === "resolved_by_bot" || e.eventType === "resolved_by_agent")).length,
  560. }));
  561. return {
  562. totalSessions,
  563. resolvedByBot,
  564. resolvedByAgent,
  565. escalated,
  566. abandoned,
  567. messagesSent,
  568. messagesReceived,
  569. buttonClicks,
  570. positiveFeedback,
  571. negativeFeedback,
  572. resolutionRate: totalSessions > 0 ? Math.round(((resolvedByBot + resolvedByAgent) / totalSessions) * 100) : 0,
  573. botResolutionRate: totalSessions > 0 ? Math.round((resolvedByBot / totalSessions) * 100) : 0,
  574. categoryBreakdown,
  575. totalEvents: allEvents.length,
  576. };
  577. }
  578. export async function getIntentStats(startDate?: Date, endDate?: Date) {
  579. const db = await getDb();
  580. if (!db) return [];
  581. const conditions: any[] = [eq(analyticsEvents.eventType, "intent_detected" as any)];
  582. if (startDate) conditions.push(gte(analyticsEvents.createdAt, startDate));
  583. if (endDate) conditions.push(lte(analyticsEvents.createdAt, endDate));
  584. const rows = await db.select({
  585. category: analyticsEvents.category,
  586. count: sql<number>`COUNT(*)`,
  587. })
  588. .from(analyticsEvents)
  589. .where(and(...conditions))
  590. .groupBy(analyticsEvents.category)
  591. .orderBy(sql`COUNT(*) DESC`)
  592. .limit(20);
  593. return rows.map(r => ({ category: r.category ?? "unclassified", count: Number(r.count) }));
  594. }
  595. export async function getResponseTimeStats(startDate?: Date, endDate?: Date) {
  596. const db = await getDb();
  597. if (!db) return { avgSeconds: null, p50Seconds: null, sampleSize: 0 };
  598. const conditions: any[] = [
  599. isNotNull(conversations.escalatedAt),
  600. isNotNull(conversations.firstAgentReplyAt),
  601. ];
  602. if (startDate) conditions.push(gte(conversations.createdAt, startDate));
  603. if (endDate) conditions.push(lte(conversations.createdAt, endDate));
  604. const rows = await db.select({
  605. seconds: sql<number>`EXTRACT(EPOCH FROM ("first_agent_reply_at" - "escalated_at"))`,
  606. })
  607. .from(conversations)
  608. .where(and(...conditions));
  609. if (!rows.length) return { avgSeconds: null, p50Seconds: null, sampleSize: 0 };
  610. const times = rows.map(r => Number(r.seconds)).filter(n => n >= 0).sort((a, b) => a - b);
  611. const avg = times.reduce((s, v) => s + v, 0) / times.length;
  612. const p50 = times[Math.floor(times.length * 0.5)];
  613. return { avgSeconds: Math.round(avg), p50Seconds: Math.round(p50), sampleSize: times.length };
  614. }
  615. /* ─── Data Sources helpers ─── */
  616. export async function createDataSource(source: Omit<InsertDataSource, "id" | "createdAt" | "updatedAt">) {
  617. const db = await getDb();
  618. if (!db) return null;
  619. const [inserted] = await db.insert(dataSources).values(source as any).returning({ id: dataSources.id });
  620. return inserted?.id ?? null;
  621. }
  622. export async function getDataSources() {
  623. const db = await getDb();
  624. if (!db) return [];
  625. return db.select().from(dataSources).orderBy(desc(dataSources.createdAt));
  626. }
  627. export async function getDataSourceById(id: number) {
  628. const db = await getDb();
  629. if (!db) return null;
  630. const [source] = await db.select().from(dataSources).where(eq(dataSources.id, id));
  631. return source || null;
  632. }
  633. export async function updateDataSource(id: number, updates: Partial<InsertDataSource>) {
  634. const db = await getDb();
  635. if (!db) return null;
  636. await db.update(dataSources).set(updates as any).where(eq(dataSources.id, id));
  637. return getDataSourceById(id);
  638. }
  639. export async function deleteDataSource(id: number) {
  640. const db = await getDb();
  641. if (!db) return;
  642. await db.delete(dataSources).where(eq(dataSources.id, id));
  643. }
  644. /* ─── API Connections helpers ─── */
  645. export async function createApiConnection(conn: Omit<InsertApiConnection, "id" | "createdAt" | "updatedAt">) {
  646. const db = await getDb();
  647. if (!db) return null;
  648. const [inserted] = await db.insert(apiConnections).values(conn as any).returning({ id: apiConnections.id });
  649. return inserted?.id ?? null;
  650. }
  651. export async function getApiConnections() {
  652. const db = await getDb();
  653. if (!db) return [];
  654. return db.select().from(apiConnections).orderBy(desc(apiConnections.createdAt));
  655. }
  656. export async function getApiConnectionById(id: number) {
  657. const db = await getDb();
  658. if (!db) return null;
  659. const [conn] = await db.select().from(apiConnections).where(eq(apiConnections.id, id));
  660. return conn || null;
  661. }
  662. export async function updateApiConnection(id: number, updates: Partial<InsertApiConnection>) {
  663. const db = await getDb();
  664. if (!db) return null;
  665. await db.update(apiConnections).set(updates as any).where(eq(apiConnections.id, id));
  666. return getApiConnectionById(id);
  667. }
  668. export async function deleteApiConnection(id: number) {
  669. const db = await getDb();
  670. if (!db) return;
  671. await db.delete(apiConnections).where(eq(apiConnections.id, id));
  672. }
  673. export async function incrementApiConnectionExecution(id: number) {
  674. const db = await getDb();
  675. if (!db) return;
  676. const conn = await getApiConnectionById(id);
  677. if (conn) {
  678. await db.update(apiConnections).set({
  679. executionCount: conn.executionCount + 1,
  680. lastExecutedAt: new Date(),
  681. } as any).where(eq(apiConnections.id, id));
  682. }
  683. }
  684. /* ─── Knowledge Base helpers ─────────────────────────────── */
  685. /**
  686. * Simple keyword-based knowledge search.
  687. * Splits the user question into words, filters stop-words, searches
  688. * question text case-insensitively. Returns the best match or null.
  689. */
  690. export async function searchKnowledge(userQuestion: string): Promise<{ id: number; question: string; answer: string; category: string | null } | null> {
  691. const db = await getDb();
  692. if (!db) return null;
  693. const entries = await db
  694. .select({ id: knowledgeEntries.id, question: knowledgeEntries.question, answer: knowledgeEntries.answer, category: knowledgeEntries.category })
  695. .from(knowledgeEntries)
  696. .where(eq(knowledgeEntries.status, "active"))
  697. .limit(1000); // safety cap — migrate to DB full-text search when KB exceeds this
  698. if (!entries.length) return null;
  699. const stopWords = new Set(["the","a","an","is","are","do","you","have","i","can","tell","me","about","how","what","when","where","why","which","my","your","our"]);
  700. const queryWords = userQuestion.toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter(w => w.length > 2 && !stopWords.has(w));
  701. if (!queryWords.length) return null;
  702. let best: { entry: typeof entries[0]; score: number } | null = null;
  703. for (const entry of entries) {
  704. const text = entry.question.toLowerCase();
  705. let score = 0;
  706. for (const word of queryWords) {
  707. if (text.includes(word)) score++;
  708. }
  709. if (score > 0 && (!best || score > best.score)) {
  710. best = { entry, score };
  711. }
  712. }
  713. if (!best) return null;
  714. const threshold = queryWords.some(w => w.length > 5) ? 1 : 2;
  715. if (best.score < threshold) return null;
  716. return best.entry;
  717. }
  718. export async function incrementKnowledgeUseCount(id: number) {
  719. const db = await getDb();
  720. if (!db) return;
  721. await db.update(knowledgeEntries)
  722. .set({ useCount: sql`${knowledgeEntries.useCount} + 1`, updatedAt: new Date() })
  723. .where(eq(knowledgeEntries.id, id));
  724. }
  725. /**
  726. * Log an unanswered question as a Suggestion.
  727. * If the same question (fuzzy) already exists, increment its count.
  728. */
  729. export async function logKnowledgeSuggestion(question: string) {
  730. const db = await getDb();
  731. if (!db) return;
  732. const key = question.slice(0, 80).toLowerCase();
  733. const existing = await db.select().from(knowledgeSuggestions)
  734. .where(eq(knowledgeSuggestions.status, "pending"))
  735. .orderBy(desc(knowledgeSuggestions.createdAt))
  736. .limit(50);
  737. const match = existing.find(s => s.question.slice(0, 80).toLowerCase() === key);
  738. if (match) {
  739. await db.update(knowledgeSuggestions)
  740. .set({ occurrenceCount: sql`${knowledgeSuggestions.occurrenceCount} + 1`, lastSeen: new Date() })
  741. .where(eq(knowledgeSuggestions.id, match.id));
  742. } else {
  743. await db.insert(knowledgeSuggestions).values({ question });
  744. }
  745. }
  746. export async function getKnowledgeEntries(status?: string) {
  747. const db = await getDb();
  748. if (!db) return [];
  749. const conditions = status ? [eq(knowledgeEntries.status, status)] : [];
  750. return db.select().from(knowledgeEntries)
  751. .where(conditions.length ? and(...conditions) : undefined)
  752. .orderBy(desc(knowledgeEntries.createdAt));
  753. }
  754. export async function getKnowledgeEntryById(id: number) {
  755. const db = await getDb();
  756. if (!db) return null;
  757. const [entry] = await db.select().from(knowledgeEntries).where(eq(knowledgeEntries.id, id));
  758. return entry ?? null;
  759. }
  760. export async function createKnowledgeEntry(data: { question: string; answer: string; category?: string; source?: string }) {
  761. const db = await getDb();
  762. if (!db) return null;
  763. const [inserted] = await db.insert(knowledgeEntries).values({ ...data, useCount: 0, status: "active" }).returning({ id: knowledgeEntries.id });
  764. return inserted?.id ?? null;
  765. }
  766. export async function updateKnowledgeEntry(id: number, data: Partial<{ question: string; answer: string; category: string; status: string }>) {
  767. const db = await getDb();
  768. if (!db) return;
  769. await db.update(knowledgeEntries).set({ ...data, updatedAt: new Date() }).where(eq(knowledgeEntries.id, id));
  770. }
  771. export async function deleteKnowledgeEntry(id: number) {
  772. const db = await getDb();
  773. if (!db) return;
  774. await db.delete(knowledgeEntries).where(eq(knowledgeEntries.id, id));
  775. }
  776. export async function bulkCreateKnowledgeEntries(entries: { question: string; answer: string; category?: string; source?: string }[]) {
  777. const db = await getDb();
  778. if (!db) return { created: 0 };
  779. if (!entries.length) return { created: 0 };
  780. await db.insert(knowledgeEntries).values(entries.map(e => ({ ...e, useCount: 0, status: "active" })));
  781. return { created: entries.length };
  782. }
  783. export async function getKnowledgeSuggestions(status?: string) {
  784. const db = await getDb();
  785. if (!db) return [];
  786. const conditions = status ? [eq(knowledgeSuggestions.status, status)] : [];
  787. return db.select().from(knowledgeSuggestions)
  788. .where(conditions.length ? and(...conditions) : undefined)
  789. .orderBy(desc(knowledgeSuggestions.occurrenceCount));
  790. }
  791. export async function promoteKnowledgeSuggestion(id: number, answer: string, category?: string) {
  792. const db = await getDb();
  793. if (!db) return null;
  794. const [suggestion] = await db.select().from(knowledgeSuggestions).where(eq(knowledgeSuggestions.id, id));
  795. if (!suggestion) return null;
  796. const entryId = await createKnowledgeEntry({ question: suggestion.question, answer, category, source: "suggestion" });
  797. await db.update(knowledgeSuggestions)
  798. .set({ status: "promoted", promotedToId: entryId ?? undefined })
  799. .where(eq(knowledgeSuggestions.id, id));
  800. return entryId;
  801. }
  802. export async function dismissKnowledgeSuggestion(id: number) {
  803. const db = await getDb();
  804. if (!db) return;
  805. await db.update(knowledgeSuggestions).set({ status: "dismissed" }).where(eq(knowledgeSuggestions.id, id));
  806. }
  807. // Knowledge Products
  808. export async function getKnowledgeProducts() {
  809. const db = await getDb();
  810. if (!db) return [];
  811. return db.select().from(knowledgeProducts).where(eq(knowledgeProducts.status, "active")).orderBy(knowledgeProducts.model);
  812. }
  813. export async function bulkCreateKnowledgeProducts(products: Omit<InsertKnowledgeProduct, "id" | "createdAt" | "status">[]) {
  814. const db = await getDb();
  815. if (!db) return { created: 0 };
  816. if (!products.length) return { created: 0 };
  817. await db.insert(knowledgeProducts).values(products.map(p => ({ ...p, status: "active" })));
  818. return { created: products.length };
  819. }
  820. export async function deleteAllKnowledgeProducts() {
  821. const db = await getDb();
  822. if (!db) return;
  823. await db.delete(knowledgeProducts);
  824. }