- Add Convex query machines.getById with full payload (metrics/inventory) - Update AdminMachineDetailsClient to use getById instead of listByTenant+find - Update MachineBreadcrumbs to fetch hostname by ID This prevents the empty state when the list query hasn’t loaded or filtered out the machine.
1323 lines
44 KiB
TypeScript
1323 lines
44 KiB
TypeScript
// ci: trigger convex functions deploy
|
|
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 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 }>)
|
|
|
|
return {
|
|
id: machine._id,
|
|
tenantId: machine.tenantId,
|
|
hostname: machine.hostname,
|
|
companyId: machine.companyId ?? null,
|
|
companySlug: machine.companySlug ?? 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 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 ?? 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 }
|
|
},
|
|
})
|