Fix reports pagination helper and CSAT handling

This commit is contained in:
Esdras Renan 2025-11-12 22:21:35 -03:00
parent 3e4943f79c
commit 1ba1f4a63c

View file

@ -62,11 +62,23 @@ type PaginatedResult<T> = {
async function paginateTickets<T>( async function paginateTickets<T>(
buildQuery: () => { buildQuery: () => {
paginate: (options: { cursor: string | null; numItems: number }) => Promise<PaginatedResult<T>>; paginate: (options: { cursor: string | null; numItems: number }) => Promise<PaginatedResult<T>>;
collect?: () => Promise<T[]>;
}, },
handler: (doc: T) => void | Promise<void>, handler: (doc: T) => void | Promise<void>,
pageSize = REPORTS_PAGE_SIZE, pageSize = REPORTS_PAGE_SIZE,
) { ) {
const query = buildQuery(); const query = buildQuery();
if (typeof (query as { paginate?: unknown }).paginate !== "function") {
const collectFn = (query as { collect?: (() => Promise<T[]>) | 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; let cursor: string | null = null;
while (true) { while (true) {
const page = await query.paginate({ cursor, numItems: pageSize }); const page = await query.paginate({ cursor, numItems: pageSize });
@ -81,6 +93,22 @@ async function paginateTickets<T>(
} }
} }
function withLowerBound<T>(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<T>(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( function resolveScopedCompanyId(
viewer: Awaited<ReturnType<typeof requireStaff>>, viewer: Awaited<ReturnType<typeof requireStaff>>,
companyId?: Id<"companies">, companyId?: Id<"companies">,
@ -103,6 +131,7 @@ export async function fetchOpenScopedTickets(
const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); const scopedCompanyId = resolveScopedCompanyId(viewer, companyId);
const statuses: TicketStatusNormalized[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]; const statuses: TicketStatusNormalized[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED"];
const results: Doc<"tickets">[] = []; const results: Doc<"tickets">[] = [];
const seen = new Set<string>();
for (const status of statuses) { for (const status of statuses) {
await paginateTickets( await paginateTickets(
@ -115,6 +144,12 @@ export async function fetchOpenScopedTickets(
if (scopedCompanyId && ticket.companyId !== scopedCompanyId) { if (scopedCompanyId && ticket.companyId !== scopedCompanyId) {
return; 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); results.push(ticket);
}, },
); );
@ -240,16 +275,26 @@ export async function fetchScopedTicketsByCreatedRange(
await paginateTickets( await paginateTickets(
() => { () => {
const query = scopedCompanyId if (scopedCompanyId) {
? ctx.db return ctx.db
.query("tickets") .query("tickets")
.withIndex("by_tenant_company_created", (q) => .withIndex("by_tenant_company_created", (q) => {
q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId).gte("createdAt", startMs), let range = q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId);
) range = withLowerBound(range, "createdAt", startMs);
: ctx.db range = withUpperBound(range, "createdAt", endMs);
.query("tickets") return range;
.withIndex("by_tenant_created", (q) => q.eq("tenantId", tenantId).gte("createdAt", startMs)); })
return query.filter((q) => q.lt(q.field("createdAt"), endMs)).order("desc"); .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) => { (ticket) => {
const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null; const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null;
@ -275,16 +320,26 @@ export async function fetchScopedTicketsByResolvedRange(
await paginateTickets( await paginateTickets(
() => { () => {
const query = scopedCompanyId if (scopedCompanyId) {
? ctx.db return ctx.db
.query("tickets") .query("tickets")
.withIndex("by_tenant_company_resolved", (q) => .withIndex("by_tenant_company_resolved", (q) => {
q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId).gte("resolvedAt", startMs), let range = q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId);
) range = withLowerBound(range, "resolvedAt", startMs);
: ctx.db range = withUpperBound(range, "resolvedAt", endMs);
.query("tickets") return range;
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", startMs)); })
return query.filter((q) => q.lt(q.field("resolvedAt"), endMs)).order("desc"); .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) => { (ticket) => {
const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null; const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null;
@ -344,53 +399,77 @@ type CsatSurvey = {
assigneeName: string | null; assigneeName: string | null;
}; };
function collectCsatSurveys(tickets: Doc<"tickets">[]): CsatSurvey[] { async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise<CsatSurvey[]> {
return tickets const perTicket = await Promise.all(
.map((ticket) => { tickets.map(async (ticket) => {
if (typeof ticket.csatScore !== "number") { if (typeof ticket.csatScore === "number") {
return null; 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 { const events = await ctx.db
name?: string | null .query("ticketEvents")
email?: string | null .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
} | null .collect();
const assigneeId = return events
ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string" .filter((event) => event.type === "CSAT_RECEIVED" || event.type === "CSAT_RATED")
? ticket.csatAssigneeId .map((event) => {
: ticket.csatAssigneeId const score = extractScore(event.payload);
? String(ticket.csatAssigneeId) if (score === null) return null;
: ticket.assigneeId const assignee = extractAssignee(event.payload);
? String(ticket.assigneeId) return {
: null ticketId: ticket._id,
const assigneeName = snapshot?.name?.trim?.() || snapshot?.email?.trim?.() || null reference: ticket.reference,
const maxScore = score,
typeof ticket.csatMaxScore === "number" && Number.isFinite(ticket.csatMaxScore) maxScore: extractMaxScore(event.payload) ?? 5,
? (ticket.csatMaxScore as number) comment: extractComment(event.payload),
: 5 receivedAt: event.createdAt,
const receivedAtRaw = assigneeId: assignee.id,
typeof ticket.csatRatedAt === "number" assigneeName: assignee.name,
? ticket.csatRatedAt } as CsatSurvey;
: typeof ticket.resolvedAt === "number" })
? ticket.resolvedAt .filter(isNotNull);
: 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;
}) })
.filter(isNotNull); );
return perTicket.flat();
} }
function formatDateKey(timestamp: number) { function formatDateKey(timestamp: number) {
@ -558,8 +637,8 @@ export async function csatOverviewHandler(
end.setUTCHours(0, 0, 0, 0); end.setUTCHours(0, 0, 0, 0);
const endMs = end.getTime() + ONE_DAY_MS; const endMs = end.getTime() + ONE_DAY_MS;
const startMs = endMs - days * ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS;
const tickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
const surveys = collectCsatSurveys(tickets).filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); const surveys = (await collectCsatSurveys(ctx, tickets)).filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs);
const normalizeToFive = (value: CsatSurvey) => { const normalizeToFive = (value: CsatSurvey) => {
if (!value.maxScore || value.maxScore <= 0) return value.score; if (!value.maxScore || value.maxScore <= 0) return value.score;