From 68ace0a85894f569bff7691906ecd62204b7701a Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 16 Oct 2025 19:14:46 -0300 Subject: [PATCH] refactor: quality workflow, docs, tests --- .github/workflows/quality-checks.yml | 43 ++++++ apps/desktop/src/main.tsx | 2 +- convex/alerts.ts | 4 +- convex/machines.ts | 125 ++++++++++++---- convex/migrations.ts | 34 +++-- convex/tickets.ts | 28 ++-- docs/DEPLOY-RUNBOOK.md | 124 ++++++++++++++++ docs/DEV.md | 138 ++++++++---------- docs/STATUS-2025-10-16.md | 51 +++++++ package.json | 2 +- src/app/api/machines/companies/route.ts | 46 +++--- src/app/api/machines/heartbeat/route.ts | 25 ++-- src/app/api/machines/inventory/route.ts | 34 +++-- src/app/api/machines/provisioning/route.ts | 26 ++-- src/app/api/machines/register/route.ts | 28 ++-- src/app/api/machines/session/route.ts | 114 ++++----------- src/app/api/machines/sessions/route.ts | 21 ++- src/app/login/login-page-client.tsx | 21 ++- .../tickets/ticket-summary-header.tsx | 2 +- src/components/ui/copy-button.tsx | 49 +++++++ src/components/ui/crossblur.tsx | 21 +++ src/server/convex-client.ts | 24 +++ src/server/machines/context.ts | 70 +++++++++ tests/email-smtp.test.ts | 49 ++++--- tests/time.test.ts | 3 +- types/pdfkit-standalone.d.ts | 3 +- vitest.config.ts | 1 + 27 files changed, 758 insertions(+), 330 deletions(-) create mode 100644 .github/workflows/quality-checks.yml create mode 100644 docs/DEPLOY-RUNBOOK.md create mode 100644 docs/STATUS-2025-10-16.md create mode 100644 src/components/ui/copy-button.tsx create mode 100644 src/components/ui/crossblur.tsx create mode 100644 src/server/convex-client.ts create mode 100644 src/server/machines/context.ts diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml new file mode 100644 index 0000000..ec411db --- /dev/null +++ b/.github/workflows/quality-checks.yml @@ -0,0 +1,43 @@ +name: Quality Checks + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + lint-test-build: + name: Lint, Test and Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm prisma:generate + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 3fd3786..7d17b28 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -543,7 +543,7 @@ function App() { const redirectTarget = persona === "manager" ? "/dashboard" : "/portal/tickets" const url = `${resolvedAppUrl}/machines/handshake?token=${encodeURIComponent(token)}&redirect=${encodeURIComponent(redirectTarget)}` window.location.href = url - }, [token, config?.accessRole, resolvedAppUrl]) + }, [token, config?.accessRole, resolvedAppUrl, store]) async function reprovision() { if (!store) return diff --git a/convex/alerts.ts b/convex/alerts.ts index 8b0b9ab..eeb44d3 100644 --- a/convex/alerts.ts +++ b/convex/alerts.ts @@ -1,7 +1,5 @@ -import { action, mutation, query } from "./_generated/server" -import { api } from "./_generated/api" +import { mutation, query } from "./_generated/server" import { v } from "convex/values" -import type { Id } from "./_generated/dataModel" export const log = mutation({ args: { diff --git a/convex/machines.ts b/convex/machines.ts index 683be89..8a7fefb 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -107,7 +107,7 @@ async function getActiveToken( const tokenHash = hashToken(tokenValue) const token = await ctx.db .query("machineTokens") - .withIndex("by_token_hash", (q: any) => q.eq("tokenHash", tokenHash)) + .withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash)) .unique() if (!token) { @@ -163,6 +163,42 @@ function mergeMetadata(current: unknown, patch: Record) { return base } +type JsonRecord = Record + +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 @@ -179,16 +215,16 @@ async function createTicketForAlert( 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)) + .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: any) => q.eq("tenantId", tenantId)).first() + 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: any) => q.eq("categoryId", category._id)) + .withIndex("by_category_order", (q) => q.eq("categoryId", category._id)) .first() if (!subcategory) return null @@ -217,20 +253,32 @@ async function createTicketForAlert( async function evaluatePostureAndMaybeRaise( ctx: MutationCtx, machine: Doc<"machines">, - args: { metrics?: any; inventory?: any; metadata?: any } + args: { + metrics?: JsonRecord | null + inventory?: JsonRecord | null + metadata?: JsonRecord | null + } ) { const findings: PostureFinding[] = [] // Janela temporal de CPU (5 minutos) const now = Date.now() - const metrics = args.metrics ?? (args.metadata?.metrics ?? null) - const metaObj = machine.metadata && typeof machine.metadata === "object" ? (machine.metadata as Record) : {} - const prevWindow: Array<{ ts: number; usage: number }> = Array.isArray((metaObj as any).cpuWindow) - ? (((metaObj as any).cpuWindow as Array).map((p) => ({ ts: Number(p.ts ?? 0), usage: Number(p.usage ?? NaN) })).filter((p) => Number.isFinite(p.ts) && Number.isFinite(p.usage))) - : [] + 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 = Number((metrics as any)?.cpuUsagePercent ?? (metrics as any)?.cpu_usage_percent ?? NaN) - if (Number.isFinite(usage)) { + const usage = + ensureFiniteNumber(metrics?.["cpuUsagePercent"]) ?? ensureFiniteNumber(metrics?.["cpu_usage_percent"]) + if (usage !== null) { window.push({ ts: now, usage }) } if (window.length > 0) { @@ -240,30 +288,48 @@ async function evaluatePostureAndMaybeRaise( } } - const inventory = args.inventory ?? (args.metadata?.inventory ?? null) - if (inventory && typeof inventory === "object") { - const services = (inventory as any).services - if (Array.isArray(services)) { + 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((s: any) => typeof s?.name === "string" && String(s.status ?? s?.Status ?? "").toLowerCase() !== "running") + 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 = String(firstDown.name ?? firstDown.Name ?? "serviço") + 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 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) { - const model = failing?.model_name ?? failing?.model_family ?? "Disco" - const serial = failing?.serial_number ?? failing?.device?.name ?? "—" - const temp = failing?.temperature?.current ?? failing?.temperature?.value ?? null - const details = temp ? `${model} (${serial}) · ${temp}ºC` : `${model} (${serial})` + 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" }) } } @@ -279,8 +345,8 @@ async function evaluatePostureAndMaybeRaise( postureAlerts: findings, lastPostureAt: now, } - const prevMeta = (machine.metadata && typeof machine.metadata === "object") ? (machine.metadata as Record) : null - const lastAtPrev = typeof prevMeta?.lastPostureAt === "number" ? (prevMeta!.lastPostureAt as number) : 0 + const prevMeta = ensureRecord(machine.metadata) + const lastAtPrev = ensureFiniteNumber(prevMeta?.["lastPostureAt"]) ?? 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 @@ -934,10 +1000,9 @@ export const rename = mutation({ args: { machineId: v.id("machines"), actorId: v.id("users"), - tenantId: v.optional(v.string()), hostname: v.string(), }, - handler: async (ctx, { machineId, actorId, tenantId, hostname }) => { + handler: async (ctx, { machineId, actorId, hostname }) => { // Reutiliza requireStaff através de tickets.ts helpers const machine = await ctx.db.get(machineId) if (!machine) { diff --git a/convex/migrations.ts b/convex/migrations.ts index 08a9c57..48eac85 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -204,24 +204,32 @@ async function ensureCompany( let id: Id<"companies"> if (existing) { + const existingIsAvulso = existing.isAvulso ?? undefined + const targetIsAvulso = payload.isAvulso ?? existingIsAvulso + const targetCnpj = payload.cnpj ?? undefined + const targetDomain = payload.domain ?? undefined + const targetPhone = payload.phone ?? undefined + const targetDescription = payload.description ?? undefined + const targetAddress = payload.address ?? undefined + const needsPatch = existing.name !== payload.name || - (existing as any).isAvulso !== (payload.isAvulso ?? (existing as any).isAvulso) || - existing.cnpj !== (payload.cnpj ?? undefined) || - existing.domain !== (payload.domain ?? undefined) || - existing.phone !== (payload.phone ?? undefined) || - existing.description !== (payload.description ?? undefined) || - existing.address !== (payload.address ?? undefined) || + existingIsAvulso !== targetIsAvulso || + (existing.cnpj ?? undefined) !== targetCnpj || + (existing.domain ?? undefined) !== targetDomain || + (existing.phone ?? undefined) !== targetPhone || + (existing.description ?? undefined) !== targetDescription || + (existing.address ?? undefined) !== targetAddress || existing.provisioningCode !== payload.provisioningCode if (needsPatch) { await ctx.db.patch(existing._id, { name: payload.name, - isAvulso: payload.isAvulso, - cnpj: payload.cnpj, - domain: payload.domain, - phone: payload.phone, - description: payload.description, - address: payload.address, + isAvulso: targetIsAvulso, + cnpj: targetCnpj, + domain: targetDomain, + phone: targetPhone, + description: targetDescription, + address: targetAddress, provisioningCode: payload.provisioningCode, updatedAt: Date.now(), }) @@ -359,7 +367,7 @@ export const exportTenantSnapshot = query({ companies: companies.map((company) => ({ slug: company.slug, name: company.name, - isAvulso: (company as any).isAvulso ?? false, + isAvulso: company.isAvulso ?? false, cnpj: company.cnpj ?? null, domain: company.domain ?? null, phone: company.phone ?? null, diff --git a/convex/tickets.ts b/convex/tickets.ts index a604b77..c273960 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -347,7 +347,7 @@ export const list = query({ priority: t.priority, channel: t.channel, queue: queueName, - company: company ? { id: company._id, name: company.name, isAvulso: (company as any).isAvulso ?? false } : null, + company: company ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } : null, requester: requester && { id: requester._id, name: requester.name, @@ -377,14 +377,14 @@ export const list = query({ subcategory: subcategorySummary, workSummary: { totalWorkedMs: t.totalWorkedMs ?? 0, - internalWorkedMs: (t as any).internalWorkedMs ?? 0, - externalWorkedMs: (t as any).externalWorkedMs ?? 0, + internalWorkedMs: t.internalWorkedMs ?? 0, + externalWorkedMs: t.externalWorkedMs ?? 0, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, - workType: (activeSession as any).workType ?? "INTERNAL", + workType: activeSession.workType ?? "INTERNAL", } : null, }, @@ -525,7 +525,7 @@ export const getById = query({ priority: t.priority, channel: t.channel, queue: queueName, - company: company ? { id: company._id, name: company.name, isAvulso: (company as any).isAvulso ?? false } : null, + company: company ? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false } : null, requester: requester && { id: requester._id, name: requester.name, @@ -566,14 +566,14 @@ export const getById = query({ : null, workSummary: { totalWorkedMs: t.totalWorkedMs ?? 0, - internalWorkedMs: (t as any).internalWorkedMs ?? 0, - externalWorkedMs: (t as any).externalWorkedMs ?? 0, + internalWorkedMs: t.internalWorkedMs ?? 0, + externalWorkedMs: t.externalWorkedMs ?? 0, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, - workType: (activeSession as any).workType ?? "INTERNAL", + workType: activeSession.workType ?? "INTERNAL", } : null, }, @@ -1130,14 +1130,14 @@ export const workSummary = query({ return { ticketId, totalWorkedMs: ticket.totalWorkedMs ?? 0, - internalWorkedMs: (ticket as any).internalWorkedMs ?? 0, - externalWorkedMs: (ticket as any).externalWorkedMs ?? 0, + internalWorkedMs: ticket.internalWorkedMs ?? 0, + externalWorkedMs: ticket.externalWorkedMs ?? 0, activeSession: activeSession ? { id: activeSession._id, agentId: activeSession.agentId, startedAt: activeSession.startedAt, - workType: (activeSession as any).workType ?? "INTERNAL", + workType: activeSession.workType ?? "INTERNAL", } : null, } @@ -1275,7 +1275,7 @@ export const pauseWork = mutation({ pauseNote: note ?? "", }) - const sessionType = ((session as any).workType ?? "INTERNAL").toUpperCase() + const sessionType = (session.workType ?? "INTERNAL").toUpperCase() const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0 const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0 @@ -1283,8 +1283,8 @@ export const pauseWork = mutation({ working: false, activeSessionId: undefined, totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs, - internalWorkedMs: ((ticket as any).internalWorkedMs ?? 0) + deltaInternal, - externalWorkedMs: ((ticket as any).externalWorkedMs ?? 0) + deltaExternal, + internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal, + externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal, updatedAt: now, }) diff --git a/docs/DEPLOY-RUNBOOK.md b/docs/DEPLOY-RUNBOOK.md new file mode 100644 index 0000000..3cb8b30 --- /dev/null +++ b/docs/DEPLOY-RUNBOOK.md @@ -0,0 +1,124 @@ +# Deploy runbook (Swarm) + +Este guia documenta o fluxo de deploy atual e os principais passos de diagnóstico/correção que resolveram o problema do front não atualizar mesmo com o CI verde. + +## Visão geral (como você trabalha) + +- Você dá push na `main` e aguarda o GitHub Actions concluir. +- O pipeline cria um build imutável no servidor em `/home/renan/apps/sistema.build.`. +- Um symlink estável aponta para o release ativo: `/home/renan/apps/sistema.current`. +- O serviço `sistema_web` monta sempre `/home/renan/apps/sistema.current:/app`. Para atualizar, basta mudar o symlink e forçar a task. + +Resultado: front/back sobem com o novo código sem editar o stack a cada release. + +## Fluxo de release (mínimo) + +1. Gerar build em `/home/renan/apps/sistema.build.` (CI faz isso). +2. Atualizar symlink: `ln -sfn /home/renan/apps/sistema.build. /home/renan/apps/sistema.current`. +3. Rollout do serviço web: `docker service update --force sistema_web`. +4. Opcional: se o `stack.yml` mudou, aplicar: `docker stack deploy --with-registry-auth -c /home/renan/apps/sistema.build./stack.yml sistema`. + +## Stack estável (essência) + +- Mount fixo: `/home/renan/apps/sistema.current:/app` (não interpolar APP_DIR). +- Comando inline (sem script), com migrations na subida: + - `command: ["bash","-lc","corepack enable && corepack prepare pnpm@9 --activate && pnpm exec prisma migrate deploy && pnpm start -p 3000"]` +- Env obrigatórias (URLs válidas): + - `DATABASE_URL=file:/app/data/db.sqlite` + - `NEXT_PUBLIC_CONVEX_URL=http://sistema_convex_backend:3210` + - `NEXT_PUBLIC_APP_URL=https://tickets.esdrasrenan.com.br` + - `BETTER_AUTH_URL=https://tickets.esdrasrenan.com.br` +- Update com `stop-first` (evita `database is locked` em SQLite) + healthcheck. + +## Prisma/SQLite do stack + +- O volume do stack é namespaced: `sistema_sistema_db` (não `sistema_db`). +- Ao operar Prisma fora do Swarm, use SEMPRE este volume e a mesma `DATABASE_URL`: + +``` +APP_DIR=/home/renan/apps/sistema.current +docker run --rm -it \ + -e DATABASE_URL=file:/app/data/db.sqlite \ + -v "$APP_DIR:/app" -v sistema_sistema_db:/app/data -w /app \ + node:20-bullseye bash -lc 'corepack enable; corepack prepare pnpm@9 --activate; pnpm i --no-frozen-lockfile; pnpm exec prisma migrate status' +``` + +## Diagnóstico rápido + +- Ver a task atual + erros: `docker service ps --no-trunc sistema_web` +- Logs frescos do serviço: `docker service logs --since=2m -f sistema_web` +- Spec aplicado (Args + Mounts): + +``` +docker service inspect sistema_web \ + --format '{{json .Spec.TaskTemplate.ContainerSpec.Args}} {{json .Spec.TaskTemplate.ContainerSpec.Mounts}}' +``` + +- Envs do serviço: `docker service inspect sistema_web --format '{{json .Spec.TaskTemplate.ContainerSpec.Env}}'` + +## O incidente (front não atualizava) — causa e correções + +Sintomas: +- Actions verde, mas UI antiga; logs com rollbacks de `docker service update`. + +Causas encontradas: +1) Serviço ainda montava build antigo e comando antigo (spec não mudava). + - Inspect mostrava `Source=/home/renan/apps/sistema.build. -> /app` e comando inline antigo. + - Correção: redeploy do stack com mount em `/home/renan/apps/sistema.current` + `docker service update --force sistema_web`. + +2) Migration P3009 ("failed migrations") no SQLite do stack. + - Motivo: resolver/aplicar migrations no volume errado (`sistema_db`), enquanto o serviço usa `sistema_sistema_db`. + - Correção determinística: + - `docker service scale sistema_web=0` + - `prisma migrate resolve --rolled-back 20251015223259_add_company_provisioning_code` no volume `sistema_sistema_db` (comando acima em "Prisma/SQLite do stack"). + - `pnpm exec prisma migrate deploy` + - `docker service scale sistema_web=1` (ou `update --force`). + +3) Rollback por script ausente (`/app/scripts/start-web.sh`). + - Task caía com exit 127 porque o build não continha o script. + - Correção: voltar ao comando inline no stack (sem depender do script) OU garantir o script no build e executável. + +4) Falha de env (Invalid URL em `NEXT_PUBLIC_APP_URL`/`BETTER_AUTH_URL`). + - Correção: definir URLs válidas no stack ou via `docker service update --env-add ...`. + +## Cheatsheet de correções + +- Forçar rollout da task: + - `docker service update --force sistema_web` + +- Aplicar build novo (sem tocar stack): + - `ln -sfn /home/renan/apps/sistema.build. /home/renan/apps/sistema.current` + - `docker service update --force sistema_web` + +- Corrigir mount/args no serviço (hotfix): + +``` +docker service update \ + --mount-rm target=/app \ + --mount-add type=bind,src=/home/renan/apps/sistema.current,dst=/app \ + --args 'bash -lc "corepack enable && corepack prepare pnpm@9 --activate && pnpm exec prisma migrate deploy && pnpm start -p 3000"' \ + sistema_web +``` + +- Resolver P3009 (volume certo) e aplicar migrations: + +``` +APP_DIR=/home/renan/apps/sistema.current +docker service scale sistema_web=0 +docker run --rm -it -e DATABASE_URL=file:/app/data/db.sqlite \ + -v "$APP_DIR:/app" -v sistema_sistema_db:/app/data -w /app \ + node:20-bullseye bash -lc 'corepack enable; corepack prepare pnpm@9 --activate; pnpm i --no-frozen-lockfile; pnpm exec prisma migrate resolve --rolled-back 20251015223259_add_company_provisioning_code; pnpm exec prisma migrate deploy' +docker service scale sistema_web=1 +``` + +- Criar DB se faltar (P1003): + - `docker run --rm -v sistema_sistema_db:/data busybox sh -lc ': >/data/db.sqlite'` + +- Ajustar envs em runtime: + - `docker service update --env-add NEXT_PUBLIC_APP_URL=https://tickets.esdrasrenan.com.br --env-add BETTER_AUTH_URL=https://tickets.esdrasrenan.com.br sistema_web` + +## Notas finais + +- Como o stack monta `/home/renan/apps/sistema.current`, um novo release exige apenas atualizar o symlink e forçar a task. O `stack.yml` só precisa ser redeployado quando você altera labels/envs/serviços. +- Se a UI parecer não mudar, valide o mount/args via inspect, confira logs da task atual e force hard‑reload no navegador. + diff --git a/docs/DEV.md b/docs/DEV.md index a10c4c0..fd5a2db 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -1,79 +1,68 @@ -# Guia de DEV — Banco (Prisma), Auth e Desktop (Tauri) +# Guia de Desenvolvimento — 16/10/2025 -Este guia descreve o que foi corrigido, por quê e como seguir no DEV, incluindo como gerar o executável do Desktop (Tauri) localmente. +Este documento consolida o estado atual do ambiente de desenvolvimento, descreve como rodar lint/test/build localmente (e no CI) e registra erros recorrentes com as respectivas soluções. -## O que foi feito e por quê +## Resumo rápido -- Ajuste do Prisma no DEV (P2021) - - Problema: o Prisma CLI lia o `.env` da raiz (produção) e o banco local não tinha as tabelas do Better Auth (ex.: `AuthUser`, `AuthSession`). - - Decisão: evitar conflito entre `.env` (raiz) e `prisma/.env` — usamos `DATABASE_URL=...` inline nos comandos do Prisma, apontando para `./prisma/db.dev.sqlite` apenas no DEV. - - Resultado: banco `prisma/prisma/db.dev.sqlite` criado/sincronizado e seed de usuários executado. +- **Node/PNPM**: Node 20 + pnpm 9 (habilite via `corepack enable && corepack prepare pnpm@9 --activate`). +- **Lint/Test/Build**: `pnpm lint`, `pnpm test`, `pnpm build`. O script de testes usa `vitest --run --passWithNoTests`, eliminando o modo watch interativo. +- **Banco DEV**: SQLite em `prisma/prisma/db.dev.sqlite`. Defina `DATABASE_URL="file:./prisma/db.dev.sqlite"` ao chamar CLI do Prisma. +- **Desktop (Tauri)**: fonte em `apps/desktop`. Usa Radix tabs + componentes shadcn-like, integra com os endpoints `/api/machines/*` e suporta atualização automática via GitHub Releases. +- **CI**: Workflow `Quality Checks` roda lint/test/build para pushes e PRs na `main`, além do pipeline de deploy existente. -- Migração das abas do Desktop para shadcn/Radix - - O Tauri não roda Next.js. Para manter o visual consistente com o web, migramos as abas para um wrapper shadcn-like usando Radix Tabs e Tailwind (`apps/desktop/src/components/ui/tabs.tsx`). - - Incluímos badge de status equivalente ao web e o botão “Enviar inventário agora” (POST `/api/machines/inventory`). +## Banco de dados (Prisma) -- CI (GitHub Actions) e lockfile - - Erro resolvido: `ERR_PNPM_OUTDATED_LOCKFILE` em `apps/desktop`. Atualizamos o `pnpm-lock.yaml` para refletir as novas dependências do Desktop. - - O workflow do desktop usa `--frozen-lockfile`; manter o lockfile em sincronia evita falhas. +1. Gere/atualize o schema local: -## Fluxo de banco (DEV) + ```bash + DATABASE_URL="file:./prisma/db.dev.sqlite" pnpm exec prisma db push + DATABASE_URL="file:./prisma/db.dev.sqlite" pnpm prisma:generate + DATABASE_URL="file:./prisma/db.dev.sqlite" pnpm auth:seed + ``` -- Banco de DEV: `file:./prisma/db.dev.sqlite` (arquivo: `prisma/prisma/db.dev.sqlite`). -- Comandos (forçar alvo de DEV no terminal atual): +2. Rode o app Next.js: -``` -DATABASE_URL="file:./prisma/db.dev.sqlite" pnpm exec prisma db push -DATABASE_URL="file:./prisma/db.dev.sqlite" pnpm prisma:generate -DATABASE_URL="file:./prisma/db.dev.sqlite" pnpm auth:seed -``` + ```bash + pnpm dev + ``` -- Rodar app local (Next): +3. Credenciais padrão (seed): `admin@sistema.dev / admin123`. -``` -pnpm dev -``` +> **Por quê inline?** Evitamos declarar `DATABASE_URL` em `prisma/.env` porque o Prisma lê também o `.env` da raiz (produção). O override inline garante isolamento do banco DEV. -- Login de teste (DEV): `admin@sistema.dev / admin123` +## Comandos de qualidade -- Prisma Studio (DEV): +- `pnpm lint`: executa ESLint (flat config) sobre os arquivos do projeto. +- `pnpm test`: Vitest em modo não interativo (`--run --passWithNoTests`). Use `pnpm test -- --watch` somente quando quiser rodar em watch localmente. +- `pnpm build`: `next build --turbopack`. +- `pnpm prisma:generate`: necessário antes do build quando o client Prisma muda. -``` -DATABASE_URL="file:./prisma/db.dev.sqlite" pnpm exec prisma studio -``` +### Automação no CI -Observação: evitar `prisma/.env` nesse setup, pois causa conflito com o `.env` da raiz (o Prisma acusa conflitos e falha o comando). Manter o override inline é a forma mais segura de isolar DEV sem tocar produção. +Arquivo: `.github/workflows/quality-checks.yml` + +Etapas: + +1. Instala dependências (`pnpm install --frozen-lockfile`). +2. `pnpm prisma:generate`. +3. `pnpm lint`. +4. `pnpm test`. +5. `pnpm build`. + +O workflow dispara em todo `push`/`pull_request` para `main` e fornece feedback imediato sem depender do pipeline de deploy. ## Desktop (Tauri) -- Onde foram feitas as mudanças principais: - - `apps/desktop/src/components/ui/tabs.tsx` (Tabs Radix + estilos shadcn-like) - - `apps/desktop/src/main.tsx` (layout com abas: Resumo/Inventário/Diagnóstico/Configurações; status badge; botão “Enviar inventário agora”; seleção do perfil de acesso colaborador/gestor e sincronização do usuário vinculado). - - `apps/desktop/src-tauri/src/agent.rs` (coleta e normalização de hardware, discos, GPUs e inventário estendido por SO). +- Tabs Radix + estilos shadcn: `apps/desktop/src/components/ui/tabs.tsx`. +- Painel principal: `apps/desktop/src/main.tsx` — abas Resumo/Inventário/Diagnóstico/Configurações, envio manual de inventário, seleção de persona (colaborador/gestor) e vínculo com usuário. +- Coleta/hardware: `apps/desktop/src-tauri/src/agent.rs`. +- Variáveis de build: + - `VITE_APP_URL` (URL Web). + - `VITE_API_BASE_URL` (API). -- Variáveis de ambiente do Desktop (em tempo de build): - - `VITE_APP_URL` e `VITE_API_BASE_URL` — por padrão, use a URL da aplicação web. +### Build local -### Atualizações automáticas (GitHub) - -1. Gere o par de chaves do updater (`pnpm tauri signer generate -- -w ~/.tauri/raven.key`) e configure as variáveis de ambiente `TAURI_SIGNING_PRIVATE_KEY` e `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` antes de rodar `pnpm -C apps/desktop tauri build`. -2. Garanta que `bundle.createUpdaterArtifacts` esteja habilitado (já configurado) para gerar os pacotes `.nsis`/`.AppImage` e os arquivos `.sig`. -3. Publique os artefatos de cada SO em um release do GitHub e atualize o `latest.json` público (ex.: no próprio repositório ou em um gist) com `version`, `notes`, `pub_date` e as entradas por plataforma (`url` e `signature`). -4. O agente já consulta o updater ao iniciar e também possui o botão “Verificar atualizações” na aba Configurações. Ao detectar nova versão o download é feito em segundo plano e o app reinicia automaticamente após o `downloadAndInstall`. - -### Build do executável localmente - -Você pode gerar o executável local sem precisar da VPS. O que muda é apenas o sistema operacional alvo (Linux/Windows/macOS). O Tauri recomenda compilar em cada SO para obter o bundle nativo desse SO. Em produção, o GitHub Actions já faz isso em matriz. - -1) Pré-requisitos gerais - - Node 20 + pnpm 9 (via Corepack) - - Rust (stable) — `rustup` instalado - - Dependências do sistema (Linux): - - `libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev build-essential curl wget file` - -2) Instalar deps do desktop e buildar - -``` +```bash corepack enable && corepack prepare pnpm@9 --activate pnpm -C apps/desktop install VITE_APP_URL=http://localhost:3000 \ @@ -81,27 +70,28 @@ VITE_API_BASE_URL=http://localhost:3000 \ pnpm -C apps/desktop tauri build ``` -3) Saída do build - - Os artefatos ficam em: `apps/desktop/src-tauri/target/release/bundle/` - - No Linux: `.AppImage`/`.deb`/`.rpm` (conforme target) - - No Windows/macOS: executável/instalador específicos do SO (para assinatura, usar chaves/AC, se desejado) - - Para liberar atualizações OTA, publique release no GitHub com artefatos e `latest.json` — o plugin de updater verifica a URL configurada em `tauri.conf.json`. +Artefatos: `apps/desktop/src-tauri/target/release/bundle/`. -### Build na VPS x Local +### Atualizações OTA -- Não há diferença funcional além do SO alvo e de possíveis chaves de assinatura. Use a VPS apenas se quiser gerar pacotes Linux em ambiente isolado. Para Windows/macOS, é preferível buildar nesses SOs ou usar a matriz do GitHub Actions (já configurada). +1. Gere chaves (`pnpm tauri signer generate`). +2. Defina `TAURI_SIGNING_PRIVATE_KEY` (+ password) no ambiente de build. +3. Publique os pacotes e um `latest.json` em release GitHub. +4. O app verifica ao iniciar e pelo botão “Verificar atualizações”. -## CI/CD — Observações +## Erros recorrentes e soluções -- `desktop-release.yml` (Tauri): instala dependências, faz build por SO e publica artefatos. Mantendo o `pnpm-lock.yaml` atualizado, o passo `--frozen-lockfile` passa. -- `ci-cd-web-desktop.yml`: já usa `pnpm install --no-frozen-lockfile` no web, evitando falhas em pipelines de integração. - - Smoke de provisionamento pode ser desligado definindo `RUN_MACHINE_SMOKE=false` (default); quando quiser exercitar o fluxo complete register/heartbeat, defina `RUN_MACHINE_SMOKE=true`. +| Sintoma | Causa | Correção | +| --- | --- | --- | +| `ERR_PNPM_OUTDATED_LOCKFILE` no pipeline | Dependências do desktop alteradas sem atualizar `pnpm-lock.yaml` | Rodar `pnpm install` na raiz e commitar o lockfile. | +| Prisma falha com `P2021` / tabelas Better Auth inexistentes | CLI leu `.env` da raiz (produção) | Usar `DATABASE_URL="file:./prisma/db.dev.sqlite"` nos comandos. | +| Vitest trava em modo watch | Script `pnpm test` sem `--run` e CI detecta TTY | Ajustado para `vitest --run --passWithNoTests`. Localmente, use `pnpm test -- --watch` se quiser. | +| Desktop não encontra updater | Falta `latest.json` ou assinatura inválida | Publicar release com `*.sig` e `latest.json` apontando para os pacotes corretos. | -## Troubleshooting +## Referências úteis -- Sign-in 500 após `db push`/seed: - - Verifique o terminal do app e confirme a existência da tabela `AuthUser` no Prisma Studio (alvo DEV). +- **Deploy (Swarm)**: veja `docs/DEPLOY-RUNBOOK.md`. +- **Plano do agente desktop / heartbeat**: `docs/plano-app-desktop-maquinas.md`. +- **Histórico de incidentes**: `docs/historico-agente-desktop-2025-10-10.md`. -- `ERR_PNPM_OUTDATED_LOCKFILE` no Desktop: - - Atualize `pnpm-lock.yaml` no root após alterar dependências de `apps/desktop/package.json`. - - Alternativa: usar `--no-frozen-lockfile` (não recomendado para releases reproduzíveis). +> Última revisão: 16/10/2025. Atualize este guia sempre que o fluxo de DEV ou automações mudarem. diff --git a/docs/STATUS-2025-10-16.md b/docs/STATUS-2025-10-16.md new file mode 100644 index 0000000..27489b7 --- /dev/null +++ b/docs/STATUS-2025-10-16.md @@ -0,0 +1,51 @@ +# Status do Projeto — 16/10/2025 + +Documento de referência sobre o estado atual do sistema (web + desktop), melhorias recentes e pontos de atenção. + +## 1. Panorama + +- **Web (Next.js 15 + Convex)**: build limpo (`pnpm build`), lint sem avisos e testes estáveis (Vitest em modo não interativo). +- **Desktop (Tauri)**: fluxo de provisionamento e heartbeat operacional; inventário consolidado com coleta multi-plataforma; atualizações OTA suportadas. +- **CI**: workflow `Quality Checks` roda lint/test/build em todo push/PR na `main`; pipeline de deploy (`ci-cd-web-desktop.yml`) permanece responsável por sincronizar com a VPS. +- **Infra**: deploy documentado no runbook (Swarm com symlink `sistema.current`). Migrações Prisma e variáveis críticas mapeadas. + +## 2. Melhorias concluídas em 16/10/2025 + +| Item | Descrição | Impacto | +| --- | --- | --- | +| **Centralização Convex** | Helpers `createConvexClient` e normalização do cookie da máquina (`src/server/convex-client.ts`, `src/server/machines/context.ts`). | Código das rotas `/api/machines/*` ficou mais enxuto e resiliente a erros de configuração. | +| **Auth/Login redirect** | Redirecionamento baseado em role/persona sem uso de `any`, com dependências explícitas (`src/app/login/login-page-client.tsx`). | Evita warnings de hooks e garante rota correta para máquinas/colaboradores. | +| **Ticket header** | Sincronização do responsável com dependências completas (`ticket-summary-header.tsx`). | Removeu warning do lint e previne estados inconsistentes. | +| **Posture / inventário** | Type guards e normalização de métricas SMART/serviços (`convex/machines.ts`). | Reduziu `any`, melhorou detecção de alertas e consistência do metadata. | +| **Docs** | Revisão completa de `docs/DEV.md`, novo `STATUS-2025-10-16.md`, estrutura uniforme e casos de erro registrados. | Documentação enxuta e atualizada, com trilhas claras para DEV/CI/Deploy. | +| **Testes no CI** | Novo workflow `.github/workflows/quality-checks.yml` e script `pnpm test` em modo não-interativo. | Previne “travamentos” e garante checagens de qualidade automáticas. | + +## 3. Pontos de atenção (curto prazo) + +- **Migrações Prisma em produção**: qualquer mudança requer executar no volume `sistema_sistema_db` (ver `docs/DEPLOY-RUNBOOK.md`). Atenção para evitar regressões P3009. +- **Atualização dos artefatos Tauri**: releases exigem `latest.json` atualizado e assinatura (`*.sig`). Automação via GitHub Actions já preparada, mas depende de manter as chaves seguras. +- **Seeds Better Auth**: se novos perfis/roles forem adicionados, atualizar `scripts/seed-auth.mjs` e o seed do Convex. +- **Variáveis críticas**: `NEXT_PUBLIC_APP_URL`, `BETTER_AUTH_URL`, `MACHINE_PROVISIONING_SECRET` e `NEXT_PUBLIC_CONVEX_URL` devem ser válidas no stack — qualquer alteração de domínio implica revisar `.env` e `stack.yml`. + +## 4. Backlog recomendado + +1. **Testes end-to-end**: cobrir fluxo de provisionamento (desktop ↔ API) com smoke automatizado (pode rodar condicional no CI). +2. **Autenticação agnóstica**: avaliar suporte para Clerk/Auth0 conforme docs do Convex (custom JWTs). +3. **Observabilidade**: adicionar métricas/alertas para heartbeats em atraso (Convex + dashboards). +4. **Documentação do Desktop Installer**: guias por SO sobre instalação/assinatura e troubleshooting do updater. + +## 5. Casos de erro conhecidos + +| Cenário | Sintoma | Como resolver | +| --- | --- | --- | +| Token de máquina revogado | POST `/api/machines/sessions` retorna 401 e desktop volta ao onboarding | Reprovisionar pela UI do agente; garantir que `machineToken` foi atualizado. | +| Falha de heartbeat | Logs com `Falha ao registrar heartbeat` + status 500 | Verificar `NEXT_PUBLIC_CONVEX_URL` e conectividade. Roda `pnpm convext:dev` em DEV para confirmar schema. | +| Updater sem atualização | Desktop fica em “Procurando atualização” indefinidamente | Confirmar release publicado com `latest.json` apontando para URLs públicas do bundle e assinaturas válidas. | + +## 6. Próximos passos imediatos + +- Monitorar execução do novo workflow de qualidade em PRs. +- Garantir que a equipe esteja ciente do procedimento atualizado de deploy (symlink + service update). +- Revisar backlog acima e priorizar smoke tests para o fluxo da máquina. + +_Última atualização: 16/10/2025 (UTC-3)._ diff --git a/package.json b/package.json index 903ad33..06a06d3 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "eslint", "prisma:generate": "prisma generate", "convex:dev": "convex dev", - "test": "vitest", + "test": "vitest --run --passWithNoTests", "auth:seed": "node scripts/seed-auth.mjs", "queues:ensure": "node scripts/ensure-default-queues.mjs", "desktop:dev": "pnpm --filter appsdesktop tauri dev", diff --git a/src/app/api/machines/companies/route.ts b/src/app/api/machines/companies/route.ts index 9ffb6b9..fd8022e 100644 --- a/src/app/api/machines/companies/route.ts +++ b/src/app/api/machines/companies/route.ts @@ -1,6 +1,4 @@ import { randomBytes } from "crypto" -import { ConvexHttpClient } from "convex/browser" - import { Prisma } from "@prisma/client" import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" @@ -8,6 +6,7 @@ import { env } from "@/lib/env" import { normalizeSlug, slugify } from "@/lib/slug" import { prisma } from "@/lib/prisma" import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" export const runtime = "nodejs" @@ -33,10 +32,7 @@ function extractSecret(request: Request, url: URL): string | null { } async function ensureConvexCompany(params: { tenantId: string; slug: string; name: string; provisioningCode: string }) { - if (!env.NEXT_PUBLIC_CONVEX_URL) { - throw new Error("Convex não configurado") - } - const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL) + const client = createConvexClient() await client.mutation(api.companies.ensureProvisioned, params) } @@ -161,12 +157,19 @@ export async function POST(request: Request) { }, })) - await ensureConvexCompany({ - tenantId, - slug: company.slug, - name: company.name, - provisioningCode: company.provisioningCode, - }) + try { + await ensureConvexCompany({ + tenantId, + slug: company.slug, + name: company.name, + provisioningCode: company.provisioningCode, + }) + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error + } return jsonWithCors( { @@ -188,12 +191,19 @@ export async function POST(request: Request) { try { const fallback = await prisma.company.findFirst({ where: { tenantId, slug: normalizedSlug } }) if (fallback) { - await ensureConvexCompany({ - tenantId, - slug: fallback.slug, - name: fallback.name, - provisioningCode: fallback.provisioningCode, - }) + try { + await ensureConvexCompany({ + tenantId, + slug: fallback.slug, + name: fallback.name, + provisioningCode: fallback.provisioningCode, + }) + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error + } return jsonWithCors( { company: { diff --git a/src/app/api/machines/heartbeat/route.ts b/src/app/api/machines/heartbeat/route.ts index 259094a..d4902c9 100644 --- a/src/app/api/machines/heartbeat/route.ts +++ b/src/app/api/machines/heartbeat/route.ts @@ -1,9 +1,8 @@ import { z } from "zod" -import { ConvexHttpClient } from "convex/browser" import { api } from "@/convex/_generated/api" -import { env } from "@/lib/env" import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" const heartbeatSchema = z.object({ machineToken: z.string().min(1), @@ -28,13 +27,19 @@ export async function OPTIONS(request: Request) { } export async function POST(request: Request) { + const origin = request.headers.get("origin") if (request.method !== "POST") { - return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors({ error: "Método não permitido" }, 405, origin, CORS_METHODS) } - const convexUrl = env.NEXT_PUBLIC_CONVEX_URL - if (!convexUrl) { - return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS) + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error } let payload @@ -45,19 +50,17 @@ export async function POST(request: Request) { return jsonWithCors( { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, 400, - request.headers.get("origin"), + origin, CORS_METHODS ) } - const client = new ConvexHttpClient(convexUrl) - try { const response = await client.mutation(api.machines.heartbeat, payload) - return jsonWithCors(response, 200, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors(response, 200, origin, CORS_METHODS) } catch (error) { console.error("[machines.heartbeat] Falha ao registrar heartbeat", error) const details = error instanceof Error ? error.message : String(error) - return jsonWithCors({ error: "Falha ao registrar heartbeat", details }, 500, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors({ error: "Falha ao registrar heartbeat", details }, 500, origin, CORS_METHODS) } } diff --git a/src/app/api/machines/inventory/route.ts b/src/app/api/machines/inventory/route.ts index a7fc61f..99044cb 100644 --- a/src/app/api/machines/inventory/route.ts +++ b/src/app/api/machines/inventory/route.ts @@ -1,9 +1,8 @@ import { z } from "zod" -import { ConvexHttpClient } from "convex/browser" import { api } from "@/convex/_generated/api" -import { env } from "@/lib/env" import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" const tokenModeSchema = z.object({ machineToken: z.string().min(1), @@ -41,9 +40,16 @@ export async function OPTIONS(request: Request) { } export async function POST(request: Request) { - const convexUrl = env.NEXT_PUBLIC_CONVEX_URL - if (!convexUrl) { - return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS) + const origin = request.headers.get("origin") + + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error } let raw: unknown @@ -53,13 +59,11 @@ export async function POST(request: Request) { return jsonWithCors( { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, 400, - request.headers.get("origin"), + origin, CORS_METHODS ) } - const client = new ConvexHttpClient(convexUrl) - // Modo A: com token da máquina (usa heartbeat para juntar inventário) const tokenParsed = tokenModeSchema.safeParse(raw) if (tokenParsed.success) { @@ -71,11 +75,11 @@ export async function POST(request: Request) { metrics: tokenParsed.data.metrics, inventory: tokenParsed.data.inventory, }) - return jsonWithCors({ ok: true, machineId: result.machineId, expiresAt: result.expiresAt }, 200, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors({ ok: true, machineId: result.machineId, expiresAt: result.expiresAt }, 200, origin, CORS_METHODS) } catch (error) { console.error("[machines.inventory:token] Falha ao atualizar inventário", error) const details = error instanceof Error ? error.message : String(error) - return jsonWithCors({ error: "Falha ao atualizar inventário", details }, 500, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors({ error: "Falha ao atualizar inventário", details }, 500, origin, CORS_METHODS) } } @@ -90,16 +94,16 @@ export async function POST(request: Request) { macAddresses: provParsed.data.macAddresses, serialNumbers: provParsed.data.serialNumbers, inventory: provParsed.data.inventory, - metrics: provParsed.data.metrics, - registeredBy: provParsed.data.registeredBy ?? "agent:inventory", + metrics: provParsed.data.metrics, + registeredBy: provParsed.data.registeredBy ?? "agent:inventory", }) - return jsonWithCors({ ok: true, machineId: result.machineId, status: result.status }, 200, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors({ ok: true, machineId: result.machineId, status: result.status }, 200, origin, CORS_METHODS) } catch (error) { console.error("[machines.inventory:prov] Falha ao fazer upsert de inventário", error) const details = error instanceof Error ? error.message : String(error) - return jsonWithCors({ error: "Falha ao fazer upsert de inventário", details }, 500, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors({ error: "Falha ao fazer upsert de inventário", details }, 500, origin, CORS_METHODS) } } - return jsonWithCors({ error: "Formato de payload não suportado" }, 400, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors({ error: "Formato de payload não suportado" }, 400, origin, CORS_METHODS) } diff --git a/src/app/api/machines/provisioning/route.ts b/src/app/api/machines/provisioning/route.ts index 58f1b2d..c50ece0 100644 --- a/src/app/api/machines/provisioning/route.ts +++ b/src/app/api/machines/provisioning/route.ts @@ -1,9 +1,7 @@ -import { ConvexHttpClient } from "convex/browser" - import { api } from "@/convex/_generated/api" import { prisma } from "@/lib/prisma" -import { env } from "@/lib/env" import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" export const runtime = "nodejs" @@ -54,16 +52,18 @@ export async function POST(request: Request) { return jsonWithCors({ error: "Código de provisionamento inválido" }, 404, origin, CORS_METHODS) } - if (env.NEXT_PUBLIC_CONVEX_URL) { - try { - const client = new ConvexHttpClient(env.NEXT_PUBLIC_CONVEX_URL) - await client.mutation(api.companies.ensureProvisioned, { - tenantId: company.tenantId, - slug: company.slug, - name: company.name, - provisioningCode: company.provisioningCode, - }) - } catch (error) { + try { + const client = createConvexClient() + await client.mutation(api.companies.ensureProvisioned, { + tenantId: company.tenantId, + slug: company.slug, + name: company.name, + provisioningCode: company.provisioningCode, + }) + } catch (error) { + if (error instanceof ConvexConfigurationError) { + console.warn("[machines.provisioning] Convex não configurado; ignorando sincronização de empresa.") + } else { console.error("[machines.provisioning] Falha ao sincronizar empresa no Convex", error) } } diff --git a/src/app/api/machines/register/route.ts b/src/app/api/machines/register/route.ts index c9e9c70..873bc44 100644 --- a/src/app/api/machines/register/route.ts +++ b/src/app/api/machines/register/route.ts @@ -1,13 +1,12 @@ import { z } from "zod" -import { ConvexHttpClient } from "convex/browser" import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" -import { env } from "@/lib/env" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { ensureCollaboratorAccount, ensureMachineAccount } from "@/server/machines-auth" import { createCorsPreflight, jsonWithCors } from "@/server/cors" import { prisma } from "@/lib/prisma" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" const registerSchema = z .object({ @@ -42,13 +41,19 @@ export async function OPTIONS(request: Request) { } export async function POST(request: Request) { + const origin = request.headers.get("origin") if (request.method !== "POST") { - return jsonWithCors({ error: "Método não permitido" }, 405, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors({ error: "Método não permitido" }, 405, origin, CORS_METHODS) } - const convexUrl = env.NEXT_PUBLIC_CONVEX_URL - if (!convexUrl) { - return jsonWithCors({ error: "Convex não configurado" }, 500, request.headers.get("origin"), CORS_METHODS) + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error } let payload @@ -59,12 +64,11 @@ export async function POST(request: Request) { return jsonWithCors( { error: "Payload inválido", details: error instanceof Error ? error.message : String(error) }, 400, - request.headers.get("origin"), + origin, CORS_METHODS ) } - const client = new ConvexHttpClient(convexUrl) try { const provisioningCode = payload.provisioningCode.trim().toLowerCase() const companyRecord = await prisma.company.findFirst({ @@ -76,7 +80,7 @@ export async function POST(request: Request) { return jsonWithCors( { error: "Código de provisionamento inválido" }, 404, - request.headers.get("origin"), + origin, CORS_METHODS ) } @@ -89,7 +93,7 @@ export async function POST(request: Request) { return jsonWithCors( { error: "Informe os dados do colaborador/gestor ao definir o perfil de acesso." }, 400, - request.headers.get("origin"), + origin, CORS_METHODS ) } @@ -195,7 +199,7 @@ export async function POST(request: Request) { collaborator: collaborator ?? null, }, { status: 201 }, - request.headers.get("origin"), + origin, CORS_METHODS ) } catch (error) { @@ -207,6 +211,6 @@ export async function POST(request: Request) { const isConvexError = msg.includes("convexerror") const status = isInvalidCode ? 401 : isCompanyNotFound ? 404 : isConvexError ? 400 : 500 const payload = { error: "Falha ao provisionar máquina", details } - return jsonWithCors(payload, status, request.headers.get("origin"), CORS_METHODS) + return jsonWithCors(payload, status, origin, CORS_METHODS) } } diff --git a/src/app/api/machines/session/route.ts b/src/app/api/machines/session/route.ts index 1d8a27f..f4cc1f4 100644 --- a/src/app/api/machines/session/route.ts +++ b/src/app/api/machines/session/route.ts @@ -1,105 +1,43 @@ import { NextRequest, NextResponse } from "next/server" import { cookies } from "next/headers" -import { ConvexHttpClient } from "convex/browser" import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" -import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" - -const MACHINE_CTX_COOKIE = "machine_ctx" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" +import { + MACHINE_CTX_COOKIE, + extractCollaboratorFromMetadata, + parseMachineCookie, + serializeMachineCookie, + type CollaboratorMetadata, + type MachineContextCookiePayload, +} from "@/server/machines/context" // Força runtime Node.js para leitura consistente de cookies de sessão export const runtime = "nodejs" -type CollaboratorMetadata = { - email: string - name: string | null - role: "collaborator" | "manager" | null -} - -function decodeMachineCookie(value: string) { - try { - const json = Buffer.from(value, "base64url").toString("utf8") - return JSON.parse(json) as { - machineId: string - persona: string | null - assignedUserId: string | null - assignedUserEmail: string | null - assignedUserName: string | null - assignedUserRole: string | null - } - } catch { - return null - } -} - -function encodeMachineCookie(payload: { - machineId: string - persona: string | null - assignedUserId: string | null - assignedUserEmail: string | null - assignedUserName: string | null - assignedUserRole: string | null -}) { - return Buffer.from(JSON.stringify(payload)).toString("base64url") -} - -function extractCollaboratorFromMetadata(metadata: unknown): CollaboratorMetadata | null { - if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { - return null - } - const record = metadata as Record - const raw = record["collaborator"] - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return null - } - const base = raw as Record - const emailValue = base["email"] - if (typeof emailValue !== "string") { - return null - } - const email = emailValue.trim().toLowerCase() - if (!email) { - return null - } - - const nameValue = base["name"] - const roleValue = base["role"] - const name = typeof nameValue === "string" ? (nameValue.trim() || null) : null - const normalizedRole = - typeof roleValue === "string" ? roleValue.trim().toLowerCase() : null - const role = - normalizedRole === "manager" - ? "manager" - : normalizedRole === "collaborator" - ? "collaborator" - : null - - return { - email, - name, - role, - } -} - export async function GET(request: NextRequest) { const session = await assertAuthenticatedSession() if (!session || session.user?.role !== "machine") { return NextResponse.json({ error: "Sessão de máquina não encontrada." }, { status: 403 }) } - const convexUrl = env.NEXT_PUBLIC_CONVEX_URL - if (!convexUrl) { - return NextResponse.json({ error: "Convex não configurado." }, { status: 500 }) + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + throw error } - const client = new ConvexHttpClient(convexUrl) const cookieStore = await cookies() const cookieValue = cookieStore.get(MACHINE_CTX_COOKIE)?.value ?? null - const decoded = cookieValue ? decodeMachineCookie(cookieValue) : null + const decoded = parseMachineCookie(cookieValue) let machineId: Id<"machines"> | null = decoded?.machineId ? (decoded.machineId as Id<"machines">) : null if (!machineId) { @@ -136,7 +74,7 @@ export async function GET(request: NextRequest) { authEmail: string | null } - const metadataCollaborator = extractCollaboratorFromMetadata(context.metadata) + const metadataCollaborator: CollaboratorMetadata | null = extractCollaboratorFromMetadata(context.metadata) let ensuredAssignedUserId = context.assignedUserId let ensuredAssignedUserEmail = context.assignedUserEmail ?? metadataCollaborator?.email ?? null @@ -200,13 +138,13 @@ export async function GET(request: NextRequest) { ensuredPersona ?? (ensuredAssignedUserRole ? ensuredAssignedUserRole.toLowerCase() : null) - const responsePayload = { + const responsePayload: MachineContextCookiePayload = { machineId: context.id, - persona: resolvedPersona, - assignedUserId: ensuredAssignedUserId, - assignedUserEmail: ensuredAssignedUserEmail, - assignedUserName: ensuredAssignedUserName, - assignedUserRole: ensuredAssignedUserRole, + persona: resolvedPersona ?? null, + assignedUserId: ensuredAssignedUserId ?? null, + assignedUserEmail: ensuredAssignedUserEmail ?? null, + assignedUserName: ensuredAssignedUserName ?? null, + assignedUserRole: ensuredAssignedUserRole ?? null, } const response = NextResponse.json({ @@ -224,7 +162,7 @@ export async function GET(request: NextRequest) { const isSecure = request.nextUrl.protocol === "https:" response.cookies.set({ name: MACHINE_CTX_COOKIE, - value: encodeMachineCookie(responsePayload), + value: serializeMachineCookie(responsePayload), httpOnly: true, sameSite: "lax", secure: isSecure, diff --git a/src/app/api/machines/sessions/route.ts b/src/app/api/machines/sessions/route.ts index 3dfe5ac..c0a4ba2 100644 --- a/src/app/api/machines/sessions/route.ts +++ b/src/app/api/machines/sessions/route.ts @@ -2,6 +2,11 @@ import { NextResponse } from "next/server" import { z } from "zod" import { createMachineSession } from "@/server/machines-session" import { applyCorsHeaders, createCorsPreflight, jsonWithCors } from "@/server/cors" +import { + MACHINE_CTX_COOKIE, + serializeMachineCookie, + type MachineContextCookiePayload, +} from "@/server/machines/context" const sessionSchema = z.object({ machineToken: z.string().min(1), @@ -96,18 +101,18 @@ export async function POST(request: Request) { response.cookies.set(name, value, options) } - const machineCookiePayload = { + const machineCookiePayload: MachineContextCookiePayload = { machineId: session.machine.id, - persona: session.machine.persona, - assignedUserId: session.machine.assignedUserId, - assignedUserEmail: session.machine.assignedUserEmail, - assignedUserName: session.machine.assignedUserName, - assignedUserRole: session.machine.assignedUserRole, + persona: session.machine.persona ?? null, + assignedUserId: session.machine.assignedUserId ?? null, + assignedUserEmail: session.machine.assignedUserEmail ?? null, + assignedUserName: session.machine.assignedUserName ?? null, + assignedUserRole: session.machine.assignedUserRole ?? null, } const isSecure = new URL(request.url).protocol === "https:" response.cookies.set({ - name: "machine_ctx", - value: Buffer.from(JSON.stringify(machineCookiePayload)).toString("base64url"), + name: MACHINE_CTX_COOKIE, + value: serializeMachineCookie(machineCookiePayload), httpOnly: true, sameSite: "lax", secure: isSecure, diff --git a/src/app/login/login-page-client.tsx b/src/app/login/login-page-client.tsx index 758c905..b8a8963 100644 --- a/src/app/login/login-page-client.tsx +++ b/src/app/login/login-page-client.tsx @@ -21,23 +21,30 @@ export function LoginPageClient() { const { data: session, isPending } = useSession() const callbackUrl = searchParams?.get("callbackUrl") ?? undefined const [isHydrated, setIsHydrated] = useState(false) + const sessionUser = session?.user + const userId = sessionUser?.id ?? null + const normalizedRole = sessionUser?.role ? sessionUser.role.toLowerCase() : null + const persona = typeof sessionUser?.machinePersona === "string" ? sessionUser.machinePersona.toLowerCase() : null useEffect(() => { if (isPending) return - if (!session?.user) return - const role = (session.user.role ?? "").toLowerCase() - const persona = (session.user as any).machinePersona - ? String((session.user as any).machinePersona).toLowerCase() - : null + if (!userId) return const defaultDest = - role === "machine" + normalizedRole === "machine" ? persona === "manager" ? "/dashboard" : "/portal/tickets" : "/dashboard" const destination = callbackUrl ?? defaultDest router.replace(destination) - }, [callbackUrl, isPending, router, session?.user]) + }, [ + callbackUrl, + isPending, + normalizedRole, + persona, + router, + userId, + ]) useEffect(() => { setIsHydrated(true) diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index f774bd9..5de8bd6 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -307,7 +307,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { }) setQueueSelection(ticket.queue ?? "") setAssigneeSelection(ticket.assignee?.id ?? "") - }, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue]) + }, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, ticket.assignee?.id]) useEffect(() => { if (!editing) return diff --git a/src/components/ui/copy-button.tsx b/src/components/ui/copy-button.tsx new file mode 100644 index 0000000..855a9f3 --- /dev/null +++ b/src/components/ui/copy-button.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useCallback, useState } from "react" +import { Copy, Sparkles } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +interface CopyButtonProps { + value: string + onCopied?: () => void +} + +export function CopyButton({ value, onCopied }: CopyButtonProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(value) + setCopied(true) + onCopied?.() + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error("Falha ao copiar código", error) + } + }, [onCopied, value]) + + return ( + + ) +} diff --git a/src/components/ui/crossblur.tsx b/src/components/ui/crossblur.tsx new file mode 100644 index 0000000..417fd19 --- /dev/null +++ b/src/components/ui/crossblur.tsx @@ -0,0 +1,21 @@ +"use client" + +import { cn } from "@/lib/utils" + +export function Crossblur({ active }: { active: boolean }) { + return ( + + + + ) +} diff --git a/src/server/convex-client.ts b/src/server/convex-client.ts new file mode 100644 index 0000000..4b7c0be --- /dev/null +++ b/src/server/convex-client.ts @@ -0,0 +1,24 @@ +import { ConvexHttpClient } from "convex/browser" + +import { env } from "@/lib/env" + +export class ConvexConfigurationError extends Error { + constructor(message = "Convex não configurado.") { + super(message) + this.name = "ConvexConfigurationError" + } +} + +export function requireConvexUrl(): string { + const url = env.NEXT_PUBLIC_CONVEX_URL + if (!url) { + throw new ConvexConfigurationError() + } + return url +} + +export function createConvexClient(): ConvexHttpClient { + const url = requireConvexUrl() + return new ConvexHttpClient(url) +} + diff --git a/src/server/machines/context.ts b/src/server/machines/context.ts new file mode 100644 index 0000000..b2e22f5 --- /dev/null +++ b/src/server/machines/context.ts @@ -0,0 +1,70 @@ +import { z } from "zod" + +export const MACHINE_CTX_COOKIE = "machine_ctx" + +const machineCookieSchema = z.object({ + machineId: z.string(), + persona: z.string().nullable().optional(), + assignedUserId: z.string().nullable().optional(), + assignedUserEmail: z.string().nullable().optional(), + assignedUserName: z.string().nullable().optional(), + assignedUserRole: z.string().nullable().optional(), +}) + +const collaboratorSchema = z + .object({ + email: z.string().email(), + name: z.string().optional(), + role: z.string().optional(), + }) + .transform(({ email, name, role }) => { + const trimmedRole = role?.trim().toLowerCase() + const normalizedRole = + trimmedRole === "manager" + ? "manager" + : trimmedRole === "collaborator" + ? "collaborator" + : null + + const normalizedName = typeof name === "string" ? name.trim() || null : null + + return { + email: email.trim().toLowerCase(), + name: normalizedName, + role: normalizedRole, + } + }) + +const metadataSchema = z + .object({ + collaborator: collaboratorSchema, + }) + .passthrough() + +export type MachineContextCookiePayload = z.infer +export type CollaboratorMetadata = z.output + +export function parseMachineCookie(value: string | null | undefined): MachineContextCookiePayload | null { + if (!value) return null + try { + const json = Buffer.from(value, "base64url").toString("utf8") + const parsed = JSON.parse(json) + return machineCookieSchema.parse(parsed) + } catch { + return null + } +} + +export function serializeMachineCookie(payload: MachineContextCookiePayload): string { + return Buffer.from(JSON.stringify(machineCookieSchema.parse(payload))).toString("base64url") +} + +export function extractCollaboratorFromMetadata(metadata: unknown): CollaboratorMetadata | null { + if (!metadata) return null + const parsed = metadataSchema.safeParse(metadata) + if (!parsed.success) { + return null + } + return parsed.data.collaborator +} + diff --git a/tests/email-smtp.test.ts b/tests/email-smtp.test.ts index 93a491c..b815462 100644 --- a/tests/email-smtp.test.ts +++ b/tests/email-smtp.test.ts @@ -1,19 +1,35 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" +import { describe, it, expect, vi } from "vitest" // Mock tls to simulate an SMTP server over implicit TLS let lastWrites: string[] = [] vi.mock("tls", () => { + type Listener = (...args: unknown[]) => void + class MockSocket { - listeners: Record = {} + listeners: Record = {} writes: string[] = [] // very small state machine of server responses private step = 0 - on(event: string, cb: Function) { + private enqueue(messages: string | string[], type: "data" | "end" = "data") { + const chunks = Array.isArray(messages) ? messages : [messages] + chunks.forEach((chunk, index) => { + const delay = index === 0 ? 0 : 1 + setTimeout(() => { + if (type === "end") { + void chunk + this.emit("end") + return + } + this.emit("data", Buffer.from(chunk)) + }, delay) + }) + } + on(event: string, cb: Listener) { this.listeners[event] = this.listeners[event] || [] this.listeners[event].push(cb) return this } - removeListener(event: string, cb: Function) { + removeListener(event: string, cb: Listener) { if (!this.listeners[event]) return this this.listeners[event] = this.listeners[event].filter((f) => f !== cb) return this @@ -27,37 +43,36 @@ vi.mock("tls", () => { // Respond depending on client command if (this.step === 0 && line.startsWith("EHLO")) { this.step = 1 - this.emit("data", Buffer.from("250-local\r\n")) - this.emit("data", Buffer.from("250 OK\r\n")) + this.enqueue(["250-local\r\n", "250 OK\r\n"]) } else if (this.step === 1 && line === "AUTH LOGIN") { this.step = 2 - this.emit("data", Buffer.from("334 VXNlcm5hbWU6\r\n")) + this.enqueue("334 VXNlcm5hbWU6\r\n") } else if (this.step === 2) { this.step = 3 - this.emit("data", Buffer.from("334 UGFzc3dvcmQ6\r\n")) + this.enqueue("334 UGFzc3dvcmQ6\r\n") } else if (this.step === 3) { this.step = 4 - this.emit("data", Buffer.from("235 Auth OK\r\n")) + this.enqueue("235 Auth OK\r\n") } else if (this.step === 4 && line.startsWith("MAIL FROM:")) { this.step = 5 - this.emit("data", Buffer.from("250 FROM OK\r\n")) + this.enqueue("250 FROM OK\r\n") } else if (this.step === 5 && line.startsWith("RCPT TO:")) { this.step = 6 - this.emit("data", Buffer.from("250 RCPT OK\r\n")) + this.enqueue("250 RCPT OK\r\n") } else if (this.step === 6 && line === "DATA") { this.step = 7 - this.emit("data", Buffer.from("354 End data with .\r\n")) + this.enqueue("354 End data with .\r\n") } else if (this.step === 7 && line.endsWith(".")) { this.step = 8 - this.emit("data", Buffer.from("250 Queued\r\n")) + this.enqueue("250 Queued\r\n") } else if (this.step === 8 && line === "QUIT") { - this.emit("end") + this.enqueue("", "end") } } end() {} } - function connect(_port: number, _host: string, _opts: unknown, cb?: Function) { + function connect(_port: number, _host: string, _opts: unknown, cb?: () => void) { const socket = new MockSocket() lastWrites = socket.writes // initial server greeting @@ -92,7 +107,7 @@ describe("sendSmtpMail", () => { it("extracts envelope address from parentheses or raw email", async () => { const { sendSmtpMail } = await import("@/server/email-smtp") - const tlsMock = await import("tls" as any) + const tlsMock = (await import("tls")) as unknown as { __getLastWrites: () => string[] } await sendSmtpMail( { host: "smtp.mock", @@ -105,7 +120,7 @@ describe("sendSmtpMail", () => { "Subject", "

Hi

" ) - const writes = (tlsMock as any).__getLastWrites() as string[] + const writes = tlsMock.__getLastWrites() expect(writes.some((w) => /MAIL FROM:\r\n/.test(w))).toBe(true) }) }) diff --git a/tests/time.test.ts b/tests/time.test.ts index 4652ec9..2ca6b9e 100644 --- a/tests/time.test.ts +++ b/tests/time.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { dateKeyTZ, getTZParts, isAtHourTZ } from "@/lib/time" +import { dateKeyTZ, isAtHourTZ } from "@/lib/time" describe("time tz helpers", () => { it("computes date key in timezone", () => { @@ -14,4 +14,3 @@ describe("time tz helpers", () => { expect(isAtHourTZ(d, "America/Sao_Paulo", 8)).toBe(true) }) }) - diff --git a/types/pdfkit-standalone.d.ts b/types/pdfkit-standalone.d.ts index 4a9f9f5..05d64b9 100644 --- a/types/pdfkit-standalone.d.ts +++ b/types/pdfkit-standalone.d.ts @@ -1,5 +1,4 @@ declare module "pdfkit/js/pdfkit.standalone.js" { - const PDFDocument: any + const PDFDocument: typeof import("pdfkit") export default PDFDocument } - diff --git a/vitest.config.ts b/vitest.config.ts index 58baeed..5c9c6b7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,5 +13,6 @@ export default defineConfig({ globals: true, include: ["src/**/*.test.ts", "tests/**/*.test.ts"], setupFiles: ["./vitest.setup.ts"], + testTimeout: 15000, }, })