Implementa o MVP do Adaptador de Planilhas no Elo Pro.

O módulo usa a identidade Supabase, isola cada workspace e registra storage, TTL, runner e biblioteca XLSX como decisões ainda abertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
camilafagundesandCursor committed 2026-10-02 23:02:00 -03:00
commit 6e23f1533b
144 files changed
+19060

No files matched your search

@@ -0,0 +1,46 @@
# Auditoria de integração — Adaptador de Planilhas
Data da inspeção: 2026-10-02. O diretório de trabalho era um repositório Git vazio, sem aplicação Elo Pro.
## O que não existia para reutilizar
- App Router, clientes Supabase, middleware e logout
- Resolução de `workspace_id` e `workspace_members`
- Papéis, RLS, feature flags, design system, TanStack Query, Sentry, Redis, audit log e storage
- Padrão de Route Handlers, Vitest, CI e migrations
## Diferença em relação ao master context
O contexto pede para encaixar o módulo no Elo Pro existente. Como não havia código de plataforma, este repositório implementa o Adaptador como aplicação Next.js pronta para compartilhar a mesma identidade, com um shim SQL das tabelas `profiles`, `workspaces`, `workspace_members`, `workspace_module_flags` e `audit_log`. Essas tabelas não têm senha, hash ou refresh token. Em um monorepo Elo Pro real, a migration `0001_platform_shim.sql` não deve recriar o que já existe (`create table if not exists`).
## Decisões já fechadas pelo escopo V3
Identidade única via Supabase Auth, tenant `workspace_id`, RLS, limites do MVP, mapeamento manual 1:1, prévia obrigatória, modelo imutável, flag de módulo e papéis owner/admin/gestor/agent.
## OPEN DECISIONS
Registradas em `docs/decisions/`:
1. Object storage definitivo
2. Topologia de cookie/login entre hosts
3. Job runner durável
4. TTL de temporários
5. Biblioteca XLSX de produção
6. Exclusão física versus soft delete
## Arquitetura
```mermaid
flowchart LR
browser[Browser Elo Pro] --> session[Sessão Supabase]
session --> app[Next.js Adaptador]
app --> domain[Domínio de planilhas]
app --> pg[(PostgreSQL + RLS)]
app --> redis[(Redis lock e progresso)]
app --> storage[Storage privado]
app --> sentry[Sentry sem células]
```
## Fases
Fundação e flag, autorização, schema, shell visual, modelos, fontes, mapeamento, jobs, Redis, auditoria/Sentry, testes, operação e revisão de gaps. A implementação seguiu essa ordem no mesmo repositório.
+40
View File
@@ -0,0 +1,40 @@
# Fundação, rotas e feature flag
## Rotas de página
- `/login` redireciona ao login central (`ELO_PRO_LOGIN_URL`) ou, só fora de produção, oferece identidades locais sem senha
- `/adaptador` dashboard
- `/adaptador/operacao` jornada
- `/adaptador/configuracoes` somente owner/admin
`loading.tsx` e `error.tsx` cobrem espera e falha recuperável. Sem sessão, o proxy manda para `/login`. Sem a flag `spreadsheet_adapter`, a página informa que o módulo está desligado e as APIs respondem `FEATURE_DISABLED`.
## APIs
- `GET/POST /api/spreadsheet-adapter/models`
- `DELETE /api/spreadsheet-adapter/models/:id`
- `GET/PATCH /api/spreadsheet-adapter/settings`
- `POST /api/spreadsheet-adapter/sources/validate`
- `POST /api/spreadsheet-adapter/jobs`
- `GET /api/spreadsheet-adapter/jobs/:id`
- `PUT /api/spreadsheet-adapter/jobs/:id/mappings`
- `POST /api/spreadsheet-adapter/jobs/:id/preview`
- `POST /api/spreadsheet-adapter/jobs/:id/process`
- `PATCH/DELETE /api/spreadsheet-adapter/jobs/:id/sources/:sourceId`
- `GET /api/spreadsheet-adapter/jobs/:id/download?format=xlsx|csv|pdf&sourceId=`
O `workspace_id` do corpo é ignorado como tenant. Se vier diferente da sessão, a chamada é negada e auditada.
## Códigos de erro estáveis
`UNAUTHENTICATED`, `WORKSPACE_FORBIDDEN`, `FEATURE_DISABLED`, `FORBIDDEN`, `TOO_MANY_MODELS`, `DUPLICATE_MODEL_NAME`, `TOO_MANY_SOURCES`, `FILE_TOO_LARGE`, `TOO_MANY_ROWS`, `UNSUPPORTED_FORMAT`, `MIME_MISMATCH`, `INVALID_STRUCTURE`, `ZIP_BOMB`, `EMPTY_HEADER`, `DUPLICATE_HEADER`, `HEADER_ROW_INVALID`, `SHEET_REQUIRED`, `TYPE_INCONSISTENT`, `MAPPING_CONFLICT`, `MAPPING_INCOMPLETE`, `MAPPING_UNKNOWN`, `MODEL_IN_USE`, `JOB_LOCKED`, `REDIS_UNAVAILABLE`, `MODEL_TAMPERED`, `NOT_FOUND`.
## Contratos
Upload de modelo: `multipart` com `file`, `name`, `sheet` opcional, `headerRow`, `requiredColumns` (JSON). Sem `requiredColumns`, todas as colunas nascem obrigatórias e a tela permite desmarcar.
Validação de fonte: `file`, `jobId`, `sheet`, `headerRow`. Várias abas devolvem `409 SHEET_REQUIRED` com a fonte já armazenada para troca de aba sem novo upload. Tipo misto devolve `422` e preserva as outras fontes.
Prévia: `{ preview: true, columns, rows, rowCount, truncated }`. Colunas na ordem do modelo. Não grava saída nem altera o modelo.
Temporários: `expires_at = agora + SPREADSHEET_TEMP_TTL_SECONDS`. Em produção a variável é obrigatória. Fora de produção, a ausência usa 3600 segundos apenas como apoio de desenvolvimento, não como política de produto.
@@ -0,0 +1,33 @@
# Autenticação, workspace, permissões e RLS
## Fluxo
1. Supabase Auth (`getUser`) quando `NEXT_PUBLIC_SUPABASE_URL` e a anon key existem.
2. Workspace ativo no cookie `elo_pro_workspace_id`, validado contra `workspace_members`.
3. Fora de produção, `ADAPTER_DEV_AUTH=1` aceita um cookie assinado sem senha, só para identidades já existentes no banco.
4. A flag `workspace_module_flags.module_key = spreadsheet_adapter` precisa estar ligada.
5. Logout chama `supabase.auth.signOut()` e apaga o cookie local de desenvolvimento.
6. `src/proxy.ts` só redireciona quem não tem cookie de sessão. A autorização real continua no servidor.
Não existe formulário de senha, tabela de credencial ou refresh token do Adaptador. Superadmin (`profiles.is_superadmin`) não ganha bypass dentro do módulo.
## Fonte do workspace
O identificador vem do cookie de plataforma ou da sessão de desenvolvimento. Consultas usam o valor já validado no servidor. Payload do browser não escolhe o tenant.
## Matriz
| Ação | Owner/Admin | Gestor/Agent |
| --- | --- | --- |
| Usar módulo habilitado | Sim | Sim |
| Operar fonte, mapa, prévia, processo e download | Sim | Sim |
| Enviar modelo | Sim | Só se `allow_non_admin_model_upload` |
| Excluir modelo e alterar a flag de upload | Sim | Não |
## RLS
`migrations/0003_rls.sql` liga e força RLS. O aplicativo faz `set local role app_user` e `app.user_id`. Funções `security definer` resolvem membership, flag, upload e job ativo sem vazamento entre workspaces. A conexão de serviço (worker, seed, flag, limpeza) não passa por `app_user` e permanece só no servidor.
## Testes
`tests/integration/rls.test.ts` cobre leitura/escrita cruzada, insert bloqueado por RLS, flag desligada mesmo com contexto mentiroso e agent impedido de gravar settings. `tests/integration/journey.test.ts` cobre agent sem upload, upload após a setting e exclusão negada.
+26
View File
@@ -0,0 +1,26 @@
# Modelo de dados
```mermaid
erDiagram
workspaces ||--o| spreadsheet_adapter_settings : configura
workspaces ||--o{ spreadsheet_models : possui
workspaces ||--o{ spreadsheet_jobs : isola
profiles ||--o{ spreadsheet_jobs : executa
spreadsheet_models ||--o{ spreadsheet_jobs : orienta
spreadsheet_jobs ||--o{ spreadsheet_job_sources : contem
spreadsheet_job_sources ||--o{ spreadsheet_job_mappings : mapeia
workspaces ||--o{ audit_log : audita
```
Migrations: `0001_platform_shim.sql`, `0002_spreadsheet_adapter.sql`, `0003_rls.sql`. Rollback do módulo: `migrations/down/0002_spreadsheet_adapter_down.sql`. Não remove tabelas de identidade do Elo Pro.
## Integridade
- Nome lógico único entre modelos `active` por workspace
- No máximo 10 modelos ativos e 10 fontes por job, também por trigger
- Mapeamento único nos dois sentidos (`source_column` e `model_column`)
- Job não referencia modelo de outro workspace
- Conteúdo da planilha fica no storage, não em JSONB de linhas
- `status = deleted` e `deleted_at` deixam a exclusão física reversível até a decisão de produto
Colunas extras em fontes (`output_csv_key`, `output_xlsx_key`, `columns_schema`, `process_idempotency_key`) guardam metadados da saída temporária. O PDF é gerado na hora do download a partir do CSV privado.
+15
View File
@@ -0,0 +1,15 @@
# Shell de UX
Não havia Design System publicado no repositório. Os componentes em `src/components/ui.tsx` seguem os tokens do escopo V3: Cloud White `#FBFBFE`, Midnight Ink `#071B45`, Bright Blue `#2F8FFF`, Aqua Signal `#00C2A8`, Warning `#F39A2D`, Error `#E63946`, Manrope e raio de 10/14/18/24 px. O azul de ação `#0B3D91` está registrado como provisório em `docs/decisions/0006-action-blue.md` porque o token oficial não estava no repo.
## Páginas
Dashboard, jornada (modelo, fontes, mapeamento, revisão, processamento, resultado) e configurações. `JourneyTrail` mostra continuidade da jornada, com texto de etapa concluída, não só cor.
## Componentes
Button, IconButton, Input, Select, Switch, Dialog, Card, SemanticMarker, estados de loading, vazio e erro. Lucide para ícones. Mapeamento usa `@dnd-kit` e um `select` com o mesmo efeito, utilizável por teclado.
## O que a interface não faz
Não inventa modelos de demonstração. Lista vazia é estado vazio. Flag desligada não abre a jornada.
@@ -0,0 +1,35 @@
# Segurança e observabilidade
## Auditoria
`audit_log` recebe upload e exclusão de modelo, mudança da setting, criação, início, conclusão, falha e download. Metadados passam por lista de chaves bloqueadas e só aceitam escalares. O teste de jornada procura o token `=CMD()` no log e não o encontra.
## Sentry
`beforeSend` remove body, cookies, breadcrumbs e headers de autorização. Tags previstas: módulo, `job_id`, `workspace_id`, `error_code`. Validação de negócio (`AdapterError` abaixo de 500) não é capturada. Sem DSN, a inicialização não ocorre.
## Controles
- RLS e checagem de papel no serviço
- Extensão conferida com assinatura ZIP/OLE
- Rejeição de XLS, ODS, XML, MIME incompatível e ZIP com expansão acima de 256 MB ou taxa anômala
- Chave de storage sem `..` e, no disco local, resolvida dentro do diretório privado
- CSV/XLSX de saída neutraliza células que começam com `=`, `+`, `@` ou hífen que não é número
- Download exige sessão, workspace, fonte concluída e `expires_at` futuro
- Lock Redis (ou falha `REDIS_UNAVAILABLE`) antes de processar; clique repetido com a mesma chave de idempotência devolve o job existente
- Nomes exibidos são basename sanitizado
## Threat model resumido
| Ameaça | Mitigação | Residual |
| --- | --- | --- |
| IDOR / workspace forjado | Sessão + RLS + auditoria | Conexão de serviço ignora RLS e só pode existir no servidor |
| Bypass de papel | Serviço e policies | — |
| Planilha em log | Scrub e auditoria escalar | Erro inesperado ainda pode ir ao Sentry se o `throw` embutir dado; o código de domínio evita isso |
| ZIP bomb | Teto de expansão antes do ExcelJS | Estruturas com data descriptor continuam limitadas pelos 10 MB de entrada |
| CSV injection | Prefixo `'` | Números negativos permanecem numéricos de propósito |
| Job duplicado | Lock + idempotência | Lock em memória vale para um processo; produção exige Redis |
## Riscos residuais
PDF limita 200 linhas e 12 colunas e declara a limitação no próprio arquivo. XLSX/CSV levam o resultado completo. A formatação visual do modelo não é copiada: a saída é um arquivo novo e o binário do modelo é conferido por SHA-256 antes e depois.
@@ -0,0 +1,22 @@
# Plano de testes e resultado
Comandos: `npm test`, `npm run typecheck`. A suíte abaixo passou na execução local desta entrega, junto com o typecheck.
| Área | Evidência | Resultado |
| --- | --- | --- |
| Cabeçalho, tipos, 1:1, permissões, nomes, CSV, cache por workspace | `tests/unit/domain.test.ts` | Passou |
| Lock, expiração e scrub do Sentry | mesmo arquivo | Passou |
| 50.000 linhas CSV | `tests/performance/rows.test.ts` | Passou |
| Path traversal, MIME, XLS, ZIP | `tests/security/files.test.ts` | Passou |
| Switch, dialog, select, loading, vazio, erro | `tests/component/ui.test.tsx` | Passou |
| Migrations e rollback do módulo | `tests/integration/migrations.test.ts` | Passou |
| RLS A/B, agent versus owner | `tests/integration/rls.test.ts` | Passou |
| Jornada modelo → mapa → prévia → processo → download, modelo intacto, idempotência, limites, aba, Redis indisponível, TTL, workspace forjado | `tests/integration/journey.test.ts` | Passou |
## Não executado aqui
- E2E de browser contra o login real do Elo Pro. Não há host do ecossistema neste repositório. A jornada de API cobre o mesmo fluxo de negócio.
- XLSX de 10 MB e 50.000 linhas pelo ExcelJS. O teto de 10 MB é rejeitado antes do parse. As 50.000 linhas foram medidas no caminho CSV/domínio.
- Dois processos OS com Redis real. O lock em memória cobre dois usuários no mesmo processo; usuários diferentes usam chaves diferentes.
Não há bloqueio de teste vermelho na suíte automatizada. Os bloqueios de release estão na revisão final e são decisões de plataforma, não falhas desta suíte.
@@ -0,0 +1,49 @@
# Deploy e operação
## Ordem
1. Conferir envs sem colar segredo no repositório.
2. `npm run migrate` na `DATABASE_URL` do ambiente.
3. Subir a aplicação com a flag ainda desligada.
4. Subir `npm run worker` e o Redis do ambiente.
5. Confirmar bucket privado. Não criar bucket público.
6. Smoke test no workspace piloto e só então `npm run flag -- <workspace_id> on`.
## Variáveis
| Nome | Uso |
| --- | --- |
| `NEXT_PUBLIC_SUPABASE_URL` | Auth no browser/servidor |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Chave pública do Auth |
| `SUPABASE_SERVICE_ROLE_KEY` | Storage no servidor. Nunca `NEXT_PUBLIC_` |
| `SUPABASE_STORAGE_BUCKET` | Bucket privado |
| `DATABASE_URL` | PostgreSQL. Obrigatório em produção |
| `REDIS_URL` | Lock. Sem ela o processamento recusa iniciar |
| `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` | Observabilidade |
| `SENTRY_ENVIRONMENT` / `SENTRY_RELEASE` | Ambiente e release |
| `ELO_PRO_LOGIN_URL` | Login central |
| `SPREADSHEET_TEMP_TTL_SECONDS` | Retenção. Obrigatório em produção; valor de produto ainda aberto |
| `ADAPTER_JOB_MODE` | `worker` em produção, `inline` em desenvolvimento |
| `ADAPTER_DEV_AUTH` | Só fora de produção |
| `ADAPTER_DEV_AUTH_SECRET` | Assina o cookie local. Não é senha de usuário |
O cookie de workspace lido pela aplicação chama-se `elo_pro_workspace_id`.
## Rollback
- Aplicação: voltar o deploy anterior.
- Flag: `npm run flag -- <workspace_id> off`.
- Schema: `migrations/down/0002_spreadsheet_adapter_down.sql` remove só tabelas do módulo.
- Jobs: parar o worker. Jobs `queued` não avançam. Jobs `processing` sem lock viram `INTERRUPTED` na próxima varredura.
## Smoke
Login Elo Pro sem segunda senha; workspace piloto com flag ligada; upload de modelo; fonte; aba ou cabeçalho se preciso; mapeamento; prévia; download XLSX, CSV e PDF; workspace sem flag recebe bloqueio; download sem sessão recebe 401.
## Runbook
- Job travado em `processing`: confirmar se o lock Redis ainda existe. Se expirou, o worker marca `INTERRUPTED`. O usuário pode tentar de novo.
- Redis indisponível: APIs de leitura seguem; processamento responde `REDIS_UNAVAILABLE` e não grava saída.
- Storage indisponível: upload falha com `STORAGE_FAILURE` ou 503 e não deixa modelo sem arquivo.
- Migration parcial: `schema_migrations` só registra arquivo aplicado por completo. Reexecutar `npm run migrate` continua do próximo arquivo. Não editar migration já aplicada.
- Limpeza: `npm run cleanup` apaga blobs em chaves `/tmp/` expiradas e não mexe em `/models/`.
@@ -0,0 +1,48 @@
# Revisão final contra o escopo V3
| Requisito | Status | Evidência |
| --- | --- | --- |
| Sem login, senha ou token próprio | OK | `src/server/auth`, ausência de tabela de senha |
| Sessão Supabase e cookie de workspace | OK | `context.ts`, middleware |
| Flag de módulo, padrão desligado | OK | `workspace_module_flags`, página de módulo desligado |
| RLS e papéis | OK | `0003_rls.sql`, testes de RLS e jornada |
| 10 modelos, nome único, soft delete provisório | OK | triggers, unique index, `status` |
| XLSX/CSV, 10 MB, 50k, 10 fontes | OK | domínio + testes; 50k no caminho CSV |
| Uma aba e linha de cabeçalho | OK | jornada e `updateSource` |
| Mapeamento 1:1, obrigatórias, select e drag | OK | domínio, UI e testes |
| Prévia antes de processar | OK | `previewSource` e passo da jornada |
| Lock, idempotência, Redis obrigatório em produção | OK | coordinator e teste de indisponibilidade |
| Modelo imutável | OK | SHA-256 e comparação de bytes na jornada |
| Temporários e limpeza | OK | `cleanupExpired`; TTL de produto aberto |
| Exclusão só owner/admin e bloqueio com job ativo | OK | jornada |
| Auditoria sem células | OK | scrub e teste do token `=CMD()` |
| Sentry sem planilha | OK | `beforeSend` e teste de scrub |
| Saída XLSX, CSV e PDF | OK | jornada gera os três |
| UI com tokens V3, Manrope, Lucide | PARCIAL | Componentes locais no token do escopo; biblioteca oficial do Elo Pro não estava no repo |
| Acessibilidade básica | PARCIAL | Select, switch, dialog e foco visível cobertos; não houve auditoria WCAG formal |
| E2E no browser do Elo Pro | PARCIAL | Jornada de API completa; browser do ecossistema inexistente aqui |
| Performance 10 MB / 50k XLSX / 10 fontes paralelas | PARCIAL | 50k CSV e rejeição de arquivo acima de 10 MB; ExcelJS de 50k linhas não medido |
| Job runner de produção | DECISÃO ABERTA | Worker no mesmo código; fila externa não escolhida |
| Storage de produção | DECISÃO ABERTA | Interface pronta; local só fora de produção |
| TTL, biblioteca XLSX, soft delete, SSO entre hosts | DECISÃO ABERTA | ADRs |
## Bloqueios de release para o piloto no ecossistema
1. Apontar `DATABASE_URL` do Postgres Elo Pro e não aplicar o shim de identidade se as tabelas já existirem com outro formato.
2. Aprovar bucket privado e preencher as envs de storage.
3. Definir TTL numérico em `SPREADSHEET_TEMP_TTL_SECONDS`.
4. Rodar `ADAPTER_JOB_MODE=worker` com Redis, não o modo inline, no ambiente de piloto.
5. Confirmar a biblioteca ExcelJS (MIT, provisória) ou trocar o codec em `src/infra/spreadsheet/parse.ts`.
6. Ligar a flag só no workspace piloto.
## Riscos residuais
PDF truncado de propósito. Formatação do modelo não é reaproveitada. Conexão de serviço ignora RLS. Lock em memória não atravessa várias instâncias.
## Checklist de release
- [x] Suíte Vitest, typecheck, lint e build de produção locais
- [ ] Smoke no staging com Supabase, Redis e bucket reais
- [ ] Smoke no staging com Supabase, Redis e bucket reais
- [ ] Flag desligada para quem não é piloto
- [ ] Sentry com DSN, sem anexo de arquivo