import { ConvexError, v } from "convex/values" import type { Doc, Id } from "./_generated/dataModel" import { query } from "./_generated/server" import type { QueryCtx } from "./_generated/server" import { OPEN_STATUSES, ONE_DAY_MS, fetchOpenScopedTickets, fetchScopedTicketsByCreatedRange, fetchScopedTicketsByResolvedRange, fetchScopedTicketsByResolvedRangeSnapshot, normalizeStatus, } from "./reports" import { requireStaff } from "./rbac" type Viewer = Awaited> type MetricResolverInput = { tenantId: string viewer: Viewer viewerId: Id<"users"> params?: Record | undefined } type MetricRunPayload = { meta: { kind: string; key: string } & Record data: unknown } type MetricResolver = (ctx: QueryCtx, input: MetricResolverInput) => Promise function parseRange(params?: Record): number { const value = params?.range if (typeof value === "string") { const normalized = value.toLowerCase() if (normalized === "7d") return 7 if (normalized === "90d") return 90 } if (typeof value === "number" && Number.isFinite(value) && value > 0) { return Math.min(365, Math.max(1, Math.round(value))) } return 30 } function parseLimit(params?: Record, fallback = 20) { const value = params?.limit if (typeof value === "number" && Number.isFinite(value) && value > 0) { return Math.min(200, Math.round(value)) } return fallback } function parseCompanyId(params?: Record): Id<"companies"> | undefined { const value = params?.companyId if (typeof value === "string" && value.length > 0) { return value as Id<"companies"> } return undefined } function parseQueueIds(params?: Record): string[] | undefined { const value = params?.queueIds ?? params?.queueId if (Array.isArray(value)) { const clean = value .map((entry) => (typeof entry === "string" ? entry.trim() : null)) .filter((entry): entry is string => Boolean(entry && entry.length > 0)) return clean.length > 0 ? clean : undefined } if (typeof value === "string") { const trimmed = value.trim() return trimmed.length > 0 ? [trimmed] : undefined } return undefined } function filterTicketsByQueue | null }>( tickets: T[], queueIds?: string[], ): T[] { if (!queueIds || queueIds.length === 0) { return tickets } const normalized = new Set(queueIds.map((id) => id.trim())) const includesNull = normalized.has("sem-fila") || normalized.has("null") return tickets.filter((ticket) => { if (!ticket.queueId) { return includesNull } return normalized.has(String(ticket.queueId)) }) } const QUEUE_RENAME_LOOKUP: Record = { "Suporte N1": "Chamados", "suporte-n1": "Chamados", chamados: "Chamados", "Suporte N2": "Laboratório", "suporte-n2": "Laboratório", laboratorio: "Laboratório", Laboratorio: "Laboratório", visitas: "Visitas", } function renameQueueName(value: string) { const direct = QUEUE_RENAME_LOOKUP[value] if (direct) return direct const normalizedKey = value.replace(/\s+/g, "-").toLowerCase() return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value } type AgentStatsRaw = { agentId: Id<"users"> name: string | null email: string | null open: number paused: number resolved: number totalSla: number compliantSla: number resolutionMinutes: number[] firstResponseMinutes: number[] } type AgentStatsComputed = { agentId: string name: string | null email: string | null open: number paused: number resolved: number slaRate: number | null avgResolutionMinutes: number | null avgFirstResponseMinutes: number | null totalSla: number compliantSla: number } function average(values: number[]): number | null { if (!values || values.length === 0) return null const sum = values.reduce((acc, value) => acc + value, 0) return sum / values.length } function isTicketCompliant(ticket: Doc<"tickets">, now: number) { const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null if (dueAt) { if (resolvedAt) { return resolvedAt <= dueAt } return dueAt >= now } return resolvedAt !== null } function ensureAgentStats(map: Map, ticket: Doc<"tickets">): AgentStatsRaw | null { const assigneeId = ticket.assigneeId if (!assigneeId) return null const key = String(assigneeId) let stats = map.get(key) const snapshot = ticket.assigneeSnapshot as { name?: string | null; email?: string | null } | undefined const snapshotName = snapshot?.name ?? null const snapshotEmail = snapshot?.email ?? null if (!stats) { stats = { agentId: assigneeId, name: snapshotName, email: snapshotEmail, open: 0, paused: 0, resolved: 0, totalSla: 0, compliantSla: 0, resolutionMinutes: [], firstResponseMinutes: [], } map.set(key, stats) } else { if (!stats.name && snapshotName) stats.name = snapshotName if (!stats.email && snapshotEmail) stats.email = snapshotEmail } return stats } async function computeAgentStats( ctx: QueryCtx, tenantId: string, viewer: Viewer, rangeDays: number, agentFilter?: Id<"users">, ) { const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - rangeDays * ONE_DAY_MS const statsMap = new Map() const openTickets = await fetchOpenScopedTickets(ctx, tenantId, viewer) const scopedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs) const matchesFilter = (ticket: Doc<"tickets">) => { if (!ticket.assigneeId) return false if (agentFilter && ticket.assigneeId !== agentFilter) return false return true } for (const ticket of openTickets) { if (!matchesFilter(ticket)) continue const stats = ensureAgentStats(statsMap, ticket) if (!stats) continue const status = normalizeStatus(ticket.status) if (status === "PAUSED") { stats.paused += 1 } else if (OPEN_STATUSES.has(status)) { stats.open += 1 } } const inRange = scopedTickets.filter((ticket) => matchesFilter(ticket)) const now = Date.now() for (const ticket of inRange) { const stats = ensureAgentStats(statsMap, ticket) if (!stats) continue stats.totalSla += 1 if (isTicketCompliant(ticket, now)) { stats.compliantSla += 1 } const status = normalizeStatus(ticket.status) if ( status === "RESOLVED" && typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < endMs ) { stats.resolved += 1 stats.resolutionMinutes.push((ticket.resolvedAt - ticket.createdAt) / 60000) } if ( typeof ticket.firstResponseAt === "number" && ticket.firstResponseAt >= startMs && ticket.firstResponseAt < endMs ) { stats.firstResponseMinutes.push((ticket.firstResponseAt - ticket.createdAt) / 60000) } } const agentIds = Array.from(statsMap.keys()) as string[] if (agentIds.length > 0) { const docs = await Promise.all(agentIds.map((id) => ctx.db.get(id as Id<"users">))) docs.forEach((doc, index) => { const stats = statsMap.get(agentIds[index]) if (!stats || !doc) return if (!stats.name && doc.name) stats.name = doc.name if (!stats.email && doc.email) stats.email = doc.email }) } const computed = new Map() for (const [key, raw] of statsMap.entries()) { const avgResolution = average(raw.resolutionMinutes) const avgFirstResponse = average(raw.firstResponseMinutes) const slaRate = raw.totalSla > 0 ? Math.min(1, Math.max(0, raw.compliantSla / raw.totalSla)) : null computed.set(key, { agentId: key, name: raw.name ?? raw.email ?? null, email: raw.email ?? null, open: raw.open, paused: raw.paused, resolved: raw.resolved, slaRate, avgResolutionMinutes: avgResolution, avgFirstResponseMinutes: avgFirstResponse, totalSla: raw.totalSla, compliantSla: raw.compliantSla, }) } return computed } const metricResolvers: Record = { "tickets.opened_resolved_by_day": async (ctx, { tenantId, viewer, params }) => { const rangeDays = parseRange(params) const companyId = parseCompanyId(params) const queueIds = parseQueueIds(params) const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - rangeDays * ONE_DAY_MS const openedTickets = filterTicketsByQueue( await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId), queueIds, ) const resolvedTickets = filterTicketsByQueue( await fetchScopedTicketsByResolvedRangeSnapshot(ctx, tenantId, viewer, startMs, endMs, companyId), queueIds, ) const opened: Record = {} const resolved: Record = {} for (let offset = rangeDays - 1; offset >= 0; offset -= 1) { const d = new Date(endMs - (offset + 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 } } for (const ticket of resolvedTickets) { if (typeof ticket.resolvedAt !== "number") continue if (ticket.resolvedAt < startMs || ticket.resolvedAt >= endMs) continue const key = formatDateKey(ticket.resolvedAt) resolved[key] = (resolved[key] ?? 0) + 1 } const series = [] for (let offset = rangeDays - 1; offset >= 0; offset -= 1) { const d = new Date(endMs - (offset + 1) * ONE_DAY_MS) const key = formatDateKey(d.getTime()) series.push({ date: key, opened: opened[key] ?? 0, resolved: resolved[key] ?? 0 }) } return { meta: { kind: "series", key: "tickets.opened_resolved_by_day", rangeDays }, data: series, } }, "tickets.waiting_action_now": async (ctx, { tenantId, viewer, params }) => { const tickets = filterTicketsByQueue( await fetchOpenScopedTickets(ctx, tenantId, viewer), parseQueueIds(params), ) const now = Date.now() let total = 0 let atRisk = 0 for (const ticket of tickets) { const status = normalizeStatus(ticket.status) if (!OPEN_STATUSES.has(status)) continue total += 1 if (ticket.dueAt && ticket.dueAt < now) { atRisk += 1 } } return { meta: { kind: "single", key: "tickets.waiting_action_now", unit: "tickets" }, data: { value: total, atRisk }, } }, "tickets.waiting_action_last_7d": async (ctx, { tenantId, viewer, params }) => { const rangeDays = 7 const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - rangeDays * ONE_DAY_MS const tickets = filterTicketsByQueue( await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs), parseQueueIds(params), ) const daily: Record = {} for (let offset = rangeDays - 1; offset >= 0; offset -= 1) { const d = new Date(endMs - (offset + 1) * ONE_DAY_MS) const key = formatDateKey(d.getTime()) daily[key] = { total: 0, atRisk: 0 } } for (const ticket of tickets) { if (ticket.createdAt < startMs) continue const key = formatDateKey(ticket.createdAt) const bucket = daily[key] if (!bucket) continue if (OPEN_STATUSES.has(normalizeStatus(ticket.status))) { bucket.total += 1 if (ticket.dueAt && ticket.dueAt < Date.now()) { bucket.atRisk += 1 } } } const values = Object.values(daily) const total = values.reduce((sum, item) => sum + item.total, 0) const atRisk = values.reduce((sum, item) => sum + item.atRisk, 0) return { meta: { kind: "single", key: "tickets.waiting_action_last_7d", aggregation: "sum", rangeDays }, data: { value: total, atRisk }, } }, "tickets.open_by_priority": async (ctx, { tenantId, viewer, params }) => { const rangeDays = parseRange(params) const companyId = parseCompanyId(params) const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - rangeDays * ONE_DAY_MS const tickets = filterTicketsByQueue( await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId), parseQueueIds(params), ) const counts: Record = {} for (const ticket of tickets) { if (!OPEN_STATUSES.has(normalizeStatus(ticket.status))) continue const key = (ticket.priority ?? "MEDIUM").toUpperCase() counts[key] = (counts[key] ?? 0) + 1 } const data = Object.entries(counts).map(([priority, total]) => ({ priority, total })) data.sort((a, b) => b.total - a.total) return { meta: { kind: "collection", key: "tickets.open_by_priority", rangeDays }, data, } }, "tickets.open_by_queue": async (ctx, { tenantId, viewer, params }) => { const rangeDays = parseRange(params) const companyId = parseCompanyId(params) const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - rangeDays * ONE_DAY_MS const queueFilter = parseQueueIds(params) const tickets = filterTicketsByQueue( await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId), parseQueueIds(params), ) const queueCounts = new Map() for (const ticket of tickets) { if (!OPEN_STATUSES.has(normalizeStatus(ticket.status))) continue const queueKey = ticket.queueId ? String(ticket.queueId) : "sem-fila" if (queueFilter && queueFilter.length > 0 && !queueFilter.includes(queueKey)) { continue } queueCounts.set(queueKey, (queueCounts.get(queueKey) ?? 0) + 1) } const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect() const data = Array.from(queueCounts.entries()).map(([queueId, total]) => { const queue = queues.find((q) => String(q._id) === queueId) return { queueId, name: queue ? queue.name : queueId === "sem-fila" ? "Sem fila" : "Fila desconhecida", total, } }) data.sort((a, b) => b.total - a.total) return { meta: { kind: "collection", key: "tickets.open_by_queue", rangeDays }, data, } }, "queues.summary_cards": async (ctx, { tenantId, viewer, params }) => { const queueFilter = parseQueueIds(params) const filterHas = queueFilter && queueFilter.length > 0 const normalizeKey = (id: Id<"queues"> | null) => (id ? String(id) : "sem-fila") const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect() const queueNameMap = new Map() queues.forEach((queue) => { const key = String(queue._id) queueNameMap.set(key, renameQueueName(queue.name)) }) const now = Date.now() const stats = new Map< string, { id: string; name: string; pending: number; inProgress: number; paused: number; breached: number } >() const ensureEntry = (key: string, fallbackName?: string) => { if (!stats.has(key)) { const resolvedName = queueNameMap.get(key) ?? (key === "sem-fila" ? "Sem fila" : fallbackName ?? "Fila desconhecida") stats.set(key, { id: key, name: resolvedName, pending: 0, inProgress: 0, paused: 0, breached: 0, }) } return stats.get(key)! } for (const queue of queues) { const key = String(queue._id) if (filterHas && queueFilter && !queueFilter.includes(key)) continue ensureEntry(key) } const scopedTickets = await fetchOpenScopedTickets(ctx, tenantId, viewer) for (const ticket of scopedTickets) { const key = normalizeKey(ticket.queueId ?? null) if (filterHas && queueFilter && !queueFilter.includes(key)) continue const entry = ensureEntry(key) const status = normalizeStatus(ticket.status) if (status === "PENDING") { entry.pending += 1 } else if (status === "AWAITING_ATTENDANCE") { entry.inProgress += 1 } else if (status === "PAUSED") { entry.paused += 1 } if (status !== "RESOLVED") { const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null if (dueAt && dueAt < now) { entry.breached += 1 } } } if (!(filterHas && queueFilter && !queueFilter.includes("sem-fila"))) { ensureEntry("sem-fila", "Sem fila") } else if (filterHas) { stats.delete("sem-fila") } const data = Array.from(stats.values()).map((item) => ({ id: item.id, name: item.name, pending: item.pending, inProgress: item.inProgress, paused: item.paused, breached: item.breached, })) data.sort((a, b) => { const totalA = a.pending + a.inProgress + a.paused const totalB = b.pending + b.inProgress + b.paused if (totalA === totalB) { return a.name.localeCompare(b.name, "pt-BR") } return totalB - totalA }) return { meta: { kind: "collection", key: "queues.summary_cards" }, data, } }, "tickets.sla_compliance_by_queue": async (ctx, { tenantId, viewer, params }) => { const rangeDays = parseRange(params) const companyId = parseCompanyId(params) const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - rangeDays * ONE_DAY_MS const queueFilter = parseQueueIds(params) const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) const now = Date.now() const stats = new Map() for (const ticket of tickets) { const queueKey = ticket.queueId ? String(ticket.queueId) : "sem-fila" if (queueFilter && queueFilter.length > 0 && !queueFilter.includes(queueKey)) { continue } const current = stats.get(queueKey) ?? { total: 0, compliant: 0 } current.total += 1 const dueAt = typeof ticket.dueAt === "number" ? ticket.dueAt : null const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null let compliant = false if (dueAt) { if (resolvedAt) { compliant = resolvedAt <= dueAt } else { compliant = dueAt >= now } } else { compliant = resolvedAt !== null } if (compliant) { current.compliant += 1 } stats.set(queueKey, current) } const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect() const data = Array.from(stats.entries()).map(([queueId, value]) => { const queue = queues.find((q) => String(q._id) === queueId) const compliance = value.total > 0 ? value.compliant / value.total : 0 return { queueId, name: queue ? queue.name : queueId === "sem-fila" ? "Sem fila" : "Fila desconhecida", total: value.total, compliance, } }) data.sort((a, b) => (b.compliance ?? 0) - (a.compliance ?? 0)) return { meta: { kind: "collection", key: "tickets.sla_compliance_by_queue", rangeDays }, data, } }, "tickets.sla_rate": async (ctx, { tenantId, viewer, params }) => { const rangeDays = parseRange(params) const companyId = parseCompanyId(params) const end = new Date() end.setUTCHours(0, 0, 0, 0) const endMs = end.getTime() + ONE_DAY_MS const startMs = endMs - rangeDays * ONE_DAY_MS const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId) const total = tickets.length const resolved = tickets.filter((t) => normalizeStatus(t.status) === "RESOLVED").length const rate = total > 0 ? resolved / total : 0 return { meta: { kind: "single", key: "tickets.sla_rate", rangeDays, unit: "ratio" }, data: { value: rate, total, resolved }, } }, "tickets.awaiting_table": async (ctx, { tenantId, viewer, params }) => { const limit = parseLimit(params, 20) const tickets = filterTicketsByQueue( await fetchOpenScopedTickets(ctx, tenantId, viewer), parseQueueIds(params), ) const awaiting = tickets .filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) .slice(0, limit) .map((ticket) => ({ id: ticket._id, reference: ticket.reference ?? null, subject: ticket.subject, status: normalizeStatus(ticket.status), priority: ticket.priority, updatedAt: ticket.updatedAt, createdAt: ticket.createdAt, assignee: ticket.assigneeSnapshot ? { name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null, email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null, } : null, queueId: ticket.queueId ?? null, })) return { meta: { kind: "table", key: "tickets.awaiting_table", limit }, data: awaiting, } }, "devices.health_summary": async (ctx, { tenantId, params }) => { const limit = parseLimit(params, 10) // Limita a 200 maquinas para evitar OOM const machines = await ctx.db.query("machines").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).take(200) const now = Date.now() const summary = machines .map((machine) => { const lastHeartbeatAt = machine.lastHeartbeatAt ?? null const minutesSinceHeartbeat = lastHeartbeatAt ? Math.round((now - lastHeartbeatAt) / 60000) : null const status = deriveMachineStatus(machine, now) const cpu = clampPercent(pickMachineMetric(machine, ["cpuUsagePercent", "cpu_usage_percent"])) const memory = clampPercent(pickMachineMetric(machine, ["memoryUsedPercent", "memory_usage_percent"])) const disk = clampPercent(pickMachineMetric(machine, ["diskUsedPercent", "diskUsagePercent", "storageUsedPercent"])) const alerts = readMachineAlertsCount(machine) const fallbackHostname = readString((machine as unknown as Record)["computerName"]) const hostname = machine.hostname ?? fallbackHostname ?? "Dispositivo sem nome" const attention = (cpu ?? 0) > 85 || (memory ?? 0) > 90 || (disk ?? 0) > 90 || (minutesSinceHeartbeat ?? Infinity) > 120 || alerts > 0 return { id: machine._id, hostname, status, cpuUsagePercent: cpu, memoryUsedPercent: memory, diskUsedPercent: disk, lastHeartbeatAt, minutesSinceHeartbeat, alerts, attention, } }) .sort((a, b) => { if (a.attention === b.attention) { return (b.cpuUsagePercent ?? 0) - (a.cpuUsagePercent ?? 0) } return a.attention ? -1 : 1 }) .slice(0, limit) return { meta: { kind: "collection", key: "devices.health_summary", limit }, data: summary, } }, "agents.self_ticket_status": async (ctx, { tenantId, viewer, viewerId, params }) => { const rangeDays = parseRange(params) const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) const stats = statsMap.get(String(viewerId)) const data = [ { status: "Abertos", total: stats?.open ?? 0 }, { status: "Pausados", total: stats?.paused ?? 0 }, { status: "Resolvidos", total: stats?.resolved ?? 0 }, ] return { meta: { kind: "collection", key: "agents.self_ticket_status", rangeDays }, data, } }, "agents.self_open_total": async (ctx, { tenantId, viewer, viewerId, params }) => { const rangeDays = parseRange(params) const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) const stats = statsMap.get(String(viewerId)) return { meta: { kind: "single", key: "agents.self_open_total", unit: "tickets", rangeDays }, data: { value: stats?.open ?? 0 }, } }, "agents.self_paused_total": async (ctx, { tenantId, viewer, viewerId, params }) => { const rangeDays = parseRange(params) const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) const stats = statsMap.get(String(viewerId)) return { meta: { kind: "single", key: "agents.self_paused_total", unit: "tickets", rangeDays }, data: { value: stats?.paused ?? 0 }, } }, "agents.self_resolved_total": async (ctx, { tenantId, viewer, viewerId, params }) => { const rangeDays = parseRange(params) const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) const stats = statsMap.get(String(viewerId)) return { meta: { kind: "single", key: "agents.self_resolved_total", unit: "tickets", rangeDays }, data: { value: stats?.resolved ?? 0 }, } }, "agents.self_sla_rate": async (ctx, { tenantId, viewer, viewerId, params }) => { const rangeDays = parseRange(params) const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) const stats = statsMap.get(String(viewerId)) return { meta: { kind: "single", key: "agents.self_sla_rate", rangeDays }, data: { value: stats?.slaRate ?? 0, total: stats?.totalSla ?? 0, compliant: stats?.compliantSla ?? 0, }, } }, "agents.self_avg_resolution_minutes": async (ctx, { tenantId, viewer, viewerId, params }) => { const rangeDays = parseRange(params) const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays, viewerId) const stats = statsMap.get(String(viewerId)) const raw = stats?.avgResolutionMinutes ?? null const value = raw !== null ? Math.round(raw * 10) / 10 : 0 return { meta: { kind: "single", key: "agents.self_avg_resolution_minutes", unit: "minutes", rangeDays }, data: { value }, } }, "agents.team_overview": async (ctx, { tenantId, viewer, params }) => { if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") { throw new ConvexError("Apenas administradores podem acessar esta métrica.") } const rangeDays = parseRange(params) const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays) const data = Array.from(statsMap.values()) .map((stats) => ({ agentId: stats.agentId, agentName: stats.name ?? stats.email ?? "Agente", open: stats.open, paused: stats.paused, resolved: stats.resolved, slaRate: stats.slaRate !== null ? Math.round(stats.slaRate * 1000) / 10 : null, avgResolutionMinutes: stats.avgResolutionMinutes !== null ? Math.round(stats.avgResolutionMinutes * 10) / 10 : null, })) .sort((a, b) => b.resolved - a.resolved) return { meta: { kind: "collection", key: "agents.team_overview", rangeDays }, data, } }, "agents.team_resolved_total": async (ctx, { tenantId, viewer, params }) => { if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") { throw new ConvexError("Apenas administradores podem acessar esta métrica.") } const rangeDays = parseRange(params) const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays) const data = Array.from(statsMap.values()) .map((stats) => ({ agentId: stats.agentId, agentName: stats.name ?? stats.email ?? "Agente", resolved: stats.resolved, })) .sort((a, b) => b.resolved - a.resolved) return { meta: { kind: "collection", key: "agents.team_resolved_total", rangeDays }, data, } }, "agents.team_sla_rate": async (ctx, { tenantId, viewer, params }) => { if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") { throw new ConvexError("Apenas administradores podem acessar esta métrica.") } const rangeDays = parseRange(params) const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays) const data = Array.from(statsMap.values()) .map((stats) => ({ agentId: stats.agentId, agentName: stats.name ?? stats.email ?? "Agente", compliance: stats.slaRate ?? 0, total: stats.totalSla, compliant: stats.compliantSla, })) .sort((a, b) => (b.compliance ?? 0) - (a.compliance ?? 0)) return { meta: { kind: "collection", key: "agents.team_sla_rate", rangeDays }, data, } }, "agents.team_avg_resolution_minutes": async (ctx, { tenantId, viewer, params }) => { if (viewer.role !== "ADMIN" && viewer.role !== "MANAGER") { throw new ConvexError("Apenas administradores podem acessar esta métrica.") } const rangeDays = parseRange(params) const statsMap = await computeAgentStats(ctx, tenantId, viewer, rangeDays) const data = Array.from(statsMap.values()) .map((stats) => ({ agentId: stats.agentId, agentName: stats.name ?? stats.email ?? "Agente", minutes: stats.avgResolutionMinutes !== null ? Math.round(stats.avgResolutionMinutes * 10) / 10 : 0, })) .sort((a, b) => (a.minutes ?? 0) - (b.minutes ?? 0)) return { meta: { kind: "collection", key: "agents.team_avg_resolution_minutes", rangeDays }, data, } }, } export const run = query({ args: { tenantId: v.string(), viewerId: v.id("users"), metricKey: v.string(), params: v.optional(v.any()), }, handler: async (ctx, { tenantId, viewerId, metricKey, params }) => { const viewer = await requireStaff(ctx, viewerId, tenantId) const resolver = metricResolvers[metricKey] if (!resolver) { return { meta: { kind: "error", key: metricKey, message: "Métrica não suportada" }, data: null, } } const payload = await resolver(ctx, { tenantId, viewer, viewerId, params: params && typeof params === "object" ? (params as Record) : undefined, }) return payload }, }) function formatDateKey(timestamp: number) { const d = new Date(timestamp) const year = d.getUTCFullYear() const month = `${d.getUTCMonth() + 1}`.padStart(2, "0") const day = `${d.getUTCDate()}`.padStart(2, "0") return `${year}-${month}-${day}` } function deriveMachineStatus(machine: Record, now: number) { const lastHeartbeatAt = typeof machine.lastHeartbeatAt === "number" ? machine.lastHeartbeatAt : null if (!lastHeartbeatAt) return "unknown" const diffMinutes = (now - lastHeartbeatAt) / 60000 if (diffMinutes <= 10) return "online" if (diffMinutes <= 120) return "stale" return "offline" } function clampPercent(value: unknown) { if (typeof value !== "number" || !Number.isFinite(value)) return null if (value < 0) return 0 if (value > 100) return 100 return Math.round(value * 10) / 10 } function readNumeric(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value } if (typeof value === "string") { const parsed = Number(value) if (Number.isFinite(parsed)) { return parsed } } return null } function pickMachineMetric(machine: Doc<"machines">, keys: string[]): number | null { const record = machine as unknown as Record for (const key of keys) { const direct = readNumeric(record[key]) if (direct !== null) { return direct } } const metadata = record["metadata"] if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) { const metrics = (metadata as Record)["metrics"] if (metrics && typeof metrics === "object" && !Array.isArray(metrics)) { const metricsRecord = metrics as Record for (const key of keys) { const value = readNumeric(metricsRecord[key]) if (value !== null) { return value } } } } return null } function readMachineAlertsCount(machine: Doc<"machines">): number { const record = machine as unknown as Record const directCount = readNumeric(record["postureAlertsCount"]) if (directCount !== null) { return directCount } const directArray = record["postureAlerts"] if (Array.isArray(directArray)) { return directArray.length } const metadata = record["metadata"] if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) { const metadataRecord = metadata as Record const metaCount = readNumeric(metadataRecord["postureAlertsCount"]) if (metaCount !== null) { return metaCount } const metaAlerts = metadataRecord["postureAlerts"] if (Array.isArray(metaAlerts)) { return metaAlerts.length } } return 0 } function readString(value: unknown): string | null { if (typeof value === "string" && value.trim().length > 0) { return value } return null }