sistema-de-chamados/convex/machines.ts
rever-tecnologia 7c5bc828cf Fix getById not returning USB policy fields
The getByIdHandler was missing usbPolicy, usbPolicyStatus,
and usbPolicyError fields, causing the chip to always show
"Permitido" instead of the actual policy value.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 10:06:18 -03:00

2525 lines
84 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/sha256"
import { randomBytes } from "@noble/hashes/utils"
import type { Doc, Id } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server"
import { normalizeStatus } from "./tickets"
import { requireAdmin } from "./rbac"
import { ensureMobileDeviceFields } from "./deviceFieldDefaults"
const DEFAULT_TENANT_ID = "tenant-atlas"
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
const ALLOWED_MACHINE_PERSONAS = new Set(["collaborator", "manager"])
const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000
const OPEN_TICKET_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"])
const MACHINE_TICKETS_STATS_PAGE_SIZE = 200
type NormalizedIdentifiers = {
macs: string[]
serials: string[]
}
function getTokenTtlMs(): number {
const raw = process.env["MACHINE_TOKEN_TTL_MS"]
if (!raw) return DEFAULT_TOKEN_TTL_MS
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed < 60_000) {
return DEFAULT_TOKEN_TTL_MS
}
return parsed
}
export function getOfflineThresholdMs(): number {
const raw = process.env["MACHINE_OFFLINE_THRESHOLD_MS"]
if (!raw) return DEFAULT_OFFLINE_THRESHOLD_MS
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_OFFLINE_THRESHOLD_MS
}
return parsed
}
export function getStaleThresholdMs(offlineMs: number): number {
const raw = process.env["MACHINE_STALE_THRESHOLD_MS"]
if (!raw) return offlineMs * 12
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= offlineMs) {
return offlineMs * 12
}
return parsed
}
function normalizeIdentifiers(macAddresses: string[], serialNumbers: string[]): NormalizedIdentifiers {
const normalizeMac = (value: string) => value.replace(/[^a-f0-9]/gi, "").toLowerCase()
const normalizeSerial = (value: string) => value.trim().toLowerCase()
const macs = Array.from(new Set(macAddresses.map(normalizeMac).filter(Boolean))).sort()
const serials = Array.from(new Set(serialNumbers.map(normalizeSerial).filter(Boolean))).sort()
if (macs.length === 0 && serials.length === 0) {
throw new ConvexError("Informe ao menos um identificador (MAC ou serial)")
}
return { macs, serials }
}
function normalizeOptionalIdentifiers(macAddresses?: string[] | null, serialNumbers?: string[] | null): NormalizedIdentifiers {
const normalizeMac = (value: string) => value.replace(/[^a-f0-9]/gi, "").toLowerCase()
const normalizeSerial = (value: string) => value.trim().toLowerCase()
const macs = Array.from(new Set((macAddresses ?? []).map(normalizeMac).filter(Boolean))).sort()
const serials = Array.from(new Set((serialNumbers ?? []).map(normalizeSerial).filter(Boolean))).sort()
return { macs, serials }
}
async function findActiveMachineToken(ctx: QueryCtx, machineId: Id<"machines">, now: number) {
const tokens = await ctx.db
.query("machineTokens")
.withIndex("by_machine_revoked_expires", (q) =>
q.eq("machineId", machineId).eq("revoked", false).gt("expiresAt", now),
)
.collect()
return tokens.length > 0 ? tokens[0]! : null
}
function toHex(input: Uint8Array) {
return Array.from(input)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
}
function computeFingerprint(tenantId: string, companySlug: string | undefined, hostname: string, ids: NormalizedIdentifiers) {
const payload = JSON.stringify({
tenantId,
companySlug: companySlug ?? null,
hostname: hostname.trim().toLowerCase(),
macs: ids.macs,
serials: ids.serials,
})
return toHex(sha256(payload))
}
function generateManualFingerprint(tenantId: string, displayName: string) {
const payload = JSON.stringify({
tenantId,
displayName: displayName.trim().toLowerCase(),
nonce: toHex(randomBytes(16)),
createdAt: Date.now(),
})
return toHex(sha256(payload))
}
function formatDeviceCustomFieldDisplay(
type: string,
value: unknown,
options?: Array<{ value: string; label: string }>
): string | null {
if (value === null || value === undefined) return null
switch (type) {
case "text":
return String(value).trim()
case "number": {
const num = typeof value === "number" ? value : Number(value)
if (!Number.isFinite(num)) return null
return String(num)
}
case "boolean":
return value ? "Sim" : "Não"
case "date": {
const date = value instanceof Date ? value : new Date(String(value))
if (Number.isNaN(date.getTime())) return null
return date.toISOString().slice(0, 10)
}
case "select": {
const raw = String(value)
const option = options?.find((opt) => opt.value === raw || opt.label === raw)
return option?.label ?? raw
}
case "multiselect": {
const arr = Array.isArray(value)
? value
: typeof value === "string"
? value.split(",").map((s) => s.trim()).filter(Boolean)
: []
if (arr.length === 0) return null
const labels = arr.map((raw) => {
const opt = options?.find((o) => o.value === raw || o.label === raw)
return opt?.label ?? String(raw)
})
return labels.join(", ")
}
default:
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
}
function extractCollaboratorEmail(metadata: unknown): string | null {
if (!metadata || typeof metadata !== "object") return null
const record = metadata as Record<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(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)
}
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
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
let existing = await ctx.db
.query("machines")
.withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint))
.first()
if (!existing) {
const collaboratorEmail = extractCollaboratorEmail(metadataPatch ?? args.metadata)
if (collaboratorEmail) {
const candidate = await ctx.db
.query("machines")
.withIndex("by_tenant_assigned_email", (q) => q.eq("tenantId", tenantId).eq("assignedUserEmail", collaboratorEmail))
.first()
if (candidate && matchesExistingHardware(candidate, identifiers, args.hostname)) {
existing = candidate
}
}
}
let machineId: Id<"machines">
if (existing) {
await ctx.db.patch(existing._id, {
tenantId,
companyId: companyId ?? existing.companyId,
companySlug: companySlug ?? existing.companySlug,
hostname: args.hostname,
displayName: existing.displayName ?? args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
macAddresses: identifiers.macs,
serialNumbers: identifiers.serials,
fingerprint,
metadata: metadataPatch ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata,
lastHeartbeatAt: now,
updatedAt: now,
status: "online",
isActive: true,
registeredBy: args.registeredBy ?? existing.registeredBy,
deviceType: existing.deviceType ?? "desktop",
devicePlatform: args.os.name ?? existing.devicePlatform,
managementMode: existing.managementMode ?? "agent",
persona: existing.persona,
assignedUserId: existing.assignedUserId,
assignedUserEmail: existing.assignedUserEmail,
assignedUserName: existing.assignedUserName,
assignedUserRole: existing.assignedUserRole,
})
machineId = existing._id
} else {
machineId = await ctx.db.insert("machines", {
tenantId,
companyId,
companySlug,
hostname: args.hostname,
displayName: args.hostname,
osName: args.os.name,
osVersion: args.os.version,
architecture: args.os.architecture,
macAddresses: identifiers.macs,
serialNumbers: identifiers.serials,
fingerprint,
metadata: metadataPatch ? mergeMetadata(undefined, metadataPatch) : undefined,
lastHeartbeatAt: now,
status: "online",
isActive: true,
createdAt: now,
updatedAt: now,
registeredBy: args.registeredBy,
deviceType: "desktop",
devicePlatform: args.os.name,
managementMode: "agent",
persona: undefined,
assignedUserId: undefined,
assignedUserEmail: undefined,
assignedUserName: undefined,
assignedUserRole: undefined,
})
}
const previousTokens = await ctx.db
.query("machineTokens")
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
.collect()
for (const token of previousTokens) {
if (!token.revoked) {
await ctx.db.patch(token._id, {
revoked: true,
revokedAt: now,
lastUsedAt: now,
expiresAt: now,
})
}
}
const tokenPlain = toHex(randomBytes(32))
const tokenHash = hashToken(tokenPlain)
const expiresAt = now + getTokenTtlMs()
await ctx.db.insert("machineTokens", {
tenantId,
machineId,
tokenHash,
expiresAt,
revoked: false,
createdAt: now,
usageCount: 0,
type: "machine",
})
return {
machineId,
tenantId,
companyId,
companySlug,
machineToken: tokenPlain,
expiresAt,
}
},
})
export const upsertInventory = mutation({
args: {
provisioningCode: v.string(),
hostname: v.string(),
os: v.object({
name: v.string(),
version: v.optional(v.string()),
architecture: v.optional(v.string()),
}),
macAddresses: v.array(v.string()),
serialNumbers: v.array(v.string()),
inventory: v.optional(v.any()),
metrics: v.optional(v.any()),
registeredBy: v.optional(v.string()),
},
handler: async (ctx, args) => {
const normalizedCode = args.provisioningCode.trim().toLowerCase()
const companyRecord = await ctx.db
.query("companies")
.withIndex("by_provisioning_code", (q) => q.eq("provisioningCode", normalizedCode))
.unique()
if (!companyRecord) {
throw new ConvexError("Código de provisionamento inválido")
}
const tenantId = companyRecord.tenantId ?? DEFAULT_TENANT_ID
const companyId = companyRecord._id
const companySlug = companyRecord.slug
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
const fingerprint = computeFingerprint(tenantId, companySlug, args.hostname, identifiers)
const now = Date.now()
const metadataPatch: Record<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()
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>
}
const mergedMetadata = Object.keys(metadataPatch).length ? mergeMetadata(machine.metadata, metadataPatch) : machine.metadata
await ctx.db.patch(machine._id, {
hostname: args.hostname ?? machine.hostname,
displayName: machine.displayName ?? args.hostname ?? machine.hostname,
osName: args.os?.name ?? machine.osName,
osVersion: args.os?.version ?? machine.osVersion,
architecture: args.os?.architecture ?? machine.architecture,
devicePlatform: args.os?.name ?? machine.devicePlatform,
deviceType: machine.deviceType ?? "desktop",
managementMode: machine.managementMode ?? "agent",
lastHeartbeatAt: now,
updatedAt: now,
status: args.status ?? "online",
metadata: mergedMetadata,
})
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 = (await ctx.db.get(machine._id)) as Doc<"machines">
await evaluatePostureAndMaybeRaise(ctx, fresh, { metrics: args.metrics, inventory: args.inventory, metadata: args.metadata })
return {
ok: true,
machineId: machine._id,
expiresAt: now + getTokenTtlMs(),
}
},
})
export const resolveToken = mutation({
args: {
machineToken: v.string(),
},
handler: async (ctx, args) => {
const { machine, token } = await getActiveToken(ctx, args.machineToken)
const now = Date.now()
await ctx.db.patch(token._id, {
lastUsedAt: now,
usageCount: (token.usageCount ?? 0) + 1,
})
return {
machine: {
_id: machine._id,
tenantId: machine.tenantId,
companyId: machine.companyId,
companySlug: machine.companySlug,
hostname: machine.hostname,
osName: machine.osName,
osVersion: machine.osVersion,
architecture: machine.architecture,
authUserId: machine.authUserId,
authEmail: machine.authEmail,
persona: machine.persona ?? null,
assignedUserId: machine.assignedUserId ?? null,
assignedUserEmail: machine.assignedUserEmail ?? null,
assignedUserName: machine.assignedUserName ?? null,
assignedUserRole: machine.assignedUserRole ?? null,
linkedUserIds: machine.linkedUserIds ?? [],
status: machine.status,
lastHeartbeatAt: machine.lastHeartbeatAt,
metadata: machine.metadata,
isActive: machine.isActive ?? true,
},
token: {
expiresAt: token.expiresAt,
lastUsedAt: token.lastUsedAt ?? null,
usageCount: token.usageCount ?? 0,
},
}
},
})
export const listByTenant = query({
args: {
tenantId: v.optional(v.string()),
includeMetadata: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
const includeMetadata = Boolean(args.includeMetadata)
const now = Date.now()
const tenantCompanies = await ctx.db
.query("companies")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
const companyById = new Map<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)
}
}
const machines = await ctx.db
.query("machines")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
return Promise.all(
machines.map(async (machine) => {
const activeToken = await findActiveMachineToken(ctx, machine._id, now)
const offlineThresholdMs = getOfflineThresholdMs()
const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs)
const manualStatus = (machine.status ?? "").toLowerCase()
let derivedStatus: string
if (machine.isActive === false) {
derivedStatus = "deactivated"
} else if (["maintenance", "blocked"].includes(manualStatus)) {
derivedStatus = manualStatus
} else if (machine.lastHeartbeatAt) {
const age = now - machine.lastHeartbeatAt
if (age <= offlineThresholdMs) {
derivedStatus = "online"
} else if (age <= staleThresholdMs) {
derivedStatus = "offline"
} else {
derivedStatus = "stale"
}
} else {
derivedStatus = machine.status ?? "unknown"
}
const metadata = includeMetadata ? (machine.metadata ?? null) : null
let metrics: Record<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 }>)
const companyFromId = machine.companyId ? companyById.get(machine.companyId) ?? null : null
const companyFromSlug = machine.companySlug ? companyBySlug.get(machine.companySlug) ?? null : null
const resolvedCompany = companyFromId ?? companyFromSlug
return {
id: machine._id,
tenantId: machine.tenantId,
hostname: machine.hostname,
displayName: machine.displayName ?? null,
deviceType: machine.deviceType ?? "desktop",
devicePlatform: machine.devicePlatform ?? null,
deviceProfile: machine.deviceProfile ?? null,
managementMode: machine.managementMode ?? "agent",
companyId: machine.companyId ?? null,
companySlug: machine.companySlug ?? companyFromId?.slug ?? companyFromSlug?.slug ?? null,
companyName: resolvedCompany?.name ?? null,
osName: machine.osName,
osVersion: machine.osVersion ?? null,
architecture: machine.architecture ?? null,
macAddresses: machine.macAddresses,
serialNumbers: machine.serialNumbers,
authUserId: machine.authUserId ?? null,
authEmail: machine.authEmail ?? null,
persona: machine.persona ?? null,
assignedUserId: machine.assignedUserId ?? null,
assignedUserEmail: machine.assignedUserEmail ?? null,
assignedUserName: machine.assignedUserName ?? null,
assignedUserRole: machine.assignedUserRole ?? null,
linkedUsers,
status: derivedStatus,
isActive: machine.isActive ?? true,
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
registeredBy: machine.registeredBy ?? null,
createdAt: machine.createdAt,
updatedAt: machine.updatedAt,
token: activeToken
? {
expiresAt: activeToken.expiresAt,
lastUsedAt: activeToken.lastUsedAt ?? null,
usageCount: activeToken.usageCount ?? 0,
}
: null,
metrics,
inventory,
postureAlerts,
lastPostureAt,
remoteAccess: machine.remoteAccess ?? null,
customFields: machine.customFields ?? [],
usbPolicy: machine.usbPolicy ?? null,
usbPolicyStatus: machine.usbPolicyStatus ?? null,
usbPolicyError: machine.usbPolicyError ?? null,
}
})
)
},
})
export async function getByIdHandler(
ctx: QueryCtx,
args: { id: Id<"machines">; includeMetadata?: boolean }
) {
const includeMetadata = Boolean(args.includeMetadata)
const now = Date.now()
const machine = await ctx.db.get(args.id)
if (!machine) return null
const companyFromId = machine.companyId ? await ctx.db.get(machine.companyId) : null
const machineSlug = machine.companySlug ?? null
let companyFromSlug: typeof companyFromId | null = null
if (!companyFromId && machineSlug) {
companyFromSlug = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", machine.tenantId).eq("slug", machineSlug))
.unique()
}
const resolvedCompany = companyFromId ?? companyFromSlug
const activeToken = await findActiveMachineToken(ctx, machine._id, now)
const offlineThresholdMs = getOfflineThresholdMs()
const staleThresholdMs = getStaleThresholdMs(offlineThresholdMs)
const manualStatus = (machine.status ?? "").toLowerCase()
let derivedStatus: string
if (machine.isActive === false) {
derivedStatus = "deactivated"
} else if (["maintenance", "blocked"].includes(manualStatus)) {
derivedStatus = manualStatus
} else if (machine.lastHeartbeatAt) {
const age = now - machine.lastHeartbeatAt
if (age <= offlineThresholdMs) {
derivedStatus = "online"
} else if (age <= staleThresholdMs) {
derivedStatus = "offline"
} else {
derivedStatus = "stale"
}
} else {
derivedStatus = machine.status ?? "unknown"
}
const meta = includeMetadata ? (machine.metadata ?? null) : null
let metrics: Record<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: machine.lastHeartbeatAt ?? null,
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
registeredBy: machine.registeredBy ?? null,
createdAt: machine.createdAt,
updatedAt: machine.updatedAt,
token: activeToken
? {
expiresAt: activeToken.expiresAt,
lastUsedAt: activeToken.lastUsedAt ?? null,
usageCount: activeToken.usageCount ?? 0,
}
: null,
metrics,
inventory,
postureAlerts,
lastPostureAt,
remoteAccess: machine.remoteAccess ?? null,
customFields: machine.customFields ?? [],
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
from: number | null
to: number | null
}
type ListTicketsHistoryArgs = {
machineId: Id<"machines">
status?: "all" | "open" | "resolved"
priority?: string
search?: string
from?: number
to?: number
paginationOpts: Infer<typeof paginationOptsValidator>
}
type GetTicketsHistoryStatsArgs = {
machineId: Id<"machines">
status?: "all" | "open" | "resolved"
priority?: string
search?: string
from?: number
to?: number
}
function createMachineTicketsQuery(
ctx: QueryCtx,
machine: Doc<"machines">,
machineId: Id<"machines">,
filters: MachineTicketsHistoryFilter
) {
let working = ctx.db
.query("tickets")
.withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", machineId))
.order("desc")
if (filters.statusFilter === "open") {
working = working.filter((q) =>
q.or(
q.eq(q.field("status"), "PENDING"),
q.eq(q.field("status"), "AWAITING_ATTENDANCE"),
q.eq(q.field("status"), "PAUSED")
)
)
} else if (filters.statusFilter === "resolved") {
working = working.filter((q) => q.eq(q.field("status"), "RESOLVED"))
}
if (filters.priorityFilter) {
working = working.filter((q) => q.eq(q.field("priority"), filters.priorityFilter))
}
if (typeof filters.from === "number") {
working = working.filter((q) => q.gte(q.field("updatedAt"), filters.from!))
}
if (typeof filters.to === "number") {
working = working.filter((q) => q.lte(q.field("updatedAt"), filters.to!))
}
return working
}
function matchesTicketSearch(ticket: Doc<"tickets">, searchTerm: string): boolean {
const normalized = searchTerm.trim().toLowerCase()
if (!normalized) return true
const subject = ticket.subject.toLowerCase()
if (subject.includes(normalized)) return true
const summary = typeof ticket.summary === "string" ? ticket.summary.toLowerCase() : ""
if (summary.includes(normalized)) return true
const reference = `#${ticket.reference}`.toLowerCase()
if (reference.includes(normalized)) return true
const requesterSnapshot = ticket.requesterSnapshot as { name?: string; email?: string } | undefined
if (requesterSnapshot) {
if (requesterSnapshot.name?.toLowerCase().includes(normalized)) return true
if (requesterSnapshot.email?.toLowerCase().includes(normalized)) return true
}
const assigneeSnapshot = ticket.assigneeSnapshot as { name?: string; email?: string } | undefined
if (assigneeSnapshot) {
if (assigneeSnapshot.name?.toLowerCase().includes(normalized)) return true
if (assigneeSnapshot.email?.toLowerCase().includes(normalized)) return true
}
return false
}
export async function listTicketsHistoryHandler(ctx: QueryCtx, args: ListTicketsHistoryArgs) {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
return {
page: [],
isDone: true,
continueCursor: args.paginationOpts.cursor ?? "",
}
}
const normalizedStatusFilter = args.status ?? "all"
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
const searchTerm = args.search?.trim().toLowerCase() ?? null
const from = typeof args.from === "number" ? args.from : null
const to = typeof args.to === "number" ? args.to : null
const filters: MachineTicketsHistoryFilter = {
statusFilter: normalizedStatusFilter,
priorityFilter: normalizedPriorityFilter,
from,
to,
}
const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate(args.paginationOpts)
const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page
const queueCache = new Map<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()),
search: v.optional(v.string()),
from: v.optional(v.number()),
to: v.optional(v.number()),
paginationOpts: paginationOptsValidator,
},
handler: listTicketsHistoryHandler,
})
export async function getTicketsHistoryStatsHandler(
ctx: QueryCtx,
args: GetTicketsHistoryStatsArgs
) {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
return { total: 0, openCount: 0, resolvedCount: 0 }
}
const normalizedStatusFilter = args.status ?? "all"
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null
const searchTerm = args.search?.trim().toLowerCase() ?? ""
const from = typeof args.from === "number" ? args.from : null
const to = typeof args.to === "number" ? args.to : null
const filters: MachineTicketsHistoryFilter = {
statusFilter: normalizedStatusFilter,
priorityFilter: normalizedPriorityFilter,
from,
to,
}
let cursor: string | null = null
let total = 0
let openCount = 0
let done = false
while (!done) {
const pageResult = await createMachineTicketsQuery(ctx, machine, args.machineId, filters).paginate({
numItems: MACHINE_TICKETS_STATS_PAGE_SIZE,
cursor,
})
const page = searchTerm ? pageResult.page.filter((ticket) => matchesTicketSearch(ticket, searchTerm)) : pageResult.page
total += page.length
for (const ticket of page) {
if (OPEN_TICKET_STATUSES.has(normalizeStatus(ticket.status))) {
openCount += 1
}
}
done = pageResult.isDone
cursor = pageResult.continueCursor ?? null
if (!cursor) {
done = true
}
}
return {
total,
openCount,
resolvedCount: total - openCount,
}
}
export const getTicketsHistoryStats = query({
args: {
machineId: v.id("machines"),
status: v.optional(v.union(v.literal("all"), v.literal("open"), v.literal("resolved"))),
priority: v.optional(v.string()),
search: v.optional(v.string()),
from: v.optional(v.number()),
to: v.optional(v.number()),
},
handler: getTicketsHistoryStatsHandler,
})
export async function updatePersonaHandler(
ctx: MutationCtx,
args: {
machineId: Id<"machines">
persona?: string | null
assignedUserId?: Id<"users">
assignedUserEmail?: string | null
assignedUserName?: string | null
assignedUserRole?: string | null
}
) {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Dispositivo não encontrada")
}
let nextPersona = machine.persona ?? undefined
const personaProvided = args.persona !== undefined
if (args.persona !== undefined) {
const trimmed = (args.persona ?? "").trim().toLowerCase()
if (!trimmed) {
nextPersona = undefined
} else if (!ALLOWED_MACHINE_PERSONAS.has(trimmed)) {
throw new ConvexError("Perfil inválido para a dispositivo")
} else {
nextPersona = trimmed
}
}
let nextAssignedUserId = machine.assignedUserId ?? undefined
if (args.assignedUserId !== undefined) {
nextAssignedUserId = args.assignedUserId
}
let nextAssignedEmail = machine.assignedUserEmail ?? undefined
if (args.assignedUserEmail !== undefined) {
const trimmedEmail = (args.assignedUserEmail ?? "").trim().toLowerCase()
nextAssignedEmail = trimmedEmail || undefined
}
let nextAssignedName = machine.assignedUserName ?? undefined
if (args.assignedUserName !== undefined) {
const trimmedName = (args.assignedUserName ?? "").trim()
nextAssignedName = trimmedName || undefined
}
let nextAssignedRole = machine.assignedUserRole ?? undefined
if (args.assignedUserRole !== undefined) {
const trimmedRole = (args.assignedUserRole ?? "").trim().toUpperCase()
nextAssignedRole = trimmedRole || undefined
}
if (personaProvided && !nextPersona) {
nextAssignedUserId = undefined
nextAssignedEmail = undefined
nextAssignedName = undefined
nextAssignedRole = undefined
}
if (nextPersona && !nextAssignedUserId) {
throw new ConvexError("Associe um usuário ao definir a persona da dispositivo")
}
if (nextPersona && nextAssignedUserId) {
const assignedUser = await ctx.db.get(nextAssignedUserId)
if (!assignedUser) {
throw new ConvexError("Usuário vinculado não encontrado")
}
if (assignedUser.tenantId !== machine.tenantId) {
throw new ConvexError("Usuário vinculado pertence a outro tenant")
}
}
let nextMetadata = machine.metadata
if (nextPersona) {
const collaboratorMeta = {
email: nextAssignedEmail ?? null,
name: nextAssignedName ?? null,
role: nextPersona,
}
nextMetadata = mergeMetadata(machine.metadata, { collaborator: collaboratorMeta })
}
const patch: Record<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))
.collect()
const now = Date.now()
let revokedCount = 0
for (const token of tokens) {
if (!token.revoked) {
await ctx.db.patch(token._id, {
revoked: true,
revokedAt: now,
expiresAt: now,
})
revokedCount += 1
}
}
await ctx.db.patch(machineId, {
status: "unknown",
updatedAt: now,
})
return { machineId, revoked: revokedCount }
},
})
type RemoteAccessEntry = {
id: string
provider: string
identifier: string
url: string | null
username: string | null
password: string | null
notes: string | null
lastVerifiedAt: number | null
metadata: Record<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
) {
const machines = await ctx.db
.query("machines")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
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)
const idx = existingEntries.findIndex(
(entry) => entry.provider.toLowerCase() === provider.toLowerCase() && entry.identifier.toLowerCase() === identifier.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))
.collect()
await Promise.all(tokens.map((token) => ctx.db.delete(token._id)))
await ctx.db.delete(machineId)
return { ok: true }
},
})