Fix reports pagination helper and CSAT handling
This commit is contained in:
parent
3e4943f79c
commit
1ba1f4a63c
1 changed files with 146 additions and 67 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue