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>(
buildQuery: () => {
paginate: (options: { cursor: string | null; numItems: number }) => Promise<PaginatedResult<T>>;
collect?: () => Promise<T[]>;
},
handler: (doc: T) => void | Promise<void>,
pageSize = REPORTS_PAGE_SIZE,
) {
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;
while (true) {
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(
viewer: Awaited<ReturnType<typeof requireStaff>>,
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<string>();
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<CsatSurvey[]> {
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;