975 lines
33 KiB
TypeScript
975 lines
33 KiB
TypeScript
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,
|
|
normalizeStatus,
|
|
} from "./reports"
|
|
import { requireStaff } from "./rbac"
|
|
|
|
type Viewer = Awaited<ReturnType<typeof requireStaff>>
|
|
|
|
type MetricResolverInput = {
|
|
tenantId: string
|
|
viewer: Viewer
|
|
viewerId: Id<"users">
|
|
params?: Record<string, unknown> | undefined
|
|
}
|
|
|
|
type MetricRunPayload = {
|
|
meta: { kind: string; key: string } & Record<string, unknown>
|
|
data: unknown
|
|
}
|
|
|
|
type MetricResolver = (ctx: QueryCtx, input: MetricResolverInput) => Promise<MetricRunPayload>
|
|
|
|
function parseRange(params?: Record<string, unknown>): 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<string, unknown>, 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<string, unknown>): 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, unknown>): 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<T extends { queueId?: Id<"queues"> | 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<string, string> = {
|
|
"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<string, AgentStatsRaw>, 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<string, AgentStatsRaw>()
|
|
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<string, AgentStatsComputed>()
|
|
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<string, MetricResolver> = {
|
|
"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<string, number> = {}
|
|
const resolved: Record<string, number> = {}
|
|
|
|
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<string, { total: number; atRisk: number }> = {}
|
|
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<string, number> = {}
|
|
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<string, number>()
|
|
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<string, string>()
|
|
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<string, { total: number; compliant: number }>()
|
|
|
|
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)
|
|
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<string, unknown>)["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<string, unknown>) : 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<string, unknown>, 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<string, unknown>
|
|
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<string, unknown>)["metrics"]
|
|
if (metrics && typeof metrics === "object" && !Array.isArray(metrics)) {
|
|
const metricsRecord = metrics as Record<string, unknown>
|
|
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<string, unknown>
|
|
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<string, unknown>
|
|
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
|
|
}
|