Files
eden/packages/database/migrations/1788451374900_support.cjs
Matheus (Handix) eff515b70d feat(api,ui): MFA/TOTP, Inventory, Support, HR-Timeclock, Fiscal, Billing e Design System
Leva grande, pedida como "pelo menos mais 20 etapas, use subagentes":
fecha a pendência de MFA/TOTP da Fase 1 (feito diretamente, autenticação é
sensível demais para delegar) e lança 5 subagentes em paralelo em 2 ondas
(eden-inventory, eden-support, eden-hr-timeclock; depois eden-finance,
eden-fiscal, eden-frontend), cada um em worktree isolado, com integração
seletiva manual (barrel, app.module.ts, migrations renomeadas/aplicadas,
build/typecheck/teste ao vivo antes de cada merge).

## Backend

- MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `crypto/field-encryption.service.ts`):
  login em duas etapas quando `mfa_enabled=true`, `FieldEncryptionService`
  genérico (AES-256-GCM versionado, ADR-0005) reaproveitado por outros
  segredos reversíveis (ex. credencial de equipamento Control iD).
- Inventory: warehouses, stock_movements (ledger append-only), serialized_assets,
  asset_assignments, stock_reservations — invariante "serial/patrimônio/MAC
  nunca duplicado entre ativos ativos" reforçada por índices únicos parciais
  no banco (Master Prompt §15.3).
- Support/Service Desk: tickets com máquina de estados, SLA com pausa/retomada
  em `waiting_customer`/`waiting_third_party` (funções puras testáveis).
- HR-Timeclock (fundação): dispositivos Control iD (senha cifrada), import de
  AFD com parser + CRC-16/KERMIT, `afd_records` imutável sem exceção nem
  para super_admin (sem válvula de escape, diferente de audit_log).
- Fiscal: NCM/CFOP/municípios/naturezas de operação (eden.md módulo 5, MVP de
  5 das ~19 tabelas), product_fiscal_profiles com invariante de único
  profile ativo por billing_component.
- Billing: subscription→billing_run→invoice idempotente (regenerar em draft
  substitui, nunca duplica) e imutável após consolidar (ADR-0008: nunca
  confunde faturamento/documento fiscal/recebimento).
- FKs cruzadas fechadas: quotes/client_registrations/contracts.

## Frontend — primeira leva

- `packages/ui`: Design System real extraído do tema DreamsERP comprado —
  achado que mudou o escopo: o tema já vem em React+Tailwind (não só
  Angular), tokens visuais reais portados (cores, Geist como fonte real,
  divergindo da doc antiga que citava Nunito/Poppins).
- `apps/core-web`: login real (com suporte a MFA), sessão, shell autenticado
  via `AppShell` — substitui a tela estática "Bootstrap da Fase 1".

## Bugs reais encontrados e corrigidos (todos via teste ao vivo, não leitura)

1. **Segurança**: o token de desafio MFA era aceito como access token normal
   pelo `JwtAuthGuard` (mesma assinatura, nenhuma checagem da claim
   `purpose`) — bypass total do segundo fator. Explorado e fechado ao vivo
   contra o container real; teste de regressão em `test/mfa.spec.ts`.
2. Crase solta dentro de comentário SQL na migration de hr-timeclock quebrava
   o template literal JS (`SyntaxError` ao rodar a migration).
3. CORS ausente por completo na API.
4. `turbo.json` não declarava `VITE_API_BASE_URL` como input de build — o
   Turborepo cacheava o bundle do frontend ignorando mudança na env var.
5. `compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`, que colidia com a
   variável de mesmo nome do `.env` local — o default nunca era aplicado.
6. Não existia `.dockerignore` — todo build copiava node_modules/.git/
   worktrees de subagentes (665MB) para dentro do contexto Docker.

