diff --git a/convex/metrics.ts b/convex/metrics.ts index f4db8dd..d29a280 100644 --- a/convex/metrics.ts +++ b/convex/metrics.ts @@ -6,7 +6,7 @@ import type { QueryCtx } from "./_generated/server" import { OPEN_STATUSES, ONE_DAY_MS, - fetchScopedTickets, + fetchOpenScopedTickets, fetchScopedTicketsByCreatedRange, fetchScopedTicketsByResolvedRange, normalizeStatus, @@ -189,13 +189,14 @@ async function computeAgentStats( rangeDays: number, agentFilter?: Id<"users">, ) { - const scopedTickets = await fetchScopedTickets(ctx, tenantId, viewer) const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - rangeDays * ONE_DAY_MS const statsMap = new Map() + const openTickets = await fetchOpenScopedTickets(ctx, tenantId, viewer) + const scopedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs) const matchesFilter = (ticket: Doc<"tickets">) => { if (!ticket.assigneeId) return false @@ -203,7 +204,7 @@ async function computeAgentStats( return true } - for (const ticket of scopedTickets) { + for (const ticket of openTickets) { if (!matchesFilter(ticket)) continue const stats = ensureAgentStats(statsMap, ticket) if (!stats) continue @@ -215,9 +216,7 @@ async function computeAgentStats( } } - const inRange = scopedTickets.filter( - (ticket) => matchesFilter(ticket) && ticket.createdAt >= startMs && ticket.createdAt < endMs, - ) + const inRange = scopedTickets.filter((ticket) => matchesFilter(ticket)) const now = Date.now() for (const ticket of inRange) { const stats = ensureAgentStats(statsMap, ticket) @@ -336,7 +335,10 @@ const metricResolvers: Record = { } }, "tickets.waiting_action_now": async (ctx, { tenantId, viewer, params }) => { - const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params)) + const tickets = filterTicketsByQueue( + await fetchOpenScopedTickets(ctx, tenantId, viewer), + parseQueueIds(params), + ) const now = Date.now() let total = 0 let atRisk = 0 @@ -361,7 +363,10 @@ const metricResolvers: Record = { end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - rangeDays * ONE_DAY_MS - const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params)) + const tickets = filterTicketsByQueue( + await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs), + parseQueueIds(params), + ) const daily: Record = {} for (let offset = rangeDays - 1; offset >= 0; offset -= 1) { @@ -500,7 +505,7 @@ const metricResolvers: Record = { ensureEntry(key) } - const scopedTickets = await fetchScopedTickets(ctx, tenantId, viewer) + const scopedTickets = await fetchOpenScopedTickets(ctx, tenantId, viewer) for (const ticket of scopedTickets) { const key = normalizeKey(ticket.queueId ?? null) if (filterHas && queueFilter && !queueFilter.includes(key)) continue @@ -626,7 +631,10 @@ const metricResolvers: Record = { }, "tickets.awaiting_table": async (ctx, { tenantId, viewer, params }) => { const limit = parseLimit(params, 20) - const tickets = filterTicketsByQueue(await fetchScopedTickets(ctx, tenantId, viewer), parseQueueIds(params)) + const tickets = filterTicketsByQueue( + await fetchOpenScopedTickets(ctx, tenantId, viewer), + parseQueueIds(params), + ) const awaiting = tickets .filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) diff --git a/convex/reports.ts b/convex/reports.ts index 281e6dd..72ffe49 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -50,6 +50,79 @@ function resolveCategoryName( export const OPEN_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]); export const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const REPORTS_PAGE_SIZE = 200; + +type PaginatedResult = { + page: T[]; + continueCursor?: string | null; + done?: boolean; + isDone?: boolean; +}; + +async function paginateTickets( + buildQuery: () => { + paginate: (options: { cursor: string | null; numItems: number }) => Promise>; + }, + handler: (doc: T) => void | Promise, + pageSize = REPORTS_PAGE_SIZE, +) { + const query = buildQuery(); + let cursor: string | null = null; + while (true) { + const page = await query.paginate({ cursor, numItems: pageSize }); + for (const doc of page.page) { + await handler(doc); + } + const done = page.done ?? page.isDone ?? !page.continueCursor; + if (done) { + break; + } + cursor = page.continueCursor ?? null; + } +} + +function resolveScopedCompanyId( + viewer: Awaited>, + companyId?: Id<"companies">, +): Id<"companies"> | undefined { + if (viewer.role === "MANAGER") { + if (!viewer.user.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada"); + } + return viewer.user.companyId; + } + return companyId; +} + +export async function fetchOpenScopedTickets( + ctx: QueryCtx, + tenantId: string, + viewer: Awaited>, + companyId?: Id<"companies">, +): Promise[]> { + const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); + const statuses: TicketStatusNormalized[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]; + const results: Doc<"tickets">[] = []; + + for (const status of statuses) { + await paginateTickets( + () => + ctx.db + .query("tickets") + .withIndex("by_tenant_status", (q) => q.eq("tenantId", tenantId).eq("status", status)) + .order("desc"), + (ticket) => { + if (scopedCompanyId && ticket.companyId !== scopedCompanyId) { + return; + } + results.push(ticket); + }, + ); + } + + return results; +} + function percentageChange(current: number, previous: number) { if (previous === 0) { return current === 0 ? 0 : null; @@ -106,10 +179,14 @@ function isNotNull(value: T | null): value is T { } export async function fetchTickets(ctx: QueryCtx, tenantId: string) { - return ctx.db - .query("tickets") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect(); + const results: Doc<"tickets">[] = []; + await paginateTickets( + () => ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).order("desc"), + (ticket) => { + results.push(ticket); + }, + ); + return results; } async function fetchCategoryMap(ctx: QueryCtx, tenantId: string) { @@ -129,18 +206,25 @@ export async function fetchScopedTickets( tenantId: string, viewer: Awaited>, ) { - if (viewer.role === "MANAGER") { - if (!viewer.user.companyId) { - throw new ConvexError("Gestor não possui empresa vinculada"); - } - return ctx.db - .query("tickets") - .withIndex("by_tenant_company", (q) => - q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!) - ) - .collect(); - } - return fetchTickets(ctx, tenantId); + 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); + }, + ); + + return results; } export async function fetchScopedTicketsByCreatedRange( @@ -151,61 +235,31 @@ export async function fetchScopedTicketsByCreatedRange( endMs: number, companyId?: Id<"companies">, ) { - const collectRange = async (buildQuery: (chunkStart: number) => unknown) => { - const results: Doc<"tickets">[] = []; - const chunkSize = 7 * ONE_DAY_MS; - for (let chunkStart = startMs; chunkStart < endMs; chunkStart += chunkSize) { - const chunkEnd = Math.min(chunkStart + chunkSize, endMs); - const baseQuery = buildQuery(chunkStart); - const filterFn = (baseQuery as { filter?: (fn: (q: QueryFilterBuilder) => unknown) => unknown }).filter; - const queryForChunk = - typeof filterFn === "function" - ? filterFn.call(baseQuery, (q: QueryFilterBuilder) => q.lt(q.field("createdAt"), chunkEnd)) - : baseQuery; - const collectFn = (queryForChunk as { collect?: () => Promise[]> }).collect; - if (typeof collectFn !== "function") { - throw new ConvexError("Indexed query does not support collect (createdAt)"); - } - const page = await collectFn.call(queryForChunk); - if (!page || page.length === 0) continue; - for (const ticket of page) { - const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null; - if (createdAt === null) continue; - if (createdAt < chunkStart || createdAt >= endMs) continue; - results.push(ticket); - } - } - return results; - }; + const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); + const results: Doc<"tickets">[] = []; - if (viewer.role === "MANAGER") { - if (!viewer.user.companyId) { - throw new ConvexError("Gestor não possui empresa vinculada"); - } - return collectRange((chunkStart) => - ctx.db - .query("tickets") - .withIndex("by_tenant_company_created", (q) => - q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("createdAt", chunkStart) - ) - ); - } - - if (companyId) { - return collectRange((chunkStart) => - ctx.db - .query("tickets") - .withIndex("by_tenant_company_created", (q) => - q.eq("tenantId", tenantId).eq("companyId", companyId).gte("createdAt", chunkStart) - ) - ); - } - - return collectRange((chunkStart) => - ctx.db - .query("tickets") - .withIndex("by_tenant_created", (q) => q.eq("tenantId", tenantId).gte("createdAt", chunkStart)) + 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"); + }, + (ticket) => { + const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null; + if (createdAt === null) return; + if (createdAt < startMs || createdAt >= endMs) return; + results.push(ticket); + }, ); + + return results; } export async function fetchScopedTicketsByResolvedRange( @@ -216,61 +270,31 @@ export async function fetchScopedTicketsByResolvedRange( endMs: number, companyId?: Id<"companies">, ) { - const collectRange = async (buildQuery: (chunkStart: number) => unknown) => { - const results: Doc<"tickets">[] = []; - const chunkSize = 7 * ONE_DAY_MS; - for (let chunkStart = startMs; chunkStart < endMs; chunkStart += chunkSize) { - const chunkEnd = Math.min(chunkStart + chunkSize, endMs); - const baseQuery = buildQuery(chunkStart); - const filterFn = (baseQuery as { filter?: (fn: (q: QueryFilterBuilder) => unknown) => unknown }).filter; - const queryForChunk = - typeof filterFn === "function" - ? filterFn.call(baseQuery, (q: QueryFilterBuilder) => q.lt(q.field("resolvedAt"), chunkEnd)) - : baseQuery; - const collectFn = (queryForChunk as { collect?: () => Promise[]> }).collect; - if (typeof collectFn !== "function") { - throw new ConvexError("Indexed query does not support collect (resolvedAt)"); - } - const page = await collectFn.call(queryForChunk); - if (!page || page.length === 0) continue; - for (const ticket of page) { - const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null; - if (resolvedAt === null) continue; - if (resolvedAt < chunkStart || resolvedAt >= endMs) continue; - results.push(ticket); - } - } - return results; - }; + const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); + const results: Doc<"tickets">[] = []; - if (viewer.role === "MANAGER") { - if (!viewer.user.companyId) { - throw new ConvexError("Gestor não possui empresa vinculada"); - } - return collectRange((chunkStart) => - ctx.db - .query("tickets") - .withIndex("by_tenant_company_resolved", (q) => - q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("resolvedAt", chunkStart) - ) - ); - } - - if (companyId) { - return collectRange((chunkStart) => - ctx.db - .query("tickets") - .withIndex("by_tenant_company_resolved", (q) => - q.eq("tenantId", tenantId).eq("companyId", companyId).gte("resolvedAt", chunkStart) - ) - ); - } - - return collectRange((chunkStart) => - ctx.db - .query("tickets") - .withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", chunkStart)) + 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"); + }, + (ticket) => { + const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null; + if (resolvedAt === null) return; + if (resolvedAt < startMs || resolvedAt >= endMs) return; + results.push(ticket); + }, ); + + return results; } async function fetchQueues(ctx: QueryCtx, tenantId: string) { @@ -320,65 +344,53 @@ 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 - email?: string - } | null - const assigneeId = - ticket.csatAssigneeId && typeof ticket.csatAssigneeId === "string" - ? ticket.csatAssigneeId - : ticket.csatAssigneeId - ? String(ticket.csatAssigneeId) - : null - const assigneeName = - snapshot && typeof snapshot.name === "string" && snapshot.name.trim().length > 0 - ? snapshot.name.trim() - : null - return [ - { - ticketId: ticket._id, - reference: ticket.reference, - score: ticket.csatScore, - maxScore: ticket.csatMaxScore && Number.isFinite(ticket.csatMaxScore) ? (ticket.csatMaxScore as number) : 5, - comment: - typeof ticket.csatComment === "string" && ticket.csatComment.trim().length > 0 - ? ticket.csatComment.trim() - : null, - receivedAt: ticket.csatRatedAt ?? ticket.updatedAt ?? ticket.createdAt, - assigneeId, - assigneeName, - } satisfies CsatSurvey, - ]; +function collectCsatSurveys(tickets: Doc<"tickets">[]): CsatSurvey[] { + return tickets + .map((ticket) => { + if (typeof ticket.csatScore !== "number") { + return null; } - 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); + 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; }) - ); - return perTicket.flat(); + .filter(isNotNull); } function formatDateKey(timestamp: number) { @@ -394,15 +406,13 @@ export async function slaOverviewHandler( { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); - let tickets = await fetchScopedTickets(ctx, tenantId, viewer); - if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) // Optional range filter (createdAt) for reporting purposes, similar ao backlog/csat const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const end = new Date(); end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; - const inRange = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs); + const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); const queues = await fetchQueues(ctx, tenantId); const categoriesMap = await fetchCategoryMap(ctx, tenantId); @@ -543,15 +553,13 @@ export async function csatOverviewHandler( { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); - let tickets = await fetchScopedTickets(ctx, tenantId, viewer); - if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) - const surveysAll = await collectCsatSurveys(ctx, tickets); const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const end = new Date(); end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; - const surveys = surveysAll.filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); + const tickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId); + const surveys = collectCsatSurveys(tickets).filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs); const normalizeToFive = (value: CsatSurvey) => { if (!value.maxScore || value.maxScore <= 0) return value.score; @@ -775,7 +783,8 @@ export async function queueLoadTrendHandler( end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - days * ONE_DAY_MS - const tickets = await fetchScopedTickets(ctx, tenantId, viewer) + const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs) + const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs) const queues = await fetchQueues(ctx, tenantId) const queueNames = new Map() @@ -806,24 +815,26 @@ export async function queueLoadTrendHandler( return stats.get(queueId)! } - for (const ticket of tickets) { + for (const ticket of openedTickets) { const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned" - if (ticket.createdAt >= startMs && ticket.createdAt < endMs) { - const entry = ensureEntry(queueId) - const bucket = entry.series.get(formatDateKey(ticket.createdAt)) - if (bucket) { - bucket.opened += 1 - } - entry.openedTotal += 1 + const entry = ensureEntry(queueId) + const bucket = entry.series.get(formatDateKey(ticket.createdAt)) + if (bucket) { + bucket.opened += 1 } - if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < endMs) { - const entry = ensureEntry(queueId) - const bucket = entry.series.get(formatDateKey(ticket.resolvedAt)) - if (bucket) { - bucket.resolved += 1 - } - entry.resolvedTotal += 1 + entry.openedTotal += 1 + } + + for (const ticket of resolvedTickets) { + const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned" + const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null + if (resolvedAt === null) continue + const entry = ensureEntry(queueId) + const bucket = entry.series.get(formatDateKey(resolvedAt)) + if (bucket) { + bucket.resolved += 1 } + entry.resolvedTotal += 1 } const maxEntries = Math.max(1, Math.min(limit ?? 3, 6)) @@ -852,8 +863,6 @@ export async function agentProductivityHandler( { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) - let tickets = await fetchScopedTickets(ctx, tenantId, viewer) - if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 const end = new Date() @@ -861,7 +870,7 @@ export async function agentProductivityHandler( const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - days * ONE_DAY_MS - const inRange = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs) + const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) type Acc = { agentId: Id<"users"> name: string | null @@ -1075,52 +1084,55 @@ export async function dashboardOverviewHandler( { tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); - const tickets = await fetchScopedTickets(ctx, tenantId, viewer); const now = Date.now(); const lastDayStart = now - ONE_DAY_MS; const previousDayStart = now - 2 * ONE_DAY_MS; - - const newTickets = tickets.filter((ticket) => ticket.createdAt >= lastDayStart); - const previousTickets = tickets.filter( - (ticket) => ticket.createdAt >= previousDayStart && ticket.createdAt < lastDayStart - ); - - const trend = percentageChange(newTickets.length, previousTickets.length); - - const inProgressCurrent = tickets.filter((ticket) => { - if (!ticket.firstResponseAt) return false; - const status = normalizeStatus(ticket.status); - if (status === "RESOLVED") return false; - return !ticket.resolvedAt; - }); - - const inProgressPrevious = tickets.filter((ticket) => { - if (!ticket.firstResponseAt || ticket.firstResponseAt >= lastDayStart) return false; - if (ticket.resolvedAt && ticket.resolvedAt < lastDayStart) return false; - const status = normalizeStatus(ticket.status); - return status !== "RESOLVED" || !ticket.resolvedAt; - }); - - const inProgressTrend = percentageChange(inProgressCurrent.length, inProgressPrevious.length); - const lastWindowStart = now - 7 * ONE_DAY_MS; const previousWindowStart = now - 14 * ONE_DAY_MS; - const firstResponseWindow = tickets + const openTickets = await fetchOpenScopedTickets(ctx, tenantId, viewer); + const recentCreatedTickets = await fetchScopedTicketsByCreatedRange( + ctx, + tenantId, + viewer, + previousWindowStart, + now, + ); + const recentResolvedTickets = await fetchScopedTicketsByResolvedRange( + ctx, + tenantId, + viewer, + previousWindowStart, + now, + ); + + const newTickets = recentCreatedTickets.filter((ticket) => ticket.createdAt >= lastDayStart); + const previousTickets = recentCreatedTickets.filter( + (ticket) => ticket.createdAt >= previousDayStart && ticket.createdAt < lastDayStart, + ); + const trend = percentageChange(newTickets.length, previousTickets.length); + + const inProgressCurrent = openTickets.filter((ticket) => Boolean(ticket.firstResponseAt)); + const inProgressPrevious = openTickets.filter( + (ticket) => Boolean(ticket.firstResponseAt && ticket.firstResponseAt < lastDayStart), + ); + const inProgressTrend = percentageChange(inProgressCurrent.length, inProgressPrevious.length); + + const firstResponseWindow = recentCreatedTickets .filter( (ticket) => ticket.createdAt >= lastWindowStart && ticket.createdAt < now && - ticket.firstResponseAt + ticket.firstResponseAt, ) .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); - const firstResponsePrevious = tickets + const firstResponsePrevious = recentCreatedTickets .filter( (ticket) => ticket.createdAt >= previousWindowStart && ticket.createdAt < lastWindowStart && - ticket.firstResponseAt + ticket.firstResponseAt, ) .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); @@ -1129,23 +1141,21 @@ export async function dashboardOverviewHandler( const deltaMinutes = averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null; - const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); + const awaitingTickets = openTickets; const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); - const resolvedLastWindow = tickets.filter( - (ticket) => ticket.resolvedAt && ticket.resolvedAt >= lastWindowStart && ticket.resolvedAt < now + const resolvedLastWindow = recentResolvedTickets.filter( + (ticket) => ticket.resolvedAt && ticket.resolvedAt >= lastWindowStart && ticket.resolvedAt < now, ); - const resolvedPreviousWindow = tickets.filter( + const resolvedPreviousWindow = recentResolvedTickets.filter( (ticket) => ticket.resolvedAt && ticket.resolvedAt >= previousWindowStart && - ticket.resolvedAt < lastWindowStart + ticket.resolvedAt < lastWindowStart, ); - const resolutionRate = tickets.length > 0 ? (resolvedLastWindow.length / tickets.length) * 100 : null; - const resolutionDelta = - resolvedPreviousWindow.length > 0 - ? ((resolvedLastWindow.length - resolvedPreviousWindow.length) / resolvedPreviousWindow.length) * 100 - : null; + const resolutionBase = Math.max(recentCreatedTickets.length, awaitingTickets.length); + const resolutionRate = resolutionBase > 0 ? (resolvedLastWindow.length / resolutionBase) * 100 : null; + const resolutionDelta = percentageChange(resolvedLastWindow.length, resolvedPreviousWindow.length); return { newTickets: { @@ -1187,14 +1197,13 @@ export async function ticketsByChannelHandler( { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); - let tickets = await fetchScopedTickets(ctx, tenantId, viewer); - if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; const end = new Date(); 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) { diff --git a/convex/tickets.ts b/convex/tickets.ts index 888fc6a..580ac89 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -3,7 +3,7 @@ import { mutation, query } from "./_generated/server"; import { api } from "./_generated/api"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; -import { Id, type Doc } from "./_generated/dataModel"; +import { Id, type Doc, type TableNames } from "./_generated/dataModel"; import { requireAdmin, requireStaff, requireUser } from "./rbac"; import { @@ -1379,12 +1379,12 @@ function getCustomFieldRecordEntry( return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined; } -const DEFAULT_TICKETS_LIST_LIMIT = 250; +const DEFAULT_TICKETS_LIST_LIMIT = 120; const MIN_TICKETS_LIST_LIMIT = 25; -const MAX_TICKETS_LIST_LIMIT = 600; -const MAX_FETCH_LIMIT = 1000; -const FETCH_MULTIPLIER_NO_SEARCH = 3; -const FETCH_MULTIPLIER_WITH_SEARCH = 5; +const MAX_TICKETS_LIST_LIMIT = 400; +const MAX_FETCH_LIMIT = 400; +const BASE_FETCH_PADDING = 50; +const SEARCH_FETCH_PADDING = 200; function clampTicketLimit(limit: number) { if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT; @@ -1392,11 +1392,31 @@ function clampTicketLimit(limit: number) { } function computeFetchLimit(limit: number, hasSearch: boolean) { - const multiplier = hasSearch ? FETCH_MULTIPLIER_WITH_SEARCH : FETCH_MULTIPLIER_NO_SEARCH; - const target = limit * multiplier; + const padding = hasSearch ? SEARCH_FETCH_PADDING : BASE_FETCH_PADDING; + const target = limit + padding; return Math.max(limit, Math.min(MAX_FETCH_LIMIT, target)); } +async function loadDocs( + ctx: QueryCtx, + ids: (Id | null | undefined)[], +): Promise>> { + const uniqueIds = Array.from( + new Set(ids.filter((value): value is Id => Boolean(value))), + ); + if (uniqueIds.length === 0) { + return new Map(); + } + const docs = await Promise.all(uniqueIds.map((id) => ctx.db.get(id))); + const map = new Map>(); + docs.forEach((doc, index) => { + if (doc) { + map.set(String(uniqueIds[index]), doc); + } + }); + return map; +} + function dedupeTicketsById(tickets: Doc<"tickets">[]) { const seen = new Set(); const result: Doc<"tickets">[] = []; @@ -1515,151 +1535,175 @@ export const list = query({ } const limited = filtered.slice(0, requestedLimit); - const categoryCache = new Map | null>(); - const subcategoryCache = new Map | null>(); - const machineCache = new Map | null>(); - // hydrate requester and assignee - const result = await Promise.all( - limited.map(async (t) => { - const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null; - const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; - const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; - const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null; - const queueName = normalizeQueueName(queue); - const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null; - let categorySummary: { id: Id<"ticketCategories">; name: string } | null = null; - let subcategorySummary: { id: Id<"ticketSubcategories">; name: string } | null = null; - if (t.categoryId) { - if (!categoryCache.has(t.categoryId)) { - categoryCache.set(t.categoryId, await ctx.db.get(t.categoryId)); - } - const category = categoryCache.get(t.categoryId); - if (category) { - categorySummary = { id: category._id, name: category.name }; - } - } - if (t.subcategoryId) { - if (!subcategoryCache.has(t.subcategoryId)) { - subcategoryCache.set(t.subcategoryId, await ctx.db.get(t.subcategoryId)); - } - const subcategory = subcategoryCache.get(t.subcategoryId); - if (subcategory) { - subcategorySummary = { id: subcategory._id, name: subcategory.name }; - } - } - const machineSnapshot = t.machineSnapshot as - | { - hostname?: string - persona?: string - assignedUserName?: string - assignedUserEmail?: string - status?: string + if (limited.length === 0) { + return []; + } + + const [ + requesterDocs, + assigneeDocs, + queueDocs, + companyDocs, + machineDocs, + activeSessionDocs, + categoryDocs, + subcategoryDocs, + ] = await Promise.all([ + loadDocs(ctx, limited.map((t) => t.requesterId)), + loadDocs(ctx, limited.map((t) => (t.assigneeId as Id<"users"> | null) ?? null)), + loadDocs(ctx, limited.map((t) => (t.queueId as Id<"queues"> | null) ?? null)), + loadDocs(ctx, limited.map((t) => (t.companyId as Id<"companies"> | null) ?? null)), + loadDocs(ctx, limited.map((t) => (t.machineId as Id<"machines"> | null) ?? null)), + loadDocs(ctx, limited.map((t) => (t.activeSessionId as Id<"ticketWorkSessions"> | null) ?? null)), + loadDocs(ctx, limited.map((t) => (t.categoryId as Id<"ticketCategories"> | null) ?? null)), + loadDocs(ctx, limited.map((t) => (t.subcategoryId as Id<"ticketSubcategories"> | null) ?? null)), + ]); + + const serverNow = Date.now(); + const result = limited.map((t) => { + const requesterSnapshot = t.requesterSnapshot as UserSnapshot | undefined; + const requesterDoc = requesterDocs.get(String(t.requesterId)) ?? null; + const requesterSummary = requesterDoc + ? buildRequesterSummary(requesterDoc, t.requesterId, { ticketId: t._id }) + : buildRequesterFromSnapshot(t.requesterId, requesterSnapshot, { ticketId: t._id }); + + const assigneeDoc = t.assigneeId + ? assigneeDocs.get(String(t.assigneeId)) ?? null + : null; + const assigneeSummary = t.assigneeId + ? assigneeDoc + ? { + id: assigneeDoc._id, + name: assigneeDoc.name, + email: assigneeDoc.email, + avatarUrl: assigneeDoc.avatarUrl, + teams: normalizeTeams(assigneeDoc.teams), } - | undefined; - let machineSummary: - | { - id: Id<"machines"> | null - hostname: string | null - persona: string | null - assignedUserName: string | null - assignedUserEmail: string | null - status: string | null - } - | null = null; - if (t.machineId) { - const cacheKey = String(t.machineId); - if (!machineCache.has(cacheKey)) { - machineCache.set(cacheKey, (await ctx.db.get(t.machineId)) as Doc<"machines"> | null); + : buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined) + : null; + + const queueDoc = t.queueId ? queueDocs.get(String(t.queueId)) ?? null : null; + const queueName = normalizeQueueName(queueDoc); + + const companyDoc = t.companyId ? companyDocs.get(String(t.companyId)) ?? null : null; + const companySummary = companyDoc + ? { id: companyDoc._id, name: companyDoc.name, isAvulso: companyDoc.isAvulso ?? false } + : t.companyId || t.companySnapshot + ? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined) + : null; + + const machineSnapshot = t.machineSnapshot as + | { + hostname?: string; + persona?: string; + assignedUserName?: string; + assignedUserEmail?: string; + status?: string; } - const machineDoc = machineCache.get(cacheKey); - machineSummary = { - id: t.machineId, - hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null, - persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null, - assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null, - assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null, - status: machineDoc?.status ?? machineSnapshot?.status ?? null, - }; - } else if (machineSnapshot) { - machineSummary = { - id: null, - hostname: machineSnapshot.hostname ?? null, - persona: machineSnapshot.persona ?? null, - assignedUserName: machineSnapshot.assignedUserName ?? null, - assignedUserEmail: machineSnapshot.assignedUserEmail ?? null, - status: machineSnapshot.status ?? null, - }; - } - const serverNow = Date.now() - return { - id: t._id, - reference: t.reference, - tenantId: t.tenantId, - subject: t.subject, - summary: t.summary, - status: normalizeStatus(t.status), - priority: t.priority, - channel: t.channel, - queue: queueName, - csatScore: typeof t.csatScore === "number" ? t.csatScore : null, - csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null, - csatComment: typeof t.csatComment === "string" && t.csatComment.trim().length > 0 ? t.csatComment.trim() : null, - csatRatedAt: t.csatRatedAt ?? null, - csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null, - formTemplate: t.formTemplate ?? null, - formTemplateLabel: resolveFormTemplateLabel(t.formTemplate ?? null, t.formTemplateLabel ?? null), - company: company - ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } - : t.companyId || t.companySnapshot - ? buildCompanyFromSnapshot(t.companyId as Id<"companies"> | undefined, t.companySnapshot ?? undefined) - : null, - requester: requester - ? buildRequesterSummary(requester, t.requesterId, { ticketId: t._id }) - : buildRequesterFromSnapshot( - t.requesterId, - t.requesterSnapshot ?? undefined, - { ticketId: t._id } - ), - assignee: t.assigneeId - ? assignee - ? { - id: assignee._id, - name: assignee.name, - email: assignee.email, - avatarUrl: assignee.avatarUrl, - teams: normalizeTeams(assignee.teams), - } - : buildAssigneeFromSnapshot(t.assigneeId, t.assigneeSnapshot ?? undefined) - : null, - slaPolicy: null, - dueAt: t.dueAt ?? null, - firstResponseAt: t.firstResponseAt ?? null, - resolvedAt: t.resolvedAt ?? null, - updatedAt: t.updatedAt, - createdAt: t.createdAt, - tags: t.tags ?? [], - lastTimelineEntry: null, - metrics: null, - category: categorySummary, - subcategory: subcategorySummary, - machine: machineSummary, - workSummary: { - totalWorkedMs: t.totalWorkedMs ?? 0, - internalWorkedMs: t.internalWorkedMs ?? 0, - externalWorkedMs: t.externalWorkedMs ?? 0, - serverNow, - activeSession: activeSession - ? { - id: activeSession._id, - agentId: activeSession.agentId, - startedAt: activeSession.startedAt, - workType: activeSession.workType ?? "INTERNAL", - } - : null, - }, + | undefined; + const machineDoc = t.machineId ? machineDocs.get(String(t.machineId)) ?? null : null; + let machineSummary: + | { + id: Id<"machines"> | null; + hostname: string | null; + persona: string | null; + assignedUserName: string | null; + assignedUserEmail: string | null; + status: string | null; + } + | null = null; + if (t.machineId) { + machineSummary = { + id: t.machineId, + hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null, + persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null, + assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null, + assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null, + status: machineDoc?.status ?? machineSnapshot?.status ?? null, }; - }) - ); + } else if (machineSnapshot) { + machineSummary = { + id: null, + hostname: machineSnapshot.hostname ?? null, + persona: machineSnapshot.persona ?? null, + assignedUserName: machineSnapshot.assignedUserName ?? null, + assignedUserEmail: machineSnapshot.assignedUserEmail ?? null, + status: machineSnapshot.status ?? null, + }; + } + + const categoryDoc = t.categoryId ? categoryDocs.get(String(t.categoryId)) ?? null : null; + const categorySummary = categoryDoc + ? { id: categoryDoc._id, name: categoryDoc.name } + : null; + + const subcategoryDoc = t.subcategoryId + ? subcategoryDocs.get(String(t.subcategoryId)) ?? null + : null; + const subcategorySummary = subcategoryDoc + ? { id: subcategoryDoc._id, name: subcategoryDoc.name, categoryId: subcategoryDoc.categoryId } + : null; + + const activeSessionDoc = t.activeSessionId + ? activeSessionDocs.get(String(t.activeSessionId)) ?? null + : null; + const activeSession = activeSessionDoc + ? { + id: activeSessionDoc._id, + agentId: activeSessionDoc.agentId, + startedAt: activeSessionDoc.startedAt, + workType: activeSessionDoc.workType ?? "INTERNAL", + } + : null; + + return { + id: t._id, + reference: t.reference, + tenantId: t.tenantId, + subject: t.subject, + summary: t.summary, + status: normalizeStatus(t.status), + priority: t.priority, + channel: t.channel, + queue: queueName, + csatScore: typeof t.csatScore === "number" ? t.csatScore : null, + csatMaxScore: typeof t.csatMaxScore === "number" ? t.csatMaxScore : null, + csatComment: + typeof t.csatComment === "string" && t.csatComment.trim().length > 0 + ? t.csatComment.trim() + : null, + csatRatedAt: t.csatRatedAt ?? null, + csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null, + formTemplate: t.formTemplate ?? null, + formTemplateLabel: resolveFormTemplateLabel( + t.formTemplate ?? null, + t.formTemplateLabel ?? null, + ), + company: companySummary, + requester: requesterSummary, + assignee: assigneeSummary, + slaPolicy: null, + dueAt: t.dueAt ?? null, + firstResponseAt: t.firstResponseAt ?? null, + resolvedAt: t.resolvedAt ?? null, + updatedAt: t.updatedAt, + createdAt: t.createdAt, + tags: t.tags ?? [], + lastTimelineEntry: null, + metrics: null, + category: categorySummary, + subcategory: subcategorySummary, + machine: machineSummary, + workSummary: { + totalWorkedMs: t.totalWorkedMs ?? 0, + internalWorkedMs: t.internalWorkedMs ?? 0, + externalWorkedMs: t.externalWorkedMs ?? 0, + serverNow, + activeSession, + }, + }; + }); + // sort by updatedAt desc return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); }, diff --git a/stack.yml b/stack.yml index adfaed0..4e7e805 100644 --- a/stack.yml +++ b/stack.yml @@ -85,7 +85,7 @@ services: failure_action: rollback resources: limits: - memory: "4G" + memory: "5G" restart_policy: condition: any placement: @@ -114,7 +114,7 @@ services: - NEXT_PUBLIC_DEPLOYMENT_URL=https://convex.esdrasrenan.com.br deploy: mode: replicated - replicas: 1 + replicas: 0 placement: constraints: - node.role == manager