chore: expand reports coverage and upgrade next

This commit is contained in:
codex-bot 2025-10-31 17:27:51 -03:00
parent 2fb587b01d
commit 8b82284e8c
21 changed files with 2952 additions and 2713 deletions

View file

@ -1,7 +1,8 @@
// ci: trigger convex functions deploy (no-op)
import { mutation, query } from "./_generated/server"
import { api } from "./_generated/api"
import { ConvexError, v } from "convex/values"
import { paginationOptsValidator } from "convex/server"
import { ConvexError, v, Infer } from "convex/values"
import { sha256 } from "@noble/hashes/sha256"
import { randomBytes } from "@noble/hashes/utils"
import type { Doc, Id } from "./_generated/dataModel"
@ -12,6 +13,8 @@ const DEFAULT_TENANT_ID = "tenant-atlas"
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
const ALLOWED_MACHINE_PERSONAS = new Set(["collaborator", "manager"])
const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000
const OPEN_TICKET_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"])
const MACHINE_TICKETS_STATS_PAGE_SIZE = 200
type NormalizedIdentifiers = {
macs: string[]
@ -876,123 +879,128 @@ export const listByTenant = query({
},
})
export async function getByIdHandler(
ctx: QueryCtx,
args: { id: Id<"machines">; includeMetadata?: boolean }
) {
const includeMetadata = Boolean(args.includeMetadata)
const now = Date.now()
const machine = await ctx.db.get(args.id)
if (!machine) return null
const companyFromId = machine.companyId ? await ctx.db.get(machine.companyId) : null
const machineSlug = machine.companySlug ?? null
let companyFromSlug: typeof companyFromId | null = null
if (!companyFromId && machineSlug) {
companyFromSlug = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", machine.tenantId).eq("slug", machineSlug))
.unique()
}
const resolvedCompany = companyFromId ?? companyFromSlug
const activeToken = await findActiveMachineToken(ctx, machine._id, now)
const offlineThresholdMs = getOfflineThresholdMs()
const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs)
const manualStatus = (machine.status ?? "").toLowerCase()
let derivedStatus: string
if (machine.isActive === false) {
derivedStatus = "deactivated"
} else if (["maintenance", "blocked"].includes(manualStatus)) {
derivedStatus = manualStatus
} else if (machine.lastHeartbeatAt) {
const age = now - machine.lastHeartbeatAt
if (age <= offlineThresholdMs) {
derivedStatus = "online"
} else if (age <= staleThresholdMs) {
derivedStatus = "offline"
} else {
derivedStatus = "stale"
}
} else {
derivedStatus = machine.status ?? "unknown"
}
const meta = includeMetadata ? (machine.metadata ?? null) : null
let metrics: Record<string, unknown> | null = null
let inventory: Record<string, unknown> | null = null
let postureAlerts: Array<Record<string, unknown>> | null = null
let lastPostureAt: number | null = null
if (meta && typeof meta === "object") {
const metaRecord = meta as Record<string, unknown>
if (metaRecord.metrics && typeof metaRecord.metrics === "object") {
metrics = metaRecord.metrics as Record<string, unknown>
}
if (metaRecord.inventory && typeof metaRecord.inventory === "object") {
inventory = metaRecord.inventory as Record<string, unknown>
}
if (Array.isArray(metaRecord.postureAlerts)) {
postureAlerts = metaRecord.postureAlerts as Array<Record<string, unknown>>
}
if (typeof metaRecord.lastPostureAt === "number") {
lastPostureAt = metaRecord.lastPostureAt as number
}
}
const linkedUserIds = machine.linkedUserIds ?? []
const linkedUsers = await Promise.all(
linkedUserIds.map(async (id) => {
const u = await ctx.db.get(id)
if (!u) return null
return { id: u._id, email: u.email, name: u.name }
})
).then((arr) => arr.filter(Boolean) as Array<{ id: string; email: string; name: string }>)
return {
id: machine._id,
tenantId: machine.tenantId,
hostname: machine.hostname,
companyId: machine.companyId ?? null,
companySlug: machine.companySlug ?? resolvedCompany?.slug ?? null,
companyName: resolvedCompany?.name ?? null,
osName: machine.osName,
osVersion: machine.osVersion ?? null,
architecture: machine.architecture ?? null,
macAddresses: machine.macAddresses,
serialNumbers: machine.serialNumbers,
authUserId: machine.authUserId ?? null,
authEmail: machine.authEmail ?? null,
persona: machine.persona ?? null,
assignedUserId: machine.assignedUserId ?? null,
assignedUserEmail: machine.assignedUserEmail ?? null,
assignedUserName: machine.assignedUserName ?? null,
assignedUserRole: machine.assignedUserRole ?? null,
linkedUsers,
status: derivedStatus,
isActive: machine.isActive ?? true,
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
registeredBy: machine.registeredBy ?? null,
createdAt: machine.createdAt,
updatedAt: machine.updatedAt,
token: activeToken
? {
expiresAt: activeToken.expiresAt,
lastUsedAt: activeToken.lastUsedAt ?? null,
usageCount: activeToken.usageCount ?? 0,
}
: null,
metrics,
inventory,
postureAlerts,
lastPostureAt,
remoteAccess: machine.remoteAccess ?? null,
}
}
export const getById = query({
args: {
id: v.id("machines"),
includeMetadata: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const includeMetadata = Boolean(args.includeMetadata)
const now = Date.now()
const machine = await ctx.db.get(args.id)
if (!machine) return null
const companyFromId = machine.companyId ? await ctx.db.get(machine.companyId) : null
const machineSlug = machine.companySlug ?? null
let companyFromSlug: typeof companyFromId | null = null
if (!companyFromId && machineSlug) {
companyFromSlug = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", machine.tenantId).eq("slug", machineSlug))
.unique()
}
const resolvedCompany = companyFromId ?? companyFromSlug
const activeToken = await findActiveMachineToken(ctx, machine._id, now)
const offlineThresholdMs = getOfflineThresholdMs()
const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs)
const manualStatus = (machine.status ?? "").toLowerCase()
let derivedStatus: string
if (machine.isActive === false) {
derivedStatus = "deactivated"
} else if (["maintenance", "blocked"].includes(manualStatus)) {
derivedStatus = manualStatus
} else if (machine.lastHeartbeatAt) {
const age = now - machine.lastHeartbeatAt
if (age <= offlineThresholdMs) {
derivedStatus = "online"
} else if (age <= staleThresholdMs) {
derivedStatus = "offline"
} else {
derivedStatus = "stale"
}
} else {
derivedStatus = machine.status ?? "unknown"
}
const meta = includeMetadata ? (machine.metadata ?? null) : null
let metrics: Record<string, unknown> | null = null
let inventory: Record<string, unknown> | null = null
let postureAlerts: Array<Record<string, unknown>> | null = null
let lastPostureAt: number | null = null
if (meta && typeof meta === "object") {
const metaRecord = meta as Record<string, unknown>
if (metaRecord.metrics && typeof metaRecord.metrics === "object") {
metrics = metaRecord.metrics as Record<string, unknown>
}
if (metaRecord.inventory && typeof metaRecord.inventory === "object") {
inventory = metaRecord.inventory as Record<string, unknown>
}
if (Array.isArray(metaRecord.postureAlerts)) {
postureAlerts = metaRecord.postureAlerts as Array<Record<string, unknown>>
}
if (typeof metaRecord.lastPostureAt === "number") {
lastPostureAt = metaRecord.lastPostureAt as number
}
}
const linkedUserIds = machine.linkedUserIds ?? []
const linkedUsers = await Promise.all(
linkedUserIds.map(async (id) => {
const u = await ctx.db.get(id)
if (!u) return null
return { id: u._id, email: u.email, name: u.name }
})
).then((arr) => arr.filter(Boolean) as Array<{ id: string; email: string; name: string }>)
return {
id: machine._id,
tenantId: machine.tenantId,
hostname: machine.hostname,
companyId: machine.companyId ?? null,
companySlug: machine.companySlug ?? resolvedCompany?.slug ?? null,
companyName: resolvedCompany?.name ?? null,
osName: machine.osName,
osVersion: machine.osVersion ?? null,
architecture: machine.architecture ?? null,
macAddresses: machine.macAddresses,
serialNumbers: machine.serialNumbers,
authUserId: machine.authUserId ?? null,
authEmail: machine.authEmail ?? null,
persona: machine.persona ?? null,
assignedUserId: machine.assignedUserId ?? null,
assignedUserEmail: machine.assignedUserEmail ?? null,
assignedUserName: machine.assignedUserName ?? null,
assignedUserRole: machine.assignedUserRole ?? null,
linkedUsers,
status: derivedStatus,
isActive: machine.isActive ?? true,
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
registeredBy: machine.registeredBy ?? null,
createdAt: machine.createdAt,
updatedAt: machine.updatedAt,
token: activeToken
? {
expiresAt: activeToken.expiresAt,
lastUsedAt: activeToken.lastUsedAt ?? null,
usageCount: activeToken.usageCount ?? 0,
}
: null,
metrics,
inventory,
postureAlerts,
lastPostureAt,
remoteAccess: machine.remoteAccess ?? null,
}
},
handler: getByIdHandler,
})
export const listAlerts = query({
@ -1029,7 +1037,7 @@ export const listOpenTickets = query({
handler: async (ctx, { machineId, limit }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
return []
return { totalOpen: 0, hasMore: false, tickets: [] }
}
const takeLimit = Math.max(1, Math.min(limit ?? 10, 50))
const candidates = await ctx.db
@ -1041,31 +1049,392 @@ export const listOpenTickets = query({
const openTickets = candidates
.filter((ticket) => normalizeStatus(ticket.status) !== "RESOLVED")
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
.slice(0, takeLimit)
const totalOpen = openTickets.length
const limited = openTickets.slice(0, takeLimit)
return openTickets.map((ticket) => ({
id: ticket._id,
reference: ticket.reference,
subject: ticket.subject,
status: normalizeStatus(ticket.status),
priority: ticket.priority ?? "MEDIUM",
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,
machine: {
id: String(ticket.machineId ?? machineId),
hostname:
((ticket.machineSnapshot as { hostname?: string } | undefined)?.hostname ?? machine.hostname ?? null),
},
}))
return {
totalOpen,
hasMore: totalOpen > takeLimit,
tickets: limited.map((ticket) => ({
id: ticket._id,
reference: ticket.reference,
subject: ticket.subject,
status: normalizeStatus(ticket.status),
priority: ticket.priority ?? "MEDIUM",
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,
machine: {
id: String(ticket.machineId ?? machineId),
hostname:
((ticket.machineSnapshot as { hostname?: string } | undefined)?.hostname ?? machine.hostname ?? null),
},
})),
}
},
})
type MachineTicketsHistoryFilter = {
statusFilter: "all" | "open" | "resolved"
priorityFilter: string | null
from: number | null
to: number | null
}
type ListTicketsHistoryArgs = {
machineId: Id<"machines">
status?: "all" | "open" | "resolved"
priority?: string
search?: string
from?: number
to?: number
paginationOpts: Infer<typeof paginationOptsValidator>
}
type GetTicketsHistoryStatsArgs = {
machineId: Id<"machines">
status?: "all" | "open" | "resolved"
priority?: string
search?: string
from?: number
to?: number
}
function createMachineTicketsQuery(
ctx: QueryCtx,
machine: Doc<"machines">,
machineId: Id<"machines">,
filters: MachineTicketsHistoryFilter
) {
let working = ctx.db
.query("tickets")
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", machineId))
.order("desc")
if (filters.statusFilter === "open") {
working = working.filter((q) =>
q.or(
q.eq(q.field("status"), "PENDING"),
q.eq(q.field("status"), "AWAITING_ATTENDANCE"),
q.eq(q.field("status"), "PAUSED")
)
)
} else if (filters.statusFilter === "resolved") {
working = working.filter((q) => q.eq(q.field("status"), "RESOLVED"))
}
if (filters.priorityFilter) {
working = working.filter((q) => q.eq(q.field("priority"), filters.priorityFilter))
}
if (typeof filters.from === "number") {
working = working.filter((q) => q.gte(q.field("updatedAt"), filters.from!))
}
if (typeof filters.to === "number") {
working = working.filter((q) => q.lte(q.field("updatedAt"), filters.to!))
}
return working
}
function matchesTicketSearch(ticket: Doc<"tickets">, searchTerm: string): boolean {
const normalized = searchTerm.trim().toLowerCase()
if (!normalized) return true
const subject = ticket.subject.toLowerCase()
if (subject.includes(normalized)) return true
const summary = typeof ticket.summary === "string" ? ticket.summary.toLowerCase() : ""
if (summary.includes(normalized)) return true
const reference = `#${ticket.reference}`.toLowerCase()
if (reference.includes(normalized)) return true
const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined
if (requesterSnapshot) {
if (requesterSnapshot.name?.toLowerCase().includes(normalized)) return true
if (requesterSnapshot.email?.toLowerCase().includes(normalized)) return true
}
const assigneeSnapshot = ticket.assigneeSnapshot as { name?: string; email?: string } | undefined
if (assigneeSnapshot) {
if (assigneeSnapshot.name?.toLowerCase().includes(normalized)) return true
if (assigneeSnapshot.email?.toLowerCase().includes(normalized)) return true
}
return false
}
export async function listTicketsHistoryHandler(ctx: QueryCtx, args: ListTicketsHistoryArgs) {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
return {
page: [],
isDone: true,
continueCursor: args.paginationOpts.cursor ?? "",
}
}
const normalizedStatusFilter = args.status ?? "all"
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
const searchTerm = args.search?.trim().toLowerCase() ?? null
const from = typeof args.from === "number" ? args.from : null
const to = typeof args.to === "number" ? args.to : null
const filters: MachineTicketsHistoryFilter = {
statusFilter: normalizedStatusFilter,
priorityFilter: normalizedPriorityFilter,
from,
to,
}
const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate(args.paginationOpts)
const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page
const queueCache = new Map<string, Doc<"queues"> | null>()
const items = await Promise.all(
page.map(async (ticket) => {
let queueName: string | null = null
if (ticket.queueId) {
const key = String(ticket.queueId)
if (!queueCache.has(key)) {
queueCache.set(key, (await ctx.db.get(ticket.queueId)) as Doc<"queues"> | null)
}
queueName = queueCache.get(key)?.name ?? null
}
const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined
const assigneeSnapshot = ticket.assigneeSnapshot as { name?: string; email?: string } | undefined
return {
id: ticket._id,
reference: ticket.reference,
subject: ticket.subject,
status: normalizeStatus(ticket.status),
priority: (ticket.priority ?? "MEDIUM").toString().toUpperCase(),
updatedAt: ticket.updatedAt ?? ticket.createdAt ?? 0,
createdAt: ticket.createdAt ?? 0,
queue: queueName,
requester: requesterSnapshot
? {
name: requesterSnapshot.name ?? null,
email: requesterSnapshot.email ?? null,
}
: null,
assignee: assigneeSnapshot
? {
name: assigneeSnapshot.name ?? null,
email: assigneeSnapshot.email ?? null,
}
: null,
}
})
)
return {
page: items,
isDone: pageResult.isDone,
continueCursor: pageResult.continueCursor,
splitCursor: pageResult.splitCursor ?? undefined,
pageStatus: pageResult.pageStatus ?? undefined,
}
}
export const listTicketsHistory = query({
args: {
machineId: v.id("machines"),
status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))),
priority: v.optional(v.string()),
search: v.optional(v.string()),
from: v.optional(v.number()),
to: v.optional(v.number()),
paginationOpts: paginationOptsValidator,
},
handler: listTicketsHistoryHandler,
})
export async function getTicketsHistoryStatsHandler(
ctx: QueryCtx,
args: GetTicketsHistoryStatsArgs
) {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
return { total: 0, openCount: 0, resolvedCount: 0 }
}
const normalizedStatusFilter = args.status ?? "all"
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
const searchTerm = args.search?.trim().toLowerCase() ?? ""
const from = typeof args.from === "number" ? args.from : null
const to = typeof args.to === "number" ? args.to : null
const filters: MachineTicketsHistoryFilter = {
statusFilter: normalizedStatusFilter,
priorityFilter: normalizedPriorityFilter,
from,
to,
}
let cursor: string | null = null
let total = 0
let openCount = 0
let done = false
while (!done) {
const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate({
numItems: MACHINE_TICKETS_STATS_PAGE_SIZE,
cursor,
})
const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page
total += page.length
for (const ticket of page) {
if (OPEN_TICKET_STATUSES.has(normalizeStatus(ticket.status))) {
openCount += 1
}
}
done = pageResult.isDone
cursor = pageResult.continueCursor ?? null
if (!cursor) {
done = true
}
}
return {
total,
openCount,
resolvedCount: total - openCount,
}
}
export const getTicketsHistoryStats = query({
args: {
machineId: v.id("machines"),
status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))),
priority: v.optional(v.string()),
search: v.optional(v.string()),
from: v.optional(v.number()),
to: v.optional(v.number()),
},
handler: getTicketsHistoryStatsHandler,
})
export async function updatePersonaHandler(
ctx: MutationCtx,
args: {
machineId: Id<"machines">
persona?: string | null
assignedUserId?: Id<"users">
assignedUserEmail?: string | null
assignedUserName?: string | null
assignedUserRole?: string | null
}
) {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
}
let nextPersona = machine.persona ?? undefined
const personaProvided = args.persona !== undefined
if (args.persona !== undefined) {
const trimmed = (args.persona ?? "").trim().toLowerCase()
if (!trimmed) {
nextPersona = undefined
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
throw new ConvexError("Perfil inválido para a máquina")
} else {
nextPersona = trimmed
}
}
let nextAssignedUserId = machine.assignedUserId ?? undefined
if (args.assignedUserId !== undefined) {
nextAssignedUserId = args.assignedUserId
}
let nextAssignedEmail = machine.assignedUserEmail ?? undefined
if (args.assignedUserEmail !== undefined) {
const trimmedEmail = (args.assignedUserEmail ?? "").trim().toLowerCase()
nextAssignedEmail = trimmedEmail || undefined
}
let nextAssignedName = machine.assignedUserName ?? undefined
if (args.assignedUserName !== undefined) {
const trimmedName = (args.assignedUserName ?? "").trim()
nextAssignedName = trimmedName || undefined
}
let nextAssignedRole = machine.assignedUserRole ?? undefined
if (args.assignedUserRole !== undefined) {
const trimmedRole = (args.assignedUserRole ?? "").trim().toUpperCase()
nextAssignedRole = trimmedRole || undefined
}
if (personaProvided && !nextPersona) {
nextAssignedUserId = undefined
nextAssignedEmail = undefined
nextAssignedName = undefined
nextAssignedRole = undefined
}
if (nextPersona && !nextAssignedUserId) {
throw new ConvexError("Associe um usuário ao definir a persona da máquina")
}
if (nextPersona && nextAssignedUserId) {
const assignedUser = await ctx.db.get(nextAssignedUserId)
if (!assignedUser) {
throw new ConvexError("Usuário vinculado não encontrado")
}
if (assignedUser.tenantId !== machine.tenantId) {
throw new ConvexError("Usuário vinculado pertence a outro tenant")
}
}
let nextMetadata = machine.metadata
if (nextPersona) {
const collaboratorMeta = {
email: nextAssignedEmail ?? null,
name: nextAssignedName ?? null,
role: nextPersona,
}
nextMetadata = mergeMetadata(machine.metadata, { collaborator: collaboratorMeta })
}
const patch: Record<string, unknown> = {
persona: nextPersona,
assignedUserId: nextPersona ? nextAssignedUserId : undefined,
assignedUserEmail: nextPersona ? nextAssignedEmail : undefined,
assignedUserName: nextPersona ? nextAssignedName : undefined,
assignedUserRole: nextPersona ? nextAssignedRole : undefined,
updatedAt: Date.now(),
}
if (nextMetadata !== machine.metadata) {
patch.metadata = nextMetadata
}
if (personaProvided) {
patch.persona = nextPersona
}
if (nextPersona) {
patch.assignedUserId = nextAssignedUserId
patch.assignedUserEmail = nextAssignedEmail
patch.assignedUserName = nextAssignedName
patch.assignedUserRole = nextAssignedRole
} else if (personaProvided) {
patch.assignedUserId = undefined
patch.assignedUserEmail = undefined
patch.assignedUserName = undefined
patch.assignedUserRole = undefined
}
await ctx.db.patch(machine._id, patch)
return { ok: true, persona: nextPersona ?? null }
}
export const updatePersona = mutation({
args: {
machineId: v.id("machines"),
@ -1075,110 +1444,7 @@ export const updatePersona = mutation({
assignedUserName: v.optional(v.string()),
assignedUserRole: v.optional(v.string()),
},
handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
}
let nextPersona = machine.persona ?? undefined
const personaProvided = args.persona !== undefined
if (args.persona !== undefined) {
const trimmed = args.persona.trim().toLowerCase()
if (!trimmed) {
nextPersona = undefined
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
throw new ConvexError("Perfil inválido para a máquina")
} else {
nextPersona = trimmed
}
}
let nextAssignedUserId = machine.assignedUserId ?? undefined
if (args.assignedUserId !== undefined) {
nextAssignedUserId = args.assignedUserId
}
let nextAssignedEmail = machine.assignedUserEmail ?? undefined
if (args.assignedUserEmail !== undefined) {
const trimmedEmail = args.assignedUserEmail.trim().toLowerCase()
nextAssignedEmail = trimmedEmail || undefined
}
let nextAssignedName = machine.assignedUserName ?? undefined
if (args.assignedUserName !== undefined) {
const trimmedName = args.assignedUserName.trim()
nextAssignedName = trimmedName || undefined
}
let nextAssignedRole = machine.assignedUserRole ?? undefined
if (args.assignedUserRole !== undefined) {
const trimmedRole = args.assignedUserRole.trim().toUpperCase()
nextAssignedRole = trimmedRole || undefined
}
if (personaProvided && !nextPersona) {
nextAssignedUserId = undefined
nextAssignedEmail = undefined
nextAssignedName = undefined
nextAssignedRole = undefined
}
if (nextPersona && !nextAssignedUserId) {
throw new ConvexError("Associe um usuário ao definir a persona da máquina")
}
if (nextPersona && nextAssignedUserId) {
const assignedUser = await ctx.db.get(nextAssignedUserId)
if (!assignedUser) {
throw new ConvexError("Usuário vinculado não encontrado")
}
if (assignedUser.tenantId !== machine.tenantId) {
throw new ConvexError("Usuário vinculado pertence a outro tenant")
}
}
let nextMetadata = machine.metadata
if (nextPersona) {
const collaboratorMeta = {
email: nextAssignedEmail ?? null,
name: nextAssignedName ?? null,
role: nextPersona,
}
nextMetadata = mergeMetadata(machine.metadata, { collaborator: collaboratorMeta })
}
const patch: Record<string, unknown> = {
persona: nextPersona,
assignedUserId: nextPersona ? nextAssignedUserId : undefined,
assignedUserEmail: nextPersona ? nextAssignedEmail : undefined,
assignedUserName: nextPersona ? nextAssignedName : undefined,
assignedUserRole: nextPersona ? nextAssignedRole : undefined,
updatedAt: Date.now(),
}
if (nextMetadata !== machine.metadata) {
patch.metadata = nextMetadata
}
if (personaProvided) {
patch.persona = nextPersona
}
if (nextPersona) {
patch.assignedUserId = nextAssignedUserId
patch.assignedUserEmail = nextAssignedEmail
patch.assignedUserName = nextAssignedName
patch.assignedUserRole = nextAssignedRole
} else if (personaProvided) {
patch.assignedUserId = undefined
patch.assignedUserEmail = undefined
patch.assignedUserName = undefined
patch.assignedUserRole = undefined
}
await ctx.db.patch(machine._id, patch)
return { ok: true, persona: nextPersona ?? null }
},
handler: updatePersonaHandler,
})
export const getContext = query({

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,20 @@ A página Admin > Máquinas agora exibe um inventário detalhado e pesquisável
## Exportação
- Exportar CSV de softwares ou serviços diretamente da seção detalhada (quando disponíveis).
- Exportar planilha XLSX completa (`/admin/machines/:id/inventory.xlsx`). A partir de 31/10/2025 a planilha contém:
- **Resumo**: data de geração, filtros aplicados, contagem por status e total de acessos remotos/alertas.
- **Inventário**: colunas principais exibidas na UI (status, persona, hardware, token, build/licença do SO, domínio, colaborador, Fleet, etc.).
- **Vínculos**: usuários associados à máquina.
- **Softwares**: lista deduplicada (nome + versão + origem/publisher). A coluna “Softwares instalados” no inventário bate com o total desta aba.
- **Partições**: nome/mount/FS/capacidade/livre, convertendo unidades (ex.: 447 GB → bytes).
- **Discos físicos**: modelo, tamanho, interface, tipo e serial de cada drive.
- **Rede**: interfaces com MAC/IP de todas as fontes (agente, Fleet).
- **Acessos remotos**: TeamViewer/AnyDesk/etc. com notas, URL, última verificação e metadados brutos.
- **Serviços**: serviços coletados (Windows/Linux) com nome, display name e status.
- **Alertas**: postura recente (tipo, mensagem, severidade, criado em).
- **Métricas**: CPU/Memória/Disco/GPU com timestamp coletado.
- **Labels**: tags aplicadas à máquina.
- **Sistema**: visão categorizada (Sistema, Dispositivo, Hardware, Acesso, Token, Fleet) contendo build, licença, domínio, fabricante, serial, colaborador, contagem de acessos, etc.
## Notas
- Os dados vêm de duas fontes:

Binary file not shown.

View file

@ -56,7 +56,7 @@
"date-fns": "^4.1.0",
"dotenv": "^16.4.5",
"lucide-react": "^0.544.0",
"next": "^16.0.0",
"next": "^16.0.1",
"next-themes": "^0.4.6",
"pdfkit": "^0.17.2",
"postcss": "^8.5.6",
@ -88,7 +88,7 @@
"better-sqlite3": "^12.4.1",
"cross-env": "^10.1.0",
"eslint": "^9",
"eslint-config-next": "^16.0.0",
"eslint-config-next": "^16.0.1",
"eslint-plugin-react-hooks": "^5.0.0",
"jsdom": "^27.0.1",
"playwright": "^1.56.1",

1939
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { MachineBreadcrumbs } from "@/components/admin/machines/machine-breadcrumbs.client"
import { MachineTicketsHistoryClient } from "@/components/admin/machines/machine-tickets-history.client"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default async function AdminMachineTicketsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return (
<AppShell header={<SiteHeader title="Tickets da máquina" lead="Histórico completo de chamados vinculados à máquina." />}>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<MachineBreadcrumbs
tenantId={DEFAULT_TENANT_ID}
machineId={id}
machineHref={`/admin/machines/${id}`}
extra={[{ label: "Tickets" }]}
/>
<MachineTicketsHistoryClient tenantId={DEFAULT_TENANT_ID} machineId={id} />
</div>
</AppShell>
)
}

View file

@ -102,6 +102,12 @@ type MachineTicketSummary = {
assignee: { name: string | null; email: string | null } | null
}
type MachineOpenTicketsSummary = {
totalOpen: number
hasMore: boolean
tickets: MachineTicketSummary[]
}
type DetailLineProps = {
label: string
@ -1454,9 +1460,14 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
const machineAlertsHistory = alertsHistory ?? []
const openTickets = useQuery(
machine ? api.machines.listOpenTickets : "skip",
machine ? { machineId: machine.id as Id<"machines">, limit: 8 } : ("skip" as const)
) as MachineTicketSummary[] | undefined
const machineTickets = openTickets ?? []
machine ? { machineId: machine.id as Id<"machines">, limit: 6 } : ("skip" as const)
) as MachineOpenTicketsSummary | undefined
const machineTickets = openTickets?.tickets ?? []
const totalOpenTickets = openTickets?.totalOpen ?? machineTickets.length
const displayLimit = 3
const displayedMachineTickets = machineTickets.slice(0, displayLimit)
const hasAdditionalOpenTickets = totalOpenTickets > displayedMachineTickets.length
const machineTicketsHref = machine ? `/admin/machines/${machine.id}/tickets` : null
const metadata = machine?.inventory ?? null
const metrics = machine?.metrics ?? null
const metricsCapturedAt = useMemo(() => getMetricsTimestamp(metrics), [metrics])
@ -2356,45 +2367,65 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</div>
<div className="rounded-2xl border border-[color:var(--accent)] bg-[color:var(--accent)]/80 px-4 py-4">
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="space-y-1">
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por esta máquina</h4>
{machineTickets.length === 0 ? (
<p className="text-xs text-[color:var(--accent-foreground)]/80">Nenhum chamado em aberto registrado diretamente por esta máquina.</p>
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-accent-foreground">Tickets abertos por esta máquina</h4>
{machineTicketsHref ? (
<Link
href={machineTicketsHref}
className="text-xs font-semibold text-accent-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent-foreground)] focus-visible:ring-offset-2"
>
Ver todos
</Link>
) : null}
</div>
{totalOpenTickets === 0 ? (
<p className="text-xs text-[color:var(--accent-foreground)]/80">
Nenhum chamado em aberto registrado diretamente por esta máquina.
</p>
) : (
<ul className="space-y-2">
{machineTickets.map((ticket) => {
const priorityMeta = getTicketPriorityMeta(ticket.priority)
return (
<li key={ticket.id}>
<Link
href={`/tickets/${ticket.id}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white px-3 py-2 text-sm shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
</p>
</div>
<div className="flex items-center gap-2">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
{priorityMeta.label}
</Badge>
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
</div>
</Link>
</li>
)
})}
</ul>
<div className="space-y-2">
{hasAdditionalOpenTickets ? (
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--accent-foreground)]/70">
Mostrando últimos {Math.min(displayLimit, totalOpenTickets)} de {totalOpenTickets} chamados
em aberto
</p>
) : null}
<ul className="space-y-2">
{displayedMachineTickets.map((ticket) => {
const priorityMeta = getTicketPriorityMeta(ticket.priority)
return (
<li key={ticket.id}>
<Link
href={`/tickets/${ticket.id}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white px-3 py-2 text-sm shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
</p>
</div>
<div className="flex items-center gap-2">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
{priorityMeta.label}
</Badge>
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
</div>
</Link>
</li>
)
})}
</ul>
</div>
)}
</div>
<div className="self-center justify-self-end">
<div className="flex h-12 min-w-[72px] items-center justify-center rounded-2xl border border-[color:var(--accent)] bg-white px-5 shadow-sm sm:min-w-[88px]">
<span className="text-2xl font-semibold leading-none text-accent-foreground tabular-nums sm:text-3xl">
{machineTickets.length}
{totalOpenTickets}
</span>
</div>
</div>

View file

@ -7,7 +7,19 @@ import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
export function MachineBreadcrumbs({ tenantId: _tenantId, machineId }: { tenantId: string; machineId: string }) {
type BreadcrumbSegment = {
label: string
href?: string | null
}
type MachineBreadcrumbsProps = {
tenantId: string
machineId: string
machineHref?: string | null
extra?: BreadcrumbSegment[]
}
export function MachineBreadcrumbs({ tenantId: _tenantId, machineId, machineHref, extra }: MachineBreadcrumbsProps) {
const { convexUserId } = useAuth()
const queryArgs = machineId && convexUserId
? ({ id: machineId as Id<"machines">, includeMetadata: false } as const)
@ -15,15 +27,36 @@ export function MachineBreadcrumbs({ tenantId: _tenantId, machineId }: { tenantI
const item = useQuery(api.machines.getById, queryArgs)
const hostname = useMemo(() => item?.hostname ?? "Detalhe", [item])
const segments = useMemo(() => {
const trail: BreadcrumbSegment[] = [
{ label: "Máquinas", href: "/admin/machines" },
{ label: hostname, href: machineHref ?? undefined },
]
if (Array.isArray(extra) && extra.length > 0) {
trail.push(...extra.filter((segment): segment is BreadcrumbSegment => Boolean(segment?.label)))
}
return trail
}, [hostname, machineHref, extra])
return (
<nav className="mb-4 text-sm text-neutral-600">
<ol className="flex items-center gap-2">
<li>
<Link href="/admin/machines" className="underline-offset-4 hover:underline">Máquinas</Link>
</li>
<li className="text-neutral-400">/</li>
<li className="text-neutral-800">{hostname}</li>
{segments.map((segment, index) => {
const isLast = index === segments.length - 1
const content = segment.href && !isLast ? (
<Link href={segment.href} className="underline-offset-4 hover:underline">
{segment.label}
</Link>
) : (
<span className={isLast ? "text-neutral-800" : "text-neutral-600"}>{segment.label}</span>
)
return (
<li key={`${segment.label}-${index}`} className="flex items-center gap-2">
{content}
{!isLast ? <span className="text-neutral-400">/</span> : null}
</li>
)
})}
</ol>
</nav>
)

View file

@ -0,0 +1,439 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { usePaginatedQuery, useQuery } from "convex/react"
import { format, formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import type { Id } from "@/convex/_generated/dataModel"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Spinner } from "@/components/ui/spinner"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
type MachineTicketHistoryItem = {
id: string
reference: number
subject: string
status: TicketStatus
priority: TicketPriority | string
updatedAt: number
createdAt: number
queue: string | null
requester: { name: string | null; email: string | null } | null
assignee: { name: string | null; email: string | null } | null
}
type MachineTicketsHistoryArgs = {
machineId: Id<"machines">
status?: "open" | "resolved"
priority?: string
search?: string
from?: number
to?: number
}
type MachineTicketsHistoryStats = {
total: number
openCount: number
resolvedCount: number
}
type PeriodPreset = "7d" | "30d" | "90d" | "year" | "all" | "custom"
function startOfDayMs(date: Date) {
const copy = new Date(date)
copy.setHours(0, 0, 0, 0)
return copy.getTime()
}
function endOfDayMs(date: Date) {
const copy = new Date(date)
copy.setHours(23, 59, 59, 999)
return copy.getTime()
}
function parseDateInput(value: string) {
if (!value) return null
const parsed = new Date(`${value}T00:00:00`)
if (Number.isNaN(parsed.getTime())) {
return null
}
return parsed
}
function computeRange(preset: PeriodPreset, customFrom: string, customTo: string) {
if (preset === "all") {
return { from: null, to: null }
}
if (preset === "custom") {
const fromDate = parseDateInput(customFrom)
const toDate = parseDateInput(customTo)
return {
from: fromDate ? startOfDayMs(fromDate) : null,
to: toDate ? endOfDayMs(toDate) : null,
}
}
const now = new Date()
const end = endOfDayMs(now)
const start = new Date(now)
switch (preset) {
case "7d":
start.setDate(start.getDate() - 6)
break
case "30d":
start.setDate(start.getDate() - 29)
break
case "90d":
start.setDate(start.getDate() - 89)
break
case "year":
start.setMonth(0, 1)
start.setHours(0, 0, 0, 0)
break
default:
break
}
return { from: startOfDayMs(start), to: end }
}
function formatRelativeTime(timestamp?: number | null) {
if (!timestamp || timestamp <= 0) return "—"
return formatDistanceToNowStrict(timestamp, { addSuffix: true, locale: ptBR })
}
function formatAbsoluteTime(timestamp?: number | null) {
if (!timestamp || timestamp <= 0) return "—"
return format(timestamp, "dd/MM/yyyy HH:mm", { locale: ptBR })
}
function getPriorityMeta(priority: TicketPriority | string | null | undefined) {
const normalized = (priority ?? "MEDIUM").toString().toUpperCase()
switch (normalized) {
case "LOW":
return { label: "Baixa", badgeClass: "bg-emerald-100 text-emerald-700 border border-emerald-200" }
case "MEDIUM":
return { label: "Média", badgeClass: "bg-sky-100 text-sky-700 border border-sky-200" }
case "HIGH":
return { label: "Alta", badgeClass: "bg-amber-100 text-amber-700 border border-amber-200" }
case "URGENT":
return { label: "Urgente", badgeClass: "bg-rose-100 text-rose-700 border border-rose-200" }
default:
return { label: normalized, badgeClass: "bg-slate-100 text-slate-700 border border-slate-200" }
}
}
export function MachineTicketsHistoryClient({ tenantId: _tenantId, machineId }: { tenantId: string; machineId: string }) {
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all")
const [priorityFilter, setPriorityFilter] = useState<string>("ALL")
const [periodPreset, setPeriodPreset] = useState<PeriodPreset>("90d")
const [customFrom, setCustomFrom] = useState<string>("")
const [customTo, setCustomTo] = useState<string>("")
const [searchValue, setSearchValue] = useState<string>("")
const [debouncedSearch, setDebouncedSearch] = useState<string>("")
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchValue.trim())
}, 300)
return () => clearTimeout(timer)
}, [searchValue])
useEffect(() => {
if (periodPreset !== "custom") {
setCustomFrom("")
setCustomTo("")
}
}, [periodPreset])
const range = useMemo(() => computeRange(periodPreset, customFrom, customTo), [periodPreset, customFrom, customTo])
const queryArgs = useMemo(() => {
const args: MachineTicketsHistoryArgs = {
machineId: machineId as Id<"machines">,
}
if (statusFilter !== "all") {
args.status = statusFilter
}
if (priorityFilter !== "ALL") {
args.priority = priorityFilter
}
if (debouncedSearch) {
args.search = debouncedSearch
}
if (range.from !== null) {
args.from = range.from
}
if (range.to !== null) {
args.to = range.to
}
return args
}, [debouncedSearch, machineId, priorityFilter, range.from, range.to, statusFilter])
const { results: tickets, status: paginationStatus, loadMore } = usePaginatedQuery(
api.machines.listTicketsHistory,
queryArgs,
{ initialNumItems: 25 }
)
const stats = useQuery(api.machines.getTicketsHistoryStats, queryArgs) as MachineTicketsHistoryStats | undefined
const totalTickets = stats?.total ?? 0
const openTickets = stats?.openCount ?? 0
const resolvedTickets = stats?.resolvedCount ?? 0
const isLoadingFirstPage = paginationStatus === "LoadingFirstPage"
const isLoadingMore = paginationStatus === "LoadingMore"
const canLoadMore = paginationStatus === "CanLoadMore"
const resetFilters = () => {
setStatusFilter("all")
setPriorityFilter("ALL")
setPeriodPreset("90d")
setCustomFrom("")
setCustomTo("")
setSearchValue("")
setDebouncedSearch("")
}
return (
<div className="space-y-6">
<section className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-4 shadow-sm">
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500">Chamados no período</p>
<p className="pt-1 text-2xl font-semibold text-neutral-900">
{stats ? (
totalTickets
) : (
<span className="inline-flex items-center gap-2 text-sm text-neutral-500">
<Spinner className="size-4 text-neutral-400" /> Atualizando...
</span>
)}
</p>
</div>
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4 shadow-sm">
<p className="text-xs font-medium uppercase tracking-wide text-emerald-700">Em aberto</p>
<p className="pt-1 text-2xl font-semibold text-emerald-900">{stats ? openTickets : "—"}</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 shadow-sm">
<p className="text-xs font-medium uppercase tracking-wide text-neutral-600">Resolvidos</p>
<p className="pt-1 text-2xl font-semibold text-neutral-900">{stats ? resolvedTickets : "—"}</p>
</div>
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm lg:p-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex w-full flex-col gap-3 sm:flex-row">
<Input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Buscar por assunto, #ID, solicitante ou responsável"
className="sm:max-w-sm"
/>
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as typeof statusFilter)}>
<SelectTrigger className="sm:w-[160px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os status</SelectItem>
<SelectItem value="open">Em aberto</SelectItem>
<SelectItem value="resolved">Resolvidos</SelectItem>
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={(value) => setPriorityFilter(value)}>
<SelectTrigger className="sm:w-[160px]">
<SelectValue placeholder="Prioridade" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">Todas as prioridades</SelectItem>
<SelectItem value="URGENT">Urgente</SelectItem>
<SelectItem value="HIGH">Alta</SelectItem>
<SelectItem value="MEDIUM">Média</SelectItem>
<SelectItem value="LOW">Baixa</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select value={periodPreset} onValueChange={(value) => setPeriodPreset(value as PeriodPreset)}>
<SelectTrigger className="sm:w-[200px]">
<SelectValue placeholder="Período" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">Últimos 7 dias</SelectItem>
<SelectItem value="30d">Últimos 30 dias</SelectItem>
<SelectItem value="90d">Últimos 90 dias</SelectItem>
<SelectItem value="year">Este ano</SelectItem>
<SelectItem value="all">Desde sempre</SelectItem>
<SelectItem value="custom">Personalizado</SelectItem>
</SelectContent>
</Select>
{periodPreset === "custom" ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
type="date"
value={customFrom}
onChange={(event) => setCustomFrom(event.target.value)}
className="sm:w-[160px]"
placeholder="Início"
/>
<Input
type="date"
value={customTo}
onChange={(event) => setCustomTo(event.target.value)}
className="sm:w-[160px]"
placeholder="Fim"
/>
</div>
) : null}
<Button variant="outline" size="sm" onClick={resetFilters}>
Limpar filtros
</Button>
</div>
</div>
</section>
<section className="rounded-2xl border border-slate-200 bg-white p-0 shadow-sm">
{isLoadingFirstPage ? (
<div className="flex items-center justify-center gap-2 py-12">
<Spinner className="size-5 text-neutral-500" />
<span className="text-sm text-neutral-600">Carregando chamados...</span>
</div>
) : tickets.length === 0 ? (
<Empty className="m-4 border-dashed">
<EmptyHeader>
<EmptyTitle>Nenhum chamado encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou expanda o período para visualizar o histórico de chamados desta máquina.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" size="sm" onClick={resetFilters}>
Limpar filtros
</Button>
</EmptyContent>
</Empty>
) : (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-b border-slate-200 bg-slate-50/60">
<TableHead className="min-w-[260px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
Ticket
</TableHead>
<TableHead className="w-[140px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
Status
</TableHead>
<TableHead className="w-[140px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
Prioridade
</TableHead>
<TableHead className="w-[160px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
Última atualização
</TableHead>
<TableHead className="w-[200px] text-xs font-semibold uppercase tracking-wide text-neutral-500">
Responsável
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tickets.map((ticket) => {
const priorityMeta = getPriorityMeta(ticket.priority)
const requesterLabel = ticket.requester?.name ?? ticket.requester?.email ?? "Solicitante não informado"
const assigneeLabel = ticket.assignee?.name ?? ticket.assignee?.email ?? "Sem responsável"
const updatedLabel = formatRelativeTime(ticket.updatedAt)
const updatedAbsolute = formatAbsoluteTime(ticket.updatedAt)
return (
<TableRow key={ticket.id} className="border-b border-slate-100 hover:bg-slate-50/70">
<TableCell className="align-top">
<div className="flex flex-col gap-1">
<Link
href={`/tickets/${ticket.id}`}
className="text-sm font-semibold text-neutral-900 underline-offset-4 hover:underline"
>
#{ticket.reference} · {ticket.subject}
</Link>
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
<span>{requesterLabel}</span>
{ticket.queue ? (
<span className="inline-flex items-center rounded-full bg-slate-200/70 px-2 py-0.5 text-[11px] font-medium text-neutral-600">
{ticket.queue}
</span>
) : null}
<span className="text-neutral-400">Aberto em {formatAbsoluteTime(ticket.createdAt)}</span>
</div>
</div>
</TableCell>
<TableCell className="align-top">
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
</TableCell>
<TableCell className="align-top">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
{priorityMeta.label}
</Badge>
</TableCell>
<TableCell className="align-top">
<div className="flex flex-col text-xs text-neutral-600">
<span className="font-medium text-neutral-800">{updatedLabel}</span>
<span className="text-[11px] text-neutral-400">{updatedAbsolute}</span>
</div>
</TableCell>
<TableCell className="align-top">
<div className="flex flex-col text-sm text-neutral-700">
<span>{assigneeLabel}</span>
{ticket.assignee?.email ? (
<span className="text-xs text-neutral-400">{ticket.assignee.email}</span>
) : null}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
<div className="border-t border-slate-200 px-4 py-4 text-center">
{canLoadMore || isLoadingMore ? (
<Button
size="sm"
variant="outline"
onClick={() => loadMore(25)}
disabled={isLoadingMore}
className="inline-flex items-center gap-2"
>
{isLoadingMore ? (
<>
<Spinner className="size-4 text-neutral-500" />
Carregando...
</>
) : (
"Carregar mais"
)}
</Button>
) : (
<span className="text-xs text-neutral-500">
Todos os chamados filtrados foram exibidos.
</span>
)}
</div>
</>
)}
</section>
</div>
)
}

View file

@ -303,7 +303,7 @@ export function CloseTicketDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Encerrar ticket</DialogTitle>
<DialogDescription>

View file

@ -93,7 +93,7 @@ export function SearchableCombobox({
aria-expanded={open}
disabled={disabled}
className={cn(
"flex h-9 w-full items-center justify-between rounded-full border border-input bg-background px-3 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
"flex min-h-[42px] w-full items-center justify-between rounded-full border border-input bg-background px-3 py-2 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
@ -171,4 +171,3 @@ export function SearchableCombobox({
</Popover>
)
}

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest"
import type { Doc, Id } from "../convex/_generated/dataModel"
import { getById } from "../convex/machines"
import { getByIdHandler } from "../convex/machines"
const FIXED_NOW = 1_706_071_200_000
@ -65,8 +65,8 @@ describe("convex.machines.getById", () => {
})),
}
const ctx = { db } as unknown as Parameters<typeof getById>[0]
const result = await getById(ctx, { id: machine._id, includeMetadata: true })
const ctx = { db } as unknown as Parameters<typeof getByIdHandler>[0]
const result = await getByIdHandler(ctx, { id: machine._id, includeMetadata: true })
expect(result).toBeTruthy()
expect(result?.metrics).toBeTruthy()
@ -75,4 +75,3 @@ describe("convex.machines.getById", () => {
expect(result?.token).toBeTruthy()
})
})

View file

@ -0,0 +1,255 @@
import { describe, expect, it, vi } from "vitest"
import type { Doc, Id } from "../convex/_generated/dataModel"
import { getTicketsHistoryStatsHandler, listTicketsHistoryHandler } from "../convex/machines"
const MACHINE_ID = "machine_1" as Id<"machines">
const TENANT_ID = "tenant-1"
function buildMachine(overrides: Partial<Doc<"machines">> = {}): Doc<"machines"> {
const machine: Record<string, unknown> = {
_id: MACHINE_ID,
tenantId: TENANT_ID,
hostname: "desktop-01",
macAddresses: [],
serialNumbers: [],
fingerprint: "fp",
isActive: true,
lastHeartbeatAt: Date.now(),
createdAt: Date.now() - 10_000,
updatedAt: Date.now() - 5_000,
linkedUserIds: [],
remoteAccess: null,
}
return { ...(machine as Doc<"machines">), ...overrides }
}
function buildTicket(overrides: Partial<Doc<"tickets">> = {}): Doc<"tickets"> {
const base: Record<string, unknown> = {
_id: "ticket_base" as Id<"tickets">,
tenantId: TENANT_ID,
reference: 42600,
subject: "Generic ticket",
summary: "",
status: "PENDING",
priority: "MEDIUM",
channel: "EMAIL",
queueId: undefined,
requesterId: "user_req" as Id<"users">,
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
assigneeId: "user_assignee" as Id<"users">,
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
companyId: undefined,
companySnapshot: undefined,
machineId: MACHINE_ID,
machineSnapshot: undefined,
working: false,
dueAt: undefined,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
updatedAt: Date.now(),
createdAt: Date.now() - 2000,
tags: [],
customFields: [],
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
activeSessionId: undefined,
}
return { ...(base as Doc<"tickets">), ...overrides }
}
type PaginateHandler = (options: { cursor: string | null; numItems: number }) => Promise<{
page: Doc<"tickets">[]
isDone: boolean
continueCursor: string
}>
function createCtx({
machine = buildMachine(),
queues = new Map<string, Doc<"queues">>(),
paginate,
}: {
machine?: Doc<"machines">
queues?: Map<string, Doc<"queues">>
paginate: PaginateHandler
}) {
const createFilterBuilder = () => {
const builder: Record<string, (..._args: unknown[]) => typeof builder> = {}
builder.eq = () => builder
builder.gte = () => builder
builder.lte = () => builder
builder.or = () => builder
return builder
}
return {
db: {
get: vi.fn(async (id: Id<"machines"> | Id<"queues">) => {
if (machine && id === machine._id) return machine
const queue = queues.get(String(id))
if (queue) return queue
return null
}),
query: vi.fn(() => {
const chain = {
filter: vi.fn((cb?: (builder: ReturnType<typeof createFilterBuilder>) => unknown) => {
cb?.(createFilterBuilder())
return chain
}),
paginate: vi.fn((options: { cursor: string | null; numItems: number }) => paginate(options)),
}
return {
withIndex: vi.fn((_indexName: string, cb?: (builder: ReturnType<typeof createFilterBuilder>) => unknown) => {
cb?.(createFilterBuilder())
return {
order: vi.fn(() => chain),
}
}),
}
}),
},
} as unknown as Parameters<typeof listTicketsHistoryHandler>[0]
}
describe("convex.machines.listTicketsHistory", () => {
it("maps tickets metadata and resolves queue names", async () => {
const machine = buildMachine()
const ticket = buildTicket({
_id: "ticket_1" as Id<"tickets">,
subject: "Printer offline",
priority: "HIGH",
status: "PENDING",
queueId: "queue_1" as Id<"queues">,
updatedAt: 170000,
createdAt: 160000,
})
const paginate = vi.fn(async () => ({
page: [ticket],
isDone: false,
continueCursor: "cursor-next",
}))
const queues = new Map<string, Doc<"queues">>([
["queue_1", { _id: "queue_1" as Id<"queues">, name: "Atendimento", tenantId: TENANT_ID } as Doc<"queues">],
])
const ctx = createCtx({ machine, queues, paginate })
const result = await listTicketsHistoryHandler(ctx, {
machineId: machine._id,
paginationOpts: { numItems: 25, cursor: null },
})
expect(paginate).toHaveBeenCalledWith({ numItems: 25, cursor: null })
expect(result.page).toHaveLength(1)
expect(result.page[0]).toMatchObject({
id: "ticket_1",
subject: "Printer offline",
priority: "HIGH",
status: "PENDING",
queue: "Atendimento",
})
expect(result.continueCursor).toBe("cursor-next")
})
it("applies search filtering over paginated results", async () => {
const machine = buildMachine()
const ticketMatches = buildTicket({
_id: "ticket_match" as Id<"tickets">,
reference: 44321,
subject: "Notebook com tela quebrada",
requesterSnapshot: { name: "Carla", email: "carla@example.com", avatarUrl: undefined, teams: [] },
})
const ticketIgnored = buildTicket({
_id: "ticket_other" as Id<"tickets">,
subject: "Troca de teclado",
requesterSnapshot: { name: "Roberto", email: "roberto@example.com", avatarUrl: undefined, teams: [] },
})
const paginate = vi.fn(async () => ({
page: [ticketMatches, ticketIgnored],
isDone: true,
continueCursor: "",
}))
const ctx = createCtx({ machine, paginate })
const result = await listTicketsHistoryHandler(ctx, {
machineId: machine._id,
search: "notebook",
paginationOpts: { numItems: 50, cursor: null },
})
expect(result.page).toHaveLength(1)
expect(result.page[0].id).toBe("ticket_match")
expect(result.isDone).toBe(true)
})
})
describe("convex.machines.getTicketsHistoryStats", () => {
it("aggregates totals across multiple pages respecting open status", async () => {
const machine = buildMachine()
const firstPageTicket = buildTicket({
_id: "ticket_open" as Id<"tickets">,
status: "AWAITING_ATTENDANCE",
})
const secondPageTicket = buildTicket({
_id: "ticket_resolved" as Id<"tickets">,
status: "RESOLVED",
})
const paginate = vi.fn(async ({ cursor }: { cursor: string | null }) => {
if (!cursor) {
return { page: [firstPageTicket], isDone: false, continueCursor: "cursor-1" }
}
return { page: [secondPageTicket], isDone: true, continueCursor: "" }
})
const ctx = createCtx({ machine, paginate })
const stats = await getTicketsHistoryStatsHandler(
ctx as unknown as Parameters<typeof getTicketsHistoryStatsHandler>[0],
{
machineId: machine._id,
}
)
expect(stats).toEqual({ total: 2, openCount: 1, resolvedCount: 1 })
expect(paginate).toHaveBeenCalledTimes(2)
})
it("filters results by search term when aggregating", async () => {
const machine = buildMachine()
const matchingTicket = buildTicket({
_id: "ticket_search" as Id<"tickets">,
subject: "Notebook com lentidão",
})
const nonMatchingTicket = buildTicket({
_id: "ticket_ignored" as Id<"tickets">,
subject: "Impressora parada",
})
const paginate = vi.fn(async ({ cursor }: { cursor: string | null }) => {
if (!cursor) {
return { page: [matchingTicket, nonMatchingTicket], isDone: true, continueCursor: "" }
}
return { page: [], isDone: true, continueCursor: "" }
})
const ctx = createCtx({ machine, paginate })
const stats = await getTicketsHistoryStatsHandler(
ctx as unknown as Parameters<typeof getTicketsHistoryStatsHandler>[0],
{
machineId: machine._id,
search: "notebook",
}
)
expect(stats).toEqual({ total: 1, openCount: 1, resolvedCount: 0 })
})
})

View file

@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
import { updatePersona } from "../convex/machines"
import { updatePersonaHandler } from "../convex/machines"
import type { Doc, Id } from "../convex/_generated/dataModel"
const FIXED_NOW = 1_706_071_200_000
@ -63,9 +63,9 @@ describe("convex.machines.updatePersona", () => {
return null
})
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersona>[0]
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersonaHandler>[0]
const result = await updatePersona(ctx, { machineId: machine._id, persona: "" })
const result = await updatePersonaHandler(ctx, { machineId: machine._id, persona: "" })
expect(result).toEqual({ ok: true, persona: null })
expect(patch).toHaveBeenCalledTimes(1)
@ -92,10 +92,10 @@ describe("convex.machines.updatePersona", () => {
return null
})
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersona>[0]
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersonaHandler>[0]
await expect(
updatePersona(ctx, {
updatePersonaHandler(ctx, {
machineId: machine._id,
persona: "collaborator",
assignedUserId: missingUserId,

View file

@ -0,0 +1,267 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
vi.mock("../convex/rbac", () => ({
requireStaff: vi.fn(),
}))
import type { Doc, Id } from "../convex/_generated/dataModel"
import {
agentProductivityHandler,
dashboardOverviewHandler,
hoursByClientInternalHandler,
} from "../convex/reports"
import { requireStaff } from "../convex/rbac"
import { createReportsCtx } from "./utils/report-test-helpers"
const TENANT_ID = "tenant-1"
const VIEWER_ID = "user-agent" as Id<"users">
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
const base: Record<string, unknown> = {
_id: "ticket_base" as Id<"tickets">,
tenantId: TENANT_ID,
reference: 50000,
subject: "Chamado",
summary: null,
status: "PENDING",
priority: "MEDIUM",
channel: "EMAIL",
queueId: undefined,
requesterId: "user_req" as Id<"users">,
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
assigneeId: "user_assignee" as Id<"users">,
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
companyId: undefined,
companySnapshot: undefined,
machineId: undefined,
machineSnapshot: undefined,
working: false,
dueAt: undefined,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
updatedAt: Date.now(),
createdAt: Date.now(),
tags: [],
customFields: [],
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
activeSessionId: undefined,
}
return { ...(base as Doc<"tickets">), ...overrides }
}
function buildCompany(overrides: Partial<Doc<"companies">>): Doc<"companies"> {
const base: Record<string, unknown> = {
_id: "company_base" as Id<"companies">,
tenantId: TENANT_ID,
name: "Empresa",
slug: "empresa",
createdAt: Date.now(),
updatedAt: Date.now(),
isAvulso: false,
contractedHoursPerMonth: 40,
}
return { ...(base as Doc<"companies">), ...overrides }
}
describe("convex.reports.agentProductivity", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 6, 10, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("aggregates per-agent metrics including work sessions", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const agentA = { _id: "agent_a" as Id<"users">, name: "Ana", email: "ana@example.com" } as Doc<"users">
const agentB = { _id: "agent_b" as Id<"users">, name: "Bruno", email: "bruno@example.com" } as Doc<"users">
const tickets = [
buildTicket({
_id: "ticket_open" as Id<"tickets">,
assigneeId: agentA._id,
createdAt: Date.UTC(2024, 6, 9, 9, 0, 0),
status: "PENDING",
firstResponseAt: Date.UTC(2024, 6, 9, 9, 30, 0),
internalWorkedMs: 45 * 60 * 1000,
}),
buildTicket({
_id: "ticket_resolved" as Id<"tickets">,
assigneeId: agentA._id,
createdAt: Date.UTC(2024, 6, 8, 10, 0, 0),
firstResponseAt: Date.UTC(2024, 6, 8, 10, 20, 0),
resolvedAt: Date.UTC(2024, 6, 8, 12, 0, 0),
status: "RESOLVED",
}),
buildTicket({
_id: "ticket_old" as Id<"tickets">,
assigneeId: agentB._id,
createdAt: Date.UTC(2024, 5, 20, 10, 0, 0),
status: "RESOLVED",
}),
]
const sessionsMap = new Map<string, Array<{ agentId: Id<"users">; startedAt: number; stoppedAt?: number; durationMs?: number }>>([
[
String(agentA._id),
[
{ agentId: agentA._id, startedAt: Date.UTC(2024, 6, 9, 9, 0, 0), stoppedAt: Date.UTC(2024, 6, 9, 10, 0, 0) },
{ agentId: agentA._id, startedAt: Date.UTC(2024, 6, 8, 11, 0, 0), durationMs: 30 * 60 * 1000 },
],
],
])
const ctx = createReportsCtx({
tickets,
users: new Map<string, Doc<"users">>([
[String(agentA._id), agentA],
[String(agentB._id), agentB],
]),
ticketWorkSessionsByAgent: sessionsMap,
}) as Parameters<typeof agentProductivityHandler>[0]
const result = await agentProductivityHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.items).toHaveLength(1)
expect(result.items[0]).toMatchObject({
agentId: agentA._id,
open: 1,
resolved: 1,
avgFirstResponseMinutes: 25,
})
expect(result.items[0]?.workedHours).toBeCloseTo(1.5, 1)
})
})
describe("convex.reports.dashboardOverview", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 6, 15, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("returns trend metrics for new, in-progress and resolution data", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const tickets = [
buildTicket({
_id: "ticket_new" as Id<"tickets">,
createdAt: Date.UTC(2024, 6, 15, 8, 0, 0),
firstResponseAt: Date.UTC(2024, 6, 15, 8, 30, 0),
status: "PENDING",
}),
buildTicket({
_id: "ticket_resolved_recent" as Id<"tickets">,
createdAt: Date.UTC(2024, 6, 8, 9, 0, 0),
firstResponseAt: Date.UTC(2024, 6, 8, 9, 15, 0),
resolvedAt: Date.UTC(2024, 6, 13, 12, 0, 0),
status: "RESOLVED",
}),
buildTicket({
_id: "ticket_prev" as Id<"tickets">,
createdAt: Date.UTC(2024, 6, 13, 9, 0, 0),
firstResponseAt: Date.UTC(2024, 6, 13, 9, 45, 0),
status: "PAUSED",
dueAt: Date.UTC(2024, 6, 14, 9, 0, 0),
}),
buildTicket({
_id: "ticket_prev_resolved" as Id<"tickets">,
createdAt: Date.UTC(2024, 6, 5, 9, 0, 0),
firstResponseAt: Date.UTC(2024, 6, 5, 9, 10, 0),
resolvedAt: Date.UTC(2024, 6, 7, 10, 0, 0),
status: "RESOLVED",
}),
]
const ctx = createReportsCtx({ tickets }) as Parameters<typeof dashboardOverviewHandler>[0]
const result = await dashboardOverviewHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
})
expect(result.newTickets.last24h).toBe(1)
expect(result.inProgress.current).toBe(2)
expect(result.awaitingAction.total).toBe(2)
expect(result.resolution.resolvedLast7d).toBe(1)
})
})
describe("convex.reports.hoursByClientInternal", () => {
const FIXED_NOW = Date.UTC(2024, 7, 1, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("sums internal and external hours per company", async () => {
const companyA = buildCompany({ _id: "company_a" as Id<"companies">, name: "Empresa A" })
const companyB = buildCompany({ _id: "company_b" as Id<"companies">, name: "Empresa B", isAvulso: true })
const tickets = [
buildTicket({
_id: "ticket_a" as Id<"tickets">,
companyId: companyA._id,
updatedAt: Date.UTC(2024, 6, 30, 10, 0, 0),
internalWorkedMs: 2 * 3600000,
externalWorkedMs: 3600000,
}),
buildTicket({
_id: "ticket_b" as Id<"tickets">,
companyId: companyB._id,
updatedAt: Date.UTC(2024, 6, 29, 14, 0, 0),
internalWorkedMs: 0,
externalWorkedMs: 2 * 3600000,
}),
]
const ctx = createReportsCtx({
tickets,
companies: new Map([
[String(companyA._id), companyA],
[String(companyB._id), companyB],
]),
}) as Parameters<typeof hoursByClientInternalHandler>[0]
const result = await hoursByClientInternalHandler(ctx, { tenantId: TENANT_ID, range: "7d" })
expect(result.rangeDays).toBe(7)
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ companyId: companyA._id, internalMs: 2 * 3600000, externalMs: 3600000 }),
expect.objectContaining({ companyId: companyB._id, internalMs: 0, externalMs: 2 * 3600000 }),
])
)
})
})

