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>
165 lines
11 KiB
JavaScript
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;
|
|
`);
|
|
};
|