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"; import { requireStaff } from "./rbac"; import { getOfflineThresholdMs, getStaleThresholdMs } from "./machines"; export type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; type QueryFilterBuilder = { lt: (field: unknown, value: number) => unknown; field: (name: string) => unknown }; export const STATUS_NORMALIZE_MAP: Record = { NEW: "PENDING", PENDING: "PENDING", OPEN: "AWAITING_ATTENDANCE", AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", ON_HOLD: "PAUSED", PAUSED: "PAUSED", RESOLVED: "RESOLVED", CLOSED: "RESOLVED", }; export function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { if (!status) return "PENDING"; const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()]; return normalized ?? "PENDING"; } function average(values: number[]) { if (values.length === 0) return null; return values.reduce((sum, value) => sum + value, 0) / values.length; } function resolveCategoryName( categoryId: string | null, snapshot: { categoryName?: string } | null, categories: Map> ) { if (categoryId) { const category = categories.get(categoryId) if (category?.name) { return category.name } } if (snapshot?.categoryName && snapshot.categoryName.trim().length > 0) { return snapshot.categoryName.trim() } return "Sem categoria" } export const OPEN_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]); 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 = 90; 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[]; continueCursor?: string | null; done?: boolean; isDone?: boolean; }; type QueryBuilder = { paginate?: (options: { cursor: string | null; numItems: number }) => Promise>; collect?: () => Promise; }; async function paginateTickets( buildQuery: () => QueryBuilder, handler: (doc: T) => void | Promise, pageSize = REPORTS_PAGE_SIZE, ) { let cursor: string | null = null; while (true) { const query = buildQuery(); const paginateFn: | ((options: { cursor: string | null; numItems: number }) => Promise>) | undefined = query.paginate; if (typeof paginateFn !== "function") { if (cursor !== null) { throw new ConvexError("Query does not support pagination but cursor was provided"); } const collectFn = query.collect; if (typeof collectFn !== "function") { throw new ConvexError("Query does not support paginate or collect"); } const docs = await collectFn.call(query); for (const doc of docs) { await handler(doc); } return; } let page: PaginatedResult; try { page = await paginateFn.call(query, { cursor, numItems: pageSize }); } catch (error) { // Em cenários raros o Convex pode lançar InvalidCursor quando // o fingerprint da query muda entre páginas. Nesse caso, // fazemos fallback para uma leitura simples via collect() // para evitar quebrar o dashboard. const message = (error as Error | null | undefined)?.message ?? ""; const isInvalidCursor = typeof message === "string" && message.includes("InvalidCursor"); // Alguns erros de paginação vêm embrulhados em ConvexError com metadados. type PaginationErrorMetadata = { paginationError?: string; }; type PaginationError = { data?: PaginationErrorMetadata; }; const data = (error as PaginationError | null | undefined)?.data; const isPaginationInvalidCursor = data && typeof data === "object" && data.paginationError === "InvalidCursor"; if (isInvalidCursor || isPaginationInvalidCursor) { const fallbackQuery = buildQuery(); const collectFn = fallbackQuery.collect; if (typeof collectFn !== "function") { throw error; } const docs = await collectFn.call(fallbackQuery); for (const doc of docs) { await handler(doc); } return; } throw error; } for (const doc of page.page) { await handler(doc); } const continueCursor = page.continueCursor ?? null; const done = page.done ?? page.isDone ?? !continueCursor; if (done) { break; } cursor = continueCursor; } } function withLowerBound(range: T, field: string, value: number): T { const candidate = range as { gte?: (field: string, value: number) => unknown }; if (candidate && typeof candidate.gte === "function") { return candidate.gte(field, value) as T; } return range; } function withUpperBound(range: T, field: string, value: number): T { const candidate = range as { lt?: (field: string, value: number) => unknown }; if (candidate && typeof candidate.lt === "function") { return candidate.lt(field, value) as T; } return range; } function parseIsoDateToMs(value?: string | null): number | null { if (!value) return null; const [yearStr, monthStr, dayStr] = value.split("-"); const year = Number(yearStr); const month = Number(monthStr); const day = Number(dayStr); if (!year || !month || !day) return null; const date = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)); return date.getTime(); } function resolveRangeWindow( range: string | undefined, dateFrom: string | undefined, dateTo: string | undefined, defaultDays: number, ): { startMs: number; endMs: number; days: number } { const fromMs = parseIsoDateToMs(dateFrom); const toMs = parseIsoDateToMs(dateTo); if (fromMs !== null || toMs !== null) { const startMs = fromMs ?? toMs ?? Date.now(); const endMsBase = toMs ?? fromMs ?? startMs; const endMs = endMsBase + ONE_DAY_MS; const days = Math.max(1, Math.round((endMs - startMs) / ONE_DAY_MS)); return { startMs, endMs, days }; } const normalizedRange = range ?? "90d"; let days = defaultDays; if (normalizedRange === "7d") days = 7; else if (normalizedRange === "30d") days = 30; else if (normalizedRange === "365d" || normalizedRange === "12m") days = 365; else if (normalizedRange === "all") { const now = new Date(); now.setUTCHours(0, 0, 0, 0); const endMs = now.getTime() + ONE_DAY_MS; return { startMs: 0, endMs, days: 0 }; } const end = new Date(); end.setUTCHours(0, 0, 0, 0); const endMs = end.getTime() + ONE_DAY_MS; const startMs = endMs - days * ONE_DAY_MS; return { startMs, endMs, days }; } 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; } 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 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( () => makeQuery(), 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); }, ); } 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"); // Coleta tickets do chunk (o chunk ja e limitado por periodo) const snapshot = await query.collect(); // Limita processamento a 1000 tickets por chunk para evitar timeout const limitedSnapshot = snapshot.slice(0, 1000); for (const ticket of limitedSnapshot) { 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, viewer: Awaited>, companyId?: Id<"companies">, ): Promise[]> { const scopedCompanyId = resolveScopedCompanyId(viewer, companyId); const statuses: TicketStatusNormalized[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]; const results: Doc<"tickets">[] = []; const seen = new Set(); // Limita a 500 tickets por status para evitar OOM const MAX_PER_STATUS = 500; for (const status of statuses) { const allTickets = await ctx.db .query("tickets") .withIndex("by_tenant_status", (q) => q.eq("tenantId", tenantId).eq("status", status)) .collect(); const snapshot = allTickets.slice(0, MAX_PER_STATUS); for (const ticket of snapshot) { if (!OPEN_STATUSES.has(normalizeStatus(ticket.status))) continue; if (scopedCompanyId && ticket.companyId !== scopedCompanyId) continue; const key = String(ticket._id); if (seen.has(key)) continue; seen.add(key); results.push(ticket); } } return results; } function percentageChange(current: number, previous: number) { if (previous === 0) { return current === 0 ? 0 : null; } return ((current - previous) / previous) * 100; } function extractScore(payload: unknown): number | null { if (typeof payload === "number") return payload; if (payload && typeof payload === "object" && "score" in payload) { const value = (payload as { score: unknown }).score; if (typeof value === "number") { return value; } } return null; } function extractMaxScore(payload: unknown): number | null { if (payload && typeof payload === "object" && "maxScore" in payload) { const value = (payload as { maxScore: unknown }).maxScore; if (typeof value === "number" && Number.isFinite(value) && value > 0) { return value; } } return null; } function extractComment(payload: unknown): string | null { if (payload && typeof payload === "object" && "comment" in payload) { const value = (payload as { comment: unknown }).comment; if (typeof value === "string") { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } } return null; } function extractAssignee(payload: unknown): { id: string | null; name: string | null } { if (!payload || typeof payload !== "object") { return { id: null, name: null } } const record = payload as Record const rawId = record["assigneeId"] const rawName = record["assigneeName"] const id = typeof rawId === "string" && rawId.trim().length > 0 ? rawId.trim() : null const name = typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : null return { id, name } } function isNotNull(value: T | null): value is T { return value !== null; } export async function fetchTickets(ctx: QueryCtx, tenantId: string) { const results: Doc<"tickets">[] = []; await forEachTenantTicket(ctx, tenantId, (ticket) => { results.push(ticket); }); return results; } async function fetchCategoryMap(ctx: QueryCtx, tenantId: string) { const categories = await ctx.db .query("ticketCategories") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect(); const map = new Map>(); for (const category of categories) { map.set(String(category._id), category); } return map; } export async function fetchScopedTickets( ctx: QueryCtx, tenantId: string, viewer: Awaited>, ) { const scopedCompanyId = resolveScopedCompanyId(viewer); const results: Doc<"tickets">[] = []; await forEachScopedTicket(ctx, tenantId, viewer, (ticket) => { results.push(ticket); }); return results; } export async function fetchScopedTicketsByCreatedRange( ctx: QueryCtx, tenantId: string, viewer: Awaited>, startMs: number, endMs: number, companyId?: Id<"companies">, ) { const results: Doc<"tickets">[] = []; await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId, (ticket) => { results.push(ticket); }); return results; } export async function fetchScopedTicketsByResolvedRange( ctx: QueryCtx, tenantId: string, viewer: Awaited>, startMs: number, endMs: number, companyId?: Id<"companies">, ) { const results: Doc<"tickets">[] = []; await forEachScopedTicketByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId, (ticket) => { results.push(ticket); }); 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") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .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"; } const manualStatus = (machine.status ?? "").toLowerCase(); if (manualStatus === "maintenance" || manualStatus === "blocked") { return manualStatus; } const offlineMs = getOfflineThresholdMs(); const staleMs = getStaleThresholdMs(offlineMs); if (machine.lastHeartbeatAt) { const age = now - machine.lastHeartbeatAt; if (age <= offlineMs) return "online"; if (age <= staleMs) return "offline"; return "stale"; } return (machine.status ?? "unknown") || "unknown"; } function formatOsLabel(osName?: string | null, osVersion?: string | null) { const name = osName?.trim(); if (!name) return "Desconhecido"; const version = osVersion?.trim(); if (!version) return name; const conciseVersion = version.split(" ")[0]; if (!conciseVersion) return name; return `${name} ${conciseVersion}`.trim(); } type CsatSurvey = { ticketId: Id<"tickets">; reference: number; score: number; maxScore: number; comment: string | null; receivedAt: number; assigneeId: string | null; assigneeName: string | null; }; function formatDateKey(timestamp: number) { const date = new Date(timestamp); const year = date.getUTCFullYear(); const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); const day = `${date.getUTCDate()}`.padStart(2, "0"); return `${year}-${month}-${day}`; } export async function slaOverviewHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId, dateFrom, dateTo, }: { tenantId: string viewerId: Id<"users"> range?: string companyId?: Id<"companies"> dateFrom?: string dateTo?: string } ) { 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); const now = Date.now(); 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, { categoryId: string | null categoryName: string priority: string total: number responseMet: number solutionMet: number } >() await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, limitedEndMs, 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, categoryName, priority, total: 0, responseMet: 0, solutionMet: 0, }; categoryStats.set(key, stat); } stat.total += 1; if (ticket.slaResponseStatus === "met") { stat.responseMet += 1; } if (ticket.slaSolutionStatus === "met") { 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) => ({ ...entry, responseRate: entry.total > 0 ? entry.responseMet / entry.total : null, solutionRate: entry.total > 0 ? entry.solutionMet / entry.total : null, })) .sort((a, b) => b.total - a.total) return { totals: { total: totals.total, open: totals.open, resolved: totals.resolved, overdue: totals.overdue, }, response: { averageFirstResponseMinutes: firstResponseCount > 0 ? firstResponseSum / firstResponseCount : null, responsesRegistered: firstResponseCount, }, resolution: { averageResolutionMinutes: resolutionCount > 0 ? resolutionSum / resolutionCount : null, resolvedCount: resolutionCount, }, queueBreakdown, categoryBreakdown, rangeDays, }; } export const slaOverview = query({ 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()), }, handler: slaOverviewHandler, }); export const triggerScheduledExports = action({ args: { tenantId: v.optional(v.string()), }, handler: async (_ctx, args) => { const secret = process.env.REPORTS_CRON_SECRET const baseUrl = process.env.REPORTS_CRON_BASE_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? process.env.BETTER_AUTH_URL if (!secret || !baseUrl) { console.warn("[reports] cron skip: missing REPORTS_CRON_SECRET or base URL") return { skipped: true } } const endpoint = `${baseUrl.replace(/\/$/, "")}/api/reports/schedules/run` const response = await fetch(endpoint, { method: "POST", headers: { Authorization: `Bearer ${secret}`, "Content-Type": "application/json", }, body: JSON.stringify({ tenantId: args.tenantId }), }) if (!response.ok) { const detail = await response.text().catch(() => response.statusText) throw new ConvexError(`Falha ao disparar agendamentos: ${response.status} ${detail}`) } return response.json() }, }) export async function csatOverviewHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId, dateFrom, dateTo, }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string } ) { 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, limitedEndMs, 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; return Math.min(5, Math.max(1, (value.score / value.maxScore) * 5)); }; const averageScore = average(surveys.map((item) => normalizeToFive(item))); const distribution = [1, 2, 3, 4, 5].map((score) => ({ score, total: surveys.filter((item) => Math.round(normalizeToFive(item)) === score).length, })); const positiveThreshold = 4; const positiveCount = surveys.filter((item) => normalizeToFive(item) >= positiveThreshold).length; const positiveRate = surveys.length > 0 ? positiveCount / surveys.length : null; const agentStats = new Map< string, { id: string; name: string; total: number; sum: number; positive: number } >(); for (const survey of surveys) { const normalizedScore = normalizeToFive(survey); const key = survey.assigneeId ?? "unassigned"; const existing = agentStats.get(key) ?? { id: key, name: survey.assigneeName ?? "Sem responsável", total: 0, sum: 0, positive: 0, }; existing.total += 1; existing.sum += normalizedScore; if (normalizedScore >= positiveThreshold) { existing.positive += 1; } if (survey.assigneeName && survey.assigneeName.trim().length > 0) { existing.name = survey.assigneeName.trim(); } agentStats.set(key, existing); } const byAgent = Array.from(agentStats.values()) .map((entry) => ({ agentId: entry.id === "unassigned" ? null : entry.id, agentName: entry.id === "unassigned" ? "Sem responsável" : entry.name, totalResponses: entry.total, averageScore: entry.total > 0 ? entry.sum / entry.total : null, positiveRate: entry.total > 0 ? entry.positive / entry.total : null, })) .sort((a, b) => { const diff = (b.averageScore ?? 0) - (a.averageScore ?? 0); if (Math.abs(diff) > 0.0001) return diff; return (b.totalResponses ?? 0) - (a.totalResponses ?? 0); }); return { totalSurveys: surveys.length, averageScore, distribution, recent: surveys .slice() .sort((a, b) => b.receivedAt - a.receivedAt) .slice(0, 10) .map((item) => ({ ticketId: item.ticketId, reference: item.reference, score: item.score, maxScore: item.maxScore, comment: item.comment, receivedAt: item.receivedAt, assigneeId: item.assigneeId, assigneeName: item.assigneeName, })), rangeDays, positiveRate, byAgent, }; } export const csatOverview = query({ 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()), }, handler: csatOverviewHandler, }); export async function openedResolvedByDayHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId, dateFrom, dateTo, }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string } ) { 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 opened: Record = {} const resolved: Record = {} 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 } 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 }) 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(resolvedAt) resolved[key] = (resolved[key] ?? 0) + 1 }) const series: Array<{ date: string; opened: number; resolved: number }> = [] 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, series } } export const openedResolvedByDay = query({ 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()), }, handler: openedResolvedByDayHandler, }) export async function backlogOverviewHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId, dateFrom, dateTo, }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string } ) { 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, limitedEndMs, 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(queueKey, current); }); const queues = await fetchQueues(ctx, tenantId); for (const queue of queues) { const entry = queueMap.get(queue._id) ?? { name: queue.name, count: 0 }; entry.name = queue.name; queueMap.set(queue._id, entry); } return { rangeDays, statusCounts, priorityCounts, queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({ id, name: data.name, total: data.count, })), totalOpen, }; } export const backlogOverview = query({ 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()), }, handler: backlogOverviewHandler, }); type QueueTrendPoint = { date: string; opened: number; resolved: number } type QueueTrendEntry = { id: string name: string openedTotal: number resolvedTotal: number series: Map } export async function queueLoadTrendHandler( ctx: QueryCtx, { tenantId, viewerId, range, limit, }: { tenantId: string; viewerId: Id<"users">; range?: string; limit?: number } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) 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() queues.forEach((queue) => queueNames.set(String(queue._id), queue.name)) queueNames.set("unassigned", "Sem fila") const dayKeys: string[] = [] for (let i = rangeDays - 1; i >= 0; i--) { const key = formatDateKey(limitedEndMs - (i + 1) * ONE_DAY_MS) dayKeys.push(key) } const stats = new Map() const ensureEntry = (queueId: string) => { if (!stats.has(queueId)) { const series = new Map() dayKeys.forEach((key) => { series.set(key, { date: key, opened: 0, resolved: 0 }) }) stats.set(queueId, { id: queueId, name: queueNames.get(queueId) ?? "Sem fila", openedTotal: 0, resolvedTotal: 0, series, }) } return stats.get(queueId)! } 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(createdAt)) if (bucket) { bucket.opened += 1 } entry.openedTotal += 1 }) await forEachScopedTicketByResolvedRangeChunked(ctx, tenantId, viewer, startMs, limitedEndMs, undefined, (ticket) => { const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null 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()) .sort((a, b) => b.openedTotal - a.openedTotal) .slice(0, maxEntries) .map((entry) => ({ id: entry.id, name: entry.name, openedTotal: entry.openedTotal, resolvedTotal: entry.resolvedTotal, series: dayKeys.map((key) => entry.series.get(key)!), })) return { rangeDays, queues: queuesTrend } } export const queueLoadTrend = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), limit: v.optional(v.number()) }, handler: queueLoadTrendHandler, }) // Touch to ensure CI convex_deploy runs and that agentProductivity is deployed export async function agentProductivityHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId) 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 - windowDays * ONE_DAY_MS const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) type Acc = { agentId: Id<"users"> name: string | null email: string | null open: number resolved: number avgFirstResponseMinValues: number[] avgResolutionMinValues: number[] workedMs: number } const map = new Map() for (const t of inRange) { const assigneeId = t.assigneeId ?? null if (!assigneeId) continue let acc = map.get(assigneeId) if (!acc) { const user = await ctx.db.get(assigneeId) acc = { agentId: assigneeId, name: user?.name ?? null, email: user?.email ?? null, open: 0, resolved: 0, avgFirstResponseMinValues: [], avgResolutionMinValues: [], workedMs: 0, } map.set(assigneeId, acc) } const status = normalizeStatus(t.status) if (OPEN_STATUSES.has(status)) acc.open += 1 if (status === "RESOLVED") acc.resolved += 1 if (t.firstResponseAt) acc.avgFirstResponseMinValues.push((t.firstResponseAt - t.createdAt) / 60000) if (t.resolvedAt) acc.avgResolutionMinValues.push((t.resolvedAt - t.createdAt) / 60000) } for (const [agentId, acc] of map) { // Limita a 1000 sessoes por agente para evitar OOM const allSessions = await ctx.db .query("ticketWorkSessions") .withIndex("by_agent", (q) => q.eq("agentId", agentId as Id<"users">)) .collect() const sessions = allSessions.slice(0, 1000) let total = 0 for (const s of sessions) { const started = s.startedAt const ended = s.stoppedAt ?? s.startedAt if (ended < startMs || started >= endMs) continue total += s.durationMs ?? Math.max(0, (s.stoppedAt ?? Date.now()) - s.startedAt) } acc.workedMs = total } const items = Array.from(map.values()).map((acc) => ({ agentId: acc.agentId, name: acc.name, email: acc.email, open: acc.open, resolved: acc.resolved, avgFirstResponseMinutes: average(acc.avgFirstResponseMinValues), avgResolutionMinutes: average(acc.avgResolutionMinValues), workedHours: Math.round((acc.workedMs / 3600000) * 100) / 100, })) items.sort((a, b) => b.resolved - a.resolved) return { rangeDays: windowDays, items } } export const agentProductivity = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, handler: agentProductivityHandler, }) type CategoryAgentAccumulator = { id: Id<"ticketCategories"> | null name: string total: number resolved: number agents: Map | null; name: string | null; total: number }> } export async function ticketCategoryInsightsHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId, dateFrom, dateTo, }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string } ) { 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 categories = await ctx.db .query("ticketCategories") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() const categoriesById = new Map, Doc<"ticketCategories">>() for (const category of categories) { categoriesById.set(category._id, category) } const stats = new Map() let totalTickets = 0 await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, limitedEndMs, companyId, (ticket) => { const categoryKey = ticket.categoryId ? String(ticket.categoryId) : "uncategorized" let stat = stats.get(categoryKey) if (!stat) { const categoryDoc = ticket.categoryId ? categoriesById.get(ticket.categoryId) : null stat = { id: ticket.categoryId ?? null, name: categoryDoc?.name ?? (ticket.categoryId ? "Categoria removida" : "Sem categoria"), total: 0, resolved: 0, agents: new Map(), } stats.set(categoryKey, stat) } stat.total += 1 if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < limitedEndMs) { stat.resolved += 1 } const agentKey = ticket.assigneeId ? String(ticket.assigneeId) : "unassigned" let agent = stat.agents.get(agentKey) if (!agent) { const snapshotName = ticket.assigneeSnapshot?.name ?? null const fallbackName = ticket.assigneeId ? null : "Sem responsável" agent = { agentId: ticket.assigneeId ?? null, name: snapshotName ?? fallbackName ?? "Agente", total: 0, } stat.agents.set(agentKey, agent) } agent.total += 1 totalTickets += 1 }) const categoriesData = Array.from(stats.values()) .map((stat) => { const agents = Array.from(stat.agents.values()).sort((a, b) => b.total - a.total) const topAgent = agents[0] ?? null return { id: stat.id ? String(stat.id) : null, name: stat.name, total: stat.total, resolved: stat.resolved, topAgent: topAgent ? { id: topAgent.agentId ? String(topAgent.agentId) : null, name: topAgent.name, total: topAgent.total, } : null, agents: agents.slice(0, 5).map((agent) => ({ id: agent.agentId ? String(agent.agentId) : null, name: agent.name, total: agent.total, })), } }) .sort((a, b) => b.total - a.total) const spotlight = categoriesData.reduce< | null | { categoryId: string | null categoryName: string agentId: string | null agentName: string | null tickets: number } >((best, current) => { if (!current.topAgent) return best if (!best || current.topAgent.total > best.tickets) { return { categoryId: current.id, categoryName: current.name, agentId: current.topAgent.id, agentName: current.topAgent.name, tickets: current.topAgent.total, } } return best }, null) return { rangeDays, totalTickets, categories: categoriesData, spotlight, } } export const categoryInsights = query({ 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()), }, handler: ticketCategoryInsightsHandler, }) export async function dashboardOverviewHandler( ctx: QueryCtx, { tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> } ) { const viewer = await requireStaff(ctx, viewerId, tenantId); const now = Date.now(); const lastDayStart = now - ONE_DAY_MS; const previousDayStart = now - 2 * ONE_DAY_MS; 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 inProgressCurrent = openTickets.filter((ticket) => typeof ticket.firstResponseAt === "number"); const inProgressPrevious = openTickets.filter( (ticket) => typeof ticket.firstResponseAt === "number" && ticket.firstResponseAt < lastDayStart, ); const inProgressTrend = percentageChange(inProgressCurrent.length, inProgressPrevious.length); const awaitingTickets = openTickets; const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < 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 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: newTicketsCount, previous24h: previousTicketsCount, trendPercentage: trend, }, inProgress: { current: inProgressCurrent.length, previousSnapshot: inProgressPrevious.length, trendPercentage: inProgressTrend, }, firstResponse: { averageMinutes: averageWindow, previousAverageMinutes: averagePrevious, deltaMinutes, responsesCount: firstResponseWindowCount, }, awaitingAction: { total: awaitingTickets.length, atRisk: atRiskTickets.length, }, resolution: { resolvedLast7d: resolvedLastWindow, previousResolved: resolvedPreviousWindow, rate: resolutionRate, deltaPercentage: resolutionDelta, }, }; } export const dashboardOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users") }, 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 { 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 < limitedEndMs; ts += ONE_DAY_MS) { timeline.set(formatDateKey(ts), new Map()); } const channels = new Set(); await forEachScopedTicketByCreatedRange(ctx, tenantId, viewer, startMs, limitedEndMs, 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(); const points = Array.from(timeline.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([date, map]) => { const values: Record = {}; for (const channel of sortedChannels) { values[channel] = map.get(channel) ?? 0; } return { date, values }; }); return { rangeDays, channels: sortedChannels, points, }; } export const ticketsByChannel = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")), }, handler: ticketsByChannelHandler, }); type MachineCategoryDailyEntry = { date: string machineId: Id<"machines"> | null machineHostname: string | null companyId: Id<"companies"> | null companyName: string | null categoryId: Id<"ticketCategories"> | null categoryName: string total: number } export async function ticketsByMachineAndCategoryHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId, machineId, userId, dateFrom, dateTo, }: { tenantId: string viewerId: Id<"users"> range?: string companyId?: Id<"companies"> machineId?: Id<"machines"> userId?: Id<"users"> dateFrom?: string dateTo?: string } ) { 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 categoriesMap = await fetchCategoryMap(ctx, tenantId) const companiesById = new Map | null>() const aggregated = new Map() 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 >= limitedEndMs) return const hasMachine = Boolean(ticket.machineId) || Boolean(ticket.machineSnapshot) if (!hasMachine) return 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 const machineSnapshot = (ticket.machineSnapshot ?? null) as | { hostname?: string | null } | null const rawHostname = typeof machineSnapshot?.hostname === "string" && machineSnapshot.hostname.trim().length > 0 ? machineSnapshot.hostname.trim() : null const machineHostname = rawHostname ?? null const snapshot = (ticket.slaSnapshot ?? null) as { categoryId?: Id<"ticketCategories">; categoryName?: string } | null const rawCategoryId = ticket.categoryId && typeof ticket.categoryId === "string" ? String(ticket.categoryId) : snapshot?.categoryId ? String(snapshot.categoryId) : null const categoryName = resolveCategoryName(rawCategoryId, snapshot, categoriesMap) const companyIdValue = (ticket.companyId ?? null) as Id<"companies"> | null let companyName: string | null = null if (companyIdValue) { const company = await resolveCompany(companyIdValue) if (company?.name && company.name.trim().length > 0) { companyName = company.name.trim() } } if (!companyName) { const companySnapshot = (ticket.companySnapshot ?? null) as { name?: string | null } | null if (companySnapshot?.name && companySnapshot.name.trim().length > 0) { companyName = companySnapshot.name.trim() } } if (!companyName) { companyName = "Sem empresa" } const key = [ date, machineIdValue ? String(machineIdValue) : "null", machineHostname ?? "", rawCategoryId ?? "uncategorized", companyIdValue ? String(companyIdValue) : "null", ].join("|") const existing = aggregated.get(key) if (existing) { existing.total += 1 } else { aggregated.set(key, { date, machineId: machineIdValue, machineHostname, companyId: companyIdValue, companyName, categoryId: (rawCategoryId as Id<"ticketCategories"> | null) ?? null, categoryName, total: 1, }) } }) const items = Array.from(aggregated.values()).sort((a, b) => { if (a.date !== b.date) return a.date.localeCompare(b.date) const machineA = (a.machineHostname ?? "").toLowerCase() const machineB = (b.machineHostname ?? "").toLowerCase() if (machineA !== machineB) return machineA.localeCompare(machineB) return a.categoryName.localeCompare(b.categoryName, "pt-BR") }) return { rangeDays, items, } } export const ticketsByMachineAndCategory = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")), machineId: v.optional(v.id("machines")), userId: v.optional(v.id("users")), dateFrom: v.optional(v.string()), dateTo: v.optional(v.string()), }, handler: ticketsByMachineAndCategoryHandler, }) type MachineHoursEntry = { machineId: Id<"machines"> machineHostname: string | null companyId: Id<"companies"> | null companyName: string | null internalMs: number externalMs: number totalMs: number } export async function hoursByMachineHandler( ctx: QueryCtx, { tenantId, viewerId, range, companyId, machineId, userId, dateFrom, dateTo, }: { tenantId: string viewerId: Id<"users"> range?: string companyId?: Id<"companies"> machineId?: Id<"machines"> userId?: Id<"users"> dateFrom?: string dateTo?: string } ) { 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 machinesById = new Map | null>() const companiesById = new Map | null>() const map = new Map() 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) return const key = String(machineIdValue) let acc = map.get(key) if (!acc) { let machineDoc = machinesById.get(key) if (machineDoc === undefined) { machineDoc = (await ctx.db.get(machineIdValue)) as Doc<"machines"> | null machinesById.set(key, machineDoc ?? null) } const snapshot = (t.machineSnapshot ?? null) as { hostname?: string | null } | null const machineHostname = typeof machineDoc?.hostname === "string" && machineDoc.hostname.trim().length > 0 ? machineDoc.hostname.trim() : snapshot?.hostname && snapshot.hostname.trim().length > 0 ? snapshot.hostname.trim() : null const companyIdValue = (t.companyId ?? (machineDoc?.companyId as Id<"companies"> | undefined) ?? null) as Id<"companies"> | null let companyName: string | null = null if (companyIdValue) { const companyKey = String(companyIdValue) let companyDoc = companiesById.get(companyKey) if (companyDoc === undefined) { companyDoc = (await ctx.db.get(companyIdValue)) as Doc<"companies"> | null companiesById.set(companyKey, companyDoc ?? null) } if (companyDoc?.name && companyDoc.name.trim().length > 0) { companyName = companyDoc.name.trim() } } acc = { machineId: machineIdValue, machineHostname, companyId: companyIdValue, companyName, internalMs: 0, externalMs: 0, totalMs: 0, } map.set(key, acc) } const internal = (t.internalWorkedMs ?? 0) as number const external = (t.externalWorkedMs ?? 0) as number 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 const hostA = (a.machineHostname ?? "").toLowerCase() const hostB = (b.machineHostname ?? "").toLowerCase() return hostA.localeCompare(hostB) }) return { rangeDays, items, } } export const hoursByMachine = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")), machineId: v.optional(v.id("machines")), userId: v.optional(v.id("users")), dateFrom: v.optional(v.string()), dateTo: v.optional(v.string()), }, handler: hoursByMachineHandler, }) export async function hoursByClientHandler( ctx: QueryCtx, { tenantId, viewerId, range, dateFrom, dateTo }: { tenantId: string; viewerId: Id<"users">; range?: string; dateFrom?: string; dateTo?: string } ) { 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 = { companyId: Id<"companies"> name: string isAvulso: boolean internalMs: number externalMs: number totalMs: number contractedHoursPerMonth?: number | null } const map = new Map() await forEachScopedTicket(ctx, tenantId, viewer, async (ticket) => { 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) let acc = map.get(key) if (!acc) { const company = await getCompanySummary(ctx, companyId, companyCache) acc = { companyId, name: company.name, isAvulso: company.isAvulso, internalMs: 0, externalMs: 0, totalMs: 0, contractedHoursPerMonth: company.contractedHoursPerMonth, } map.set(key, acc) } 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 { rangeDays, items: items.map((i) => ({ companyId: i.companyId, name: i.name, isAvulso: i.isAvulso, internalMs: i.internalMs, externalMs: i.externalMs, totalMs: i.totalMs, contractedHoursPerMonth: i.contractedHoursPerMonth ?? null, })), } } export const hoursByClient = query({ args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), dateFrom: v.optional(v.string()), dateTo: v.optional(v.string()), }, handler: hoursByClientHandler, }) // Internal variant used by scheduled jobs: skips viewer scoping and aggregates for the whole tenant export async function hoursByClientInternalHandler( ctx: QueryCtx, { 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 = { companyId: Id<"companies"> name: string isAvulso: boolean internalMs: number externalMs: number totalMs: number contractedHoursPerMonth?: number | null } const map = new Map() await forEachTenantTicket(ctx, tenantId, async (ticket) => { if (ticket.updatedAt < startMs || ticket.updatedAt >= limitedEndMs) return const companyId = ticket.companyId ?? null if (!companyId) return const key = String(companyId) let acc = map.get(key) if (!acc) { const company = await getCompanySummary(ctx, companyId, companyCache) acc = { companyId, name: company.name, isAvulso: company.isAvulso, internalMs: 0, externalMs: 0, totalMs: 0, contractedHoursPerMonth: company.contractedHoursPerMonth, } map.set(key, acc) } 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 { rangeDays, items: items.map((i) => ({ companyId: i.companyId, name: i.name, isAvulso: i.isAvulso, internalMs: i.internalMs, externalMs: i.externalMs, totalMs: i.totalMs, contractedHoursPerMonth: i.contractedHoursPerMonth ?? null, })), } } export const hoursByClientInternal = query({ args: { tenantId: v.string(), range: v.optional(v.string()), dateFrom: v.optional(v.string()), dateTo: v.optional(v.string()), }, handler: hoursByClientInternalHandler, }) export const companyOverview = query({ args: { tenantId: v.string(), viewerId: v.id("users"), companyId: v.id("companies"), range: v.optional(v.string()), }, handler: async (ctx, { tenantId, viewerId, companyId, range }) => { const viewer = await requireStaff(ctx, viewerId, tenantId); if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) { throw new ConvexError("Gestores só podem consultar relatórios da própria empresa"); } const company = await ctx.db.get(companyId); if (!company || company.tenantId !== tenantId) { throw new ConvexError("Empresa não encontrada"); } const normalizedRange = (range ?? "30d").toLowerCase(); const rangeDays = normalizedRange === "90d" ? 90 : normalizedRange === "7d" ? 7 : 30; const now = Date.now(); const startMs = now - rangeDays * ONE_DAY_MS; // Limita consultas para evitar OOM em empresas muito grandes const tickets = await ctx.db .query("tickets") .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) .take(2000); const machines = await ctx.db .query("machines") .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) .take(1000); const users = await ctx.db .query("users") .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) .take(500); const statusCounts = {} as Record; const priorityCounts = {} as Record; const channelCounts = {} as Record; const trendMap = new Map(); const openTickets: Doc<"tickets">[] = []; tickets.forEach((ticket) => { const normalizedStatus = normalizeStatus(ticket.status); statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] ?? 0) + 1; const priorityKey = (ticket.priority ?? "MEDIUM").toUpperCase(); priorityCounts[priorityKey] = (priorityCounts[priorityKey] ?? 0) + 1; const channelKey = (ticket.channel ?? "MANUAL").toUpperCase(); channelCounts[channelKey] = (channelCounts[channelKey] ?? 0) + 1; if (normalizedStatus !== "RESOLVED") { openTickets.push(ticket); } if (ticket.createdAt >= startMs) { const key = formatDateKey(ticket.createdAt); if (!trendMap.has(key)) { trendMap.set(key, { opened: 0, resolved: 0 }); } trendMap.get(key)!.opened += 1; } if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs) { const key = formatDateKey(ticket.resolvedAt); if (!trendMap.has(key)) { trendMap.set(key, { opened: 0, resolved: 0 }); } trendMap.get(key)!.resolved += 1; } }); const machineStatusCounts: Record = {}; const machineOsCounts: Record = {}; const machineLookup = new Map>(); machines.forEach((machine) => { machineLookup.set(String(machine._id), machine); const status = deriveMachineStatus(machine, now); machineStatusCounts[status] = (machineStatusCounts[status] ?? 0) + 1; const osLabel = formatOsLabel(machine.osName ?? null, machine.osVersion ?? null); machineOsCounts[osLabel] = (machineOsCounts[osLabel] ?? 0) + 1; }); const roleCounts: Record = {}; users.forEach((user) => { const roleKey = (user.role ?? "COLLABORATOR").toUpperCase(); roleCounts[roleKey] = (roleCounts[roleKey] ?? 0) + 1; }); const trend = Array.from(trendMap.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([date, value]) => ({ date, opened: value.opened, resolved: value.resolved })); const machineOsDistribution = Object.entries(machineOsCounts) .map(([label, value]) => ({ label, value })) .sort((a, b) => b.value - a.value); const openTicketSummaries = openTickets .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) .slice(0, 20) .map((ticket) => { const machineSnapshot = ticket.machineSnapshot as { hostname?: string } | undefined; const machine = ticket.machineId ? machineLookup.get(String(ticket.machineId)) : null; return { id: ticket._id, reference: ticket.reference, subject: ticket.subject, status: normalizeStatus(ticket.status), priority: ticket.priority, updatedAt: ticket.updatedAt, createdAt: ticket.createdAt, machine: ticket.machineId ? { id: ticket.machineId, hostname: machine?.hostname ?? machineSnapshot?.hostname ?? null, } : null, assignee: ticket.assigneeSnapshot ? { name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null, email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null, } : null, }; }); return { company: { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false, }, rangeDays, generatedAt: now, tickets: { total: tickets.length, byStatus: statusCounts, byPriority: priorityCounts, byChannel: channelCounts, trend, open: openTicketSummaries, }, machines: { total: machines.length, byStatus: machineStatusCounts, byOs: machineOsDistribution, }, users: { total: users.length, byRole: roleCounts, }, }; }, });