From 419325db126d7afad3ee3d018a67d600bc51a23f Mon Sep 17 00:00:00 2001 From: camilafagundes Date: Fri, 2 Oct 2026 23:59:36 -0300 Subject: [PATCH] Inicio do Projeto Piloto --- .env.example | 18 +- docs/decisions/0004-temp-ttl.md | 4 +- docs/decisions/0006-action-blue.md | 14 +- .../08-final-readiness-report.md | 2 + .../09-pilot-readiness-audit.md | 39 ++++ .../10-elo-pro-auth-integration.md | 31 +++ docs/spreadsheet-adapter/11-auth-topology.md | 26 +++ docs/spreadsheet-adapter/12-job-runner.md | 19 ++ .../13-xlsx-library-review.md | 35 +++ .../14-multi-tenant-security.md | 17 ++ .../15-production-environment.md | 23 ++ .../16-pilot-deployment-runbook.md | 32 +++ .../17-performance-baseline.md | 21 ++ .../18-pilot-final-gate.md | 86 ++++++++ migrations/0004_audit_append_only.sql | 1 + scripts/set-flag.ts | 16 +- scripts/worker.ts | 19 +- src/app/adaptador/configuracoes/page.tsx | 2 +- src/app/adaptador/operacao/page.tsx | 2 +- src/app/adaptador/page.tsx | 2 +- src/app/api/health/live/route.ts | 8 + src/app/api/health/ready/route.ts | 24 +++ src/app/api/spreadsheet-adapter/_lib.ts | 14 +- src/app/login/page.tsx | 13 +- src/components/operation.tsx | 28 ++- src/components/states.tsx | 14 +- src/config/env.ts | 59 +++++ src/domain/spreadsheet-adapter/csv.ts | 10 +- .../spreadsheet-adapter/error-catalog.ts | 46 ++++ .../spreadsheet-adapter/failure-policy.ts | 28 +++ .../spreadsheet-adapter/file-signature.ts | 9 +- src/domain/spreadsheet-adapter/limits.ts | 4 + src/domain/spreadsheet-adapter/permissions.ts | 10 + .../spreadsheet-adapter/retention-policy.ts | 14 ++ src/domain/spreadsheet-adapter/types.ts | 7 + src/infra/observability/capture.ts | 15 +- src/infra/observability/scrub.ts | 30 ++- src/infra/redis/memory.ts | 17 ++ src/infra/redis/redis.ts | 14 ++ src/infra/spreadsheet/parse.ts | 17 +- src/instrumentation.ts | 2 + src/server/auth/context.ts | 31 +-- src/server/auth/identity-port.ts | 16 ++ src/server/auth/return-to.ts | 38 ++++ src/server/spreadsheet-adapter/process-job.ts | 102 ++++++--- src/server/spreadsheet-adapter/service.ts | 56 +++-- tests/integration/rls.test.ts | 24 ++- tests/performance/baseline.test.ts | 34 +++ tests/security/isolation.test.ts | 201 ++++++++++++++++++ tests/smoke/real-environment.test.ts | 15 ++ tests/unit/pilot-policy.test.ts | 82 +++++++ tests/unit/xlsx-fixtures.test.ts | 48 +++++ 52 files changed, 1329 insertions(+), 110 deletions(-) create mode 100644 docs/spreadsheet-adapter/09-pilot-readiness-audit.md create mode 100644 docs/spreadsheet-adapter/10-elo-pro-auth-integration.md create mode 100644 docs/spreadsheet-adapter/11-auth-topology.md create mode 100644 docs/spreadsheet-adapter/12-job-runner.md create mode 100644 docs/spreadsheet-adapter/13-xlsx-library-review.md create mode 100644 docs/spreadsheet-adapter/14-multi-tenant-security.md create mode 100644 docs/spreadsheet-adapter/15-production-environment.md create mode 100644 docs/spreadsheet-adapter/16-pilot-deployment-runbook.md create mode 100644 docs/spreadsheet-adapter/17-performance-baseline.md create mode 100644 docs/spreadsheet-adapter/18-pilot-final-gate.md create mode 100644 migrations/0004_audit_append_only.sql create mode 100644 src/app/api/health/live/route.ts create mode 100644 src/app/api/health/ready/route.ts create mode 100644 src/config/env.ts create mode 100644 src/domain/spreadsheet-adapter/error-catalog.ts create mode 100644 src/domain/spreadsheet-adapter/failure-policy.ts create mode 100644 src/domain/spreadsheet-adapter/retention-policy.ts create mode 100644 src/server/auth/identity-port.ts create mode 100644 src/server/auth/return-to.ts create mode 100644 tests/performance/baseline.test.ts create mode 100644 tests/security/isolation.test.ts create mode 100644 tests/smoke/real-environment.test.ts create mode 100644 tests/unit/pilot-policy.test.ts create mode 100644 tests/unit/xlsx-fixtures.test.ts diff --git a/.env.example b/.env.example index 256d920..e926e61 100644 --- a/.env.example +++ b/.env.example @@ -19,18 +19,26 @@ SENTRY_RELEASE= # Login central do ecossistema. O Adaptador não tem formulário de senha. ELO_PRO_LOGIN_URL= +# URL pública deste aplicativo. Usada para montar returnTo no mesmo host. +ELO_PRO_APP_URL= +# Hosts adicionais aceitos no returnTo, separados por vírgula. Caminho relativo sempre é preferido. +AUTH_ALLOWED_RETURN_HOSTS= # Cookie de workspace ativo definido pela plataforma Elo Pro. # Nome lido pelo servidor: elo_pro_workspace_id -# TTL de fontes e saídas, em segundos. Decisão de produto ainda aberta. -# Obrigatório em produção. Em dev/teste, a ausência usa apenas um valor provisório de código. +# TTL de fontes e saídas, em segundos. Obrigatório em produção. Inteiro de 1 a 604800. +# Em dev/teste, a ausência usa 3600. O número de produto continua configurável. SPREADSHEET_TEMP_TTL_SECONDS= -# inline = processa no mesmo processo após a resposta (dev). worker = só enfileira. -ADAPTER_JOB_MODE=inline +# Tempo máximo de um processamento, em milissegundos. Ausente usa 120000. +SPREADSHEET_JOB_TIMEOUT_MS= + +# inline = processa no mesmo processo (somente dev/teste). worker = enfileira no Redis e no Postgres. +# Em produção, inline é recusado. +ADAPTER_JOB_MODE=worker # Sessão local SEM senha, só fora de produção, para piloto sem o host Elo Pro. -# Não cria usuário, hash ou refresh token proprietário. +# Produção recusa ADAPTER_DEV_AUTH=1. ADAPTER_DEV_AUTH=0 ADAPTER_DEV_AUTH_SECRET= diff --git a/docs/decisions/0004-temp-ttl.md b/docs/decisions/0004-temp-ttl.md index 8af54df..68fa71f 100644 --- a/docs/decisions/0004-temp-ttl.md +++ b/docs/decisions/0004-temp-ttl.md @@ -1,5 +1,5 @@ # ADR 0004 — TTL de temporários -Status: aberta. +Status: política operacional definida; o número de produto continua configurável. -`SPREADSHEET_TEMP_TTL_SECONDS` é obrigatório em produção e aceita de 1 segundo a 7 dias para não gravar um número de produto no código. Fora de produção, a omissão usa 3600 segundos só para o ambiente local. A limpeza não apaga chaves que contenham `/models/`. +`SPREADSHEET_TEMP_TTL_SECONDS` é a política única. Em produção é obrigatório e o processo recusa subir sem um inteiro entre 1 e 604800. Fora de produção, a omissão usa 3600 segundos. A limpeza compara `expires_at` com o relógio injetado, é idempotente, só apaga chaves `workspaces/{workspaceId}/tmp/` e não interrompe o processamento se um delete falhar. O valor numérico aprovado pelo negócio ainda não foi fixado; o piloto define o número na configuração, não no código. diff --git a/docs/decisions/0006-action-blue.md b/docs/decisions/0006-action-blue.md index d8d9d58..f683752 100644 --- a/docs/decisions/0006-action-blue.md +++ b/docs/decisions/0006-action-blue.md @@ -2,8 +2,18 @@ ## Azul de ação -O escopo fixa Bright Blue `#2F8FFF` e pede o azul primário do token oficial, que não estava no repositório. Uso provisório: `#0B3D91` em `--color-action`, com texto branco. Substituir quando o pacote do Design System Elo Pro for ligado. +O escopo fixa Bright Blue `#2F8FFF` e pede o azul primário do token oficial, que não estava no repositório. Uso provisório: `#0B3D91` em `--color-action`, com texto branco. Esse valor não é token oficial e não deve ser promovido por conveniência. Substituir somente quando o pacote do Design System Elo Pro for disponibilizado. ## Exclusão -Status: aberta. O schema marca `deleted` e preenche `deleted_at`, mantém o arquivo e o histórico. A exclusão física pode ser feita depois sem migração destrutiva. Enquanto houver job em `validating`, `ready`, `queued` ou `processing`, a exclusão é recusada. +Status: provisória e centralizada em `src/domain/spreadsheet-adapter/retention-policy.ts`. + +| Entidade | Tratamento | +| --- | --- | +| Configuração de mapeamento | Permanece com o job | +| Modelo | Soft delete (`deleted` + `deleted_at`); arquivo retido | +| Fonte e resultado | Hard delete do blob após o TTL | +| Job | Metadados permanecem | +| Auditoria | Append-only; `app_user` não recebe update nem delete | + +A exclusão física do modelo pode ser feita depois sem migração destrutiva. Enquanto houver job em `validating`, `ready`, `queued` ou `processing`, a exclusão do modelo é recusada. O prazo dos temporários continua em `SPREADSHEET_TEMP_TTL_SECONDS`. diff --git a/docs/spreadsheet-adapter/08-final-readiness-report.md b/docs/spreadsheet-adapter/08-final-readiness-report.md index 219a17f..0faeb8b 100644 --- a/docs/spreadsheet-adapter/08-final-readiness-report.md +++ b/docs/spreadsheet-adapter/08-final-readiness-report.md @@ -1,5 +1,7 @@ # Revisão final contra o escopo V3 +Status consolidado: **Pilot Ready in Code / Environment Validation Pending**. O gate, a ordem da validação no ambiente Elo Pro e a regra de manter a feature flag desligada estão em `18-pilot-final-gate.md`. + | Requisito | Status | Evidência | | --- | --- | --- | | Sem login, senha ou token próprio | OK | `src/server/auth`, ausência de tabela de senha | diff --git a/docs/spreadsheet-adapter/09-pilot-readiness-audit.md b/docs/spreadsheet-adapter/09-pilot-readiness-audit.md new file mode 100644 index 0000000..67c1fd8 --- /dev/null +++ b/docs/spreadsheet-adapter/09-pilot-readiness-audit.md @@ -0,0 +1,39 @@ +# Auditoria de prontidão do piloto + +Estado encontrado antes do hardening desta entrega. O resultado depois das mudanças está em `18-pilot-final-gate.md`. + +Nenhum repositório real da Elo Pro estava disponível. Os nomes `profiles`, `workspaces` e `workspace_members` são o contrato local, não um schema confirmado. + +| Item | Classificação | Estado | Evidência | Risco | Dependência externa | Decisão | Módulo | Ação | Severidade | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 1. Autenticação Supabase | NEEDS INTEGRATION | `getUser` quando URL e anon key existem. Sem tabela de senha. | `src/server/auth/context.ts` | Piloto sem o projeto Supabase da Elo Pro não autentica de verdade | Projeto Supabase Elo Pro | Não criar identidade paralela | `src/server/auth` | Ligar as envs e validar login real | Alta | +| 2. Workspace | READY WITH CONFIGURATION | Cookie `elo_pro_workspace_id` validado por membership no servidor | `context.ts` | Cookie de outro ambiente aponta workspace errado | Cookie emitido pela Elo Pro | Confirmar o nome do cookie no host real | `context.ts` | Testar troca de workspace no host | Alta | +| 3. RLS | READY WITH CONFIGURATION | Policies e `FORCE RLS` no shim. Testes usam PGlite com `app_user` | `migrations/0003_rls.sql`, `tests/integration/rls.test.ts` | Postgres real pode divergir do PGlite | Postgres Elo Pro | Não aplicar o shim se as tabelas já existirem com outro formato | `migrations/` | Rodar o teste de isolamento no banco do piloto | Alta | +| 4. Papéis | READY | owner, admin, gestor, agent. Superadmin local não ignora o módulo | `permissions.ts` | Papéis reais da Elo Pro podem ter outros nomes | Membership real | Mapear papéis quando o schema for confirmado | `permissions.ts` | Manter o adaptador | Média | +| 5. Feature flag | READY WITH CONFIGURATION | `spreadsheet_adapter` nasce desligada | `workspace_module_flags`, `scripts/set-flag.ts` | Ligar para todos os workspaces | Operação | Só o workspace piloto | flag | Ativar no passo final do runbook | Alta | +| 6. Storage | NEEDS INTEGRATION | Interface privada. Produção exige bucket. Download passa pela rota autenticada | `src/infra/storage` | Bucket público ou URL permanente | Bucket Supabase | Manter privado | `storage/supabase.ts` | Criar bucket privado | Alta | +| 7. Jobs | READY WITH CONFIGURATION | Estados duráveis no Postgres. Inline só fora de produção | `service.ts`, `process-job.ts` | Piloto subir em inline | Worker | `ADAPTER_JOB_MODE=worker` | `scripts/worker.ts` | Subir o worker | Alta | +| 8. Redis | NEEDS INTEGRATION | Lock e fila de IDs. Sem Redis a produção recusa o processamento | `src/infra/redis` | Várias instâncias sem Redis processam em duplicidade | Redis | Obrigatório no piloto | `redis.ts` | Preencher `REDIS_URL` | Alta | +| 9. Locks | READY | Lock por usuário, TTL 120s, liberação no `finally` | `lockKey` | Lock em memória não atravessa processos | Redis no piloto | Não usar lock global | `redis.ts` | Usar Redis | Média | +| 10. TTL | READY WITH CONFIGURATION | Obrigatório em produção. Dev usa 3600 | `src/config/retention.ts`, ADR 0004 | Temporários eternos se a env faltar | Número operacional | Não gravar o número no código | `retention.ts` | Definir a env no piloto | Alta | +| 11. Limpeza | READY | `cleanupExpired` ignora `/models/` e é idempotente | `process-job.ts` | Falha de storage deixa blob órfão | Storage | Falha de limpeza não pode derrubar o job | worker | Observar `CLEANUP_FAILED` | Média | +| 12. Auditoria | READY | Eventos sem células. Append-only para `app_user` | `scrub.ts`, `0004_audit_append_only.sql` | Service role ainda escreve | Nenhuma | Não apagar auditoria no rollback | `audit_log` | Manter | Média | +| 13. Sentry | READY WITH CONFIGURATION | Sem DSN fica inerte. Payload descarta texto livre | `scrub.ts`, `capture.ts` | DSN ausente deixa o piloto cego | Projeto Sentry | Não anexar arquivo | `instrumentation.ts` | Configurar DSN | Média | +| 14. XLSX | NEEDS DECISION | ExcelJS 4.4.0 gera workbook novo. O modelo original não é reescrito | `parse.ts`, ADR 0005 | Formatação do modelo não é preservada | Nenhuma para o MVP | Manter ExcelJS no piloto | `13-xlsx-library-review.md` | Aceitar a recomendação | Média | +| 15. CSV | READY | UTF-8, delimitador, neutralização na saída | `csv.ts`, `csv-injection.ts` | Encoding legado é recusado | Nenhuma | UTF-8 apenas | `parse.ts` | Manter | Baixa | +| 16. PDF | READY | Gerado sob demanda, truncado em 200 linhas e 12 colunas | `pdf.tsx` | Usuário espera o arquivo inteiro | Nenhuma | Truncamento já documentado | `pdf.tsx` | Informar o limite | Baixa | +| 17. Exclusão | NEEDS DECISION | Soft delete de modelo. Blob temporário sai com o TTL | ADR 0006 | Reter planilha além do necessário | Negócio | Política provisória configurável | `retention-policy.ts` | Não fazer hard delete de auditoria | Média | +| 18. Migrations | READY WITH CONFIGURATION | 0001 é shim. 0002–0004 são do Adaptador | `migrations/` | Aplicar 0001 em cima do schema real da Elo Pro | Schema real | 0001 só se as tabelas não existirem | `identity-port.ts` | Conferir o banco antes do migrate | Alta | +| 19. Variáveis | NEEDS INTEGRATION | `.env.example` lista o conjunto. A validação recusa produção incompleta | `src/config/env.ts` | Build de CI não tem os segredos e não deve falhar | Secrets do piloto | Falhar no start, não no build | `15-production-environment.md` | Preencher no ambiente | Alta | +| 20. Dev versus produção | READY WITH CONFIGURATION | `ADAPTER_DEV_AUTH` exige não-produção | `dev-session.ts` | Segredo de dev vazado para produção | Nenhuma | Produção recusa a flag | `env.ts` | Não definir o segredo no piloto | Alta | + +## Pilot Go-Live Blockers + +Estes itens impedem ativar o primeiro workspace piloto. O restante é preparação já coberta no código ou decisão que não bloqueia se a recomendação for aceita. + +1. O banco e o Auth reais da Elo Pro não estão ligados. O shim de identidade não pode ser aplicado se as tabelas já existirem com outro formato. +2. O bucket privado de storage não está criado nem configurado. +3. Redis e o worker não estão em execução no ambiente do piloto. Inline fica recusado em produção. +4. `SPREADSHEET_TEMP_TTL_SECONDS` ainda não tem valor no ambiente de piloto. +5. O smoke com Supabase, Redis e bucket reais não foi executado. +6. A feature flag do workspace piloto continua desligada, de propósito, até o fim do runbook. diff --git a/docs/spreadsheet-adapter/10-elo-pro-auth-integration.md b/docs/spreadsheet-adapter/10-elo-pro-auth-integration.md new file mode 100644 index 0000000..f6b28c5 --- /dev/null +++ b/docs/spreadsheet-adapter/10-elo-pro-auth-integration.md @@ -0,0 +1,31 @@ +# Integração de identidade Elo Pro + +O Adaptador não cria usuário, senha, cadastro ou papel próprio. A sessão de produção é a do Supabase Auth. O workspace ativo chega no cookie `elo_pro_workspace_id` e é conferido no servidor contra a membership. + +## Contexto autenticado + +`resolveAuth` devolve `AuthenticatedContext`: + +- `userId` +- `workspaceId` +- `role` +- `permissions` (`adapter.session`, `adapter.use`, `adapter.manage`, `adapter.upload`) +- `moduleEnabled` + +APIs passam por `requireReady`. Serviços chamam `assertUse`, que relê a flag no banco. Um `workspaceId` no corpo não substitui o cookie validado. `rejectWorkspaceForgery` registra a tentativa. + +## Contrato ainda não confirmado + +`src/server/auth/identity-port.ts` lista as tabelas que o código lê hoje. O status é `unconfirmed_against_real_elo_pro`, exceto a flag do módulo, que é específica do Adaptador. Quando o repositório da Elo Pro estiver acessível, a adaptação acontece nesse ponto. Não há migration definitiva da Elo Pro neste repositório. + +## Desenvolvimento + +`ADAPTER_DEV_AUTH=1` só funciona com `NODE_ENV` diferente de `production` e com `ADAPTER_DEV_AUTH_SECRET`. Em produção a validação de ambiente recusa a flag, e `devAuthEnabled()` permanece falso. + +## Testes + +- Workspace de outro usuário não lista nem baixa o arquivo. +- `workspaceId` forjado é recusado. +- Usuário sem membership não opera. +- Módulo desligado bloqueia mesmo que o contexto do cliente diga o contrário. +- Produção ignora a sessão de desenvolvimento. diff --git a/docs/spreadsheet-adapter/11-auth-topology.md b/docs/spreadsheet-adapter/11-auth-topology.md new file mode 100644 index 0000000..4c2ad0f --- /dev/null +++ b/docs/spreadsheet-adapter/11-auth-topology.md @@ -0,0 +1,26 @@ +# Topologia de sessão + +O Adaptador não implementa IdP nem OAuth próprio e não copia cookie entre domínios. + +## Cenários + +| Cenário | Comportamento | +| --- | --- | +| Mesmo domínio, subpaths | Cookie Supabase e `elo_pro_workspace_id` já visíveis. `/login` só redireciona se a sessão não existir | +| Subdomínios | Exige cookie no domínio pai, configurado na Elo Pro. O Adaptador não redefine o domínio do cookie | +| Domínios separados | O login central emite a sessão Supabase. O retorno usa `returnTo` apenas para host permitido | +| Redirect central | `ELO_PRO_LOGIN_URL` recebe `returnTo` | +| Sessão Supabase compartilhada | `getUser` lê os cookies `sb-*` já presentes | + +## returnTo + +`safeReturnPath` aceita somente caminho relativo interno. `https://`, `//` e host fora de `ELO_PRO_APP_URL` / `AUTH_ALLOWED_RETURN_HOSTS` voltam para `/adaptador`. + +`/login` em produção redireciona para o login central. A tela local com identidades de desenvolvimento só aparece quando `ADAPTER_DEV_AUTH` está ligado fora de produção. + +## Sessão e módulo + +- Sem cookie de sessão: anônimo, 401 nas APIs. +- Cookie presente e usuário ausente: `session_expired`, mensagem para entrar de novo. +- Autenticado sem membership ou sem workspace: bloqueio, sem operar planilha. +- Módulo desligado: rotas voltam ao painel e APIs respondem `FEATURE_DISABLED`. diff --git a/docs/spreadsheet-adapter/12-job-runner.md b/docs/spreadsheet-adapter/12-job-runner.md new file mode 100644 index 0000000..218eb9c --- /dev/null +++ b/docs/spreadsheet-adapter/12-job-runner.md @@ -0,0 +1,19 @@ +# Job runner + +Postgres guarda o estado. Redis guarda o lock por usuário e uma fila de IDs. O payload da fila é somente o id do job. + +Estados já existentes e suficientes: `draft`, `validating`, `ready`, `queued`, `processing`, `completed`, `failed`, `expired`, `cancelled`. + +## Modo + +`ADAPTER_JOB_MODE=worker` enfileira. `inline` só é aceito fora de produção. Produção com `inline` falha na validação de ambiente e também em `startProcess`. + +Falha transitória (`5xx`, exceto timeout e erros de arquivo) volta para `queued` até 3 tentativas. Timeout, validação e modelo alterado não entram em loop. O lock é liberado no `finally`. Job sem lock em `processing` é recuperado por `recoverStaleJobs`. + +## Subir o piloto + +1. Redis acessível em `REDIS_URL`. +2. `npm run start` para a aplicação web, com as envs de produção. +3. `npm run worker` em outro processo, com as mesmas envs de banco, storage e Redis. + +A limpeza de temporários roda no tick do worker. Se ela falhar, o tick seguinte continua e o erro segue para o capturador com o código `CLEANUP_FAILED`, sem conteúdo de planilha. diff --git a/docs/spreadsheet-adapter/13-xlsx-library-review.md b/docs/spreadsheet-adapter/13-xlsx-library-review.md new file mode 100644 index 0000000..2ca792b --- /dev/null +++ b/docs/spreadsheet-adapter/13-xlsx-library-review.md @@ -0,0 +1,35 @@ +# Revisão da biblioteca XLSX + +## Atual + +| Campo | Valor | +| --- | --- | +| Biblioteca | ExcelJS | +| Versão declarada | 4.4.0 | +| Licença | MIT | +| Leitura | `workbook.xlsx.load`, valor exibido em `cell.text` | +| Escrita | Novo workbook, aba `Adaptado`, texto com formato `@` | + +## Vantagens + +MIT, já integrada, lê e escreve XLSX, não executa macro e não avalia fórmula. O arquivo modelo permanece o blob original; o processamento grava outra chave. + +## Limitações + +Não preserva estilo, mesclagem, largura de coluna nem as outras abas do modelo no arquivo de saída. Datas e números saem como texto exibido. PDF continua truncado à parte. Arquivo protegido por senha cai em erro de estrutura. + +## Riscos + +Trocar a biblioteca no piloto reabre a suíte de mapeamento sem ganho para o contrato atual: a saída é um artefato novo e o modelo é imutável. + +## Alternativas + +SheetJS Community tem restrição de licença que exigiria revisão jurídica. Manter ExcelJS atrás de `readSpreadsheet` e `buildXlsx` permite trocar depois sem mudar o domínio. + +## Custo de migração + +Alto para o piloto: fixtures, tipos, datas e o contrato de “não executar fórmula” teriam de ser revalidados. Não há blocker que obrigue a troca. + +## Recomendação + +Manter ExcelJS 4.4.0 no piloto. Não criar uma segunda engine. Fixtures em `tests/unit/xlsx-fixtures.test.ts` cobrem várias abas, fórmula como texto, data, caractere especial e a imutabilidade do modelo. diff --git a/docs/spreadsheet-adapter/14-multi-tenant-security.md b/docs/spreadsheet-adapter/14-multi-tenant-security.md new file mode 100644 index 0000000..fb19aa5 --- /dev/null +++ b/docs/spreadsheet-adapter/14-multi-tenant-security.md @@ -0,0 +1,17 @@ +# Isolamento multi-tenant + +Invariantes: + +1. O workspace da operação é o do contexto resolvido no servidor. +2. RLS com `app_user` esconde linha de outro workspace e de outro usuário no mesmo workspace. +3. A flag do módulo é relida no banco em listagem, processamento e download. +4. Download só lê chave `workspaces/{workspaceId}/tmp/`. +5. Limpeza só apaga chave com o prefixo do workspace dono da linha. +6. Auditoria não é atualizada pelo papel da aplicação. +7. Texto de célula não entra em auditoria nem no payload enviado ao Sentry. + +A suíte `tests/security/isolation.test.ts` e `tests/integration/rls.test.ts` cobre workspace A/B, admin com módulo desligado, agente sem o job de outro usuário, flag desligada depois do processamento, `workspaceId` de quem não é membro, lock concorrente e arquivo hostil. + +URL assinada permanente não é emitida. O download é a rota autenticada. Reutilizar um id de outro workspace devolve `NOT_FOUND` ou `FEATURE_DISABLED`. + +O que ainda depende do Postgres e do Storage reais está marcado no gate final. diff --git a/docs/spreadsheet-adapter/15-production-environment.md b/docs/spreadsheet-adapter/15-production-environment.md new file mode 100644 index 0000000..365562a --- /dev/null +++ b/docs/spreadsheet-adapter/15-production-environment.md @@ -0,0 +1,23 @@ +# Ambiente de produção + +A validação está em `src/config/env.ts`. Ela roda no start da aplicação e do worker. Durante `next build` ela não interrompe o CI, porque o build não tem os segredos do piloto. + +| Variável | Obrigatória | Ambiente | Finalidade | Exemplo sem segredo | Risco se ausente | +| --- | --- | --- | --- | --- | --- | +| NEXT_PUBLIC_SUPABASE_URL | Sim | Produção | Auth Elo Pro | `https://.supabase.co` | Ninguém autentica | +| NEXT_PUBLIC_SUPABASE_ANON_KEY | Sim | Produção | Chave pública do Auth | `anon` | Sessão não resolve | +| SUPABASE_SERVICE_ROLE_KEY | Sim | Servidor | Storage e tarefas internas | `service-role` | Upload e worker param | +| SUPABASE_STORAGE_BUCKET | Sim | Produção | Bucket privado | `spreadsheet-adapter` | Produção recusa storage. Valor `public` é recusado | +| DATABASE_URL | Sim | Produção | Postgres | `postgres://host/db` | Aplicação não sobe | +| REDIS_URL | Sim | Produção | Lock e fila de ids | `redis://host:6379` | Processamento recusado | +| SENTRY_DSN | Sim | Produção | Erros sanitizados | `https://@sentry/` | Piloto sem alerta | +| SPREADSHEET_TEMP_TTL_SECONDS | Sim | Produção | Vida dos temporários | `86400` | Start recusado | +| ADAPTER_JOB_MODE | Sim, implícito | Produção | `worker` | `worker` | `inline` recusado | +| ELO_PRO_LOGIN_URL | Sim | Produção | Login central | `https://app.elo.example/login` | `/login` não encaminha | +| ELO_PRO_APP_URL | Recomendada | Produção | `returnTo` no mesmo host | `https://app.elo.example` | Retorno cai no caminho padrão | +| AUTH_ALLOWED_RETURN_HOSTS | Não | Produção | Hosts extras do retorno | `app.elo.example` | Host externo continua bloqueado | +| ADAPTER_DEV_AUTH | Deve ficar desligada | Produção | Sessão local | `0` | Valor `1` recusa o start | +| ADAPTER_DEV_AUTH_SECRET | Não | Só dev | Assina o cookie local | vazio em produção | Não é usado no piloto | +| SPREADSHEET_JOB_TIMEOUT_MS | Não | Todos | Teto do processamento | `120000` | Usa 120000 | + +`ADAPTER_DEV_AUTH` não autentica em produção. Storage público não é aceito. TTL inválido falha cedo. Sem Redis, o modo worker não processa. diff --git a/docs/spreadsheet-adapter/16-pilot-deployment-runbook.md b/docs/spreadsheet-adapter/16-pilot-deployment-runbook.md new file mode 100644 index 0000000..fbe73e9 --- /dev/null +++ b/docs/spreadsheet-adapter/16-pilot-deployment-runbook.md @@ -0,0 +1,32 @@ +# Runbook de implantação do piloto + +Execute nesta ordem. Não apague auditoria, banco ou arquivos como parte do rollback. + +1. Preparar o projeto Supabase da Elo Pro e anotar URL, anon key e service role. +2. Validar que o login Elo Pro emite sessão Supabase e o cookie `elo_pro_workspace_id`. +3. Conferir se `profiles`, `workspaces` e `workspace_members` já existem. Se existirem com outro formato, não aplique `0001_platform_shim.sql`. Aplique só `0002`, `0003` e `0004` depois de adaptar `identity-port.ts`. +4. Validar RLS no papel `app_user`: um usuário não lê modelo de outro workspace. +5. Criar o bucket privado. Não gerar URL pública. +6. Subir Redis e preencher `REDIS_URL`. +7. Definir `ADAPTER_JOB_MODE=worker`. +8. Preencher os secrets da tabela em `15-production-environment.md`. Não definir `ADAPTER_DEV_AUTH` nem o segredo de desenvolvimento. +9. Definir `SPREADSHEET_TEMP_TTL_SECONDS` com o prazo operacional do piloto. +10. Preencher `SENTRY_DSN`. Confirmar que o evento de teste não contém célula. +11. Rodar `npm run migrate` no banco já conferido. +12. Iniciar a web com `npm run start`. +13. Iniciar `npm run worker` com as mesmas variáveis. +14. `GET /api/health/live` deve responder `live`. `GET /api/health/ready` deve responder `ready` sem connection string. +15. Executar o smoke do caminho completo no ambiente real (`SMOKE_REAL` ainda não está automatizado contra o host; percorrer a jornada na interface). +16. Ativar a flag só no workspace piloto: `npm run flag -- on`. +17. Verificar logs do worker: nome do erro e código, sem planilha. +18. Processar um arquivo pequeno de ponta a ponta e baixar XLSX, CSV e PDF. + +## Rollback + +1. `npm run flag -- off`. +2. Parar o processo do worker. +3. Manter `audit_log`, modelos e o banco. +4. Não rodar script que apague storage ou tabelas. +5. Se o deploy em si estiver incorreto, promover de volta o artefato anterior depois da flag desligada. + +A última ativação do módulo fica no checklist de `18-pilot-final-gate.md`. diff --git a/docs/spreadsheet-adapter/17-performance-baseline.md b/docs/spreadsheet-adapter/17-performance-baseline.md new file mode 100644 index 0000000..687f826 --- /dev/null +++ b/docs/spreadsheet-adapter/17-performance-baseline.md @@ -0,0 +1,21 @@ +# Baseline de performance + +Medição local em `tests/performance/baseline.test.ts`, CSV de 200 e de 5.000 linhas, duas colunas, nesta máquina de desenvolvimento. A amostra de 5.000 linhas, somando leitura, CSV e XLSX, ficou abaixo de 1 segundo. Não houve otimização em cima desse número. + +Não foi medida a planilha XLSX de 50.000 linhas no ExcelJS. O limite de 50.000 linhas úteis continua o do domínio, exercitado no caminho CSV em `tests/performance/rows.test.ts`. + +## Limites recomendados para o piloto + +| Limite | Valor | +| --- | --- | +| Tamanho do arquivo | 10 MB | +| Linhas úteis | 50.000 | +| Colunas | 200 | +| Abas | 30 | +| Caracteres por célula | 8.000 | +| Fontes por operação | 10 | +| Modelos ativos | 10 | +| Tempo de processamento | 120 segundos, configurável | +| PDF | 200 linhas e 12 colunas, com aviso de truncamento | + +Arquivo vazio, ZIP inválido, macro, CSV fora de UTF-8 e célula acima do teto são recusados antes do processamento. Fórmulas não são executadas. Macros não são abertas. diff --git a/docs/spreadsheet-adapter/18-pilot-final-gate.md b/docs/spreadsheet-adapter/18-pilot-final-gate.md new file mode 100644 index 0000000..d9ce4ad --- /dev/null +++ b/docs/spreadsheet-adapter/18-pilot-final-gate.md @@ -0,0 +1,86 @@ +# Gate final do piloto + +**Status: Pilot Ready in Code / Environment Validation Pending** + +Não há novas alterações funcionais até a validação no ambiente real da Elo Pro. O Adaptador só entra no workspace do primeiro cliente depois que todos os itens externos deste gate passarem nesse ambiente. + +| Frente | Estado | +| --- | --- | +| Código | Aprovado para piloto | +| Infraestrutura Elo Pro | Ainda não validada | +| Segurança multi-tenant | Aprovada na suíte disponível | +| Smoke real | Pendente | +| Rollback real | Pendente | +| Feature flag do cliente piloto | Desligada | + +Pendências que não bloqueiam o código atual e bloqueiam a ativação: integração com o repositório real da Elo Pro e validação visual com os tokens oficiais. `#0B3D91` permanece provisório e não vira token oficial. + +## Próximo pacote — somente validação no ambiente Elo Pro + +1. Integrar e validar o login real no navegador. +2. Validar Postgres, migrations e RLS no banco Elo Pro. +3. Provisionar e testar o bucket privado. +4. Subir Redis e worker e confirmar processamento assíncrono real. +5. Provocar um erro controlado e confirmar sanitização no Sentry. +6. Executar o smoke end-to-end completo. +7. Executar o ensaio de rollback. +8. Integrar os tokens oficiais do Design System quando forem disponibilizados. +9. Somente então ativar a feature flag do workspace piloto. + +Verificado nesta máquina em 2 de outubro de 2026: + +- `npx eslint .` passou +- `npx tsc --noEmit` passou +- `npx vitest run` passou depois da correção do teste de auditoria (34 testes, 1 smoke real ignorado de propósito) +- `npx next build` passou + +O smoke com Supabase, Redis e bucket reais não foi executado. `tests/smoke/real-environment.test.ts` permanece ignorado sem `SMOKE_REAL=1`. + +Os prompts 21 e 22 não foram executados: não há repositório da Elo Pro nem tokens oficiais do Design System neste workspace. O azul `#0B3D91` continua provisório. + +## Avaliação + +| Área | Resultado | Teste que ainda falta | +| --- | --- | --- | +| Elo Pro Auth | PASS WITH EXTERNAL DEPENDENCY | Login real no navegador, sessão reconhecida, logout e sessão expirada no host Elo Pro | +| Workspace isolation | PASS | Suíte de aplicação executada | +| RLS | PASS WITH EXTERNAL DEPENDENCY | Repetir o isolamento no Postgres do piloto, não só no PGlite | +| Storage | PASS WITH EXTERNAL DEPENDENCY | Upload e download no bucket privado real | +| Redis | PASS WITH EXTERNAL DEPENDENCY | Lock e fila contra um Redis real | +| Worker | PASS WITH EXTERNAL DEPENDENCY | Subir `npm run worker` no ambiente do piloto e concluir um job enfileirado | +| TTL | PASS | Configuração inválida, arquivo vencido e limpeza repetida cobertos na suíte | +| XLSX | PASS | ExcelJS mantido. Fixtures e imutabilidade do modelo executadas | +| Observability | PASS WITH EXTERNAL DEPENDENCY | A sanitização foi testada com sentinela. Falta um evento real no projeto Sentry | +| Feature flag | PASS | Workspace desligado, flag alterada depois do job e contexto forjado cobertos. A ativação do workspace piloto é o último passo operacional | +| Audit trail | PASS | Eventos de upload, mapeamento, processo e download. Update pelo `app_user` é recusado | +| Upload security | PASS | Macro, CSV inválido, célula enorme, zip e tamanho cobertos | +| Concurrency | PASS | Duas chamadas do mesmo usuário terminam em um resultado | +| Production env | PASS WITH EXTERNAL DEPENDENCY | O validador foi testado. Falta subir web e worker com as envs reais do piloto | +| Smoke tests | PASS WITH EXTERNAL DEPENDENCY | A jornada local passou. A jornada no ambiente real não foi executada | +| Rollback | PASS WITH EXTERNAL DEPENDENCY | O procedimento está no runbook. Um ensaio de rollback não foi feito | + +Nenhum item ficou FAIL no código desta entrega. O piloto não está liberado para o cliente enquanto os itens com dependência externa não forem executados no ambiente real. + +## Congelamento + +| Ocorrência | Classificação | +| --- | --- | +| `ADAPTER_DEV_AUTH` | Manter apenas em desenvolvimento | +| Jobs inline | Manter apenas em desenvolvimento | +| Shim `profiles` / `workspaces` / `workspace_members` | Manter até o schema real. Remover a dependência só na integração da Elo Pro | +| `console.log` dos scripts de migrate, seed, flag e cleanup | Aceitável no piloto. Não imprimem célula | +| `console.error` com o nome do erro | Aceitável no piloto | +| Azul `#0B3D91` | Pós-piloto, quando houver token oficial | +| Bucket público, senha própria, identidade paralela | Não há. Nada a remover | +| TODO / FIXME no código | Não há | + +## Pilot Activation Checklist + +- [ ] Confirmar que o schema Elo Pro já existente não receberá o shim de identidade +- [ ] Aplicar as migrations do Adaptador no banco conferido +- [ ] Criar o bucket privado +- [ ] Configurar Redis, worker, TTL, Sentry e o login central +- [ ] Subir web e worker e receber `ready` em `/api/health/ready` +- [ ] Executar o smoke real: modelo, fonte, aba, cabeçalho, mapeamento, prévia, processamento, XLSX, CSV e PDF +- [ ] Confirmar auditoria sem conteúdo de célula e rollback ensaiado com a flag desligada +- [ ] Enable Spreadsheet Adapter for pilot workspace diff --git a/migrations/0004_audit_append_only.sql b/migrations/0004_audit_append_only.sql new file mode 100644 index 0000000..e1eb4d3 --- /dev/null +++ b/migrations/0004_audit_append_only.sql @@ -0,0 +1 @@ +revoke update, delete on table public.audit_log from app_user; diff --git a/scripts/set-flag.ts b/scripts/set-flag.ts index 3b1ab8c..cab44d6 100644 --- a/scripts/set-flag.ts +++ b/scripts/set-flag.ts @@ -10,13 +10,21 @@ if (!workspaceId) { } const databaseUrl = process.env.DATABASE_URL; const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite")); -await db.withService((query) => - query.query( +await db.withService(async (query) => { + await query.query( `insert into public.workspace_module_flags (workspace_id, module_key, enabled) values ($1, 'spreadsheet_adapter', $2) on conflict (workspace_id, module_key) do update set enabled = excluded.enabled, updated_at = now()`, [workspaceId, enabled], - ), -); + ); + await query.query( + `insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result, metadata) + select $1, m.user_id, 'module.flag_changed', 'workspace', $1, 'success', jsonb_build_object('enabled', $2::boolean) + from public.workspace_members m + where m.workspace_id = $1 and m.role = 'owner' + limit 1`, + [workspaceId, enabled], + ); +}); console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"}`); await db.close(); diff --git a/scripts/worker.ts b/scripts/worker.ts index 64e0409..38875f1 100644 --- a/scripts/worker.ts +++ b/scripts/worker.ts @@ -8,6 +8,9 @@ import { cleanupExpired, listQueuedJobIds, readJobForWorker, recoverStaleJobs, r import { asJson } from "../src/server/spreadsheet-adapter/db-utils"; import type { JobProgress, RequestContext, Role } from "../src/domain/spreadsheet-adapter/types"; +import { assertProductionEnv } from "../src/config/env"; + +assertProductionEnv(process.env); const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl && process.env.NODE_ENV === "production") { throw new Error("DATABASE_URL é obrigatório para o worker."); @@ -17,9 +20,19 @@ await migrate(db); const deps = { db, storage: createStorage(), coordinator: createCoordinator() }; async function tick() { - await recoverStaleJobs(deps); - await cleanupExpired(deps); - for (const jobId of await listQueuedJobIds(deps)) { + await recoverStaleJobs(deps).catch((error) => { + console.error("worker", error instanceof Error ? error.name : "error"); + }); + await cleanupExpired(deps).catch((error) => { + console.error("worker", "cleanup", error instanceof Error ? error.name : "error"); + }); + const queued = new Set(await listQueuedJobIds(deps)); + for (let index = 0; index < 20; index += 1) { + const jobId = await deps.coordinator.dequeue().catch(() => null); + if (!jobId) break; + queued.add(jobId); + } + for (const jobId of queued) { const rows = await readJobForWorker(deps, jobId); const row = rows[0]; if (!row) continue; diff --git a/src/app/adaptador/configuracoes/page.tsx b/src/app/adaptador/configuracoes/page.tsx index 22cb60f..5b11a96 100644 --- a/src/app/adaptador/configuracoes/page.tsx +++ b/src/app/adaptador/configuracoes/page.tsx @@ -9,7 +9,7 @@ export const dynamic = "force-dynamic"; export default async function SettingsPage() { const auth = await resolveAuth(); - if (auth.kind === "anonymous") redirect("/login"); + if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login"); if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador"); if (!canManageSpreadsheetModels(auth.ctx)) { return ( diff --git a/src/app/adaptador/operacao/page.tsx b/src/app/adaptador/operacao/page.tsx index b5d42c4..9e78f5a 100644 --- a/src/app/adaptador/operacao/page.tsx +++ b/src/app/adaptador/operacao/page.tsx @@ -7,7 +7,7 @@ export const dynamic = "force-dynamic"; export default async function OperationPage() { const auth = await resolveAuth(); - if (auth.kind === "anonymous") redirect("/login"); + if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login"); if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador"); return ( diff --git a/src/app/adaptador/page.tsx b/src/app/adaptador/page.tsx index 15de372..5917629 100644 --- a/src/app/adaptador/page.tsx +++ b/src/app/adaptador/page.tsx @@ -9,7 +9,7 @@ export const dynamic = "force-dynamic"; export default async function AdapterHome() { const auth = await resolveAuth(); - if (auth.kind === "anonymous") redirect("/login"); + if (auth.kind === "anonymous" || auth.kind === "session_expired") redirect(auth.kind === "session_expired" ? "/login?reason=expired" : "/login"); if (auth.kind !== "ok") { return (
diff --git a/src/app/api/health/live/route.ts b/src/app/api/health/live/route.ts new file mode 100644 index 0000000..8605964 --- /dev/null +++ b/src/app/api/health/live/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function GET() { + return NextResponse.json({ status: "live", module: "spreadsheet-adapter" }); +} diff --git a/src/app/api/health/ready/route.ts b/src/app/api/health/ready/route.ts new file mode 100644 index 0000000..08f3049 --- /dev/null +++ b/src/app/api/health/ready/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { collectProductionIssues } from "@/config/env"; +import { getDb } from "@/infra/db/client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + const checks: Record = { + database: "down", + redis: process.env.REDIS_URL ? "ok" : "missing", + storage: process.env.SUPABASE_STORAGE_BUCKET && process.env.SUPABASE_STORAGE_BUCKET !== "public" ? "ok" : "missing", + config: collectProductionIssues(process.env).length === 0 ? "ok" : "missing", + }; + try { + const db = await getDb(); + await db.withService((query) => query.query("select 1 as ok")); + checks.database = "ok"; + } catch { + checks.database = "down"; + } + const ready = checks.database === "ok" && (process.env.NODE_ENV !== "production" || (checks.redis === "ok" && checks.storage === "ok" && checks.config === "ok")); + return NextResponse.json({ status: ready ? "ready" : "not_ready", checks }, { status: ready ? 200 : 503 }); +} diff --git a/src/app/api/spreadsheet-adapter/_lib.ts b/src/app/api/spreadsheet-adapter/_lib.ts index 42433c2..d8d6d92 100644 --- a/src/app/api/spreadsheet-adapter/_lib.ts +++ b/src/app/api/spreadsheet-adapter/_lib.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { presentError } from "@/domain/spreadsheet-adapter/error-catalog"; import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors"; import type { RequestContext } from "@/domain/spreadsheet-adapter/types"; import { resolveAuth } from "@/server/auth/context"; @@ -9,18 +10,25 @@ export const dynamic = "force-dynamic"; export function errorResponse(error: unknown): NextResponse { if (isAdapterError(error)) { + const view = presentError(error.code); return NextResponse.json( - { error: { code: error.code, message: error.message, details: error.details ?? null } }, + { error: { code: error.code, message: error.message, action: view.action, category: view.category, reference: error.code, details: error.details ?? null } }, { status: error.status }, ); } console.error("spreadsheet-adapter", error instanceof Error ? error.name : "error"); - return NextResponse.json({ error: { code: "INTERNAL", message: "Erro interno." } }, { status: 500 }); + const view = presentError("INTERNAL"); + return NextResponse.json( + { error: { code: "INTERNAL", message: "Erro interno.", action: view.action, category: view.category, reference: "INTERNAL" } }, + { status: 500 }, + ); } export async function requireReady(): Promise<{ ctx: RequestContext; deps: AppDeps }> { const auth = await resolveAuth(); - if (auth.kind === "anonymous") throw new AdapterError("UNAUTHENTICATED", "Sessão Elo Pro necessária.", 401); + if (auth.kind === "anonymous" || auth.kind === "session_expired") { + throw new AdapterError("UNAUTHENTICATED", auth.kind === "session_expired" ? "Sessão expirada. Entre novamente." : "Sessão Elo Pro necessária.", 401); + } if (auth.kind !== "ok") throw new AdapterError("WORKSPACE_FORBIDDEN", "Workspace não autorizado.", 403); const { runtimeDeps } = await import("@/server/spreadsheet-adapter/runtime"); return { ctx: auth.ctx, deps: await runtimeDeps() }; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 21688dc..095ad11 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,14 +2,20 @@ import { redirect } from "next/navigation"; import { DEV_IDENTITIES } from "@/dev/fixtures"; import { resolveAuth } from "@/server/auth/context"; import { devAuthEnabled } from "@/server/auth/dev-session"; +import { buildCentralLoginUrl, safeReturnPath } from "@/server/auth/return-to"; import { DevLogin } from "@/components/dev-login"; export const dynamic = "force-dynamic"; -export default async function LoginPage() { +export default async function LoginPage({ searchParams }: { searchParams: Promise<{ next?: string; reason?: string }> }) { + const params = await searchParams; const auth = await resolveAuth(); - if (auth.kind === "ok") redirect("/adaptador"); - if (!devAuthEnabled() && process.env.ELO_PRO_LOGIN_URL) redirect(process.env.ELO_PRO_LOGIN_URL); + const nextPath = safeReturnPath(params.next); + if (auth.kind === "ok") redirect(nextPath); + if (!devAuthEnabled() && process.env.ELO_PRO_LOGIN_URL) { + redirect(buildCentralLoginUrl(process.env.ELO_PRO_LOGIN_URL, nextPath, process.env.ELO_PRO_APP_URL)); + } + const expired = auth.kind === "session_expired" || params.reason === "expired"; return (

ELO PRO

@@ -17,6 +23,7 @@ export default async function LoginPage() {

O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.

+ {expired ?

A sessão expirou. Entre novamente pelo Elo Pro.

: null} {devAuthEnabled() ? :

Defina ELO_PRO_LOGIN_URL com o endereço do login central.

}
); diff --git a/src/components/operation.tsx b/src/components/operation.tsx index 194eed1..666edc0 100644 --- a/src/components/operation.tsx +++ b/src/components/operation.tsx @@ -49,7 +49,7 @@ export function Operation({ ctx }: { ctx: RequestContext }) { const client = useQueryClient(); const [jobId, setJobId] = useState(null); const [step, setStep] = useState(0); - const [message, setMessage] = useState(null); + const [message, setMessage] = useState<{ text: string; action?: string; reference?: string } | null>(null); const [criteria, setCriteria] = useState(false); const [sheetDialog, setSheetDialog] = useState<{ sourceId: string; sheets: string[] } | null>(null); const [mappings, setMappings] = useState<{ sourceColumn: string; modelColumn: string }[]>([]); @@ -94,6 +94,10 @@ export function Operation({ ctx }: { ctx: RequestContext }) { }); } + function fail(error?: { message?: string; action?: string; code?: string }, fallback = "Não foi possível continuar.") { + setMessage({ text: error?.message ?? fallback, action: error?.action, reference: error?.code }); + } + async function start(modelId: string) { setMessage(null); const response = await fetch("/api/spreadsheet-adapter/jobs", { @@ -101,9 +105,9 @@ export function Operation({ ctx }: { ctx: RequestContext }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ modelId, idempotencyKey: crypto.randomUUID() }), }); - const data = await readJson<{ job?: JobDto; error?: { message: string } }>(response); + const data = await readJson<{ job?: JobDto; error?: { message: string; action?: string; code?: string } }>(response); if (!response.ok || !data.job) { - setMessage(data.error?.message ?? "Não foi possível abrir a operação"); + fail(data.error, "Não foi possível abrir a operação"); return; } setJobId(data.job.id); @@ -117,12 +121,12 @@ export function Operation({ ctx }: { ctx: RequestContext }) { form.set("file", file); form.set("jobId", jobId); const response = await fetch("/api/spreadsheet-adapter/sources/validate", { method: "POST", body: form }); - const data = await readJson<{ job: JobDto; code?: string; message?: string; error?: { message: string } }>(response); + const data = await readJson<{ job: JobDto; code?: string; message?: string; error?: { message: string; action?: string; code?: string } }>(response); if (data.error) { - setMessage(data.error.message); + fail(data.error); return; } - setMessage(data.message ?? null); + setMessage(data.message ? { text: data.message } : null); const created = data.job.sources.at(-1); if (created && data.code === "SHEET_REQUIRED") setSheetDialog({ sourceId: created.id, sheets: created.sheets }); await job.refetch(); @@ -147,7 +151,7 @@ export function Operation({ ctx }: { ctx: RequestContext }) { }); const data = await readJson(response); if (!response.ok) { - setMessage(data.error?.message ?? "Prévia indisponível"); + fail(data.error, "Prévia indisponível"); return; } setPreview(data); @@ -163,8 +167,8 @@ export function Operation({ ctx }: { ctx: RequestContext }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sourceId: source.id, idempotencyKey: crypto.randomUUID() }), }); - const data = await readJson<{ error?: { message: string } }>(response); - if (!response.ok) setMessage(data.error?.message ?? "Processamento não iniciado"); + const data = await readJson<{ error?: { message: string; action?: string; code?: string } }>(response); + if (!response.ok) fail(data.error, "Processamento não iniciado"); await job.refetch(); setStep(5); } @@ -176,7 +180,7 @@ export function Operation({ ctx }: { ctx: RequestContext }) {

Uma transferência por vez. A prévia é obrigatória antes de gerar o arquivo.

- {message ? : null} + {message ? : null} {step === 0 ? ( {models.isLoading ? : null} @@ -284,7 +288,9 @@ export function Operation({ ctx }: { ctx: RequestContext }) {

{job.data.progress?.label ?? "Processando"}

{job.data.progress?.percent == null ? "Progresso ainda não é estimável." : `${job.data.progress.percent}%`}

- {job.data.status === "failed" ? void processCurrent()} /> : null} + {job.data.status === "failed" ? ( + void processCurrent()} /> + ) : null} {source?.hasOutput ? (
{(["xlsx", "csv", "pdf"] as const).map((format) => ( diff --git a/src/components/states.tsx b/src/components/states.tsx index 3b171e7..9e6a3ef 100644 --- a/src/components/states.tsx +++ b/src/components/states.tsx @@ -19,11 +19,23 @@ export function EmptyState({ title, description, action }: { title: string; desc ); } -export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) { +export function ErrorState({ + message, + action, + reference, + onRetry, +}: { + message: string; + action?: string; + reference?: string; + onRetry?: () => void; +}) { return (

Não foi possível continuar

{message}

+ {action ?

{action}

: null} + {reference ?

Referência: {reference}

: null} {onRetry ? (