feat: dispositivos e ajustes de csat e relatórios

This commit is contained in:
codex-bot 2025-11-03 19:29:50 -03:00
parent 25d2a9b062
commit e0ef66555d
86 changed files with 5811 additions and 992 deletions

View file

@ -15,7 +15,7 @@ export async function ensureMachineAccount(params: EnsureMachineAccountParams) {
const context = await auth.$context
const passwordHash = await context.password.hash(machineToken)
const machineName = `Máquina ${hostname}`
const machineName = `Dispositivo ${hostname}`
const user = await prisma.authUser.upsert({
where: { email: machineEmail },

View file

@ -31,7 +31,7 @@ export type MachineSessionContext = {
}
export class MachineInactiveError extends Error {
constructor(message = "Máquina desativada") {
constructor(message = "Dispositivo desativada") {
super(message)
this.name = "MachineInactiveError"
}
@ -45,7 +45,7 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
const client = new ConvexHttpClient(convexUrl)
const resolved = await client.mutation(api.machines.resolveToken, { machineToken })
const resolved = await client.mutation(api.devices.resolveToken, { machineToken })
let machineEmail = resolved.machine.authEmail ?? null
const machineActive = resolved.machine.isActive ?? true
@ -62,7 +62,7 @@ export async function createMachineSession(machineToken: string, rememberMe = tr
persona: (resolved.machine.persona ?? null) ?? undefined,
})
await client.mutation(api.machines.linkAuthAccount, {
await client.mutation(api.devices.linkAuthAccount, {
machineId: resolved.machine._id as Id<"machines">,
authUserId: account.authUserId,
authEmail: account.authEmail,

View file

@ -1,5 +1,15 @@
import { buildXlsxWorkbook, type WorksheetConfig } from "@/lib/xlsx"
import type { Id } from "@/convex/_generated/dataModel"
import { DEVICE_INVENTORY_COLUMN_METADATA, type DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
import { buildXlsxWorkbook, type WorksheetConfig } from "@/lib/xlsx"
type DeviceCustomField = {
fieldId: Id<"deviceFields">
fieldKey: string
label: string
type: string
value: unknown
displayValue?: string
}
type LinkedUser = {
id: string
@ -11,6 +21,11 @@ export type MachineInventoryRecord = {
id: Id<"machines">
tenantId: string
hostname: string
displayName: string | null
deviceType: string | null
devicePlatform: string | null
deviceProfile?: Record<string, unknown> | null
managementMode: string | null
companyId: Id<"companies"> | null
companySlug: string | null
companyName: string | null
@ -36,6 +51,7 @@ export type MachineInventoryRecord = {
lastPostureAt?: number | null
remoteAccess?: unknown
linkedUsers?: LinkedUser[]
customFields?: DeviceCustomField[]
}
type WorkbookOptions = {
@ -43,6 +59,7 @@ type WorkbookOptions = {
generatedBy?: string | null
companyFilterLabel?: string | null
generatedAt?: Date
columns?: DeviceInventoryColumnConfig[]
}
type SoftwareEntry = {
@ -54,59 +71,270 @@ type SoftwareEntry = {
installedOn: string | null
}
const INVENTORY_HEADERS = [
"Hostname",
"Empresa",
"Status",
"Persona",
"Ativa",
"Último heartbeat",
"Responsável",
"E-mail responsável",
"Usuários vinculados",
"E-mail autenticado",
"Sistema operacional",
"Versão SO",
"Arquitetura",
"Fabricante",
"Modelo",
"Serial hardware",
"Processador",
"Cores físicas",
"Cores lógicas",
"Memória (GiB)",
"GPUs",
"Labels",
"MACs",
"Seriais",
"IP principal",
"IP público",
"Registrada via",
"Token expira em",
"Token último uso",
"Uso do token",
"Criada em",
"Atualizada em",
"Softwares instalados",
"Build SO",
"Licença ativada",
"Experiência SO",
"Domínio",
"Grupo de trabalho",
"Nome do dispositivo",
"Serial placa-mãe",
"Colaborador (nome)",
"Colaborador (e-mail)",
"Acessos remotos",
"Fleet ID",
"Equipe Fleet",
"Fleet atualizado em",
] as const
type InventoryColumnDefinition = {
key: string
label: string
width: number
getValue: (machine: MachineInventoryRecord, derived: MachineDerivedData) => unknown
}
const INVENTORY_COLUMN_WIDTHS = [
22, 26, 16, 14, 10, 20, 22, 24, 28, 24, 20, 18, 14, 18, 22, 22, 24, 12, 12, 14, 26, 20, 24, 24, 18, 18, 18, 20, 20, 14, 20, 20, 18,
18, 18, 20, 20, 20, 26, 24, 24, 26, 16, 18, 18, 20,
] as const
type MachineDerivedData = {
inventory: Record<string, unknown>
hardware: ReturnType<typeof extractHardware>
gpuNames: string[]
labels: string[]
primaryIp: string | null
publicIp: string | null
softwareEntries: SoftwareEntry[]
linkedUsersLabel: string | null
systemInfo: ReturnType<typeof extractSystemInfo>
collaborator: ReturnType<typeof extractCollaborator>
remoteAccessCount: number
fleetInfo: ReturnType<typeof extractFleetInfo>
customFieldByKey: Record<string, DeviceCustomField>
customFieldById: Record<string, DeviceCustomField>
}
const COLUMN_VALUE_RESOLVERS: Record<string, (machine: MachineInventoryRecord, derived: MachineDerivedData) => unknown> = {
displayName: (machine) => machine.displayName ?? machine.hostname,
hostname: (machine) => machine.hostname,
deviceType: (machine) => describeDeviceType(machine.deviceType),
devicePlatform: (machine) => machine.devicePlatform ?? null,
company: (machine) => machine.companyName ?? null,
status: (machine) => describeStatus(machine.status),
persona: (machine) => describePersona(machine.persona),
active: (machine) => yesNo(machine.isActive),
lastHeartbeat: (machine) => formatDateTime(machine.lastHeartbeatAt),
assignedUser: (machine) => machine.assignedUserName ?? machine.assignedUserEmail ?? null,
assignedEmail: (machine) => machine.assignedUserEmail ?? null,
linkedUsers: (_machine, derived) => derived.linkedUsersLabel,
authEmail: (machine) => machine.authEmail ?? null,
osName: (machine) => machine.osName,
osVersion: (machine) => machine.osVersion ?? null,
architecture: (machine) => machine.architecture ?? null,
hardwareVendor: (_machine, derived) => derived.hardware.vendor ?? derived.systemInfo.systemManufacturer ?? null,
hardwareModel: (_machine, derived) => derived.hardware.model ?? derived.systemInfo.systemModel ?? null,
hardwareSerial: (_machine, derived) => derived.hardware.serial ?? derived.systemInfo.boardSerial ?? null,
cpu: (_machine, derived) => derived.hardware.cpuType ?? null,
physicalCores: (_machine, derived) => derived.hardware.physicalCores ?? null,
logicalCores: (_machine, derived) => derived.hardware.logicalCores ?? null,
memoryGiB: (_machine, derived) => derived.hardware.memoryGiB ?? null,
gpus: (_machine, derived) => (derived.gpuNames.length > 0 ? derived.gpuNames.join(", ") : null),
labels: (_machine, derived) => (derived.labels.length > 0 ? derived.labels.join(", ") : null),
macs: (machine) => (machine.macAddresses.length > 0 ? machine.macAddresses.join(", ") : null),
serials: (machine) => (machine.serialNumbers.length > 0 ? machine.serialNumbers.join(", ") : null),
primaryIp: (_machine, derived) => derived.primaryIp ?? null,
publicIp: (_machine, derived) => derived.publicIp ?? null,
registeredBy: (machine) => machine.registeredBy ?? null,
tokenExpiresAt: (machine) => (machine.token?.expiresAt ? formatDateTime(machine.token.expiresAt) : null),
tokenLastUsedAt: (machine) => (machine.token?.lastUsedAt ? formatDateTime(machine.token.lastUsedAt) : null),
tokenUsageCount: (machine) => machine.token?.usageCount ?? 0,
createdAt: (machine) => formatDateTime(machine.createdAt),
updatedAt: (machine) => formatDateTime(machine.updatedAt),
softwareCount: (_machine, derived) => derived.softwareEntries.length,
osBuild: (_machine, derived) => derived.systemInfo.osBuild ?? null,
osLicense: (_machine, derived) => derived.systemInfo.license ?? null,
osExperience: (_machine, derived) => derived.systemInfo.experience ?? null,
domain: (_machine, derived) => derived.systemInfo.domain ?? null,
workgroup: (_machine, derived) => derived.systemInfo.workgroup ?? null,
deviceName: (_machine, derived) => derived.systemInfo.deviceName ?? null,
boardSerial: (_machine, derived) => derived.systemInfo.boardSerial ?? derived.hardware.serial ?? null,
collaboratorName: (_machine, derived) => derived.collaborator?.name ?? null,
collaboratorEmail: (_machine, derived) => derived.collaborator?.email ?? null,
remoteAccessCount: (_machine, derived) => derived.remoteAccessCount,
fleetId: (_machine, derived) => derived.fleetInfo?.id ?? null,
fleetTeam: (_machine, derived) => derived.fleetInfo?.team ?? null,
fleetUpdatedAt: (_machine, derived) =>
derived.fleetInfo?.updatedAt ? formatDateTime(derived.fleetInfo.updatedAt) : null,
managementMode: (machine) => describeManagementMode(machine.managementMode),
}
const DEFAULT_COLUMN_CONFIG: DeviceInventoryColumnConfig[] = DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map(
(meta) => ({ key: meta.key })
)
const INVENTORY_COLUMN_DEFINITIONS: InventoryColumnDefinition[] = DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({
key: meta.key,
label: meta.label,
width: meta.width,
getValue: COLUMN_VALUE_RESOLVERS[meta.key] ?? (() => null),
}))
const INVENTORY_COLUMN_MAP: Record<string, InventoryColumnDefinition> = Object.fromEntries(
INVENTORY_COLUMN_DEFINITIONS.map((column) => [column.key, column]),
)
const CUSTOM_FIELD_KEY_PREFIX = "custom:"
const CUSTOM_FIELD_ID_PREFIX = "custom#"
function deriveMachineData(machine: MachineInventoryRecord): MachineDerivedData {
const inventory = toRecord(machine.inventory) ?? {}
const hardware = extractHardware(inventory)
const gpuNames = extractGpuNames(inventory)
const labels = extractLabels(inventory)
const primaryIp = extractPrimaryIp(inventory)
const publicIp = extractPublicIp(inventory)
const softwareEntries = extractSoftwareEntries(machine.hostname, inventory)
const linkedUsersLabel = summarizeLinkedUsers(machine.linkedUsers)
const systemInfo = extractSystemInfo(inventory)
const collaborator = extractCollaborator(machine, inventory)
const remoteAccessCount = collectRemoteAccessEntries(machine).length
const fleetInfo = extractFleetInfo(inventory)
const customFieldByKey: Record<string, DeviceCustomField> = {}
const customFieldById: Record<string, DeviceCustomField> = {}
for (const field of machine.customFields ?? []) {
customFieldByKey[field.fieldKey] = field
customFieldById[String(field.fieldId)] = field
}
return {
inventory,
hardware,
gpuNames,
labels,
primaryIp,
publicIp,
softwareEntries,
linkedUsersLabel,
systemInfo,
collaborator,
remoteAccessCount,
fleetInfo,
customFieldByKey,
customFieldById,
}
}
function normalizeColumnConfig(columns?: DeviceInventoryColumnConfig[]): DeviceInventoryColumnConfig[] {
if (!columns || columns.length === 0) {
return [...DEFAULT_COLUMN_CONFIG]
}
const seen = new Set<string>()
const normalized: DeviceInventoryColumnConfig[] = []
for (const column of columns) {
const key = column.key.trim()
if (!key) continue
if (seen.has(key)) continue
if (!isCustomFieldKey(key) && !INVENTORY_COLUMN_MAP[key]) continue
seen.add(key)
normalized.push({
key,
label: column.label?.trim() || undefined,
})
}
return normalized.length > 0 ? normalized : [...DEFAULT_COLUMN_CONFIG]
}
function resolveColumnLabel(column: DeviceInventoryColumnConfig, derivedList: MachineDerivedData[]): string {
if (column.label) return column.label
const definition = INVENTORY_COLUMN_MAP[column.key]
if (definition) return definition.label
if (isCustomFieldKey(column.key)) {
for (const derived of derivedList) {
const field = lookupCustomField(column.key, derived)
if (field) return field.label
}
return "Campo personalizado"
}
return column.key
}
function resolveColumnWidth(key: string): number {
const definition = INVENTORY_COLUMN_MAP[key]
if (definition) return definition.width
if (isCustomFieldKey(key)) return 28
return 20
}
function isCustomFieldKey(key: string): boolean {
return key.startsWith(CUSTOM_FIELD_KEY_PREFIX) || key.startsWith(CUSTOM_FIELD_ID_PREFIX)
}
function lookupCustomField(key: string, derived: MachineDerivedData): DeviceCustomField | null {
if (key.startsWith(CUSTOM_FIELD_KEY_PREFIX)) {
const fieldKey = key.slice(CUSTOM_FIELD_KEY_PREFIX.length)
return derived.customFieldByKey[fieldKey] ?? null
}
if (key.startsWith(CUSTOM_FIELD_ID_PREFIX)) {
const fieldId = key.slice(CUSTOM_FIELD_ID_PREFIX.length)
return derived.customFieldById[fieldId] ?? null
}
return null
}
function formatCustomFieldValue(field: DeviceCustomField): string {
if (field.value === null || field.value === undefined) {
return "—"
}
if (field.displayValue !== undefined && field.displayValue !== null) {
const text = String(field.displayValue).trim()
if (text.length > 0) return text
}
switch (field.type) {
case "boolean":
return yesNo(Boolean(field.value))
case "number": {
const num = typeof field.value === "number" ? field.value : Number(field.value)
return Number.isFinite(num) ? String(num) : String(field.value)
}
case "date": {
const date = new Date(field.value as string | number)
if (Number.isNaN(date.getTime())) return String(field.value)
return date.toISOString().slice(0, 10)
}
default:
return String(field.value)
}
}
function resolveColumnValue(
key: string,
machine: MachineInventoryRecord,
derived: MachineDerivedData
): unknown {
if (isCustomFieldKey(key)) {
const field = lookupCustomField(key, derived)
if (!field) return "—"
return formatCustomFieldValue(field)
}
const definition = INVENTORY_COLUMN_MAP[key]
if (!definition) return "—"
return definition.getValue(machine, derived)
}
function formatInventoryCell(value: unknown): unknown {
if (value === null || value === undefined) return "—"
if (typeof value === "string") {
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : "—"
}
return value
}
function describeDeviceType(type: string | null | undefined): string {
const normalized = (type ?? "").toLowerCase()
switch (normalized) {
case "desktop":
return "Desktop"
case "mobile":
return "Celular"
case "tablet":
return "Tablet"
default:
return "Desconhecido"
}
}
function describeManagementMode(mode: string | null | undefined): string {
const normalized = (mode ?? "").toLowerCase()
switch (normalized) {
case "agent":
return "Agente"
case "manual":
return "Manual"
default:
return "—"
}
}
const SOFTWARE_HEADERS = ["Hostname", "Aplicativo", "Versão", "Origem", "Publicador", "Instalado em"] as const
const SOFTWARE_COLUMN_WIDTHS = [22, 36, 18, 18, 22, 20] as const
@ -173,7 +401,7 @@ const STATUS_LABELS: Record<string, string> = {
const PERSONA_LABELS: Record<string, string> = {
collaborator: "Colaborador",
manager: "Gestor",
machine: "Máquina",
machine: "Dispositivo",
}
const SUMMARY_STATUS_ORDER = ["Online", "Sem sinal", "Offline", "Manutenção", "Bloqueada", "Desativada", "Desconhecido"]
@ -186,7 +414,14 @@ export function buildMachinesInventoryWorkbook(
): Buffer {
const generatedAt = options.generatedAt ?? new Date()
const summaryRows = buildSummaryRows(machines, options, generatedAt)
const inventoryRows = machines.map((machine) => flattenMachine(machine))
const columnConfig = normalizeColumnConfig(options.columns)
const derivedList = machines.map((machine) => deriveMachineData(machine))
const headers = columnConfig.map((column) => resolveColumnLabel(column, derivedList))
const columnWidths = columnConfig.map((column) => resolveColumnWidth(column.key))
const inventoryRows = machines.map((machine, index) => {
const derived = derivedList[index]
return columnConfig.map((column) => formatInventoryCell(resolveColumnValue(column.key, machine, derived)))
})
const linksRows = buildLinkedUsersRows(machines)
const softwareRows = buildSoftwareRows(machines)
const partitionRows = buildPartitionRows(machines)
@ -210,9 +445,9 @@ export function buildMachinesInventoryWorkbook(
sheets.push({
name: "Inventário",
headers: [...INVENTORY_HEADERS],
headers,
rows: inventoryRows,
columnWidths: [...INVENTORY_COLUMN_WIDTHS],
columnWidths,
freezePane: { rowSplit: 1 },
autoFilter: true,
})
@ -353,11 +588,28 @@ function buildSummaryRows(
rows.push(["Filtro de empresa", options.companyFilterLabel])
}
rows.push(["Total de máquinas", machines.length])
rows.push(["Total de dispositivos", machines.length])
const activeCount = machines.filter((machine) => machine.isActive).length
rows.push(["Máquinas ativas", activeCount])
rows.push(["Máquinas inativas", machines.length - activeCount])
rows.push(["Dispositivos ativos", activeCount])
rows.push(["Dispositivos inativos", machines.length - activeCount])
const typeCounts = new Map<string, number>()
machines.forEach((machine) => {
const label = describeDeviceType(machine.deviceType)
typeCounts.set(label, (typeCounts.get(label) ?? 0) + 1)
})
const DEVICE_TYPE_ORDER = ["Desktop", "Celular", "Tablet", "Desconhecido"]
Array.from(typeCounts.entries())
.sort((a, b) => {
const idxA = DEVICE_TYPE_ORDER.indexOf(a[0])
const idxB = DEVICE_TYPE_ORDER.indexOf(b[0])
if (idxA === -1 && idxB === -1) return a[0].localeCompare(b[0], "pt-BR")
if (idxA === -1) return 1
if (idxB === -1) return -1
return idxA - idxB
})
.forEach(([label, total]) => rows.push([`Tipo: ${label}`, total]))
const statusCounts = new Map<string, number>()
machines.forEach((machine) => {
@ -396,70 +648,6 @@ function buildSummaryRows(
return rows
}
function flattenMachine(machine: MachineInventoryRecord): WorksheetRow {
const inventory = toRecord(machine.inventory)
const hardware = extractHardware(inventory)
const gpuNames = extractGpuNames(inventory)
const labels = extractLabels(inventory)
const primaryIp = extractPrimaryIp(inventory)
const publicIp = extractPublicIp(inventory)
const softwareEntries = extractSoftwareEntries(machine.hostname, inventory)
const linkedUsers = summarizeLinkedUsers(machine.linkedUsers)
const systemInfo = extractSystemInfo(inventory)
const collaborator = extractCollaborator(machine, inventory)
const remoteAccessCount = collectRemoteAccessEntries(machine).length
const fleetInfo = extractFleetInfo(inventory)
return [
machine.hostname,
machine.companyName ?? "—",
describeStatus(machine.status),
describePersona(machine.persona),
yesNo(machine.isActive),
formatDateTime(machine.lastHeartbeatAt),
machine.assignedUserName ?? machine.assignedUserEmail ?? "—",
machine.assignedUserEmail ?? "—",
linkedUsers ?? "—",
machine.authEmail ?? "—",
machine.osName,
machine.osVersion ?? "—",
machine.architecture ?? "—",
hardware.vendor ?? systemInfo.systemManufacturer ?? "—",
hardware.model ?? systemInfo.systemModel ?? "—",
hardware.serial ?? systemInfo.boardSerial ?? "—",
hardware.cpuType ?? "—",
hardware.physicalCores ?? "—",
hardware.logicalCores ?? "—",
hardware.memoryGiB ?? "—",
gpuNames.length > 0 ? gpuNames.join(", ") : "—",
labels.length > 0 ? labels.join(", ") : "—",
machine.macAddresses.length > 0 ? machine.macAddresses.join(", ") : "—",
machine.serialNumbers.length > 0 ? machine.serialNumbers.join(", ") : "—",
primaryIp ?? "—",
publicIp ?? "—",
machine.registeredBy ?? "—",
machine.token?.expiresAt ? formatDateTime(machine.token.expiresAt) ?? "—" : "—",
machine.token?.lastUsedAt ? formatDateTime(machine.token.lastUsedAt) ?? "—" : "—",
machine.token?.usageCount ?? 0,
formatDateTime(machine.createdAt) ?? "—",
formatDateTime(machine.updatedAt) ?? "—",
softwareEntries.length,
systemInfo.osBuild ?? "—",
systemInfo.license ?? "—",
systemInfo.experience ?? "—",
systemInfo.domain ?? "—",
systemInfo.workgroup ?? "—",
systemInfo.deviceName ?? "—",
systemInfo.boardSerial ?? hardware.serial ?? "—",
collaborator?.name ?? "—",
collaborator?.email ?? "—",
remoteAccessCount,
fleetInfo?.id ?? "—",
fleetInfo?.team ?? "—",
fleetInfo?.updatedAt ? formatDateTime(fleetInfo.updatedAt) ?? "—" : "—",
]
}
function buildLinkedUsersRows(machines: MachineInventoryRecord[]): Array<[string, string | null, string | null, string | null]> {
const rows: Array<[string, string | null, string | null, string | null]> = []
machines.forEach((machine) => {

View file

@ -408,7 +408,7 @@ function TicketPdfDocument({ ticket, logoDataUrl }: { ticket: TicketWithDetails;
]
if (ticket.machine) {
const machineLabel = ticket.machine.hostname ?? (ticket.machine.id ? `ID ${ticket.machine.id}` : "—")
rightMeta.push({ label: "Máquina", value: machineLabel })
rightMeta.push({ label: "Dispositivo", value: machineLabel })
}
if (ticket.resolvedAt) {
rightMeta.push({ label: "Resolvido em", value: formatDateTime(ticket.resolvedAt) })