From 5d0cdb5c48d2d34751bbe80df016809f2acbfa39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Melga=C3=A7o?= Date: Tue, 28 Apr 2026 23:32:27 -0300 Subject: [PATCH] feat(EPIC-12): hardening + e2e + polish [10 waves, partial] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error handling: - app/error.tsx + app/app/error.tsx + app/(public)/error.tsx via shared components/feedback/SegmentError.tsx — captures to Sentry, displays eventId, copy-to-clipboard, reset button. - app/global-error.tsx upgraded with Sentry capture + eventId UI. - app/not-found.tsx (PT-BR copy B1). - app/403/page.tsx polished (copy B2). - app/500/page.tsx + app/503/page.tsx new (copy B3, B4). Empty states: - components/empty/EmptyState.tsx (base) + 10 specialized variants (Inbox, Kanban, Contacts, Audit, Pipeline, Team, ApiTokens, Timeline, MergeQueue, FilterResults). - Wired into kanban picker, contacts list, inbox conversation list (3 sites). Loading skeletons: - app/app/loading.tsx + 4 route-specific loading.tsx (inbox, kanban, contacts, audit) using shadcn Skeleton. Sentry: - beforeSend in sentry.server.config.ts, sentry.edge.config.ts, instrumentation-client.ts scrubs Authorization/Cookie/x-api-key/ x-waha-api-key/x-nuvemshop-token/x-deskcomm-token headers + CPF/email/phone patterns from message + exception values. sendDefaultPii: false. - lib/logger.ts: structured JSON logger (zero deps). Web Vitals: - next.config.ts: experimental.optimizePackageImports for phosphor/lucide/ date-fns. Performance budget block documented inline. - .github/workflows/perf.yml reports build output sizes to Step Summary. E2E: - tests/e2e/auth.spec.ts: anon redirect, invalid creds, keyboard tab order, axe-core a11y audit on /login (fails on serious/critical). - tests/e2e/error-pages.spec.ts: 404/403/500/503 routes. - @axe-core/playwright integrated. Public paths: - lib/auth/public-paths.ts allows /500 and /503 (must not require auth). Docs: - README.md quickstart 5min reescrito. - ARCHITECTURE.md (1-page overview + spec refs). - CONTRIBUTING.md (PR + epic-executor workflow). - docs/DEPLOY-CHECKLIST.md preflight. Migrations: - supabase/migrations/ reconciled against remote schema_migrations (verified via Supabase MCP list_migrations); 9 stub files created with pointers to the corresponding spec; MANIFEST.md updated for 0008/0009. Deferred to follow-up (documented in EPIC-12 Wave Completion Log): - Lighthouse CI + bundle-analyzer thresholds in GitHub Actions. - E2E specs covering /app/* routes (require MFA bypass strategy via test-only env var or storageState fixture). - Full 5-jornada E2E suite — depends on EPIC-06 (AI) and EPIC-08 (LGPD) which remain pending. Implements S-12.01..S-12.10 contracts. Closes EPIC-12 (10/10 waves with documented stubs). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/perf.yml | 41 +++ .gitignore | 3 + ARCHITECTURE.md | 50 ++++ CONTRIBUTING.md | 78 ++++++ README.md | 150 +++++------ app/(public)/error.tsx | 7 + app/403/page.tsx | 18 +- app/500/page.tsx | 28 +++ app/503/page.tsx | 21 ++ app/api/sentry-example-api/route.ts | 17 ++ app/app/audit/loading.tsx | 19 ++ app/app/contacts/_client.tsx | 7 +- app/app/contacts/loading.tsx | 15 ++ app/app/error.tsx | 7 + app/app/inbox/loading.tsx | 25 ++ app/app/kanban/loading.tsx | 19 ++ app/app/kanban/page.tsx | 7 +- app/app/loading.tsx | 11 + app/error.tsx | 7 + app/global-error.tsx | 109 ++++++++ app/not-found.tsx | 24 ++ app/sentry-example-page/page.tsx | 236 ++++++++++++++++++ components/empty/EmptyState.tsx | 66 +++++ components/empty/index.ts | 13 + components/empty/variants.tsx | 140 +++++++++++ components/feedback/SegmentError.tsx | 57 +++++ components/inbox/ConversationList.tsx | 5 +- docker-compose.yml | 1 + docs/DEPLOY-CHECKLIST.md | 42 ++++ instrumentation-client.ts | 57 +++++ instrumentation.ts | 13 + lib/auth/public-paths.ts | 2 + lib/logger.ts | 41 +++ next.config.ts | 46 +++- package.json | 1 + pnpm-lock.yaml | 13 + sentry.edge.config.ts | 48 ++++ sentry.server.config.ts | 49 ++++ .../20260428195354_0001_platform_base.sql | 13 + ...260428195513_0002_event_log_and_compat.sql | 7 + .../20260428195708_0003_customer_360.sql | 7 + .../20260428200016_0004_whatsapp_waha.sql | 7 + .../migrations/20260428200128_0005_ai_rag.sql | 7 + .../20260428200211_0006_nuvemshop_lgpd.sql | 7 + ...20260428200331_0007_security_hardening.sql | 7 + ...429013958_0008_tenant_onboarding_state.sql | 7 + ...1857_0009_expand_messaging_constraints.sql | 7 + supabase/migrations/MANIFEST.md | 4 +- tests/e2e/auth.spec.ts | 42 ++++ tests/e2e/error-pages.spec.ts | 25 ++ 50 files changed, 1538 insertions(+), 95 deletions(-) create mode 100644 .github/workflows/perf.yml create mode 100644 ARCHITECTURE.md create mode 100644 CONTRIBUTING.md create mode 100644 app/(public)/error.tsx create mode 100644 app/500/page.tsx create mode 100644 app/503/page.tsx create mode 100644 app/api/sentry-example-api/route.ts create mode 100644 app/app/audit/loading.tsx create mode 100644 app/app/contacts/loading.tsx create mode 100644 app/app/error.tsx create mode 100644 app/app/inbox/loading.tsx create mode 100644 app/app/kanban/loading.tsx create mode 100644 app/app/loading.tsx create mode 100644 app/error.tsx create mode 100644 app/global-error.tsx create mode 100644 app/not-found.tsx create mode 100644 app/sentry-example-page/page.tsx create mode 100644 components/empty/EmptyState.tsx create mode 100644 components/empty/index.ts create mode 100644 components/empty/variants.tsx create mode 100644 components/feedback/SegmentError.tsx create mode 100644 docs/DEPLOY-CHECKLIST.md create mode 100644 instrumentation-client.ts create mode 100644 instrumentation.ts create mode 100644 lib/logger.ts create mode 100644 sentry.edge.config.ts create mode 100644 sentry.server.config.ts create mode 100644 supabase/migrations/20260428195354_0001_platform_base.sql create mode 100644 supabase/migrations/20260428195513_0002_event_log_and_compat.sql create mode 100644 supabase/migrations/20260428195708_0003_customer_360.sql create mode 100644 supabase/migrations/20260428200016_0004_whatsapp_waha.sql create mode 100644 supabase/migrations/20260428200128_0005_ai_rag.sql create mode 100644 supabase/migrations/20260428200211_0006_nuvemshop_lgpd.sql create mode 100644 supabase/migrations/20260428200331_0007_security_hardening.sql create mode 100644 supabase/migrations/20260429013958_0008_tenant_onboarding_state.sql create mode 100644 supabase/migrations/20260429021857_0009_expand_messaging_constraints.sql create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/e2e/error-pages.spec.ts diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 000000000..aa08b99d7 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,41 @@ +name: perf + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build-and-size: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + env: + NEXT_TELEMETRY_DISABLED: "1" + SENTRY_DSN: "" + + - name: Report bundle sizes + run: | + { + echo "## Build output sizes" + du -sh .next 2>/dev/null || true + if [ -d .next/static ]; then du -sh .next/static; fi + } >> "$GITHUB_STEP_SUMMARY" + + # NOTE: Lighthouse CI + bundle-analyzer thresholds deferred (S-12.05 follow-up). diff --git a/.gitignore b/.gitignore index e996c391c..2eaa2b14c 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ supabase/.branches/ .waha-data/ .epic-executor/ .playwright-mcp/ + +# Sentry Config File +.env.sentry-build-plugin diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 000000000..0ccba815b --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,50 @@ +# Architecture — DeskcommCRM + +> Visão de 1 página. Profundidade vive em `docs/specs/` e `docs/stories/epics/MASTER.md`. + +## Camadas + +- **App (Next.js 15 App Router)**: UI + Route Handlers no mesmo repo. Server Components por default, Client onde precisa de estado. +- **DB (Supabase Postgres)**: RLS em toda tabela tenant-aware via `fn_user_org_ids()`. Migrations versionadas em `supabase/migrations/`. +- **Auth (Supabase Auth + `@supabase/ssr`)**: cookie SameSite=Strict, MFA TOTP forçado pra admin/super-admin. Sempre `getUser()` no server. +- **Realtime (Supabase Realtime)**: `postgres_changes` para inbox/kanban; `broadcast` para sinais leves. +- **Storage (Supabase Storage)**: bucket `whatsapp-media` privado, URLs assinadas. +- **WhatsApp (WAHA Plus / engine NOWEB)**: HMAC-SHA512 webhooks; throttle anti-banimento; STOP detection. +- **Filas (event sourcing leve)**: `event_log` table + workers via cron. Trigger Postgres NUNCA faz HTTP. +- **Rate limit (Upstash Redis)**: sliding window. +- **AI (Vercel AI Gateway)**: Anthropic primário, OpenAI backup pra embeddings. +- **Observability (Sentry)**: `beforeSend` scrubs PII (CPF/email/phone) e headers sensíveis. + +## Multi-tenancy + +`organization_id uuid not null` em toda tabela tenant-aware. RLS via helper. Service role bypassa RLS — handlers admin **DEVEM** filtrar `organization_id` manualmente, resolvido de fonte confiável (cookie/JWT/webhook secret/path token), nunca do body. + +Detalhes: [`docs/specs/01-spec-platform-base.md`](docs/specs/01-spec-platform-base.md). + +## API REST `/api/v1/` + +- JSON snake_case. UUID v4. ISO-8601 UTC. Dinheiro `_cents` + `currency`. +- Wrappers `ok()` / `fail()` em `lib/api/wrappers.ts`. +- Auth dual: cookie session (frontend) ou `Authorization: Bearer tok_...` (server-to-server). +- `Idempotency-Key` para POSTs de criação. `X-Request-Id` em toda response. +- Detalhes: [`docs/specs/01-spec-platform-base.md`](docs/specs/01-spec-platform-base.md) §API. + +## Event log + workers + +Triggers Postgres emitem linhas em `event_log`. Workers (cron / Realtime listener) consomem e disparam side effects. Idempotência via `unique (organization_id, external_id)` + captura `code === '23505'`. + +## Hardening + +- Error boundaries em `app/error.tsx`, `app/app/error.tsx`, `app/(public)/error.tsx`, `app/global-error.tsx` (Sentry capture + eventId visível). +- Páginas customizadas 404/403/500/503 com copy PT-BR canônica. +- Loading skeletons em rotas P0. +- E2E Playwright + axe-core. +- Detalhes: [`docs/stories/epics/EPIC-12-hardening.md`](docs/stories/epics/EPIC-12-hardening.md). + +## Onde olhar a fundo + +- [`docs/prd/`](docs/prd/) — PRDs (visão, escopo MVP, KPIs, plataforma base, customer 360, WhatsApp, pipeline, IA-RAG, Nuvemshop). +- [`docs/specs/`](docs/specs/) — specs técnicas com schema SQL e payloads. +- [`docs/business-rules/`](docs/business-rules/) — regras de negócio fora do código. +- [`docs/stories/epics/MASTER.md`](docs/stories/epics/MASTER.md) — plano de execução por epic/wave. +- [`CLAUDE.md`](CLAUDE.md) — convenções não-negociáveis (multi-tenancy, idempotência, RBAC, LGPD, WAHA, anti-patterns). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..d530085f9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,78 @@ +# Contributing — DeskcommCRM + +## Antes de começar + +1. Leia [`CLAUDE.md`](CLAUDE.md) — convenções não-negociáveis. +2. Leia [`ARCHITECTURE.md`](ARCHITECTURE.md) — visão de 1 página. +3. Identifique o epic de origem em [`docs/stories/epics/MASTER.md`](docs/stories/epics/MASTER.md). + +## Fluxo + +### Branches + +``` +feat/EPIC-XX-short-slug # nova feature +fix/EPIC-XX-short-slug # bug fix +chore/short-slug # chore (deps, configs) +docs/short-slug # apenas docs +``` + +### Commits + +Conventional commits + escopo `EPIC-XX`: + +``` +feat(EPIC-04): kanban drag-and-drop com fractional indexing +fix(EPIC-03): cron recover-stuck-messages marcando sending stuck >5min como failed +docs(EPIC-12): mark complete + wave log +``` + +Mensagens em PT-BR são aceitas. O assunto deve ser imperativo e ≤72 chars. + +### epic-executor + +Mudanças grandes seguem [`docs/stories/epics/`](docs/stories/epics/). O `epic-executor` consome o frontmatter (`epic_id`, `priority`, `depends_on`, `status`) e executa wave-by-wave com validação E2E continuous. + +Ao finalizar um epic: + +1. Atualizar frontmatter `status: pending → completed (partial: ...)` ou `status: completed`. +2. Append "Wave Completion Log" no final do arquivo. +3. Atualizar a row correspondente em `docs/stories/epics/MASTER.md`. + +### PR process + +1. Branch a partir de `main`. +2. Implementar. Adicionar testes (E2E pra fluxos, unit pra lógica pura). +3. **Definition of Done** — todos verdes: + - `pnpm typecheck` + - `pnpm lint` + - `pnpm test:unit` + - `pnpm test:e2e` (subset relevante) + - RLS testada se feature toca tabela tenant-aware + - Audit log emitido se há mutação relevante + - Rate limit aplicado se rota é pública + - Zod valida todo input externo + - Sem `console.log` esquecido (use `lib/logger.ts`) + - Env vars novas em `.env.example` + `lib/env.ts` + - Docs atualizadas se mudou contrato (PRD/spec) +4. Abrir PR contra `main`. Description deve referenciar o epic e listar evidências (logs/screenshots dos testes). +5. CI deve passar antes de merge. Teste de isolamento RLS é gate obrigatório. + +### Anti-patterns proibidos + +Lista completa em `CLAUDE.md`. Os mais letais: + +- Trigger Postgres fazendo HTTP +- Service role usado em handler sem filtrar `organization_id` manualmente +- `getSession()` no backend (use `getUser()`) +- API key em query string +- Bearer plaintext no DB +- `console.log` em código merged + +## Setup local + +Veja [`README.md`](README.md) §Como rodar local. + +## Suporte + +Dúvidas: `rafael@maudibrasil.com.br`. Canal interno do BPO Discord (link no Notion). diff --git a/README.md b/README.md index b3576fd65..8dc174023 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ ## Visão de Produto -DeskcommCRM unifica atendimento humano, chatbot com RAG por tenant, gestão de pedidos e pipeline de pós-venda numa única plataforma multi-tenant. O canal primário é WhatsApp (via WAHA — API não-oficial). A arquitetura é multi-tenant desde o dia 1, sem refactor previsto pro pivot SaaS. +DeskcommCRM unifica atendimento humano, chatbot com RAG por tenant, gestão de pedidos e pipeline de pós-venda numa única plataforma multi-tenant. Canal primário: WhatsApp (via WAHA). Multi-tenant desde o dia 1. LGPD nativa. -**Diferenciais competitivos:** +**Diferenciais:** -1. **IA operando o atendimento** com RAG por tenant (FAQ + política + catálogo Nuvemshop + conversas resolvidas), não chatbot decorativo. -2. **E-commerce-native**: pipeline e vocabulário desenhados pro ciclo "Carrinho abandonado → Pago → Enviado → Entregue → Pós-venda". -3. **MCP-ready**: arquitetura inclui MCP server (Fase 2) com 19 tools canônicas. -4. **LGPD nativa**: webhooks `customer/redact` e `customer/data_request` da Nuvemshop são contrato de primeira-classe. +1. IA operando o atendimento com RAG por tenant (não chatbot decorativo). +2. E-commerce-native: vocabulário desenhado pro ciclo "Carrinho abandonado → Pago → Enviado → Entregue → Pós-venda". +3. MCP-ready (Fase 2). +4. LGPD nativa: webhooks `customer/redact` e `customer/data_request` da Nuvemshop como contrato de primeira-classe. --- @@ -27,111 +27,99 @@ DeskcommCRM unifica atendimento humano, chatbot com RAG por tenant, gestão de p |---|---| | Frontend | Next.js 15 App Router + TypeScript + Tailwind + shadcn/ui | | Backend | Next.js Route Handlers (mesmo repo) | -| DB | Supabase (Postgres gerenciado, RLS em toda tabela tenant-aware) | +| DB | Supabase (Postgres, RLS em toda tabela tenant-aware) | | Realtime | Supabase Realtime | | Auth | Supabase Auth via `@supabase/ssr` | | Storage | Supabase Storage | | WhatsApp | WAHA Plus (engine NOWEB) | | Hospedagem app | Vercel | -| Hospedagem WAHA | Railway (MVP) → VPS Hetzner (prod) | | Validação | Zod | | Rate limit | Upstash Redis | -| Cron | Vercel Cron | | AI Gateway | Vercel AI Gateway (Anthropic primário, OpenAI backup) | | Observability | Sentry | --- -## Como rodar local - -### Pré-requisitos - -- Node 20+ (use `nvm use` — `.nvmrc` está no repo) -- Docker + Docker Compose (pra WAHA local) -- Conta Supabase (projeto criado, plano free serve pra dev) -- Conta Upstash (Redis REST) -- Conta Sentry (opcional em dev) -- ngrok ou cloudflared (pra expor webhook do WAHA) - -### Setup +## Quickstart 5 minutos ```bash -# 1. Instalar dependências -npm install +# 1. Clone + Node 20 +nvm use # ou instale Node 20+ -# 2. Configurar env +# 2. Deps +pnpm install + +# 3. Env cp .env.example .env.local -# Preencher: NEXT_PUBLIC_SUPABASE_URL, ANON_KEY, SERVICE_ROLE_KEY, INTERNAL_SECRET, -# WAHA_API_KEY (plaintext), UPSTASH_*, etc. +# Preencher SUPABASE_*, WAHA_API_KEY, UPSTASH_*, SENTRY_DSN, etc. -# 3. Rodar migrations Supabase (placeholder; schema real virá das specs) -npm run db:migrate - -# 4. Subir WAHA local (com hash SHA512 da api key) -echo -n "$WAHA_API_KEY" | shasum -a 512 | awk '{print $1}' # → cole em WAHA_API_KEY_SHA512 +# 4. WAHA local (opcional em dev sem WhatsApp) docker compose up -d -# 5. Expor webhook publicamente (terminal separado) -ngrok http 3000 # → cole URL HTTPS em WAHA_WEBHOOK_BASE_URL e reinicie compose - -# 6. Iniciar dev server -npm run dev +# 5. Dev server +pnpm dev ``` -App: -WAHA dashboard: (porta 3000 do container) -Health check: +App: · Health check: ---- - -## Estrutura do Projeto - -``` -DeskcommCRM/ -├── app/ # Next.js App Router -│ ├── (admin)/ # Rotas super-admin (admin.deskcomm.com) -│ ├── (app)/ # Rotas do tenant (app.deskcomm.com) -│ └── api/v1/ # API REST canônica -├── components/ # React components -│ └── ui/ # shadcn/ui primitives -├── lib/ -│ ├── supabase/ # Clients (browser, server, admin) -│ ├── waha/ # Cliente WAHA (Spec 03) -│ ├── ai/ # IA + RAG (Spec 05) -│ ├── api/ # Wrappers, errors -│ └── env.ts # Validação Zod das env vars -├── hooks/ # React hooks compartilhados -├── supabase/ -│ ├── config.toml -│ └── migrations/ # SQL versionado -├── tests/ -│ ├── e2e/ # Playwright -│ └── unit/ # Vitest -├── scripts/ # CLI utilities (seed-tenant, etc.) -├── docs/ # PRDs, specs, business rules, research -└── tasks/ # Workflow de construção (todo.md) -``` - ---- - -## Documentação - -- [`docs/prd/00-prd-master.md`](docs/prd/00-prd-master.md) — Visão e escopo -- [`docs/prd/01-prd-platform-base.md`](docs/prd/01-prd-platform-base.md) — Plataforma base (auth, tenancy, RBAC, LGPD) -- [`docs/research/reference-synthesis.md`](docs/research/reference-synthesis.md) — Arquitetura herdada -- [`CLAUDE.md`](CLAUDE.md) — Convenções e regras críticas (leitura obrigatória pra contribuir) +Login seed (após seed): `rafael@maudibrasil.com.br` / `DeskcommAdmin@2026`. --- ## Testes ```bash -npm run typecheck # tsc --noEmit -npm run lint # eslint -npm run test:unit # Vitest -npm run test:e2e # Playwright +pnpm typecheck # tsc --noEmit +pnpm lint # eslint +pnpm test:unit # Vitest +pnpm test:e2e # Playwright (requer dev server) ``` +CI roda todos antes de merge. Teste de isolamento RLS é gate obrigatório. + +--- + +## Estrutura + +``` +DeskcommCRM/ +├── app/ # Next.js App Router +│ ├── (admin)/ # Rotas super-admin +│ ├── (public)/ # Login, recovery +│ ├── app/ # Rotas autenticadas (inbox, kanban, contacts, audit, ...) +│ └── api/v1/ # API REST canônica +├── components/ # React (ui/, empty/, feedback/, shell/, ...) +├── lib/ # supabase/, waha/, ai/, api/, logger.ts, env.ts +├── hooks/ +├── supabase/migrations/ # SQL versionado +├── tests/{e2e,unit}/ +├── scripts/ +├── docs/ # PRDs, specs, stories +└── tasks/ +``` + +--- + +## Atalhos de teclado + +- `Tab` / `Shift+Tab` — navegação focável (login, formulários, kanban cards) +- `Enter` — confirma ações primárias +- `Esc` — fecha dialogs/sheets + +Documentação completa de keyboard shortcuts vem com EPIC-04 (kanban) e EPIC-03 (inbox). + +--- + +## Documentação + +- [`CLAUDE.md`](CLAUDE.md) — convenções não-negociáveis (leitura obrigatória pra contribuir) +- [`ARCHITECTURE.md`](ARCHITECTURE.md) — visão de 1 página +- [`CONTRIBUTING.md`](CONTRIBUTING.md) — fluxo PR + epic-executor +- [`docs/prd/`](docs/prd/) — PRDs +- [`docs/specs/`](docs/specs/) — specs técnicas +- [`docs/stories/epics/MASTER.md`](docs/stories/epics/MASTER.md) — plano de execução +- [`docs/DEPLOY-CHECKLIST.md`](docs/DEPLOY-CHECKLIST.md) — preflight pré-go-live + --- ## Licença diff --git a/app/(public)/error.tsx b/app/(public)/error.tsx new file mode 100644 index 000000000..fa54b3bf3 --- /dev/null +++ b/app/(public)/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { SegmentError, type SegmentErrorProps } from "@/components/feedback/SegmentError"; + +export default function PublicError(props: SegmentErrorProps) { + return ; +} diff --git a/app/403/page.tsx b/app/403/page.tsx index ca8c314e2..f99bcc78b 100644 --- a/app/403/page.tsx +++ b/app/403/page.tsx @@ -1,12 +1,24 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + export default function ForbiddenPage() { return (
-
+

403 — Sem permissão

- Você não tem acesso a esta área. Volte para a página inicial. + Você não tem acesso a essa área.

-
+
+ + +
+
); } diff --git a/app/500/page.tsx b/app/500/page.tsx new file mode 100644 index 000000000..b3741cb86 --- /dev/null +++ b/app/500/page.tsx @@ -0,0 +1,28 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +export default function InternalErrorPage() { + return ( +
+ +

500 — Erro interno

+

+ Algo deu errado do nosso lado. Reporte ao suporte com o ID exibido na tela + anterior, se houver. +

+
+ ID: — +
+
+ + +
+
+
+ ); +} diff --git a/app/503/page.tsx b/app/503/page.tsx new file mode 100644 index 000000000..cb936046e --- /dev/null +++ b/app/503/page.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +export default function ServiceUnavailablePage() { + return ( +
+ +

503 — Em manutenção

+

+ Voltamos em alguns minutos. +

+
+ +
+
+
+ ); +} diff --git a/app/api/sentry-example-api/route.ts b/app/api/sentry-example-api/route.ts new file mode 100644 index 000000000..aa23929a0 --- /dev/null +++ b/app/api/sentry-example-api/route.ts @@ -0,0 +1,17 @@ +import * as Sentry from "@sentry/nextjs"; +export const dynamic = "force-dynamic"; + +class SentryExampleAPIError extends Error { + constructor(message: string | undefined) { + super(message); + this.name = "SentryExampleAPIError"; + } +} + +// A faulty API route to test Sentry's error monitoring +export function GET() { + Sentry.logger.info("Sentry example API called"); + throw new SentryExampleAPIError( + "This error is raised on the backend called by the example page.", + ); +} diff --git a/app/app/audit/loading.tsx b/app/app/audit/loading.tsx new file mode 100644 index 000000000..68b4731ee --- /dev/null +++ b/app/app/audit/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function AuditLoading() { + return ( +
+ +
+ + + +
+
+ {Array.from({ length: 12 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/app/app/contacts/_client.tsx b/app/app/contacts/_client.tsx index b3527c40a..8272ff4ef 100644 --- a/app/app/contacts/_client.tsx +++ b/app/app/contacts/_client.tsx @@ -16,6 +16,7 @@ import { import { useContactList } from "@/hooks/contacts/useContactList"; import { ContactsTable } from "@/components/contacts/ContactsTable"; import { NewContactDialog } from "@/components/contacts/NewContactDialog"; +import { EmptyContacts } from "@/components/empty"; const SOURCE_OPTIONS = [ { value: undefined, label: "Todas as origens" }, @@ -150,10 +151,8 @@ export function ContactsListClient() { ) : allContacts.length === 0 ? ( - -

- Nenhum contato ainda. Clique em "Novo contato" para começar. -

+ + ) : ( <> diff --git a/app/app/contacts/loading.tsx b/app/app/contacts/loading.tsx new file mode 100644 index 000000000..dadeb393f --- /dev/null +++ b/app/app/contacts/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function ContactsLoading() { + return ( +
+ + +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/app/app/error.tsx b/app/app/error.tsx new file mode 100644 index 000000000..401dab36c --- /dev/null +++ b/app/app/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { SegmentError, type SegmentErrorProps } from "@/components/feedback/SegmentError"; + +export default function AppError(props: SegmentErrorProps) { + return ; +} diff --git a/app/app/inbox/loading.tsx b/app/app/inbox/loading.tsx new file mode 100644 index 000000000..234ccb24a --- /dev/null +++ b/app/app/inbox/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function InboxLoading() { + return ( +
+
+ + {Array.from({ length: 8 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ + + +
+
+ ); +} diff --git a/app/app/kanban/loading.tsx b/app/app/kanban/loading.tsx new file mode 100644 index 000000000..dc607bdd9 --- /dev/null +++ b/app/app/kanban/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function KanbanLoading() { + return ( +
+ +
+ {Array.from({ length: 5 }).map((_, col) => ( +
+ + {Array.from({ length: 3 }).map((_, card) => ( + + ))} +
+ ))} +
+
+ ); +} diff --git a/app/app/kanban/page.tsx b/app/app/kanban/page.tsx index 0cdc9e692..0d665a7fa 100644 --- a/app/app/kanban/page.tsx +++ b/app/app/kanban/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { Kanban } from "@/lib/ui/icons"; import { Badge } from "@/components/ui/badge"; +import { EmptyPipeline } from "@/components/empty"; import { createClient } from "@/lib/supabase/server"; export const dynamic = "force-dynamic"; @@ -24,9 +25,9 @@ export default async function KanbanPickerPage() { {list.length === 0 ? (
-
- Nenhum pipeline. Crie um em Configurações. -
+
) : (
    diff --git a/app/app/loading.tsx b/app/app/loading.tsx new file mode 100644 index 000000000..320f0238d --- /dev/null +++ b/app/app/loading.tsx @@ -0,0 +1,11 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function AppLoading() { + return ( +
    + + + +
    + ); +} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 000000000..0001c5304 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { SegmentError, type SegmentErrorProps } from "@/components/feedback/SegmentError"; + +export default function RootError(props: SegmentErrorProps) { + return ; +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 000000000..c7ad1f8f9 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,109 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import { useEffect, useState } from "react"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const [eventId, setEventId] = useState(undefined); + const [copied, setCopied] = useState(false); + + useEffect(() => { + const id = Sentry.captureException(error); + setEventId(id); + }, [error]); + + const displayId = eventId ?? error.digest ?? "—"; + + return ( + + +
    +

    + Algo deu errado +

    +

    + Tente novamente em instantes. Se persistir, contate o suporte com o ID abaixo. +

    +
    + ID: {displayId} +
    +
    + + +
    +
    + + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 000000000..130354f4d --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +export default function NotFound() { + return ( +
    + +

    404 — Página não encontrada

    +

    + Verifique o link ou volte pra inbox. +

    +
    + + +
    +
    +
    + ); +} diff --git a/app/sentry-example-page/page.tsx b/app/sentry-example-page/page.tsx new file mode 100644 index 000000000..b7d5ea9f1 --- /dev/null +++ b/app/sentry-example-page/page.tsx @@ -0,0 +1,236 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import Head from "next/head"; +import { useEffect, useState } from "react"; + +class SentryExampleFrontendError extends Error { + constructor(message: string | undefined) { + super(message); + this.name = "SentryExampleFrontendError"; + } +} + +export default function Page() { + const [hasSentError, setHasSentError] = useState(false); + const [isConnected, setIsConnected] = useState(true); + + useEffect(() => { + Sentry.logger.info("Sentry example page loaded"); + async function checkConnectivity() { + const result = await Sentry.diagnoseSdkConnectivity(); + setIsConnected(result !== "sentry-unreachable"); + } + checkConnectivity(); + }, []); + + return ( +
    + + sentry-example-page + + + +
    +
    + + + +

    sentry-example-page

    + +

    + Click the button below, and view the sample error on the Sentry{" "} + + Issues Page + + . For more details about setting up Sentry,{" "} + + read our docs + + . +

    + + + + {hasSentError ? ( +

    Error sent to Sentry.

    + ) : !isConnected ? ( +
    +

    + It looks like network requests to Sentry are being blocked, which will prevent errors + from being captured. Try disabling your ad-blocker to complete the test. +

    +
    + ) : ( +
    + )} + +
    +
    + + +
    + ); +} diff --git a/components/empty/EmptyState.tsx b/components/empty/EmptyState.tsx new file mode 100644 index 000000000..4ccbffc42 --- /dev/null +++ b/components/empty/EmptyState.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Link from "next/link"; +import type { Icon as PhosphorIcon } from "@phosphor-icons/react"; +import { Button } from "@/components/ui/button"; + +export interface EmptyStateAction { + label: string; + onClick?: () => void; + href?: string; +} + +export interface EmptyStateProps { + icon: PhosphorIcon; + headline: string; + subcopy?: string; + primary?: EmptyStateAction; + secondary?: EmptyStateAction; +} + +function ActionButton({ + action, + variant, +}: { + action: EmptyStateAction; + variant?: "default" | "outline"; +}) { + if (action.href) { + return ( + + ); + } + return ( + + ); +} + +export function EmptyState({ + icon: Icon, + headline, + subcopy, + primary, + secondary, +}: EmptyStateProps) { + return ( +
    +
    + +
    +

    {headline}

    + {subcopy ? ( +

    {subcopy}

    + ) : null} + {(primary || secondary) && ( +
    + {secondary ? : null} + {primary ? : null} +
    + )} +
    + ); +} diff --git a/components/empty/index.ts b/components/empty/index.ts new file mode 100644 index 000000000..f390d7cdb --- /dev/null +++ b/components/empty/index.ts @@ -0,0 +1,13 @@ +export { EmptyState, type EmptyStateProps, type EmptyStateAction } from "./EmptyState"; +export { + EmptyInbox, + EmptyKanban, + EmptyContacts, + EmptyAudit, + EmptyPipeline, + EmptyTeam, + EmptyApiTokens, + EmptyTimeline, + EmptyMergeQueue, + EmptyFilterResults, +} from "./variants"; diff --git a/components/empty/variants.tsx b/components/empty/variants.tsx new file mode 100644 index 000000000..301158d35 --- /dev/null +++ b/components/empty/variants.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { + ChatCircle, + Kanban, + UsersThree, + ListMagnifyingGlass, + Funnel, + ArrowsLeftRight, + Key, + ClockCounterClockwise, + GitBranch, + Users, +} from "@phosphor-icons/react"; +import { EmptyState, type EmptyStateAction } from "./EmptyState"; + +interface VariantProps { + primary?: EmptyStateAction; + secondary?: EmptyStateAction; +} + +export function EmptyInbox({ primary, secondary }: VariantProps = {}) { + return ( + + ); +} + +export function EmptyKanban({ primary, secondary }: VariantProps = {}) { + return ( + + ); +} + +export function EmptyContacts({ primary, secondary }: VariantProps = {}) { + return ( + + ); +} + +export function EmptyAudit({ primary, secondary }: VariantProps = {}) { + return ( + + ); +} + +export function EmptyPipeline({ primary, secondary }: VariantProps = {}) { + return ( + + ); +} + +export function EmptyTeam({ primary, secondary }: VariantProps = {}) { + return ( + + ); +} + +export function EmptyApiTokens({ primary, secondary }: VariantProps = {}) { + return ( + + ); +} + +export function EmptyTimeline({ primary, secondary }: VariantProps = {}) { + return ( + + ); +} + +export function EmptyMergeQueue({ primary, secondary }: VariantProps = {}) { + return ( + + ); +} + +export function EmptyFilterResults({ primary, secondary }: VariantProps = {}) { + return ( + + ); +} diff --git a/components/feedback/SegmentError.tsx b/components/feedback/SegmentError.tsx new file mode 100644 index 000000000..a438c6be5 --- /dev/null +++ b/components/feedback/SegmentError.tsx @@ -0,0 +1,57 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +export interface SegmentErrorProps { + error: Error & { digest?: string }; + reset: () => void; + segment?: string; +} + +export function SegmentError({ error, reset, segment }: SegmentErrorProps) { + const [eventId, setEventId] = useState(undefined); + const [copied, setCopied] = useState(false); + + useEffect(() => { + const id = Sentry.captureException(error, { + tags: segment ? { segment } : undefined, + }); + setEventId(id); + }, [error, segment]); + + const displayId = eventId ?? error.digest ?? "—"; + + function copyId() { + if (typeof navigator !== "undefined" && navigator.clipboard) { + void navigator.clipboard.writeText(displayId).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + } + + return ( +
    + +

    Algo deu errado

    +

    + Tente novamente em instantes. Se persistir, contate o suporte com o ID abaixo. +

    +
    + ID: {displayId} +
    +
    + + +
    +
    +
    + ); +} diff --git a/components/inbox/ConversationList.tsx b/components/inbox/ConversationList.tsx index 49a3ff11a..0381264fe 100644 --- a/components/inbox/ConversationList.tsx +++ b/components/inbox/ConversationList.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { ConversationListItem } from "./ConversationListItem"; +import { EmptyInbox } from "@/components/empty"; import { useConversationsRealtime, type ConversationsFilters, @@ -69,8 +70,8 @@ export function ConversationList({ if (items.length === 0) { return ( -
    - Nenhuma conversa para os filtros atuais. +
    +
    ); } diff --git a/docker-compose.yml b/docker-compose.yml index 44d0b52f6..5860c5f3e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ version: "3.9" services: waha: image: devlikeapro/waha-plus:latest + platform: linux/amd64 container_name: deskcomm-waha restart: unless-stopped ports: diff --git a/docs/DEPLOY-CHECKLIST.md b/docs/DEPLOY-CHECKLIST.md new file mode 100644 index 000000000..b67bf85be --- /dev/null +++ b/docs/DEPLOY-CHECKLIST.md @@ -0,0 +1,42 @@ +# Deploy Preflight Checklist + +Run through this list before promoting to production (or before each release tag). + +## Environment + +- [ ] All envs set in Vercel project (mirror `.env.local`) +- [ ] `SENTRY_DSN` set; `SENTRY_AUTH_TOKEN` configured for source maps upload +- [ ] `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY` set +- [ ] `WAHA_*` envs set (URL, plaintext API key client side, SHA512 hash server side) +- [ ] `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` set +- [ ] `INTERNAL_SECRET` set (rotated at least once) + +## Infrastructure + +- [ ] Supabase migrations applied to target environment (`supabase/migrations/`) +- [ ] Supabase RLS policies verified on tenant-aware tables (cross-tenant smoke) +- [ ] WAHA Plus running with auth'd WhatsApp number, webhook URL pointing to deploy +- [ ] Sentry project configured + DSN in env, test event captured +- [ ] Resend domain verified (transactional emails) +- [ ] Nuvemshop app published in Partners portal + +## Verification + +- [ ] `pnpm typecheck` clean locally on the release commit +- [ ] `pnpm lint` clean +- [ ] `pnpm test:unit` green +- [ ] `pnpm test:e2e` green against preview URL +- [ ] Manual smoke: login (with MFA), create lead, send/receive WhatsApp message, see audit log entry, view kanban +- [ ] Sentry test event captured from prod environment +- [ ] LCP/CLS/INP within budget (Vercel Analytics RUM) +- [ ] No `console.log` leaks in build output (`pnpm build | grep -i console` should be quiet) + +## Rollback plan + +- [ ] Previous deploy URL noted +- [ ] DB migrations reversible OR forward-only with documented hot-fix path +- [ ] On-call engineer notified + +--- + +Reference: [`docs/stories/epics/EPIC-12-hardening.md`](stories/epics/EPIC-12-hardening.md) §S-12.10. diff --git a/instrumentation-client.ts b/instrumentation-client.ts new file mode 100644 index 000000000..ddde95a1b --- /dev/null +++ b/instrumentation-client.ts @@ -0,0 +1,57 @@ +// This file configures the initialization of Sentry on the client. +// The added config here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +const SENSITIVE_HEADERS = [ + "authorization", + "cookie", + "x-api-key", + "x-waha-api-key", + "x-nuvemshop-token", + "x-deskcomm-token", +]; + +function scrubMessage(input: string): string { + return input + .replace(/\d{3}\.?\d{3}\.?\d{3}-?\d{2}/g, "[CPF]") + .replace(/\+?\d{2}\s?\d{4,5}-?\d{4}/g, "[PHONE]") + .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "[EMAIL]"); +} + +Sentry.init({ + dsn: "https://58fabf8ad54504863d404a3647ef3714@o4509908078559232.ingest.us.sentry.io/4509908083212288", + + integrations: [Sentry.replayIntegration()], + + tracesSampleRate: 1, + enableLogs: true, + + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + + sendDefaultPii: false, + + beforeSend(event) { + if (event.request?.headers) { + const headers = event.request.headers as Record; + for (const k of Object.keys(headers)) { + if (SENSITIVE_HEADERS.includes(k.toLowerCase())) { + delete headers[k]; + } + } + } + if (typeof event.message === "string") { + event.message = scrubMessage(event.message); + } + if (event.exception?.values) { + for (const ex of event.exception.values) { + if (ex.value) ex.value = scrubMessage(ex.value); + } + } + return event; + }, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 000000000..7cbe93c13 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from "@sentry/nextjs"; + +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config"); + } + + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/lib/auth/public-paths.ts b/lib/auth/public-paths.ts index 2035ced24..595f5c04c 100644 --- a/lib/auth/public-paths.ts +++ b/lib/auth/public-paths.ts @@ -7,6 +7,8 @@ export const PUBLIC_PATHS: RegExp[] = [ /^\/login(\/.*)?$/, /^\/403$/, /^\/404$/, + /^\/500$/, + /^\/503$/, /^\/api\/v1\/health$/, /^\/api\/v1\/webhooks\//, /^\/_next\//, diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 000000000..2d5c43a67 --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,41 @@ +/** + * Structured JSON logger (zero-deps, pino-style API). + * + * Used in API routes and workers. Output is one JSON line per call so log + * aggregators (Vercel runtime logs, Sentry breadcrumb sinks, etc.) can index + * arbitrary fields without parsing free-form strings. + * + * Never log secrets, raw tokens, message bodies, CPF, or phone numbers. + */ + +export type LogContext = Record; + +function fmt(level: string, msg: string, ctx?: LogContext): string { + return JSON.stringify({ + level, + msg, + ts: new Date().toISOString(), + ...(ctx ?? {}), + }); +} + +export const logger = { + info(msg: string, ctx?: LogContext): void { + // eslint-disable-next-line no-console + console.log(fmt("info", msg, ctx)); + }, + warn(msg: string, ctx?: LogContext): void { + // eslint-disable-next-line no-console + console.warn(fmt("warn", msg, ctx)); + }, + error(msg: string, ctx?: LogContext): void { + // eslint-disable-next-line no-console + console.error(fmt("error", msg, ctx)); + }, + debug(msg: string, ctx?: LogContext): void { + if (process.env.NODE_ENV === "development") { + // eslint-disable-next-line no-console + console.log(fmt("debug", msg, ctx)); + } + }, +}; diff --git a/next.config.ts b/next.config.ts index d3ab8e8fc..5cb6fd122 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,10 +1,18 @@ +import { withSentryConfig } from "@sentry/nextjs"; import type { NextConfig } from "next"; +/** Performance budget (EPIC-12 §S-12.05): + * - LCP < 2.5s p75 + * - CLS < 0.1 p75 + * - INP < 200ms p75 + * - Initial bundle /app/inbox < 250KB gzipped + */ const nextConfig: NextConfig = { reactStrictMode: true, poweredByHeader: false, experimental: { typedRoutes: true, + optimizePackageImports: ["@phosphor-icons/react", "lucide-react", "date-fns"], }, images: { remotePatterns: [ @@ -28,4 +36,40 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withSentryConfig(nextConfig, { + // For all available options, see: + // https://www.npmjs.com/package/@sentry/webpack-plugin#options + + org: "automatik-labs", + + project: "javascript-nextjs", + + // Only print logs for uploading source maps in CI + silent: !process.env.CI, + + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + tunnelRoute: "/monitoring", + + webpack: { + // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.) + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, + + // Tree-shaking options for reducing bundle size + treeshake: { + // Automatically tree-shake Sentry logger statements to reduce bundle size + removeDebugLogging: true, + }, + }, +}); diff --git a/package.json b/package.json index 4a6586de0..7f3869a4a 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@axe-core/playwright": "^4.11.2", "@playwright/test": "^1.59.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 843351d10..1fee67409 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: specifier: ^3.23.8 version: 3.25.76 devDependencies: + '@axe-core/playwright': + specifier: ^4.11.2 + version: 4.11.2(playwright-core@1.59.1) '@playwright/test': specifier: ^1.59.1 version: 1.59.1 @@ -206,6 +209,11 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@axe-core/playwright@4.11.2': + resolution: {integrity: sha512-iP6hfNl9G0j/SEUSo8M7D80RbcDo9KRAAfDP4IT5OHB+Wm6zUHIrm8Y51BKI+Oyqduvipf9u1hcRy57zCBKzWQ==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -4585,6 +4593,11 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@axe-core/playwright@4.11.2(playwright-core@1.59.1)': + dependencies: + axe-core: 4.11.3 + playwright-core: 1.59.1 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts new file mode 100644 index 000000000..70218c838 --- /dev/null +++ b/sentry.edge.config.ts @@ -0,0 +1,48 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +const SENSITIVE_HEADERS = [ + "authorization", + "cookie", + "x-api-key", + "x-waha-api-key", + "x-nuvemshop-token", + "x-deskcomm-token", +]; + +function scrubMessage(input: string): string { + return input + .replace(/\d{3}\.?\d{3}\.?\d{3}-?\d{2}/g, "[CPF]") + .replace(/\+?\d{2}\s?\d{4,5}-?\d{4}/g, "[PHONE]") + .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "[EMAIL]"); +} + +Sentry.init({ + dsn: "https://58fabf8ad54504863d404a3647ef3714@o4509908078559232.ingest.us.sentry.io/4509908083212288", + + tracesSampleRate: 1, + enableLogs: true, + sendDefaultPii: false, + + beforeSend(event) { + if (event.request?.headers) { + const headers = event.request.headers as Record; + for (const k of Object.keys(headers)) { + if (SENSITIVE_HEADERS.includes(k.toLowerCase())) { + delete headers[k]; + } + } + } + if (typeof event.message === "string") { + event.message = scrubMessage(event.message); + } + if (event.exception?.values) { + for (const ex of event.exception.values) { + if (ex.value) ex.value = scrubMessage(ex.value); + } + } + return event; + }, +}); diff --git a/sentry.server.config.ts b/sentry.server.config.ts new file mode 100644 index 000000000..07223ee38 --- /dev/null +++ b/sentry.server.config.ts @@ -0,0 +1,49 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +const SENSITIVE_HEADERS = [ + "authorization", + "cookie", + "x-api-key", + "x-waha-api-key", + "x-nuvemshop-token", + "x-deskcomm-token", +]; + +function scrubMessage(input: string): string { + return input + .replace(/\d{3}\.?\d{3}\.?\d{3}-?\d{2}/g, "[CPF]") + .replace(/\+?\d{2}\s?\d{4,5}-?\d{4}/g, "[PHONE]") + .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "[EMAIL]"); +} + +Sentry.init({ + dsn: "https://58fabf8ad54504863d404a3647ef3714@o4509908078559232.ingest.us.sentry.io/4509908083212288", + + tracesSampleRate: 1, + enableLogs: true, + sendDefaultPii: false, + + beforeSend(event) { + if (event.request?.headers) { + const headers = event.request.headers as Record; + for (const k of Object.keys(headers)) { + if (SENSITIVE_HEADERS.includes(k.toLowerCase())) { + delete headers[k]; + } + } + } + if (typeof event.message === "string") { + event.message = scrubMessage(event.message); + } + if (event.exception?.values) { + for (const ex of event.exception.values) { + if (ex.value) ex.value = scrubMessage(ex.value); + } + } + return event; + }, +}); diff --git a/supabase/migrations/20260428195354_0001_platform_base.sql b/supabase/migrations/20260428195354_0001_platform_base.sql new file mode 100644 index 000000000..015331c0d --- /dev/null +++ b/supabase/migrations/20260428195354_0001_platform_base.sql @@ -0,0 +1,13 @@ +-- ============================================================================= +-- Migration 0001 — platform base (applied 2026-04-28 via Supabase MCP) +-- ============================================================================= +-- Tables: organizations, user_organizations, platform_admins, api_tokens, +-- api_audit_log, user_recovery_codes, idempotency_keys. +-- Helpers: fn_user_org_ids, fn_is_platform_admin, fn_user_role_in_org, +-- fn_role_at_least. +-- +-- Source of truth: docs/specs/01-spec-platform-base.md +-- Stub kept here to make supabase/migrations/ reflect remote +-- supabase_migrations.schema_migrations. To replay on a fresh project, use the +-- SQL embedded in the spec or `supabase db pull` from the source project. +SELECT 1; diff --git a/supabase/migrations/20260428195513_0002_event_log_and_compat.sql b/supabase/migrations/20260428195513_0002_event_log_and_compat.sql new file mode 100644 index 000000000..2e7bcadbd --- /dev/null +++ b/supabase/migrations/20260428195513_0002_event_log_and_compat.sql @@ -0,0 +1,7 @@ +-- ============================================================================= +-- Migration 002_event_log_and_compat (applied via Supabase MCP) +-- ============================================================================= +-- Source of truth: docs/specs/02-spec-*.md +-- Stub kept here to make supabase/migrations/ reflect remote +-- supabase_migrations.schema_migrations. See MANIFEST.md for the row index. +SELECT 1; diff --git a/supabase/migrations/20260428195708_0003_customer_360.sql b/supabase/migrations/20260428195708_0003_customer_360.sql new file mode 100644 index 000000000..0bfe458a2 --- /dev/null +++ b/supabase/migrations/20260428195708_0003_customer_360.sql @@ -0,0 +1,7 @@ +-- ============================================================================= +-- Migration 003_customer_360 (applied via Supabase MCP) +-- ============================================================================= +-- Source of truth: docs/specs/02-spec-*.md +-- Stub kept here to make supabase/migrations/ reflect remote +-- supabase_migrations.schema_migrations. See MANIFEST.md for the row index. +SELECT 1; diff --git a/supabase/migrations/20260428200016_0004_whatsapp_waha.sql b/supabase/migrations/20260428200016_0004_whatsapp_waha.sql new file mode 100644 index 000000000..5d89c124c --- /dev/null +++ b/supabase/migrations/20260428200016_0004_whatsapp_waha.sql @@ -0,0 +1,7 @@ +-- ============================================================================= +-- Migration 004_whatsapp_waha (applied via Supabase MCP) +-- ============================================================================= +-- Source of truth: docs/specs/03-spec-*.md +-- Stub kept here to make supabase/migrations/ reflect remote +-- supabase_migrations.schema_migrations. See MANIFEST.md for the row index. +SELECT 1; diff --git a/supabase/migrations/20260428200128_0005_ai_rag.sql b/supabase/migrations/20260428200128_0005_ai_rag.sql new file mode 100644 index 000000000..0eef79783 --- /dev/null +++ b/supabase/migrations/20260428200128_0005_ai_rag.sql @@ -0,0 +1,7 @@ +-- ============================================================================= +-- Migration 005_ai_rag (applied via Supabase MCP) +-- ============================================================================= +-- Source of truth: docs/specs/05-spec-*.md +-- Stub kept here to make supabase/migrations/ reflect remote +-- supabase_migrations.schema_migrations. See MANIFEST.md for the row index. +SELECT 1; diff --git a/supabase/migrations/20260428200211_0006_nuvemshop_lgpd.sql b/supabase/migrations/20260428200211_0006_nuvemshop_lgpd.sql new file mode 100644 index 000000000..55489dd4d --- /dev/null +++ b/supabase/migrations/20260428200211_0006_nuvemshop_lgpd.sql @@ -0,0 +1,7 @@ +-- ============================================================================= +-- Migration 006_nuvemshop_lgpd (applied via Supabase MCP) +-- ============================================================================= +-- Source of truth: docs/specs/06-spec-*.md +-- Stub kept here to make supabase/migrations/ reflect remote +-- supabase_migrations.schema_migrations. See MANIFEST.md for the row index. +SELECT 1; diff --git a/supabase/migrations/20260428200331_0007_security_hardening.sql b/supabase/migrations/20260428200331_0007_security_hardening.sql new file mode 100644 index 000000000..df33477f2 --- /dev/null +++ b/supabase/migrations/20260428200331_0007_security_hardening.sql @@ -0,0 +1,7 @@ +-- ============================================================================= +-- Migration 007_security_hardening (applied via Supabase MCP) +-- ============================================================================= +-- Source of truth: docs/specs/01-spec-*.md +-- Stub kept here to make supabase/migrations/ reflect remote +-- supabase_migrations.schema_migrations. See MANIFEST.md for the row index. +SELECT 1; diff --git a/supabase/migrations/20260429013958_0008_tenant_onboarding_state.sql b/supabase/migrations/20260429013958_0008_tenant_onboarding_state.sql new file mode 100644 index 000000000..d6baa1e02 --- /dev/null +++ b/supabase/migrations/20260429013958_0008_tenant_onboarding_state.sql @@ -0,0 +1,7 @@ +-- ============================================================================= +-- Migration 008_tenant_onboarding_state (applied via Supabase MCP) +-- ============================================================================= +-- Source of truth: docs/specs/02-spec-*.md +-- Stub kept here to make supabase/migrations/ reflect remote +-- supabase_migrations.schema_migrations. See MANIFEST.md for the row index. +SELECT 1; diff --git a/supabase/migrations/20260429021857_0009_expand_messaging_constraints.sql b/supabase/migrations/20260429021857_0009_expand_messaging_constraints.sql new file mode 100644 index 000000000..d37ff2e32 --- /dev/null +++ b/supabase/migrations/20260429021857_0009_expand_messaging_constraints.sql @@ -0,0 +1,7 @@ +-- ============================================================================= +-- Migration 009_expand_messaging_constraints (applied via Supabase MCP) +-- ============================================================================= +-- Source of truth: docs/specs/03-spec-*.md +-- Stub kept here to make supabase/migrations/ reflect remote +-- supabase_migrations.schema_migrations. See MANIFEST.md for the row index. +SELECT 1; diff --git a/supabase/migrations/MANIFEST.md b/supabase/migrations/MANIFEST.md index e759a76c0..173ea597c 100644 --- a/supabase/migrations/MANIFEST.md +++ b/supabase/migrations/MANIFEST.md @@ -12,7 +12,9 @@ Migrations applied to Supabase project `rrydmwnporysaiysiztn` (sa-east-1, Postgr | `20260428200016` | `0004_whatsapp_waha` | channel_sessions, channel_session_warmup, conversations, messages, webhook_events_log + emit_message_event trigger | | `20260428200128` | `0005_ai_rag` | ai_agents, ai_knowledge_sources, ai_chunks (vector(1536) ivfflat), ai_knowledge_versions, ai_invocations, ai_pricing (3 seeded), ai_budgets + fn_audit_log_row helper | | `20260428200211` | `0006_nuvemshop_lgpd` | tenant_integrations, orders, nuvemshop_products, lgpd_requests + fn_encrypt_oauth/fn_decrypt_oauth + LGPD/DLQ extra indexes on webhook_events_log | -| `20260428xxxxxx` | `0007_security_hardening` | search_path=public set on all functions, ai_pricing public-read policy, revoke EXECUTE anon on internal helpers, tighten api_audit_log INSERT policy | +| `20260428200331` | `0007_security_hardening` | search_path=public set on all functions, ai_pricing public-read policy, revoke EXECUTE anon on internal helpers, tighten api_audit_log INSERT policy | +| `20260429013958` | `0008_tenant_onboarding_state` | onboarding state machine columns + transitions on organizations | +| `20260429021857` | `0009_expand_messaging_constraints` | extra check constraints + indexes on conversations/messages for inbox perf | ## Reproducibility diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 000000000..59f9e45e7 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +test.describe("auth flow", () => { + test("anon GET /app/inbox redirects to /login", async ({ page }) => { + await page.goto("/app/inbox"); + // Either we land on /login (with optional ?next=) or middleware sends us elsewhere + await page.waitForURL(/\/login/); + expect(page.url()).toMatch(/\/login/); + }); + + test("invalid login shows error", async ({ page }) => { + await page.goto("/login"); + await page.locator("#email").fill("nobody@example.com"); + await page.locator("#password").fill("wrong-password-xyz"); + await page.getByRole("button", { name: /entrar/i }).click(); + // Wait for either an inline error or that we did NOT navigate to /app + await page.waitForTimeout(1500); + expect(page.url()).not.toMatch(/\/app\//); + }); + + test("login form is keyboard navigable in tab order", async ({ page }) => { + await page.goto("/login"); + await page.locator("#email").focus(); + await expect(page.locator("#email")).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.locator("#password")).toBeFocused(); + await page.keyboard.press("Tab"); + // Next focusable is the submit button + const submit = page.getByRole("button", { name: /entrar/i }); + await expect(submit).toBeFocused(); + }); + + test("login page has no serious or critical a11y violations", async ({ page }) => { + await page.goto("/login"); + const results = await new AxeBuilder({ page }).analyze(); + const blocking = results.violations.filter((v) => + ["serious", "critical"].includes(v.impact ?? ""), + ); + expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]); + }); +}); diff --git a/tests/e2e/error-pages.spec.ts b/tests/e2e/error-pages.spec.ts new file mode 100644 index 000000000..460b5d127 --- /dev/null +++ b/tests/e2e/error-pages.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from "@playwright/test"; + +test.describe("error pages", () => { + test("/404 renders PT-BR copy", async ({ page }) => { + const res = await page.goto("/404"); + // Next renders the not-found.tsx component; status may be 404 or 200 depending on routing. + expect([200, 404]).toContain(res?.status() ?? 0); + await expect(page.getByText(/não encontrada/i)).toBeVisible(); + }); + + test("/403 renders sem permissão", async ({ page }) => { + await page.goto("/403"); + await expect(page.getByText(/sem permissão/i)).toBeVisible(); + }); + + test("/500 renders erro interno", async ({ page }) => { + await page.goto("/500"); + await expect(page.getByText(/erro interno/i)).toBeVisible(); + }); + + test("/503 renders manutenção", async ({ page }) => { + await page.goto("/503"); + await expect(page.getByText(/manutenção/i)).toBeVisible(); + }); +});