|
|
@@ -1,4 +1,4 @@
|
|
|
-import { eq, desc, asc, and, sql, like, ilike, or, lt, gte, lte, isNotNull, inArray } from "drizzle-orm";
|
|
|
+import { eq, desc, asc, and, sql, like, ilike, or, lt, gte, lte, isNotNull, inArray, count } from "drizzle-orm";
|
|
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
|
import {
|
|
|
InsertUser, users,
|
|
|
@@ -435,9 +435,11 @@ export async function deleteConversations(ids: number[]) {
|
|
|
const db = await getDb();
|
|
|
if (!db) throw new Error("Database not available");
|
|
|
if (ids.length === 0) return { deleted: 0 };
|
|
|
- // Delete messages first, then conversations — both in a single statement each
|
|
|
- await db.delete(messages).where(inArray(messages.conversationId, ids));
|
|
|
- await db.delete(conversations).where(inArray(conversations.id, ids));
|
|
|
+ // Delete messages first, then conversations — wrapped in a transaction
|
|
|
+ await db.transaction(async (tx) => {
|
|
|
+ await tx.delete(messages).where(inArray(messages.conversationId, ids));
|
|
|
+ await tx.delete(conversations).where(inArray(conversations.id, ids));
|
|
|
+ });
|
|
|
return { deleted: ids.length };
|
|
|
}
|
|
|
|
|
|
@@ -600,34 +602,59 @@ export async function getAnalyticsEvents(filters?: {
|
|
|
export async function getAnalyticsSummary(startDate?: Date, endDate?: Date) {
|
|
|
const db = await getDb();
|
|
|
if (!db) return null;
|
|
|
- const conditions: any[] = [];
|
|
|
- if (startDate) conditions.push(gte(analyticsEvents.createdAt, startDate));
|
|
|
- if (endDate) conditions.push(lte(analyticsEvents.createdAt, endDate));
|
|
|
|
|
|
- const baseQuery = conditions.length > 0
|
|
|
- ? db.select().from(analyticsEvents).where(and(...conditions))
|
|
|
- : db.select().from(analyticsEvents);
|
|
|
+ const dateConditions: any[] = [];
|
|
|
+ if (startDate) dateConditions.push(gte(analyticsEvents.createdAt, startDate));
|
|
|
+ if (endDate) dateConditions.push(lte(analyticsEvents.createdAt, endDate));
|
|
|
|
|
|
- const allEvents = await baseQuery;
|
|
|
-
|
|
|
- const totalSessions = allEvents.filter(e => e.eventType === "session_start").length;
|
|
|
- const resolvedByBot = allEvents.filter(e => e.eventType === "resolved_by_bot").length;
|
|
|
- const resolvedByAgent = allEvents.filter(e => e.eventType === "resolved_by_agent").length;
|
|
|
- const escalated = allEvents.filter(e => e.eventType === "escalated").length;
|
|
|
- const abandoned = allEvents.filter(e => e.eventType === "abandoned").length;
|
|
|
- const messagesSent = allEvents.filter(e => e.eventType === "message_sent").length;
|
|
|
- const messagesReceived = allEvents.filter(e => e.eventType === "message_received").length;
|
|
|
- const buttonClicks = allEvents.filter(e => e.eventType === "button_clicked").length;
|
|
|
- const positiveFeedback = allEvents.filter(e => e.eventType === "feedback_positive").length;
|
|
|
- const negativeFeedback = allEvents.filter(e => e.eventType === "feedback_negative").length;
|
|
|
+ // Single GROUP BY query for event-type counts
|
|
|
+ const eventRows = await db.select({
|
|
|
+ eventType: analyticsEvents.eventType,
|
|
|
+ count: sql<number>`COUNT(*)`,
|
|
|
+ })
|
|
|
+ .from(analyticsEvents)
|
|
|
+ .where(dateConditions.length > 0 ? and(...dateConditions) : undefined)
|
|
|
+ .groupBy(analyticsEvents.eventType);
|
|
|
+
|
|
|
+ const eventCounts: Record<string, number> = {};
|
|
|
+ let totalEvents = 0;
|
|
|
+ for (const row of eventRows) {
|
|
|
+ const c = Number(row.count);
|
|
|
+ eventCounts[row.eventType as string] = c;
|
|
|
+ totalEvents += c;
|
|
|
+ }
|
|
|
|
|
|
- // Category breakdown
|
|
|
+ const totalSessions = eventCounts["session_start"] || 0;
|
|
|
+ const resolvedByBot = eventCounts["resolved_by_bot"] || 0;
|
|
|
+ const resolvedByAgent = eventCounts["resolved_by_agent"] || 0;
|
|
|
+ const escalated = eventCounts["escalated"] || 0;
|
|
|
+ const abandoned = eventCounts["abandoned"] || 0;
|
|
|
+ const messagesSent = eventCounts["message_sent"] || 0;
|
|
|
+ const messagesReceived = eventCounts["message_received"] || 0;
|
|
|
+ const buttonClicks = eventCounts["button_clicked"] || 0;
|
|
|
+ const positiveFeedback = eventCounts["feedback_positive"] || 0;
|
|
|
+ const negativeFeedback = eventCounts["feedback_negative"] || 0;
|
|
|
+
|
|
|
+ // Category breakdown — single GROUP BY (category, eventType) query
|
|
|
const categories = ["orders", "shipping", "returning", "cancelling"];
|
|
|
- const categoryBreakdown = categories.map(cat => ({
|
|
|
- category: cat,
|
|
|
- count: allEvents.filter(e => e.category === cat).length,
|
|
|
- resolved: allEvents.filter(e => e.category === cat && (e.eventType === "resolved_by_bot" || e.eventType === "resolved_by_agent")).length,
|
|
|
- }));
|
|
|
+ const catConditions = [...dateConditions, inArray(analyticsEvents.category, categories)];
|
|
|
+ const catRows = await db.select({
|
|
|
+ category: analyticsEvents.category,
|
|
|
+ eventType: analyticsEvents.eventType,
|
|
|
+ count: sql<number>`COUNT(*)`,
|
|
|
+ })
|
|
|
+ .from(analyticsEvents)
|
|
|
+ .where(and(...catConditions))
|
|
|
+ .groupBy(analyticsEvents.category, analyticsEvents.eventType);
|
|
|
+
|
|
|
+ const categoryBreakdown = categories.map(cat => {
|
|
|
+ const rowsForCat = catRows.filter(r => r.category === cat);
|
|
|
+ const total = rowsForCat.reduce((s, r) => s + Number(r.count), 0);
|
|
|
+ const resolved = rowsForCat
|
|
|
+ .filter(r => r.eventType === "resolved_by_bot" || r.eventType === "resolved_by_agent")
|
|
|
+ .reduce((s, r) => s + Number(r.count), 0);
|
|
|
+ return { category: cat, count: total, resolved };
|
|
|
+ });
|
|
|
|
|
|
return {
|
|
|
totalSessions,
|
|
|
@@ -643,7 +670,7 @@ export async function getAnalyticsSummary(startDate?: Date, endDate?: Date) {
|
|
|
resolutionRate: totalSessions > 0 ? Math.round(((resolvedByBot + resolvedByAgent) / totalSessions) * 100) : 0,
|
|
|
botResolutionRate: totalSessions > 0 ? Math.round((resolvedByBot / totalSessions) * 100) : 0,
|
|
|
categoryBreakdown,
|
|
|
- totalEvents: allEvents.length,
|
|
|
+ totalEvents,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
@@ -777,19 +804,29 @@ export async function searchKnowledge(userQuestion: string): Promise<{ id: numbe
|
|
|
const db = await getDb();
|
|
|
if (!db) return null;
|
|
|
|
|
|
+ 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"]);
|
|
|
+ const queryWords = userQuestion.toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter(w => w.length > 2 && !stopWords.has(w));
|
|
|
+
|
|
|
+ if (!queryWords.length) return null;
|
|
|
+
|
|
|
+ // Push search to DB: OR of ILIKE matches against question/answer
|
|
|
+ const ilikeConditions = queryWords.flatMap(w => {
|
|
|
+ const pattern = `%${w}%`;
|
|
|
+ return [
|
|
|
+ ilike(knowledgeEntries.question, pattern),
|
|
|
+ ilike(knowledgeEntries.answer, pattern),
|
|
|
+ ];
|
|
|
+ });
|
|
|
+
|
|
|
const entries = await db
|
|
|
.select({ id: knowledgeEntries.id, question: knowledgeEntries.question, answer: knowledgeEntries.answer, category: knowledgeEntries.category })
|
|
|
.from(knowledgeEntries)
|
|
|
- .where(eq(knowledgeEntries.status, "active"))
|
|
|
- .limit(1000); // safety cap — migrate to DB full-text search when KB exceeds this
|
|
|
+ .where(and(eq(knowledgeEntries.status, "active"), or(...ilikeConditions))!)
|
|
|
+ .limit(5);
|
|
|
|
|
|
if (!entries.length) return null;
|
|
|
|
|
|
- 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"]);
|
|
|
- const queryWords = userQuestion.toLowerCase().replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter(w => w.length > 2 && !stopWords.has(w));
|
|
|
-
|
|
|
- if (!queryWords.length) return null;
|
|
|
-
|
|
|
+ // Score the small candidate set in JS to pick the best match
|
|
|
let best: { entry: typeof entries[0]; score: number } | null = null;
|
|
|
for (const entry of entries) {
|
|
|
const text = entry.question.toLowerCase();
|