- Criar tabela machineHeartbeats para armazenar lastHeartbeatAt separadamente - Modificar heartbeat para so atualizar machines quando ha mudancas reais - Atualizar queries listByTenant e getById para usar nova tabela - Reducao drastica de versoes de documentos criadas a cada heartbeat Antes: ~54 versoes por maquina (3524 linhas para 65 maquinas) Agora: heartbeat atualiza documento leve, machines so muda com dados novos 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2702 lines
91 KiB
TypeScript
2702 lines
91 KiB
TypeScript
// 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/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)
|
|
}
|
|
|
|
// 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: unknown, patch: unknown): unknown {
|
|
if (!isObject(patch)) {
|
|
return patch
|
|
}
|
|
const base: Record<string, unknown> = isObject(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (value === undefined) continue
|
|
// Filtrar campos volumosos que causam OOM
|
|
if (INVENTORY_BLOCKLIST.has(key)) 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<string, unknown>) {
|
|
const base: Record<string, unknown> = isObject(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
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<string, unknown>
|
|
|
|
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<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.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()
|
|
|
|
// 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)
|
|
const metadataPatch: Record<string, unknown> = {}
|
|
if (args.metadata && typeof args.metadata === "object") {
|
|
Object.assign(metadataPatch, args.metadata as Record<string, unknown>)
|
|
}
|
|
const remoteAccessSnapshot = metadataPatch["remoteAccessSnapshot"]
|
|
if (remoteAccessSnapshot !== undefined) {
|
|
delete metadataPatch["remoteAccessSnapshot"]
|
|
}
|
|
if (args.inventory && typeof args.inventory === "object") {
|
|
metadataPatch.inventory = mergeInventory(metadataPatch.inventory, args.inventory as Record<string, unknown>)
|
|
}
|
|
if (args.metrics && typeof args.metrics === "object") {
|
|
metadataPatch.metrics = args.metrics as Record<string, unknown>
|
|
}
|
|
|
|
// 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 = args.status && 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
|
|
|
|
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: args.status ?? "online",
|
|
metadata: mergedMetadata,
|
|
})
|
|
}
|
|
|
|
if (remoteAccessSnapshot) {
|
|
await upsertRemoteAccessSnapshotFromHeartbeat(ctx, machine, remoteAccessSnapshot, now)
|
|
}
|
|
|
|
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: 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))
|
|
.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 }
|
|
},
|
|
})
|
|
|
|
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)
|
|
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: {
|
|
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 }
|
|
},
|
|
})
|