// ci: trigger convex functions deploy (no-op) import { mutation, query } from "./_generated/server" import { api } from "./_generated/api" import { paginationOptsValidator } from "convex/server" import { ConvexError, v, Infer } from "convex/values" import { sha256 } from "@noble/hashes/sha256" import { randomBytes } from "@noble/hashes/utils" import type { Doc, Id } from "./_generated/dataModel" 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), ) .collect() return tokens.length > 0 ? tokens[0]! : null } function toHex(input: Uint8Array) { return Array.from(input) .map((byte) => byte.toString(16).padStart(2, "0")) .join("") } 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(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 } 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 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(token)) } const REMOTE_ACCESS_TOKEN_GRACE_MS = 5 * 60 * 1000 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 { return Boolean(value) && typeof value === "object" && !Array.isArray(value) } function mergeInventory(current: unknown, patch: unknown): unknown { if (!isObject(patch)) { return patch } const base: Record = isObject(current) ? { ...(current as Record) } : {} for (const [key, value] of Object.entries(patch)) { if (value === undefined) continue if (isObject(value) && isObject(base[key])) { base[key] = mergeInventory(base[key], value) } else { base[key] = value } } return base } function mergeMetadata(current: unknown, patch: Record) { const base: Record = isObject(current) ? { ...(current as Record) } : {} for (const [key, value] of Object.entries(patch)) { if (value === undefined) continue if (key === "inventory") { base[key] = mergeInventory(base[key], value) } else if (isObject(value) && isObject(base[key])) { base[key] = mergeInventory(base[key], value) } else { base[key] = value } } return base } type JsonRecord = Record function ensureRecord(value: unknown): JsonRecord | null { return isObject(value) ? (value as JsonRecord) : null } function ensureRecordArray(value: unknown): JsonRecord[] { if (!Array.isArray(value)) return [] return value.filter(isObject) as JsonRecord[] } 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 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) : undefined let existing = await ctx.db .query("machines") .withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint)) .first() 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 } } } 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)) .collect() 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 = {} if (args.inventory && typeof args.inventory === "object") { metadataPatch.inventory = args.inventory as Record } if (args.metrics && typeof args.metrics === "object") { metadataPatch.metrics = args.metrics as Record } 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.any()), inventory: v.optional(v.any()), metadata: v.optional(v.any()), }, handler: async (ctx, args) => { const { machine, token } = await getActiveToken(ctx, args.machineToken) const now = Date.now() const metadataPatch: Record = {} if (args.metadata && typeof args.metadata === "object") { Object.assign(metadataPatch, args.metadata as Record) } if (args.inventory && typeof args.inventory === "object") { metadataPatch.inventory = mergeInventory(metadataPatch.inventory, args.inventory as Record) } if (args.metrics && typeof args.metrics === "object") { metadataPatch.metrics = args.metrics as Record } const mergedMetadata = Object.keys(metadataPatch).length ? mergeMetadata(machine.metadata, metadataPatch) : machine.metadata 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", metadata: mergedMetadata, }) await ctx.db.patch(token._id, { lastUsedAt: now, usageCount: (token.usageCount ?? 0) + 1, expiresAt: now + getTokenTtlMs(), }) // Evaluate posture/alerts & optionally create ticket const fresh = (await ctx.db.get(machine._id)) as Doc<"machines"> await evaluatePostureAndMaybeRaise(ctx, fresh, { metrics: args.metrics, inventory: args.inventory, metadata: args.metadata }) 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)) .collect() const companyById = new Map() const companyBySlug = new Map() for (const company of tenantCompanies) { companyById.set(company._id, company) if (company.slug) { companyBySlug.set(company.slug, company) } } const machines = await ctx.db .query("machines") .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .collect() return Promise.all( machines.map(async (machine) => { const activeToken = await findActiveMachineToken(ctx, machine._id, now) const offlineThresholdMs = getOfflineThresholdMs() const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs) const manualStatus = (machine.status ?? "").toLowerCase() let derivedStatus: string if (machine.isActive === false) { derivedStatus = "deactivated" } else if (["maintenance", "blocked"].includes(manualStatus)) { derivedStatus = manualStatus } else if (machine.lastHeartbeatAt) { const age = now - machine.lastHeartbeatAt if (age <= offlineThresholdMs) { derivedStatus = "online" } else if (age <= staleThresholdMs) { derivedStatus = "offline" } else { derivedStatus = "stale" } } else { derivedStatus = machine.status ?? "unknown" } const metadata = includeMetadata ? (machine.metadata ?? null) : null let metrics: Record | null = null let inventory: Record | null = null let postureAlerts: Array> | null = null let lastPostureAt: number | null = null if (metadata && typeof metadata === "object") { const metaRecord = metadata as Record if (metaRecord.metrics && typeof metaRecord.metrics === "object") { metrics = metaRecord.metrics as Record } if (metaRecord.inventory && typeof metaRecord.inventory === "object") { inventory = metaRecord.inventory as Record } if (Array.isArray(metaRecord.postureAlerts)) { postureAlerts = metaRecord.postureAlerts as Array> } 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 }>) 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: machine.lastHeartbeatAt ?? null, heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null, registeredBy: machine.registeredBy ?? null, createdAt: machine.createdAt, updatedAt: machine.updatedAt, token: activeToken ? { expiresAt: activeToken.expiresAt, lastUsedAt: activeToken.lastUsedAt ?? null, usageCount: activeToken.usageCount ?? 0, } : null, metrics, inventory, postureAlerts, lastPostureAt, remoteAccess: machine.remoteAccess ?? null, customFields: machine.customFields ?? [], } }) ) }, }) export async function getByIdHandler( ctx: QueryCtx, args: { id: Id<"machines">; includeMetadata?: boolean } ) { const includeMetadata = Boolean(args.includeMetadata) const now = Date.now() const machine = await ctx.db.get(args.id) if (!machine) return null const companyFromId = machine.companyId ? await ctx.db.get(machine.companyId) : null const machineSlug = machine.companySlug ?? null let companyFromSlug: typeof companyFromId | null = null if (!companyFromId && machineSlug) { companyFromSlug = await ctx.db .query("companies") .withIndex("by_tenant_slug", (q) => q.eq("tenantId", machine.tenantId).eq("slug", machineSlug)) .unique() } const resolvedCompany = companyFromId ?? companyFromSlug const activeToken = await findActiveMachineToken(ctx, machine._id, now) const offlineThresholdMs = getOfflineThresholdMs() const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs) const manualStatus = (machine.status ?? "").toLowerCase() let derivedStatus: string if (machine.isActive === false) { derivedStatus = "deactivated" } else if (["maintenance", "blocked"].includes(manualStatus)) { derivedStatus = manualStatus } else if (machine.lastHeartbeatAt) { const age = now - machine.lastHeartbeatAt if (age <= offlineThresholdMs) { derivedStatus = "online" } else if (age <= staleThresholdMs) { derivedStatus = "offline" } else { derivedStatus = "stale" } } else { derivedStatus = machine.status ?? "unknown" } const meta = includeMetadata ? (machine.metadata ?? null) : null let metrics: Record | null = null let inventory: Record | null = null let postureAlerts: Array> | null = null let lastPostureAt: number | null = null if (meta && typeof meta === "object") { const metaRecord = meta as Record if (metaRecord.metrics && typeof metaRecord.metrics === "object") { metrics = metaRecord.metrics as Record } if (metaRecord.inventory && typeof metaRecord.inventory === "object") { inventory = metaRecord.inventory as Record } if (Array.isArray(metaRecord.postureAlerts)) { postureAlerts = metaRecord.postureAlerts as Array> } 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: machine.lastHeartbeatAt ?? null, heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null, registeredBy: machine.registeredBy ?? null, createdAt: machine.createdAt, updatedAt: machine.updatedAt, token: activeToken ? { expiresAt: activeToken.expiresAt, lastUsedAt: activeToken.lastUsedAt ?? null, usageCount: activeToken.usageCount ?? 0, } : null, metrics, inventory, postureAlerts, lastPostureAt, remoteAccess: machine.remoteAccess ?? null, customFields: machine.customFields ?? [], } } 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 from: number | null to: number | null } type ListTicketsHistoryArgs = { machineId: Id<"machines"> status?: "all" | "open" | "resolved" priority?: string search?: string from?: number to?: number paginationOpts: Infer } type GetTicketsHistoryStatsArgs = { machineId: Id<"machines"> status?: "all" | "open" | "resolved" priority?: string search?: string from?: number to?: number } function createMachineTicketsQuery( ctx: QueryCtx, machine: Doc<"machines">, machineId: Id<"machines">, filters: MachineTicketsHistoryFilter ) { let working = ctx.db .query("tickets") .withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", machineId)) .order("desc") if (filters.statusFilter === "open") { working = working.filter((q) => q.or( q.eq(q.field("status"), "PENDING"), q.eq(q.field("status"), "AWAITING_ATTENDANCE"), q.eq(q.field("status"), "PAUSED") ) ) } else if (filters.statusFilter === "resolved") { working = working.filter((q) => q.eq(q.field("status"), "RESOLVED")) } if (filters.priorityFilter) { working = working.filter((q) => q.eq(q.field("priority"), filters.priorityFilter)) } if (typeof filters.from === "number") { working = working.filter((q) => q.gte(q.field("updatedAt"), filters.from!)) } if (typeof filters.to === "number") { working = working.filter((q) => q.lte(q.field("updatedAt"), filters.to!)) } return working } function matchesTicketSearch(ticket: Doc<"tickets">, searchTerm: string): boolean { const normalized = searchTerm.trim().toLowerCase() if (!normalized) return true const subject = ticket.subject.toLowerCase() if (subject.includes(normalized)) return true const summary = typeof ticket.summary === "string" ? ticket.summary.toLowerCase() : "" if (summary.includes(normalized)) return true const reference = `#${ticket.reference}`.toLowerCase() if (reference.includes(normalized)) return true const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined if (requesterSnapshot) { if (requesterSnapshot.name?.toLowerCase().includes(normalized)) return true if (requesterSnapshot.email?.toLowerCase().includes(normalized)) return true } const assigneeSnapshot = ticket.assigneeSnapshot as { name?: string; email?: string } | undefined if (assigneeSnapshot) { if (assigneeSnapshot.name?.toLowerCase().includes(normalized)) return true if (assigneeSnapshot.email?.toLowerCase().includes(normalized)) return true } return false } export async function listTicketsHistoryHandler(ctx: QueryCtx, args: ListTicketsHistoryArgs) { const machine = await ctx.db.get(args.machineId) if (!machine) { return { page: [], isDone: true, continueCursor: args.paginationOpts.cursor ?? "", } } const normalizedStatusFilter = args.status ?? "all" const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null const searchTerm = args.search?.trim().toLowerCase() ?? null const from = typeof args.from === "number" ? args.from : null const to = typeof args.to === "number" ? args.to : null const filters: MachineTicketsHistoryFilter = { statusFilter: normalizedStatusFilter, priorityFilter: normalizedPriorityFilter, from, to, } const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate(args.paginationOpts) const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page const queueCache = new Map | null>() const items = await Promise.all( page.map(async (ticket) => { let queueName: string | null = null if (ticket.queueId) { const key = String(ticket.queueId) if (!queueCache.has(key)) { queueCache.set(key, (await ctx.db.get(ticket.queueId)) as Doc<"queues"> | null) } queueName = queueCache.get(key)?.name ?? null } const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined const assigneeSnapshot = ticket.assigneeSnapshot as { name?: string; email?: string } | undefined return { id: ticket._id, reference: ticket.reference, subject: ticket.subject, status: normalizeStatus(ticket.status), priority: (ticket.priority ?? "MEDIUM").toString().toUpperCase(), updatedAt: ticket.updatedAt ?? ticket.createdAt ?? 0, createdAt: ticket.createdAt ?? 0, queue: queueName, requester: requesterSnapshot ? { name: requesterSnapshot.name ?? null, email: requesterSnapshot.email ?? null, } : null, assignee: assigneeSnapshot ? { name: assigneeSnapshot.name ?? null, email: assigneeSnapshot.email ?? null, } : null, } }) ) return { page: items, isDone: pageResult.isDone, continueCursor: pageResult.continueCursor, splitCursor: pageResult.splitCursor ?? undefined, pageStatus: pageResult.pageStatus ?? undefined, } } export const listTicketsHistory = query({ args: { machineId: v.id("machines"), status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))), priority: v.optional(v.string()), search: v.optional(v.string()), from: v.optional(v.number()), to: v.optional(v.number()), paginationOpts: paginationOptsValidator, }, handler: listTicketsHistoryHandler, }) export async function getTicketsHistoryStatsHandler( ctx: QueryCtx, args: GetTicketsHistoryStatsArgs ) { const machine = await ctx.db.get(args.machineId) if (!machine) { return { total: 0, openCount: 0, resolvedCount: 0 } } const normalizedStatusFilter = args.status ?? "all" const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null const searchTerm = args.search?.trim().toLowerCase() ?? "" const from = typeof args.from === "number" ? args.from : null const to = typeof args.to === "number" ? args.to : null const filters: MachineTicketsHistoryFilter = { statusFilter: normalizedStatusFilter, priorityFilter: normalizedPriorityFilter, from, to, } let cursor: string | null = null let total = 0 let openCount = 0 let done = false while (!done) { const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate({ numItems: MACHINE_TICKETS_STATS_PAGE_SIZE, cursor, }) const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page total += page.length for (const ticket of page) { if (OPEN_TICKET_STATUSES.has(normalizeStatus(ticket.status))) { openCount += 1 } } done = pageResult.isDone cursor = pageResult.continueCursor ?? null if (!cursor) { done = true } } return { total, openCount, resolvedCount: total - openCount, } } export const getTicketsHistoryStats = query({ args: { machineId: v.id("machines"), status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))), priority: v.optional(v.string()), search: v.optional(v.string()), from: v.optional(v.number()), to: v.optional(v.number()), }, handler: getTicketsHistoryStatsHandler, }) export async function updatePersonaHandler( ctx: MutationCtx, args: { machineId: Id<"machines"> persona?: string | null assignedUserId?: Id<"users"> assignedUserEmail?: string | null assignedUserName?: string | null assignedUserRole?: string | null } ) { const machine = await ctx.db.get(args.machineId) if (!machine) { throw new ConvexError("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 = { 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>(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)) .collect() 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 } }, }) 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 | 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 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).username) ?? coerceString((record as Record).user) ?? coerceString((record as Record).login) ?? coerceString((record as Record).email) ?? coerceString((record as Record).account) ?? null const password = coerceString((record as Record).password) ?? coerceString((record as Record).pass) ?? coerceString((record as Record).secret) ?? coerceString((record as Record).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) : 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() 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 } 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) const existingIndex = existingEntries.findIndex( (entry) => entry.provider.toLowerCase() === trimmedProvider.toLowerCase() && entry.identifier.toLowerCase() === trimmedIdentifier.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: { provider: trimmedProvider, identifier: trimmedIdentifier, url: normalizedUrl, username: cleanedUsername, password: cleanedPassword, notes: cleanedNotes, lastVerifiedAt: timestamp, }, } const nextEntries = existingIndex >= 0 ? existingEntries.map((entry, index) => (index === existingIndex ? updatedEntry : entry)) : [...existingEntries, updatedEntry] 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)) .collect() await Promise.all(tokens.map((token) => ctx.db.delete(token._id))) await ctx.db.delete(machineId) return { ok: true } }, })