sistema-de-chamados/convex/machines.ts

1354 lines
46 KiB
TypeScript

// ci: trigger convex functions deploy (no-op)
import { mutation, query } from "./_generated/server"
import { api } from "./_generated/api"
import { ConvexError, v } 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 } from "./_generated/server"
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
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
}
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
}
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 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 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))
}
async function getActiveToken(
ctx: MutationCtx,
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 máquina inválido")
}
if (token.revoked) {
throw new ConvexError("Token de máquina revogado")
}
if (token.expiresAt < Date.now()) {
throw new ConvexError("Token de máquina expirado")
}
const machine = await ctx.db.get(token.machineId)
if (!machine) {
throw new ConvexError("Máquina não encontrada para o token fornecido")
}
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 máquina: ${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,
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,
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,
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,
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, lastUsedAt: 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,
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,
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,
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,
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>)
}
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,
osName: args.os?.name ?? machine.osName,
osVersion: args.os?.version ?? machine.osVersion,
architecture: args.os?.architecture ?? machine.architecture,
lastHeartbeatAt: now,
updatedAt: now,
status: args.status ?? "online",
metadata: mergedMetadata,
})
await ctx.db.patch(token._id, {
lastUsedAt: now,
usageCount: (token.usageCount ?? 0) + 1,
expiresAt: now + getTokenTtlMs(),
})
// Evaluate posture/alerts & optionally create ticket
const fresh = (await ctx.db.get(machine._id)) as Doc<"machines">
await evaluatePostureAndMaybeRaise(ctx, fresh, { metrics: args.metrics, inventory: args.inventory, metadata: args.metadata })
return {
ok: true,
machineId: machine._id,
expiresAt: now + getTokenTtlMs(),
}
},
})
export const resolveToken = mutation({
args: {
machineToken: v.string(),
},
handler: async (ctx, args) => {
const { machine, token } = await getActiveToken(ctx, args.machineToken)
const now = Date.now()
await ctx.db.patch(token._id, {
lastUsedAt: now,
usageCount: (token.usageCount ?? 0) + 1,
})
return {
machine: {
_id: machine._id,
tenantId: machine.tenantId,
companyId: machine.companyId,
companySlug: machine.companySlug,
hostname: machine.hostname,
osName: machine.osName,
osVersion: machine.osVersion,
architecture: machine.architecture,
authUserId: machine.authUserId,
authEmail: machine.authEmail,
persona: machine.persona ?? null,
assignedUserId: machine.assignedUserId ?? null,
assignedUserEmail: machine.assignedUserEmail ?? null,
assignedUserName: machine.assignedUserName ?? null,
assignedUserRole: machine.assignedUserRole ?? null,
linkedUserIds: machine.linkedUserIds ?? [],
status: machine.status,
lastHeartbeatAt: machine.lastHeartbeatAt,
metadata: machine.metadata,
isActive: machine.isActive ?? true,
},
token: {
expiresAt: token.expiresAt,
lastUsedAt: token.lastUsedAt ?? null,
usageCount: token.usageCount ?? 0,
},
}
},
})
export const listByTenant = query({
args: {
tenantId: v.optional(v.string()),
includeMetadata: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
const includeMetadata = Boolean(args.includeMetadata)
const now = Date.now()
const tenantCompanies = await ctx.db
.query("companies")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
const companyById = new Map<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 tokens = await ctx.db
.query("machineTokens")
.withIndex("by_machine", (q) => q.eq("machineId", machine._id))
.collect()
const activeToken = tokens.find((token) => !token.revoked && token.expiresAt > now) ?? null
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,
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,
}
})
)
},
})
export const getById = query({
args: {
id: v.id("machines"),
includeMetadata: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
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 tokens = await ctx.db
.query("machineTokens")
.withIndex("by_machine", (q) => q.eq("machineId", machine._id))
.collect()
const activeToken = tokens.find((token) => !token.revoked && token.expiresAt > now) ?? null
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,
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,
}
},
})
export const listAlerts = query({
args: {
machineId: v.id("machines"),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
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", args.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 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: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina 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 máquina")
} 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 máquina")
}
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 getContext = query({
args: {
machineId: v.id("machines"),
},
handler: async (ctx, args) => {
const machine = await ctx.db.get(args.machineId)
if (!machine) {
throw new ConvexError("Máquina 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("Máquina 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("Máquina 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 máquinas')
}
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("Máquina 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("Máquina não encontrada")
}
// Verifica permissão no tenant da máquina
const viewer = await ctx.db.get(actorId)
if (!viewer || viewer.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
}
const normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) {
throw new ConvexError("Apenas equipe interna pode renomear máquinas")
}
const nextName = hostname.trim()
if (nextName.length < 2) {
throw new ConvexError("Informe um nome válido para a máquina")
}
await ctx.db.patch(machineId, { hostname: 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("Máquina não encontrada")
}
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
}
const normalizedRole = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(normalizedRole)) {
throw new ConvexError("Apenas equipe interna pode atualizar o status da máquina")
}
await ctx.db.patch(machineId, {
isActive: active,
updatedAt: Date.now(),
})
return { ok: true }
},
})
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("Máquina não encontrada")
}
const actor = await ctx.db.get(actorId)
if (!actor || actor.tenantId !== machine.tenantId) {
throw new ConvexError("Acesso negado ao tenant da máquina")
}
const role = (actor.role ?? "AGENT").toUpperCase()
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT"])
if (!STAFF.has(role)) {
throw new ConvexError("Apenas equipe interna pode excluir máquinas")
}
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 }
},
})