db.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879
  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. await db.update(conversations).set(updateData).where(eq(conversations.id, id));
  415. return getConversationById(id);
  416. }
  417. export async function getConversationStats() {
  418. const db = await getDb();
  419. if (!db) return { total: 0, active: 0, escalated: 0, resolved: 0, closed: 0 };
  420. const result = await db.select({
  421. status: conversations.status,
  422. count: sql<number>`COUNT(*)`,
  423. }).from(conversations).groupBy(conversations.status);
  424. const stats = { total: 0, active: 0, escalated: 0, resolved: 0, closed: 0 };
  425. for (const row of result) {
  426. const count = Number(row.count);
  427. stats[row.status as keyof typeof stats] = count;
  428. stats.total += count;
  429. }
  430. return stats;
  431. }
  432. /* ─── Message helpers ─── */
  433. export async function addMessage(data: InsertMessage) {
  434. const db = await getDb();
  435. if (!db) throw new Error("Database not available");
  436. const [inserted] = await db.insert(messages).values(data).returning({ id: messages.id });
  437. await db.update(conversations).set({ updatedAt: new Date() }).where(eq(conversations.id, data.conversationId));
  438. return { id: inserted.id, ...data };
  439. }
  440. export async function getMessagesByConversation(conversationId: number) {
  441. const db = await getDb();
  442. if (!db) return [];
  443. return db.select().from(messages)
  444. .where(eq(messages.conversationId, conversationId))
  445. .orderBy(messages.createdAt);
  446. }
  447. /* ─── Workflow helpers ─── */
  448. export async function saveWorkflow(workflowId: string, nodes: InsertWorkflowNode[], edges: InsertWorkflowEdge[]) {
  449. const db = await getDb();
  450. if (!db) throw new Error("Database not available");
  451. await db.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
  452. await db.delete(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
  453. if (nodes.length > 0) await db.insert(workflowNodes).values(nodes);
  454. if (edges.length > 0) await db.insert(workflowEdges).values(edges);
  455. return { workflowId, nodeCount: nodes.length, edgeCount: edges.length };
  456. }
  457. export async function getWorkflow(workflowId: string) {
  458. const db = await getDb();
  459. if (!db) return { nodes: [], edges: [] };
  460. const nodes = await db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
  461. const edges = await db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
  462. return { nodes, edges };
  463. }
  464. /* ─── Workflow suggestion helpers ─── */
  465. export async function createWorkflowSuggestion(data: InsertWorkflowSuggestion) {
  466. const db = await getDb();
  467. if (!db) throw new Error("Database not available");
  468. await db.insert(workflowSuggestions).values(data);
  469. return data;
  470. }
  471. export async function getWorkflowSuggestions(workflowId: string, status?: string) {
  472. const db = await getDb();
  473. if (!db) return [];
  474. const conditions = [eq(workflowSuggestions.workflowId, workflowId)];
  475. if (status) conditions.push(eq(workflowSuggestions.status, status as any));
  476. return db.select().from(workflowSuggestions).where(and(...conditions)).orderBy(desc(workflowSuggestions.frequency));
  477. }
  478. export async function updateWorkflowSuggestionStatus(id: number, status: "approved" | "declined" | "waiting", reviewedById: number) {
  479. const db = await getDb();
  480. if (!db) throw new Error("Database not available");
  481. await db.update(workflowSuggestions)
  482. .set({ status, reviewedById, reviewedAt: new Date() })
  483. .where(eq(workflowSuggestions.id, id));
  484. return { id, status };
  485. }
  486. export async function bulkCreateWorkflowSuggestions(suggestions: InsertWorkflowSuggestion[]) {
  487. const db = await getDb();
  488. if (!db) throw new Error("Database not available");
  489. if (suggestions.length === 0) return { created: 0 };
  490. await db.insert(workflowSuggestions).values(suggestions);
  491. return { created: suggestions.length };
  492. }
  493. /* ─── Analytics helpers ─── */
  494. export async function trackAnalyticsEvent(event: Omit<InsertAnalyticsEvent, "id" | "createdAt">) {
  495. const db = await getDb();
  496. if (!db) return null;
  497. const [inserted] = await db.insert(analyticsEvents).values(event as any).returning({ id: analyticsEvents.id });
  498. return inserted?.id ?? null;
  499. }
  500. export async function getAnalyticsEvents(filters?: {
  501. eventType?: string;
  502. category?: string;
  503. startDate?: Date;
  504. endDate?: Date;
  505. }) {
  506. const db = await getDb();
  507. if (!db) return [];
  508. const conditions: any[] = [];
  509. if (filters?.eventType) conditions.push(eq(analyticsEvents.eventType, filters.eventType as any));
  510. if (filters?.category) conditions.push(eq(analyticsEvents.category, filters.category));
  511. if (filters?.startDate) conditions.push(gte(analyticsEvents.createdAt, filters.startDate));
  512. if (filters?.endDate) conditions.push(lte(analyticsEvents.createdAt, filters.endDate));
  513. const query = db.select().from(analyticsEvents);
  514. if (conditions.length > 0) {
  515. return query.where(and(...conditions)).orderBy(desc(analyticsEvents.createdAt)).limit(1000);
  516. }
  517. return query.orderBy(desc(analyticsEvents.createdAt)).limit(1000);
  518. }
  519. export async function getAnalyticsSummary(startDate?: Date, endDate?: Date) {
  520. const db = await getDb();
  521. if (!db) return null;
  522. const conditions: any[] = [];
  523. if (startDate) conditions.push(gte(analyticsEvents.createdAt, startDate));
  524. if (endDate) conditions.push(lte(analyticsEvents.createdAt, endDate));
  525. const baseQuery = conditions.length > 0
  526. ? db.select().from(analyticsEvents).where(and(...conditions))
  527. : db.select().from(analyticsEvents);
  528. const allEvents = await baseQuery;
  529. const totalSessions = allEvents.filter(e => e.eventType === "session_start").length;
  530. const resolvedByBot = allEvents.filter(e => e.eventType === "resolved_by_bot").length;
  531. const resolvedByAgent = allEvents.filter(e => e.eventType === "resolved_by_agent").length;
  532. const escalated = allEvents.filter(e => e.eventType === "escalated").length;
  533. const abandoned = allEvents.filter(e => e.eventType === "abandoned").length;
  534. const messagesSent = allEvents.filter(e => e.eventType === "message_sent").length;
  535. const messagesReceived = allEvents.filter(e => e.eventType === "message_received").length;
  536. const buttonClicks = allEvents.filter(e => e.eventType === "button_clicked").length;
  537. const positiveFeedback = allEvents.filter(e => e.eventType === "feedback_positive").length;
  538. const negativeFeedback = allEvents.filter(e => e.eventType === "feedback_negative").length;
  539. // Category breakdown
  540. const categories = ["orders", "shipping", "returning", "cancelling"];
  541. const categoryBreakdown = categories.map(cat => ({
  542. category: cat,
  543. count: allEvents.filter(e => e.category === cat).length,
  544. resolved: allEvents.filter(e => e.category === cat && (e.eventType === "resolved_by_bot" || e.eventType === "resolved_by_agent")).length,
  545. }));
  546. return {
  547. totalSessions,
  548. resolvedByBot,
  549. resolvedByAgent,
  550. escalated,
  551. abandoned,
  552. messagesSent,
  553. messagesReceived,
  554. buttonClicks,
  555. positiveFeedback,
  556. negativeFeedback,
  557. resolutionRate: totalSessions > 0 ? Math.round(((resolvedByBot + resolvedByAgent) / totalSessions) * 100) : 0,
  558. botResolutionRate: totalSessions > 0 ? Math.round((resolvedByBot / totalSessions) * 100) : 0,
  559. categoryBreakdown,
  560. totalEvents: allEvents.length,
  561. };
  562. }
  563. /* ─── Data Sources helpers ─── */
  564. export async function createDataSource(source: Omit<InsertDataSource, "id" | "createdAt" | "updatedAt">) {
  565. const db = await getDb();
  566. if (!db) return null;
  567. const [inserted] = await db.insert(dataSources).values(source as any).returning({ id: dataSources.id });
  568. return inserted?.id ?? null;
  569. }
  570. export async function getDataSources() {
  571. const db = await getDb();
  572. if (!db) return [];
  573. return db.select().from(dataSources).orderBy(desc(dataSources.createdAt));
  574. }
  575. export async function getDataSourceById(id: number) {
  576. const db = await getDb();
  577. if (!db) return null;
  578. const [source] = await db.select().from(dataSources).where(eq(dataSources.id, id));
  579. return source || null;
  580. }
  581. export async function updateDataSource(id: number, updates: Partial<InsertDataSource>) {
  582. const db = await getDb();
  583. if (!db) return null;
  584. await db.update(dataSources).set(updates as any).where(eq(dataSources.id, id));
  585. return getDataSourceById(id);
  586. }
  587. export async function deleteDataSource(id: number) {
  588. const db = await getDb();
  589. if (!db) return;
  590. await db.delete(dataSources).where(eq(dataSources.id, id));
  591. }
  592. /* ─── API Connections helpers ─── */
  593. export async function createApiConnection(conn: Omit<InsertApiConnection, "id" | "createdAt" | "updatedAt">) {
  594. const db = await getDb();
  595. if (!db) return null;
  596. const [inserted] = await db.insert(apiConnections).values(conn as any).returning({ id: apiConnections.id });
  597. return inserted?.id ?? null;
  598. }
  599. export async function getApiConnections() {
  600. const db = await getDb();
  601. if (!db) return [];
  602. return db.select().from(apiConnections).orderBy(desc(apiConnections.createdAt));
  603. }
  604. export async function getApiConnectionById(id: number) {
  605. const db = await getDb();
  606. if (!db) return null;
  607. const [conn] = await db.select().from(apiConnections).where(eq(apiConnections.id, id));
  608. return conn || null;
  609. }
  610. export async function updateApiConnection(id: number, updates: Partial<InsertApiConnection>) {
  611. const db = await getDb();
  612. if (!db) return null;
  613. await db.update(apiConnections).set(updates as any).where(eq(apiConnections.id, id));
  614. return getApiConnectionById(id);
  615. }
  616. export async function deleteApiConnection(id: number) {
  617. const db = await getDb();
  618. if (!db) return;
  619. await db.delete(apiConnections).where(eq(apiConnections.id, id));
  620. }
  621. export async function incrementApiConnectionExecution(id: number) {
  622. const db = await getDb();
  623. if (!db) return;
  624. const conn = await getApiConnectionById(id);
  625. if (conn) {
  626. await db.update(apiConnections).set({
  627. executionCount: conn.executionCount + 1,
  628. lastExecutedAt: new Date(),
  629. } as any).where(eq(apiConnections.id, id));
  630. }
  631. }
  632. /* ─── Knowledge Base helpers ─────────────────────────────── */
  633. /**
  634. * Simple keyword-based knowledge search.
  635. * Splits the user question into words, filters stop-words, searches
  636. * question text case-insensitively. Returns the best match or null.
  637. */
  638. export async function searchKnowledge(userQuestion: string): Promise<{ id: number; question: string; answer: string; category: string | null } | null> {
  639. const db = await getDb();
  640. if (!db) return null;
  641. const entries = await db
  642. .select({ id: knowledgeEntries.id, question: knowledgeEntries.question, answer: knowledgeEntries.answer, category: knowledgeEntries.category })
  643. .from(knowledgeEntries)
  644. .where(eq(knowledgeEntries.status, "active"))
  645. .limit(1000); // safety cap — migrate to DB full-text search when KB exceeds this
  646. if (!entries.length) return null;
  647. 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"]);
  648. const queryWords = userQuestion.toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter(w => w.length > 2 && !stopWords.has(w));
  649. if (!queryWords.length) return null;
  650. let best: { entry: typeof entries[0]; score: number } | null = null;
  651. for (const entry of entries) {
  652. const text = entry.question.toLowerCase();
  653. let score = 0;
  654. for (const word of queryWords) {
  655. if (text.includes(word)) score++;
  656. }
  657. if (score > 0 && (!best || score > best.score)) {
  658. best = { entry, score };
  659. }
  660. }
  661. if (!best) return null;
  662. const threshold = queryWords.some(w => w.length > 5) ? 1 : 2;
  663. if (best.score < threshold) return null;
  664. return best.entry;
  665. }
  666. export async function incrementKnowledgeUseCount(id: number) {
  667. const db = await getDb();
  668. if (!db) return;
  669. await db.update(knowledgeEntries)
  670. .set({ useCount: sql`${knowledgeEntries.useCount} + 1`, updatedAt: new Date() })
  671. .where(eq(knowledgeEntries.id, id));
  672. }
  673. /**
  674. * Log an unanswered question as a Suggestion.
  675. * If the same question (fuzzy) already exists, increment its count.
  676. */
  677. export async function logKnowledgeSuggestion(question: string) {
  678. const db = await getDb();
  679. if (!db) return;
  680. const key = question.slice(0, 80).toLowerCase();
  681. const existing = await db.select().from(knowledgeSuggestions)
  682. .where(eq(knowledgeSuggestions.status, "pending"))
  683. .orderBy(desc(knowledgeSuggestions.createdAt))
  684. .limit(50);
  685. const match = existing.find(s => s.question.slice(0, 80).toLowerCase() === key);
  686. if (match) {
  687. await db.update(knowledgeSuggestions)
  688. .set({ occurrenceCount: sql`${knowledgeSuggestions.occurrenceCount} + 1`, lastSeen: new Date() })
  689. .where(eq(knowledgeSuggestions.id, match.id));
  690. } else {
  691. await db.insert(knowledgeSuggestions).values({ question });
  692. }
  693. }
  694. export async function getKnowledgeEntries(status?: string) {
  695. const db = await getDb();
  696. if (!db) return [];
  697. const conditions = status ? [eq(knowledgeEntries.status, status)] : [];
  698. return db.select().from(knowledgeEntries)
  699. .where(conditions.length ? and(...conditions) : undefined)
  700. .orderBy(desc(knowledgeEntries.createdAt));
  701. }
  702. export async function getKnowledgeEntryById(id: number) {
  703. const db = await getDb();
  704. if (!db) return null;
  705. const [entry] = await db.select().from(knowledgeEntries).where(eq(knowledgeEntries.id, id));
  706. return entry ?? null;
  707. }
  708. export async function createKnowledgeEntry(data: { question: string; answer: string; category?: string; source?: string }) {
  709. const db = await getDb();
  710. if (!db) return null;
  711. const [inserted] = await db.insert(knowledgeEntries).values({ ...data, useCount: 0, status: "active" }).returning({ id: knowledgeEntries.id });
  712. return inserted?.id ?? null;
  713. }
  714. export async function updateKnowledgeEntry(id: number, data: Partial<{ question: string; answer: string; category: string; status: string }>) {
  715. const db = await getDb();
  716. if (!db) return;
  717. await db.update(knowledgeEntries).set({ ...data, updatedAt: new Date() }).where(eq(knowledgeEntries.id, id));
  718. }
  719. export async function deleteKnowledgeEntry(id: number) {
  720. const db = await getDb();
  721. if (!db) return;
  722. await db.delete(knowledgeEntries).where(eq(knowledgeEntries.id, id));
  723. }
  724. export async function bulkCreateKnowledgeEntries(entries: { question: string; answer: string; category?: string; source?: string }[]) {
  725. const db = await getDb();
  726. if (!db) return { created: 0 };
  727. if (!entries.length) return { created: 0 };
  728. await db.insert(knowledgeEntries).values(entries.map(e => ({ ...e, useCount: 0, status: "active" })));
  729. return { created: entries.length };
  730. }
  731. export async function getKnowledgeSuggestions(status?: string) {
  732. const db = await getDb();
  733. if (!db) return [];
  734. const conditions = status ? [eq(knowledgeSuggestions.status, status)] : [];
  735. return db.select().from(knowledgeSuggestions)
  736. .where(conditions.length ? and(...conditions) : undefined)
  737. .orderBy(desc(knowledgeSuggestions.occurrenceCount));
  738. }
  739. export async function promoteKnowledgeSuggestion(id: number, answer: string, category?: string) {
  740. const db = await getDb();
  741. if (!db) return null;
  742. const [suggestion] = await db.select().from(knowledgeSuggestions).where(eq(knowledgeSuggestions.id, id));
  743. if (!suggestion) return null;
  744. const entryId = await createKnowledgeEntry({ question: suggestion.question, answer, category, source: "suggestion" });
  745. await db.update(knowledgeSuggestions)
  746. .set({ status: "promoted", promotedToId: entryId ?? undefined })
  747. .where(eq(knowledgeSuggestions.id, id));
  748. return entryId;
  749. }
  750. export async function dismissKnowledgeSuggestion(id: number) {
  751. const db = await getDb();
  752. if (!db) return;
  753. await db.update(knowledgeSuggestions).set({ status: "dismissed" }).where(eq(knowledgeSuggestions.id, id));
  754. }
  755. // Knowledge Products
  756. export async function getKnowledgeProducts() {
  757. const db = await getDb();
  758. if (!db) return [];
  759. return db.select().from(knowledgeProducts).where(eq(knowledgeProducts.status, "active")).orderBy(knowledgeProducts.model);
  760. }
  761. export async function bulkCreateKnowledgeProducts(products: Omit<InsertKnowledgeProduct, "id" | "createdAt" | "status">[]) {
  762. const db = await getDb();
  763. if (!db) return { created: 0 };
  764. if (!products.length) return { created: 0 };
  765. await db.insert(knowledgeProducts).values(products.map(p => ({ ...p, status: "active" })));
  766. return { created: products.length };
  767. }
  768. export async function deleteAllKnowledgeProducts() {
  769. const db = await getDb();
  770. if (!db) return;
  771. await db.delete(knowledgeProducts);
  772. }