Decisão do operador incorporada: a API nunca tem domínio público próprio
(só IP interno) — os 3 frontends chamam por caminho relativo `/api/v1`,
proxy externo roteia same-origin; CORS só é relevante para dev local.

Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter
@eden/api test` (30/30, cresceu de 15), todo o lote testado ao vivo via
curl contra os containers reais reconstruídos, incluindo os 6 bugs acima
já fechados e confirmados, e a trilha de auditoria conferida no Postgres.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 17:25:31 -03:00

165 lines
11 KiB
JavaScript

/**
* Support/Service Desk bounded context (Master Prompt §6.13). Greenfield —
* no legacy OrçaFácil module to port, unlike most other EDEN modules.
*
* This MVP builds the 4 tables needed for a working ticket lifecycle with
* real SLA timers:
* - support_tickets (this migration)
* - ticket_comments (this migration)
* - ticket_status_history (this migration)
* - sla_timers (this migration, 1:1 with support_tickets)
*
* Explicitly DEFERRED, not forgotten:
* - Real attachments (S3) — ticket_comments/support_tickets have no
* attachment table yet; needs the Documents/S3 integration adapter
* (packages/integrations) to land first, same reasoning as
* contract_documents in the Contracts migration.
* - CSAT (customer satisfaction survey after closing) — needs a survey
* delivery mechanism (email/portal) that doesn't exist yet.
* - Dedicated work order (OS) entity distinct from the ticket itself —
* this MVP treats the ticket as also carrying the OS-equivalent
* fields (root_cause/resolution_notes); a first-class `work_orders`
* table with its own scheduling/dispatch workflow is future work.
* - Queue model with automatic routing rules — there is no `queues`
* table yet; tickets are assigned via owner_user_id only (manual).
* - Configurable attendance calendar (business hours/holidays) for SLA
* due-date math — `sla_timers` due dates are computed with naive
* wall-clock offsets (see apps/api/src/support/sla.ts), not a
* calendar-aware engine. Category-specific SLA policy is also
* deferred — this MVP uses one flat default policy for every ticket
* regardless of category/priority (see sla.ts comment).
*
* `support_tickets.client_registration_id` and `.contract_id` are
* deliberately nullable with NO foreign key, same adopted pattern as
* `contracts.client_registration_id`/`contracts.quote_id`: those modules
* are being built in parallel by other agents. Wiring the FKs is
* integration-step work, not this migration's.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- support_tickets — the core aggregate
-- =====================================================================
CREATE SEQUENCE support_tickets_protocol_number_seq START 1;
CREATE TABLE support_tickets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
protocol_number TEXT NOT NULL UNIQUE
DEFAULT ('TCK-' || to_char(now(), 'YYYY') || '-' || lpad(nextval('support_tickets_protocol_number_seq')::text, 6, '0')),
title TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL,
subcategory TEXT,
priority TEXT NOT NULL DEFAULT 'medium'
CHECK (priority IN ('low', 'medium', 'high', 'critical')),
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN (
'new', 'triage', 'in_progress', 'waiting_customer',
'waiting_third_party', 'resolved', 'closed', 'cancelled'
)),
reseller_id UUID REFERENCES resellers(id),
client_registration_id UUID,
contract_id UUID,
owner_user_id UUID REFERENCES users(id),
opened_by_user_id UUID NOT NULL REFERENCES users(id),
root_cause TEXT,
resolution_notes TEXT,
first_response_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ
);
COMMENT ON TABLE support_tickets IS
'Ticket de atendimento (Service Desk, Master Prompt §6.13) — módulo greenfield, sem equivalente no legado OrçaFácil. protocol_number é gerado inline via support_tickets_protocol_number_seq + to_char, mesmo padrão de quotes.quote_number (ver migration 1788445280742_quotes.cjs), formato TCK-{ano}-{sequência de 6 dígitos}. client_registration_id/contract_id são nullable SEM FK — client_registrations e contracts existem em outras migrations mas a FK cruzada é trabalho de integração, não desta migration (mesmo padrão adotado por contracts.client_registration_id/contracts.quote_id).';
COMMENT ON COLUMN support_tickets.root_cause IS
'Causa raiz — só tem sentido preenchido no fechamento do ticket (transição para ''closed''), mas não há CHECK de banco forçando isso: a camada de aplicação (support.service.ts) só aceita esse campo no payload da transição para ''closed''.';
COMMENT ON COLUMN support_tickets.first_response_at IS
'Quando a primeira resposta pública (não-interna) ao ticket ocorreu. Setado uma única vez (nunca sobrescrito) pela camada de aplicação ao adicionar o primeiro ticket_comments.is_internal=false — ver apps/api/src/support/support.service.ts e sla_timers.first_response_met.';
COMMENT ON COLUMN support_tickets.resolved_at IS
'Quando o status se tornou ''resolved'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE, mesmo padrão de contracts.activated_at).';
COMMENT ON COLUMN support_tickets.closed_at IS
'Quando o status se tornou ''closed'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE, mesmo padrão de contracts.activated_at).';
CREATE TRIGGER trg_support_tickets_updated_at BEFORE UPDATE ON support_tickets
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_support_tickets_status ON support_tickets(status);
CREATE INDEX idx_support_tickets_reseller ON support_tickets(reseller_id);
CREATE INDEX idx_support_tickets_owner ON support_tickets(owner_user_id);
CREATE INDEX idx_support_tickets_client_registration ON support_tickets(client_registration_id);
CREATE INDEX idx_support_tickets_contract ON support_tickets(contract_id);
ALTER SEQUENCE support_tickets_protocol_number_seq OWNED BY support_tickets.protocol_number;
-- =====================================================================
-- ticket_comments — public/internal comment thread
-- =====================================================================
CREATE TABLE ticket_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
author_user_id UUID NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
is_internal BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE ticket_comments IS
'Thread de comentários do ticket. is_internal=true nunca pode ser exposto ao portal do assinante ou da revenda — reforçado na camada de query/serviço (apps/api/src/support/support.service.ts), não apenas por convenção de UI, mesma disciplina de isolamento cross-tenant do módulo comercial (Master Prompt, eden-support persona).';
CREATE INDEX idx_ticket_comments_ticket ON ticket_comments(ticket_id);
-- =====================================================================
-- ticket_status_history — append-only UX-facing lifecycle log,
-- distinct from (and complementary to) the generic audit_log
-- =====================================================================
CREATE TABLE ticket_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
from_status TEXT,
to_status TEXT NOT NULL,
reason TEXT,
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE ticket_status_history IS
'Log de transições de status do ticket — append-only por convenção da CAMADA DE APLICAÇÃO (nenhuma rota de UPDATE/DELETE é exposta), mesmo padrão e mesma justificativa de coexistência com audit_log de contract_status_history (ver migration 1788447802216_contracts.cjs).';
CREATE INDEX idx_ticket_status_history_ticket ON ticket_status_history(ticket_id);
-- =====================================================================
-- sla_timers — 1:1 with support_tickets (ticket_id is both PK and FK)
-- =====================================================================
CREATE TABLE sla_timers (
ticket_id UUID PRIMARY KEY REFERENCES support_tickets(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
first_response_due_at TIMESTAMPTZ NOT NULL,
resolution_due_at TIMESTAMPTZ NOT NULL,
first_response_met BOOLEAN,
resolution_met BOOLEAN,
paused_since TIMESTAMPTZ,
accumulated_pause_seconds INTEGER NOT NULL DEFAULT 0
);
COMMENT ON TABLE sla_timers IS
'Timer de SLA, uma linha por ticket (ticket_id é PK e FK ao mesmo tempo — força o 1:1). Regra de pausa: ao entrar em waiting_customer/waiting_third_party, paused_since = now(); ao voltar para in_progress, accumulated_pause_seconds soma o intervalo pausado e paused_since volta a NULL — implementado atomicamente em packages/database/src/repositories/support-tickets.ts::updateTicketStatus (mesmo padrão SELECT...FOR UPDATE + withTransaction de contracts.ts::updateContractStatus). O cálculo de violação de SLA (subtraindo o tempo pausado, incluindo a pausa corrente se ainda pausado) é feito pelas funções puras computeElapsedSeconds/isSlaBreached em apps/api/src/support/sla.ts — nunca reimplementado ad-hoc. first_response_met/resolution_met só são preenchidos no momento em que o evento correspondente (primeira resposta pública / entrada em resolved) de fato ocorre — permanecem NULL até lá, não são recalculados retroativamente.';
COMMENT ON COLUMN sla_timers.paused_since IS
'Quando não-nulo, o timer está pausado desde este instante — todo o tempo entre paused_since e now() ainda NÃO está refletido em accumulated_pause_seconds e deve ser somado no cálculo de "tempo decorrido" (ver computeElapsedSeconds em sla.ts).';
CREATE TRIGGER trg_sla_timers_updated_at BEFORE UPDATE ON sla_timers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS sla_timers;
DROP TABLE IF EXISTS ticket_status_history;
DROP TABLE IF EXISTS ticket_comments;
DROP TABLE IF EXISTS support_tickets;
DROP SEQUENCE IF EXISTS support_tickets_protocol_number_seq;
`);
};