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

+26
View File
@@ -0,0 +1,26 @@
---
description: Regras do Adaptador de Planilhas dentro do Elo Pro
globs: src/**/*.{ts,tsx},migrations/**/*.sql,tests/**/*.{ts,tsx}
alwaysApply: false
---
# Adaptador de Planilhas
Ao editar o Adaptador de Planilhas:
- Nunca crie autenticação, cadastro, senha, refresh token ou base de usuários própria.
- Reutilize Supabase Auth, `profiles`, `workspace_members`, workspace ativo, feature flags, RLS, audit log, Sentry e os tokens do EloPro Design System Global V2.1.
- Tenant é `workspace_id`.
- Nunca confie em `workspace_id` vindo do browser sem validação no servidor.
- Storage é privado.
- Não registre conteúdo de células ou arquivos em log, auditoria ou Sentry.
- MVP aceita somente XLSX e CSV.
- Limites: 10 modelos ativos por workspace; 10 fontes por operação; 10 MB e 50.000 linhas úteis por fonte.
- XLSX com várias abas processa exatamente uma aba escolhida.
- Mapeamento é 1:1 e colunas obrigatórias precisam estar mapeadas.
- Prévia é obrigatória antes do processamento.
- O modelo original é imutável.
- Redis coordena; PostgreSQL é a fonte durável.
- Anthropic não faz parte do caminho crítico do MVP.
- Drag-and-drop precisa de alternativa por select/teclado.
- Storage, job runner, TTL, biblioteca XLSX e soft delete seguem os ADRs em `docs/decisions/` ou permanecem decisão aberta. Não invente uma política final no lugar deles.
+36
View File
@@ -0,0 +1,36 @@
# Identidade Elo Pro (Supabase Auth). Sem estes valores o módulo não autentica em produção.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Service role: somente servidor. Nunca prefixar com NEXT_PUBLIC_.
SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_STORAGE_BUCKET=
# PostgreSQL do Elo Pro. Obrigatório em produção.
DATABASE_URL=
# Redis. Obrigatório em produção para lock. Sem REDIS_URL o processamento recusa executar.
REDIS_URL=
# Sentry. Sem DSN a instrumentação fica inerte.
SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_ENVIRONMENT=development
SENTRY_RELEASE=
# Login central do ecossistema. O Adaptador não tem formulário de senha.
ELO_PRO_LOGIN_URL=
# 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.
SPREADSHEET_TEMP_TTL_SECONDS=
# inline = processa no mesmo processo após a resposta (dev). worker = só enfileira.
ADAPTER_JOB_MODE=inline
# 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.
ADAPTER_DEV_AUTH=0
ADAPTER_DEV_AUTH_SECRET=
+23
View File
@@ -0,0 +1,23 @@
name: ci
on:
push:
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
env:
ADAPTER_JOB_MODE: inline
SPREADSHEET_TEMP_TTL_SECONDS: "3600"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.data
dist
coverage
.env
.env.local
*.tsbuildinfo
_prompts_tmp
_escopo_extraido.txt
+41
View File
@@ -0,0 +1,41 @@
# Adaptador de Planilhas
Módulo do Elo Pro para validar planilhas XLSX/CSV, mapear colunas para um modelo e gerar um arquivo novo. Não há login, senha ou tabela de usuários própria.
## Desenvolvimento local
```bash
npm install
copy .env.example .env.local
```
No `.env.local`, para o piloto sem o host do Elo Pro:
```
ADAPTER_DEV_AUTH=1
ADAPTER_DEV_AUTH_SECRET=um-segredo-local
ADAPTER_JOB_MODE=inline
```
```bash
npm run dev
```
Abra `/login` e escolha uma identidade de desenvolvimento. Isso não pede senha: apenas assume um vínculo que o seed cria em `profiles` e `workspace_members`.
## Produção
Obrigatórios: `DATABASE_URL`, `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_STORAGE_BUCKET`, `REDIS_URL`, `SPREADSHEET_TEMP_TTL_SECONDS`, `ELO_PRO_LOGIN_URL`.
A feature flag `spreadsheet_adapter` nasce desligada. Habilite por workspace com `npm run flag -- <workspace_id> on` usando a conexão de serviço, nunca pelo browser.
O processamento de produção deve usar `ADAPTER_JOB_MODE=worker` e o processo `npm run worker`. O modo `inline` existe para desenvolvimento.
## Verificação
```bash
npm run lint
npm run typecheck
npm test
npm run build
```
@@ -0,0 +1,42 @@
# Pacote de Prompts — Adaptador de Planilhas Elo Pro
Este pacote foi preparado para construir o MVP do **Adaptador de Planilhas** dentro do ecossistema **Elo Pro** usando Cursor.
## Como usar
1. Coloque esta pasta dentro do repositório (por exemplo, `docs/cursor-prompts/spreadsheet-adapter/`) ou mantenha-a aberta ao lado do projeto.
2. Execute os prompts na ordem numérica. Não envie todos de uma vez.
3. Em cada etapa, peça ao Cursor para **primeiro inspecionar o repositório e apresentar um plano curto**, e somente então alterar código.
4. Após cada pacote, rode lint, typecheck e testes pertinentes, revise o diff e faça um commit antes de seguir.
5. Se o repositório Elo Pro já tiver implementações equivalentes (auth, workspace, RLS, design system, audit log, feature flags, Sentry, Supabase clients), **reutilize-as**. Não crie versões paralelas.
6. As pendências arquiteturais ainda não decididas (storage, job runner, TTL, biblioteca XLSX, soft delete) devem ser implementadas somente se houver uma decisão existente no repositório; caso contrário, documente a decisão necessária e use uma abstração que permita troca posterior.
## Ordem recomendada
- `01-MASTER-CONTEXT.md` — contexto fixo e regras que o agente deve respeitar em todas as etapas.
- `02-REPO-AUDIT-PLAN.md` — auditoria do repositório Elo Pro e plano de integração.
- `03-FOUNDATION-ROUTES-FEATURE-FLAG.md` — esqueleto do módulo e rotas.
- `04-AUTH-WORKSPACE-PERMISSIONS-RLS.md` — identidade única, workspace, autorização e RLS.
- `05-DATABASE-MIGRATIONS.md` — schema do módulo.
- `06-DESIGN-SYSTEM-APP-SHELL.md` — UI Elo Pro, shell e estados comuns.
- `07-MODEL-MANAGEMENT.md` — gestão de modelos e configuração de upload.
- `08-SOURCE-UPLOAD-VALIDATION.md` — fontes, validação, abas e cabeçalho.
- `09-MAPPING-PREVIEW.md` — mapeamento 1:1 e revisão.
- `10-JOBS-PROCESSING-EXPORTS.md` — jobs, processamento sequencial e saídas.
- `11-REDIS-PROGRESS-RESILIENCE.md` — lock, progresso, idempotência e falhas.
- `12-AUDIT-SENTRY-SECURITY.md` — auditoria, observabilidade e hardening.
- `13-TESTING-QA.md` — testes unitários, integração, RLS, E2E e performance.
- `14-DEPLOYMENT-OPERATIONS.md` — staging, migrations, feature flag e rollback.
- `15-FINAL-GAP-REVIEW.md` — revisão final contra o escopo.
## Regra de precedência
Quando houver conflito de informação, siga:
1. Regra de negócio validada do produto.
2. Arquitetura e segurança já existentes do Elo Pro.
3. Comportamento útil comprovado pelo primeiro MVP.
4. Documento V2 do Adaptador.
5. Fluxos/protótipos antigos.
Não invente regra de negócio para preencher lacunas. Registre a lacuna em `docs/decisions/` ou em uma seção `OPEN DECISIONS` no relatório da etapa.
@@ -0,0 +1,185 @@
# MASTER CONTEXT — Adaptador de Planilhas Elo Pro
Use este contexto em **todas** as etapas de implementação.
## Papel do agente
Você está implementando um módulo de produção dentro do ecossistema Elo Pro. Trabalhe como engenheiro sênior full-stack. Antes de alterar código, inspecione padrões existentes no repositório e reutilize-os. Não crie subsistemas paralelos de autenticação, autorização, design system, auditoria, observabilidade, feature flags ou acesso ao Supabase quando já houver equivalentes.
## Objetivo do produto
O Adaptador recebe planilhas de origens diferentes, valida sua estrutura, mapeia colunas manualmente para um modelo padronizado, mostra uma prévia do resultado e gera um novo arquivo consolidado sem alterar o modelo original.
Fluxo principal:
`Elo Pro autenticado -> Adaptador -> selecionar modelo -> carregar fonte(s) -> selecionar aba/cabeçalho quando necessário -> validar -> mapear -> revisar -> processar -> acompanhar progresso -> baixar -> continuar com outra fonte ou encerrar`.
## Identidade e login — regra inegociável
- O Adaptador **NÃO possui login próprio**.
- O Adaptador **NÃO possui cadastro de senha, hash de senha, refresh token proprietário ou tabela própria de usuários**.
- Use o mesmo **Supabase Auth** do Elo Pro.
- Use o mesmo `auth.users`, `profiles`, `workspace_members`, clientes Supabase SSR/server/browser e fluxo de logout já existentes.
- Ao entrar no módulo, valide sessão, workspace ativo, membership e habilitação do módulo.
- Ausência de sessão deve seguir o fluxo central de autenticação Elo Pro.
- Logout encerra a sessão Elo Pro.
- A meta é uma identidade/uma senha para vários apps do ecossistema. Não copie usuários entre bancos e não crie credenciais duplicadas.
## Tenant e autorização
- Tenant técnico: `workspace_id`.
- Toda persistência do módulo deve ser isolada por workspace.
- RLS é obrigatória quando aplicável.
- Não aceite `workspace_id` do cliente como verdade. Derive/valide no servidor conforme padrão Elo Pro.
- Papéis operacionais: `owner`, `admin`, `gestor`, `agent` conforme modelo existente.
- `owner/admin`: gestão de modelos e configurações.
- `gestor/agent`: fluxo operacional. Upload de modelo só se configuração específica permitir.
- `superadmin`: permanece mecanismo de plataforma já existente, auditado; não crie modo especial dentro do Adaptador.
- Segurança deve existir no servidor/banco. Esconder botão no frontend é apenas UX.
## Regras de negócio do MVP
- Até 10 modelos ativos por workspace.
- Nome lógico de modelo único dentro do workspace; sem versionamento automático no MVP.
- Entradas suportadas: XLSX e CSV.
- XLS, ODS e XML ficam fora do MVP.
- Fonte: até 10 MB e 50.000 linhas úteis por arquivo.
- Até 10 fontes por operação/jornada.
- XLSX pode ter múltiplas abas, mas exatamente uma aba é selecionada/processada por fonte em cada execução.
- Permitir seleção da linha de cabeçalho quando necessário.
- Toda coluna utilizada precisa ter cabeçalho identificável.
- Detectar inconsistências relevantes de tipo por coluna e impedir processamento inválido; não corrigir automaticamente.
- Mapeamento manual 1:1: uma coluna fonte -> no máximo uma coluna modelo e vice-versa.
- Todas as colunas obrigatórias do modelo devem estar mapeadas.
- Mapeamento não vira template reutilizável no MVP.
- Deve existir etapa de pré-visualização antes do processamento.
- Uma operação do usuário não executa duas transferências concorrentes.
- Modelo persistido é imutável durante transformação. Saída é sempre novo artefato.
- Modelos são persistentes. Fontes e saídas são temporárias, sujeitas a TTL.
- Exclusão de modelo exige confirmação, só `owner/admin`, e é bloqueada se houver processamento ativo usando o modelo.
- Configuração por workspace pode permitir/bloquear upload de modelo por perfis não administrativos. Isso não concede exclusão/gestão geral.
- Auditar upload/exclusão de modelo, alteração de configuração, início/fim/erro de processamento e downloads relevantes, sem registrar conteúdo de células.
- Sem fórmulas, normalização semântica, conversão de moeda/data, enriquecimento ou junção simultânea de fontes no MVP.
## Stack obrigatória
Frontend:
- Next.js 16, App Router
- React 19
- TypeScript 5
- Tailwind CSS v4
- Lucide React
- `@dnd-kit` para drag and drop acessível
- TanStack Query v5 para estado remoto/cache
- `@react-pdf/renderer` para PDF
Backend/infra:
- Supabase Auth + PostgreSQL com `@supabase/supabase-js` e `@supabase/ssr`
- Redis via `ioredis`
- Sentry client/server/edge
- `postgres` + `tsx` para migrations/ETL onde esse for o padrão do repositório
IA:
- `@anthropic-ai/sdk` pode existir na stack, mas **não participa do caminho crítico do MVP**.
Qualidade:
- Vitest
- ESLint 9
- typecheck estrito conforme padrão do monorepo/repo
## UX/UI — EloPro Design System Global V2.1
O Adaptador não cria Design System próprio.
- Interface predominantemente clara.
- Cloud White `#FBFBFE`: base.
- Midnight Ink `#071B45`: texto/estrutura.
- Action/Primary Blue: ações principais conforme token oficial do repo.
- Bright Blue `#2F8FFF`: foco/hover/ativos quando previsto.
- Aqua Signal `#00C2A8`: sucesso, continuidade e progresso.
- Warning `#F39A2D`.
- Error `#E63946`, sempre com texto/ícone; nunca cor isolada.
- Tipografia operacional: Manrope.
- Espaçamento base: 4px.
- Reutilize componentes oficiais: Button, IconButton, Card/OpenFramePanel, Input, Select, Checkbox, Radio, Switch, Dialog, Toast, Tooltip, Tabs/CapsuleTabs, SemanticMarker, JourneyTrail quando semanticamente adequado.
- Lucide React para ícones.
- Drag-and-drop precisa ter alternativa via teclado/select.
- Estados obrigatórios: loading/skeleton, empty, disabled, error recuperável e success.
- Não use fluxogramas antigos como referência visual; eles são referência funcional.
## Modelo de dados proposto
Adapte nomes/convenções ao repo, mantendo semântica:
- `spreadsheet_adapter_settings`
- `workspace_id` PK/FK
- `allow_non_admin_model_upload`
- timestamps
- `spreadsheet_models`
- `id`, `workspace_id`, `name`, `original_filename`, `format`, `storage_key`
- `sheet_name`, `header_row`, `columns_schema jsonb`
- `created_by`, `created_at`, `status`
- `spreadsheet_jobs`
- `id`, `workspace_id`, `user_id`, `model_id`, `status`
- `source_count`, `current_source`, `output_format`, `progress`
- `error_code`, `started_at`, `finished_at`, `created_at`
- `spreadsheet_job_sources`
- `id`, `job_id`, `source_name`, `temp_storage_key`
- `selected_sheet`, `header_row`, `row_count`, `size_bytes`, `status`, `expires_at`
- `spreadsheet_job_mappings`
- `id`, `job_source_id`, `source_column`, `model_column`, `is_required`, `position`
- Reuse `audit_log` existente quando compatível.
## Rotas sugeridas
Adapte ao padrão existente do Elo Pro:
- `GET /api/spreadsheet-adapter/models`
- `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`
- `POST /api/spreadsheet-adapter/jobs/:id/process`
- `GET /api/spreadsheet-adapter/jobs/:id/download?format=xlsx|csv|pdf`
Server Actions podem substituir rotas quando o padrão Elo Pro indicar, desde que contratos de segurança, teste e idempotência permaneçam claros.
## Pendências que NÃO devem ser inventadas
Se o repo não trouxer uma decisão, pare e documente a alternativa recomendada em vez de “decidir silenciosamente”:
1. Object storage definitivo: Supabase Storage privado ou equivalente S3.
2. Topologia de login único entre hosts/domínios.
3. Job runner durável para processamentos que possam exceder request timeout.
4. TTL exato de fontes/saídas temporárias.
5. Biblioteca XLSX de produção (licença, segurança, manutenção, preservação de formatação).
6. Exclusão física vs soft delete de modelos.
## Segurança mínima
- RLS e checagem server-side contra IDOR.
- Validar extensão, MIME e assinatura/estrutura do arquivo.
- Proteger contra path traversal e nomes maliciosos.
- Tratar CSV injection em exportações quando relevante.
- Nunca logar conteúdo de célula no Sentry/audit log.
- Não enviar planilha ao Anthropic no MVP.
- Storage privado; download somente após autorização.
- Sanitizar nomes e metadados exibidos.
## Definição de pronto geral
Uma etapa só está concluída quando:
- implementação segue padrões existentes do Elo Pro;
- lint/typecheck passam;
- testes da etapa passam;
- segurança de workspace/permissões foi coberta;
- estados de UX relevantes existem;
- nenhuma nova autenticação paralela foi criada;
- mudanças e decisões abertas estão documentadas.
@@ -0,0 +1,58 @@
# PROMPT 02 — Auditoria do repositório e plano de integração
Use `01-MASTER-CONTEXT.md` como contexto obrigatório.
## Tarefa
Antes de escrever funcionalidades, faça uma auditoria técnica do repositório Elo Pro para descobrir os padrões reais que o Adaptador deve reutilizar.
### Inspecione especificamente
1. Estrutura Next.js/App Router e convenções de pastas.
2. Implementação atual de Supabase:
- browser client;
- server client;
- SSR/cookies;
- middleware;
- refresh de sessão;
- logout.
3. Como o app resolve `workspace_id` ativo e membership.
4. Tipos/papéis reais existentes (`owner/admin/gestor/agent` ou equivalentes).
5. RLS/migrations existentes e helpers de autorização.
6. Sistema de feature flags/planos/módulos.
7. Design System Elo Pro: componentes, tokens, Tailwind, fontes, ícones e layouts.
8. TanStack Query: QueryClient, providers, query keys e padrão de mutations.
9. Sentry: client/server/edge, scrubbing e tags.
10. Redis: conexão, helpers, namespaces e fallback.
11. Audit log existente.
12. Storage existente e políticas de bucket.
13. Padrão para Route Handlers/Server Actions.
14. Estratégia de testes, fixtures, Vitest e E2E se houver.
15. CI/CD, migrations e ambientes.
## Não faça ainda
- Não crie tabelas.
- Não implemente telas.
- Não instale biblioteca XLSX.
- Não altere autenticação.
## Saída esperada
Crie `docs/spreadsheet-adapter/00-repo-integration-audit.md` contendo:
- padrões existentes que serão reutilizados;
- caminhos/arquivos relevantes;
- diferenças entre o MASTER CONTEXT e o estado atual do repo;
- decisões já resolvidas pelo repo;
- `OPEN DECISIONS` somente para o que realmente não estiver definido;
- arquitetura proposta do módulo em 1 diagrama Mermaid;
- plano de implementação por fases e dependências.
No chat do Cursor, finalize com:
1. resumo de no máximo 15 bullets;
2. lista dos arquivos inspecionados mais importantes;
3. lista de decisões que exigem confirmação humana.
Não altere código de produção nesta etapa.
@@ -0,0 +1,48 @@
# PROMPT 03 — Fundação do módulo, rotas e feature flag
Use `01-MASTER-CONTEXT.md` e a auditoria produzida no Prompt 02.
## Objetivo
Criar o esqueleto do Adaptador dentro do Elo Pro sem duplicar infraestrutura já existente.
## Implementar
1. Rota/página principal do Adaptador seguindo a convenção do App Router existente.
2. Entrada do módulo pelo mecanismo de navegação do Elo Pro.
3. Proteção por feature flag/plano/módulo conforme sistema existente.
4. Layout/shell inicial reutilizando navegação, workspace e componentes globais.
5. Rotas internas planejadas, por exemplo:
- dashboard;
- operação nova/atual;
- configurações (somente perfis administrativos);
Adapte nomes ao padrão do repo.
6. Boundary de erro e loading states no padrão Elo Pro.
7. Tipos centrais do domínio do Adaptador em local coerente com o repo.
8. Estrutura de módulos de domínio para evitar lógica pesada nos componentes React.
## Regras
- Não crie login próprio.
- Não crie novo seletor de workspace se o Elo Pro já possui um.
- Não permita acesso ao módulo sem feature habilitada.
- Não implemente lógica de planilha ainda.
- Não introduza store global nova se não for necessária.
## UX mínima
Dashboard inicial deve explicar o propósito e fornecer um CTA claro para iniciar uma adaptação, mas pode usar dados mockados somente dentro de testes/story fixtures. Em produção, não invente modelos/fonte falsos.
## Testes
- acesso com módulo habilitado;
- acesso com módulo desabilitado;
- estado loading/error;
- rota de configurações inacessível a quem não tem permissão administrativa.
## Entrega
- código;
- testes;
- `docs/spreadsheet-adapter/01-foundation.md` com rotas, guardas e decisões.
- reporte comandos rodados e resultados.
@@ -0,0 +1,52 @@
# PROMPT 04 — Autenticação Elo Pro, workspace, permissões e RLS
Use o MASTER CONTEXT. Este é um pacote de segurança crítica.
## Objetivo
Garantir que o Adaptador opere integralmente com a identidade e o workspace existentes do Elo Pro.
## Implementar/validar
1. Resolver sessão via mecanismo Supabase SSR já existente.
2. Resolver usuário/perfil sem segunda consulta desnecessária quando contexto já estiver disponível.
3. Resolver workspace ativo e validar `workspace_members` no servidor.
4. Helpers de autorização do módulo reutilizando os helpers Elo Pro quando existirem:
- `canUseSpreadsheetAdapter`
- `canManageSpreadsheetModels`
- `canUploadSpreadsheetModel`
Os nomes podem variar; a semântica deve permanecer.
5. Preparar políticas RLS para as futuras tabelas do módulo segundo as convenções do repo.
6. Garantir que `workspace_id` nunca seja confiado diretamente a payload de cliente.
7. Integrar feature flag à autorização.
8. Garantir comportamento correto ao trocar workspace.
## Perfis esperados
- `owner/admin`: podem gerir modelos/configuração.
- `gestor/agent`: podem operar adaptações.
- upload de modelo por não-admin somente se setting do workspace permitir.
- Superadmin segue mecanismos globais existentes, sem bypass local criado pelo Adaptador.
## Testes obrigatórios
- usuário sem sessão;
- usuário autenticado sem membership;
- workspace A tentando ler/escrever B;
- agent tentando ação administrativa;
- admin/owner executando ação administrativa;
- feature flag desligada;
- troca de workspace invalidando cache/dados anteriores.
## Restrições
Não altere o modelo central de autenticação para “facilitar” o módulo. O Adaptador deve se adaptar ao Elo Pro.
## Entrega
Documente em `docs/spreadsheet-adapter/02-auth-workspace-permissions.md`:
- fluxo de autenticação;
- fonte do workspace;
- matriz de permissões efetiva;
- estratégia RLS;
- testes executados.
@@ -0,0 +1,96 @@
# PROMPT 05 — Banco de dados e migrations
Use o MASTER CONTEXT e as convenções de migrations descobertas no Prompt 02.
## Objetivo
Criar o schema persistente mínimo do Adaptador, multi-tenant por `workspace_id`, sem duplicar entidades do Elo Pro.
## Tabelas alvo
Adapte nomenclatura/campos às convenções do repo, preservando a semântica:
### spreadsheet_adapter_settings
- `workspace_id` PK/FK
- `allow_non_admin_model_upload boolean not null default false`
- timestamps
### spreadsheet_models
- id
- workspace_id
- name lógico
- original_filename
- format (`xlsx|csv`)
- storage_key
- selected/default sheet quando aplicável
- header_row
- `columns_schema jsonb`
- created_by
- created_at / updated_at
- status
- constraint de nome único ativo por workspace, se compatível com estratégia de exclusão
### spreadsheet_jobs
- id
- workspace_id
- user_id
- model_id
- status com enum/check coerente
- source_count
- current_source
- output_format
- progress
- error_code / error_metadata sanitizada
- timestamps de ciclo
### spreadsheet_job_sources
- id
- job_id
- source_name
- temp_storage_key
- selected_sheet
- header_row
- row_count
- size_bytes
- status
- expires_at
### spreadsheet_job_mappings
- id
- job_source_id
- source_column
- model_column
- is_required
- position
- unicidade suficiente para garantir 1:1
### audit log
Reutilize o audit log Elo Pro se a estrutura suportar os eventos do módulo. Não crie tabela paralela sem necessidade.
## Regras de banco
- FKs corretas e cascades cuidadosamente escolhidas.
- Índices por `workspace_id`, status e relacionamentos frequentes.
- RLS em todas as tabelas tenant-aware.
- Constraints para integridade que não dependam só do frontend.
- Não persistir conteúdo bruto das planilhas no PostgreSQL.
- Não armazenar segredos.
## Decisões abertas
Se soft delete vs delete físico ainda estiver aberto, implemente schema que não torne a decisão irreversível e documente a escolha temporária. Não invente política de retenção.
## Testes
- migrations sobem do zero;
- rollback/estratégia de reversão documentada;
- RLS A != B;
- constraints de duplicidade;
- 1:1 de mapping;
- FKs e deleções.
## Entrega
- migrations;
- tipos gerados/atualizados conforme padrão Supabase do repo;
- `docs/spreadsheet-adapter/03-data-model.md` com ERD Mermaid.
@@ -0,0 +1,56 @@
# PROMPT 06 — Design System Elo Pro e shell de UX
Use o MASTER CONTEXT e componentes reais do Design System encontrados no repo.
## Objetivo
Construir a estrutura visual do Adaptador sem criar um design system paralelo.
## Criar
1. Dashboard do módulo.
2. Estrutura da jornada:
- modelo;
- fontes;
- mapeamento;
- revisão;
- processamento;
- resultado.
3. Área de configurações administrativa.
4. Componentes de estado comuns:
- loading/skeleton;
- empty state;
- error recuperável;
- warning;
- success;
- disabled.
5. Navegação/journey indicator usando os componentes Elo Pro adequados. Não force `JourneyTrail` como stepper se o componente não tiver essa semântica.
## Requisitos visuais
- Manrope.
- Cloud White como base predominante.
- Midnight Ink para texto/estrutura.
- Action/Primary Blue para CTAs conforme token oficial.
- Aqua Signal para sucesso/progresso.
- Warning/Error conforme tokens.
- Não usar cor como único sinal.
- Lucide React.
- Responsivo para desktop/tablet; mobile não é foco, mas não deve quebrar.
- Foco visível, navegação por teclado, labels e ARIA adequados.
## Componentes
Reutilize Button, Card/OpenFramePanel, Dialog, Toast, Select, Input, Switch, Tooltip, SemanticMarker e demais oficiais. Se algum não existir exatamente, componha a partir dos primitives já adotados no Elo Pro, mantendo tokens.
## Não implementar ainda
Parsing real de planilhas ou jobs.
## Testes
Inclua testes de estados e acessibilidade básica dos componentes-chave.
## Entrega
`docs/spreadsheet-adapter/04-ux-shell.md` com mapa de páginas e componentes reutilizados.
@@ -0,0 +1,64 @@
# PROMPT 07 — Gestão de modelos e configuração administrativa
Use o MASTER CONTEXT.
## Objetivo
Implementar o ciclo de vida de modelos do workspace e a configuração que permite upload por perfis não administrativos.
## Funcionalidades
### Lista de modelos
- listar somente workspace ativo;
- ordem alfabética;
- nome, data, criador e metadados úteis;
- empty state e loading state;
- seleção do modelo para operação.
### Upload de modelo
- XLSX e CSV apenas;
- drag-and-drop + file picker;
- critérios de upload visíveis em Dialog/ajuda contextual;
- até 10 modelos ativos por workspace;
- impedir nome lógico duplicado;
- validar cabeçalho/estrutura mínima;
- permitir escolha de aba e linha de cabeçalho quando necessário;
- persistir arquivo somente em storage privado aprovado pelo repo;
- persistir metadados/`columns_schema` no PostgreSQL.
### Exclusão
- owner/admin apenas;
- Dialog de confirmação;
- bloquear se houver job ativo usando o modelo;
- auditar;
- respeitar estratégia delete/soft delete já decidida.
### Setting de upload por não-admin
- Switch oficial;
- `owner/admin` alteram;
- valor por workspace;
- server-side authorization;
- `gestor/agent` apenas recebem permissão de upload, não de exclusão/administração.
## Segurança
- storage key não deve ser construída de modo vulnerável a path traversal;
- MIME/extensão/estrutura devem ser checados;
- não confiar em metadata do navegador;
- sem bucket público.
## API/Actions
Implemente no padrão do repo as operações equivalentes a:
- listar;
- criar;
- excluir;
- ler/alterar settings.
## Testes
Cubra 10 modelos, 11º bloqueado, duplicidade, papel não autorizado, setting ligado/desligado, RLS, job ativo e arquivo inválido.
## Entrega
Atualize documentação técnica e descreva o contrato de upload e erros possíveis.
@@ -0,0 +1,49 @@
# PROMPT 08 — Upload e validação de planilhas fonte
Use o MASTER CONTEXT.
## Objetivo
Implementar carregamento temporário e validação estrutural das fontes sem persistência permanente.
## Requisitos
- Até 10 fontes por operação.
- XLSX/CSV apenas.
- Até 10 MB por arquivo.
- Até 50.000 linhas úteis por arquivo.
- Arquivo corrompido/inválido deve falhar individualmente sem destruir as demais fontes válidas.
- XLSX multiaba: listar abas e exigir seleção de exatamente uma antes do mapeamento.
- Permitir definir linha de cabeçalho quando o sistema não puder assumir a primeira linha.
- Detectar cabeçalhos vazios/duplicados/problemáticos e apresentar causa clara.
- Inferir tipos estruturais suficientes para alertar inconsistências relevantes; não transformar dados.
- Retornar metadados para UI: abas, headers, row count, size, amostra segura e warnings.
- Temporários devem receber `expires_at` e storage privado conforme política existente. Se TTL não estiver decidido, não invente número final; use configuração central/documentada.
## Parsing
Antes de instalar biblioteca XLSX, verifique se o repo já possui uma. Se não houver decisão aprovada, proponha 2–3 alternativas com licença/manutenção/segurança e pare antes de consolidar dependência de produção.
## UX
- lista de fontes com status individual;
- progress/validation state;
- erro recuperável por arquivo;
- opção de remover/recarregar;
- critérios acessíveis antes do upload.
## Segurança
- MIME spoofing;
- arquivo zip bomb / estruturas anômalas quando aplicável ao XLSX;
- path traversal;
- limite de memória;
- não logar conteúdo bruto.
## Testes
Arquivos válidos, 11º arquivo, >10MB, >50k, corrompido, CSV inválido, XLSX multiaba, header row alternativo, colunas sem título, tipos inconsistentes.
## Entrega
Documente códigos de erro estáveis para a UI e estratégia de temporários.
@@ -0,0 +1,57 @@
# PROMPT 09 — Mapeamento 1:1 e pré-visualização
Use o MASTER CONTEXT.
## Objetivo
Implementar a etapa central do produto: mapear colunas fonte para colunas modelo e revisar o resultado antes de processar.
## Interface de mapeamento
- mostrar metadados da fonte selecionada e do modelo;
- apresentar colunas fonte e destino claramente;
- permitir `Select` como interação básica obrigatória;
- adicionar drag-and-drop com `@dnd-kit` somente como melhoria, mantendo alternativa completa por teclado/select;
- marcar colunas obrigatórias do modelo;
- impedir mapeamento duplicado em ambos os sentidos;
- permitir deixar colunas não obrigatórias sem mapeamento;
- botão de continuar desabilitado enquanto obrigatórias faltarem;
- mensagem textual indicando exatamente o que falta.
## Persistência
- mapping pertence à operação/fonte atual;
- salvar de forma suficiente para retomar a jornada da operação, mas não convertê-lo em template reutilizável;
- invalidar/recalcular quando fonte/modelo/header/aba mudar.
## Preview
Antes do processamento:
- gerar amostra da estrutura resultante;
- respeitar ordem/colunas do modelo;
- não modificar o modelo persistido;
- deixar claro que é prévia;
- permitir voltar ao mapeamento sem perder estado válido;
- permitir confirmar processamento.
## Acessibilidade
- drag via teclado ou alternativa equivalente;
- labels explícitos;
- foco previsível;
- estados não dependem só de cor.
## Testes
- 1:1;
- obrigatórias;
- conflito;
- desmapear/remapear;
- troca de fonte;
- troca de aba/header;
- preview com ordem do modelo;
- navegação por teclado.
## Entrega
Documente o algoritmo de validação de mapping e o contrato do preview.
@@ -0,0 +1,61 @@
# PROMPT 10 — Jobs, processamento e geração de arquivos
Use o MASTER CONTEXT.
## Objetivo
Implementar o processamento real de uma fonte mapeada para um novo arquivo baseado no modelo, preservando o original.
## Ciclo do job
Defina estados explícitos e tipados, por exemplo:
`draft -> validating -> ready -> processing -> completed | failed | expired | cancelled`
Adapte ao padrão do repo sem perder clareza.
## Regras
- job sempre pertence a workspace e usuário;
- validar novamente autorização e integridade no servidor antes de processar;
- uma mesma operação do usuário não executa duas transferências simultâneas;
- processamento deve ser idempotente para evitar cliques duplicados;
- múltiplas fontes na mesma jornada são processadas sequencialmente;
- o modelo original jamais é sobrescrito;
- saída é novo arquivo;
- mapping 1:1 e obrigatórias devem ser revalidados server-side;
- erros não devem expor conteúdo sensível.
## Saídas
- XLSX;
- CSV;
- PDF via `@react-pdf/renderer` quando o formato fizer sentido para o resultado definido no produto.
Garanta compatibilidade de nomes, encoding, caracteres especiais e download autorizado.
## Preservação de modelo
Se o requisito de “manter formatação do modelo” depender da biblioteca XLSX, escreva teste de não regressão e documente limitações reais. Não prometa preservação que a implementação não suporte.
## Download
- autorização no momento do download;
- URL assinada/stream privado conforme storage;
- audit event relevante;
- temporário com expiração;
- nome de arquivo seguro e previsível.
## Job runner
Se o repo já tiver fila/worker, use-o. Se não tiver e o processamento puder exceder timeout do runtime atual, não esconda o problema dentro de uma request longa: crie abstração/ADR e implemente somente solução aprovada.
## Testes
- sucesso XLSX/CSV/PDF;
- modelo intacto;
- mapping inválido barrado;
- idempotência;
- dupla submissão;
- usuário/workspace indevido;
- caracteres especiais;
- erro de storage;
- retry seguro quando aplicável.
@@ -0,0 +1,39 @@
# PROMPT 11 — Redis, progresso, lock e resiliência
Use o MASTER CONTEXT e a implementação Redis existente do Elo Pro.
## Objetivo
Adicionar coordenação efêmera sem transformar Redis em fonte de verdade.
## Implementar
1. Namespace claro para chaves do Adaptador.
2. Lock por operação/usuário necessário para impedir processamento concorrente indevido.
3. TTL do lock com renovação/cleanup seguro para evitar deadlock.
4. Progresso efêmero em Redis quando trouxer benefício, mantendo status durável no PostgreSQL.
5. Idempotency key para disparo de processamento, se o padrão do repo suportar.
6. Query/polling/subscription da UI conforme arquitetura existente; TanStack Query deve invalidar/refetch adequadamente.
7. Fallback claro se Redis estiver indisponível: falha controlada ou degradação segura, nunca execução duplicada silenciosa.
## UX de progresso
- percentual somente se for real/estimável; não invente precisão falsa;
- estados textuais como “Validando”, “Processando”, “Gerando arquivo”;
- sucesso e erro persistentes o suficiente para o usuário agir;
- botão de processar desabilitado durante job ativo.
## Observabilidade
Tags de job ID e workspace ID técnico, sem dados de planilha.
## Testes
- lock adquirido;
- lock concorrente;
- expiração;
- erro de Redis;
- retry;
- progresso;
- refresh de página durante job;
- job concluído enquanto UI estava fechada.
@@ -0,0 +1,51 @@
# PROMPT 12 — Auditoria, Sentry e hardening de segurança
Use o MASTER CONTEXT. Trate esta etapa como security review.
## Auditoria
Reutilize o mecanismo Elo Pro para registrar, no mínimo:
- upload de modelo;
- exclusão de modelo;
- alteração do setting de upload por não-admin;
- criação/início de job;
- conclusão;
- falha;
- download relevante.
Registre IDs técnicos, actor, workspace, timestamps, ação e resultado. Não registre conteúdo de célula, planilha completa, token, segredo ou arquivo bruto.
## Sentry
- usar integrações client/server/edge existentes;
- definir tags seguras: módulo, job_id, workspace_id técnico, error_code;
- scrubbing de request body, headers e breadcrumbs;
- não anexar arquivo nem amostra de linhas;
- errors esperados de validação não devem gerar ruído como exception crítica.
## Hardening
Revisar e testar:
- IDOR;
- workspace forging;
- bypass de papel;
- MIME spoofing;
- extensão enganosa;
- path traversal;
- nome de arquivo malicioso;
- CSV injection nas exportações;
- zip bomb/arquivo excessivamente complexo quando aplicável;
- download de artefato de outro workspace;
- replay/double-submit;
- XSS em nomes de arquivo/headers;
- logs contendo PII/dados financeiros.
## Headers/Runtime
Siga CSP/security headers já existentes. Não crie política conflitante com o app principal.
## Entrega
- testes automatizados onde possível;
- `docs/spreadsheet-adapter/05-security-observability.md` com threat model resumido;
- lista de riscos residuais e mitigação.
@@ -0,0 +1,73 @@
# PROMPT 13 — Testes e QA do MVP
Use o MASTER CONTEXT e todo o código produzido até aqui.
## Objetivo
Criar cobertura suficiente para liberar piloto sem depender apenas de teste manual.
## Matriz mínima
### Unitário / Vitest
- normalização/validação de cabeçalho;
- limites de arquivo/linha;
- nomes duplicados;
- validação de tipo;
- mapping 1:1;
- obrigatórias;
- transformação de linha;
- geração de nomes de saída;
- state machine do job.
### Componentes
- upload;
- Dialog;
- Select;
- drag-and-drop + alternativa teclado;
- preview;
- loading/error/disabled;
- progress;
- settings Switch.
### Integração
- sessão válida/inválida;
- workspace membership;
- owner/admin/gestor/agent;
- feature flag;
- Redis indisponível;
- storage failure;
- job/download.
### Banco/RLS
- workspace A não lê/escreve B;
- agent não administra;
- owner/admin administram;
- setting de upload não-admin;
- constraints de mapping/duplicidade.
### E2E
Cenário principal:
`login Elo Pro existente -> abrir Adaptador -> selecionar/subir modelo -> upload fonte -> selecionar aba/header -> mapear -> revisar -> processar -> baixar -> continuar`.
### Performance
- fonte 10MB;
- 50.000 linhas;
- 10 fontes sequenciais;
- dois usuários distintos processando em paralelo;
- observar memória e timeout.
### Segurança
Inclua os cenários do Prompt 12.
## Critérios de corte
- zero acesso cruzado nos testes RLS;
- fluxo principal completo;
- modelo original intacto;
- sem segundo login;
- erros inesperados correlacionados no Sentry;
- lint/typecheck/test suite verdes.
## Entrega
Crie `docs/spreadsheet-adapter/06-test-plan-and-results.md` com matriz de testes, resultados e itens bloqueantes.
@@ -0,0 +1,38 @@
# PROMPT 14 — Deploy, operação e rollout
Use o MASTER CONTEXT e padrões CI/CD do Elo Pro.
## Objetivo
Preparar o módulo para staging e piloto controlado.
## Implementar/documentar
1. Migrations idempotentes e ordem de deploy.
2. Feature flag desligada por padrão, se esse for o padrão de rollout.
3. Configuração de envs reutilizando naming conventions do Elo Pro.
4. Redis em cada ambiente.
5. Storage privado e políticas por ambiente.
6. Sentry release/environment.
7. Limites de runtime e job runner.
8. Política de limpeza de temporários.
9. Rollback técnico:
- app;
- migration;
- feature flag;
- workers/jobs.
10. Checklist de smoke test pós-deploy.
11. Runbook para job travado, Redis indisponível, storage indisponível e migration parcial.
## Segurança operacional
- nenhuma service role exposta ao cliente;
- secrets somente no mecanismo padrão do ambiente;
- downloads privados;
- não criar buckets públicos para simplificar teste.
## Entrega
- `docs/spreadsheet-adapter/07-deployment-runbook.md`;
- checklist de staging/piloto;
- tabela de env vars necessárias sem valores secretos.
@@ -0,0 +1,71 @@
# PROMPT 15 — Revisão final e fechamento de gaps
Use `01-MASTER-CONTEXT.md` e todos os documentos/commits produzidos nas etapas anteriores.
## Objetivo
Fazer uma revisão final do Adaptador contra o escopo V3 antes de declarar o MVP pronto.
## Procedimento
1. Inspecione toda a implementação do módulo.
2. Compare requisito por requisito com o MASTER CONTEXT.
3. Crie uma matriz:
- requisito;
- status: `OK`, `PARCIAL`, `FALTA`, `DECISÃO ABERTA`;
- evidência (arquivo/teste);
- correção necessária.
4. Corrija automaticamente apenas gaps claros que não exigem nova decisão de produto/arquitetura.
5. Para decisões abertas, não invente. Gere ADR/issue sugerida.
## Verificações críticas
- nenhum login próprio;
- nenhum armazenamento de senha/token paralelo;
- workspace/RLS em todas as entidades;
- feature flag;
- matriz de permissões;
- 10 modelos;
- nomes únicos;
- XLSX/CSV somente;
- 10MB/50k/10 fontes;
- multiaba com uma aba selecionada;
- header row;
- mapping 1:1;
- obrigatórias;
- preview;
- lock/processamento sequencial;
- modelo imutável;
- temporários;
- exclusão protegida;
- auditoria;
- Sentry sem conteúdo sensível;
- XLSX/CSV/PDF;
- Design System Elo Pro;
- acessibilidade básica;
- testes e performance.
## Rodar
Use os comandos reais do repo para:
- lint;
- typecheck;
- unit/integration;
- RLS;
- E2E;
- build de produção.
## Entrega
Crie `docs/spreadsheet-adapter/08-final-readiness-report.md` com:
- scorecard factual por requisito (sem notas subjetivas);
- bloqueadores;
- riscos residuais;
- decisões abertas;
- evidências de teste;
- checklist final de release.
No chat, apresente somente:
1. o que está pronto;
2. o que bloqueia release;
3. quais decisões humanas ainda são necessárias.
@@ -0,0 +1,25 @@
# OPCIONAL — Regra persistente para Cursor
Se o repositório usa `.cursor/rules/`, transforme o conteúdo abaixo em uma rule apropriada ao padrão da versão instalada do Cursor.
## spreadsheet-adapter.mdc — intenção
Ao editar arquivos do Adaptador de Planilhas:
- Nunca crie autenticação, cadastro, senha, refresh token ou base de usuários própria.
- Reutilize Supabase Auth, `profiles`, `workspace_members`, workspace ativo, feature flags, RLS, audit log, Sentry e Design System do Elo Pro.
- Tenant é `workspace_id`.
- Nunca confie em `workspace_id` fornecido pelo browser sem validação server-side.
- Storage deve ser privado.
- Não logue conteúdo de células/arquivos.
- Suporte somente XLSX/CSV no MVP.
- Limites: 10 modelos/workspace; 10 fontes/operação; 10MB e 50k linhas por fonte.
- XLSX multiaba processa uma aba selecionada por fonte.
- Mapping é 1:1 e obrigatórias devem estar mapeadas.
- Preview é obrigatório antes do processamento.
- Modelo original é imutável.
- Redis é coordenação, PostgreSQL é fonte durável.
- Anthropic não faz parte do caminho crítico do MVP.
- UI deve consumir EloPro Design System Global V2.1 e Lucide React.
- Drag-and-drop precisa ter alternativa acessível.
- Não invente decisões para storage, job runner, TTL, XLSX library ou soft delete; siga ADR/padrão existente ou registre decisão aberta.
@@ -0,0 +1,27 @@
# Checklist de execução no Cursor
- [ ] 02 Auditoria concluída sem mudanças de produção
- [ ] 03 Fundação/feature flag
- [ ] 04 Auth/workspace/permissões/RLS
- [ ] 05 Banco/migrations
- [ ] 06 Design System/shell
- [ ] 07 Modelos/settings
- [ ] 08 Fontes/validação
- [ ] 09 Mapping/preview
- [ ] 10 Jobs/exports
- [ ] 11 Redis/progresso
- [ ] 12 Auditoria/Sentry/security
- [ ] 13 Testes/QA
- [ ] 14 Deploy/runbook
- [ ] 15 Gap review
## Gates obrigatórios antes do piloto
- [ ] Uma identidade/senha Elo Pro, sem login paralelo
- [ ] Zero acesso cruzado entre workspaces nos testes
- [ ] Modelo original permanece intacto
- [ ] Fluxo upload -> mapping -> preview -> processo -> download funciona
- [ ] Feature flag e permissões funcionando
- [ ] Storage privado e temporários com limpeza definida
- [ ] Sentry sem dados de planilha
- [ ] Lint, typecheck, testes e build verdes
+7
View File
@@ -0,0 +1,7 @@
# ADR 0001 — Object storage
Status: aberta, com adaptação provisória.
Produção exige `SUPABASE_SERVICE_ROLE_KEY` e `SUPABASE_STORAGE_BUCKET`. O cliente só faz upload/download pelo Route Handler, depois da autorização. Sem essas variáveis, produção recusa subir o storage. Desenvolvimento usa `.data/storage`, fora da pasta pública do Next.
Recomendação: bucket privado no Supabase Storage, ou S3 compatível atrás da mesma interface `ObjectStorage`.
+5
View File
@@ -0,0 +1,5 @@
# ADR 0002 — Login único entre hosts
Status: aberta.
O Adaptador lê a sessão Supabase e o cookie `elo_pro_workspace_id`. Não copia usuários nem cria senha. A topologia de domínio e cookie compartilhado continua com a arquitetura do Elo Pro. `ELO_PRO_LOGIN_URL` é o destino quando não há sessão.
+7
View File
@@ -0,0 +1,7 @@
# ADR 0003 — Job runner
Status: aberta, com dois modos explícitos.
`ADAPTER_JOB_MODE=worker` apenas marca o job como `queued`. O processo `npm run worker` executa com o mesmo `runProcess`, lock e estado no PostgreSQL. `inline` executa na própria chamada e existe para desenvolvimento e teste. Produção sem a variável cai em `worker`.
Recomendação: manter o worker como processo contínuo no piloto. Não depender do tempo de uma função serverless.
+5
View File
@@ -0,0 +1,5 @@
# ADR 0004 — TTL de temporários
Status: aberta.
`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/`.
+11
View File
@@ -0,0 +1,11 @@
# ADR 0005 — Biblioteca de planilha
Status: aberta. Implementação provisória: ExcelJS (MIT) atrás de `readSpreadsheet`, `buildXlsx` e CSV próprio.
Alternativas consideradas:
- ExcelJS, MIT, leitura e escrita, manutenção ativa, não promete preservar a formatação do modelo.
- SheetJS comunitário, leitura muito usada; a licença das versões atuais precisa de revisão jurídica antes de produção.
- ExcelJS para grade e geração de arquivo novo, que é o comportamento do MVP: a saída não sobrescreve o modelo.
O binário do modelo é só lido. O teste de jornada compara os bytes antes e depois. Trocar de biblioteca não deve vazar para os componentes React.
+9
View File
@@ -0,0 +1,9 @@
# ADR 0006 — Azul de ação e exclusão de modelo
## 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.
## 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.
@@ -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
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
export default defineConfig([
...nextVitals,
globalIgnores([".next/**", "node_modules/**", ".data/**", "coverage/**", "_prompts_tmp/**"]),
]);
+52
View File
@@ -0,0 +1,52 @@
-- Compatibilidade para desenvolvimento isolado.
-- Em produção estas tabelas pertencem ao Elo Pro e NÃO devem ser recriadas se já existirem.
-- Não há coluna de senha, hash, refresh token ou sessão paralela.
-- gen_random_uuid() faz parte do núcleo do PostgreSQL 13+.
do $$ begin
create role app_user nologin nosuperuser nobypassrls;
exception when duplicate_object then null;
end $$;
create table if not exists public.profiles (
id uuid primary key,
display_name text not null,
is_superadmin boolean not null default false,
created_at timestamptz not null default now()
);
create table if not exists public.workspaces (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamptz not null default now()
);
create table if not exists public.workspace_members (
workspace_id uuid not null references public.workspaces (id) on delete cascade,
user_id uuid not null references public.profiles (id) on delete cascade,
role text not null check (role in ('owner', 'admin', 'gestor', 'agent')),
created_at timestamptz not null default now(),
primary key (workspace_id, user_id)
);
create table if not exists public.workspace_module_flags (
workspace_id uuid not null references public.workspaces (id) on delete cascade,
module_key text not null,
enabled boolean not null default false,
updated_at timestamptz not null default now(),
primary key (workspace_id, module_key)
);
create table if not exists public.audit_log (
id uuid primary key default gen_random_uuid(),
workspace_id uuid references public.workspaces (id) on delete cascade,
actor_id uuid references public.profiles (id),
action text not null,
resource_type text,
resource_id text,
result text not null check (result in ('success', 'denied', 'error')),
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create index if not exists audit_log_workspace_idx on public.audit_log (workspace_id, created_at desc);
+166
View File
@@ -0,0 +1,166 @@
-- Schema do Adaptador. Soft delete via status para não tornar a exclusão física irreversível.
create table if not exists public.spreadsheet_adapter_settings (
workspace_id uuid primary key references public.workspaces (id) on delete cascade,
allow_non_admin_model_upload boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists public.spreadsheet_models (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null references public.workspaces (id) on delete cascade,
name text not null,
original_filename text not null,
format text not null check (format in ('xlsx', 'csv')),
storage_key text not null,
content_sha256 text not null,
sheet_name text,
header_row integer not null default 1 check (header_row >= 1),
columns_schema jsonb not null,
created_by uuid not null references public.profiles (id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz,
status text not null default 'active' check (status in ('active', 'deleted'))
);
create unique index if not exists spreadsheet_models_active_name_idx
on public.spreadsheet_models (workspace_id, lower(name))
where status = 'active';
create index if not exists spreadsheet_models_workspace_idx
on public.spreadsheet_models (workspace_id, status);
create table if not exists public.spreadsheet_jobs (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null references public.workspaces (id) on delete cascade,
user_id uuid not null references public.profiles (id),
model_id uuid not null references public.spreadsheet_models (id) on delete restrict,
status text not null check (status in (
'draft', 'validating', 'ready', 'queued', 'processing', 'completed', 'failed', 'expired', 'cancelled'
)),
source_count integer not null default 0 check (source_count >= 0 and source_count <= 10),
current_source integer not null default 0,
output_format text check (output_format in ('xlsx', 'csv', 'pdf')),
progress jsonb not null default '{}'::jsonb,
error_code text,
idempotency_key text,
started_at timestamptz,
finished_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index if not exists spreadsheet_jobs_idempotency_idx
on public.spreadsheet_jobs (user_id, idempotency_key)
where idempotency_key is not null;
create index if not exists spreadsheet_jobs_workspace_user_idx
on public.spreadsheet_jobs (workspace_id, user_id, status);
create table if not exists public.spreadsheet_job_sources (
id uuid primary key default gen_random_uuid(),
job_id uuid not null references public.spreadsheet_jobs (id) on delete cascade,
source_name text not null,
temp_storage_key text not null,
selected_sheet text,
header_row integer not null default 1 check (header_row >= 1),
row_count integer not null default 0,
size_bytes integer not null check (size_bytes >= 0),
status text not null check (status in (
'pending', 'validated', 'invalid', 'mapped', 'processing', 'completed', 'failed', 'expired'
)),
columns_schema jsonb not null default '[]'::jsonb,
error_code text,
output_storage_key text,
output_csv_key text,
output_xlsx_key text,
process_idempotency_key text,
expires_at timestamptz not null,
processed_at timestamptz,
created_at timestamptz not null default now()
);
create index if not exists spreadsheet_job_sources_job_idx on public.spreadsheet_job_sources (job_id);
create index if not exists spreadsheet_job_sources_expires_idx on public.spreadsheet_job_sources (expires_at);
create table if not exists public.spreadsheet_job_mappings (
id uuid primary key default gen_random_uuid(),
job_source_id uuid not null references public.spreadsheet_job_sources (id) on delete cascade,
source_column text not null check (char_length(source_column) > 0),
model_column text not null check (char_length(model_column) > 0),
is_required boolean not null default false,
position integer not null check (position >= 0),
unique (job_source_id, source_column),
unique (job_source_id, model_column)
);
create or replace function public.jobs_model_same_workspace()
returns trigger
language plpgsql
as $$
declare
model_ws uuid;
begin
select workspace_id into model_ws from public.spreadsheet_models where id = new.model_id;
if model_ws is null or model_ws <> new.workspace_id then
raise exception 'MODEL_WORKSPACE_MISMATCH' using errcode = '23514';
end if;
if new.user_id is null then
raise exception 'JOB_USER_REQUIRED' using errcode = '23514';
end if;
return new;
end;
$$;
drop trigger if exists spreadsheet_jobs_workspace_guard on public.spreadsheet_jobs;
create trigger spreadsheet_jobs_workspace_guard
before insert or update of model_id, workspace_id on public.spreadsheet_jobs
for each row execute function public.jobs_model_same_workspace();
create or replace function public.enforce_active_model_limit()
returns trigger
language plpgsql
as $$
declare
total integer;
begin
if new.status = 'active' then
select count(*) into total
from public.spreadsheet_models
where workspace_id = new.workspace_id
and status = 'active'
and id <> new.id;
if total >= 10 then
raise exception 'MODEL_LIMIT' using errcode = '23514';
end if;
end if;
return new;
end;
$$;
drop trigger if exists spreadsheet_models_limit on public.spreadsheet_models;
create trigger spreadsheet_models_limit
before insert or update of status, workspace_id on public.spreadsheet_models
for each row execute function public.enforce_active_model_limit();
create or replace function public.enforce_source_limit()
returns trigger
language plpgsql
as $$
declare
total integer;
begin
select count(*) into total from public.spreadsheet_job_sources where job_id = new.job_id;
if total >= 10 then
raise exception 'SOURCE_LIMIT' using errcode = '23514';
end if;
return new;
end;
$$;
drop trigger if exists spreadsheet_sources_limit on public.spreadsheet_job_sources;
create trigger spreadsheet_sources_limit
before insert on public.spreadsheet_job_sources
for each row execute function public.enforce_source_limit();
+284
View File
@@ -0,0 +1,284 @@
create or replace function public.auth_uid()
returns uuid
language sql
stable
as $$
select nullif(current_setting('app.user_id', true), '')::uuid
$$;
create or replace function public.is_member(ws uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1 from public.workspace_members m
where m.workspace_id = ws and m.user_id = public.auth_uid()
)
$$;
create or replace function public.member_role(ws uuid)
returns text
language sql
stable
security definer
set search_path = public
as $$
select m.role from public.workspace_members m
where m.workspace_id = ws and m.user_id = public.auth_uid()
$$;
create or replace function public.module_enabled(ws uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select coalesce((
select f.enabled from public.workspace_module_flags f
where f.workspace_id = ws and f.module_key = 'spreadsheet_adapter'
), false)
$$;
create or replace function public.can_manage_adapter(ws uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select public.module_enabled(ws) and public.member_role(ws) in ('owner', 'admin')
$$;
create or replace function public.can_upload_model(ws uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select public.module_enabled(ws) and (
public.member_role(ws) in ('owner', 'admin')
or (
public.member_role(ws) in ('gestor', 'agent')
and coalesce((
select s.allow_non_admin_model_upload
from public.spreadsheet_adapter_settings s
where s.workspace_id = ws
), false)
)
)
$$;
create or replace function public.shares_workspace(other_user uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.workspace_members a
join public.workspace_members b on a.workspace_id = b.workspace_id
where a.user_id = public.auth_uid() and b.user_id = other_user
)
$$;
create or replace function public.model_has_active_job(model uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1 from public.spreadsheet_jobs j
where j.model_id = model
and j.status in ('validating', 'ready', 'queued', 'processing')
)
$$;
revoke all on function public.auth_uid() from public;
revoke all on function public.is_member(uuid) from public;
revoke all on function public.member_role(uuid) from public;
revoke all on function public.module_enabled(uuid) from public;
revoke all on function public.can_manage_adapter(uuid) from public;
revoke all on function public.can_upload_model(uuid) from public;
revoke all on function public.shares_workspace(uuid) from public;
revoke all on function public.model_has_active_job(uuid) from public;
grant execute on function public.auth_uid() to app_user;
grant execute on function public.is_member(uuid) to app_user;
grant execute on function public.member_role(uuid) to app_user;
grant execute on function public.module_enabled(uuid) to app_user;
grant execute on function public.can_manage_adapter(uuid) to app_user;
grant execute on function public.can_upload_model(uuid) to app_user;
grant execute on function public.shares_workspace(uuid) to app_user;
grant execute on function public.model_has_active_job(uuid) to app_user;
alter table public.profiles enable row level security;
alter table public.workspaces enable row level security;
alter table public.workspace_members enable row level security;
alter table public.workspace_module_flags enable row level security;
alter table public.audit_log enable row level security;
alter table public.spreadsheet_adapter_settings enable row level security;
alter table public.spreadsheet_models enable row level security;
alter table public.spreadsheet_jobs enable row level security;
alter table public.spreadsheet_job_sources enable row level security;
alter table public.spreadsheet_job_mappings enable row level security;
alter table public.profiles force row level security;
alter table public.workspaces force row level security;
alter table public.workspace_members force row level security;
alter table public.workspace_module_flags force row level security;
alter table public.audit_log force row level security;
alter table public.spreadsheet_adapter_settings force row level security;
alter table public.spreadsheet_models force row level security;
alter table public.spreadsheet_jobs force row level security;
alter table public.spreadsheet_job_sources force row level security;
alter table public.spreadsheet_job_mappings force row level security;
drop policy if exists profiles_select on public.profiles;
create policy profiles_select on public.profiles
for select to app_user
using (id = public.auth_uid() or public.shares_workspace(id));
drop policy if exists workspaces_select on public.workspaces;
create policy workspaces_select on public.workspaces
for select to app_user
using (public.is_member(id));
drop policy if exists members_select on public.workspace_members;
create policy members_select on public.workspace_members
for select to app_user
using (user_id = public.auth_uid() or public.is_member(workspace_id));
drop policy if exists flags_select on public.workspace_module_flags;
create policy flags_select on public.workspace_module_flags
for select to app_user
using (public.is_member(workspace_id));
drop policy if exists audit_insert on public.audit_log;
create policy audit_insert on public.audit_log
for insert to app_user
with check (actor_id = public.auth_uid() and (workspace_id is null or public.is_member(workspace_id)));
drop policy if exists audit_select on public.audit_log;
create policy audit_select on public.audit_log
for select to app_user
using (public.can_manage_adapter(workspace_id));
drop policy if exists settings_select on public.spreadsheet_adapter_settings;
create policy settings_select on public.spreadsheet_adapter_settings
for select to app_user
using (public.is_member(workspace_id) and public.module_enabled(workspace_id));
drop policy if exists settings_insert on public.spreadsheet_adapter_settings;
create policy settings_insert on public.spreadsheet_adapter_settings
for insert to app_user
with check (public.can_manage_adapter(workspace_id));
drop policy if exists settings_update on public.spreadsheet_adapter_settings;
create policy settings_update on public.spreadsheet_adapter_settings
for update to app_user
using (public.can_manage_adapter(workspace_id))
with check (public.can_manage_adapter(workspace_id));
drop policy if exists models_select on public.spreadsheet_models;
create policy models_select on public.spreadsheet_models
for select to app_user
using (public.is_member(workspace_id) and public.module_enabled(workspace_id));
drop policy if exists models_insert on public.spreadsheet_models;
create policy models_insert on public.spreadsheet_models
for insert to app_user
with check (
public.can_upload_model(workspace_id)
and created_by = public.auth_uid()
);
drop policy if exists models_update on public.spreadsheet_models;
create policy models_update on public.spreadsheet_models
for update to app_user
using (public.can_manage_adapter(workspace_id))
with check (public.can_manage_adapter(workspace_id));
drop policy if exists jobs_all on public.spreadsheet_jobs;
create policy jobs_select on public.spreadsheet_jobs
for select to app_user
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
create policy jobs_insert on public.spreadsheet_jobs
for insert to app_user
with check (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
create policy jobs_update on public.spreadsheet_jobs
for update to app_user
using (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id))
with check (user_id = public.auth_uid() and public.is_member(workspace_id) and public.module_enabled(workspace_id));
drop policy if exists sources_select on public.spreadsheet_job_sources;
create policy sources_select on public.spreadsheet_job_sources
for select to app_user
using (exists (
select 1 from public.spreadsheet_jobs j
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
));
create policy sources_insert on public.spreadsheet_job_sources
for insert to app_user
with check (exists (
select 1 from public.spreadsheet_jobs j
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
));
create policy sources_update on public.spreadsheet_job_sources
for update to app_user
using (exists (
select 1 from public.spreadsheet_jobs j
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
))
with check (exists (
select 1 from public.spreadsheet_jobs j
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
));
create policy sources_delete on public.spreadsheet_job_sources
for delete to app_user
using (exists (
select 1 from public.spreadsheet_jobs j
where j.id = job_id and j.user_id = public.auth_uid() and public.module_enabled(j.workspace_id)
));
drop policy if exists mappings_select on public.spreadsheet_job_mappings;
create policy mappings_select on public.spreadsheet_job_mappings
for select to app_user
using (exists (
select 1 from public.spreadsheet_job_sources s
join public.spreadsheet_jobs j on j.id = s.job_id
where s.id = job_source_id and j.user_id = public.auth_uid()
));
create policy mappings_insert on public.spreadsheet_job_mappings
for insert to app_user
with check (exists (
select 1 from public.spreadsheet_job_sources s
join public.spreadsheet_jobs j on j.id = s.job_id
where s.id = job_source_id and j.user_id = public.auth_uid()
));
create policy mappings_delete on public.spreadsheet_job_mappings
for delete to app_user
using (exists (
select 1 from public.spreadsheet_job_sources s
join public.spreadsheet_jobs j on j.id = s.job_id
where s.id = job_source_id and j.user_id = public.auth_uid()
));
grant usage on schema public to app_user;
grant select, insert, update, delete on all tables in schema public to app_user;
grant usage, select on all sequences in schema public to app_user;
@@ -0,0 +1,15 @@
-- Rollback do módulo Adaptador.
-- Não remove profiles, workspaces, members, flags ou audit_log do Elo Pro.
-- Funções de autorização usadas também pelo audit_log permanecem.
drop table if exists public.spreadsheet_job_mappings;
drop table if exists public.spreadsheet_job_sources;
drop table if exists public.spreadsheet_jobs;
drop table if exists public.spreadsheet_models;
drop table if exists public.spreadsheet_adapter_settings;
drop function if exists public.enforce_source_limit();
drop function if exists public.enforce_active_model_limit();
drop function if exists public.jobs_model_same_workspace();
drop function if exists public.model_has_active_job(uuid);
drop function if exists public.can_upload_model(uuid);
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+31
View File
@@ -0,0 +1,31 @@
import type { NextConfig } from "next";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.dirname(fileURLToPath(import.meta.url));
const nextConfig: NextConfig = {
turbopack: { root },
serverExternalPackages: [
"exceljs",
"@react-pdf/renderer",
"postgres",
"ioredis",
"@electric-sql/pglite",
],
poweredByHeader: false,
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Frame-Options", value: "DENY" },
],
},
];
},
};
export default nextConfig;
+12339
View File
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
{
"name": "adaptador-planilhas",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"migrate": "tsx scripts/migrate.ts",
"seed": "tsx scripts/seed-dev.ts",
"worker": "tsx scripts/worker.ts",
"cleanup": "tsx scripts/cleanup-temp.ts",
"flag": "tsx scripts/set-flag.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@electric-sql/pglite": "^0.3.14",
"@react-pdf/renderer": "^4.3.1",
"@sentry/nextjs": "^10.15.0",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.75.0",
"@tanstack/react-query": "^5.90.2",
"exceljs": "^4.4.0",
"ioredis": "^5.8.1",
"lucide-react": "^0.544.0",
"next": "^16.0.0",
"postgres": "^3.4.7",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"server-only": "^0.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.14",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.6.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.37.0",
"eslint-config-next": "^16.0.0",
"jsdom": "^27.0.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+12
View File
@@ -0,0 +1,12 @@
import path from "node:path";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { MemoryCoordinator } from "../src/infra/redis/memory";
import { createStorage } from "../src/infra/storage";
import { cleanupExpired } from "../src/server/spreadsheet-adapter/process-job";
const databaseUrl = process.env.DATABASE_URL;
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: new MemoryCoordinator() });
console.log(`temporários expirados: ${removed}`);
await db.close();
+10
View File
@@ -0,0 +1,10 @@
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { migrate } from "../src/infra/db/migrate";
import path from "node:path";
const databaseUrl = process.env.DATABASE_URL;
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
const ran = await migrate(db);
console.log(ran.length ? `migrations: ${ran.join(", ")}` : "migrations: nada pendente");
await db.close();
+12
View File
@@ -0,0 +1,12 @@
import path from "node:path";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { migrate } from "../src/infra/db/migrate";
import { ensureDevFixtures } from "../src/dev/fixtures";
const databaseUrl = process.env.DATABASE_URL;
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
await migrate(db);
await ensureDevFixtures(db);
console.log("fixtures de desenvolvimento conferidas");
await db.close();
+22
View File
@@ -0,0 +1,22 @@
import path from "node:path";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
const workspaceId = process.argv[2];
const enabled = process.argv[3] !== "off";
if (!workspaceId) {
console.error("uso: npm run flag -- <workspace_id> on|off");
process.exit(1);
}
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(
`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],
),
);
console.log(`spreadsheet_adapter ${enabled ? "ligado" : "desligado"}`);
await db.close();
+63
View File
@@ -0,0 +1,63 @@
import path from "node:path";
import { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres";
import { createCoordinator } from "../src/infra/redis";
import { createStorage } from "../src/infra/storage";
import { migrate } from "../src/infra/db/migrate";
import { cleanupExpired, listQueuedJobIds, readJobForWorker, recoverStaleJobs, runProcess } from "../src/server/spreadsheet-adapter/process-job";
import { asJson } from "../src/server/spreadsheet-adapter/db-utils";
import type { JobProgress, RequestContext, Role } from "../src/domain/spreadsheet-adapter/types";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl && process.env.NODE_ENV === "production") {
throw new Error("DATABASE_URL é obrigatório para o worker.");
}
const db = databaseUrl ? createPostgresDb(databaseUrl) : await createFileDb(path.join(process.cwd(), ".data", "pglite"));
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)) {
const rows = await readJobForWorker(deps, jobId);
const row = rows[0];
if (!row) continue;
const progress = asJson<JobProgress | null>(row.progress, null);
if (!progress?.sourceId) continue;
const profile = await db.withService((query) =>
query.query<{ display_name: string; is_superadmin: boolean; role: Role; workspace_name: string; module_enabled: boolean; allow_upload: boolean }>(
`select p.display_name, p.is_superadmin, w.name as workspace_name, m.role,
coalesce(f.enabled, false) as module_enabled,
coalesce(s.allow_non_admin_model_upload, false) as allow_upload
from public.profiles p
join public.workspace_members m on m.user_id = p.id and m.workspace_id = $2
join public.workspaces w on w.id = $2
left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter'
left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id
where p.id = $1`,
[row.user_id, row.workspace_id],
),
);
const found = profile[0];
if (!found) continue;
const ctx: RequestContext = {
userId: row.user_id,
displayName: found.display_name,
isSuperadmin: found.is_superadmin,
workspaceId: row.workspace_id,
workspaceName: found.workspace_name,
role: found.role,
moduleEnabled: found.module_enabled,
allowNonAdminModelUpload: found.allow_upload,
};
await runProcess(deps, ctx, jobId, progress.sourceId).catch((error) => {
console.error("worker", error instanceof Error ? error.name : "error");
});
}
}
await tick();
setInterval(() => {
void tick();
}, 2000);
+26
View File
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { SettingsPanel } from "@/components/settings-panel";
import { AppShell } from "@/components/shell";
import { ErrorState } from "@/components/states";
import { canManageSpreadsheetModels } from "@/domain/spreadsheet-adapter/permissions";
import { resolveAuth } from "@/server/auth/context";
export const dynamic = "force-dynamic";
export default async function SettingsPage() {
const auth = await resolveAuth();
if (auth.kind === "anonymous") redirect("/login");
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
if (!canManageSpreadsheetModels(auth.ctx)) {
return (
<AppShell ctx={auth.ctx}>
<ErrorState message="Somente owner ou admin acessam as configurações do Adaptador." />
</AppShell>
);
}
return (
<AppShell ctx={auth.ctx}>
<SettingsPanel ctx={auth.ctx} />
</AppShell>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { useEffect } from "react";
export default function AdapterError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
console.error("adaptador", error.digest ?? error.name);
}, [error]);
return (
<main className="p-8">
<h1 className="text-2xl font-semibold">Algo saiu do esperado</h1>
<p className="mt-2 text-sm">A operação foi interrompida. Você pode tentar de novo sem perder a sessão do Elo Pro.</p>
{error.digest ? <p className="mt-2 text-xs">Referência {error.digest}</p> : null}
<button type="button" className="mt-4 underline" onClick={reset}>Tentar novamente</button>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import { Providers } from "@/components/providers";
export const dynamic = "force-dynamic";
export default function AdapterLayout({ children }: { children: ReactNode }) {
return <Providers>{children}</Providers>;
}
+9
View File
@@ -0,0 +1,9 @@
import { LoadingBlock } from "@/components/states";
export default function Loading() {
return (
<main className="p-8">
<LoadingBlock label="Abrindo o Adaptador" />
</main>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import { Operation } from "@/components/operation";
import { AppShell } from "@/components/shell";
import { resolveAuth } from "@/server/auth/context";
export const dynamic = "force-dynamic";
export default async function OperationPage() {
const auth = await resolveAuth();
if (auth.kind === "anonymous") redirect("/login");
if (auth.kind !== "ok" || !auth.ctx.moduleEnabled) redirect("/adaptador");
return (
<AppShell ctx={auth.ctx}>
<Operation ctx={auth.ctx} />
</AppShell>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation";
import { Dashboard } from "@/components/dashboard";
import { AppShell } from "@/components/shell";
import { ErrorState } from "@/components/states";
import { Card } from "@/components/ui";
import { resolveAuth } from "@/server/auth/context";
export const dynamic = "force-dynamic";
export default async function AdapterHome() {
const auth = await resolveAuth();
if (auth.kind === "anonymous") redirect("/login");
if (auth.kind !== "ok") {
return (
<main className="p-8">
<ErrorState message={auth.kind === "no_workspace" ? "O Elo Pro ainda não informou o workspace ativo." : "Você não é membro deste workspace."} />
</main>
);
}
if (!auth.ctx.moduleEnabled) {
return (
<AppShell ctx={auth.ctx}>
<Card>
<h1 className="text-2xl font-semibold">Módulo não habilitado</h1>
<p className="mt-2 text-sm">O Adaptador está desligado para este workspace. A habilitação é feita na plataforma Elo Pro.</p>
</Card>
</AppShell>
);
}
return (
<AppShell ctx={auth.ctx}>
<Dashboard ctx={auth.ctx} />
</AppShell>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";
export const runtime = "nodejs";
export async function POST() {
const jar = await cookies();
jar.delete("adapter_dev_session");
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (url && key) {
const supabase = createServerClient(url, key, {
cookies: {
getAll: () => jar.getAll(),
setAll: (items) => {
for (const item of items) jar.set(item.name, item.value, item.options);
},
},
});
await supabase.auth.signOut();
}
return NextResponse.json({ ok: true });
}
+29
View File
@@ -0,0 +1,29 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { getDb } from "@/infra/db/client";
import { ensureDevFixtures } from "@/dev/fixtures";
import { devAuthEnabled, signDevSession } from "@/server/auth/dev-session";
export const runtime = "nodejs";
export async function POST(request: Request) {
if (!devAuthEnabled()) return NextResponse.json({ error: { code: "NOT_FOUND", message: "Indisponível." } }, { status: 404 });
const body = (await request.json()) as { userId?: string; workspaceId?: string };
if (!body.userId || !body.workspaceId) {
return NextResponse.json({ error: { code: "UNAUTHENTICATED", message: "Identidade incompleta." } }, { status: 400 });
}
const db = await getDb();
await ensureDevFixtures(db);
const members = await db.withService((query) =>
query.query(`select role from public.workspace_members where user_id = $1 and workspace_id = $2`, [body.userId, body.workspaceId]),
);
if (!members[0]) return NextResponse.json({ error: { code: "WORKSPACE_FORBIDDEN", message: "Vínculo não encontrado." } }, { status: 403 });
const jar = await cookies();
jar.set("adapter_dev_session", signDevSession(body.userId, body.workspaceId), {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 8,
});
return NextResponse.json({ ok: true });
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { getDb } from "@/infra/db/client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const db = await getDb();
await db.withService((query) => query.query("select 1 as ok"));
return NextResponse.json({
ok: true,
module: "spreadsheet-adapter",
redis: process.env.REDIS_URL ? "configured" : "not_configured",
storage: process.env.SUPABASE_STORAGE_BUCKET ? "supabase" : "unconfigured",
});
} catch {
return NextResponse.json({ ok: false }, { status: 503 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { AdapterError, isAdapterError } from "@/domain/spreadsheet-adapter/errors";
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
import { resolveAuth } from "@/server/auth/context";
import type { AppDeps } from "@/server/spreadsheet-adapter/deps";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export function errorResponse(error: unknown): NextResponse {
if (isAdapterError(error)) {
return NextResponse.json(
{ error: { code: error.code, message: error.message, 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 });
}
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 !== "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() };
}
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { errorResponse, requireReady } from "../../../_lib";
import { downloadOutput } from "@/server/spreadsheet-adapter/process-job";
import type { OutputFormat } from "@/domain/spreadsheet-adapter/types";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { id } = await context.params;
const url = new URL(request.url);
const format = url.searchParams.get("format") as OutputFormat | null;
const sourceId = url.searchParams.get("sourceId");
if (!sourceId || (format !== "xlsx" && format !== "csv" && format !== "pdf")) {
return NextResponse.json({ error: { code: "INVALID_FORMAT", message: "Escolha XLSX, CSV ou PDF." } }, { status: 400 });
}
const file = await downloadOutput(deps, ctx, id, sourceId, format);
return new NextResponse(new Uint8Array(file.body), {
headers: {
"Content-Type": file.contentType,
"Content-Disposition": `attachment; filename="${file.filename}"`,
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
return errorResponse(error);
}
}
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { errorResponse, requireReady } from "../../../_lib";
import { rejectWorkspaceForgery, saveMappings } from "@/server/spreadsheet-adapter/service";
import type { MappingInput } from "@/server/spreadsheet-adapter/dto";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function PUT(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { id } = await context.params;
const body = (await request.json()) as { sourceId?: string; mappings?: MappingInput[]; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
if (!body.sourceId || !Array.isArray(body.mappings)) {
return NextResponse.json({ error: { code: "MAPPING_INCOMPLETE", message: "Informe o mapeamento." } }, { status: 400 });
}
return NextResponse.json({ job: await saveMappings(deps, ctx, id, body.sourceId, body.mappings) });
} catch (error) {
return errorResponse(error);
}
}
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { errorResponse, requireReady } from "../../../_lib";
import { previewSource, rejectWorkspaceForgery } from "@/server/spreadsheet-adapter/service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { id } = await context.params;
const body = (await request.json()) as { sourceId?: string; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
if (!body.sourceId) return NextResponse.json({ error: { code: "NOT_FOUND", message: "Informe a fonte." } }, { status: 400 });
return NextResponse.json(await previewSource(deps, ctx, id, body.sourceId));
} catch (error) {
return errorResponse(error);
}
}
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { errorResponse, requireReady } from "../../../_lib";
import { rejectWorkspaceForgery, startProcess } from "@/server/spreadsheet-adapter/service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { id } = await context.params;
const body = (await request.json()) as { sourceId?: string; idempotencyKey?: string; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
if (!body.sourceId || !body.idempotencyKey) {
return NextResponse.json({ error: { code: "IDEMPOTENCY_REQUIRED", message: "Informe a fonte e a chave de idempotência." } }, { status: 400 });
}
const job = await startProcess(deps, ctx, id, body.sourceId, body.idempotencyKey);
return NextResponse.json({ job });
} catch (error) {
return errorResponse(error);
}
}
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { errorResponse, requireReady } from "../../_lib";
import { getJob } from "@/server/spreadsheet-adapter/service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { id } = await context.params;
return NextResponse.json({ job: await getJob(deps, ctx, id) });
} catch (error) {
return errorResponse(error);
}
}
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { errorResponse, requireReady } from "../../../../_lib";
import { rejectWorkspaceForgery, removeSource, updateSource } from "@/server/spreadsheet-adapter/service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function PATCH(request: Request, context: { params: Promise<{ id: string; sourceId: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { id, sourceId } = await context.params;
const body = (await request.json()) as { sheet?: string | null; headerRow?: number; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
const result = await updateSource(deps, ctx, id, sourceId, { sheet: body.sheet, headerRow: body.headerRow });
return NextResponse.json(result, { status: result.httpStatus });
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(request: Request, context: { params: Promise<{ id: string; sourceId: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { id, sourceId } = await context.params;
const body = await request.json().catch(() => ({}));
await rejectWorkspaceForgery(deps, ctx, (body as { workspace_id?: string }).workspace_id);
return NextResponse.json({ job: await removeSource(deps, ctx, id, sourceId) });
} catch (error) {
return errorResponse(error);
}
}
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { errorResponse, requireReady } from "../_lib";
import { createJob, rejectWorkspaceForgery } from "@/server/spreadsheet-adapter/service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
const { ctx, deps } = await requireReady();
const body = (await request.json()) as { modelId?: string; idempotencyKey?: string; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
if (!body.modelId) return NextResponse.json({ error: { code: "NOT_FOUND", message: "Selecione um modelo." } }, { status: 400 });
const job = await createJob(deps, ctx, body.modelId, body.idempotencyKey);
return NextResponse.json({ job }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
}
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { errorResponse, requireReady } from "../../_lib";
import { deleteModel, rejectWorkspaceForgery } from "@/server/spreadsheet-adapter/service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
try {
const { ctx, deps } = await requireReady();
const { id } = await context.params;
const body = await request.json().catch(() => ({}));
await rejectWorkspaceForgery(deps, ctx, (body as { workspace_id?: string }).workspace_id);
await deleteModel(deps, ctx, id);
return NextResponse.json({ ok: true });
} catch (error) {
return errorResponse(error);
}
}
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { errorResponse, requireReady } from "../_lib";
import { createModel, listModels, rejectWorkspaceForgery } from "@/server/spreadsheet-adapter/service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const { ctx, deps } = await requireReady();
return NextResponse.json({ models: await listModels(deps, ctx) });
} catch (error) {
return errorResponse(error);
}
}
export async function POST(request: Request) {
try {
const { ctx, deps } = await requireReady();
const form = await request.formData();
await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id"));
const file = form.get("file");
if (!(file instanceof File)) return errorResponse(new Error("arquivo"));
const requiredRaw = form.get("requiredColumns");
const model = await createModel(deps, ctx, {
name: String(form.get("name") ?? ""),
filename: file.name,
bytes: Buffer.from(await file.arrayBuffer()),
sheet: form.get("sheet") ? String(form.get("sheet")) : null,
headerRow: Number(form.get("headerRow") ?? 1),
requiredColumns: requiredRaw ? (JSON.parse(String(requiredRaw)) as string[]) : undefined,
});
return NextResponse.json({ model }, { status: 201 });
} catch (error) {
if (error instanceof Error && error.message === "arquivo") {
return NextResponse.json({ error: { code: "EMPTY_FILE", message: "Selecione um arquivo." } }, { status: 400 });
}
return errorResponse(error);
}
}
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { errorResponse, requireReady } from "../_lib";
import { getSettings, rejectWorkspaceForgery, updateSettings } from "@/server/spreadsheet-adapter/service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const { ctx, deps } = await requireReady();
return NextResponse.json(await getSettings(deps, ctx));
} catch (error) {
return errorResponse(error);
}
}
export async function PATCH(request: Request) {
try {
const { ctx, deps } = await requireReady();
const body = (await request.json()) as { allowNonAdminModelUpload?: boolean; workspace_id?: string };
await rejectWorkspaceForgery(deps, ctx, body.workspace_id);
if (typeof body.allowNonAdminModelUpload !== "boolean") {
return NextResponse.json({ error: { code: "INVALID_SETTING", message: "Informe o valor da configuração." } }, { status: 400 });
}
await updateSettings(deps, ctx, body.allowNonAdminModelUpload);
return NextResponse.json(await getSettings(deps, ctx));
} catch (error) {
return errorResponse(error);
}
}
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { errorResponse, requireReady } from "../../_lib";
import { addSource, rejectWorkspaceForgery } from "@/server/spreadsheet-adapter/service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
const { ctx, deps } = await requireReady();
const form = await request.formData();
await rejectWorkspaceForgery(deps, ctx, form.get("workspace_id"));
const file = form.get("file");
const jobId = String(form.get("jobId") ?? "");
if (!(file instanceof File) || !jobId) {
return NextResponse.json({ error: { code: "EMPTY_FILE", message: "Envie o arquivo e a operação." } }, { status: 400 });
}
const result = await addSource(
deps,
ctx,
jobId,
{ filename: file.name, bytes: Buffer.from(await file.arrayBuffer()) },
form.get("sheet") ? String(form.get("sheet")) : null,
Number(form.get("headerRow") ?? 1),
);
return NextResponse.json(result, { status: result.httpStatus });
} catch (error) {
return errorResponse(error);
}
}
+31
View File
@@ -0,0 +1,31 @@
@import "tailwindcss";
@theme {
--font-sans: var(--font-manrope), "Manrope", sans-serif;
--color-cloud: #fbfbfe;
--color-ink: #071b45;
--color-action: #0b3d91;
--color-bright: #2f8fff;
--color-aqua: #00c2a8;
--color-warning: #f39a2d;
--color-danger: #e63946;
--color-line: #e4e7f2;
}
body {
background: var(--color-cloud);
color: var(--color-ink);
font-family: var(--font-sans);
}
:focus-visible {
outline: 2px solid var(--color-bright);
outline-offset: 2px;
}
button:disabled,
input:disabled,
select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { Manrope } from "next/font/google";
import "./globals.css";
const manrope = Manrope({ subsets: ["latin"], variable: "--font-manrope" });
export const metadata: Metadata = {
title: "Adaptador de Planilhas · Elo Pro",
description: "Módulo Elo Pro para padronizar planilhas sem alterar o modelo original.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR">
<body className={manrope.variable}>{children}</body>
</html>
);
}
+23
View File
@@ -0,0 +1,23 @@
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 { DevLogin } from "@/components/dev-login";
export const dynamic = "force-dynamic";
export default async function LoginPage() {
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);
return (
<main className="mx-auto max-w-xl p-8">
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
<h1 className="mt-2 text-3xl font-semibold">Entrar no ecossistema</h1>
<p className="mt-3 text-sm text-ink/80">
O Adaptador não cria senha, cadastro ou sessão própria. A identidade é a do Elo Pro.
</p>
{devAuthEnabled() ? <DevLogin identities={DEV_IDENTITIES} /> : <p className="mt-6 text-sm">Defina ELO_PRO_LOGIN_URL com o endereço do login central.</p>}
</main>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/adaptador");
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
import { queryKeys } from "@/domain/spreadsheet-adapter/query-keys";
import type { ModelDto } from "@/server/spreadsheet-adapter/dto";
import { Button, Card, SemanticMarker } from "./ui";
import { EmptyState, ErrorState, LoadingBlock } from "./states";
export function Dashboard({ ctx }: { ctx: RequestContext }) {
const models = useQuery({
queryKey: queryKeys.models(ctx.workspaceId),
queryFn: async () => {
const response = await fetch("/api/spreadsheet-adapter/models");
const data = (await response.json()) as { models?: ModelDto[]; error?: { message: string } };
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao listar modelos");
return data.models ?? [];
},
});
return (
<div className="space-y-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold">Adaptador de Planilhas</h1>
<p className="mt-2 max-w-2xl text-sm text-ink/80">
Receba planilhas de origens diferentes, valide a estrutura, mapeie as colunas para um modelo e gere um arquivo novo sem alterar o modelo original.
</p>
</div>
<Link href="/adaptador/operacao">
<Button>Iniciar adaptação</Button>
</Link>
</header>
{models.isLoading ? <LoadingBlock label="Carregando modelos" /> : null}
{models.isError ? <ErrorState message={models.error.message} onRetry={() => models.refetch()} /> : null}
{models.data && models.data.length === 0 ? (
<EmptyState title="Nenhum modelo ativo" description="Cadastre um modelo XLSX ou CSV para começar uma adaptação." action={<Link href="/adaptador/configuracoes"><Button variant="secondary">Gerir modelos</Button></Link>} />
) : null}
{models.data && models.data.length > 0 ? (
<div className="grid gap-3">
{models.data.map((model) => (
<Card key={model.id}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">{model.name}</h2>
<p className="text-sm text-ink/70">{model.columns.length} colunas · {model.format.toUpperCase()} · {new Date(model.createdAt).toLocaleDateString("pt-BR")} · {model.createdByName}</p>
</div>
<SemanticMarker tone="info">{model.columns.filter((column) => column.required).length} obrigatórias</SemanticMarker>
</div>
</Card>
))}
</div>
) : null}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "./ui";
export function DevLogin({ identities }: { identities: { userId: string; workspaceId: string; label: string }[] }) {
const router = useRouter();
const [message, setMessage] = useState<string | null>(null);
return (
<div className="mt-6 space-y-2">
<p className="text-sm">Ambiente local: escolha uma identidade já vinculada a um workspace. Não há campo de senha.</p>
{identities.map((identity) => (
<Button
key={identity.userId + identity.workspaceId}
variant="secondary"
className="w-full"
onClick={async () => {
const response = await fetch("/api/dev/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: identity.userId, workspaceId: identity.workspaceId }),
});
if (!response.ok) {
setMessage("Não foi possível abrir a sessão local.");
return;
}
router.push("/adaptador");
router.refresh();
}}
>
{identity.label}
</Button>
))}
{message ? <p role="alert">{message}</p> : null}
</div>
);
}
+327
View File
@@ -0,0 +1,327 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { DndContext, type DragEndEvent, useDraggable, useDroppable } from "@dnd-kit/core";
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
import { queryKeys } from "@/domain/spreadsheet-adapter/query-keys";
import { validateMappings } from "@/domain/spreadsheet-adapter/mapping";
import type { PreviewContract } from "@/domain/spreadsheet-adapter/preview";
import type { JobDto, ModelDto } from "@/server/spreadsheet-adapter/dto";
import { Button, Card, Dialog, JourneyTrail, Select, SemanticMarker, TextInput } from "./ui";
import { EmptyState, ErrorState, LoadingBlock } from "./states";
async function readJson<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}
/* eslint-disable react-hooks/refs -- setNodeRef e listeners são a API de render do @dnd-kit */
function SourceChip({ name }: { name: string }) {
const drag = useDraggable({ id: `source:${name}` });
return (
<button type="button" ref={drag.setNodeRef} {...drag.listeners} {...drag.attributes} className="rounded-full border border-line bg-cloud px-3 py-1 text-sm">
{name}
</button>
);
}
function ModelDrop({ name, required, value, options, onChange }: { name: string; required: boolean; value: string; options: string[]; onChange: (value: string) => void }) {
const drop = useDroppable({ id: `model:${name}` });
return (
<div ref={drop.setNodeRef} className={`rounded-[14px] border p-3 ${drop.isOver ? "border-bright" : "border-line"}`}>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="font-medium">{name}</span>
{required ? <SemanticMarker tone="warning">Obrigatória</SemanticMarker> : <span className="text-xs">Opcional</span>}
</div>
<label className="text-xs" htmlFor={`map-${name}`}>Coluna da fonte para {name}</label>
<Select id={`map-${name}`} value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">Não mapear</option>
{options.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</Select>
</div>
);
}
/* eslint-enable react-hooks/refs */
export function Operation({ ctx }: { ctx: RequestContext }) {
const client = useQueryClient();
const [jobId, setJobId] = useState<string | null>(null);
const [step, setStep] = useState(0);
const [message, setMessage] = useState<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 }[]>([]);
const [preview, setPreview] = useState<PreviewContract | null>(null);
const [activeSource, setActiveSource] = useState<string | null>(null);
const models = useQuery({
queryKey: queryKeys.models(ctx.workspaceId),
queryFn: async () => {
const response = await fetch("/api/spreadsheet-adapter/models");
const data = await readJson<{ models: ModelDto[]; error?: { message: string } }>(response);
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao listar modelos");
return data.models;
},
});
const job = useQuery({
queryKey: queryKeys.job(ctx.workspaceId, jobId ?? "none"),
enabled: Boolean(jobId),
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "queued" || status === "processing" ? 1000 : false;
},
queryFn: async () => {
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}`);
const data = await readJson<{ job: JobDto; error?: { message: string } }>(response);
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao consultar a operação");
return data.job;
},
});
const source = job.data?.sources.find((item) => item.id === activeSource) ?? job.data?.sources.find((item) => item.status !== "completed");
const validation = useMemo(() => {
if (!job.data || !source) return null;
return validateMappings(job.data.model.columns, source.columns.map((column) => column.name), mappings);
}, [job.data, source, mappings]);
function assign(modelColumn: string, sourceColumn: string) {
setMappings((current) => {
const next = current.filter((item) => item.modelColumn !== modelColumn && item.sourceColumn !== sourceColumn);
if (sourceColumn) next.push({ sourceColumn, modelColumn });
return next;
});
}
async function start(modelId: string) {
setMessage(null);
const response = await fetch("/api/spreadsheet-adapter/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ modelId, idempotencyKey: crypto.randomUUID() }),
});
const data = await readJson<{ job?: JobDto; error?: { message: string } }>(response);
if (!response.ok || !data.job) {
setMessage(data.error?.message ?? "Não foi possível abrir a operação");
return;
}
setJobId(data.job.id);
setStep(1);
await client.invalidateQueries({ queryKey: queryKeys.job(ctx.workspaceId, data.job.id) });
}
async function upload(file: File) {
if (!jobId) return;
const form = new FormData();
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);
if (data.error) {
setMessage(data.error.message);
return;
}
setMessage(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();
}
const saveMap = useMutation({
mutationFn: async () => {
if (!jobId || !source) return;
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}/mappings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: source.id, mappings }),
});
const data = await readJson<{ error?: { message: string } }>(response);
if (!response.ok) throw new Error(data.error?.message ?? "Mapeamento rejeitado");
},
onSuccess: async () => {
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: source?.id }),
});
const data = await readJson<PreviewContract & { error?: { message: string } }>(response);
if (!response.ok) {
setMessage(data.error?.message ?? "Prévia indisponível");
return;
}
setPreview(data);
setStep(3);
},
});
async function processCurrent() {
if (!jobId || !source) return;
setStep(4);
const response = await fetch(`/api/spreadsheet-adapter/jobs/${jobId}/process`, {
method: "POST",
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");
await job.refetch();
setStep(5);
}
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-semibold">Nova adaptação</h1>
<p className="mt-2 text-sm text-ink/75">Uma transferência por vez. A prévia é obrigatória antes de gerar o arquivo.</p>
</header>
<JourneyTrail steps={["Modelo", "Fontes", "Mapeamento", "Revisão", "Processamento", "Resultado"]} current={step} />
{message ? <ErrorState message={message} /> : null}
{step === 0 ? (
<Card>
{models.isLoading ? <LoadingBlock /> : null}
{models.isError ? <ErrorState message={models.error.message} onRetry={() => models.refetch()} /> : null}
{models.data?.length === 0 ? <EmptyState title="Sem modelos" description="Peça a um administrador para cadastrar um modelo ou habilite o upload operacional." /> : null}
<div className="grid gap-3">
{models.data?.map((model) => (
<div key={model.id} className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold">{model.name}</p>
<p className="text-sm text-ink/70">{model.columns.map((column) => column.name).join(", ")}</p>
</div>
<Button onClick={() => start(model.id)}>Usar este modelo</Button>
</div>
))}
</div>
</Card>
) : null}
{step === 1 && job.data ? (
<Card>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold">Fontes de {job.data.model.name}</h2>
<Button variant="ghost" onClick={() => setCriteria(true)}>Ver critérios para upload</Button>
</div>
<label className="block text-sm font-medium" htmlFor="fontes">Enviar XLSX ou CSV</label>
<input id="fontes" className="mt-2 block w-full" type="file" accept=".xlsx,.csv,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" multiple onChange={(event) => {
const files = [...(event.target.files ?? [])];
files.forEach((file) => void upload(file));
event.target.value = "";
}} />
<ul className="mt-4 space-y-3">
{job.data.sources.map((item) => (
<li key={item.id} className="rounded-[14px] border border-line p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<strong>{item.sourceName}</strong>
<SemanticMarker tone={item.status === "invalid" || item.status === "pending" ? "error" : "success"}>{item.status}</SemanticMarker>
</div>
<label className="mt-2 block text-xs" htmlFor={`header-${item.id}`}>Linha do cabeçalho</label>
<TextInput id={`header-${item.id}`} type="number" min={1} defaultValue={item.headerRow} onBlur={(event) => {
void fetch(`/api/spreadsheet-adapter/jobs/${job.data?.id}/sources/${item.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ headerRow: Number(event.target.value), sheet: item.selectedSheet }),
}).then(() => job.refetch());
}} />
<Button variant="ghost" onClick={() => fetch(`/api/spreadsheet-adapter/jobs/${job.data?.id}/sources/${item.id}`, { method: "DELETE" }).then(() => job.refetch())}>Remover</Button>
</li>
))}
</ul>
<Button className="mt-4" disabled={!job.data.sources.some((item) => item.status === "validated" || item.status === "mapped")} onClick={() => {
const next = job.data?.sources.find((item) => item.status === "validated" || item.status === "mapped");
if (!next) return;
setActiveSource(next.id);
setMappings(next.mappings.map((item) => ({ sourceColumn: item.sourceColumn, modelColumn: item.modelColumn })));
setStep(2);
}}>Mapear fonte</Button>
</Card>
) : null}
{step === 2 && job.data && source ? (
<Card>
<h2 className="mb-2 text-lg font-semibold">Mapear {source.sourceName}</h2>
<p className="mb-4 text-sm">Arraste uma coluna ou use o select. As duas formas gravam o mesmo mapeamento 1:1.</p>
<DndContext onDragEnd={(event: DragEndEvent) => {
const sourceName = String(event.active.id).replace("source:", "");
const modelName = String(event.over?.id ?? "").replace("model:", "");
if (modelName) assign(modelName, sourceName);
}}>
<div className="mb-4 flex flex-wrap gap-2">
{source.columns.map((column) => <SourceChip key={column.name} name={column.name} />)}
</div>
<div className="grid gap-3 md:grid-cols-2">
{job.data.model.columns.map((column) => (
<ModelDrop key={column.name} name={column.name} required={column.required} value={mappings.find((item) => item.modelColumn === column.name)?.sourceColumn ?? ""} options={source.columns.map((item) => item.name)} onChange={(value) => assign(column.name, value)} />
))}
</div>
</DndContext>
{validation && !validation.ok ? <p className="mt-3 text-sm" role="status">{validation.missingRequired.length ? `Faltam obrigatórias: ${validation.missingRequired.join(", ")}.` : validation.errors[0]}</p> : <SemanticMarker tone="success">Mapeamento pronto para revisão</SemanticMarker>}
<div className="mt-4 flex gap-2">
<Button variant="secondary" onClick={() => setStep(1)}>Voltar</Button>
<Button disabled={!validation?.ok || saveMap.isPending} loading={saveMap.isPending} onClick={() => saveMap.mutate()}>Revisar prévia</Button>
</div>
</Card>
) : null}
{step === 3 && preview ? (
<Card>
<h2 className="text-lg font-semibold">Prévia do resultado</h2>
<p className="mt-1 text-sm">Isto ainda não grava a saída. O modelo original permanece intacto.</p>
<div className="mt-4 overflow-auto">
<table className="w-full text-left text-sm">
<thead>
<tr>{preview.columns.map((column) => <th key={column.name} className="border-b border-line p-2">{column.name}{column.required ? " (obrigatória)" : ""}</th>)}</tr>
</thead>
<tbody>
{preview.rows.map((row, index) => <tr key={index}>{row.map((cell, cellIndex) => <td key={cellIndex} className="border-b border-line p-2">{cell}</td>)}</tr>)}
</tbody>
</table>
</div>
<div className="mt-4 flex gap-2">
<Button variant="secondary" onClick={() => setStep(2)}>Voltar ao mapeamento</Button>
<Button onClick={() => void processCurrent()}>Processar</Button>
</div>
</Card>
) : null}
{(step === 4 || step === 5) && job.data ? (
<Card>
<h2 className="text-lg font-semibold">{job.data.progress?.label ?? "Processando"}</h2>
<p className="mt-2 text-sm">{job.data.progress?.percent == null ? "Progresso ainda não é estimável." : `${job.data.progress.percent}%`}</p>
{job.data.status === "failed" ? <ErrorState message={job.data.errorCode ?? "Falha no processamento"} onRetry={() => void processCurrent()} /> : null}
{source?.hasOutput ? (
<div className="mt-4 flex flex-wrap gap-2">
{(["xlsx", "csv", "pdf"] as const).map((format) => (
<a key={format} className="inline-flex min-h-11 items-center rounded-[18px] bg-action px-4 text-sm font-semibold text-white" href={`/api/spreadsheet-adapter/jobs/${job.data?.id}/download?format=${format}&sourceId=${source.id}`}>{format.toUpperCase()}</a>
))}
<Button variant="secondary" onClick={() => { setPreview(null); setStep(1); }}>Continuar com outra fonte</Button>
</div>
) : null}
{job.data.status === "completed" ? <div className="mt-4"><SemanticMarker tone="success">Adaptação concluída</SemanticMarker></div> : null}
</Card>
) : null}
<Dialog open={criteria} title="Critérios para upload" onClose={() => setCriteria(false)}>
<ul className="list-disc space-y-2 pl-5 text-sm">
<li>Formatos XLSX e CSV. XLS, ODS e XML ficam de fora.</li>
<li>Até 10 MB e 50.000 linhas úteis por arquivo.</li>
<li>Até 10 fontes nesta operação e 10 modelos ativos no workspace.</li>
<li>Em XLSX com várias abas, escolha exatamente uma.</li>
<li>Toda coluna usada precisa de cabeçalho. A linha pode ser ajustada.</li>
</ul>
<Button className="mt-4" onClick={() => setCriteria(false)}>Fechar</Button>
</Dialog>
<Dialog open={Boolean(sheetDialog)} title="Selecione uma aba" onClose={() => setSheetDialog(null)}>
<div className="space-y-2">
{sheetDialog?.sheets.map((sheet) => (
<Button key={sheet} variant="secondary" onClick={() => {
void fetch(`/api/spreadsheet-adapter/jobs/${jobId}/sources/${sheetDialog.sourceId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet }),
}).then(() => {
setSheetDialog(null);
void job.refetch();
});
}}>{sheet}</Button>
))}
</div>
</Dialog>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } }));
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
import { queryKeys } from "@/domain/spreadsheet-adapter/query-keys";
import type { ModelDto } from "@/server/spreadsheet-adapter/dto";
import { Button, Card, Dialog, Switch } from "./ui";
import { EmptyState, ErrorState, LoadingBlock } from "./states";
export function SettingsPanel({ ctx }: { ctx: RequestContext }) {
const client = useQueryClient();
const [criteria, setCriteria] = useState(false);
const [pendingDelete, setPendingDelete] = useState<ModelDto | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [name, setName] = useState("");
const [sheet, setSheet] = useState("");
const [file, setFile] = useState<File | null>(null);
const [required, setRequired] = useState<string[]>([]);
const [columns, setColumns] = useState<string[]>([]);
const models = useQuery({
queryKey: queryKeys.models(ctx.workspaceId),
queryFn: async () => {
const response = await fetch("/api/spreadsheet-adapter/models");
const data = (await response.json()) as { models: ModelDto[]; error?: { message: string } };
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao listar");
return data.models;
},
});
const settings = useQuery({
queryKey: queryKeys.settings(ctx.workspaceId),
queryFn: async () => {
const response = await fetch("/api/spreadsheet-adapter/settings");
const data = (await response.json()) as { allowNonAdminModelUpload: boolean; error?: { message: string } };
if (!response.ok) throw new Error(data.error?.message ?? "Falha ao ler configuração");
return data;
},
});
const upload = useMutation({
mutationFn: async () => {
if (!file || !name.trim()) throw new Error("Informe o nome e o arquivo do modelo.");
const form = new FormData();
form.set("file", file);
form.set("name", name.trim());
if (sheet.trim()) form.set("sheet", sheet.trim());
form.set("requiredColumns", JSON.stringify(required));
const response = await fetch("/api/spreadsheet-adapter/models", { method: "POST", body: form });
const data = (await response.json()) as { error?: { message: string; details?: { sheets?: string[] } } };
if (!response.ok) throw new Error(data.error?.message ?? "Upload rejeitado");
},
onSuccess: async () => {
setMessage(null);
setFile(null);
setName("");
await client.invalidateQueries({ queryKey: queryKeys.models(ctx.workspaceId) });
},
onError: (error: Error) => setMessage(error.message),
});
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-semibold">Configurações</h1>
<p className="mt-2 text-sm text-ink/75">Somente owner e admin gerenciam modelos e a permissão de upload.</p>
</header>
<Card>
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="font-semibold">Upload de modelo por gestor e agent</h2>
<p className="text-sm text-ink/70">A opção não concede exclusão nem outras configurações.</p>
</div>
<Switch
label="Permitir upload de modelo por perfis operacionais"
checked={Boolean(settings.data?.allowNonAdminModelUpload)}
disabled={settings.isLoading}
onCheckedChange={async (value) => {
await fetch("/api/spreadsheet-adapter/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ allowNonAdminModelUpload: value }),
});
await settings.refetch();
}}
/>
</div>
</Card>
<Card>
<div className="mb-3 flex justify-between">
<h2 className="font-semibold">Novo modelo</h2>
<Button variant="ghost" onClick={() => setCriteria(true)}>Ver critérios para upload</Button>
</div>
<label className="text-sm" htmlFor="model-name">Nome lógico</label>
<input id="model-name" className="mt-1 h-11 w-full rounded-[10px] border border-line px-3" value={name} onChange={(event) => setName(event.target.value)} />
<label className="mt-3 block text-sm" htmlFor="model-sheet">Aba, se o XLSX tiver mais de uma</label>
<input id="model-sheet" className="mt-1 h-11 w-full rounded-[10px] border border-line px-3" value={sheet} onChange={(event) => setSheet(event.target.value)} />
<label className="mt-3 block text-sm" htmlFor="model-file">Arquivo</label>
<input id="model-file" className="mt-1 block" type="file" accept=".xlsx,.csv" onChange={async (event) => {
const next = event.target.files?.[0] ?? null;
setFile(next);
if (!next || !next.name.toLowerCase().endsWith(".csv")) {
setColumns([]);
return;
}
const text = await next.text();
const header = text.split(/\r?\n/)[0]?.split(/[;,]/).map((item) => item.trim()).filter(Boolean) ?? [];
setColumns(header);
setRequired(header);
}} />
{columns.length > 0 ? (
<fieldset className="mt-3">
<legend className="text-sm font-medium">Colunas obrigatórias</legend>
{columns.map((column) => (
<label key={column} className="mt-1 flex items-center gap-2 text-sm">
<input type="checkbox" checked={required.includes(column)} onChange={(event) => setRequired((current) => event.target.checked ? [...current, column] : current.filter((item) => item !== column))} />
{column}
</label>
))}
</fieldset>
) : null}
{message ? <div className="mt-3"><ErrorState message={message} /></div> : null}
<Button className="mt-4" loading={upload.isPending} disabled={!ctx.moduleEnabled} onClick={() => upload.mutate()}>Cadastrar modelo</Button>
</Card>
{models.isLoading ? <LoadingBlock /> : null}
{models.data?.length === 0 ? <EmptyState title="Nenhum modelo" description="Os modelos ativos deste workspace aparecem aqui, em ordem alfabética." /> : null}
<div className="grid gap-3">
{models.data?.map((model) => (
<Card key={model.id}>
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="font-semibold">{model.name}</h3>
<p className="text-sm text-ink/70">{model.originalFilename} · {model.createdByName}</p>
</div>
<Button variant="danger" onClick={() => setPendingDelete(model)}>Excluir</Button>
</div>
</Card>
))}
</div>
<Dialog open={criteria} title="Critérios para upload" onClose={() => setCriteria(false)}>
<p className="text-sm">XLSX ou CSV, com cabeçalho identificável. O workspace pode ter até 10 modelos ativos e o nome lógico não pode repetir.</p>
<Button className="mt-4" onClick={() => setCriteria(false)}>Fechar</Button>
</Dialog>
<Dialog open={Boolean(pendingDelete)} title="Excluir modelo" onClose={() => setPendingDelete(null)}>
<p className="text-sm">Confirme a exclusão de {pendingDelete?.name}. A operação é bloqueada se houver processamento ativo.</p>
<div className="mt-4 flex gap-2">
<Button variant="secondary" onClick={() => setPendingDelete(null)}>Cancelar</Button>
<Button variant="danger" onClick={async () => {
if (!pendingDelete) return;
const response = await fetch(`/api/spreadsheet-adapter/models/${pendingDelete.id}`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: "{}" });
const data = (await response.json()) as { error?: { message: string } };
if (!response.ok) setMessage(data.error?.message ?? "Exclusão bloqueada");
setPendingDelete(null);
await models.refetch();
}}>Confirmar exclusão</Button>
</div>
</Dialog>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { FileSpreadsheet, LogOut, Settings, Table2 } from "lucide-react";
import type { ReactNode } from "react";
import type { RequestContext } from "@/domain/spreadsheet-adapter/types";
import { canManageSpreadsheetModels } from "@/domain/spreadsheet-adapter/permissions";
export function AppShell({ ctx, children }: { ctx: RequestContext; children: ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const admin = canManageSpreadsheetModels(ctx);
const item = (href: string, label: string, icon: ReactNode) => (
<Link href={href} aria-current={pathname === href ? "page" : undefined} className={`flex items-center gap-2 rounded-[14px] px-3 py-2 text-sm font-medium ${pathname === href ? "bg-ink text-white" : "hover:bg-white"}`}>
{icon}
{label}
</Link>
);
return (
<div className="min-h-screen md:grid md:grid-cols-[240px_1fr]">
<aside className="border-b border-line bg-white p-4 md:border-b-0 md:border-r">
<p className="text-xs font-semibold tracking-wide text-bright">ELO PRO</p>
<p className="mt-1 flex items-center gap-2 text-lg font-semibold">
<FileSpreadsheet aria-hidden="true" size={18} /> Adaptador
</p>
<p className="mt-3 text-sm">Workspace {ctx.workspaceName}</p>
<p className="text-xs text-ink/70">{ctx.displayName} · {ctx.role}</p>
<nav className="mt-6 space-y-1" aria-label="Módulo">
{item("/adaptador", "Início", <Table2 size={16} aria-hidden="true" />)}
{item("/adaptador/operacao", "Nova adaptação", <FileSpreadsheet size={16} aria-hidden="true" />)}
{admin ? item("/adaptador/configuracoes", "Configurações", <Settings size={16} aria-hidden="true" />) : null}
</nav>
<button
type="button"
className="mt-8 flex items-center gap-2 text-sm font-semibold"
onClick={async () => {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}}
>
<LogOut size={16} aria-hidden="true" /> Sair do Elo Pro
</button>
</aside>
<main className="p-4 md:p-8">{children}</main>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
export function LoadingBlock({ label = "Carregando" }: { label?: string }) {
return (
<div role="status" aria-live="polite" className="space-y-3" aria-label={label}>
<div className="h-8 w-48 animate-pulse rounded bg-line" />
<div className="h-24 animate-pulse rounded-[14px] bg-white" />
</div>
);
}
export function EmptyState({ title, description, action }: { title: string; description: string; action?: ReactNode }) {
return (
<div className="rounded-[14px] border border-dashed border-line bg-white p-8 text-center">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 text-sm text-ink/75">{description}</p>
{action ? <div className="mt-4">{action}</div> : null}
</div>
);
}
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div role="alert" className="rounded-[14px] border border-danger bg-white p-4">
<p className="font-semibold text-danger">Não foi possível continuar</p>
<p className="mt-1 text-sm">{message}</p>
{onRetry ? (
<button type="button" className="mt-3 text-sm font-semibold underline" onClick={onRetry}>
Tentar novamente
</button>
) : null}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
export function Button({
variant = "primary",
loading = false,
children,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "primary" | "secondary" | "danger" | "ghost"; loading?: boolean }) {
const styles = {
primary: "bg-action text-white hover:bg-bright",
secondary: "bg-white text-ink border border-line hover:border-bright",
danger: "bg-white text-danger border border-danger",
ghost: "bg-transparent text-ink hover:bg-white",
}[variant];
return (
<button
{...props}
disabled={props.disabled || loading}
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-[18px] px-4 text-sm font-semibold transition duration-200 ${styles} ${props.className ?? ""}`}
>
{loading ? "Aguarde…" : children}
</button>
);
}
export function IconButton({ label, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
return (
<button {...props} aria-label={label} className={`inline-flex size-11 items-center justify-center rounded-[18px] border border-line bg-white ${props.className ?? ""}`}>
{children}
</button>
);
}
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
return <input {...props} className={`h-11 w-full rounded-[10px] border border-line bg-white px-3 ${props.className ?? ""}`} />;
}
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
return <select {...props} className={`h-11 w-full rounded-[10px] border border-line bg-white px-3 ${props.className ?? ""}`} />;
}
export function Switch({
checked,
onCheckedChange,
label,
disabled,
}: {
checked: boolean;
onCheckedChange: (value: boolean) => void;
label: string;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onCheckedChange(!checked)}
className={`relative h-7 w-12 rounded-full transition ${checked ? "bg-aqua" : "bg-line"}`}
>
<span className={`absolute top-1 size-5 rounded-full bg-white transition ${checked ? "left-6" : "left-1"}`} />
<span className="sr-only">{checked ? "Ligado" : "Desligado"}</span>
</button>
);
}
export function Card({ children, className = "" }: { children: ReactNode; className?: string }) {
return <section className={`rounded-[14px] border border-line bg-white p-5 shadow-sm ${className}`}>{children}</section>;
}
export function Dialog({
open,
title,
children,
onClose,
}: {
open: boolean;
title: string;
children: ReactNode;
onClose: () => void;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-ink/40 p-4" role="presentation" onClick={onClose}>
<div role="dialog" aria-modal="true" aria-label={title} className="max-h-[90vh] w-full max-w-lg overflow-auto rounded-[24px] bg-cloud p-6" onClick={(event) => event.stopPropagation()}>
<h2 className="mb-3 text-xl font-semibold">{title}</h2>
{children}
</div>
</div>
);
}
export function SemanticMarker({ tone, children }: { tone: "success" | "warning" | "error" | "info"; children: ReactNode }) {
const toneClass = { success: "text-aqua", warning: "text-warning", error: "text-danger", info: "text-bright" }[tone];
return (
<span className={`inline-flex items-center gap-2 text-sm font-medium ${toneClass}`}>
<span aria-hidden="true">{tone === "success" ? "●" : tone === "error" ? "!" : "•"}</span>
{children}
</span>
);
}
export function JourneyTrail({ steps, current }: { steps: string[]; current: number }) {
return (
<ol className="flex flex-wrap gap-2" aria-label="Jornada da adaptação">
{steps.map((step, index) => (
<li key={step} aria-current={index === current ? "step" : undefined} className={`rounded-full px-3 py-1 text-sm ${index === current ? "bg-ink text-white" : index < current ? "bg-aqua/15 text-ink" : "bg-white text-ink/70"}`}>
{index < current ? `Concluído: ${step}` : step}
</li>
))}
</ol>
);
}
+17
View File
@@ -0,0 +1,17 @@
/** TTL de temporários. O número de produto ainda é decisão aberta. */
export function tempTtlSeconds(): number {
const raw = process.env.SPREADSHEET_TEMP_TTL_SECONDS;
if (raw && raw.trim() !== "") {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0 || value > 60 * 60 * 24 * 7) {
throw new Error("SPREADSHEET_TEMP_TTL_SECONDS inválido.");
}
return value;
}
if (process.env.NODE_ENV === "production") {
throw new Error(
"SPREADSHEET_TEMP_TTL_SECONDS é obrigatório em produção. A retenção de temporários continua decisão aberta.",
);
}
return 60 * 60;
}
+43
View File
@@ -0,0 +1,43 @@
import type { Db } from "@/infra/db/types";
export const FIXTURES = {
alpha: "11111111-1111-4111-8111-111111111111",
beta: "22222222-2222-4222-8222-222222222222",
ana: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
bruno: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
carla: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
diego: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
eva: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
};
export const DEV_IDENTITIES = [
{ userId: FIXTURES.ana, workspaceId: FIXTURES.alpha, label: "Ana · owner · Alpha (módulo ligado)" },
{ userId: FIXTURES.bruno, workspaceId: FIXTURES.alpha, label: "Bruno · agent · Alpha" },
{ userId: FIXTURES.carla, workspaceId: FIXTURES.alpha, label: "Carla · gestor · Alpha" },
{ userId: FIXTURES.diego, workspaceId: FIXTURES.beta, label: "Diego · admin · Beta (módulo desligado)" },
{ userId: FIXTURES.eva, workspaceId: FIXTURES.beta, label: "Eva · agent · Beta (superadmin de plataforma, sem atalho)" },
];
export async function ensureDevFixtures(db: Db): Promise<void> {
const existing = await db.withService((query) => query.query(`select id from public.workspaces limit 1`));
if (existing.length > 0) return;
const { alpha, beta, ana, bruno, carla, diego, eva } = FIXTURES;
await db.withService(async (query) => {
await query.query(
`insert into public.profiles (id, display_name, is_superadmin) values
($1,'Ana Owner', false), ($2,'Bruno Agent', false), ($3,'Carla Gestor', false), ($4,'Diego Admin', false), ($5,'Eva Agent', true)`,
[ana, bruno, carla, diego, eva],
);
await query.query(`insert into public.workspaces (id, name) values ($1,'Alpha'), ($2,'Beta')`, [alpha, beta]);
await query.query(
`insert into public.workspace_members (workspace_id, user_id, role) values
($1,$2,'owner'), ($1,$3,'agent'), ($1,$4,'gestor'), ($5,$6,'admin'), ($5,$7,'agent')`,
[alpha, ana, bruno, carla, beta, diego, eva],
);
await query.query(
`insert into public.workspace_module_flags (workspace_id, module_key, enabled) values
($1,'spreadsheet_adapter', true), ($2,'spreadsheet_adapter', false)`,
[alpha, beta],
);
});
}
@@ -0,0 +1,10 @@
export function neutralizeSpreadsheetFormula(value: string): string {
if (/^[=+@]/.test(value) || /^-(?![\d.,])/.test(value) || /^[\t\r]/.test(value)) {
return `'${value}`;
}
return value;
}
export function neutralizeRow(row: string[]): string[] {
return row.map(neutralizeSpreadsheetFormula);
}
+83
View File
@@ -0,0 +1,83 @@
import { AdapterError } from "./errors";
import { extractHeaders } from "./headers";
import { MAX_USEFUL_ROWS } from "./limits";
import { isUsefulRow } from "./transform";
import { inferColumns, typeConflictMessages } from "./typing";
import type { ParsedGrid } from "./types";
export function detectDelimiter(line: string): "," | ";" {
const commas = (line.match(/,/g) ?? []).length;
const semis = (line.match(/;/g) ?? []).length;
return semis > commas ? ";" : ",";
}
export function parseCsvText(text: string): string[][] {
const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
if (source.includes("\u0000")) {
throw new AdapterError("CSV_INVALID", "O arquivo não é um CSV de texto.");
}
const firstLine = source.split(/\r?\n/, 1)[0] ?? "";
const delimiter = detectDelimiter(firstLine);
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let quoted = false;
for (let i = 0; i < source.length; i += 1) {
const char = source[i];
if (quoted) {
if (char === '"') {
if (source[i + 1] === '"') {
cell += '"';
i += 1;
} else quoted = false;
} else cell += char;
continue;
}
if (char === '"') {
quoted = true;
continue;
}
if (char === delimiter) {
row.push(cell);
cell = "";
continue;
}
if (char === "\n" || char === "\r") {
if (char === "\r" && source[i + 1] === "\n") i += 1;
row.push(cell);
rows.push(row);
row = [];
cell = "";
continue;
}
cell += char;
}
if (quoted) throw new AdapterError("CSV_INVALID", "CSV com aspas não fechadas.");
if (cell.length > 0 || row.length > 0) {
row.push(cell);
rows.push(row);
}
return rows;
}
export function gridFromRows(allRows: string[][], headerRow: number, sheetNames: string[], selectedSheet: string | null): ParsedGrid {
if (!Number.isInteger(headerRow) || headerRow < 1) {
throw new AdapterError("HEADER_ROW_INVALID", "A linha de cabeçalho precisa ser um inteiro maior ou igual a 1.");
}
if (headerRow > allRows.length) {
throw new AdapterError("HEADER_ROW_INVALID", "A linha de cabeçalho está além do conteúdo do arquivo.");
}
const headers = extractHeaders(allRows[headerRow - 1] ?? []);
const data: string[][] = [];
for (const raw of allRows.slice(headerRow)) {
const sized = headers.map((_, index) => String(raw[index] ?? ""));
if (!isUsefulRow(sized)) continue;
data.push(sized);
if (data.length > MAX_USEFUL_ROWS) {
throw new AdapterError("TOO_MANY_ROWS", `O arquivo excede ${MAX_USEFUL_ROWS} linhas úteis.`);
}
}
const columns = inferColumns(headers, data);
const warnings = typeConflictMessages(columns);
return { sheetNames, selectedSheet, headerRow, headers, rows: data, columns, warnings };
}
+17
View File
@@ -0,0 +1,17 @@
export class AdapterError extends Error {
readonly code: string;
readonly status: number;
readonly details?: Record<string, unknown>;
constructor(code: string, message: string, status = 400, details?: Record<string, unknown>) {
super(message);
this.name = "AdapterError";
this.code = code;
this.status = status;
this.details = details;
}
}
export function isAdapterError(error: unknown): error is AdapterError {
return error instanceof AdapterError;
}
@@ -0,0 +1,66 @@
import { AdapterError } from "./errors";
import { assertFileSize } from "./limits";
export function isZip(buf: Buffer): boolean {
return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04;
}
export function isOle(buf: Buffer): boolean {
return buf.length >= 8 && buf[0] === 0xd0 && buf[1] === 0xcf && buf[2] === 0x11 && buf[3] === 0xe0;
}
export function detectSpreadsheetFormat(filename: string, buf: Buffer): "xlsx" | "csv" {
assertFileSize(buf.length);
const ext = filename.toLowerCase().split(".").pop() ?? "";
if (ext === "xls" || isOle(buf)) {
throw new AdapterError("UNSUPPORTED_FORMAT", "XLS não faz parte do MVP. Envie XLSX ou CSV.");
}
if (ext === "ods" || ext === "xml") {
throw new AdapterError("UNSUPPORTED_FORMAT", "ODS e XML não fazem parte do MVP.");
}
if (ext === "xlsx") {
if (!isZip(buf)) throw new AdapterError("MIME_MISMATCH", "A extensão XLSX não corresponde ao conteúdo do arquivo.");
return "xlsx";
}
if (ext === "csv") {
if (isZip(buf) || isOle(buf)) throw new AdapterError("MIME_MISMATCH", "A extensão CSV não corresponde ao conteúdo do arquivo.");
return "csv";
}
throw new AdapterError("UNSUPPORTED_FORMAT", "Envie um arquivo XLSX ou CSV.");
}
export function assertZipSafe(buf: Buffer): void {
if (!isZip(buf)) throw new AdapterError("INVALID_STRUCTURE", "Arquivo XLSX inválido.");
let offset = 0;
let uncompressed = 0;
let entries = 0;
while (offset + 30 <= buf.length) {
const sig = buf.readUInt32LE(offset);
if (sig !== 0x04034b50) break;
const flags = buf.readUInt16LE(offset + 6);
const method = buf.readUInt16LE(offset + 8);
const compressed = buf.readUInt32LE(offset + 18);
const rawSize = buf.readUInt32LE(offset + 22);
const nameLen = buf.readUInt16LE(offset + 26);
const extraLen = buf.readUInt16LE(offset + 28);
if (method !== 0 && method !== 8) {
throw new AdapterError("INVALID_STRUCTURE", "A compressão deste XLSX não é suportada.");
}
entries += 1;
if (entries > 2000) throw new AdapterError("ZIP_BOMB", "O arquivo compactado tem estrutura complexa demais.");
uncompressed += rawSize;
if (uncompressed > 256 * 1024 * 1024) {
throw new AdapterError("ZIP_BOMB", "O arquivo expandiria além do limite seguro.");
}
if (rawSize > 0 && compressed > 0 && rawSize / compressed > 100 && rawSize > 10_000_000) {
throw new AdapterError("ZIP_BOMB", "Taxa de compressão anômala.");
}
const dataStart = offset + 30 + nameLen + extraLen;
if (flags & 0x08 && compressed === 0) break;
if (dataStart + compressed > buf.length + 64) {
throw new AdapterError("INVALID_STRUCTURE", "Estrutura ZIP inconsistente.");
}
offset = dataStart + compressed;
}
if (entries === 0) throw new AdapterError("INVALID_STRUCTURE", "XLSX sem conteúdo reconhecível.");
}
@@ -0,0 +1,38 @@
import { AdapterError } from "./errors";
const UNSAFE = /[<>:"/\\|?*\u0000-\u001f]/g;
export function sanitizeDisplayName(name: string): string {
const base = name.replace(/\\/g, "/").split("/").pop() ?? "";
const cleaned = base.replace(UNSAFE, "_").replace(/^\.+/, "").trim().slice(0, 120);
if (!cleaned || cleaned === "." || cleaned === "..") {
throw new AdapterError("INVALID_FILENAME", "Nome de arquivo inválido.");
}
return cleaned;
}
export function assertStorageKey(key: string): void {
if (!key || key.startsWith("/") || key.includes("\\") || key.split("/").some((p) => p === ".." || p === "." || p === "")) {
throw new AdapterError("PATH_TRAVERSAL", "Chave de armazenamento rejeitada.");
}
}
export function buildOutputFilename(modelName: string, format: "xlsx" | "csv" | "pdf", now = new Date()): string {
const safe = modelName
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w.-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.slice(0, 40) || "modelo";
const stamp = now.toISOString().replace(/[:.]/g, "-");
return `adaptado_${safe}_${stamp}.${format}`;
}
export function normalizeModelName(name: string): string {
const trimmed = name.trim().replace(/\s+/g, " ");
if (trimmed.length < 1 || trimmed.length > 120) {
throw new AdapterError("INVALID_MODEL_NAME", "Informe um nome de modelo entre 1 e 120 caracteres.");
}
return trimmed;
}
+27
View File
@@ -0,0 +1,27 @@
import { AdapterError } from "./errors";
export function normalizeHeader(value: string): string {
return value.replace(/\u00a0/g, " ").trim().replace(/\s+/g, " ");
}
export function extractHeaders(cells: string[]): string[] {
const trimmed = cells.map((cell) => normalizeHeader(cell ?? ""));
let end = trimmed.length;
while (end > 0 && trimmed[end - 1] === "") end -= 1;
const headers = trimmed.slice(0, end);
if (headers.length === 0) {
throw new AdapterError("EMPTY_HEADER", "Nenhum cabeçalho identificável nesta linha.");
}
if (headers.some((header) => header === "")) {
throw new AdapterError("EMPTY_HEADER", "Há coluna utilizada sem cabeçalho. Ajuste a linha de cabeçalho.");
}
const seen = new Set<string>();
for (const header of headers) {
const key = header.toLocaleLowerCase("pt-BR");
if (seen.has(key)) {
throw new AdapterError("DUPLICATE_HEADER", `O cabeçalho "${header}" está duplicado.`);
}
seen.add(key);
}
return headers;
}
@@ -0,0 +1,17 @@
import type { JobStatus } from "./types";
const ALLOWED: Record<JobStatus, JobStatus[]> = {
draft: ["validating", "ready", "cancelled", "expired", "failed"],
validating: ["ready", "failed", "draft"],
ready: ["queued", "draft", "cancelled", "validating"],
queued: ["processing", "failed", "cancelled"],
processing: ["completed", "failed", "ready"],
completed: ["ready", "expired"],
failed: ["queued", "ready", "draft"],
expired: [],
cancelled: [],
};
export function canTransition(from: JobStatus, to: JobStatus): boolean {
return ALLOWED[from].includes(to);
}
+18
View File
@@ -0,0 +1,18 @@
import { AdapterError } from "./errors";
export const MAX_ACTIVE_MODELS = 10;
export const MAX_SOURCES_PER_JOB = 10;
export const MAX_FILE_BYTES = 10 * 1024 * 1024;
export const MAX_USEFUL_ROWS = 50_000;
export const PREVIEW_ROWS = 8;
export const PDF_MAX_ROWS = 200;
export const PDF_MAX_COLUMNS = 12;
export function assertFileSize(size: number): void {
if (size <= 0) {
throw new AdapterError("EMPTY_FILE", "O arquivo está vazio.");
}
if (size > MAX_FILE_BYTES) {
throw new AdapterError("FILE_TOO_LARGE", "O arquivo excede 10 MB.");
}
}
+40
View File
@@ -0,0 +1,40 @@
import type { ColumnSchema, MappingInput } from "./types";
export type MappingValidation = {
ok: boolean;
code: "OK" | "MAPPING_CONFLICT" | "MAPPING_INCOMPLETE" | "MAPPING_UNKNOWN";
errors: string[];
missingRequired: string[];
};
export function validateMappings(
modelColumns: Pick<ColumnSchema, "name" | "required">[],
sourceColumns: string[],
mappings: MappingInput[],
): MappingValidation {
const errors: string[] = [];
const sourceSet = new Set(sourceColumns);
const modelSet = new Set(modelColumns.map((column) => column.name));
const usedSource = new Set<string>();
const usedModel = new Set<string>();
for (const mapping of mappings) {
if (!sourceSet.has(mapping.sourceColumn) || !modelSet.has(mapping.modelColumn)) {
errors.push("O mapeamento referencia uma coluna que não existe na fonte ou no modelo.");
}
if (usedSource.has(mapping.sourceColumn)) {
errors.push(`A coluna fonte "${mapping.sourceColumn}" está mapeada mais de uma vez.`);
}
if (usedModel.has(mapping.modelColumn)) {
errors.push(`A coluna do modelo "${mapping.modelColumn}" recebe mais de uma fonte.`);
}
usedSource.add(mapping.sourceColumn);
usedModel.add(mapping.modelColumn);
}
const missingRequired = modelColumns.filter((column) => column.required && !usedModel.has(column.name)).map((column) => column.name);
const unknown = errors.some((error) => error.includes("não existe"));
const conflict = errors.some((error) => error.includes("mais de uma"));
const code = unknown ? "MAPPING_UNKNOWN" : conflict ? "MAPPING_CONFLICT" : missingRequired.length ? "MAPPING_INCOMPLETE" : "OK";
return { ok: code === "OK", code, errors, missingRequired };
}
@@ -0,0 +1,23 @@
import type { RequestContext, Role } from "./types";
const OPERATIONAL: Role[] = ["owner", "admin", "gestor", "agent"];
export function canUseSpreadsheetAdapter(ctx: Pick<RequestContext, "role" | "moduleEnabled">): boolean {
return ctx.moduleEnabled && OPERATIONAL.includes(ctx.role);
}
export function canManageSpreadsheetModels(ctx: Pick<RequestContext, "role" | "moduleEnabled">): boolean {
return canUseSpreadsheetAdapter(ctx) && (ctx.role === "owner" || ctx.role === "admin");
}
export function canUploadSpreadsheetModel(
ctx: Pick<RequestContext, "role" | "moduleEnabled" | "allowNonAdminModelUpload">,
): boolean {
if (!canUseSpreadsheetAdapter(ctx)) return false;
if (ctx.role === "owner" || ctx.role === "admin") return true;
return ctx.allowNonAdminModelUpload && (ctx.role === "gestor" || ctx.role === "agent");
}
export function canOperateSpreadsheet(ctx: Pick<RequestContext, "role" | "moduleEnabled">): boolean {
return canUseSpreadsheetAdapter(ctx);
}
+28
View File
@@ -0,0 +1,28 @@
import { PREVIEW_ROWS } from "./limits";
import { transformRows } from "./transform";
import type { ColumnSchema, MappingInput } from "./types";
export type PreviewContract = {
preview: true;
columns: { name: string; required: boolean }[];
rows: string[][];
rowCount: number;
truncated: boolean;
};
export function buildPreview(
modelColumns: ColumnSchema[],
sourceHeaders: string[],
dataRows: string[][],
mappings: MappingInput[],
): PreviewContract {
const ordered = modelColumns.map((column) => column.name);
const all = transformRows(ordered, sourceHeaders, dataRows, mappings);
return {
preview: true,
columns: modelColumns.map((column) => ({ name: column.name, required: column.required })),
rows: all.slice(0, PREVIEW_ROWS),
rowCount: all.length,
truncated: all.length > PREVIEW_ROWS,
};
}
@@ -0,0 +1,5 @@
export const queryKeys = {
models: (workspaceId: string) => ["spreadsheet-adapter", workspaceId, "models"] as const,
settings: (workspaceId: string) => ["spreadsheet-adapter", workspaceId, "settings"] as const,
job: (workspaceId: string, jobId: string) => ["spreadsheet-adapter", workspaceId, "job", jobId] as const,
};
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more