628 lines
20 KiB
TypeScript
628 lines
20 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
|
|
|
|
type NormalizedIdentifiers = {
|
|
macs: string[]
|
|
serials: string[]
|
|
}
|
|
|
|
function getProvisioningSecret() {
|
|
const secret = process.env["MACHINE_PROVISIONING_SECRET"]
|
|
if (!secret) {
|
|
throw new ConvexError("Provisionamento de máquinas não configurado")
|
|
}
|
|
return secret
|
|
}
|
|
|
|
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 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 hashToken(token: string) {
|
|
return toHex(sha256(token))
|
|
}
|
|
|
|
async function ensureCompany(
|
|
ctx: MutationCtx,
|
|
tenantId: string,
|
|
companySlug?: string
|
|
): Promise<{ companyId?: Id<"companies">; companySlug?: string }> {
|
|
if (!companySlug) return {}
|
|
const company = await ctx.db
|
|
.query("companies")
|
|
.withIndex("by_tenant_slug", (q: any) => q.eq("tenantId", tenantId).eq("slug", companySlug))
|
|
.unique()
|
|
if (!company) {
|
|
throw new ConvexError("Empresa não encontrada para o tenant informado")
|
|
}
|
|
return { companyId: company._id, companySlug: company.slug }
|
|
}
|
|
|
|
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: any) => 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 mergeMetadata(current: unknown, patch: Record<string, unknown>) {
|
|
if (!current || typeof current !== "object") return patch
|
|
return { ...(current as Record<string, unknown>), ...patch }
|
|
}
|
|
|
|
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: any) => 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: any) => q.eq("tenantId", tenantId)).first()
|
|
if (!category) return null
|
|
const subcategory = await ctx.db
|
|
.query("ticketSubcategories")
|
|
.withIndex("by_category_order", (q: any) => 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?: any; inventory?: any; metadata?: any }
|
|
) {
|
|
const findings: PostureFinding[] = []
|
|
|
|
const metrics = args.metrics ?? (args.metadata?.metrics ?? null)
|
|
if (metrics && typeof metrics === "object") {
|
|
const usage = Number((metrics as any).cpuUsagePercent ?? (metrics as any).cpu_usage_percent)
|
|
if (Number.isFinite(usage) && usage >= 90) {
|
|
findings.push({ kind: "CPU_HIGH", message: `CPU acima de ${usage.toFixed(0)}%`, severity: "warning" })
|
|
}
|
|
}
|
|
|
|
const inventory = args.inventory ?? (args.metadata?.inventory ?? null)
|
|
if (inventory && typeof inventory === "object") {
|
|
const services = (inventory as any).services
|
|
if (Array.isArray(services)) {
|
|
const criticalDown = services.find((s: any) => typeof s?.name === "string" && String(s.status ?? "").toLowerCase() !== "running")
|
|
if (criticalDown) {
|
|
findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${criticalDown.name}`, severity: "warning" })
|
|
}
|
|
}
|
|
const smart = (inventory as any).extended?.linux?.smart
|
|
if (Array.isArray(smart)) {
|
|
const failing = smart.find((e: any) => e?.smart_status && e.smart_status.passed === false)
|
|
if (failing) {
|
|
findings.push({ kind: "SMART_FAIL", message: `Disco com SMART em falha`, severity: "critical" })
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!findings.length) return
|
|
|
|
const now = Date.now()
|
|
const record = {
|
|
postureAlerts: findings,
|
|
lastPostureAt: now,
|
|
}
|
|
const prevMeta = (machine.metadata && typeof machine.metadata === "object") ? (machine.metadata as Record<string, unknown>) : null
|
|
const lastAtPrev = typeof prevMeta?.lastPostureAt === "number" ? (prevMeta!.lastPostureAt as number) : 0
|
|
await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now })
|
|
|
|
if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "true").toLowerCase() !== "true") return
|
|
// Evita excesso: não cria ticket se já houve alerta nos últimos 30 minutos
|
|
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: {
|
|
provisioningSecret: v.string(),
|
|
tenantId: v.optional(v.string()),
|
|
companySlug: v.optional(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 secret = getProvisioningSecret()
|
|
if (args.provisioningSecret !== secret) {
|
|
throw new ConvexError("Código de provisionamento inválido")
|
|
}
|
|
|
|
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
|
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
|
const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers)
|
|
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug)
|
|
const now = Date.now()
|
|
|
|
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: args.metadata ? mergeMetadata(existing.metadata, { inventory: args.metadata }) : existing.metadata,
|
|
lastHeartbeatAt: now,
|
|
updatedAt: now,
|
|
status: "online",
|
|
registeredBy: args.registeredBy ?? existing.registeredBy,
|
|
})
|
|
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: args.metadata ? { inventory: args.metadata } : undefined,
|
|
lastHeartbeatAt: now,
|
|
status: "online",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
registeredBy: args.registeredBy,
|
|
})
|
|
}
|
|
|
|
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: {
|
|
provisioningSecret: v.string(),
|
|
tenantId: v.optional(v.string()),
|
|
companySlug: v.optional(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 secret = getProvisioningSecret()
|
|
if (args.provisioningSecret !== secret) {
|
|
throw new ConvexError("Código de provisionamento inválido")
|
|
}
|
|
|
|
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
|
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
|
const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers)
|
|
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug)
|
|
const now = Date.now()
|
|
|
|
const metadataPatch = mergeMetadata({}, {
|
|
...(args.inventory ? { inventory: args.inventory } : {}),
|
|
...(args.metrics ? { metrics: args.metrics } : {}),
|
|
})
|
|
|
|
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: mergeMetadata(existing.metadata, metadataPatch),
|
|
lastHeartbeatAt: now,
|
|
updatedAt: now,
|
|
status: args.metrics ? "online" : existing.status ?? "unknown",
|
|
registeredBy: args.registeredBy ?? existing.registeredBy,
|
|
})
|
|
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,
|
|
lastHeartbeatAt: now,
|
|
status: args.metrics ? "online" : "unknown",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
registeredBy: args.registeredBy,
|
|
})
|
|
}
|
|
|
|
// 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 mergedMetadata = mergeMetadata(machine.metadata, {
|
|
...(args.metadata ?? {}),
|
|
...(args.metrics ? { metrics: args.metrics } : {}),
|
|
...(args.inventory ? { inventory: args.inventory } : {}),
|
|
})
|
|
|
|
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,
|
|
status: machine.status,
|
|
lastHeartbeatAt: machine.lastHeartbeatAt,
|
|
metadata: machine.metadata,
|
|
},
|
|
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 derivedStatus =
|
|
machine.status ??
|
|
(machine.lastHeartbeatAt && now - machine.lastHeartbeatAt <= 5 * 60 * 1000 ? "online" : machine.lastHeartbeatAt ? "offline" : "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
|
|
}
|
|
}
|
|
|
|
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,
|
|
status: derivedStatus,
|
|
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 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 }
|
|
},
|
|
})
|