From c3ee23f967ee0eb3b593dec901af3c1c0b7990a1 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Mon, 17 Nov 2025 10:53:06 -0300 Subject: [PATCH] Reduce Convex report memory footprint --- convex/reports.ts | 586 ++++++++++++++++++++++++++-------------------- 1 file changed, 332 insertions(+), 254 deletions(-) diff --git a/convex/reports.ts b/convex/reports.ts index a3b2efd..7ab3bda 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -169,6 +169,120 @@ function resolveScopedCompanyId( return companyId; } +type TicketProcessor = (ticket: Doc<"tickets">) => void | Promise; + +async function forEachScopedTicket( + ctx: QueryCtx, + tenantId: string, + viewer: Awaited>, + processor: TicketProcessor, +) { + const scopedCompanyId = resolveScopedCompanyId(viewer); + await paginateTickets( + () => + scopedCompanyId + ? ctx.db + .query("tickets") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId)) + .order("desc") + : ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).order("desc"), + async (ticket) => { + await processor(ticket); + }, + ); +} + +async function forEachTenantTicket(ctx: QueryCtx, tenantId: string, processor: TicketProcessor) { + await paginateTickets( + () => ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).order("desc"), + async (ticket) => { + await processor(ticket); + }, + ); +} + +async function forEachScopedTicketByCreatedRange( + ctx: QueryCtx, + tenantId: string, + viewer: Awaited>, + startMs: number, + endMs: number, + companyId: Id<"companies"> | undefined, + processor: TicketProcessor, +) { + const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); + const query = scopedCompanyId + ? 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") + : 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"); + + await paginateTickets( + () => query, + async (ticket) => { + const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null; + if (createdAt === null) return; + if (createdAt < startMs || createdAt >= endMs) return; + await processor(ticket); + }, + ); +} + +async function forEachScopedTicketByResolvedRange( + ctx: QueryCtx, + tenantId: string, + viewer: Awaited>, + startMs: number, + endMs: number, + companyId: Id<"companies"> | undefined, + processor: TicketProcessor, +) { + const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); + const query = scopedCompanyId + ? 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") + : 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"); + + await paginateTickets( + () => query, + async (ticket) => { + const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null; + if (resolvedAt === null) return; + if (resolvedAt < startMs || resolvedAt >= endMs) return; + await processor(ticket); + }, + ); +} + export async function fetchOpenScopedTickets( ctx: QueryCtx, tenantId: string, @@ -255,12 +369,9 @@ function isNotNull(value: T | null): value is T { export async function fetchTickets(ctx: QueryCtx, tenantId: string) { const results: Doc<"tickets">[] = []; - await paginateTickets( - () => ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).order("desc"), - (ticket) => { - results.push(ticket); - }, - ); + await forEachTenantTicket(ctx, tenantId, (ticket) => { + results.push(ticket); + }); return results; } @@ -284,20 +395,9 @@ export async function fetchScopedTickets( const scopedCompanyId = resolveScopedCompanyId(viewer); const results: Doc<"tickets">[] = []; - await paginateTickets( - () => { - if (scopedCompanyId) { - return ctx.db - .query("tickets") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", scopedCompanyId)) - .order("desc"); - } - return ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).order("desc"); - }, - (ticket) => { - results.push(ticket); - }, - ); + await forEachScopedTicket(ctx, tenantId, viewer, (ticket) => { + results.push(ticket); + }); return results; } @@ -310,36 +410,10 @@ export async function fetchScopedTicketsByCreatedRange( endMs: number, companyId?: Id<"companies">, ) { - const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); const results: Doc<"tickets">[] = []; - - const query = scopedCompanyId - ? 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") - : 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"); - - const snapshot = await query.collect(); - for (const ticket of snapshot) { - const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null; - if (createdAt === null) continue; - if (createdAt < startMs || createdAt >= endMs) continue; + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId, (ticket) => { results.push(ticket); - } + }); return results; } @@ -352,36 +426,10 @@ export async function fetchScopedTicketsByResolvedRange( endMs: number, companyId?: Id<"companies">, ) { - const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); const results: Doc<"tickets">[] = []; - - const query = scopedCompanyId - ? 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") - : 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"); - - const snapshot = await query.collect(); - for (const ticket of snapshot) { - const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null; - if (resolvedAt === null) continue; - if (resolvedAt < startMs || resolvedAt >= endMs) continue; + await forEachScopedTicketByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId, (ticket) => { results.push(ticket); - } + }); return results; } @@ -393,6 +441,30 @@ async function fetchQueues(ctx: QueryCtx, tenantId: string) { .collect(); } +type CompanySummary = { + name: string; + isAvulso: boolean; + contractedHoursPerMonth: number | null; +}; + +async function getCompanySummary( + ctx: QueryCtx, + companyId: Id<"companies">, + cache: Map, +): Promise { + const key = String(companyId); + const cached = cache.get(key); + if (cached) return cached; + const company = await ctx.db.get(companyId); + const summary: CompanySummary = { + name: company?.name ?? "Sem empresa", + isAvulso: Boolean(company?.isAvulso ?? false), + contractedHoursPerMonth: company?.contractedHoursPerMonth ?? null, + }; + cache.set(key, summary); + return summary; +} + function deriveMachineStatus(machine: Doc<"machines">, now: number) { if (machine.isActive === false) { return "deactivated"; @@ -433,79 +505,6 @@ type CsatSurvey = { assigneeName: string | 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 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); - }) - ); - return perTicket.flat(); -} - function formatDateKey(timestamp: number) { const date = new Date(timestamp); const year = date.getUTCFullYear(); @@ -534,33 +533,16 @@ export async function slaOverviewHandler( ) { const viewer = await requireStaff(ctx, viewerId, tenantId); const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90); - const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const queues = await fetchQueues(ctx, tenantId); const categoriesMap = await fetchCategoryMap(ctx, tenantId); const now = Date.now(); - const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); - const resolvedTickets = inRange.filter((ticket) => { - const status = normalizeStatus(ticket.status); - return status === "RESOLVED"; - }); - const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); - - const firstResponseTimes = inRange - .filter((ticket) => ticket.firstResponseAt) - .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); - const resolutionTimes = resolvedTickets - .filter((ticket) => ticket.resolvedAt) - .map((ticket) => (ticket.resolvedAt! - ticket.createdAt) / 60000); - - const queueBreakdown = queues.map((queue) => { - const count = openTickets.filter((ticket) => ticket.queueId === queue._id).length; - return { - id: queue._id, - name: queue.name, - open: count, - }; - }); + const totals = { total: 0, open: 0, resolved: 0, overdue: 0 }; + const queueOpenCounts = new Map(); + let firstResponseSum = 0; + let firstResponseCount = 0; + let resolutionSum = 0; + let resolutionCount = 0; const categoryStats = new Map< string, @@ -574,13 +556,37 @@ export async function slaOverviewHandler( } >() - for (const ticket of inRange) { - const snapshot = (ticket.slaSnapshot ?? null) as { categoryId?: Id<"ticketCategories">; categoryName?: string; priority?: string } | null - const rawCategoryId = ticket.categoryId ? String(ticket.categoryId) : snapshot?.categoryId ? String(snapshot.categoryId) : null - const categoryName = resolveCategoryName(rawCategoryId, snapshot, categoriesMap) - const priority = (snapshot?.priority ?? ticket.priority ?? "MEDIUM").toUpperCase() - const key = `${rawCategoryId ?? "uncategorized"}::${priority}` - let stat = categoryStats.get(key) + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId, (ticket) => { + totals.total += 1; + const status = normalizeStatus(ticket.status); + if (OPEN_STATUSES.has(status)) { + totals.open += 1; + if (ticket.dueAt && ticket.dueAt < now) { + totals.overdue += 1; + } + if (ticket.queueId) { + const key = String(ticket.queueId); + queueOpenCounts.set(key, (queueOpenCounts.get(key) ?? 0) + 1); + } + } + if (status === "RESOLVED") { + totals.resolved += 1; + if (typeof ticket.resolvedAt === "number") { + resolutionSum += (ticket.resolvedAt - ticket.createdAt) / 60000; + resolutionCount += 1; + } + } + if (typeof ticket.firstResponseAt === "number") { + firstResponseSum += (ticket.firstResponseAt - ticket.createdAt) / 60000; + firstResponseCount += 1; + } + + const snapshot = (ticket.slaSnapshot ?? null) as { categoryId?: Id<"ticketCategories">; categoryName?: string; priority?: string } | null; + const rawCategoryId = ticket.categoryId ? String(ticket.categoryId) : snapshot?.categoryId ? String(snapshot.categoryId) : null; + const categoryName = resolveCategoryName(rawCategoryId, snapshot, categoriesMap); + const priority = (snapshot?.priority ?? ticket.priority ?? "MEDIUM").toUpperCase(); + const key = `${rawCategoryId ?? "uncategorized"}::${priority}`; + let stat = categoryStats.get(key); if (!stat) { stat = { categoryId: rawCategoryId, @@ -589,17 +595,23 @@ export async function slaOverviewHandler( total: 0, responseMet: 0, solutionMet: 0, - } - categoryStats.set(key, stat) + }; + categoryStats.set(key, stat); } - stat.total += 1 + stat.total += 1; if (ticket.slaResponseStatus === "met") { - stat.responseMet += 1 + stat.responseMet += 1; } if (ticket.slaSolutionStatus === "met") { - stat.solutionMet += 1 + stat.solutionMet += 1; } - } + }); + + const queueBreakdown = queues.map((queue) => ({ + id: queue._id, + name: queue.name, + open: queueOpenCounts.get(String(queue._id)) ?? 0, + })); const categoryBreakdown = Array.from(categoryStats.values()) .map((entry) => ({ @@ -611,18 +623,19 @@ export async function slaOverviewHandler( return { totals: { - total: inRange.length, - open: openTickets.length, - resolved: resolvedTickets.length, - overdue: overdueTickets.length, + total: totals.total, + open: totals.open, + resolved: totals.resolved, + overdue: totals.overdue, }, response: { - averageFirstResponseMinutes: average(firstResponseTimes), - responsesRegistered: firstResponseTimes.length, + averageFirstResponseMinutes: + firstResponseCount > 0 ? firstResponseSum / firstResponseCount : null, + responsesRegistered: firstResponseCount, }, resolution: { - averageResolutionMinutes: average(resolutionTimes), - resolvedCount: resolutionTimes.length, + averageResolutionMinutes: resolutionCount > 0 ? resolutionSum / resolutionCount : null, + resolvedCount: resolutionCount, }, queueBreakdown, categoryBreakdown, @@ -689,9 +702,80 @@ export async function csatOverviewHandler( }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); - const { startMs, endMs } = resolveRangeWindow(range, dateFrom, dateTo, 90); - 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 { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90); + const surveys: CsatSurvey[] = []; + + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId, 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; + } + if (receivedAtRaw < startMs || receivedAtRaw >= endMs) { + return; + } + surveys.push({ + 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, + }); + return; + } + + const events = await ctx.db + .query("ticketEvents") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id)) + .collect(); + + for (const event of events) { + if (event.type !== "CSAT_RECEIVED" && event.type !== "CSAT_RATED") continue; + const score = extractScore(event.payload); + if (score === null) continue; + const assignee = extractAssignee(event.payload); + const receivedAt = event.createdAt; + if (receivedAt < startMs || receivedAt >= endMs) continue; + surveys.push({ + ticketId: ticket._id, + reference: ticket.reference, + score, + maxScore: extractMaxScore(event.payload) ?? 5, + comment: extractComment(event.payload), + receivedAt, + assigneeId: assignee.id, + assigneeName: assignee.name, + }); + } + }); const normalizeToFive = (value: CsatSurvey) => { if (!value.maxScore || value.maxScore <= 0) return value.score; @@ -766,7 +850,7 @@ export async function csatOverviewHandler( assigneeId: item.assigneeId, assigneeName: item.assigneeName, })), - rangeDays: Math.max(1, Math.round((endMs - startMs) / ONE_DAY_MS)), + rangeDays: days, positiveRate, byAgent, }; @@ -861,28 +945,26 @@ export async function backlogOverviewHandler( ) { const viewer = await requireStaff(ctx, viewerId, tenantId); const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90); - const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); - - const statusCounts = inRange.reduce>((acc, ticket) => { - const status = normalizeStatus(ticket.status); - acc[status] = (acc[status] ?? 0) + 1; - return acc; - }, {} as Record); - - const priorityCounts = inRange.reduce>((acc, ticket) => { - acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1; - return acc; - }, {}); - - const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); - + const statusCounts = {} as Record; + const priorityCounts: Record = {}; const queueMap = new Map(); - for (const ticket of openTickets) { - const queueId = ticket.queueId ? ticket.queueId : "sem-fila"; - const current = queueMap.get(queueId) ?? { name: queueId === "sem-fila" ? "Sem fila" : "", count: 0 }; + let totalOpen = 0; + + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId, (ticket) => { + const status = normalizeStatus(ticket.status); + statusCounts[status] = (statusCounts[status] ?? 0) + 1; + priorityCounts[ticket.priority] = (priorityCounts[ticket.priority] ?? 0) + 1; + + if (!OPEN_STATUSES.has(status)) { + return; + } + + totalOpen += 1; + const queueKey = ticket.queueId ? String(ticket.queueId) : "sem-fila"; + const current = queueMap.get(queueKey) ?? { name: queueKey === "sem-fila" ? "Sem fila" : "", count: 0 }; current.count += 1; - queueMap.set(queueId, current); - } + queueMap.set(queueKey, current); + }); const queues = await fetchQueues(ctx, tenantId); @@ -901,7 +983,7 @@ export async function backlogOverviewHandler( name: data.name, total: data.count, })), - totalOpen: openTickets.length, + totalOpen, }; } @@ -1361,7 +1443,6 @@ export async function ticketsByChannelHandler( end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; - const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const timeline = new Map>(); for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) { @@ -1370,15 +1451,14 @@ export async function ticketsByChannelHandler( const channels = new Set(); - for (const ticket of tickets) { - if (ticket.createdAt < startMs || ticket.createdAt >= endMs) continue; + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId, (ticket) => { const dateKey = formatDateKey(ticket.createdAt); const channelKey = ticket.channel ?? "OUTRO"; channels.add(channelKey); const dayMap = timeline.get(dateKey) ?? new Map(); dayMap.set(channelKey, (dayMap.get(channelKey) ?? 0) + 1); timeline.set(dateKey, dayMap); - } + }); const sortedChannels = Array.from(channels).sort(); @@ -1713,9 +1793,8 @@ export async function hoursByClientHandler( { tenantId, viewerId, range, dateFrom, dateTo }: { tenantId: string; viewerId: Id<"users">; range?: string; dateFrom?: string; dateTo?: string } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) - const tickets = await fetchScopedTickets(ctx, tenantId, viewer) - const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90) + const companyCache = new Map() type Acc = { companyId: Id<"companies"> @@ -1728,31 +1807,31 @@ export async function hoursByClientHandler( } const map = new Map() - for (const t of tickets) { - if (t.updatedAt < startMs || t.updatedAt >= endMs) continue - const companyId = t.companyId ?? null - if (!companyId) continue - - let acc = map.get(companyId) + await forEachScopedTicket(ctx, tenantId, viewer, async (ticket) => { + if (ticket.updatedAt < startMs || ticket.updatedAt >= endMs) return + const companyId = ticket.companyId ?? null + if (!companyId) return + const key = String(companyId) + let acc = map.get(key) if (!acc) { - const company = await ctx.db.get(companyId) + const company = await getCompanySummary(ctx, companyId, companyCache) acc = { companyId, - name: company?.name ?? "Sem empresa", - isAvulso: Boolean(company?.isAvulso ?? false), + name: company.name, + isAvulso: company.isAvulso, internalMs: 0, externalMs: 0, totalMs: 0, - contractedHoursPerMonth: company?.contractedHoursPerMonth ?? null, + contractedHoursPerMonth: company.contractedHoursPerMonth, } - map.set(companyId, acc) + map.set(key, acc) } - const internal = t.internalWorkedMs ?? 0 - const external = t.externalWorkedMs ?? 0 + const internal = ticket.internalWorkedMs ?? 0 + const external = ticket.externalWorkedMs ?? 0 acc.internalMs += internal acc.externalMs += external acc.totalMs += internal + external - } + }) const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs) return { @@ -1785,9 +1864,8 @@ export async function hoursByClientInternalHandler( ctx: QueryCtx, { tenantId, range, dateFrom, dateTo }: { tenantId: string; range?: string; dateFrom?: string; dateTo?: string } ) { - const tickets = await fetchTickets(ctx, tenantId) - const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90) + const companyCache = new Map() type Acc = { companyId: Id<"companies"> @@ -1800,31 +1878,31 @@ export async function hoursByClientInternalHandler( } const map = new Map() - for (const t of tickets) { - if (t.updatedAt < startMs || t.updatedAt >= endMs) continue - const companyId = t.companyId ?? null - if (!companyId) continue - - let acc = map.get(companyId) + await forEachTenantTicket(ctx, tenantId, async (ticket) => { + if (ticket.updatedAt < startMs || ticket.updatedAt >= endMs) return + const companyId = ticket.companyId ?? null + if (!companyId) return + const key = String(companyId) + let acc = map.get(key) if (!acc) { - const company = await ctx.db.get(companyId) + const company = await getCompanySummary(ctx, companyId, companyCache) acc = { companyId, - name: company?.name ?? "Sem empresa", - isAvulso: Boolean(company?.isAvulso ?? false), + name: company.name, + isAvulso: company.isAvulso, internalMs: 0, externalMs: 0, totalMs: 0, - contractedHoursPerMonth: company?.contractedHoursPerMonth ?? null, + contractedHoursPerMonth: company.contractedHoursPerMonth, } - map.set(companyId, acc) + map.set(key, acc) } - const internal = t.internalWorkedMs ?? 0 - const external = t.externalWorkedMs ?? 0 + const internal = ticket.internalWorkedMs ?? 0 + const external = ticket.externalWorkedMs ?? 0 acc.internalMs += internal acc.externalMs += external acc.totalMs += internal + external - } + }) const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs) return {