View file

@ -0,0 +1,199 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
vi.mock("../convex/rbac", () => ({
requireStaff: vi.fn(),
}))
import type { Doc, Id } from "../convex/_generated/dataModel"
import { backlogOverviewHandler, slaOverviewHandler } from "../convex/reports"
import { requireStaff } from "../convex/rbac"
import { createReportsCtx } from "./utils/report-test-helpers"
const TENANT_ID = "tenant-1"
const VIEWER_ID = "user-staff" as Id<"users">
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
const base: Record<string, unknown> = {
_id: "ticket_base" as Id<"tickets">,
tenantId: TENANT_ID,
reference: 50000,
subject: "Chamado",
summary: null,
status: "PENDING",
priority: "MEDIUM",
channel: "EMAIL",
queueId: undefined,
requesterId: "user_req" as Id<"users">,
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
assigneeId: "user_agent" as Id<"users">,
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
companyId: undefined,
companySnapshot: undefined,
machineId: undefined,
machineSnapshot: undefined,
working: false,
dueAt: undefined,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
updatedAt: Date.now(),
createdAt: Date.now(),
tags: [],
customFields: [],
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
activeSessionId: undefined,
}
return { ...(base as Doc<"tickets">), ...overrides }
}
function buildQueue(overrides: Partial<Doc<"queues">>): Doc<"queues"> {
const base: Record<string, unknown> = {
_id: "queue_base" as Id<"queues">,
tenantId: TENANT_ID,
name: "Suporte",
slug: "suporte",
createdAt: Date.now(),
updatedAt: Date.now(),
}
return { ...(base as Doc<"queues">), ...overrides }
}
describe("convex.reports.slaOverview", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 4, 8, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("summarizes totals, averages and queue breakdown", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const queue = buildQueue({
_id: "queue_1" as Id<"queues">,
name: "Suporte Nível 1",
})
const tickets = [
buildTicket({
_id: "ticket_open" as Id<"tickets">,
status: "PENDING",
queueId: queue._id,
createdAt: Date.UTC(2024, 4, 7, 9, 0, 0),
dueAt: Date.UTC(2024, 4, 7, 11, 0, 0),
}),
buildTicket({
_id: "ticket_resolved" as Id<"tickets">,
status: "RESOLVED",
queueId: queue._id,
createdAt: Date.UTC(2024, 4, 6, 8, 0, 0),
firstResponseAt: Date.UTC(2024, 4, 6, 8, 30, 0),
resolvedAt: Date.UTC(2024, 4, 6, 10, 0, 0),
}),
]
const ctx = createReportsCtx({ tickets, queues: [queue] }) as Parameters<typeof slaOverviewHandler>[0]
const result = await slaOverviewHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.totals).toEqual({ total: 2, open: 1, resolved: 1, overdue: 1 })
expect(result.response).toEqual({ averageFirstResponseMinutes: 30, responsesRegistered: 1 })
expect(result.resolution).toEqual({ averageResolutionMinutes: 120, resolvedCount: 1 })
expect(result.queueBreakdown).toEqual([{ id: queue._id, name: queue.name, open: 1 }])
})
})
describe("convex.reports.backlogOverview", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 6, 1, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("aggregates status, priority and queue counts for open tickets", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const queueA = buildQueue({ _id: "queue_a" as Id<"queues">, name: "Atendimento" })
const queueB = buildQueue({ _id: "queue_b" as Id<"queues">, name: "Infraestrutura" })
const tickets = [
buildTicket({
_id: "ticket_pending" as Id<"tickets">,
status: "PENDING",
priority: "HIGH",
queueId: queueA._id,
createdAt: Date.UTC(2024, 5, 28, 10, 0, 0),
}),
buildTicket({
_id: "ticket_paused" as Id<"tickets">,
status: "PAUSED",
priority: "MEDIUM",
queueId: queueB._id,
createdAt: Date.UTC(2024, 5, 29, 15, 0, 0),
}),
buildTicket({
_id: "ticket_resolved" as Id<"tickets">,
status: "RESOLVED",
priority: "URGENT",
queueId: undefined,
createdAt: Date.UTC(2024, 5, 27, 9, 0, 0),
}),
]
const ctx = createReportsCtx({
tickets,
createdRangeTickets: tickets,
queues: [queueA, queueB],
}) as Parameters<typeof backlogOverviewHandler>[0]
const result = await backlogOverviewHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.statusCounts).toEqual({
PENDING: 1,
PAUSED: 1,
RESOLVED: 1,
})
expect(result.priorityCounts).toEqual({
HIGH: 1,
MEDIUM: 1,
URGENT: 1,
})
expect(result.totalOpen).toBe(2)
expect(result.queueCounts).toHaveLength(2)
expect(result.queueCounts).toEqual(
expect.arrayContaining([
{ id: "queue_a", name: queueA.name, total: 1 },
{ id: "queue_b", name: queueB.name, total: 1 },
])
)
})
})

