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

@ -8,6 +8,7 @@ import { randomBytes } from "@noble/hashes/utils"
import type { Doc, Id } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server"
import { normalizeStatus } from "./tickets"
import { requireAdmin } from "./rbac"
const DEFAULT_TENANT_ID = "tenant-atlas"
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
@ -65,6 +66,14 @@ function normalizeIdentifiers(macAddresses: string[], serialNumbers: string[]):
return { macs, serials }
}
function normalizeOptionalIdentifiers(macAddresses?: string[] | null, serialNumbers?: string[] | null): NormalizedIdentifiers {
const normalizeMac = (value: string) => value.replace(/[^a-f0-9]/gi, "").toLowerCase()
const normalizeSerial = (value: string) => value.trim().toLowerCase()
const macs = Array.from(new Set((macAddresses ?? []).map(normalizeMac).filter(Boolean))).sort()
const serials = Array.from(new Set((serialNumbers ?? []).map(normalizeSerial).filter(Boolean))).sort()
return { macs, serials }
}
async function findActiveMachineToken(ctx: QueryCtx, machineId: Id<"machines">, now: number) {
const tokens = await ctx.db
.query("machineTokens")
@ -93,6 +102,51 @@ function computeFingerprint(tenantId: string, companySlug: string | undefined, h
return toHex(sha256(payload))
}
function generateManualFingerprint(tenantId: string, displayName: string) {
const payload = JSON.stringify({
tenantId,
displayName: displayName.trim().toLowerCase(),
nonce: toHex(randomBytes(16)),
createdAt: Date.now(),
})
return toHex(sha256(payload))
}
function formatDeviceCustomFieldDisplay(
type: string,
value: unknown,
options?: Array<{ value: string; label: string }>
): string | null {
if (value === null || value === undefined) return null
switch (type) {
case "text":
return String(value).trim()
case "number": {
const num = typeof value === "number" ? value : Number(value)
if (!Number.isFinite(num)) return null
return String(num)
}
case "boolean":
return value ? "Sim" : "Não"
case "date": {
const date = value instanceof Date ? value : new Date(String(value))
if (Number.isNaN(date.getTime())) return null
return date.toISOString().slice(0, 10)
}
case "select": {
const raw = String(value)
const option = options?.find((opt) => opt.value === raw || opt.label === raw)
return option?.label ?? raw
}
default:
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
}
function extractCollaboratorEmail(metadata: unknown): string | null {
if (!metadata || typeof metadata !== "object") return null
const record = metadata as Record<string, unknown>
@ -126,18 +180,18 @@ async function getActiveToken(
.unique()
if (!token) {
throw new ConvexError("Token de máquina inválido")
throw new ConvexError("Token de dispositivo inválido")
}
if (token.revoked) {
throw new ConvexError("Token de máquina revogado")
throw new ConvexError("Token de dispositivo revogado")
}
if (token.expiresAt < Date.now()) {
throw new ConvexError("Token de máquina expirado")
throw new ConvexError("Token de dispositivo expirado")
}
const machine = await ctx.db.get(token.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada para o token fornecido")
throw new ConvexError("Dispositivo não encontrada para o token fornecido")
}
return { token, machine }
@ -381,7 +435,7 @@ async function evaluatePostureAndMaybeRaise(
if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "false").toLowerCase() !== "true") return
if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return
const subject = `Alerta de máquina: ${machine.hostname}`
const subject = `Alerta de dispositivo: ${machine.hostname}`
const summary = findings.map((f) => `${f.severity.toUpperCase()}: ${f.message}`).join(" | ")
await createTicketForAlert(ctx, machine.tenantId, machine.companyId, subject, summary)
}
@ -445,6 +499,7 @@ export const register = mutation({
companyId: companyId ?? existing.companyId,
companySlug: companySlug ?? existing.companySlug,
hostname: args.hostname,
displayName: existing.displayName ?? args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
@ -457,6 +512,9 @@ export const register = mutation({
status: "online",
isActive: true,
registeredBy: args.registeredBy ?? existing.registeredBy,
deviceType: existing.deviceType ?? "desktop",
devicePlatform: args.os.name ?? existing.devicePlatform,
managementMode: existing.managementMode ?? "agent",
persona: existing.persona,
assignedUserId: existing.assignedUserId,
assignedUserEmail: existing.assignedUserEmail,
@ -470,6 +528,7 @@ export const register = mutation({
companyId,
companySlug,
hostname: args.hostname,
displayName: args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
@ -483,6 +542,9 @@ export const register = mutation({
createdAt: now,
updatedAt: now,
registeredBy: args.registeredBy,
deviceType: "desktop",
devicePlatform: args.os.name,
managementMode: "agent",
persona: undefined,
assignedUserId: undefined,
assignedUserEmail: undefined,
@ -582,6 +644,7 @@ export const upsertInventory = mutation({
companyId: companyId ?? existing.companyId,
companySlug: companySlug ?? existing.companySlug,
hostname: args.hostname,
displayName: existing.displayName ?? args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
@ -592,6 +655,9 @@ export const upsertInventory = mutation({
updatedAt: now,
status: args.metrics ? "online" : existing.status ?? "unknown",
registeredBy: args.registeredBy ?? existing.registeredBy,
deviceType: existing.deviceType ?? "desktop",
devicePlatform: args.os.name ?? existing.devicePlatform,
managementMode: existing.managementMode ?? "agent",
persona: existing.persona,
assignedUserId: existing.assignedUserId,
assignedUserEmail: existing.assignedUserEmail,
@ -605,6 +671,7 @@ export const upsertInventory = mutation({
companyId,
companySlug,
hostname: args.hostname,
displayName: args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
@ -617,6 +684,9 @@ export const upsertInventory = mutation({
createdAt: now,
updatedAt: now,
registeredBy: args.registeredBy,
deviceType: "desktop",
devicePlatform: args.os.name,
managementMode: "agent",
persona: undefined,
assignedUserId: undefined,
assignedUserEmail: undefined,
@ -673,9 +743,13 @@ export const heartbeat = mutation({
await ctx.db.patch(machine._id, {
hostname: args.hostname ?? machine.hostname,
displayName: machine.displayName ?? args.hostname ?? machine.hostname,
osName: args.os?.name ?? machine.osName,
osVersion: args.os?.version ?? machine.osVersion,
architecture: args.os?.architecture ?? machine.architecture,
devicePlatform: args.os?.name ?? machine.devicePlatform,
deviceType: machine.deviceType ?? "desktop",
managementMode: machine.managementMode ?? "agent",
lastHeartbeatAt: now,
updatedAt: now,
status: args.status ?? "online",
@ -839,6 +913,11 @@ export const listByTenant = query({
id: machine._id,
tenantId: machine.tenantId,
hostname: machine.hostname,
displayName: machine.displayName ?? null,
deviceType: machine.deviceType ?? "desktop",
devicePlatform: machine.devicePlatform ?? null,
deviceProfile: machine.deviceProfile ?? null,
managementMode: machine.managementMode ?? "agent",
companyId: machine.companyId ?? null,
companySlug: machine.companySlug ?? companyFromId?.slug ?? companyFromSlug?.slug ?? null,
companyName: resolvedCompany?.name ?? null,
@ -873,6 +952,7 @@ export const listByTenant = query({
inventory,
postureAlerts,
lastPostureAt,
customFields: machine.customFields ?? [],
}
})
)
@ -957,6 +1037,11 @@ export async function getByIdHandler(
id: machine._id,
tenantId: machine.tenantId,
hostname: machine.hostname,
displayName: machine.displayName ?? null,
deviceType: machine.deviceType ?? "desktop",
devicePlatform: machine.devicePlatform ?? null,
deviceProfile: machine.deviceProfile ?? null,
managementMode: machine.managementMode ?? "agent",
companyId: machine.companyId ?? null,
companySlug: machine.companySlug ?? resolvedCompany?.slug ?? null,
companyName: resolvedCompany?.name ?? null,
@ -992,6 +1077,7 @@ export async function getByIdHandler(
postureAlerts,
lastPostureAt,
remoteAccess: machine.remoteAccess ?? null,
customFields: machine.customFields ?? [],
}
}
@ -1333,7 +1419,7 @@ export async function updatePersonaHandler(
) {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
let nextPersona = machine.persona ?? undefined
@ -1343,7 +1429,7 @@ export async function updatePersonaHandler(
if (!trimmed) {
nextPersona = undefined
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
throw new ConvexError("Perfil inválido para a máquina")
throw new ConvexError("Perfil inválido para a dispositivo")
} else {
nextPersona = trimmed
}
@ -1380,7 +1466,7 @@ export async function updatePersonaHandler(
}
if (nextPersona && !nextAssignedUserId) {
throw new ConvexError("Associe um usuário ao definir a persona da máquina")
throw new ConvexError("Associe um usuário ao definir a persona da dispositivo")
}
if (nextPersona && nextAssignedUserId) {
@ -1435,6 +1521,196 @@ export async function updatePersonaHandler(
return { ok: true, persona: nextPersona ?? null }
}
export const saveDeviceCustomFields = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
machineId: v.id("machines"),
fields: v.array(
v.object({
fieldId: v.id("deviceFields"),
value: v.any(),
})
),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const machine = await ctx.db.get(args.machineId)
if (!machine || machine.tenantId !== args.tenantId) {
throw new ConvexError("Dispositivo não encontrado")
}
const companyId = machine.companyId ?? null
const deviceType = (machine.deviceType ?? "desktop").toLowerCase()
const entries = await Promise.all(
args.fields.map(async ({ fieldId, value }) => {
const definition = await ctx.db.get(fieldId)
if (!definition || definition.tenantId !== args.tenantId) {
return null
}
if (companyId && definition.companyId && definition.companyId !== companyId) {
return null
}
if (!companyId && definition.companyId) {
return null
}
const scope = (definition.scope ?? "all").toLowerCase()
if (scope !== "all" && scope !== deviceType) {
return null
}
const displayValue =
value === null || value === undefined
? null
: formatDeviceCustomFieldDisplay(definition.type, value, definition.options ?? undefined)
return {
fieldId: definition._id,
fieldKey: definition.key,
label: definition.label,
type: definition.type,
value: value ?? null,
displayValue: displayValue ?? undefined,
}
})
)
const customFields = entries.filter(Boolean) as Array<{
fieldId: Id<"deviceFields">
fieldKey: string
label: string
type: string
value: unknown
displayValue?: string
}>
await ctx.db.patch(args.machineId, {
customFields,
updatedAt: Date.now(),
})
},
})
export const saveDeviceProfile = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
machineId: v.optional(v.id("machines")),
companyId: v.optional(v.id("companies")),
companySlug: v.optional(v.string()),
displayName: v.string(),
hostname: v.optional(v.string()),
deviceType: v.string(),
devicePlatform: v.optional(v.string()),
osName: v.optional(v.string()),
osVersion: v.optional(v.string()),
macAddresses: v.optional(v.array(v.string())),
serialNumbers: v.optional(v.array(v.string())),
profile: v.optional(v.any()),
status: v.optional(v.string()),
isActive: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
await requireAdmin(ctx, args.actorId, args.tenantId)
const displayName = args.displayName.trim()
if (!displayName) {
throw new ConvexError("Informe o nome do dispositivo")
}
const hostname = (args.hostname ?? displayName).trim()
if (!hostname) {
throw new ConvexError("Informe o identificador do dispositivo")
}
const normalizedType = (() => {
const candidate = args.deviceType.trim().toLowerCase()
if (["desktop", "mobile", "tablet"].includes(candidate)) return candidate
return "desktop"
})()
const normalizedPlatform = args.devicePlatform?.trim() || args.osName?.trim() || null
const normalizedStatus = (args.status ?? "unknown").trim() || "unknown"
const normalizedSlug = args.companySlug?.trim() || undefined
const osNameInput = args.osName === undefined ? undefined : args.osName.trim()
const osVersionInput = args.osVersion === undefined ? undefined : args.osVersion.trim()
const now = Date.now()
if (args.machineId) {
const machine = await ctx.db.get(args.machineId)
if (!machine || machine.tenantId !== args.tenantId) {
throw new ConvexError("Dispositivo não encontrado para atualização")
}
if (machine.managementMode && machine.managementMode !== "manual") {
throw new ConvexError("Somente dispositivos manuais podem ser editados por esta ação")
}
const normalizedIdentifiers = normalizeOptionalIdentifiers(args.macAddresses, args.serialNumbers)
const macAddresses =
args.macAddresses === undefined ? machine.macAddresses : normalizedIdentifiers.macs
const serialNumbers =
args.serialNumbers === undefined ? machine.serialNumbers : normalizedIdentifiers.serials
const deviceProfilePatch = args.profile === undefined ? undefined : args.profile ?? null
const osNameValue = osNameInput === undefined ? machine.osName : osNameInput || machine.osName
const osVersionValue =
osVersionInput === undefined ? machine.osVersion ?? undefined : osVersionInput || undefined
await ctx.db.patch(args.machineId, {
companyId: args.companyId ?? machine.companyId ?? undefined,
companySlug: normalizedSlug ?? machine.companySlug ?? undefined,
hostname,
displayName,
osName: osNameValue,
osVersion: osVersionValue,
macAddresses,
serialNumbers,
deviceType: normalizedType,
devicePlatform: normalizedPlatform ?? machine.devicePlatform ?? undefined,
deviceProfile: deviceProfilePatch,
managementMode: "manual",
status: normalizedStatus,
isActive: args.isActive ?? machine.isActive ?? true,
updatedAt: now,
})
return { machineId: args.machineId }
}
const normalizedIdentifiers = normalizeOptionalIdentifiers(args.macAddresses, args.serialNumbers)
const fingerprint = generateManualFingerprint(args.tenantId, displayName)
const deviceProfile = args.profile ?? undefined
const osNameValue = osNameInput || normalizedPlatform || "Desconhecido"
const osVersionValue = osVersionInput || undefined
const machineId = await ctx.db.insert("machines", {
tenantId: args.tenantId,
companyId: args.companyId ?? undefined,
companySlug: normalizedSlug ?? undefined,
hostname,
displayName,
osName: osNameValue,
osVersion: osVersionValue,
macAddresses: normalizedIdentifiers.macs,
serialNumbers: normalizedIdentifiers.serials,
fingerprint,
metadata: undefined,
deviceType: normalizedType,
devicePlatform: normalizedPlatform ?? undefined,
deviceProfile,
managementMode: "manual",
status: normalizedStatus,
isActive: args.isActive ?? true,
createdAt: now,
updatedAt: now,
registeredBy: "manual",
persona: undefined,
assignedUserId: undefined,
assignedUserEmail: undefined,
assignedUserName: undefined,
assignedUserRole: undefined,
})
return { machineId }
},
})
export const updatePersona = mutation({
args: {
machineId: v.id("machines"),
@ -1454,7 +1730,7 @@ export const getContext = query({
handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
const linkedUserIds = machine.linkedUserIds ?? []
@ -1515,7 +1791,7 @@ export const linkAuthAccount = mutation({
handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
await ctx.db.patch(machine._id, {
@ -1535,7 +1811,7 @@ export const linkUser = mutation({
},
handler: async (ctx, { machineId, email }) => {
const machine = await ctx.db.get(machineId)
if (!machine) throw new ConvexError("Máquina não encontrada")
if (!machine) throw new ConvexError("Dispositivo não encontrada")
const tenantId = machine.tenantId
const normalized = email.trim().toLowerCase()
@ -1546,7 +1822,7 @@ export const linkUser = mutation({
if (!user) throw new ConvexError("Usuário não encontrado")
const role = (user.role ?? "").toUpperCase()
if (role === 'ADMIN' || role === 'AGENT') {
throw new ConvexError('Usuários administrativos não podem ser vinculados a máquinas')
throw new ConvexError('Usuários administrativos não podem ser vinculados a dispositivos')
}
const current = new Set<Id<"users">>(machine.linkedUserIds ?? [])
@ -1563,7 +1839,7 @@ export const unlinkUser = mutation({
},
handler: async (ctx, { machineId, userId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) throw new ConvexError("Máquina não encontrada")
if (!machine) throw new ConvexError("Dispositivo não encontrada")
const next = (machine.linkedUserIds ?? []).filter((id) => id !== userId)
await ctx.db.patch(machine._id, { linkedUserIds: next, updatedAt: Date.now() })
return { ok: true }
@ -1580,25 +1856,29 @@ export const rename = mutation({
// Reutiliza requireStaff através de tickets.ts helpers
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
// Verifica permissão no tenant da máquina
// Verifica permissão no tenant da dispositivo
const viewer = await ctx.db.get(actorId)
if (!viewer || viewer.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
throw new ConvexError("Acesso negado ao tenant da dispositivo")
}
const normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) {
throw new ConvexError("Apenas equipe interna pode renomear máquinas")
throw new ConvexError("Apenas equipe interna pode renomear dispositivos")
}
const nextName = hostname.trim()
if (nextName.length < 2) {
throw new ConvexError("Informe um nome válido para a máquina")
throw new ConvexError("Informe um nome válido para a dispositivo")
}
await ctx.db.patch(machineId, { hostname: nextName, updatedAt: Date.now() })
await ctx.db.patch(machineId, {
hostname: nextName,
displayName: nextName,
updatedAt: Date.now(),
})
return { ok: true }
},
})
@ -1612,17 +1892,17 @@ export const toggleActive = mutation({
handler: async (ctx, { machineId, actorId, active }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
throw new ConvexError("Acesso negado ao tenant da dispositivo")
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) {
throw new ConvexError("Apenas equipe interna pode atualizar o status da máquina")
throw new ConvexError("Apenas equipe interna pode atualizar o status da dispositivo")
}
await ctx.db.patch(machineId, {
@ -1642,17 +1922,17 @@ export const resetAgent = mutation({
handler: async (ctx, { machineId, actorId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
throw new ConvexError("Acesso negado ao tenant da dispositivo")
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) {
throw new ConvexError("Apenas equipe interna pode resetar o agente da máquina")
throw new ConvexError("Apenas equipe interna pode resetar o agente da dispositivo")
}
const tokens = await ctx.db
@ -1808,12 +2088,12 @@ export const updateRemoteAccess = mutation({
handler: async (ctx, { machineId, actorId, provider, identifier, url, notes, action, entryId, clear }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
throw new ConvexError("Acesso negado ao tenant da dispositivo")
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
@ -1937,17 +2217,17 @@ export const remove = mutation({
handler: async (ctx, { machineId, actorId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada")
throw new ConvexError("Dispositivo não encontrada")
}
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
throw new ConvexError("Acesso negado ao tenant da dispositivo")
}
const role = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(role)) {
throw new ConvexError("Apenas equipe interna pode excluir máquinas")
throw new ConvexError("Apenas equipe interna pode excluir dispositivos")
}
const tokens = await ctx.db