mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 4.7
parent
7334f1e45a
commit
5d0cdb5c48
@@ -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).
|
||||
@@ -53,3 +53,6 @@ supabase/.branches/
|
||||
.waha-data/
|
||||
.epic-executor/
|
||||
.playwright-mcp/
|
||||
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
+15
-3
@@ -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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Novo contato" para começar.
|
||||
</p>
|
||||
<Card className="p-2">
|
||||
<EmptyContacts />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -7,6 +7,8 @@ export const PUBLIC_PATHS: RegExp[] = [
|
||||
/^\/login(\/.*)?$/,
|
||||
/^\/403$/,
|
||||
/^\/404$/,
|
||||
/^\/500$/,
|
||||
/^\/503$/,
|
||||
/^\/api\/v1\/health$/,
|
||||
/^\/api\/v1\/webhooks\//,
|
||||
/^\/_next\//,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+13
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user