View file

@ -0,0 +1,111 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
vi.mock("../convex/rbac", () => ({
requireStaff: vi.fn(),
}))
import type { Doc, Id } from "../convex/_generated/dataModel"
import { ticketsByChannelHandler } from "../convex/reports"
import { requireStaff } from "../convex/rbac"
import { createReportsCtx } from "./utils/report-test-helpers"
const TENANT_ID = "tenant-1"
const VIEWER_ID = "user-viewer" as Id<"users">
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
const base: Record<string, unknown> = {
_id: "ticket_base" as Id<"tickets">,
tenantId: TENANT_ID,
reference: 50000,
subject: "Chamado",
summary: null,
status: "PENDING",
priority: "MEDIUM",
channel: "EMAIL",
queueId: undefined,
requesterId: "user_req" as Id<"users">,
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
assigneeId: "user_assignee" as Id<"users">,
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
companyId: undefined,
companySnapshot: undefined,
machineId: undefined,
machineSnapshot: undefined,
working: false,
dueAt: undefined,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
updatedAt: Date.now(),
createdAt: Date.now(),
tags: [],
customFields: [],
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
activeSessionId: undefined,
}
return { ...(base as Doc<"tickets">), ...overrides }
}
describe("convex.reports.ticketsByChannel", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 4, 8, 12, 0, 0) // 8 May 2024 12:00 UTC
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("builds timeline grouped by channel within the requested range", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const tickets = [
buildTicket({
_id: "ticket_email" as Id<"tickets">,
channel: "EMAIL",
createdAt: Date.UTC(2024, 4, 7, 10, 0, 0),
}),
buildTicket({
_id: "ticket_chat" as Id<"tickets">,
channel: "CHAT",
createdAt: Date.UTC(2024, 4, 7, 12, 0, 0),
}),
buildTicket({
_id: "ticket_other" as Id<"tickets">,
channel: undefined,
createdAt: Date.UTC(2024, 4, 6, 9, 0, 0),
}),
buildTicket({
_id: "ticket_outside" as Id<"tickets">,
createdAt: Date.UTC(2024, 3, 25, 12, 0, 0), // outside 7-day window
}),
]
const ctx = createReportsCtx({ tickets }) as Parameters<typeof ticketsByChannelHandler>[0]
const result = await ticketsByChannelHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.channels).toEqual(["CHAT", "EMAIL", "OUTRO"])
const may06 = result.points.find((point) => point.date === "2024-05-06")
const may07 = result.points.find((point) => point.date === "2024-05-07")
expect(may06?.values).toEqual({ CHAT: 0, EMAIL: 0, OUTRO: 1 })
expect(may07?.values).toEqual({ CHAT: 1, EMAIL: 1, OUTRO: 0 })
expect(may06).toBeTruthy()
expect(may07).toBeTruthy()
})
})

