feat(EPIC-12): hardening + e2e + polish [10 waves, partial]

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) <noreply@anthropic.com>
This commit is contained in:
Rafael Melgaço
2026-04-28 23:32:27 -03:00
co-authored by Claude Opus 4.7
parent 7334f1e45a
commit 5d0cdb5c48
50 changed files with 1538 additions and 95 deletions
+41
View File
@@ -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).
+3
View File
@@ -53,3 +53,6 @@ supabase/.branches/
.waha-data/
.epic-executor/
.playwright-mcp/
# Sentry Config File
.env.sentry-build-plugin
+50
View File
@@ -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).
+78
View File
@@ -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).
+69 -81
View File
@@ -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: <http://localhost:3000>
WAHA dashboard: <http://localhost:3000/dashboard> (porta 3000 do container)
Health check: <http://localhost:3000/api/v1/health>
App: <http://localhost:3000> · Health check: <http://localhost:3000/api/v1/health>
---
## 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
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { SegmentError, type SegmentErrorProps } from "@/components/feedback/SegmentError";
export default function PublicError(props: SegmentErrorProps) {
return <SegmentError {...props} segment="public" />;
}
+14 -2
View File
@@ -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 (
<main className="flex min-h-screen items-center justify-center p-8">
<div className="text-center">
<Card className="w-full max-w-md p-8 text-center">
<h1 className="text-2xl font-semibold">403 — Sem permissão</h1>
<p className="mt-2 text-sm text-muted-foreground">
Você não tem acesso a esta área. Volte para a página inicial.
Você não tem acesso a essa área.
</p>
<div className="mt-6 flex justify-center gap-2">
<Button asChild variant="outline">
<Link href="/">Voltar</Link>
</Button>
<Button asChild>
<Link href="/app/inbox">Voltar pra Inbox</Link>
</Button>
</div>
</Card>
</main>
);
}
+28
View File
@@ -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 (
<main className="flex min-h-screen items-center justify-center p-8">
<Card className="w-full max-w-md p-8 text-center">
<h1 className="text-2xl font-semibold">500 — Erro interno</h1>
<p className="mt-2 text-sm text-muted-foreground">
Algo deu errado do nosso lado. Reporte ao suporte com o ID exibido na tela
anterior, se houver.
</p>
<div className="mt-4 break-all rounded-md bg-muted px-3 py-2 font-mono text-xs">
ID: —
</div>
<div className="mt-4 flex justify-center gap-2">
<Button asChild variant="outline">
<Link href="/">Voltar</Link>
</Button>
<Button asChild>
<Link href="/app/inbox">Voltar pra Inbox</Link>
</Button>
</div>
</Card>
</main>
);
}
+21
View File
@@ -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 (
<main className="flex min-h-screen items-center justify-center p-8">
<Card className="w-full max-w-md p-8 text-center">
<h1 className="text-2xl font-semibold">503 — Em manutenção</h1>
<p className="mt-2 text-sm text-muted-foreground">
Voltamos em alguns minutos.
</p>
<div className="mt-6 flex justify-center gap-2">
<Button asChild>
<Link href="/">Voltar</Link>
</Button>
</div>
</Card>
</main>
);
}
+17
View File
@@ -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.",
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function AuditLoading() {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-48" />
<div className="flex gap-2">
<Skeleton className="h-9 w-32" />
<Skeleton className="h-9 w-32" />
<Skeleton className="h-9 w-32" />
</div>
<div className="space-y-2">
{Array.from({ length: 12 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</div>
);
}
+3 -4
View File
@@ -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() {
</Button>
</Card>
) : allContacts.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-sm text-muted-foreground">
Nenhum contato ainda. Clique em &quot;Novo contato&quot; para começar.
</p>
<Card className="p-2">
<EmptyContacts />
</Card>
) : (
<>
+15
View File
@@ -0,0 +1,15 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function ContactsLoading() {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-md" />
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { SegmentError, type SegmentErrorProps } from "@/components/feedback/SegmentError";
export default function AppError(props: SegmentErrorProps) {
return <SegmentError {...props} segment="app" />;
}
+25
View File
@@ -0,0 +1,25 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function InboxLoading() {
return (
<div className="flex h-full">
<div className="w-80 border-r p-4 space-y-3">
<Skeleton className="h-9 w-full" />
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
<div className="flex-1 p-6 space-y-4">
<Skeleton className="h-12 w-1/3" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-12 w-full" />
</div>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function KanbanLoading() {
return (
<div className="p-6">
<Skeleton className="h-8 w-64 mb-6" />
<div className="flex gap-4 overflow-x-auto">
{Array.from({ length: 5 }).map((_, col) => (
<div key={col} className="w-72 flex-shrink-0 space-y-3">
<Skeleton className="h-6 w-32" />
{Array.from({ length: 3 }).map((_, card) => (
<Skeleton key={card} className="h-24 w-full" />
))}
</div>
))}
</div>
</div>
);
}
+4 -3
View File
@@ -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 ? (
<div className="flex flex-1 items-center justify-center">
<div className="max-w-sm text-center text-sm text-muted-foreground">
Nenhum pipeline. Crie um em Configurações.
</div>
<EmptyPipeline
primary={{ label: "Ir para Configurações", href: "/app/settings" }}
/>
</div>
) : (
<ul className="flex flex-col gap-2">
+11
View File
@@ -0,0 +1,11 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function AppLoading() {
return (
<div className="flex flex-col gap-4 p-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { SegmentError, type SegmentErrorProps } from "@/components/feedback/SegmentError";
export default function RootError(props: SegmentErrorProps) {
return <SegmentError {...props} segment="root" />;
}
+109
View File
@@ -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<string | undefined>(undefined);
const [copied, setCopied] = useState(false);
useEffect(() => {
const id = Sentry.captureException(error);
setEventId(id);
}, [error]);
const displayId = eventId ?? error.digest ?? "—";
return (
<html lang="pt-BR">
<body
style={{
margin: 0,
fontFamily:
"ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
background: "#fafaf9",
color: "#1c1917",
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
}}
>
<div
style={{
maxWidth: 480,
width: "100%",
background: "white",
border: "1px solid #e7e5e4",
borderRadius: 12,
padding: "2rem",
textAlign: "center",
}}
>
<h1 style={{ fontSize: "1.5rem", margin: "0 0 0.5rem", fontWeight: 600 }}>
Algo deu errado
</h1>
<p style={{ color: "#57534e", margin: "0 0 1.5rem" }}>
Tente novamente em instantes. Se persistir, contate o suporte com o ID abaixo.
</p>
<div
style={{
fontFamily: "ui-monospace, monospace",
fontSize: "0.75rem",
background: "#f5f5f4",
padding: "0.5rem",
borderRadius: 6,
marginBottom: "1rem",
wordBreak: "break-all",
}}
>
ID: {displayId}
</div>
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "center" }}>
<button
type="button"
onClick={() => {
if (typeof navigator !== "undefined" && navigator.clipboard) {
void navigator.clipboard.writeText(displayId).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
}}
style={{
padding: "0.5rem 1rem",
border: "1px solid #d6d3d1",
background: "white",
borderRadius: 6,
cursor: "pointer",
}}
>
{copied ? "Copiado!" : "Copiar ID"}
</button>
<button
type="button"
onClick={() => reset()}
style={{
padding: "0.5rem 1rem",
border: "1px solid #1c1917",
background: "#1c1917",
color: "white",
borderRadius: 6,
cursor: "pointer",
}}
>
Tentar de novo
</button>
</div>
</div>
</body>
</html>
);
}
+24
View File
@@ -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 (
<main className="flex min-h-screen items-center justify-center p-8">
<Card className="w-full max-w-md p-8 text-center">
<h1 className="text-2xl font-semibold">404 — Página não encontrada</h1>
<p className="mt-2 text-sm text-muted-foreground">
Verifique o link ou volte pra inbox.
</p>
<div className="mt-6 flex justify-center gap-2">
<Button asChild variant="outline">
<Link href="/">Voltar</Link>
</Button>
<Button asChild>
<Link href="/app/inbox">Voltar pra Inbox</Link>
</Button>
</div>
</Card>
</main>
);
}
+236
View File
@@ -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 (
<div>
<Head>
<title>sentry-example-page</title>
<meta name="description" content="Test Sentry for your Next.js app!" />
</Head>
<main>
<div className="flex-spacer" />
<svg
height="40"
width="40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Sentry logo"
>
<path
d="M21.85 2.995a3.698 3.698 0 0 1 1.353 1.354l16.303 28.278a3.703 3.703 0 0 1-1.354 5.053 3.694 3.694 0 0 1-1.848.496h-3.828a31.149 31.149 0 0 0 0-3.09h3.815a.61.61 0 0 0 .537-.917L20.523 5.893a.61.61 0 0 0-1.057 0l-3.739 6.494a28.948 28.948 0 0 1 9.63 10.453 28.988 28.988 0 0 1 3.499 13.78v1.542h-9.852v-1.544a19.106 19.106 0 0 0-2.182-8.85 19.08 19.08 0 0 0-6.032-6.829l-1.85 3.208a15.377 15.377 0 0 1 6.382 12.484v1.542H3.696A3.694 3.694 0 0 1 0 34.473c0-.648.17-1.286.494-1.849l2.33-4.074a8.562 8.562 0 0 1 2.689 1.536L3.158 34.17a.611.611 0 0 0 .538.917h8.448a12.481 12.481 0 0 0-6.037-9.09l-1.344-.772 4.908-8.545 1.344.77a22.16 22.16 0 0 1 7.705 7.444 22.193 22.193 0 0 1 3.316 10.193h3.699a25.892 25.892 0 0 0-3.811-12.033 25.856 25.856 0 0 0-9.046-8.796l-1.344-.772 5.269-9.136a3.698 3.698 0 0 1 3.2-1.849c.648 0 1.285.17 1.847.495Z"
fill="currentcolor"
/>
</svg>
<h1>sentry-example-page</h1>
<p className="description">
Click the button below, and view the sample error on the Sentry{" "}
<a
target="_blank"
rel="noopener"
href="https://automatik-labs.sentry.io/issues/?project=4509908083212288"
>
Issues Page
</a>
. For more details about setting up Sentry,{" "}
<a
target="_blank"
rel="noopener"
href="https://docs.sentry.io/platforms/javascript/guides/nextjs/"
>
read our docs
</a>
.
</p>
<button
type="button"
onClick={async () => {
Sentry.logger.info("User clicked the button, throwing a sample error");
await Sentry.startSpan(
{
name: "Example Frontend/Backend Span",
op: "test",
},
async () => {
const res = await fetch("/api/sentry-example-api");
if (!res.ok) {
setHasSentError(true);
}
},
);
throw new SentryExampleFrontendError(
"This error is raised on the frontend of the example page.",
);
}}
disabled={!isConnected}
>
<span>Throw Sample Error</span>
</button>
{hasSentError ? (
<p className="success">Error sent to Sentry.</p>
) : !isConnected ? (
<div className="connectivity-error">
<p>
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.
</p>
</div>
) : (
<div className="success_placeholder" />
)}
<div className="flex-spacer" />
</main>
<style>{`
main {
display: flex;
min-height: 100vh;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
padding: 16px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
}
h1 {
padding: 0px 4px;
border-radius: 4px;
background-color: rgba(24, 20, 35, 0.03);
font-family: monospace;
font-size: 20px;
line-height: 1.2;
}
p {
margin: 0;
font-size: 20px;
}
a {
color: #6341F0;
text-decoration: underline;
cursor: pointer;
@media (prefers-color-scheme: dark) {
color: #B3A1FF;
}
}
button {
border-radius: 8px;
color: white;
cursor: pointer;
background-color: #553DB8;
border: none;
padding: 0;
margin-top: 4px;
& > span {
display: inline-block;
padding: 12px 16px;
border-radius: inherit;
font-size: 20px;
font-weight: bold;
line-height: 1;
background-color: #7553FF;
border: 1px solid #553DB8;
transform: translateY(-4px);
}
&:hover > span {
transform: translateY(-8px);
}
&:active > span {
transform: translateY(0);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
& > span {
transform: translateY(0);
border: none
}
}
}
.description {
text-align: center;
color: #6E6C75;
max-width: 500px;
line-height: 1.5;
font-size: 20px;
@media (prefers-color-scheme: dark) {
color: #A49FB5;
}
}
.flex-spacer {
flex: 1;
}
.success {
padding: 12px 16px;
border-radius: 8px;
font-size: 20px;
line-height: 1;
background-color: #00F261;
border: 1px solid #00BF4D;
color: #181423;
}
.success_placeholder {
height: 46px;
}
.connectivity-error {
padding: 12px 16px;
background-color: #E50045;
border-radius: 8px;
width: 500px;
color: #FFFFFF;
border: 1px solid #A80033;
text-align: center;
margin: 0;
}
.connectivity-error a {
color: #FFFFFF;
text-decoration: underline;
}
`}</style>
</div>
);
}
+66
View File
@@ -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 (
<Button asChild variant={variant}>
<Link href={action.href}>{action.label}</Link>
</Button>
);
}
return (
<Button type="button" onClick={action.onClick} variant={variant}>
{action.label}
</Button>
);
}
export function EmptyState({
icon: Icon,
headline,
subcopy,
primary,
secondary,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon size={24} weight="duotone" />
</div>
<h3 className="text-base font-semibold">{headline}</h3>
{subcopy ? (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{subcopy}</p>
) : null}
{(primary || secondary) && (
<div className="mt-4 flex gap-2">
{secondary ? <ActionButton action={secondary} variant="outline" /> : null}
{primary ? <ActionButton action={primary} variant="default" /> : null}
</div>
)}
</div>
);
}
+13
View File
@@ -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";
+140
View File
@@ -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 (
<EmptyState
icon={ChatCircle}
headline="Sem conversas por aqui"
subcopy="Quando chegarem mensagens, elas aparecem aqui em tempo real."
primary={primary}
secondary={secondary}
/>
);
}
export function EmptyKanban({ primary, secondary }: VariantProps = {}) {
return (
<EmptyState
icon={Kanban}
headline="Pipeline vazio"
subcopy="Crie um lead pra começar a movimentar cards entre as etapas."
primary={primary}
secondary={secondary}
/>
);
}
export function EmptyContacts({ primary, secondary }: VariantProps = {}) {
return (
<EmptyState
icon={UsersThree}
headline="Nenhum contato ainda"
subcopy="Contatos chegam automaticamente via WhatsApp ou Nuvemshop."
primary={primary}
secondary={secondary}
/>
);
}
export function EmptyAudit({ primary, secondary }: VariantProps = {}) {
return (
<EmptyState
icon={ListMagnifyingGlass}
headline="Sem eventos no período"
subcopy="Ajuste o filtro de datas ou a busca pra ver eventos."
primary={primary}
secondary={secondary}
/>
);
}
export function EmptyPipeline({ primary, secondary }: VariantProps = {}) {
return (
<EmptyState
icon={GitBranch}
headline="Sem pipelines configurados"
subcopy="Crie um pipeline pra organizar leads em etapas."
primary={primary}
secondary={secondary}
/>
);
}
export function EmptyTeam({ primary, secondary }: VariantProps = {}) {
return (
<EmptyState
icon={Users}
headline="Sem membros no time"
subcopy="Convide colegas pra atender em conjunto."
primary={primary}
secondary={secondary}
/>
);
}
export function EmptyApiTokens({ primary, secondary }: VariantProps = {}) {
return (
<EmptyState
icon={Key}
headline="Nenhum token criado"
subcopy="Tokens permitem integrações server-to-server."
primary={primary}
secondary={secondary}
/>
);
}
export function EmptyTimeline({ primary, secondary }: VariantProps = {}) {
return (
<EmptyState
icon={ClockCounterClockwise}
headline="Sem atividades registradas"
subcopy="A timeline mostra mensagens, mudanças de stage e notas."
primary={primary}
secondary={secondary}
/>
);
}
export function EmptyMergeQueue({ primary, secondary }: VariantProps = {}) {
return (
<EmptyState
icon={ArrowsLeftRight}
headline="Sem candidatos a merge"
subcopy="Contatos duplicados aparecerão aqui pra revisão."
primary={primary}
secondary={secondary}
/>
);
}
export function EmptyFilterResults({ primary, secondary }: VariantProps = {}) {
return (
<EmptyState
icon={Funnel}
headline="Nenhum resultado"
subcopy="Tente ajustar os filtros ou a busca."
primary={primary}
secondary={secondary}
/>
);
}
+57
View File
@@ -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<string | undefined>(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 (
<main className="flex min-h-[60vh] items-center justify-center p-8">
<Card className="w-full max-w-md p-8 text-center">
<h1 className="text-xl font-semibold">Algo deu errado</h1>
<p className="mt-2 text-sm text-muted-foreground">
Tente novamente em instantes. Se persistir, contate o suporte com o ID abaixo.
</p>
<div className="mt-4 break-all rounded-md bg-muted px-3 py-2 font-mono text-xs">
ID: {displayId}
</div>
<div className="mt-4 flex justify-center gap-2">
<Button type="button" variant="outline" onClick={copyId}>
{copied ? "Copiado!" : "Copiar ID"}
</Button>
<Button type="button" onClick={() => reset()}>
Tentar de novo
</Button>
</div>
</Card>
</main>
);
}
+3 -2
View File
@@ -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 (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
Nenhuma conversa para os filtros atuais.
<div className="flex h-full items-center justify-center p-6">
<EmptyInbox />
</div>
);
}
+1
View File
@@ -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:
+42
View File
@@ -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.
+57
View File
@@ -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<string, string>;
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;
+13
View File
@@ -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;
+2
View File
@@ -7,6 +7,8 @@ export const PUBLIC_PATHS: RegExp[] = [
/^\/login(\/.*)?$/,
/^\/403$/,
/^\/404$/,
/^\/500$/,
/^\/503$/,
/^\/api\/v1\/health$/,
/^\/api\/v1\/webhooks\//,
/^\/_next\//,
+41
View File
@@ -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<string, unknown>;
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));
}
},
};
+45 -1
View File
@@ -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,
},
},
});
+1
View File
@@ -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",
+13
View File
@@ -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
+48
View File
@@ -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<string, string>;
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;
},
});
+49
View File
@@ -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<string, string>;
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;
},
});
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+3 -1
View File
@@ -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
+42
View File
@@ -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([]);
});
});
+25
View File
@@ -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();
});
});