db.ts 32 KB

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