View file

@ -0,0 +1,260 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
vi.mock("../convex/rbac", () => ({
requireStaff: vi.fn(),
}))
import type { Doc, Id } from "../convex/_generated/dataModel"
import {
csatOverviewHandler,
hoursByClientHandler,
openedResolvedByDayHandler,
} from "../convex/reports"
import { requireStaff } from "../convex/rbac"
import { createReportsCtx } from "./utils/report-test-helpers"
const TENANT_ID = "tenant-1"
const VIEWER_ID = "user-reports" as Id<"users">
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
const base: Record<string, unknown> = {
_id: "ticket_base" as Id<"tickets">,
tenantId: TENANT_ID,
reference: 50000,
subject: "Chamado",
summary: null,
status: "PENDING",
priority: "MEDIUM",
channel: "EMAIL",
queueId: undefined,
requesterId: "user_req" as Id<"users">,
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
assigneeId: "user_assignee" as Id<"users">,
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
companyId: undefined,
companySnapshot: undefined,
machineId: undefined,
machineSnapshot: undefined,
working: false,
dueAt: undefined,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
updatedAt: Date.now(),
createdAt: Date.now(),
tags: [],
customFields: [],
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
activeSessionId: undefined,
}
return { ...(base as Doc<"tickets">), ...overrides }
}
function buildCompany(overrides: Partial<Doc<"companies">>): Doc<"companies"> {
const base: Record<string, unknown> = {
_id: "company_base" as Id<"companies">,
tenantId: TENANT_ID,
name: "Empresa",
slug: "empresa",
createdAt: Date.now(),
updatedAt: Date.now(),
isAvulso: false,
contractedHoursPerMonth: 40,
}
return { ...(base as Doc<"companies">), ...overrides }
}
describe("convex.reports.openedResolvedByDay", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 4, 15, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("counts opened and resolved tickets per day within the range", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const tickets = [
buildTicket({
_id: "ticket_open" as Id<"tickets">,
createdAt: Date.UTC(2024, 4, 13, 9, 0, 0),
status: "PENDING",
}),
buildTicket({
_id: "ticket_resolved" as Id<"tickets">,
createdAt: Date.UTC(2024, 4, 12, 10, 0, 0),
resolvedAt: Date.UTC(2024, 4, 14, 8, 0, 0),
status: "RESOLVED",
}),
buildTicket({
_id: "ticket_old" as Id<"tickets">,
createdAt: Date.UTC(2024, 3, 30, 10, 0, 0),
status: "PENDING",
}),
]
const ctx = createReportsCtx({ tickets }) as Parameters<typeof openedResolvedByDayHandler>[0]
const result = await openedResolvedByDayHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
const opened13 = result.series.find((point) => point.date === "2024-05-13")
const resolved14 = result.series.find((point) => point.date === "2024-05-14")
expect(result.rangeDays).toBe(7)
expect(opened13).toMatchObject({ opened: 1, resolved: 0 })
expect(resolved14).toMatchObject({ opened: 0, resolved: 1 })
})
})
describe("convex.reports.csatOverview", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 4, 20, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("summarizes survey averages and distribution", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const ticketA = buildTicket({
_id: "ticket_a" as Id<"tickets">,
createdAt: Date.UTC(2024, 4, 18, 9, 0, 0),
})
const ticketB = buildTicket({
_id: "ticket_b" as Id<"tickets">,
createdAt: Date.UTC(2024, 4, 17, 15, 0, 0),
})
const eventsByTicket = new Map<string, Array<{ type: string; payload?: unknown; createdAt: number }>>([
["ticket_a", [{ type: "CSAT_RECEIVED", payload: { score: 5 }, createdAt: Date.UTC(2024, 4, 19, 8, 0, 0) }]],
[
"ticket_b",
[
{ type: "CSAT_RECEIVED", payload: { score: 3 }, createdAt: Date.UTC(2024, 4, 18, 14, 0, 0) },
{ type: "COMMENT", payload: {}, createdAt: Date.UTC(2024, 4, 18, 15, 0, 0) },
],
],
])
const ctx = createReportsCtx({ tickets: [ticketA, ticketB], ticketEventsByTicket: eventsByTicket }) as Parameters<typeof csatOverviewHandler>[0]
const result = await csatOverviewHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.totalSurveys).toBe(2)
expect(result.averageScore).toBe(4)
expect(result.distribution.find((entry) => entry.score === 5)?.total).toBe(1)
expect(result.distribution.find((entry) => entry.score === 3)?.total).toBe(1)
expect(result.recent[0]?.ticketId).toBe("ticket_a")
})
})
describe("convex.reports.hoursByClient", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 5, 5, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("aggregates worked hours by company", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const companyA = buildCompany({ _id: "company_a" as Id<"companies">, name: "Empresa A", contractedHoursPerMonth: 60 })
const companyB = buildCompany({ _id: "company_b" as Id<"companies">, name: "Empresa B", isAvulso: true })
const tickets = [
buildTicket({
_id: "ticket_a" as Id<"tickets">,
companyId: companyA._id,
updatedAt: Date.UTC(2024, 5, 4, 10, 0, 0),
internalWorkedMs: 3 * 3600000,
externalWorkedMs: 1 * 3600000,
}),
buildTicket({
_id: "ticket_b" as Id<"tickets">,
companyId: companyB._id,
updatedAt: Date.UTC(2024, 5, 3, 15, 0, 0),
internalWorkedMs: 3600000,
externalWorkedMs: 2 * 3600000,
}),
buildTicket({
_id: "ticket_old" as Id<"tickets">,
companyId: companyA._id,
updatedAt: Date.UTC(2024, 4, 20, 12, 0, 0),
internalWorkedMs: 5 * 3600000,
externalWorkedMs: 0,
}),
]
const companies = new Map<string, Doc<"companies">>([
[String(companyA._id), companyA],
[String(companyB._id), companyB],
])
const ctx = createReportsCtx({ tickets, companies }) as Parameters<typeof hoursByClientHandler>[0]
const result = await hoursByClientHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.items).toHaveLength(2)
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
companyId: companyA._id,
internalMs: 3 * 3600000,
externalMs: 3600000,
totalMs: 4 * 3600000,
contractedHoursPerMonth: 60,
}),
expect.objectContaining({
companyId: companyB._id,
internalMs: 3600000,
externalMs: 2 * 3600000,
totalMs: 3 * 3600000,
isAvulso: true,
}),
])
)
})
})

