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:
commit
6e23f1533b
144 files changed
+19060
No files matched your search
@@ -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.
|
||||||
@@ -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=
|
||||||
@@ -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
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.data
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.tsbuildinfo
|
||||||
|
_prompts_tmp
|
||||||
|
_escopo_extraido.txt
|
||||||
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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/`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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/**"]),
|
||||||
|
]);
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
Vendored
+7
@@ -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.
|
||||||
@@ -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;
|
||||||
Generated
+12339
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { LoadingBlock } from "@/components/states";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<main className="p-8">
|
||||||
|
<LoadingBlock label="Abrindo o Adaptador" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
redirect("/adaptador");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
Reference in new issue
Block a user