sistema-de-chamados/convex/machines.ts
rever-tecnologia 23fe67e7d3 feat(devices): implementa tabela separada para softwares instalados
- Cria tabela machineSoftware no schema com indices otimizados
- Adiciona mutations para sincronizar softwares do heartbeat
- Atualiza heartbeat para processar e salvar softwares
- Cria componente DeviceSoftwareList com pesquisa e paginacao
- Integra lista de softwares no drawer de detalhes do dispositivo

feat(sla): transforma formulario em modal completo

- Substitui formulario inline por modal guiado
- Adiciona badge "Global" para indicar escopo da politica
- Adiciona seletor de unidade de tempo (minutos, horas, dias)
- Melhora textos e adiciona dica sobre hierarquia de SLAs

fix(reports): ajusta altura do SearchableCombobox

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 08:00:40 -03:00

2884 lines
98 KiB
TypeScript

// ci: trigger convex functions deploy (no-op)
import { mutation, query } from "./_generated/server"
import { internal, api } from "./_generated/api"
import { paginationOptsValidator } from "convex/server"
import { ConvexError, v, Infer } from "convex/values"
import { sha256 } from "@noble/hashes/sha2.js"
import { randomBytes } from "@noble/hashes/utils.js"
import type { Doc, Id } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server"
import { normalizeStatus } from "./tickets"
import { requireAdmin } from "./rbac"
import { ensureMobileDeviceFields } from "./deviceFieldDefaults"
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[]
serials: string[]
}
function getTokenTtlMs(): number {
const raw = process.env["MACHINE_TOKEN_TTL_MS"]
if (!raw) return DEFAULT_TOKEN_TTL_MS
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed < 60_000) {
return DEFAULT_TOKEN_TTL_MS
}
return parsed
}
export function getOfflineThresholdMs(): number {
const raw = process.env["MACHINE_OFFLINE_THRESHOLD_MS"]
if (!raw) return DEFAULT_OFFLINE_THRESHOLD_MS
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_OFFLINE_THRESHOLD_MS
}
return parsed
}
export function getStaleThresholdMs(offlineMs: number): number {
const raw = process.env["MACHINE_STALE_THRESHOLD_MS"]
if (!raw) return offlineMs * 12
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= offlineMs) {
return offlineMs * 12
}
return parsed
}
function normalizeIdentifiers(macAddresses: string[], serialNumbers: string[]): 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()
if (macs.length === 0 && serials.length === 0) {
throw new ConvexError("Informe ao menos um identificador (MAC ou serial)")
}
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")
.withIndex("by_machine_revoked_expires", (q) =>
q.eq("machineId", machineId).eq("revoked", false).gt("expiresAt", now),
)
.take(100)
return tokens.length > 0 ? tokens[0]! : null
}
function toHex(input: Uint8Array) {
return Array.from(input)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
}
const utf8 = (s: string) => new TextEncoder().encode(s)
function computeFingerprint(tenantId: string, companySlug: string | undefined, hostname: string, ids: NormalizedIdentifiers) {
const payload = JSON.stringify({
tenantId,
companySlug: companySlug ?? null,
hostname: hostname.trim().toLowerCase(),
macs: ids.macs,
serials: ids.serials,
})
return toHex(sha256(utf8(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(utf8(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
}
case "multiselect": {
const arr = Array.isArray(value)
? value
: typeof value === "string"
? value.split(",").map((s) => s.trim()).filter(Boolean)
: []
if (arr.length === 0) return null
const labels = arr.map((raw) => {
const opt = options?.find((o) => o.value === raw || o.label === raw)
return opt?.label ?? String(raw)
})
return labels.join(", ")
}
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>
const collaborator = record["collaborator"]
if (!collaborator || typeof collaborator !== "object") return null
const email = (collaborator as { email?: unknown }).email
if (typeof email !== "string") return null
const trimmed = email.trim().toLowerCase()
return trimmed || null
}
function matchesExistingHardware(existing: Doc<"machines">, identifiers: NormalizedIdentifiers, hostname: string): boolean {
const intersectsMac = existing.macAddresses.some((mac) => identifiers.macs.includes(mac))
const intersectsSerial = existing.serialNumbers.some((serial) => identifiers.serials.includes(serial))
const sameHostname = existing.hostname.trim().toLowerCase() === hostname.trim().toLowerCase()
return intersectsMac || intersectsSerial || sameHostname
}
function hashToken(token: string) {
return toHex(sha256(utf8(token)))
}
function getRemoteAccessTokenGraceMs() {
const fallback = 15 * 60 * 1000
const raw = process.env["REMOTE_ACCESS_TOKEN_GRACE_MS"]
if (!raw) return fallback
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) return fallback
return parsed
}
const REMOTE_ACCESS_TOKEN_GRACE_MS = getRemoteAccessTokenGraceMs()
async function getTokenRecord(
ctx: MutationCtx | QueryCtx,
tokenValue: string
): Promise<{ token: Doc<"machineTokens">; machine: Doc<"machines"> }> {
const tokenHash = hashToken(tokenValue)
const token = await ctx.db
.query("machineTokens")
.withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash))
.unique()
if (!token) {
throw new ConvexError("Token de dispositivo inválido")
}
const machine = await ctx.db.get(token.machineId)
if (!machine) {
throw new ConvexError("Dispositivo não encontrada para o token fornecido")
}
return { token, machine }
}
async function getTokenWithGrace(
ctx: MutationCtx | QueryCtx,
tokenValue: string,
options?: { allowGraceMs?: number }
): Promise<{ token: Doc<"machineTokens">; machine: Doc<"machines">; mode: "active" | "grace" }> {
const { token, machine } = await getTokenRecord(ctx, tokenValue)
const now = Date.now()
if (token.revoked) {
const graceMs = options?.allowGraceMs ?? 0
const revokedAt = token.revokedAt ?? token.lastUsedAt ?? token.createdAt
if (!graceMs || now - revokedAt > graceMs) {
throw new ConvexError("Token de dispositivo revogado")
}
return { token, machine, mode: "grace" }
}
if (token.expiresAt < now) {
throw new ConvexError("Token de dispositivo expirado")
}
return { token, machine, mode: "active" }
}
async function getActiveToken(
ctx: MutationCtx | QueryCtx,
tokenValue: string
): Promise<{ token: Doc<"machineTokens">; machine: Doc<"machines"> }> {
const { token, machine } = await getTokenWithGrace(ctx, tokenValue)
return { token, machine }
}
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
type JsonPrimitive = string | number | boolean | null
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }
type JsonRecord = Record<string, JsonValue>
const MAX_JSON_DEPTH = 6
const MAX_ARRAY_LENGTH = 200
function sanitizeJsonValue(value: unknown, depth = 0): JsonValue | undefined {
if (value === null) return null
if (typeof value === "string") return value
if (typeof value === "number") return Number.isFinite(value) ? value : undefined
if (typeof value === "boolean") return value
if (depth >= MAX_JSON_DEPTH) return undefined
if (Array.isArray(value)) {
const items: JsonValue[] = []
for (const entry of value.slice(0, MAX_ARRAY_LENGTH)) {
const sanitized = sanitizeJsonValue(entry, depth + 1)
if (sanitized !== undefined) {
items.push(sanitized)
}
}
return items
}
if (isObject(value)) {
const result: JsonRecord = {}
for (const [key, entry] of Object.entries(value)) {
const sanitized = sanitizeJsonValue(entry, depth + 1)
if (sanitized !== undefined) {
result[key] = sanitized
}
}
return result
}
return undefined
}
function sanitizeRecord(value: unknown): JsonRecord | null {
const sanitized = sanitizeJsonValue(value)
if (!sanitized || Array.isArray(sanitized)) return null
return sanitized as JsonRecord
}
function stableSerialize(value: JsonValue): string {
if (value === null) return "null"
if (typeof value !== "object") return JSON.stringify(value)
if (Array.isArray(value)) {
return `[${value.map((item) => stableSerialize(item)).join(",")}]`
}
const entries = Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableSerialize((value as JsonRecord)[key]!)}`)
return `{${entries.join(",")}}`
}
function hashJson(value: JsonRecord | null): string | null {
if (!value) return null
const serialized = stableSerialize(value)
return toHex(sha256(utf8(serialized)))
}
function areJsonValuesEqual(a: JsonValue | undefined, b: JsonValue | undefined): boolean {
if (a === b) return true
if (a === undefined || b === undefined) return false
return stableSerialize(a) === stableSerialize(b)
}
// Busca o lastHeartbeatAt da tabela machineHeartbeats (fonte de verdade)
// Fallback para machine.lastHeartbeatAt para retrocompatibilidade durante migracao
async function getMachineLastHeartbeat(
ctx: QueryCtx | MutationCtx,
machineId: Id<"machines">,
fallback?: number | null
): Promise<number | null> {
const hb = await ctx.db
.query("machineHeartbeats")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.first()
return hb?.lastHeartbeatAt ?? fallback ?? null
}
// Campos do inventory que sao muito grandes e nao devem ser persistidos
// para evitar OOM no Convex (documentos de ~100KB cada)
const INVENTORY_BLOCKLIST = new Set(["software", "extended"])
function mergeInventory(current: JsonRecord | null | undefined, patch: Record<string, unknown>): JsonRecord {
const sanitizedPatch = sanitizeRecord(patch)
if (!sanitizedPatch) {
return current ? { ...current } : {}
}
const base: JsonRecord = current ? { ...current } : {}
for (const [key, value] of Object.entries(sanitizedPatch)) {
// Filtrar campos volumosos que causam OOM
if (INVENTORY_BLOCKLIST.has(key)) continue
if (value === undefined) continue
if (isObject(value) && isObject(base[key])) {
base[key] = mergeInventory(base[key] as JsonRecord, value as Record<string, unknown>)
} else {
base[key] = value as JsonValue
}
}
return base
}
function mergeMetadata(current: unknown, patch: Record<string, unknown>): JsonRecord {
const base: JsonRecord = sanitizeRecord(current) ?? {}
const sanitizedPatch = sanitizeRecord(patch) ?? {}
for (const [key, value] of Object.entries(sanitizedPatch)) {
if (value === undefined) continue
if (key === "inventory" && isObject(value)) {
base[key] = mergeInventory(sanitizeRecord(base[key]), value as Record<string, unknown>)
} else if (isObject(value) && isObject(base[key])) {
base[key] = mergeInventory(sanitizeRecord(base[key]), value as Record<string, unknown>)
} else {
base[key] = value as JsonValue
}
}
return base
}
function ensureRecord(value: unknown): JsonRecord | null {
return sanitizeRecord(value)
}
function ensureRecordArray(value: unknown): JsonRecord[] {
if (!Array.isArray(value)) return []
return value
.map((entry) => sanitizeRecord(entry))
.filter((entry): entry is JsonRecord => Boolean(entry))
}
function ensureFiniteNumber(value: unknown): number | null {
const num = typeof value === "number" ? value : Number(value)
return Number.isFinite(num) ? num : null
}
function ensureString(value: unknown): string | null {
return typeof value === "string" ? value : null
}
function sanitizeInventoryPayload(value: unknown): JsonRecord | null {
const record = sanitizeRecord(value)
if (!record) return null
for (const blocked of INVENTORY_BLOCKLIST) {
delete record[blocked]
}
return record
}
function sanitizeMetricsPayload(value: unknown): JsonRecord | null {
return sanitizeRecord(value)
}
function getNestedRecord(root: JsonRecord | null, ...keys: string[]): JsonRecord | null {
let current: JsonRecord | null = root
for (const key of keys) {
if (!current) return null
current = ensureRecord(current[key])
}
return current
}
function getNestedRecordArray(root: JsonRecord | null, ...keys: string[]): JsonRecord[] {
if (keys.length === 0) return []
const parent = getNestedRecord(root, ...keys.slice(0, -1))
if (!parent) return []
return ensureRecordArray(parent[keys[keys.length - 1]])
}
type PostureFinding = {
kind: "CPU_HIGH" | "SERVICE_DOWN" | "SMART_FAIL"
message: string
severity: "warning" | "critical"
}
async function createTicketForAlert(
ctx: MutationCtx,
tenantId: string,
companyId: Id<"companies"> | undefined,
subject: string,
summary: string
) {
const actorEmail = process.env["MACHINE_ALERTS_TICKET_REQUESTER_EMAIL"] ?? "admin@sistema.dev"
const actor = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", actorEmail))
.unique()
if (!actor) return null
// pick first category/subcategory if not configured
const category = await ctx.db.query("ticketCategories").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).first()
if (!category) return null
const subcategory = await ctx.db
.query("ticketSubcategories")
.withIndex("by_category_order", (q) => q.eq("categoryId", category._id))
.first()
if (!subcategory) return null
try {
const id = await ctx.runMutation(api.tickets.create, {
actorId: actor._id,
tenantId,
subject,
summary,
priority: "Alta",
channel: "Automação",
queueId: undefined,
requesterId: actor._id,
assigneeId: undefined,
categoryId: category._id,
subcategoryId: subcategory._id,
customFields: undefined,
})
return id
} catch (error) {
console.error("[machines.alerts] Falha ao criar ticket:", error)
return null
}
}
async function evaluatePostureAndMaybeRaise(
ctx: MutationCtx,
machine: Doc<"machines">,
args: {
metrics?: JsonRecord | null
inventory?: JsonRecord | null
metadata?: JsonRecord | null
}
) {
const findings: PostureFinding[] = []
// Janela temporal de CPU (5 minutos)
const now = Date.now()
const metadataPatch = ensureRecord(args.metadata)
const metrics = ensureRecord(args.metrics) ?? ensureRecord(metadataPatch?.["metrics"])
const metaObj: JsonRecord = ensureRecord(machine.metadata) ?? {}
const prevWindowRecords = ensureRecordArray(metaObj["cpuWindow"])
const prevWindow: Array<{ ts: number; usage: number }> = prevWindowRecords
.map((entry) => {
const ts = ensureFiniteNumber(entry["ts"])
const usage = ensureFiniteNumber(entry["usage"])
if (ts === null || usage === null) return null
return { ts, usage }
})
.filter((entry): entry is { ts: number; usage: number } => entry !== null)
const window = prevWindow.filter((p) => now - p.ts <= 5 * 60 * 1000)
const usage =
ensureFiniteNumber(metrics?.["cpuUsagePercent"]) ?? ensureFiniteNumber(metrics?.["cpu_usage_percent"])
if (usage !== null) {
window.push({ ts: now, usage })
}
if (window.length > 0) {
const avg = window.reduce((acc, p) => acc + p.usage, 0) / window.length
if (avg >= 90) {
findings.push({ kind: "CPU_HIGH", message: `CPU média ${avg.toFixed(0)}% em 5 min`, severity: "warning" })
}
}
const inventory = ensureRecord(args.inventory) ?? ensureRecord(metadataPatch?.["inventory"])
if (inventory) {
const services = ensureRecordArray(inventory["services"])
if (services.length > 0) {
const criticalList = (process.env["MACHINE_CRITICAL_SERVICES"] ?? "")
.split(/[\s,]+/)
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
const criticalSet = new Set(criticalList)
const firstDown = services.find((service) => {
const status = ensureString(service["status"]) ?? ensureString(service["Status"]) ?? ""
const name = ensureString(service["name"]) ?? ensureString(service["Name"]) ?? ""
return Boolean(name) && status.toLowerCase() !== "running"
})
if (firstDown) {
const name = ensureString(firstDown["name"]) ?? ensureString(firstDown["Name"]) ?? "serviço"
const sev: "warning" | "critical" = criticalSet.has(name.toLowerCase()) ? "critical" : "warning"
findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${name}`, severity: sev })
}
}
const smartEntries = getNestedRecordArray(inventory, "extended", "linux", "smart")
if (smartEntries.length > 0) {
const firstFail = smartEntries.find((disk) => {
const status = ensureString(disk["smart_status"]) ?? ensureString(disk["status"]) ?? ""
return status.toLowerCase() !== "ok"
})
if (firstFail) {
const model =
ensureString(firstFail["model_name"]) ??
ensureString(firstFail["model_family"]) ??
ensureString(firstFail["model"]) ??
"Disco"
const deviceRecord = getNestedRecord(firstFail, "device")
const serial =
ensureString(firstFail["serial_number"]) ??
ensureString(deviceRecord?.["name"]) ??
"—"
const temperatureRecord = getNestedRecord(firstFail, "temperature")
const temp =
ensureFiniteNumber(temperatureRecord?.["current"]) ??
ensureFiniteNumber(temperatureRecord?.["value"])
const details = temp !== null ? `${model} (${serial}) · ${temp}ºC` : `${model} (${serial})`
findings.push({ kind: "SMART_FAIL", message: `SMART em falha: ${details}`, severity: "critical" })
}
}
}
// Persistir janela de CPU (limite de 120 amostras)
const cpuWindowCapped = window.slice(-120)
await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, { cpuWindow: cpuWindowCapped }) })
if (!findings.length) return
const record = {
postureAlerts: findings,
lastPostureAt: now,
}
const prevMeta = ensureRecord(machine.metadata)
const lastAtPrev = ensureFiniteNumber(prevMeta?.["lastPostureAt"]) ?? 0
await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now })
await Promise.all(
findings.map((finding) =>
ctx.db.insert("machineAlerts", {
tenantId: machine.tenantId,
machineId: machine._id,
companyId: machine.companyId ?? undefined,
kind: finding.kind,
message: finding.message,
severity: finding.severity,
createdAt: now,
})
)
)
if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "false").toLowerCase() !== "true") return
if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return
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)
}
export const register = mutation({
args: {
provisioningCode: v.string(),
hostname: v.string(),
os: v.object({
name: v.string(),
version: v.optional(v.string()),
architecture: v.optional(v.string()),
}),
macAddresses: v.array(v.string()),
serialNumbers: v.array(v.string()),
metadata: v.optional(v.any()),
registeredBy: v.optional(v.string()),
},
handler: async (ctx, args) => {
const normalizedCode = args.provisioningCode.trim().toLowerCase()
const companyRecord = await ctx.db
.query("companies")
.withIndex("by_provisioning_code", (q) => q.eq("provisioningCode", normalizedCode))
.unique()
if (!companyRecord) {
throw new ConvexError("Código de provisionamento inválido")
}
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
const companyId = companyRecord._id
const companySlug = companyRecord.slug
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers)
const now = Date.now()
const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record<string, unknown>) : undefined
// Busca 1: fingerprint exato (tenant + slug + hostname + MACs hash)
let existing = await ctx.db
.query("machines")
.withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint))
.first()
// Busca 2: por email + validacao de hardware (fallback se fingerprint mudou mas email igual)
if (!existing) {
const collaboratorEmail = extractCollaboratorEmail(metadataPatch ?? args.metadata)
if (collaboratorEmail) {
const candidate = await ctx.db
.query("machines")
.withIndex("by_tenant_assigned_email", (q) => q.eq("tenantId", tenantId).eq("assignedUserEmail", collaboratorEmail))
.first()
if (candidate && matchesExistingHardware(candidate, identifiers, args.hostname)) {
existing = candidate
}
}
}
// Busca 3: por hostname + validacao de hardware (fallback se o usuario mudou mas e a mesma maquina fisica)
// Isso garante que o historico de tickets da maquina seja preservado independente do usuario
if (!existing) {
const hostnameLower = args.hostname.trim().toLowerCase()
const candidates = await ctx.db
.query("machines")
.withIndex("by_tenant_hostname", (q) => q.eq("tenantId", tenantId).eq("hostname", args.hostname))
.take(200)
// Procura uma maquina com hostname igual E hardware compativel (MAC ou serial)
for (const candidate of candidates) {
if (matchesExistingHardware(candidate, identifiers, args.hostname)) {
existing = candidate
break
}
}
// Se nao encontrou por hostname exato, tenta busca mais ampla por hardware
if (!existing) {
// Busca maquinas do mesmo tenant (limitado a 500 para evitar OOM)
// e verifica se alguma tem MAC/serial compativel
const allMachines = await ctx.db
.query("machines")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(500)
for (const candidate of allMachines) {
// Verifica se compartilha MAC ou serial (hardware fisico)
const sharedMac = candidate.macAddresses.some((mac) => identifiers.macs.includes(mac))
const sharedSerial = candidate.serialNumbers.some((serial) => identifiers.serials.includes(serial))
if (sharedMac || sharedSerial) {
existing = candidate
break
}
}
}
}
let machineId: Id<"machines">
if (existing) {
await ctx.db.patch(existing._id, {
tenantId,
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,
macAddresses: identifiers.macs,
serialNumbers: identifiers.serials,
fingerprint,
metadata: metadataPatch ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata,
lastHeartbeatAt: now,
updatedAt: now,
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,
assignedUserName: existing.assignedUserName,
assignedUserRole: existing.assignedUserRole,
})
machineId = existing._id
} else {
machineId = await ctx.db.insert("machines", {
tenantId,
companyId,
companySlug,
hostname: args.hostname,
displayName: args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
macAddresses: identifiers.macs,
serialNumbers: identifiers.serials,
fingerprint,
metadata: metadataPatch ? mergeMetadata(undefined, metadataPatch) : undefined,
lastHeartbeatAt: now,
status: "online",
isActive: true,
createdAt: now,
updatedAt: now,
registeredBy: args.registeredBy,
deviceType: "desktop",
devicePlatform: args.os.name,
managementMode: "agent",
persona: undefined,
assignedUserId: undefined,
assignedUserEmail: undefined,
assignedUserName: undefined,
assignedUserRole: undefined,
})
}
const previousTokens = await ctx.db
.query("machineTokens")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.take(100)
for (const token of previousTokens) {
if (!token.revoked) {
await ctx.db.patch(token._id, {
revoked: true,
revokedAt: now,
lastUsedAt: now,
expiresAt: now,
})
}
}
const tokenPlain = toHex(randomBytes(32))
const tokenHash = hashToken(tokenPlain)
const expiresAt = now + getTokenTtlMs()
await ctx.db.insert("machineTokens", {
tenantId,
machineId,
tokenHash,
expiresAt,
revoked: false,
createdAt: now,
usageCount: 0,
type: "machine",
})
return {
machineId,
tenantId,
companyId,
companySlug,
machineToken: tokenPlain,
expiresAt,
}
},
})
export const upsertInventory = mutation({
args: {
provisioningCode: v.string(),
hostname: v.string(),
os: v.object({
name: v.string(),
version: v.optional(v.string()),
architecture: v.optional(v.string()),
}),
macAddresses: v.array(v.string()),
serialNumbers: v.array(v.string()),
inventory: v.optional(v.any()),
metrics: v.optional(v.any()),
registeredBy: v.optional(v.string()),
},
handler: async (ctx, args) => {
const normalizedCode = args.provisioningCode.trim().toLowerCase()
const companyRecord = await ctx.db
.query("companies")
.withIndex("by_provisioning_code", (q) => q.eq("provisioningCode", normalizedCode))
.unique()
if (!companyRecord) {
throw new ConvexError("Código de provisionamento inválido")
}
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
const companyId = companyRecord._id
const companySlug = companyRecord.slug
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers)
const now = Date.now()
const metadataPatch: Record<string, unknown> = {}
if (args.inventory && typeof args.inventory === "object") {
metadataPatch.inventory = args.inventory as Record<string, unknown>
}
if (args.metrics && typeof args.metrics === "object") {
metadataPatch.metrics = args.metrics as Record<string, unknown>
}
const existing = await ctx.db
.query("machines")
.withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint))
.first()
let machineId: Id<"machines">
if (existing) {
await ctx.db.patch(existing._id, {
tenantId,
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,
macAddresses: identifiers.macs,
serialNumbers: identifiers.serials,
metadata: Object.keys(metadataPatch).length ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata,
lastHeartbeatAt: now,
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,
assignedUserName: existing.assignedUserName,
assignedUserRole: existing.assignedUserRole,
})
machineId = existing._id
} else {
machineId = await ctx.db.insert("machines", {
tenantId,
companyId,
companySlug,
hostname: args.hostname,
displayName: args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
macAddresses: identifiers.macs,
serialNumbers: identifiers.serials,
fingerprint,
metadata: Object.keys(metadataPatch).length ? mergeMetadata(undefined, metadataPatch) : undefined,
lastHeartbeatAt: now,
status: args.metrics ? "online" : "unknown",
createdAt: now,
updatedAt: now,
registeredBy: args.registeredBy,
deviceType: "desktop",
devicePlatform: args.os.name,
managementMode: "agent",
persona: undefined,
assignedUserId: undefined,
assignedUserEmail: undefined,
assignedUserName: undefined,
assignedUserRole: undefined,
})
}
// Evaluate posture/alerts based on provided metrics/inventory
const machine = (await ctx.db.get(machineId)) as Doc<"machines">
await evaluatePostureAndMaybeRaise(ctx, machine, { metrics: args.metrics, inventory: args.inventory })
return {
machineId,
tenantId,
companyId,
companySlug,
status: args.metrics ? "online" : "unknown",
}
},
})
export const heartbeat = mutation({
args: {
machineToken: v.string(),
status: v.optional(v.string()),
hostname: v.optional(v.string()),
os: v.optional(
v.object({
name: v.string(),
version: v.optional(v.string()),
architecture: v.optional(v.string()),
})
),
metrics: v.optional(v.record(v.string(), v.any())),
inventory: v.optional(v.record(v.string(), v.any())),
metadata: v.optional(v.record(v.string(), v.any())),
},
handler: async (ctx, args) => {
const { machine, token } = await getActiveToken(ctx, args.machineToken)
const now = Date.now()
// 1. SEMPRE atualizar machineHeartbeats (documento pequeno, upsert)
// Isso evita criar versoes do documento machines a cada heartbeat
const existingHeartbeat = await ctx.db
.query("machineHeartbeats")
.withIndex("by_machine", (q) => q.eq("machineId", machine._id))
.first()
if (existingHeartbeat) {
await ctx.db.patch(existingHeartbeat._id, { lastHeartbeatAt: now })
} else {
await ctx.db.insert("machineHeartbeats", { machineId: machine._id, lastHeartbeatAt: now })
}
// 2. Preparar patch de metadata (se houver mudancas REAIS)
// IMPORTANTE: So incluimos no patch se os dados realmente mudaram
// Isso evita criar versoes desnecessarias do documento machines
const metadataPatch: JsonRecord = {}
const currentMetadata = ensureRecord(machine.metadata) ?? {}
const incomingMeta = ensureRecord(args.metadata)
const remoteAccessSnapshot = incomingMeta ? ensureRecord(incomingMeta["remoteAccessSnapshot"]) : null
if (incomingMeta) {
// Filtrar apenas campos que realmente mudaram
for (const [key, value] of Object.entries(incomingMeta)) {
if (key === "inventory" || key === "metrics" || key === "remoteAccessSnapshot" || key === "inventoryHash" || key === "metricsHash") {
continue
}
const currentValue = currentMetadata[key] as JsonValue | undefined
if (!areJsonValuesEqual(value as JsonValue, currentValue)) {
metadataPatch[key] = value as JsonValue
}
}
}
const sanitizedInventory = sanitizeInventoryPayload(args.inventory)
const currentInventory = ensureRecord(currentMetadata.inventory)
const incomingInventoryHash = hashJson(sanitizedInventory)
const currentInventoryHash = typeof currentMetadata["inventoryHash"] === "string" ? currentMetadata["inventoryHash"] : null
if (sanitizedInventory && incomingInventoryHash && incomingInventoryHash !== currentInventoryHash) {
metadataPatch.inventory = mergeInventory(currentInventory, sanitizedInventory)
metadataPatch.inventoryHash = incomingInventoryHash
}
const sanitizedMetrics = sanitizeMetricsPayload(args.metrics)
const currentMetrics = ensureRecord(currentMetadata.metrics)
const incomingMetricsHash = hashJson(sanitizedMetrics)
const currentMetricsHash = typeof currentMetadata["metricsHash"] === "string" ? currentMetadata["metricsHash"] : null
if (sanitizedMetrics && incomingMetricsHash && incomingMetricsHash !== currentMetricsHash) {
metadataPatch.metrics = sanitizedMetrics
metadataPatch.metricsHash = incomingMetricsHash
}
// 3. Verificar se ha mudancas reais nos dados que justifiquem atualizar o documento machines
const hasMetadataChanges = Object.keys(metadataPatch).length > 0
const hasHostnameChange = args.hostname && args.hostname !== machine.hostname
const hasOsChange = args.os && (
args.os.name !== machine.osName ||
args.os.version !== machine.osVersion ||
args.os.architecture !== machine.architecture
)
const hasStatusChange = typeof args.status === "string" && args.status !== machine.status
const needsMachineUpdate = hasMetadataChanges || hasHostnameChange || hasOsChange || hasStatusChange
// 4. So atualizar machines se houver mudancas reais (evita criar versoes desnecessarias)
// NOTA: lastHeartbeatAt agora vive na tabela machineHeartbeats, nao atualizamos mais aqui
if (needsMachineUpdate) {
const mergedMetadata = hasMetadataChanges ? mergeMetadata(machine.metadata, metadataPatch) : machine.metadata
const nextStatus = args.status ?? machine.status ?? (sanitizedMetrics ? "online" : "unknown")
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",
updatedAt: now,
status: nextStatus,
metadata: mergedMetadata,
})
}
if (remoteAccessSnapshot) {
await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now)
}
// Processar softwares instalados (armazenados em tabela separada)
// Os dados de software sao extraidos ANTES de sanitizar o inventory
const rawInventory = args.inventory ?? args.metadata?.inventory
if (rawInventory && typeof rawInventory === "object") {
const softwareArray = (rawInventory as Record<string, unknown>)["software"]
if (Array.isArray(softwareArray) && softwareArray.length > 0) {
const validSoftware = softwareArray
.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object")
.map((item) => ({
name: typeof item.name === "string" ? item.name : "",
version: typeof item.version === "string" ? item.version : undefined,
publisher: typeof item.publisher === "string" || typeof item.source === "string"
? (item.publisher as string) || (item.source as string)
: undefined,
source: typeof item.source === "string" ? item.source : undefined,
}))
.filter((item) => item.name.length > 0)
if (validSoftware.length > 0) {
await ctx.runMutation(internal.machineSoftware.syncFromHeartbeat, {
tenantId: machine.tenantId,
machineId: machine._id,
software: validSoftware,
})
}
}
}
await ctx.db.patch(token._id, {
lastUsedAt: now,
usageCount: (token.usageCount ?? 0) + 1,
expiresAt: now + getTokenTtlMs(),
})
// Evaluate posture/alerts & optionally create ticket
const fresh = needsMachineUpdate ? (await ctx.db.get(machine._id)) as Doc<"machines"> : machine
await evaluatePostureAndMaybeRaise(ctx, fresh, {
metrics: sanitizedMetrics ?? null,
inventory: sanitizedInventory ?? null,
metadata: incomingMeta ?? null,
})
return {
ok: true,
machineId: machine._id,
expiresAt: now + getTokenTtlMs(),
}
},
})
export const resolveToken = mutation({
args: {
machineToken: v.string(),
},
handler: async (ctx, args) => {
const { machine, token } = await getActiveToken(ctx, args.machineToken)
const now = Date.now()
await ctx.db.patch(token._id, {
lastUsedAt: now,
usageCount: (token.usageCount ?? 0) + 1,
})
return {
machine: {
_id: machine._id,
tenantId: machine.tenantId,
companyId: machine.companyId,
companySlug: machine.companySlug,
hostname: machine.hostname,
osName: machine.osName,
osVersion: machine.osVersion,
architecture: machine.architecture,
authUserId: machine.authUserId,
authEmail: machine.authEmail,
persona: machine.persona ?? null,
assignedUserId: machine.assignedUserId ?? null,
assignedUserEmail: machine.assignedUserEmail ?? null,
assignedUserName: machine.assignedUserName ?? null,
assignedUserRole: machine.assignedUserRole ?? null,
linkedUserIds: machine.linkedUserIds ?? [],
status: machine.status,
lastHeartbeatAt: machine.lastHeartbeatAt,
metadata: machine.metadata,
isActive: machine.isActive ?? true,
},
token: {
expiresAt: token.expiresAt,
lastUsedAt: token.lastUsedAt ?? null,
usageCount: token.usageCount ?? 0,
},
}
},
})
export const listByTenant = query({
args: {
tenantId: v.optional(v.string()),
includeMetadata: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
const includeMetadata = Boolean(args.includeMetadata)
const now = Date.now()
const tenantCompanies = await ctx.db
.query("companies")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(200)
const companyById = new Map<string, typeof tenantCompanies[number]>()
const companyBySlug = new Map<string, typeof tenantCompanies[number]>()
for (const company of tenantCompanies) {
companyById.set(company._id, company)
if (company.slug) {
companyBySlug.set(company.slug, company)
}
}
// Limita a 500 maquinas para evitar OOM
const machines = await ctx.db
.query("machines")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(500)
return Promise.all(
machines.map(async (machine) => {
const activeToken = await findActiveMachineToken(ctx, machine._id, now)
// Busca heartbeat da tabela separada (fonte de verdade), fallback para legado
const lastHeartbeatAt = await getMachineLastHeartbeat(ctx, machine._id, machine.lastHeartbeatAt)
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 (lastHeartbeatAt) {
const age = now - lastHeartbeatAt
if (age <= offlineThresholdMs) {
derivedStatus = "online"
} else if (age <= staleThresholdMs) {
derivedStatus = "offline"
} else {
derivedStatus = "stale"
}
} else {
derivedStatus = machine.status ?? "unknown"
}
const metadata = 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 (metadata && typeof metadata === "object") {
const metaRecord = metadata 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
}
}
// linked users summary
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 }>)
// ticket count (limitado a 100 para performance)
const ticketCount = await ctx.db
.query("tickets")
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", tenantId).eq("machineId", machine._id))
.take(100)
.then((tickets) => tickets.length)
const companyFromId = machine.companyId ? companyById.get(machine.companyId) ?? null : null
const companyFromSlug = machine.companySlug ? companyBySlug.get(machine.companySlug) ?? null : null
const resolvedCompany = companyFromId ?? companyFromSlug
return {
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,
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: lastHeartbeatAt,
heartbeatAgeMs: lastHeartbeatAt ? now - 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,
customFields: machine.customFields ?? [],
usbPolicy: machine.usbPolicy ?? null,
usbPolicyStatus: machine.usbPolicyStatus ?? null,
usbPolicyError: machine.usbPolicyError ?? null,
ticketCount,
}
})
)
},
})
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)
// Busca heartbeat da tabela separada (fonte de verdade), fallback para legado
const lastHeartbeatAt = await getMachineLastHeartbeat(ctx, machine._id, machine.lastHeartbeatAt)
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 (lastHeartbeatAt) {
const age = now - 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,
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,
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: lastHeartbeatAt,
heartbeatAgeMs: lastHeartbeatAt ? now - 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,
customFields: machine.customFields ?? [],
usbPolicy: machine.usbPolicy ?? null,
usbPolicyStatus: machine.usbPolicyStatus ?? null,
usbPolicyError: machine.usbPolicyError ?? null,
}
}
export const getById = query({
args: {
id: v.id("machines"),
includeMetadata: v.optional(v.boolean()),
},
handler: getByIdHandler,
})
export const listAlerts = query({
args: {
machineId: v.optional(v.id("machines")),
deviceId: v.optional(v.id("machines")),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const machineId = args.machineId ?? args.deviceId
if (!machineId) {
throw new ConvexError("Identificador do dispositivo não informado")
}
const limit = Math.max(1, Math.min(args.limit ?? 50, 200))
const alerts = await ctx.db
.query("machineAlerts")
.withIndex("by_machine_created", (q) => q.eq("machineId", machineId))
.order("desc")
.take(limit)
return alerts.map((alert) => ({
id: alert._id,
machineId: alert.machineId,
tenantId: alert.tenantId,
companyId: alert.companyId ?? null,
kind: alert.kind,
message: alert.message,
severity: alert.severity,
createdAt: alert.createdAt,
}))
},
})
export const listOpenTickets = query({
args: {
machineId: v.optional(v.id("machines")),
deviceId: v.optional(v.id("machines")),
limit: v.optional(v.number()),
},
handler: async (ctx, { machineId: providedMachineId, deviceId, limit }) => {
const machineId = providedMachineId ?? deviceId
if (!machineId) {
throw new ConvexError("Identificador do dispositivo não informado")
}
const machine = await ctx.db.get(machineId)
if (!machine) {
return { totalOpen: 0, hasMore: false, tickets: [] }
}
const takeLimit = Math.max(1, Math.min(limit ?? 10, 50))
const candidates = await ctx.db
.query("tickets")
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", machineId))
.order("desc")
.take(200)
const openTickets = candidates
.filter((ticket) => normalizeStatus(ticket.status) !== "RESOLVED")
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))
const totalOpen = openTickets.length
const limited = openTickets.slice(0, takeLimit)
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
requesterEmail: string | null
from: number | null
to: number | null
}
type ListTicketsHistoryArgs = {
machineId: Id<"machines">
status?: "all" | "open" | "resolved"
priority?: string
requesterEmail?: string
search?: string
from?: number
to?: number
paginationOpts: Infer<typeof paginationOptsValidator>
}
type GetTicketsHistoryStatsArgs = {
machineId: Id<"machines">
status?: "all" | "open" | "resolved"
priority?: string
requesterEmail?: 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 matchesRequesterEmail(ticket: Doc<"tickets">, requesterEmail: string | null): boolean {
if (!requesterEmail) return true
const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined
if (!requesterSnapshot?.email) return false
return requesterSnapshot.email.toLowerCase() === requesterEmail.toLowerCase()
}
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 normalizedRequesterEmail = args.requesterEmail?.trim().toLowerCase() ?? 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,
requesterEmail: normalizedRequesterEmail,
from,
to,
}
const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate(args.paginationOpts)
let page = pageResult.page
if (normalizedRequesterEmail) {
page = page.filter((ticket) => matchesRequesterEmail(ticket, normalizedRequesterEmail))
}
if (searchTerm) {
page = page.filter((ticket) => matchesTicketSearch(ticket, searchTerm))
}
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()),
requesterEmail: 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 normalizedRequesterEmail = args.requesterEmail?.trim().toLowerCase() ?? 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,
requesterEmail: normalizedRequesterEmail,
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,
})
let page = pageResult.page
if (normalizedRequesterEmail) {
page = page.filter((ticket) => matchesRequesterEmail(ticket, normalizedRequesterEmail))
}
if (searchTerm) {
page = page.filter((ticket) => matchesTicketSearch(ticket, searchTerm))
}
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()),
requesterEmail: v.optional(v.string()),
search: v.optional(v.string()),
from: v.optional(v.number()),
to: v.optional(v.number()),
},
handler: getTicketsHistoryStatsHandler,
})
// Lista os solicitantes unicos que abriram tickets nesta maquina
export const listMachineRequesters = query({
args: {
machineId: v.id("machines"),
},
handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
return []
}
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", args.machineId))
.take(200)
const requestersMap = new Map<string, { email: string; name: string | null }>()
for (const ticket of tickets) {
const snapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined
if (snapshot?.email) {
const emailLower = snapshot.email.toLowerCase()
if (!requestersMap.has(emailLower)) {
requestersMap.set(emailLower, {
email: snapshot.email,
name: snapshot.name ?? null,
})
}
}
}
return Array.from(requestersMap.values()).sort((a, b) => {
const nameA = a.name ?? a.email
const nameB = b.name ?? b.email
return nameA.localeCompare(nameB)
})
},
})
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("Dispositivo 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 dispositivo")
} 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 dispositivo")
}
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 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)
await ensureMobileDeviceFields(ctx, 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"),
persona: v.optional(v.string()),
assignedUserId: v.optional(v.id("users")),
assignedUserEmail: v.optional(v.string()),
assignedUserName: v.optional(v.string()),
assignedUserRole: v.optional(v.string()),
},
handler: updatePersonaHandler,
})
export const getContext = query({
args: {
machineId: v.id("machines"),
},
handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Dispositivo não encontrada")
}
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,
companyId: machine.companyId ?? null,
companySlug: machine.companySlug ?? null,
persona: machine.persona ?? null,
assignedUserId: machine.assignedUserId ?? null,
assignedUserEmail: machine.assignedUserEmail ?? null,
assignedUserName: machine.assignedUserName ?? null,
assignedUserRole: machine.assignedUserRole ?? null,
metadata: machine.metadata ?? null,
authEmail: machine.authEmail ?? null,
isActive: machine.isActive ?? true,
linkedUsers,
}
},
})
export const findByAuthEmail = query({
args: {
authEmail: v.string(),
},
handler: async (ctx, args) => {
const normalizedEmail = args.authEmail.trim().toLowerCase()
const machine = await ctx.db
.query("machines")
.withIndex("by_auth_email", (q) => q.eq("authEmail", normalizedEmail))
.first()
if (!machine) {
return null
}
return {
id: machine._id,
}
},
})
export const linkAuthAccount = mutation({
args: {
machineId: v.id("machines"),
authUserId: v.string(),
authEmail: v.string(),
},
handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Dispositivo não encontrada")
}
await ctx.db.patch(machine._id, {
authUserId: args.authUserId,
authEmail: args.authEmail,
updatedAt: Date.now(),
})
return { ok: true }
},
})
export const linkUser = mutation({
args: {
machineId: v.id("machines"),
email: v.string(),
},
handler: async (ctx, { machineId, email }) => {
const machine = await ctx.db.get(machineId)
if (!machine) throw new ConvexError("Dispositivo não encontrada")
const tenantId = machine.tenantId
const normalized = email.trim().toLowerCase()
const user = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalized))
.first()
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 dispositivos')
}
const current = new Set<Id<"users">>(machine.linkedUserIds ?? [])
current.add(user._id)
await ctx.db.patch(machine._id, { linkedUserIds: Array.from(current), updatedAt: Date.now() })
return { ok: true }
},
})
export const unlinkUser = mutation({
args: {
machineId: v.id("machines"),
userId: v.id("users"),
},
handler: async (ctx, { machineId, userId }) => {
const machine = await ctx.db.get(machineId)
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 }
},
})
export const rename = mutation({
args: {
machineId: v.id("machines"),
actorId: v.id("users"),
hostname: v.string(),
},
handler: async (ctx, { machineId, actorId, hostname }) => {
// Reutiliza requireStaff através de tickets.ts helpers
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Dispositivo não encontrada")
}
// 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 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 dispositivos")
}
const nextName = hostname.trim()
if (nextName.length < 2) {
throw new ConvexError("Informe um nome válido para a dispositivo")
}
await ctx.db.patch(machineId, {
hostname: nextName,
displayName: nextName,
updatedAt: Date.now(),
})
return { ok: true }
},
})
export const toggleActive = mutation({
args: {
machineId: v.id("machines"),
actorId: v.id("users"),
active: v.boolean(),
},
handler: async (ctx, { machineId, actorId, active }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
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 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 dispositivo")
}
await ctx.db.patch(machineId, {
isActive: active,
updatedAt: Date.now(),
})
return { ok: true }
},
})
export const resetAgent = mutation({
args: {
machineId: v.id("machines"),
actorId: v.id("users"),
},
handler: async (ctx, { machineId, actorId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
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 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 dispositivo")
}
const tokens = await ctx.db
.query("machineTokens")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.take(100)
const now = Date.now()
let revokedCount = 0
for (const token of tokens) {
if (!token.revoked) {
await ctx.db.patch(token._id, {
revoked: true,
revokedAt: now,
expiresAt: now,
})
revokedCount += 1
}
}
await ctx.db.patch(machineId, {
status: "unknown",
updatedAt: now,
})
return { machineId, revoked: revokedCount }
},
})
/**
* Query para o desktop monitorar o estado da máquina em tempo real.
* O desktop faz subscribe nessa query e reage imediatamente quando:
* - isActive muda para false (desativação)
* - hasValidToken muda para false (reset/revogação de tokens)
*/
export const getMachineState = query({
args: {
machineId: v.id("machines"),
},
handler: async (ctx, { machineId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
return { found: false, isActive: false, hasValidToken: false, status: "unknown" as const }
}
// Verifica se existe algum token válido (não revogado e não expirado)
const now = Date.now()
const tokens = await ctx.db
.query("machineTokens")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.take(10)
const hasValidToken = tokens.some((token) => {
if (token.revoked) return false
if (token.expiresAt && token.expiresAt < now) return false
return true
})
return {
found: true,
isActive: machine.isActive ?? true,
hasValidToken,
status: machine.status ?? "unknown",
}
},
})
type RemoteAccessEntry = {
id: string
provider: string
identifier: string
url: string | null
username: string | null
password: string | null
notes: string | null
lastVerifiedAt: number | null
metadata: Record<string, unknown> | null
}
function createRemoteAccessId() {
return `ra_${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36)}`
}
function coerceString(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
return null
}
function coerceNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value
}
if (typeof value === "string") {
const trimmed = value.trim()
if (!trimmed) return null
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function normalizeRemoteAccessEntry(raw: unknown): RemoteAccessEntry | null {
if (!raw) return null
if (typeof raw === "string") {
const trimmed = raw.trim()
if (!trimmed) return null
const isUrl = /^https?:\/\//i.test(trimmed)
return {
id: createRemoteAccessId(),
provider: "Remoto",
identifier: isUrl ? trimmed : trimmed,
url: isUrl ? trimmed : null,
username: null,
password: null,
notes: null,
lastVerifiedAt: null,
metadata: null,
}
}
if (typeof raw !== "object") return null
const record = raw as Record<string, unknown>
const provider =
coerceString(record.provider) ??
coerceString(record.tool) ??
coerceString(record.vendor) ??
coerceString(record.name) ??
"Remoto"
const identifier =
coerceString(record.identifier) ??
coerceString(record.code) ??
coerceString(record.id) ??
coerceString(record.accessId)
const url =
coerceString(record.url) ??
coerceString(record.link) ??
coerceString(record.remoteUrl) ??
coerceString(record.console) ??
coerceString(record.viewer) ??
null
const resolvedIdentifier = identifier ?? url ?? "Acesso remoto"
const notes = coerceString(record.notes) ?? coerceString(record.note) ?? coerceString(record.description) ?? coerceString(record.obs) ?? null
const username =
coerceString((record as Record<string, unknown>).username) ??
coerceString((record as Record<string, unknown>).user) ??
coerceString((record as Record<string, unknown>).login) ??
coerceString((record as Record<string, unknown>).email) ??
coerceString((record as Record<string, unknown>).account) ??
null
const password =
coerceString((record as Record<string, unknown>).password) ??
coerceString((record as Record<string, unknown>).pass) ??
coerceString((record as Record<string, unknown>).secret) ??
coerceString((record as Record<string, unknown>).pin) ??
null
const timestamp =
coerceNumber(record.lastVerifiedAt) ??
coerceNumber(record.verifiedAt) ??
coerceNumber(record.checkedAt) ??
coerceNumber(record.updatedAt) ??
null
const id = coerceString(record.id) ?? createRemoteAccessId()
const metadata =
record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata)
? (record.metadata as Record<string, unknown>)
: null
return {
id,
provider,
identifier: resolvedIdentifier,
url,
username,
password,
notes,
lastVerifiedAt: timestamp,
metadata,
}
}
function normalizeRemoteAccessList(raw: unknown): RemoteAccessEntry[] {
const source = Array.isArray(raw) ? raw : raw ? [raw] : []
const seen = new Set<string>()
const entries: RemoteAccessEntry[] = []
for (const item of source) {
const entry = normalizeRemoteAccessEntry(item)
if (!entry) continue
let nextId = entry.id
while (seen.has(nextId)) {
nextId = createRemoteAccessId()
}
seen.add(nextId)
entries.push(nextId === entry.id ? entry : { ...entry, id: nextId })
}
return entries
}
async function removeDuplicateRemoteAccessEntries(
ctx: MutationCtx,
tenantId: string,
currentMachineId: Id<"machines">,
provider: string,
identifier: string,
now: number
) {
// Limita a 500 maquinas para evitar OOM
const machines = await ctx.db
.query("machines")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.take(500)
const providerLc = provider.toLowerCase()
const identifierLc = identifier.toLowerCase()
for (const device of machines) {
if (device._id === currentMachineId) continue
const entries = normalizeRemoteAccessList(device.remoteAccess)
const filtered = entries.filter(
(entry) =>
entry.provider.toLowerCase() !== providerLc || entry.identifier.toLowerCase() !== identifierLc
)
if (filtered.length === entries.length) continue
await ctx.db.patch(device._id, {
remoteAccess: filtered.length > 0 ? filtered : null,
updatedAt: now,
})
}
}
async function upsertRemoteAccessSnapshotFromHeartbeat(
ctx: MutationCtx,
machine: Doc<"machines">,
snapshot: unknown,
timestamp: number
) {
const normalized = normalizeRemoteAccessEntry(snapshot)
if (!normalized) return
const provider = (normalized.provider ?? "Remote").trim()
const identifier = (normalized.identifier ?? "").trim()
if (!identifier) return
const existingEntries = normalizeRemoteAccessList(machine.remoteAccess)
// Busca primeiro por provider + identifier (atualização exata)
let idx = existingEntries.findIndex(
(entry) => entry.provider.toLowerCase() === provider.toLowerCase() && entry.identifier.toLowerCase() === identifier.toLowerCase()
)
// Se não encontrou, busca apenas por provider (substituição - ex: RustDesk ID mudou)
if (idx < 0) {
idx = existingEntries.findIndex(
(entry) => entry.provider.toLowerCase() === provider.toLowerCase()
)
}
const entryId = idx >= 0 ? existingEntries[idx].id : createRemoteAccessId()
const metadata = {
...(normalized.metadata ?? {}),
snapshotSource: "heartbeat",
provider,
identifier,
machineId: machine._id,
hostname: machine.hostname,
lastVerifiedAt: timestamp,
}
const updatedEntry: RemoteAccessEntry = {
id: entryId,
provider,
identifier,
url: normalized.url ?? null,
username: normalized.username ?? null,
password: normalized.password ?? null,
notes: normalized.notes ?? null,
lastVerifiedAt: timestamp,
metadata,
}
const nextEntries =
idx >= 0
? existingEntries.map((entry, index) => (index === idx ? updatedEntry : entry))
: [...existingEntries, updatedEntry]
await ctx.db.patch(machine._id, {
remoteAccess: nextEntries,
updatedAt: timestamp,
})
}
export const updateRemoteAccess = mutation({
args: {
machineId: v.id("machines"),
actorId: v.id("users"),
provider: v.optional(v.string()),
identifier: v.optional(v.string()),
url: v.optional(v.string()),
username: v.optional(v.string()),
password: v.optional(v.string()),
notes: v.optional(v.string()),
action: v.optional(v.string()),
entryId: v.optional(v.string()),
clear: v.optional(v.boolean()),
},
handler: async (ctx, { machineId, actorId, provider, identifier, url, username, password, notes, action, entryId, clear }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
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 dispositivo")
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
if (normalizedRole !== "ADMIN" && normalizedRole !== "AGENT") {
throw new ConvexError("Somente administradores e agentes podem ajustar o acesso remoto.")
}
const actionMode = (() => {
if (clear) return "clear" as const
const normalized = (action ?? "").toLowerCase()
if (normalized === "clear") return "clear" as const
if (normalized === "delete" || normalized === "remove") return "delete" as const
return "upsert" as const
})()
const existingEntries = normalizeRemoteAccessList(machine.remoteAccess)
if (actionMode === "clear") {
await ctx.db.patch(machineId, { remoteAccess: null, updatedAt: Date.now() })
return { remoteAccess: null }
}
if (actionMode === "delete") {
const trimmedEntryId = coerceString(entryId)
const trimmedProvider = coerceString(provider)
const trimmedIdentifier = coerceString(identifier)
let target: RemoteAccessEntry | undefined
if (trimmedEntryId) {
target = existingEntries.find((entry) => entry.id === trimmedEntryId)
}
if (!target && trimmedProvider && trimmedIdentifier) {
target = existingEntries.find(
(entry) => entry.provider === trimmedProvider && entry.identifier === trimmedIdentifier
)
}
if (!target && trimmedIdentifier) {
target = existingEntries.find((entry) => entry.identifier === trimmedIdentifier)
}
if (!target && trimmedProvider) {
target = existingEntries.find((entry) => entry.provider === trimmedProvider)
}
if (!target) {
throw new ConvexError("Entrada de acesso remoto não encontrada.")
}
const nextEntries = existingEntries.filter((entry) => entry.id !== target!.id)
const nextValue = nextEntries.length > 0 ? nextEntries : null
await ctx.db.patch(machineId, { remoteAccess: nextValue, updatedAt: Date.now() })
return { remoteAccess: nextValue }
}
const trimmedProvider = (provider ?? "").trim()
const trimmedIdentifier = (identifier ?? "").trim()
if (!trimmedProvider || !trimmedIdentifier) {
throw new ConvexError("Informe provedor e identificador do acesso remoto.")
}
let normalizedUrl: string | null = null
if (url) {
const trimmedUrl = url.trim()
if (trimmedUrl) {
if (!/^https?:\/\//i.test(trimmedUrl)) {
throw new ConvexError("Informe uma URL válida iniciando com http:// ou https://.")
}
try {
new URL(trimmedUrl)
} catch {
throw new ConvexError("Informe uma URL válida para o acesso remoto.")
}
normalizedUrl = trimmedUrl
}
}
const cleanedNotes = notes?.trim() ? notes.trim() : null
const cleanedUsername = username?.trim() ? username.trim() : null
const cleanedPassword = password?.trim() ? password.trim() : null
const lastVerifiedAt = Date.now()
const targetEntryId =
coerceString(entryId) ??
existingEntries.find(
(entry) => entry.provider === trimmedProvider && entry.identifier === trimmedIdentifier
)?.id ??
createRemoteAccessId()
const updatedEntry: RemoteAccessEntry = {
id: targetEntryId,
provider: trimmedProvider,
identifier: trimmedIdentifier,
url: normalizedUrl,
username: cleanedUsername,
password: cleanedPassword,
notes: cleanedNotes,
lastVerifiedAt,
metadata: {
provider: trimmedProvider,
identifier: trimmedIdentifier,
url: normalizedUrl,
username: cleanedUsername,
password: cleanedPassword,
notes: cleanedNotes,
lastVerifiedAt,
},
}
const existingIndex = existingEntries.findIndex((entry) => entry.id === targetEntryId)
let nextEntries: RemoteAccessEntry[]
if (existingIndex >= 0) {
nextEntries = [...existingEntries]
nextEntries[existingIndex] = updatedEntry
} else {
nextEntries = [...existingEntries, updatedEntry]
}
await ctx.db.patch(machineId, {
remoteAccess: nextEntries,
updatedAt: Date.now(),
})
return { remoteAccess: nextEntries }
},
})
export const upsertRemoteAccessViaToken = mutation({
args: {
machineToken: v.string(),
provider: v.string(),
identifier: v.string(),
url: v.optional(v.string()),
username: v.optional(v.string()),
password: v.optional(v.string()),
notes: v.optional(v.string()),
},
handler: async (ctx, args) => {
const { machine, mode } = await getTokenWithGrace(ctx, args.machineToken, {
allowGraceMs: REMOTE_ACCESS_TOKEN_GRACE_MS,
})
if (mode === "grace") {
console.warn("[remote-access] Token revogado aceito dentro da janela de graça", {
machineId: machine._id,
})
}
const trimmedProvider = args.provider.trim()
const trimmedIdentifier = args.identifier.trim()
if (!trimmedProvider || !trimmedIdentifier) {
throw new ConvexError("Informe provedor e identificador do acesso remoto.")
}
let normalizedUrl: string | null = null
if (args.url) {
const trimmedUrl = args.url.trim()
if (trimmedUrl) {
const isValidScheme = /^https?:\/\//i.test(trimmedUrl) || /^rustdesk:\/\//i.test(trimmedUrl)
if (!isValidScheme) {
throw new ConvexError("Informe uma URL iniciando com http://, https:// ou rustdesk://.")
}
try {
new URL(trimmedUrl.replace(/^rustdesk:\/\//i, "https://"))
} catch {
throw new ConvexError("Informe uma URL válida para o acesso remoto.")
}
normalizedUrl = trimmedUrl
}
}
const cleanedUsername = args.username?.trim() ? args.username.trim() : null
const cleanedPassword = args.password?.trim() ? args.password.trim() : null
const cleanedNotes = args.notes?.trim() ? args.notes.trim() : null
const timestamp = Date.now()
const existingEntries = normalizeRemoteAccessList(machine.remoteAccess)
let existingIndex = existingEntries.findIndex(
(entry) =>
entry.provider.toLowerCase() === trimmedProvider.toLowerCase() &&
entry.identifier.toLowerCase() === trimmedIdentifier.toLowerCase()
)
// Se o identificador mudar (ex.: RustDesk gerando novo ID), reaproveitamos a entrada do mesmo provider
if (existingIndex < 0) {
existingIndex = existingEntries.findIndex(
(entry) => entry.provider.toLowerCase() === trimmedProvider.toLowerCase()
)
}
const entryId = existingIndex >= 0 ? existingEntries[existingIndex].id : createRemoteAccessId()
const updatedEntry: RemoteAccessEntry = {
id: entryId,
provider: trimmedProvider,
identifier: trimmedIdentifier,
url: normalizedUrl,
username: cleanedUsername,
password: cleanedPassword,
notes: cleanedNotes,
lastVerifiedAt: timestamp,
metadata: {
source: "machine-token",
provider: trimmedProvider,
identifier: trimmedIdentifier,
url: normalizedUrl,
username: cleanedUsername,
password: cleanedPassword,
notes: cleanedNotes,
lastVerifiedAt: timestamp,
machineId: machine._id,
hostname: machine.hostname,
tenantId: machine.tenantId,
},
}
const nextEntries =
existingIndex >= 0
? existingEntries.map((entry, index) => (index === existingIndex ? updatedEntry : entry))
: [...existingEntries, updatedEntry]
await removeDuplicateRemoteAccessEntries(ctx, machine.tenantId, machine._id, trimmedProvider, trimmedIdentifier, timestamp)
await ctx.db.patch(machine._id, {
remoteAccess: nextEntries,
updatedAt: timestamp,
})
return { remoteAccess: nextEntries }
},
})
export const remove = mutation({
args: {
machineId: v.id("machines"),
actorId: v.id("users"),
},
handler: async (ctx, { machineId, actorId }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
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 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 dispositivos")
}
const tokens = await ctx.db
.query("machineTokens")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.take(100)
await Promise.all(tokens.map((token) => ctx.db.delete(token._id)))
await ctx.db.delete(machineId)
return { ok: true }
},
})