View file

@ -0,0 +1,127 @@
import { vi } from "vitest"
import type { Doc, Id } from "../../convex/_generated/dataModel"
type ReportsCtxOptions = {
tickets?: Doc<"tickets">[]
createdRangeTickets?: Doc<"tickets">[]
queues?: Doc<"queues">[]
companies?: Map<string, Doc<"companies">>
users?: Map<string, Doc<"users">>
ticketEventsByTicket?: Map<string, Array<{ type: string; payload?: unknown; createdAt: number }>>
ticketWorkSessionsByAgent?: Map<string, Array<{ agentId: Id<"users">; startedAt: number; stoppedAt?: number; durationMs?: number }>>
}
const noopFilterBuilder = {
lt: () => noopFilterBuilder,
field: () => "createdAt",
}
const noopIndexBuilder = {
eq: () => noopIndexBuilder,
gte: () => noopIndexBuilder,
}
function ticketsChain(collection: Doc<"tickets">[]) {
const chain = {
filter: vi.fn((cb?: (builder: typeof noopFilterBuilder) => unknown) => {
cb?.(noopFilterBuilder)
return chain
}),
order: vi.fn(() => chain),
collect: vi.fn(async () => collection),
}
return chain
}
export function createReportsCtx({
tickets = [],
createdRangeTickets = tickets,
queues = [],
companies = new Map<string, Doc<"companies">>(),
users = new Map<string, Doc<"users">>(),
ticketEventsByTicket = new Map<string, Array<{ type: string; payload?: unknown; createdAt: number }>>(),
ticketWorkSessionsByAgent = new Map<string, Array<{ agentId: Id<"users">; startedAt: number; stoppedAt?: number; durationMs?: number }>>(),
}: ReportsCtxOptions = {}) {
const db = {
get: vi.fn(async (id: Id<"companies"> | Id<"users">) => {
const company = companies.get(String(id))
if (company) return company
const user = users.get(String(id))
if (user) return user
return null
}),
query: vi.fn((table: string) => {
if (table === "tickets") {
return {
withIndex: vi.fn((indexName: string, cb?: (builder: typeof noopIndexBuilder) => unknown) => {
cb?.(noopIndexBuilder)
const collection =
indexName.includes("created") || indexName.includes("tenant_company_created")
? createdRangeTickets
: tickets
return ticketsChain(collection)
}),
collect: vi.fn(async () => tickets),
}
}
if (table === "queues") {
return {
withIndex: vi.fn((_indexName: string, cb?: (builder: typeof noopIndexBuilder) => unknown) => {
cb?.(noopIndexBuilder)
return {
collect: vi.fn(async () => queues),
}
}),
collect: vi.fn(async () => queues),
}
}
if (table === "ticketEvents") {
return {
withIndex: vi.fn((_indexName: string, cb?: (builder: { eq: (field: unknown, value: unknown) => unknown }) => unknown) => {
let ticketId: string | null = null
const builder = {
eq: (_field: unknown, value: unknown) => {
ticketId = String(value)
return builder
},
}
cb?.(builder as { eq: (field: unknown, value: unknown) => unknown })
return {
collect: vi.fn(async () => (ticketId ? ticketEventsByTicket.get(ticketId) ?? [] : [])),
}
}),
}
}
if (table === "ticketWorkSessions") {
return {
withIndex: vi.fn((_indexName: string, cb?: (builder: { eq: (field: unknown, value: unknown) => unknown }) => unknown) => {
let agentId: string | null = null
const builder = {
eq: (_field: unknown, value: unknown) => {
agentId = String(value)
return builder
},
}
cb?.(builder as { eq: (field: unknown, value: unknown) => unknown })
return {
collect: vi.fn(async () => (agentId ? ticketWorkSessionsByAgent.get(agentId) ?? [] : [])),
}
}),
}
}
return {
withIndex: vi.fn(() => ({
collect: vi.fn(async () => []),
})),
collect: vi.fn(async () => []),
}
}),
}
return { db } as unknown
}