diff --git a/convex/crons.ts b/convex/crons.ts index e90758c..b2d29b3 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -3,12 +3,17 @@ import { api } from "./_generated/api" const crons = cronJobs() -crons.interval( - "report-export-runner", - { minutes: 15 }, - api.reports.triggerScheduledExports, - {} -) +// Keep the handler available but only register it when explicitly enabled in Convex env. +const reportsCronEnabled = process.env.REPORTS_CRON_ENABLED === "true" + +if (reportsCronEnabled) { + crons.interval( + "report-export-runner", + { minutes: 15 }, + api.reports.triggerScheduledExports, + {} + ) +} crons.daily( "auto-pause-internal-lunch", diff --git a/convex/reports.ts b/convex/reports.ts index 7ab3bda..0508c20 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -1,5 +1,5 @@ -import { action, query } from "./_generated/server"; -import type { QueryCtx } from "./_generated/server"; +import { action, mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; @@ -51,6 +51,147 @@ export const OPEN_STATUSES = new Set(["PENDING", "AWAITI export const ONE_DAY_MS = 24 * 60 * 60 * 1000; const REPORTS_PAGE_SIZE = 200; +const RESOLVED_CHUNK_MS = 6 * 60 * 60 * 1000; +const MAX_RANGE_DAYS = 14; +const MAX_RANGE_MS = MAX_RANGE_DAYS * ONE_DAY_MS; +const CACHE_TTL_MS = 60_000; +const LOCK_TTL_MS = 45_000; +const DASHBOARD_LOG_INTERVAL = 400; + +function clampRangeWindow(startMs: number, endMs: number) { + if (endMs <= startMs) { + throw new ConvexError("Intervalo inválido"); + } + if (endMs - startMs > MAX_RANGE_MS) { + return { + startMs, + endMs: startMs + MAX_RANGE_MS, + wasClamped: true, + }; + } + return { startMs, endMs, wasClamped: false }; +} + +function deriveRangeDays(startMs: number, endMs: number) { + return Math.max(1, Math.ceil((endMs - startMs) / ONE_DAY_MS)); +} + +function buildDashboardCacheKey({ + tenantId, + companyId, + range, + dateFrom, + dateTo, + allowWide, +}: { + tenantId: string; + companyId?: Id<"companies">; + range?: string; + dateFrom?: string; + dateTo?: string; + allowWide?: boolean; +}) { + return JSON.stringify({ + tenantId, + companyId: companyId ? String(companyId) : "all", + range: range ?? null, + dateFrom: dateFrom ?? null, + dateTo: dateTo ?? null, + allowWide: allowWide === true, + }); +} + +async function readDashboardCache(ctx: MutationCtx, tenantId: string, cacheKey: string) { + const cached = await ctx.db + .query("analyticsCache") + .withIndex("by_key", (q) => q.eq("tenantId", tenantId).eq("cacheKey", cacheKey)) + .first(); + if (!cached) return null; + if (cached.expiresAt <= Date.now()) { + await ctx.db.delete(cached._id); + return null; + } + return cached; +} + +async function writeDashboardCache( + ctx: MutationCtx, + tenantId: string, + cacheKey: string, + payload: unknown, +) { + const expiresAt = Date.now() + CACHE_TTL_MS; + await ctx.db.insert("analyticsCache", { + tenantId, + cacheKey, + payload, + expiresAt, + _ttl: expiresAt, + }); +} + +async function acquireDashboardLock(ctx: MutationCtx, tenantId: string, cacheKey: string) { + const now = Date.now(); + const existing = await ctx.db + .query("analyticsLocks") + .withIndex("by_key", (q) => q.eq("tenantId", tenantId).eq("cacheKey", cacheKey)) + .first(); + if (existing) { + if (existing.expiresAt > now) { + return null; + } + await ctx.db.delete(existing._id); + } + const expiresAt = now + LOCK_TTL_MS; + const lockId = await ctx.db.insert("analyticsLocks", { + tenantId, + cacheKey, + expiresAt, + _ttl: expiresAt, + }); + return lockId; +} + +async function releaseDashboardLock(ctx: MutationCtx, lockId: Id<"analyticsLocks"> | null) { + if (!lockId) return; + try { + await ctx.db.delete(lockId); + } catch { + // ignore stale lock deletions + } +} + +function logDashboardProgress(processed: number, tenantId: string) { + const rssMb = Math.round((process.memoryUsage().rss ?? 0) / (1024 * 1024)); + console.log( + `[reports] dashboardAggregate tenant=${tenantId} processed=${processed} rssMB=${rssMb}`, + ); +} + +function mapToChronologicalSeries(map: Map) { + return Array.from(map.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([key, count]) => ({ key, count })); +} + +function mapToDescendingSeries(map: Map) { + return Array.from(map.entries()) + .sort((a, b) => { + const diff = b[1] - a[1]; + return diff !== 0 ? diff : a[0].localeCompare(b[0]); + }) + .map(([key, count]) => ({ key, count })); +} + +function flattenMachineCategoryMap(map: Map>) { + const results: Array<{ machineId: string; categoryId: string; count: number }> = []; + for (const [machineId, categories] of map.entries()) { + for (const [categoryId, count] of categories.entries()) { + results.push({ machineId, categoryId, count }); + } + } + return results; +} type PaginatedResult = { page: T[]; @@ -211,28 +352,29 @@ async function forEachScopedTicketByCreatedRange( 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"); + const makeQuery = () => + 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, + () => makeQuery(), async (ticket) => { const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null; if (createdAt === null) return; @@ -283,6 +425,48 @@ async function forEachScopedTicketByResolvedRange( ); } +async function forEachScopedTicketByResolvedRangeChunked( + ctx: QueryCtx, + tenantId: string, + viewer: Awaited>, + startMs: number, + endMs: number, + companyId: Id<"companies"> | undefined, + processor: TicketProcessor, +) { + const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); + for (let chunkStart = startMs; chunkStart < endMs; chunkStart += RESOLVED_CHUNK_MS) { + const chunkEnd = Math.min(chunkStart + RESOLVED_CHUNK_MS, endMs); + 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", chunkStart); + range = withUpperBound(range, "resolvedAt", chunkEnd); + return range; + }) + .order("desc") + : ctx.db + .query("tickets") + .withIndex("by_tenant_resolved", (q) => { + let range = q.eq("tenantId", tenantId); + range = withLowerBound(range, "resolvedAt", chunkStart); + range = withUpperBound(range, "resolvedAt", chunkEnd); + 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 < chunkStart || resolvedAt >= chunkEnd) continue; + await processor(ticket); + } + } +} + export async function fetchOpenScopedTickets( ctx: QueryCtx, tenantId: string, @@ -434,6 +618,29 @@ export async function fetchScopedTicketsByResolvedRange( return results; } +export async function fetchScopedTicketsByResolvedRangeSnapshot( + ctx: QueryCtx, + tenantId: string, + viewer: Awaited>, + startMs: number, + endMs: number, + companyId?: Id<"companies">, +) { + const results: Doc<"tickets">[] = []; + await forEachScopedTicketByResolvedRangeChunked( + ctx, + tenantId, + viewer, + startMs, + endMs, + companyId, + (ticket) => { + results.push(ticket); + }, + ); + return results; +} + async function fetchQueues(ctx: QueryCtx, tenantId: string) { return ctx.db .query("queues") @@ -533,6 +740,8 @@ export async function slaOverviewHandler( ) { const viewer = await requireStaff(ctx, viewerId, tenantId); const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90); + const { endMs: limitedEndMs } = clampRangeWindow(startMs, endMs); + const rangeDays = Math.min(days, deriveRangeDays(startMs, limitedEndMs)); const queues = await fetchQueues(ctx, tenantId); const categoriesMap = await fetchCategoryMap(ctx, tenantId); @@ -556,7 +765,7 @@ export async function slaOverviewHandler( } >() - await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId, (ticket) => { + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, limitedEndMs, companyId, (ticket) => { totals.total += 1; const status = normalizeStatus(ticket.status); if (OPEN_STATUSES.has(status)) { @@ -639,7 +848,7 @@ export async function slaOverviewHandler( }, queueBreakdown, categoryBreakdown, - rangeDays: days, + rangeDays, }; } @@ -703,9 +912,11 @@ export async function csatOverviewHandler( ) { const viewer = await requireStaff(ctx, viewerId, tenantId); const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90); + const { endMs: limitedEndMs } = clampRangeWindow(startMs, endMs); + const rangeDays = Math.min(days, deriveRangeDays(startMs, limitedEndMs)); const surveys: CsatSurvey[] = []; - await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId, async (ticket) => { + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, limitedEndMs, companyId, async (ticket) => { if (typeof ticket.csatScore === "number") { const snapshot = (ticket.csatAssigneeSnapshot ?? null) as { name?: string | null; @@ -850,7 +1061,7 @@ export async function csatOverviewHandler( assigneeId: item.assigneeId, assigneeName: item.assigneeName, })), - rangeDays: days, + rangeDays, positiveRate, byAgent, }; @@ -881,43 +1092,45 @@ export async function openedResolvedByDayHandler( ) { const viewer = await requireStaff(ctx, viewerId, tenantId); const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90); - - const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId); - const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId); + const { endMs: limitedEndMs } = clampRangeWindow(startMs, endMs); + const rangeDays = Math.min(days, deriveRangeDays(startMs, limitedEndMs)); const opened: Record = {} const resolved: Record = {} - for (let i = days - 1; i >= 0; i--) { - const d = new Date(endMs - (i + 1) * ONE_DAY_MS) + for (let i = rangeDays - 1; i >= 0; i--) { + const d = new Date(limitedEndMs - (i + 1) * ONE_DAY_MS) const key = formatDateKey(d.getTime()) opened[key] = 0 resolved[key] = 0 } - for (const ticket of openedTickets) { - if (ticket.createdAt >= startMs && ticket.createdAt < endMs) { - const key = formatDateKey(ticket.createdAt) - opened[key] = (opened[key] ?? 0) + 1 + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, limitedEndMs, companyId, (ticket) => { + const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null + if (createdAt === null || createdAt < startMs || createdAt >= endMs) { + return } - } + const key = formatDateKey(createdAt) + opened[key] = (opened[key] ?? 0) + 1 + }) - for (const ticket of resolvedTickets) { - if (typeof ticket.resolvedAt !== "number") { - continue + await forEachScopedTicketByResolvedRangeChunked(ctx, tenantId, viewer, startMs, limitedEndMs, companyId, (ticket) => { + const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null + if (resolvedAt === null || resolvedAt < startMs || resolvedAt >= endMs) { + return } - const key = formatDateKey(ticket.resolvedAt) + const key = formatDateKey(resolvedAt) resolved[key] = (resolved[key] ?? 0) + 1 - } + }) const series: Array<{ date: string; opened: number; resolved: number }> = [] - for (let i = days - 1; i >= 0; i--) { - const d = new Date(endMs - (i + 1) * ONE_DAY_MS) + for (let i = rangeDays - 1; i >= 0; i--) { + const d = new Date(limitedEndMs - (i + 1) * ONE_DAY_MS) const key = formatDateKey(d.getTime()) series.push({ date: key, opened: opened[key] ?? 0, resolved: resolved[key] ?? 0 }) } - return { rangeDays: days, series } + return { rangeDays, series } } export const openedResolvedByDay = query({ @@ -945,12 +1158,14 @@ export async function backlogOverviewHandler( ) { const viewer = await requireStaff(ctx, viewerId, tenantId); const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90); + const { endMs: limitedEndMs } = clampRangeWindow(startMs, endMs); + const rangeDays = Math.min(days, deriveRangeDays(startMs, limitedEndMs)); const statusCounts = {} as Record; const priorityCounts: Record = {}; const queueMap = new Map(); let totalOpen = 0; - await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId, (ticket) => { + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, limitedEndMs, companyId, (ticket) => { const status = normalizeStatus(ticket.status); statusCounts[status] = (statusCounts[status] ?? 0) + 1; priorityCounts[ticket.priority] = (priorityCounts[ticket.priority] ?? 0) + 1; @@ -975,7 +1190,7 @@ export async function backlogOverviewHandler( } return { - rangeDays: days, + rangeDays, statusCounts, priorityCounts, queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({ @@ -1018,13 +1233,9 @@ export async function queueLoadTrendHandler( }: { tenantId: string; viewerId: Id<"users">; range?: string; limit?: number } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) - const days = range === "90d" ? 90 : range === "30d" ? 30 : 14 - 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 openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs) - const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs) + const { startMs, endMs, days } = resolveRangeWindow(range, undefined, undefined, 90) + const { endMs: limitedEndMs } = clampRangeWindow(startMs, endMs) + const rangeDays = Math.min(days, deriveRangeDays(startMs, limitedEndMs)) const queues = await fetchQueues(ctx, tenantId) const queueNames = new Map() @@ -1032,8 +1243,8 @@ export async function queueLoadTrendHandler( queueNames.set("unassigned", "Sem fila") const dayKeys: string[] = [] - for (let i = days - 1; i >= 0; i--) { - const key = formatDateKey(endMs - (i + 1) * ONE_DAY_MS) + for (let i = rangeDays - 1; i >= 0; i--) { + const key = formatDateKey(limitedEndMs - (i + 1) * ONE_DAY_MS) dayKeys.push(key) } @@ -1055,27 +1266,29 @@ export async function queueLoadTrendHandler( return stats.get(queueId)! } - for (const ticket of openedTickets) { + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, limitedEndMs, undefined, (ticket) => { + const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null + if (createdAt === null) return const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned" const entry = ensureEntry(queueId) - const bucket = entry.series.get(formatDateKey(ticket.createdAt)) + const bucket = entry.series.get(formatDateKey(createdAt)) if (bucket) { bucket.opened += 1 } entry.openedTotal += 1 - } + }) - for (const ticket of resolvedTickets) { - const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned" + await forEachScopedTicketByResolvedRangeChunked(ctx, tenantId, viewer, startMs, limitedEndMs, undefined, (ticket) => { const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null - if (resolvedAt === null) continue + if (resolvedAt === null) return + const queueId = ticket.queueId ? String(ticket.queueId) : "unassigned" 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)) const queuesTrend = Array.from(stats.values()) @@ -1089,7 +1302,7 @@ export async function queueLoadTrendHandler( series: dayKeys.map((key) => entry.series.get(key)!), })) - return { rangeDays: days, queues: queuesTrend } + return { rangeDays, queues: queuesTrend } } export const queueLoadTrend = query({ @@ -1104,11 +1317,11 @@ export async function agentProductivityHandler( ) { const viewer = await requireStaff(ctx, viewerId, tenantId) - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90 + const windowDays = 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 startMs = endMs - windowDays * ONE_DAY_MS const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) type Acc = { @@ -1174,7 +1387,7 @@ export async function agentProductivityHandler( workedHours: Math.round((acc.workedMs / 3600000) * 100) / 100, })) items.sort((a, b) => b.resolved - a.resolved) - return { rangeDays: days, items } + return { rangeDays: windowDays, items } } export const agentProductivity = query({ @@ -1203,8 +1416,9 @@ export async function ticketCategoryInsightsHandler( ) { const viewer = await requireStaff(ctx, viewerId, tenantId) const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90) + const { endMs: limitedEndMs } = clampRangeWindow(startMs, endMs) + const rangeDays = Math.min(days, deriveRangeDays(startMs, limitedEndMs)) - const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) const categories = await ctx.db .query("ticketCategories") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) @@ -1216,8 +1430,9 @@ export async function ticketCategoryInsightsHandler( } const stats = new Map() + let totalTickets = 0 - for (const ticket of inRange) { + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, limitedEndMs, companyId, (ticket) => { const categoryKey = ticket.categoryId ? String(ticket.categoryId) : "uncategorized" let stat = stats.get(categoryKey) if (!stat) { @@ -1232,7 +1447,7 @@ export async function ticketCategoryInsightsHandler( stats.set(categoryKey, stat) } stat.total += 1 - if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < endMs) { + if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < limitedEndMs) { stat.resolved += 1 } @@ -1249,7 +1464,8 @@ export async function ticketCategoryInsightsHandler( stat.agents.set(agentKey, agent) } agent.total += 1 - } + totalTickets += 1 + }) const categoriesData = Array.from(stats.values()) .map((stat) => { @@ -1300,8 +1516,8 @@ export async function ticketCategoryInsightsHandler( }, null) return { - rangeDays: days, - totalTickets: inRange.length, + rangeDays, + totalTickets, categories: categoriesData, spotlight, } @@ -1331,76 +1547,90 @@ export async function dashboardOverviewHandler( const lastWindowStart = now - 7 * ONE_DAY_MS; const previousWindowStart = now - 14 * ONE_DAY_MS; + let totalRecentCreated = 0; + let newTicketsCount = 0; + let previousTicketsCount = 0; + let firstResponseWindowSum = 0; + let firstResponseWindowCount = 0; + let firstResponsePreviousSum = 0; + let firstResponsePreviousCount = 0; + + await forEachScopedTicketByCreatedRange( + ctx, + tenantId, + viewer, + previousWindowStart, + now, + undefined, + (ticket) => { + const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null; + if (createdAt === null) return; + totalRecentCreated += 1; + if (createdAt >= lastDayStart) { + newTicketsCount += 1; + } else if (createdAt >= previousDayStart) { + previousTicketsCount += 1; + } + + const firstResponseAt = typeof ticket.firstResponseAt === "number" ? ticket.firstResponseAt : null; + if (firstResponseAt === null) return; + const durationMinutes = (firstResponseAt - createdAt) / 60000; + if (createdAt >= lastWindowStart && createdAt < now) { + firstResponseWindowSum += durationMinutes; + firstResponseWindowCount += 1; + } else if (createdAt >= previousWindowStart && createdAt < lastWindowStart) { + firstResponsePreviousSum += durationMinutes; + firstResponsePreviousCount += 1; + } + }, + ); + 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 inProgressCurrent = openTickets.filter((ticket) => typeof ticket.firstResponseAt === "number"); const inProgressPrevious = openTickets.filter( - (ticket) => Boolean(ticket.firstResponseAt && ticket.firstResponseAt < lastDayStart), + (ticket) => typeof ticket.firstResponseAt === "number" && ticket.firstResponseAt < lastDayStart, ); const inProgressTrend = percentageChange(inProgressCurrent.length, inProgressPrevious.length); - const firstResponseWindow = recentCreatedTickets - .filter( - (ticket) => - ticket.createdAt >= lastWindowStart && - ticket.createdAt < now && - ticket.firstResponseAt, - ) - .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); - const firstResponsePrevious = recentCreatedTickets - .filter( - (ticket) => - ticket.createdAt >= previousWindowStart && - ticket.createdAt < lastWindowStart && - ticket.firstResponseAt, - ) - .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); - - const averageWindow = average(firstResponseWindow); - const averagePrevious = average(firstResponsePrevious); - const deltaMinutes = - averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null; - const awaitingTickets = openTickets; const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); - const resolvedLastWindow = recentResolvedTickets.filter( - (ticket) => ticket.resolvedAt && ticket.resolvedAt >= lastWindowStart && ticket.resolvedAt < now, + let resolvedLastWindow = 0; + let resolvedPreviousWindow = 0; + await forEachScopedTicketByResolvedRangeChunked( + ctx, + tenantId, + viewer, + previousWindowStart, + now, + undefined, + (ticket) => { + const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null; + if (resolvedAt === null) return; + if (resolvedAt >= lastWindowStart && resolvedAt < now) { + resolvedLastWindow += 1; + } else if (resolvedAt >= previousWindowStart && resolvedAt < lastWindowStart) { + resolvedPreviousWindow += 1; + } + }, ); - const resolvedPreviousWindow = recentResolvedTickets.filter( - (ticket) => - ticket.resolvedAt && - ticket.resolvedAt >= previousWindowStart && - ticket.resolvedAt < lastWindowStart, - ); - const resolutionBase = Math.max(recentCreatedTickets.length, awaitingTickets.length); - const resolutionRate = resolutionBase > 0 ? (resolvedLastWindow.length / resolutionBase) * 100 : null; - const resolutionDelta = percentageChange(resolvedLastWindow.length, resolvedPreviousWindow.length); + + const averageWindow = + firstResponseWindowCount > 0 ? firstResponseWindowSum / firstResponseWindowCount : null; + const averagePrevious = + firstResponsePreviousCount > 0 ? firstResponsePreviousSum / firstResponsePreviousCount : null; + const deltaMinutes = + averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null; + + const trend = percentageChange(newTicketsCount, previousTicketsCount); + const resolutionBase = Math.max(totalRecentCreated, awaitingTickets.length); + const resolutionRate = resolutionBase > 0 ? (resolvedLastWindow / resolutionBase) * 100 : null; + const resolutionDelta = percentageChange(resolvedLastWindow, resolvedPreviousWindow); return { newTickets: { - last24h: newTickets.length, - previous24h: previousTickets.length, + last24h: newTicketsCount, + previous24h: previousTicketsCount, trendPercentage: trend, }, inProgress: { @@ -1412,15 +1642,15 @@ export async function dashboardOverviewHandler( averageMinutes: averageWindow, previousAverageMinutes: averagePrevious, deltaMinutes, - responsesCount: firstResponseWindow.length, + responsesCount: firstResponseWindowCount, }, awaitingAction: { total: awaitingTickets.length, atRisk: atRiskTickets.length, }, resolution: { - resolvedLast7d: resolvedLastWindow.length, - previousResolved: resolvedPreviousWindow.length, + resolvedLast7d: resolvedLastWindow, + previousResolved: resolvedPreviousWindow, rate: resolutionRate, deltaPercentage: resolutionDelta, }, @@ -1432,26 +1662,209 @@ export const dashboardOverview = query({ handler: dashboardOverviewHandler, }); +type DashboardAggregateResult = { + range: { + startMs: number + endMs: number + requestedEndMs: number + days: number + clamped: boolean + } + openedResolvedByDay: { + opened: Array<{ key: string; count: number }> + resolved: Array<{ key: string; count: number }> + } + categories: Array<{ key: string; count: number }> + machineCategory: Array<{ machineId: string; categoryId: string; count: number }> + channels: Array<{ key: string; count: number }> + backlog: { open: number } + csat: { average: number | null; count: number } + processed: number +}; + +export const dashboardOverviewAggregate = mutation({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + range: v.optional(v.string()), + companyId: v.optional(v.id("companies")), + dateFrom: v.optional(v.string()), + dateTo: v.optional(v.string()), + allowWide: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const viewer = await requireStaff(ctx, args.viewerId, args.tenantId); + const { startMs, endMs, days } = resolveRangeWindow( + args.range, + args.dateFrom, + args.dateTo, + 90, + ); + + let limitedEndMs = endMs; + let wasClamped = false; + if (args.allowWide !== true) { + const clamped = clampRangeWindow(startMs, endMs); + limitedEndMs = clamped.endMs; + wasClamped = clamped.wasClamped; + } + const rangeDays = + args.allowWide === true ? days : Math.min(days, deriveRangeDays(startMs, limitedEndMs)); + + const cacheKey = buildDashboardCacheKey(args); + const cached = await readDashboardCache(ctx, args.tenantId, cacheKey); + if (cached) { + return { + ...cached.payload, + cache: { hit: true, expiresAt: cached.expiresAt }, + }; + } + + const lockId = await acquireDashboardLock(ctx, args.tenantId, cacheKey); + if (!lockId) { + return { status: "locked" as const }; + } + + try { + const payload = await aggregateDashboardMetrics(ctx, viewer, { + tenantId: args.tenantId, + companyId: args.companyId, + startMs, + endMs: limitedEndMs, + requestedEndMs: endMs, + days: rangeDays, + clamped: args.allowWide === true ? false : wasClamped, + }); + + await writeDashboardCache(ctx, args.tenantId, cacheKey, payload); + return { + ...payload, + cache: { hit: false, expiresAt: Date.now() + CACHE_TTL_MS }, + }; + } finally { + await releaseDashboardLock(ctx, lockId); + } + }, +}); + +async function aggregateDashboardMetrics( + ctx: MutationCtx, + viewer: Awaited>, + { + tenantId, + companyId, + startMs, + endMs, + requestedEndMs, + days, + clamped, + }: { + tenantId: string + companyId?: Id<"companies"> + startMs: number + endMs: number + requestedEndMs: number + days: number + clamped: boolean + }, +): Promise { + const openedByDay = new Map(); + const resolvedByDay = new Map(); + const categories = new Map(); + const channels = new Map(); + const machineCategory = new Map>(); + let backlogOpen = 0; + let csatSum = 0; + let csatCount = 0; + let processed = 0; + + await forEachScopedTicketByCreatedRange( + ctx as QueryCtx, + tenantId, + viewer, + startMs, + endMs, + companyId, + (ticket) => { + processed += 1; + if (processed % DASHBOARD_LOG_INTERVAL === 0) { + logDashboardProgress(processed, tenantId); + } + + const createdKey = + typeof ticket.createdAt === "number" ? formatDateKey(ticket.createdAt) : null; + if (createdKey) { + openedByDay.set(createdKey, (openedByDay.get(createdKey) ?? 0) + 1); + } + + const resolvedKey = + typeof ticket.resolvedAt === "number" && ticket.resolvedAt < endMs + ? formatDateKey(ticket.resolvedAt) + : null; + if (resolvedKey) { + resolvedByDay.set(resolvedKey, (resolvedByDay.get(resolvedKey) ?? 0) + 1); + } + + const categoryKey = ticket.categoryId ? String(ticket.categoryId) : "uncategorized"; + categories.set(categoryKey, (categories.get(categoryKey) ?? 0) + 1); + + const channelKey = ticket.channel ?? "unknown"; + channels.set(channelKey, (channels.get(channelKey) ?? 0) + 1); + + const machineKey = ticket.machineId ? String(ticket.machineId) : "no-machine"; + const machineRow = machineCategory.get(machineKey) ?? new Map(); + machineRow.set(categoryKey, (machineRow.get(categoryKey) ?? 0) + 1); + machineCategory.set(machineKey, machineRow); + + if (!ticket.resolvedAt || ticket.resolvedAt > endMs) { + backlogOpen += 1; + } + + if (typeof ticket.csatScore === "number") { + csatSum += ticket.csatScore; + csatCount += 1; + } + }, + ); + + return { + range: { + startMs, + endMs, + requestedEndMs, + days, + clamped, + }, + openedResolvedByDay: { + opened: mapToChronologicalSeries(openedByDay), + resolved: mapToChronologicalSeries(resolvedByDay), + }, + categories: mapToDescendingSeries(categories), + machineCategory: flattenMachineCategoryMap(machineCategory), + channels: mapToDescendingSeries(channels), + backlog: { open: backlogOpen }, + csat: { average: csatCount > 0 ? csatSum / csatCount : null, count: csatCount }, + processed, + }; +} + export async function ticketsByChannelHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); - 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 { startMs, endMs, days } = resolveRangeWindow(range, undefined, undefined, 90); + const { endMs: limitedEndMs } = clampRangeWindow(startMs, endMs); + const rangeDays = Math.min(days, deriveRangeDays(startMs, limitedEndMs)); const timeline = new Map>(); - for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) { + for (let ts = startMs; ts < limitedEndMs; ts += ONE_DAY_MS) { timeline.set(formatDateKey(ts), new Map()); } const channels = new Set(); - await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId, (ticket) => { + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, limitedEndMs, companyId, (ticket) => { const dateKey = formatDateKey(ticket.createdAt); const channelKey = ticket.channel ?? "OUTRO"; channels.add(channelKey); @@ -1473,7 +1886,7 @@ export async function ticketsByChannelHandler( }); return { - rangeDays: days, + rangeDays, channels: sortedChannels, points, }; @@ -1524,44 +1937,33 @@ export async function ticketsByMachineAndCategoryHandler( ) { const viewer = await requireStaff(ctx, viewerId, tenantId) const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90) - - const tickets = - days === 0 - ? await fetchScopedTickets(ctx, tenantId, viewer).then((all) => - all.filter((ticket) => { - if (companyId && ticket.companyId && ticket.companyId !== companyId) return false - return true - }), - ) - : await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) + const { endMs: limitedEndMs } = clampRangeWindow(startMs, endMs) + const rangeDays = Math.min(days, deriveRangeDays(startMs, limitedEndMs)) const categoriesMap = await fetchCategoryMap(ctx, tenantId) - const companyIds = new Set>() - for (const ticket of tickets) { - if (ticket.companyId) { - companyIds.add(ticket.companyId) - } - } - const companiesById = new Map | null>() - await Promise.all( - Array.from(companyIds).map(async (id) => { - const doc = await ctx.db.get(id) - companiesById.set(String(id), doc ?? null) - }) - ) const aggregated = new Map() - for (const ticket of tickets) { + const resolveCompany = async (id: Id<"companies"> | null) => { + if (!id) return null + const key = String(id) + if (!companiesById.has(key)) { + const doc = await ctx.db.get(id) + companiesById.set(key, doc ?? null) + } + return companiesById.get(key) ?? null + } + + await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, limitedEndMs, companyId, async (ticket) => { const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null - if (createdAt === null || createdAt < startMs || createdAt >= endMs) continue + if (createdAt === null || createdAt < startMs || createdAt >= limitedEndMs) return const hasMachine = Boolean(ticket.machineId) || Boolean(ticket.machineSnapshot) - if (!hasMachine) continue + if (!hasMachine) return - if (machineId && ticket.machineId !== machineId) continue - if (userId && ticket.requesterId !== userId) continue + if (machineId && ticket.machineId !== machineId) return + if (userId && ticket.requesterId !== userId) return const date = formatDateKey(createdAt) const machineIdValue = (ticket.machineId ?? null) as Id<"machines"> | null @@ -1588,7 +1990,7 @@ export async function ticketsByMachineAndCategoryHandler( const companyIdValue = (ticket.companyId ?? null) as Id<"companies"> | null let companyName: string | null = null if (companyIdValue) { - const company = companiesById.get(String(companyIdValue)) + const company = await resolveCompany(companyIdValue) if (company?.name && company.name.trim().length > 0) { companyName = company.name.trim() } @@ -1626,7 +2028,7 @@ export async function ticketsByMachineAndCategoryHandler( total: 1, }) } - } + }) const items = Array.from(aggregated.values()).sort((a, b) => { if (a.date !== b.date) return a.date.localeCompare(b.date) @@ -1637,7 +2039,7 @@ export async function ticketsByMachineAndCategoryHandler( }) return { - rangeDays: days === 0 ? -1 : days, + rangeDays, items, } } @@ -1689,23 +2091,31 @@ export async function hoursByMachineHandler( } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) - const tickets = await fetchScopedTickets(ctx, tenantId, viewer) - const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90) + const { endMs: limitedEndMs } = clampRangeWindow(startMs, endMs) + const rangeDays = Math.min(days, deriveRangeDays(startMs, limitedEndMs)) const machinesById = new Map | null>() const companiesById = new Map | null>() const map = new Map() - for (const t of tickets) { - if (days !== 0 && (t.updatedAt < startMs || t.updatedAt >= endMs)) continue - if (companyId && t.companyId && t.companyId !== companyId) continue - if (machineId && t.machineId !== machineId) continue - if (userId && t.requesterId !== userId) continue + await forEachScopedTicket(ctx, tenantId, viewer, async (t) => { + const updatedAt = + typeof t.updatedAt === "number" + ? t.updatedAt + : typeof t.resolvedAt === "number" + ? t.resolvedAt + : typeof t.createdAt === "number" + ? t.createdAt + : null + if (updatedAt === null || updatedAt < startMs || updatedAt >= limitedEndMs) return + if (companyId && t.companyId && t.companyId !== companyId) return + if (machineId && t.machineId !== machineId) return + if (userId && t.requesterId !== userId) return const machineIdValue = (t.machineId ?? null) as Id<"machines"> | null - if (!machineIdValue) continue + if (!machineIdValue) return const key = String(machineIdValue) @@ -1759,7 +2169,7 @@ export async function hoursByMachineHandler( acc.internalMs += internal acc.externalMs += external acc.totalMs += internal + external - } + }) const items = Array.from(map.values()).sort((a, b) => { if (b.totalMs !== a.totalMs) return b.totalMs - a.totalMs @@ -1769,7 +2179,7 @@ export async function hoursByMachineHandler( }) return { - rangeDays: days === 0 ? -1 : days, + rangeDays, items, } } @@ -1794,6 +2204,8 @@ export async function hoursByClientHandler( ) { const viewer = await requireStaff(ctx, viewerId, tenantId) const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90) + const { endMs: limitedEndMs } = clampRangeWindow(startMs, endMs) + const rangeDays = Math.min(days, deriveRangeDays(startMs, limitedEndMs)) const companyCache = new Map() type Acc = { @@ -1808,7 +2220,8 @@ export async function hoursByClientHandler( const map = new Map() await forEachScopedTicket(ctx, tenantId, viewer, async (ticket) => { - if (ticket.updatedAt < startMs || ticket.updatedAt >= endMs) return + const updatedAt = typeof ticket.updatedAt === "number" ? ticket.updatedAt : ticket.createdAt + if (updatedAt < startMs || updatedAt >= limitedEndMs) return const companyId = ticket.companyId ?? null if (!companyId) return const key = String(companyId) @@ -1835,7 +2248,7 @@ export async function hoursByClientHandler( const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs) return { - rangeDays: days, + rangeDays, items: items.map((i) => ({ companyId: i.companyId, name: i.name, @@ -1865,6 +2278,8 @@ export async function hoursByClientInternalHandler( { tenantId, range, dateFrom, dateTo }: { tenantId: string; range?: string; dateFrom?: string; dateTo?: string } ) { const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90) + const { endMs: limitedEndMs } = clampRangeWindow(startMs, endMs) + const rangeDays = Math.min(days, deriveRangeDays(startMs, limitedEndMs)) const companyCache = new Map() type Acc = { @@ -1879,7 +2294,7 @@ export async function hoursByClientInternalHandler( const map = new Map() await forEachTenantTicket(ctx, tenantId, async (ticket) => { - if (ticket.updatedAt < startMs || ticket.updatedAt >= endMs) return + if (ticket.updatedAt < startMs || ticket.updatedAt >= limitedEndMs) return const companyId = ticket.companyId ?? null if (!companyId) return const key = String(companyId) @@ -1906,7 +2321,7 @@ export async function hoursByClientInternalHandler( const items = Array.from(map.values()).sort((a, b) => b.totalMs - a.totalMs) return { - rangeDays: days, + rangeDays, items: items.map((i) => ({ companyId: i.companyId, name: i.name, diff --git a/convex/schema.ts b/convex/schema.ts index 666fa8f..619a6d4 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -726,4 +726,21 @@ export default defineSchema({ .index("by_tenant_company", ["tenantId", "companyId"]) .index("by_tenant_default", ["tenantId", "isDefault"]) .index("by_tenant", ["tenantId"]), + + analyticsCache: defineTable({ + tenantId: v.string(), + cacheKey: v.string(), + payload: v.any(), + expiresAt: v.number(), + _ttl: v.optional(v.number()), + }) + .index("by_key", ["tenantId", "cacheKey"]), + + analyticsLocks: defineTable({ + tenantId: v.string(), + cacheKey: v.string(), + expiresAt: v.number(), + _ttl: v.optional(v.number()), + }) + .index("by_key", ["tenantId", "cacheKey"]), }); diff --git a/stack.yml b/stack.yml index 6d6891b..5c2577e 100644 --- a/stack.yml +++ b/stack.yml @@ -26,6 +26,8 @@ services: NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL}" BETTER_AUTH_URL: "${BETTER_AUTH_URL}" BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET}" + REPORTS_CRON_SECRET: "${REPORTS_CRON_SECRET}" + REPORTS_CRON_BASE_URL: "${REPORTS_CRON_BASE_URL}" # Mantém o SQLite fora do repositório DATABASE_URL: "file:/app/data/db.sqlite" # Usado para forçar novo rollout a cada deploy (setado pelo CI) @@ -76,6 +78,9 @@ services: - MACHINE_PROVISIONING_SECRET=${MACHINE_PROVISIONING_SECRET} - MACHINE_TOKEN_TTL_MS=${MACHINE_TOKEN_TTL_MS:-2592000000} - FLEET_SYNC_SECRET=${FLEET_SYNC_SECRET:-} + - REPORTS_CRON_SECRET=${REPORTS_CRON_SECRET} + - REPORTS_CRON_BASE_URL=${REPORTS_CRON_BASE_URL} + - REPORTS_CRON_ENABLED=${REPORTS_CRON_ENABLED:-false} deploy: mode: replicated replicas: 1 @@ -85,8 +90,9 @@ services: failure_action: rollback resources: limits: - # Convex vinha sendo finalizado por OOM (ExitCode 137) com limite de 5G. - # Aumentamos para 6G para aproveitar melhor os 8G da VPS sem mudar a infra. + # Limite conservador para evitar que o backend afete o host inteiro. + memory: "12G" + reservations: memory: "6G" restart_policy: condition: any @@ -105,10 +111,10 @@ services: - traefik_public healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:3210/version >/dev/null || exit 1"] - interval: 10s + interval: 30s timeout: 10s - retries: 5 - start_period: 20s + retries: 10 + start_period: 120s convex_dashboard: image: ghcr.io/get-convex/convex-dashboard:latest