import { 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, fetchScopedTickets, fetchScopedTicketsByCreatedRange, fetchScopedTicketsByResolvedRange, 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 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 fetchScopedTicketsByResolvedRange(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 fetchScopedTickets(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 fetchScopedTickets(ctx, tenantId, viewer), 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, } }, "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 fetchScopedTickets(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) const machines = await ctx.db.query("machines").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect() 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, } }, } 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 }