From 1ba1f4a63c0cb311fc7f6eeede816dc8ba4846a8 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Wed, 12 Nov 2025 22:21:35 -0300 Subject: [PATCH] Fix reports pagination helper and CSAT handling --- convex/reports.ts | 213 +++++++++++++++++++++++++++++++--------------- 1 file changed, 146 insertions(+), 67 deletions(-) diff --git a/convex/reports.ts b/convex/reports.ts index 72ffe49..fdb965c 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -62,11 +62,23 @@ type PaginatedResult = { async function paginateTickets( buildQuery: () => { paginate: (options: { cursor: string | null; numItems: number }) => Promise>; + collect?: () => Promise; }, handler: (doc: T) => void | Promise, pageSize = REPORTS_PAGE_SIZE, ) { const query = buildQuery(); + if (typeof (query as { paginate?: unknown }).paginate !== "function") { + const collectFn = (query as { collect?: (() => Promise) | undefined }).collect; + if (typeof collectFn !== "function") { + throw new ConvexError("Query does not support paginate or collect"); + } + const docs = await collectFn.call(query); + for (const doc of docs) { + await handler(doc); + } + return; + } let cursor: string | null = null; while (true) { const page = await query.paginate({ cursor, numItems: pageSize }); @@ -81,6 +93,22 @@ async function paginateTickets( } } +function withLowerBound(range: T, field: string, value: number): T { + const candidate = range as { gte?: (field: string, value: number) => unknown }; + if (candidate && typeof candidate.gte === "function") { + return candidate.gte(field, value) as T; + } + return range; +} + +function withUpperBound(range: T, field: string, value: number): T { + const candidate = range as { lt?: (field: string, value: number) => unknown }; + if (candidate && typeof candidate.lt === "function") { + return candidate.lt(field, value) as T; + } + return range; +} + function resolveScopedCompanyId( viewer: Awaited>, companyId?: Id<"companies">, @@ -103,6 +131,7 @@ export async function fetchOpenScopedTickets( const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); const statuses: TicketStatusNormalized[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]; const results: Doc<"tickets">[] = []; + const seen = new Set(); for (const status of statuses) { await paginateTickets( @@ -115,6 +144,12 @@ export async function fetchOpenScopedTickets( if (scopedCompanyId && ticket.companyId !== scopedCompanyId) { return; } + if (!OPEN_STATUSES.has(normalizeStatus(ticket.status))) { + return; + } + const key = String(ticket._id); + if (seen.has(key)) return; + seen.add(key); results.push(ticket); }, ); @@ -240,16 +275,26 @@ export async function fetchScopedTicketsByCreatedRange( await paginateTickets( () => { - const query = scopedCompanyId - ? ctx.db - .query("tickets") - .withIndex("by_tenant_company_created", (q) => - q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId).gte("createdAt", startMs), - ) - : ctx.db - .query("tickets") - .withIndex("by_tenant_created", (q) => q.eq("tenantId", tenantId).gte("createdAt", startMs)); - return query.filter((q) => q.lt(q.field("createdAt"), endMs)).order("desc"); + if (scopedCompanyId) { + return ctx.db + .query("tickets") + .withIndex("by_tenant_company_created", (q) => { + let range = q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId); + range = withLowerBound(range, "createdAt", startMs); + range = withUpperBound(range, "createdAt", endMs); + return range; + }) + .order("desc"); + } + return ctx.db + .query("tickets") + .withIndex("by_tenant_created", (q) => { + let range = q.eq("tenantId", tenantId); + range = withLowerBound(range, "createdAt", startMs); + range = withUpperBound(range, "createdAt", endMs); + return range; + }) + .order("desc"); }, (ticket) => { const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null; @@ -275,16 +320,26 @@ export async function fetchScopedTicketsByResolvedRange( await paginateTickets( () => { - const query = scopedCompanyId - ? ctx.db - .query("tickets") - .withIndex("by_tenant_company_resolved", (q) => - q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId).gte("resolvedAt", startMs), - ) - : ctx.db - .query("tickets") - .withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", startMs)); - return query.filter((q) => q.lt(q.field("resolvedAt"), endMs)).order("desc"); + if (scopedCompanyId) { + return ctx.db + .query("tickets") + .withIndex("by_tenant_company_resolved", (q) => { + let range = q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId); + range = withLowerBound(range, "resolvedAt", startMs); + range = withUpperBound(range, "resolvedAt", endMs); + return range; + }) + .order("desc"); + } + return ctx.db + .query("tickets") + .withIndex("by_tenant_resolved", (q) => { + let range = q.eq("tenantId", tenantId); + range = withLowerBound(range, "resolvedAt", startMs); + range = withUpperBound(range, "resolvedAt", endMs); + return range; + }) + .order("desc"); }, (ticket) => { const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null; @@ -344,53 +399,77 @@ type CsatSurvey = { assigneeName: string | null; }; -function collectCsatSurveys(tickets: Doc<"tickets">[]): CsatSurvey[] { - return tickets - .map((ticket) => { - if (typeof ticket.csatScore !== "number") { - return null; +async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise { + const perTicket = await Promise.all( + tickets.map(async (ticket) => { + if (typeof ticket.csatScore === "number") { + const snapshot = (ticket.csatAssigneeSnapshot ?? null) as { + name?: string | null + email?: string | null + } | null + const assigneeId = + ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string" + ? ticket.csatAssigneeId + : ticket.csatAssigneeId + ? String(ticket.csatAssigneeId) + : ticket.assigneeId + ? String(ticket.assigneeId) + : null + const assigneeName = snapshot?.name?.trim?.() || snapshot?.email?.trim?.() || null + const maxScore = + typeof ticket.csatMaxScore === "number" && Number.isFinite(ticket.csatMaxScore) + ? (ticket.csatMaxScore as number) + : 5 + const receivedAtRaw = + typeof ticket.csatRatedAt === "number" + ? ticket.csatRatedAt + : typeof ticket.resolvedAt === "number" + ? ticket.resolvedAt + : ticket.updatedAt ?? ticket.createdAt + if (typeof receivedAtRaw !== "number") { + return []; + } + return [ + { + ticketId: ticket._id, + reference: ticket.reference, + score: ticket.csatScore, + maxScore, + comment: + typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0 + ? ticket.csatComment.trim() + : null, + receivedAt: receivedAtRaw, + assigneeId, + assigneeName, + } satisfies CsatSurvey, + ]; } - const snapshot = (ticket.csatAssigneeSnapshot ?? null) as { - name?: string | null - email?: string | null - } | null - const assigneeId = - ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string" - ? ticket.csatAssigneeId - : ticket.csatAssigneeId - ? String(ticket.csatAssigneeId) - : ticket.assigneeId - ? String(ticket.assigneeId) - : null - const assigneeName = snapshot?.name?.trim?.() || snapshot?.email?.trim?.() || null - const maxScore = - typeof ticket.csatMaxScore === "number" && Number.isFinite(ticket.csatMaxScore) - ? (ticket.csatMaxScore as number) - : 5 - const receivedAtRaw = - typeof ticket.csatRatedAt === "number" - ? ticket.csatRatedAt - : typeof ticket.resolvedAt === "number" - ? ticket.resolvedAt - : ticket.updatedAt ?? ticket.createdAt - if (typeof receivedAtRaw !== "number") { - return null; - } - return { - ticketId: ticket._id, - reference: ticket.reference, - score: ticket.csatScore, - maxScore, - comment: - typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0 - ? ticket.csatComment.trim() - : null, - receivedAt: receivedAtRaw, - assigneeId, - assigneeName, - } satisfies CsatSurvey; + const events = await ctx.db + .query("ticketEvents") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id)) + .collect(); + return events + .filter((event) => event.type === "CSAT_RECEIVED" || event.type === "CSAT_RATED") + .map((event) => { + const score = extractScore(event.payload); + if (score === null) return null; + const assignee = extractAssignee(event.payload); + return { + ticketId: ticket._id, + reference: ticket.reference, + score, + maxScore: extractMaxScore(event.payload) ?? 5, + comment: extractComment(event.payload), + receivedAt: event.createdAt, + assigneeId: assignee.id, + assigneeName: assignee.name, + } as CsatSurvey; + }) + .filter(isNotNull); }) - .filter(isNotNull); + ); + return perTicket.flat(); } function formatDateKey(timestamp: number) { @@ -558,8 +637,8 @@ export async function csatOverviewHandler( end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; - const tickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId); - const surveys = collectCsatSurveys(tickets).filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); + const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); + const surveys = (await collectCsatSurveys(ctx, tickets)).filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); const normalizeToFive = (value: CsatSurvey) => { if (!value.maxScore || value.maxScore <= 0) return value.score;