diff --git a/.claude/skills/refactor-arch/SKILL.md b/.claude/skills/refactor-arch/SKILL.md new file mode 100644 index 000000000..04512d5a8 --- /dev/null +++ b/.claude/skills/refactor-arch/SKILL.md @@ -0,0 +1,150 @@ +--- +name: refactor-arch +description: >- + Audita e refatora qualquer codebase de backend para o padrão MVC, de forma agnóstica de + tecnologia (Python/Flask, Node.js/Express, e outras stacks). Executa em 3 fases sequenciais — + Análise (detecta stack, banco, domínio e arquitetura), Auditoria (pente-fino de engenheiro + sênior que classifica problemas de arquitetura, segurança, SOLID, concorrência e aderência a + MVC por severidade CRITICAL/HIGH/MEDIUM/LOW, gera relatório e pede confirmação) e Refatoração + (reestrutura para MVC em loop auto-corretivo e valida subindo a aplicação). Use esta skill + SEMPRE que o usuário pedir para refatorar, auditar, reestruturar, "arrumar a arquitetura", + aplicar MVC, achar code smells / anti-patterns, avaliar qualidade de código ou modernizar um + projeto legado — mesmo que ele não diga "MVC" ou "auditoria" explicitamente. Também dispara + com o comando /refactor-arch. +--- + +# refactor-arch — Auditoria e Refatoração Arquitetural para MVC + +Você atua como **engenheiro de software com mais de 30 anos de mercado**, especialista em +arquitetura, segurança e qualidade. Seu trabalho tem 3 fases sequenciais. Execute-as **em ordem** +e **nunca modifique arquivos antes de o usuário confirmar** (gate entre Fase 2 e Fase 3). + +Esta skill é **agnóstica de tecnologia e de localização**: ela roda no diretório de trabalho +atual (o projeto), qualquer que seja a stack. Não assuma nomes de arquivos, framework ou estrutura +— **detecte** tudo lendo o projeto. + +## Arquivos de referência + +Carregue cada um **no início da fase correspondente** (não tente segurar tudo em memória de uma vez): + +| Arquivo | Quando ler | Para quê | +|---|---|---| +| `references/01-project-analysis.md` | Fase 1 | Heurísticas de detecção de stack, banco, domínio e arquitetura | +| `references/02-antipattern-catalog.md` | Fase 2 | Catálogo de anti-patterns com sinais de detecção e severidade (inclui APIs deprecated) | +| `references/03-report-templates.md` | Fases 1, 2, 3 | Templates de saída **a serem seguidos à risca** | +| `references/04-mvc-guidelines.md` | Fases 2 e 3 | Regras do MVC-alvo (camadas e responsabilidades) | +| `references/05-refactoring-playbook.md` | Fase 3 | Transformações antes/depois + loop de correção + validação | +| `references/06-validation-checklist.md` | Fim de cada fase | Auto-verificação interna (silenciosa; só se manifesta em falha) | + +Os templates de saída não são opcionais nem "aproximados": reproduza a estrutura, os rótulos, a +ordem das seções e os separadores (`====`) **exatamente** como em `03-report-templates.md`. + +--- + +## Fase 1 — Análise + +**Objetivo:** detectar a stack, mapear a arquitetura atual e imprimir um resumo. + +1. Leia `references/01-project-analysis.md` e siga as heurísticas. +2. Explore o projeto: manifestos de dependências, arquivos-fonte, definição de rotas, camada de + dados/schema. Leia de verdade os arquivos principais — não infira só pelos nomes. +3. Determine: linguagem (+runtime), framework (+versão), dependências diretas principais, banco + (engine + forma de acesso + nuances como *in-memory*), domínio (entidades principais), + arquitetura atual (resumo de 1 linha), nº de arquivos-fonte, tabelas do banco. +4. Imprima o bloco **`PHASE 1: PROJECT ANALYSIS`** exatamente como o template. + Campos sem valor no projeto são **omitidos** (não imprima "N/A"). +5. **Auto-validação (interna e silenciosa):** rode o checklist da Fase 1 de + `references/06-validation-checklist.md`. Se tudo passar, não imprima nada. Se algo falhar, + sinalize e **corrija antes de avançar**. + +Depois, siga direto para a Fase 2. + +--- + +## Fase 2 — Auditoria + +**Objetivo:** passar um pente-fino no projeto, classificar todos os problemas por severidade, +avaliar aderência a MVC, gerar o relatório e **pedir confirmação**. + +1. Leia `references/02-antipattern-catalog.md` e `references/04-mvc-guidelines.md`. +2. Audite o projeto inteiro procurando: falhas de arquitetura, falhas de segurança, problemas de + qualidade, concorrência, bugs, anti-patterns, violações de SOLID **e violações de MVC**. + Uma auditoria completa nos projetos-alvo deve resultar em **pelo menos 5 findings**; se você + encontrou menos, provavelmente a análise foi rasa — revise o catálogo inteiro e a aderência a MVC. +3. Para cada problema, produza um finding com **arquivo e linhas exatas**, descrição, impacto e + recomendação, classificado como CRITICAL / HIGH / MEDIUM / LOW (critérios em + `02-antipattern-catalog.md`). +4. Avalie a **aderência a MVC** e produza a seção `MVC Adherence` (score + checklist por camada). + As violações de MVC também entram como findings individuais com localização exata. +5. Ordene os findings por severidade decrescente (CRITICAL → LOW). +6. Monte o relatório no formato **`ARCHITECTURE AUDIT REPORT`** (com `Summary`, `MVC Adherence`, + `Findings` e o rodapé `Total: N findings`). Este relatório deve ser: + - **impresso no console**, e + - **salvo em arquivo**. Determine o caminho assim: + - Ache a raiz do repositório git (suba diretórios até encontrar `.git`). + - Grave em `/reports/audit-project-.md`, onde `` é o próximo número livre + olhando os `audit-project-*.md` já existentes em `reports/` (começa em 1). + - Se não houver repositório git acima, use `./reports/` no próprio projeto. + - Crie a pasta `reports/` se não existir. +7. **Auto-validação (interna e silenciosa):** rode o checklist da Fase 2 de + `references/06-validation-checklist.md` (inclui "mínimo de 5 findings"). Se tudo passar, não + imprima nada. Se algo falhar, corrija o relatório **antes** de exibir o gate de confirmação. +8. **PARE e peça confirmação** antes de tocar em qualquer arquivo: + + ``` + Phase 2 complete. Proceed with refactoring (Phase 3)? [y/n] + ``` + + Prossiga para a Fase 3 **apenas** se o usuário responder afirmativamente. Se disser não, encerre + sem modificar nada. + +--- + +## Fase 3 — Refatoração + +**Objetivo:** reestruturar o projeto para MVC e corrigir os findings, validando que a aplicação +continua funcionando. **Só execute após a confirmação do usuário.** + +1. Leia `references/05-refactoring-playbook.md` e revisite `references/04-mvc-guidelines.md`. +2. Use como **input o relatório salvo na Fase 2** (`reports/audit-project-.md`). Cada finding + listado ali precisa ser endereçado. +3. Reestruture para as camadas MVC-alvo (config, models, views/routes, controllers, middlewares, + entrypoint), adaptando o **layout à convenção da linguagem** (ver `04-mvc-guidelines.md`): + **Python fica na raiz do projeto (sem `src/`)**; **Node.js usa `src/`**. + **Preserve os contratos externos** — mesmos paths, métodos HTTP, formato de resposta e schema + de dados. As mudanças são internas. +4. **Loop de correção controlado (máximo de 3 iterações):** + 1. Aplique as correções pendentes (MVC + findings). + 2. **Re-audite internamente** com os mesmos critérios da Fase 2 para verificar se (a) todos os + problemas foram resolvidos e (b) nenhum problema novo foi introduzido. Esta re-auditoria é + **silenciosa** — não imprima os findings; use-a só para decidir se continua. + 3. Se ainda houver problemas → nova iteração. Se resolveu tudo → saia do loop com sucesso. + 4. Pare ao resolver tudo **ou** ao completar 3 iterações (o que vier primeiro). +5. **Valide subindo a aplicação de verdade** (obrigatório — a app não pode quebrar): + - Instale dependências (`pip install` / `npm install`). + - Inicie o servidor num processo em background. + - Faça requisições HTTP reais a **todos os endpoints originais** e confirme que respondem. + - Encerre o servidor. + - **Salve a evidência** das saídas reais (log de boot + status/resposta de cada endpoint) em + `reports/validation-project-.md` (mesmo `` da auditoria) — é o comprovante auditável de + que a app funciona. Template e regras em `references/05-refactoring-playbook.md`. +6. Imprima o bloco **`PHASE 3: REFACTORING COMPLETE`** com a nova estrutura de diretórios e o bloco + `Validation`. A linha de anti-patterns deve **refletir o estado real**: + - `✓ Zero anti-patterns remaining` se o loop resolveu tudo, ou + - `⚠ findings remaining after 3 loops:` com a lista, se sobrou algo. + `Application boots without errors` e `All endpoints respond correctly` **têm que passar** em + qualquer caso — não marque como concluído com a aplicação quebrada. +7. **Auto-validação (interna e silenciosa):** rode o checklist da Fase 3 de + `references/06-validation-checklist.md`. Se algum item falhar (ex.: app não sobe, endpoint não + responde, camada MVC faltando, Python indevidamente em `src/`), **corrija e revalide** antes de + dar a fase por concluída. No caminho feliz, não imprima o checklist. + +--- + +## Regras gerais + +- **Fidelidade aos templates:** siga `03-report-templates.md` à risca em todas as fases. +- **Gate humano obrigatório:** nunca modifique arquivos antes do `y` na Fase 2. +- **Agnóstica:** detecte a stack; não hardcode nomes de arquivos ou frameworks. +- **Honestidade:** o relatório de validação reflete o resultado real, não o ideal. +- **Preservação de comportamento:** a refatoração não muda o contrato externo da API. diff --git a/.claude/skills/refactor-arch/references/01-project-analysis.md b/.claude/skills/refactor-arch/references/01-project-analysis.md new file mode 100644 index 000000000..5d37cf037 --- /dev/null +++ b/.claude/skills/refactor-arch/references/01-project-analysis.md @@ -0,0 +1,94 @@ +# Fase 1 — Heurísticas de Análise de Projeto + +Objetivo: detectar **linguagem, framework, dependências, banco de dados, domínio e arquitetura** +lendo o projeto, sem assumir nada pelos nomes dos arquivos. Tudo aqui é agnóstico de stack — as +tabelas dão os sinais concretos para as stacks mais comuns, mas o método vale para qualquer uma. + +## Método geral + +1. **Comece pelos manifestos de dependências** — eles revelam linguagem, framework e libs de uma vez. +2. **Liste os arquivos-fonte** (excluindo `node_modules`, `venv`, `.venv`, `.git`, `__pycache__`, `dist`, `build`). +3. **Leia os arquivos principais de verdade**: entrypoint, definição de rotas, camada de dados. + Nomes enganam; o conteúdo não. +4. **Mapeie a arquitetura**: quantos arquivos, como as responsabilidades estão distribuídas, se há + separação de camadas ou tudo está junto. + +## Detecção de linguagem e framework + +| Sinal (arquivo) | Linguagem | Como achar o framework + versão | +|---|---|---| +| `requirements.txt`, `pyproject.toml`, `Pipfile`, `setup.py` | Python | Procure `flask`, `django`, `fastapi`, `starlette` na lista de deps; a versão vem fixada (ex.: `flask==3.1.1`) | +| `package.json` | JavaScript/TypeScript (Node.js) | Campo `dependencies`: `express`, `koa`, `fastify`, `nestjs`; versão no valor (ex.: `"express": "^4.18.2"`) | +| `pom.xml`, `build.gradle` | Java | Spring/Spring Boot nas dependências | +| `go.mod` | Go | `gin`, `echo`, `fiber` | +| `Gemfile` | Ruby | `rails`, `sinatra` | +| `composer.json` | PHP | `laravel`, `symfony` | + +Se houver `tsconfig.json` ou arquivos `.ts`, a linguagem é TypeScript. +Registre o runtime quando for informativo (ex.: `JavaScript (Node.js)`). + +## Detecção de dependências principais + +- Liste apenas as **diretas** (declaradas no manifesto), **não** as transitivas. +- **Não repita o framework** (ele já tem linha própria). +- Mostre só os nomes, separados por vírgula. Foque nas que têm papel arquitetural: + ORM (`flask-sqlalchemy`, `sequelize`, `prisma`), CORS, validação (`marshmallow`, `joi`, `zod`), + auth, cache, cliente HTTP. + +## Detecção de banco de dados + +Leia a camada de dados (arquivos tipo `database.*`, `db.*`, models, ou a inicialização no entrypoint). + +| Sinal no código | Engine | Forma de acesso | +|---|---|---| +| `sqlite3.connect(...)`, `require('sqlite3')` | SQLite | driver direto (SQL manual) | +| `:memory:` como caminho | SQLite **in-memory** | ⚠️ dados voláteis — some a cada restart | +| `SQLAlchemy`, `db.Model`, `flask_sqlalchemy` | (o do `SQLALCHEMY_DATABASE_URI`) | ORM | +| `sequelize`, `prisma`, `typeorm`, `mongoose` | conforme config | ORM/ODM | +| `psycopg2`, `pg`, `postgres://` | PostgreSQL | driver ou ORM | +| `mysql`, `mysql2`, `pymysql` | MySQL | driver ou ORM | + +Extraia o engine, a forma de acesso e nuances relevantes. Ex.: +`SQLite in-memory (dados voláteis, driver sqlite3)` ou `SQLite via SQLAlchemy ORM (tasks.db)`. + +### Tabelas do banco + +Ache os nomes das tabelas por qualquer um destes caminhos: +- `CREATE TABLE ` em SQL embutido. +- Classes de modelo do ORM (`class Produto(db.Model)` → tabela `produtos`/`produto`); veja + `__tablename__` quando existir. +- Migrations ou seeds. + +Liste os nomes reais das tabelas, separados por vírgula. + +## Detecção de domínio + +Inferir do conjunto de: nomes de tabelas/entidades, rotas e vocabulário do código. +Descreva em uma frase curta com as entidades principais. Exemplos: +- Tabelas `produtos, usuarios, pedidos, itens_pedido` → `E-commerce API (produtos, pedidos, usuários)`. +- Tabelas `users, courses, enrollments, payments` → `LMS API (cursos, matrículas, pagamentos)`. +- Tabelas `tasks, users, categories` → `Task Manager API (tarefas, usuários, categorias)`. + +## Mapeamento de arquitetura (resumo em 1 linha) + +Avalie como as responsabilidades estão distribuídas e resuma em uma linha. Padrões comuns: + +| Situação observada | Resumo sugerido | +|---|---| +| Tudo em poucos arquivos, sem camadas | `Monolítica — tudo em N arquivos, sem separação de camadas` | +| Uma classe/arquivo central que faz tudo | `God Class — um único componente concentra DB, regras e rotas` | +| Alguma separação (models/routes) mas com lógica vazando | `Parcialmente em camadas — models/routes presentes, sem controllers` | +| MVC bem separado | `Em camadas — MVC com models, controllers e rotas separados` | + +As nuances detalhadas **não** vão nesta linha — elas viram findings na Fase 2. + +## Contagem de arquivos-fonte + +Conte os arquivos-fonte da linguagem principal, excluindo diretórios de dependências +(`node_modules`, `venv`, `.venv`, `.git`, `__pycache__`, `dist`, `build`). Inclua scripts +auxiliares do próprio projeto (ex.: `seed.py`). Reporte como ` files analyzed`. + +## Saída + +Monte o bloco `PHASE 1: PROJECT ANALYSIS` conforme `03-report-templates.md`, **omitindo** as linhas +de campos que não se aplicam ao projeto (ex.: sem banco → sem `Database` e sem `DB tables`). diff --git a/.claude/skills/refactor-arch/references/02-antipattern-catalog.md b/.claude/skills/refactor-arch/references/02-antipattern-catalog.md new file mode 100644 index 000000000..7f4810ea5 --- /dev/null +++ b/.claude/skills/refactor-arch/references/02-antipattern-catalog.md @@ -0,0 +1,166 @@ +# Fase 2 — Catálogo de Anti-Patterns e Critérios de Severidade + +Este catálogo é a base da auditoria. Cada entrada tem: **sinais de detecção** concretos (o que +procurar no código), a **severidade** e por quê. Use-o como checklist, mas não se limite a ele — +você é um engenheiro sênior; qualquer problema real de arquitetura, segurança, concorrência, +qualidade, SOLID ou MVC deve virar finding, mesmo que não esteja listado aqui. + +## Critérios de classificação de severidade + +Classifique **todo** finding segundo esta tabela: + +- **CRITICAL** — Falhas graves de arquitetura ou segurança que impedem o funcionamento correto, + expõem dados sensíveis (ex.: credenciais hardcoded, SQL Injection) ou violam completamente a + separação de responsabilidades (ex.: "God Class" com banco, lógica e roteamento no mesmo arquivo). +- **HIGH** — Fortes violações do padrão MVC ou de SOLID que dificultam muito manutenção e testes + (ex.: lógica de negócio pesada presa em Controllers/rotas, forte acoplamento sem Injeção de + Dependência, estado global mutável na aplicação inteira). +- **MEDIUM** — Problemas de padronização, duplicação de código ou performance moderada + (ex.: N+1 no banco, uso inadequado de middlewares, validações ausentes nas rotas). +- **LOW** — Legibilidade, nomenclatura ruim de variáveis, "magic numbers" soltos. + +Na dúvida entre dois níveis, pese o **impacto**: pode vazar dado ou derrubar a app? → sobe para CRITICAL. +É só cosmético? → LOW. + +## Como escrever um finding + +Cada finding precisa de **arquivo e linhas exatas**. Formato (ver `03-report-templates.md`): + +``` +[SEVERIDADE] +File: : +Description: +Impact: +Recommendation: +``` + +--- + +## Catálogo + +### AP-01 — God Class / God Module — **CRITICAL** +**O que é:** um único arquivo/classe concentra responsabilidades de várias camadas (dados, regra +de negócio, roteamento, formatação) e/ou de vários domínios. +**Sinais:** arquivo muito longo (centenas de linhas) misturando SQL, validação, regra e resposta +HTTP; uma classe `Manager`/`Helper`/`models.py` que faz tudo; múltiplos domínios no mesmo arquivo. +**Impacto:** impossível testar em isolamento; qualquer mudança arrisca quebrar tudo; viola SRP. +**Recomendação:** separar em models e controllers por domínio. + +### AP-02 — Hardcoded Credentials / Secrets — **CRITICAL** +**O que é:** segredos fixos no código (SECRET_KEY, chaves de API, senhas, tokens). +**Sinais:** `SECRET_KEY = "..."`, `paymentGatewayKey`, `password = "123"`, chaves em strings literais. +**Impacto:** exposição de credenciais em qualquer clone/commit; comprometimento de segurança. +**Recomendação:** mover para variáveis de ambiente / módulo de config (`os.environ`, `.env`). + +### AP-03 — SQL Injection / Query dinâmica insegura — **CRITICAL** +**O que é:** SQL montado por concatenação/interpolação de input do usuário, ou endpoint que executa +SQL arbitrário. +**Sinais:** `f"SELECT ... {var}"`, `"... " + req.body.x`, `cursor.execute(query_do_usuario)`, +rota tipo `/admin/query` que roda SQL recebido. +**Impacto:** leitura/escrita arbitrária no banco; vazamento e destruição de dados. +**Recomendação:** usar sempre queries parametrizadas; remover endpoints de SQL arbitrário. + +### AP-04 — Senha em texto puro / criptografia fraca — **CRITICAL** +**O que é:** senhas armazenadas/comparadas em texto puro ou com hash caseiro/inseguro. +**Sinais:** `INSERT INTO users (... pass) VALUES (..., '123')`, comparação direta `senha == input`, +função `badCrypto`, uso de `md5`/`sha1` sem salt para senha. +**Impacto:** vazamento direto de credenciais dos usuários em caso de breach. +**Recomendação:** hashing forte com salt (`bcrypt`, `argon2`, `werkzeug.security`). + +### AP-05 — Estado global mutável / Singleton implícito — **HIGH** +**O que é:** conexão de banco ou configuração guardada em variável global mutável compartilhada. +**Sinais:** `db_connection = None` global reatribuída; `global db_connection`; instância única de +conexão passeando pela app; `check_same_thread=False` para contornar o problema. +**Impacto:** acoplamento forte, condições de corrida, dificíssimo de testar e paralelizar. +**Recomendação:** injeção de dependência; factory de conexão; escopo por request. + +### AP-06 — Lógica de negócio na camada de rota/controller — **HIGH** +**O que é:** regra de negócio, acesso a dados e orquestração misturados dentro do handler da rota. +**Sinais:** handler que abre cursor, faz query, aplica regra, formata resposta — tudo inline; +rotas com dezenas de linhas; SQL dentro do arquivo de rotas. +**Impacto:** viola MVC (controller magro/model gordo) e SRP; impossível reusar/testar a regra. +**Recomendação:** extrair regra para model/service; controller só orquestra; rota só mapeia. + +### AP-07 — Callback Hell / Acoplamento sem separação (assíncrono aninhado) — **HIGH** +**O que é:** cadeias profundas de callbacks aninhados fazendo I/O sequencial, sem camadas. +**Sinais:** vários níveis de `db.get(... (err, x) => { db.run(... => { ... }) })`; `self = this` +para driblar escopo; lógica de transação espalhada em callbacks. +**Impacto:** ilegível, difícil tratar erro, propenso a bugs de fluxo; sem separação de camadas. +**Recomendação:** promisify/async-await; extrair repositório e service; transações explícitas. + +### AP-08 — Query N+1 — **MEDIUM** +**O que é:** consulta dentro de loop, uma por item, em vez de uma consulta agregada/join. +**Sinais:** `for ... { SELECT ... WHERE id = ? }`; loop sobre resultados disparando nova query +para cada elemento (ex.: relatório que busca usuário e pagamento por matrícula, um a um). +**Impacto:** performance degrada linearmente com o volume; carga desnecessária no banco. +**Recomendação:** usar JOIN ou uma query com `IN (...)`; agregação no banco. + +### AP-09 — Validação ausente nas rotas — **MEDIUM** +**O que é:** endpoints que confiam no input sem validar tipos, obrigatoriedade ou formato. +**Sinais:** `request.get_json()` usado direto sem checagem; ausência de schema (`marshmallow`/`joi`); +campos acessados sem verificar presença; conversões sem tratamento. +**Impacto:** erros 500, dados inconsistentes, superfície para abuso. +**Recomendação:** validação por schema na entrada; retornos 400 claros. + +### AP-10 — Duplicação de código — **MEDIUM** +**O que é:** o mesmo trecho (montagem de resposta, query, validação) repetido em vários lugares. +**Sinais:** blocos copiados entre handlers; formatação de erro repetida; mesma query em N rotas. +**Impacto:** manutenção multiplicada, divergência silenciosa entre cópias. +**Recomendação:** extrair função/helper/model reutilizável (DRY). + +### AP-11 — API deprecated / Uso de recurso obsoleto — **MEDIUM** +**O que é:** uso de APIs, métodos ou padrões marcados como obsoletos, com equivalente moderno. +**Como detectar:** confira a versão do framework/libs (Fase 1) e compare o uso com a documentação +atual da versão. Sinais comuns por ecossistema: +- Python/Flask: `@app.before_first_request` (removido no Flask 2.3+); `flask.Markup` (movido para + `markupsafe`); `app.run()` em produção; `datetime.utcnow()` (deprecated em favor de + `datetime.now(timezone.utc)`). +- Node/Express: `body-parser` avulso quando `express.json()` já existe; `crypto.createCipher` + (use `createCipheriv`); `new Buffer()` (use `Buffer.from`); callbacks onde a lib já oferece Promises. +- Geral: métodos marcados `@deprecated`, libs sem manutenção, sintaxe de versão anterior. +**Impacto:** quebra em upgrades futuros; perde correções de segurança/performance da versão nova. +**Recomendação:** substituir pelo equivalente moderno documentado (cite qual). +> A detecção de APIs deprecated é **obrigatória** na auditoria — sempre verifique este item. + +### AP-12 — Recursos vazando / Falta de tratamento transacional — **MEDIUM** +**O que é:** operações relacionadas que deveriam ser atômicas feitas sem transação, ou recursos +não liberados; deleção que deixa dados órfãos. +**Sinais:** `DELETE FROM users` sem limpar dependências (matrículas/pagamentos ficam órfãos); +múltiplos `INSERT` relacionados sem transação; conexões/arquivos não fechados. +**Impacto:** inconsistência de dados, integridade referencial quebrada. +**Recomendação:** transações; cascata explícita; liberação de recursos (context manager/finally). + +### AP-13 — Endpoint perigoso exposto sem proteção — **CRITICAL** +**O que é:** rotas administrativas destrutivas sem autenticação/autorização. +**Sinais:** `/admin/reset-db` que apaga tabelas; `/admin/query` que roda SQL; delete em massa +sem checagem de permissão. +**Impacto:** qualquer um pode destruir ou ler todo o banco. +**Recomendação:** remover ou proteger com auth/role; jamais expor SQL arbitrário. + +### AP-14 — Magic numbers / Strings mágicas — **LOW** +**O que é:** números/valores literais sem nome, com significado de negócio. +**Sinais:** `preco * 1.1`, `status == 2`, `timeout 3600`, taxas/limites soltos no meio do código. +**Impacto:** intenção obscura, mudança arriscada, duplicação de valores. +**Recomendação:** extrair para constantes nomeadas em config ou no topo do módulo. + +### AP-15 — Nomenclatura ruim / Baixa legibilidade — **LOW** +**O que é:** nomes de variáveis/funções não descritivos, abreviações crípticas, inconsistência. +**Sinais:** `u`, `e`, `p`, `cc`, `cid`; funções que não dizem o que fazem; mistura de idiomas. +**Impacto:** curva de leitura maior, mais bugs por mal-entendido. +**Recomendação:** renomear para nomes intencionais e consistentes. + +### AP-16 — Debug/verbosidade em produção — **LOW/MEDIUM** +**O que é:** modo debug ligado, `print`/`console.log` de dados sensíveis, stack traces expostos. +**Sinais:** `DEBUG = True`, `debug=True` no `run`, `console.log` de cartão/senha/chave. +**Impacto:** vazamento de informação, superfície de ataque (LOW se cosmético; MEDIUM se vaza dado sensível). +**Recomendação:** controlar por ambiente; logging estruturado sem dados sensíveis. + +--- + +## Cobertura mínima e distribuição + +- Este catálogo tem **16 anti-patterns** cobrindo as 4 severidades (CRITICAL: AP-01..04, AP-13; + HIGH: AP-05..07; MEDIUM: AP-08..12, AP-16; LOW: AP-14, AP-15). +- Sempre inclua a checagem de **APIs deprecated (AP-11)**. +- Cruze com `04-mvc-guidelines.md` para gerar os findings de **aderência a MVC** (o que está fora + de cada camada, com arquivo e linhas). diff --git a/.claude/skills/refactor-arch/references/03-report-templates.md b/.claude/skills/refactor-arch/references/03-report-templates.md new file mode 100644 index 000000000..3f5910252 --- /dev/null +++ b/.claude/skills/refactor-arch/references/03-report-templates.md @@ -0,0 +1,172 @@ +# Templates de Saída — Seguir à Risca + +Reproduza estes templates **exatamente**: mesma estrutura, mesmos rótulos, mesma ordem de seções, +mesmos separadores. A largura dos separadores é de **32 sinais de igual** (`====...`). Os campos +usam alinhamento por espaços para os valores ficarem em coluna. Não adicione, remova ou renomeie +seções. A única variação permitida é **omitir linhas de campos que não se aplicam** (Fase 1) e +**refletir o estado real** na linha de anti-patterns (Fase 3). + +--- + +## Fase 1 — `PHASE 1: PROJECT ANALYSIS` + +``` +================================ +PHASE 1: PROJECT ANALYSIS +================================ +Language: +Framework: +Dependencies: +Database: +Domain: +Architecture: +Source files: files analyzed +DB tables: +================================ +``` + +**Regras:** +- Omita a linha inteira de qualquer campo sem valor no projeto (ex.: projeto sem banco → sem + `Database` e sem `DB tables`). Não escreva "N/A". +- Mantenha a ordem dos campos acima. + +**Exemplo real (code-smells-project):** +``` +================================ +PHASE 1: PROJECT ANALYSIS +================================ +Language: Python +Framework: Flask 3.1.1 +Dependencies: flask-cors +Database: SQLite (driver sqlite3, conexão global) — loja.db +Domain: E-commerce API (produtos, pedidos, usuários) +Architecture: Monolítica — tudo em 4 arquivos, sem separação de camadas +Source files: 4 files analyzed +DB tables: produtos, usuarios, pedidos, itens_pedido +================================ +``` + +--- + +## Fase 2 — `ARCHITECTURE AUDIT REPORT` + +Este é o conteúdo **impresso no console** e **salvo** em `reports/audit-project-.md` (idêntico). + +``` +================================ +ARCHITECTURE AUDIT REPORT +================================ +Project: +Stack: +Files: analyzed | ~ lines of code + +Summary +CRITICAL: | HIGH: | MEDIUM: | LOW: + +MVC Adherence: /10 () + Models: — + Views/Routes: — + Controllers: — + Separation: — + +Findings + +[SEVERIDADE] +File: : +Description: +Impact: +Recommendation: + +[SEVERIDADE] +File: : +Description: ... +Impact: ... +Recommendation: ... + +================================ +Total: findings +================================ +``` + +**Regras:** +- `MVC Adherence`: score de 0 a 10; rótulo sugerido — `Monolítico` (0–3), `Parcial` (4–7), + `Aderente` (8–10). Cada camada marcada como presente / parcial / ausente; `Separation` como + `ok` ou `violada`. +- `Findings`: ordenados por severidade decrescente (CRITICAL → HIGH → MEDIUM → LOW). Separe cada + finding com uma linha em branco. As violações de MVC entram aqui como findings normais. +- `Total: findings` = soma de todos os findings (deve bater com o `Summary`). +- Logo após imprimir o relatório no console, **peça a confirmação** (bloco abaixo). O texto de + confirmação **não** faz parte do arquivo salvo. + +**Gate de confirmação (somente no console, após o relatório):** +``` +Phase 2 complete. Proceed with refactoring (Phase 3)? [y/n] +``` + +--- + +## Fase 3 — `PHASE 3: REFACTORING COMPLETE` + +``` +[... refatoração executada ...] + +================================ +PHASE 3: REFACTORING COMPLETE +================================ +New Project Structure: +<árvore de diretórios MVC gerada, no estilo tree> + +Validation + ✓ Application boots without errors + ✓ All endpoints respond correctly + +================================ +``` + +**Regra da linha de anti-patterns (reflete o estado real):** +- Se o loop resolveu tudo: + ``` + ✓ Zero anti-patterns remaining + ``` +- Se sobraram findings após 3 loops: + ``` + ⚠ findings remaining after 3 loops: + - [SEVERIDADE] (:) + - ... + ``` + +**Regras adicionais:** +- `Application boots without errors` e `All endpoints respond correctly` só recebem `✓` se a + validação real (subir app + bater endpoints) passou. Se algo falhou, **não** finalize como + concluído — corrija e revalide. +- A árvore em `New Project Structure` deve refletir os arquivos realmente criados, com uma nota + curta entre parênteses nos pontos-chave quando ajudar (ex.: `app.py (composition root)`). + +**Exemplo de árvore (Python/Flask — camadas na raiz, sem `src/`):** +``` +config/settings.py +models/ +├── produto_model.py +└── usuario_model.py +views/ +└── routes.py +controllers/ +├── produto_controller.py +└── pedido_controller.py +middlewares/error_handler.py +app.py (composition root) +``` + +**Exemplo de árvore (Node.js/Express — usa `src/`):** +``` +src/ +├── config/index.js +├── models/ +│ └── course.js +├── routes/ +│ └── checkout.routes.js +├── controllers/ +│ └── checkout.controller.js +├── middlewares/errorHandler.js +└── app.js (composition root) +``` diff --git a/.claude/skills/refactor-arch/references/04-mvc-guidelines.md b/.claude/skills/refactor-arch/references/04-mvc-guidelines.md new file mode 100644 index 000000000..21fe6afaa --- /dev/null +++ b/.claude/skills/refactor-arch/references/04-mvc-guidelines.md @@ -0,0 +1,106 @@ +# Guidelines de Arquitetura — Padrão MVC Alvo + +Estas são as regras do padrão MVC que a auditoria (Fase 2) usa para medir aderência e que a +refatoração (Fase 3) usa como destino. As **camadas conceituais são sempre as mesmas**; o +**layout de arquivos adapta-se à convenção da linguagem** (agnosticidade). + +## As camadas e suas responsabilidades + +### Model (camada de dados + regra de domínio) +- Representa e persiste as entidades do domínio; encapsula o **acesso a dados**. +- Contém a **regra de negócio** ligada à entidade. +- **Não** conhece HTTP (request/response), rotas nem formatação de saída. +- Toda query/SQL/ORM vive aqui (ou num repositório usado pelo model). Nada de SQL solto em rotas. + +### View / Routes (camada de apresentação / roteamento) +- Em APIs, a "View" é a **camada de rotas**: mapeia caminho + método HTTP → um controller. +- Apenas **declara** as rotas e delega. **Não** contém regra de negócio nem acesso a dados. +- Responsável por serializar a resposta no formato do contrato (JSON), sem lógica de domínio. + +### Controller (orquestração) +- Recebe a requisição já roteada, **valida a entrada**, chama os models/services necessários, + monta a resposta e trata erros. +- É **magro**: orquestra, não implementa a regra de negócio (essa é do model/service). +- Um controller por recurso/domínio (ex.: `produto_controller`, `pedido_controller`). + +### Config (configuração) +- Centraliza configuração e **segredos via ambiente** (nunca hardcoded): chaves, URLs de banco, + flags de debug, portas. +- Um único ponto de verdade para configuração da aplicação. + +### Middlewares (transversais) +- Preocupações transversais: **tratamento de erro centralizado**, CORS, autenticação, logging. +- Evita `try/except` repetido em cada rota. + +### Entry point / Composition root +- Um arquivo fino que **monta a aplicação**: cria o app, carrega config, registra middlewares e + rotas, injeta dependências (ex.: conexão de banco) e sobe o servidor. +- É onde as peças são conectadas — não contém regra de negócio. + +## Fluxo alvo + +``` +Request → Route (view) → Controller → Model/Service → (DB) + ↓ +Response ← Route (view) ← Controller ← Model/Service + (erros → Middleware de erro centralizado) +``` + +## Layout por stack (mesmas camadas, idioma diferente) + +> **Regra de layout por linguagem:** o **Python NÃO usa pasta `src/`** — as camadas ficam direto +> na raiz do projeto, com o `app.py` na raiz (idiomático em Flask; é como o `task-manager-api` já +> organiza `models/`, `routes/`, `services/`). O **Node.js usa `src/`** por convenção do ecossistema. + +**Python / Flask** (camadas na raiz do projeto — **sem `src/`**): +``` +/ +├── config/settings.py # config + segredos via os.environ +├── models/_model.py # dados + regra por domínio +├── views/routes.py # blueprints / mapeamento de rotas +├── controllers/_controller.py +├── middlewares/error_handler.py +└── app.py # composition root (create_app), na raiz +``` + +**Node.js / Express** (usa `src/`): +``` +src/ +├── config/index.js # config + segredos via process.env +├── models/.js # ou repositories/ para acesso a dados +├── routes/.routes.js # mapeamento de rotas (a "view") +├── controllers/.controller.js +├── middlewares/errorHandler.js +└── app.js # composition root +``` + +Para outras stacks, siga o idioma da linguagem mantendo as 6 camadas. Se o framework usa outra +convenção consagrada (ex.: services/repositories), respeite-a desde que a separação de +responsabilidades acima seja preservada. + +## Como pontuar a aderência a MVC (Fase 2) + +Avalie cada camada e atribua o checklist + um score de 0 a 10: + +| Camada | `presente` | `parcial` | `ausente` | +|---|---|---|---| +| Models | acesso a dados e regra isolados por entidade | existe, mas com lógica vazando p/ rotas | SQL/regra espalhados, sem model | +| Views/Routes | rotas só mapeiam e delegam | rotas existem mas contêm lógica | rotas e handlers misturados no entrypoint | +| Controllers | controllers magros orquestrando | lógica parcial em controllers | não há camada de controller | +| Separation | camadas não se contaminam | alguma contaminação | God Class / tudo junto | + +Rótulo do score: `Monolítico` (0–3), `Parcial` (4–7), `Aderente` (8–10). Baseie o número na +quantidade de camadas presentes e na ausência de contaminação. Justifique cada linha com um motivo +curto e concreto (o "porquê" observado no código). + +## Anti-aderências comuns (viram findings de MVC) + +- Regra de negócio dentro da rota/handler → mover para model/service (AP-06). +- SQL dentro de controller ou rota → mover para model/repositório. +- Estado global de conexão → injetar dependência no composition root (AP-05). +- Roteamento + lógica + dados no mesmo arquivo → separar em camadas (AP-01). +- Config/segredo hardcoded → extrair para `config` via ambiente (AP-02). +- Tratamento de erro repetido em cada rota → middleware centralizado. + +Cada uma dessas, quando encontrada, entra como **finding** com arquivo e linhas exatas, além de +rebaixar o score de aderência. diff --git a/.claude/skills/refactor-arch/references/05-refactoring-playbook.md b/.claude/skills/refactor-arch/references/05-refactoring-playbook.md new file mode 100644 index 000000000..9799b9625 --- /dev/null +++ b/.claude/skills/refactor-arch/references/05-refactoring-playbook.md @@ -0,0 +1,261 @@ +# Fase 3 — Playbook de Refatoração + +Este playbook traz **transformações concretas** (antes → depois) para os anti-patterns do catálogo, +o **loop de correção controlado** e a **validação por execução real**. O objetivo é chegar ao MVC +de `04-mvc-guidelines.md` **preservando os contratos externos** (mesmos paths, métodos, formato de +resposta e schema). + +## Processo geral da Fase 3 + +1. Leia o relatório salvo na Fase 2 (`reports/audit-project-.md`) — ele é a lista de trabalho. +2. Crie a estrutura de pastas MVC (layout conforme a stack, ver `04-mvc-guidelines.md`). +3. Mova o código para as camadas aplicando as transformações abaixo, endereçando cada finding. +4. Rode o **loop de correção** (máx. 3 iterações) com re-auditoria silenciosa. +5. **Valide** subindo a aplicação e batendo em todos os endpoints. +6. Imprima `PHASE 3: REFACTORING COMPLETE` (ver `03-report-templates.md`). + +--- + +## Transformações (antes → depois) + +### T-01 — Quebrar God Class em models + controllers por domínio (AP-01) +**Antes:** `models.py` com SQL, regra, validação e formatação de 4 domínios. +**Depois:** `models/produto_model.py`, `models/pedido_model.py`, ... (dados + regra por entidade) +e `controllers/produto_controller.py`, ... (orquestração). Cada arquivo com uma responsabilidade. + +### T-02 — Extrair segredos para config via ambiente (AP-02, AP-16) +**Antes (Python):** +```python +app.config["SECRET_KEY"] = "minha-chave-super-secreta-123" +app.config["DEBUG"] = True +``` +**Depois:** +```python +# config/settings.py +import os +SECRET_KEY = os.environ["SECRET_KEY"] +DEBUG = os.environ.get("DEBUG", "false").lower() == "true" +``` +**Node:** trocar literais por `process.env.X` num `config/index.js`. Adicione `.env.example`. + +### T-03 — Parametrizar SQL e remover SQL arbitrário (AP-03, AP-13) +**Antes:** +```python +cursor.execute(query) # query vinda do usuário +cursor.execute(f"SELECT * FROM produtos WHERE nome = '{nome}'") +``` +**Depois:** +```python +cursor.execute("SELECT * FROM produtos WHERE nome = ?", (nome,)) +``` +Remova endpoints de SQL arbitrário (`/admin/query`) e proteja/remova destrutivos (`/admin/reset-db`). + +### T-04 — Hashear senhas (AP-04) +**Antes:** `senha` salva/comparada em texto puro; `badCrypto`. +**Depois (Python):** +```python +from werkzeug.security import generate_password_hash, check_password_hash +hash_ = generate_password_hash(senha) # ao criar +check_password_hash(user["senha"], senha_informada) # ao logar +``` +**Node:** `bcrypt.hash` / `bcrypt.compare`. **Nunca** retorne o hash/senha na resposta. + +### T-05 — Injetar a conexão em vez de estado global (AP-05) +**Antes:** `db_connection` global mutável; `global db_connection`. +**Depois:** criar a conexão no composition root e injetá-la nos models/controllers (parâmetro ou +factory). Escopo por request quando a stack permitir. Remover `check_same_thread=False` como gambiarra. + +### T-06 — Mover regra da rota para model/service; controller magro (AP-06) +**Antes:** handler da rota abre cursor, faz query, aplica regra, formata resposta. +**Depois:** +```python +# views/routes.py → só mapeia +app.add_url_rule("/produtos", "listar_produtos", produto_controller.listar, methods=["GET"]) +# controllers/produto_controller.py → orquestra +def listar(): + produtos = ProdutoModel.listar_todos() + return jsonify(produtos), 200 +# models/produto_model.py → dados + regra +class ProdutoModel: + @staticmethod + def listar_todos(): ... +``` + +### T-07 — Desaninhar callbacks / extrair repositório (AP-07) +**Antes (Node):** cadeia de `db.get(... => { db.run(... => { ... }) })` dentro do handler. +**Depois:** promisificar o acesso ao banco (`util.promisify` ou wrapper que retorna Promise), +extrair um repositório (`models`/`repositories`) e usar `async/await` no controller, com +transação explícita para operações relacionadas (checkout: matrícula + pagamento + log). + +### T-08 — Eliminar N+1 com JOIN/agregação (AP-08) +**Antes:** loop sobre matrículas buscando usuário e pagamento um a um. +**Depois:** uma query com `JOIN` (ou `WHERE id IN (...)`) trazendo o conjunto de uma vez; montar a +resposta em memória. Preserve o formato final do JSON. + +### T-09 — Validar entrada nas rotas (AP-09) +**Antes:** `dados = request.get_json()` usado direto. +**Depois:** validar no controller (schema `marshmallow`/`joi`/checagem explícita); retornar `400` +com mensagem clara quando faltar campo ou o tipo for inválido. Mantenha os campos do contrato. + +### T-10 — Centralizar tratamento de erro em middleware (AP-12 parcial, transversal) +**Antes:** `try/except`/`if err return 500` repetido em cada handler. +**Depois:** um middleware/error handler único (`middlewares/error_handler.py` / +`middlewares/errorHandler.js`) registrado no composition root; controllers levantam/repassam erro. + +### T-11 — Atomicidade e integridade (AP-12) +**Antes:** múltiplos inserts relacionados sem transação; `DELETE` deixando órfãos. +**Depois:** envolver operações relacionadas em transação (commit/rollback); ao deletar, tratar +dependências (cascata explícita) para não deixar dados órfãos. + +### T-12 — Substituir APIs deprecated (AP-11) +Troque cada uso obsoleto pelo equivalente moderno detectado na Fase 1. Exemplos: +`@app.before_first_request` → inicialização no factory; `datetime.utcnow()` → +`datetime.now(timezone.utc)`; `body-parser` avulso → `express.json()`; `new Buffer()` → +`Buffer.from()`. Cite no relatório final o que foi trocado. + +### T-13 — Nomear constantes e melhorar legibilidade (AP-14, AP-15) +Extrair magic numbers para constantes nomeadas (em config ou topo do módulo); renomear variáveis +crípticas (`u`, `e`, `cc`) para nomes intencionais. Não altere comportamento. + +### T-14 — Eliminar duplicação de código (DRY) (AP-10) +**O que é:** o mesmo trecho (montagem de resposta, query, validação, formatação de erro) repetido +em vários handlers. +**Antes:** +```python +# em várias rotas, o mesmo bloco: +produto = cursor.execute("SELECT * FROM produtos WHERE id = ?", (id,)).fetchone() +if produto is None: + return jsonify({"erro": "Produto não encontrado"}), 404 +``` +**Depois:** +```python +# models/produto_model.py — a query fica num único lugar +class ProdutoModel: + @staticmethod + def buscar_por_id(id): + return cursor.execute("SELECT * FROM produtos WHERE id = ?", (id,)).fetchone() + +# controllers/produto_controller.py — reuso, sem repetição +def _obter_ou_404(id): + produto = ProdutoModel.buscar_por_id(id) + if produto is None: + abort(404, description="Produto não encontrado") + return produto +``` +Extraia o trecho repetido para uma função/helper/model reutilizável (ou middleware, quando for +formatação de erro). Regra prática: se o mesmo bloco aparece 3+ vezes, vira função. Preserve o +comportamento e o contrato de resposta. + +--- + +## Loop de correção controlado (máx. 3 iterações) + +``` +iteração = 1 +enquanto iteração <= 3: + aplicar correções pendentes (transformações acima, guiadas pelos findings) + resultado = RE-AUDITAR silenciosamente (mesmos critérios da Fase 2) + # NÃO imprimir os findings desta re-auditoria + se resultado.sem_problemas_e_sem_regressao: + sucesso = verdadeiro; sair do loop + senão: + pendentes = resultado.findings_restantes + iteração += 1 +``` + +- A re-auditoria usa **os mesmos critérios** de `02-antipattern-catalog.md` e `04-mvc-guidelines.md`. +- Ela verifica **duas** coisas: (a) os findings originais foram resolvidos e (b) a refatoração + **não introduziu** problemas novos. Se introduziu, trate-os como pendentes na próxima iteração. +- É **silenciosa**: serve para decidir se continua; nada dela vai para o console. +- Ao sair (sucesso ou 3 iterações), siga para a validação e a impressão final. + +--- + +## Validação por execução real (obrigatória) + +A aplicação **não pode quebrar**. Prove que funciona: + +1. **Instale dependências** no diretório do projeto: + - Python: `pip install -r requirements.txt` (idealmente num venv). + - Node: `npm install`. +2. **Suba o servidor** em background e aguarde ele ficar pronto: + - Python/Flask: `python app.py` (na raiz do projeto — Python **não** usa `src/`) — porta detectada na Fase 1 (ex.: 5000). + - Node/Express: `npm start` ou `node src/app.js` — porta detectada (ex.: 3000). +3. **Exercite todos os endpoints originais** com requisições HTTP reais (`curl` ou similar): + - Use as rotas mapeadas na Fase 1 e, se existir, o arquivo `api.http` do projeto. + - Confirme códigos de status esperados e formato de resposta compatível com o contrato original. + - Cubra os métodos (GET/POST/PUT/DELETE) e ao menos um caminho feliz por recurso. +4. **Encerre o servidor** (mate o processo em background). +5. Só marque `✓ Application boots without errors` e `✓ All endpoints respond correctly` se ambos + realmente passaram. Se algo falhar, **corrija e revalide** — não finalize quebrado. + +> Dica: rode o servidor com `run_in_background`, capture o log de boot para confirmar que subiu sem +> stack trace, faça as chamadas, e então encerre o processo. Se a app usa banco in-memory, o seed +> roda no boot — valide contra os dados semeados. + +### Evidência de validação (obrigatória — salvar em arquivo) + +A validação **não pode depender só da palavra do agente**. Capture as saídas reais e salve um +relatório de evidência em `reports/validation-project-.md`, usando **o mesmo ``** do relatório +de auditoria gerado para este projeto na Fase 2 (pareia `audit-project-.md` ↔ +`validation-project-.md`, na mesma pasta `reports/` da raiz do repo — mesma regra de caminho da +Fase 2). + +Registre no arquivo os **comandos reais executados e suas saídas reais** (não invente): o comando de +boot com o trecho do log que prova que o servidor subiu, e, para cada endpoint, a requisição feita +(método, path, payload) com o **status HTTP e um trecho da resposta** de fato retornados. + +Template do arquivo de evidência: + +``` +================================ +VALIDATION EVIDENCE — +================================ +Date: +Stack: +Port: + +## Boot +$ +$ +[log] +Boot: ✓ OK + +## Endpoints +| Método | Path | Payload | Status | Esperado | OK | +|--------|------------------------------|-----------------|--------|----------|----| +| GET | /produtos | — | 200 | 200 | ✓ | +| POST | /login | {email,senha} | 200 | 200 | ✓ | +| ... | ... | ... | ... | ... | .. | + +## Amostras de resposta (trechos reais) +- GET /produtos → 200 : +- POST /login → 200 : + +================================ +RESULT: (/ endpoints) +================================ +``` + +Regras: +- O `RESULT` reflete o resultado **real**. Se algum endpoint falhou, `FAIL` — e a Fase 3 não está + concluída (corrija e revalide, regerando a evidência). +- Se um status "de erro" é o esperado (ex.: `401` num endpoint admin agora protegido), marque `OK` + na coluna comparando com a coluna `Esperado`, não com `200`. +- Este arquivo é o comprovante auditável de que a app funciona após o refactor. + +--- + +## Checklist de conclusão da Fase 3 + +- [ ] Estrutura de diretórios segue MVC (layout idiomático da stack). +- [ ] Config/segredos fora do código (via ambiente). +- [ ] Models abstraem dados e regra; sem SQL em rotas/controllers. +- [ ] Views/Routes só mapeiam e delegam. +- [ ] Controllers magros orquestrando. +- [ ] Tratamento de erro centralizado (middleware). +- [ ] Entry point/composition root claro. +- [ ] Contratos externos preservados (paths, métodos, formato, schema). +- [ ] Loop de correção executado (≤ 3 iterações). +- [ ] App sobe sem erros e todos os endpoints respondem (validação real). +- [ ] Saída final impressa no formato de `03-report-templates.md`, refletindo o estado real. diff --git a/.claude/skills/refactor-arch/references/06-validation-checklist.md b/.claude/skills/refactor-arch/references/06-validation-checklist.md new file mode 100644 index 000000000..d3fcc4fb1 --- /dev/null +++ b/.claude/skills/refactor-arch/references/06-validation-checklist.md @@ -0,0 +1,51 @@ +# Checklist de Validação por Fase (auto-verificação interna) + +Ao final de **cada fase**, execute o checklist correspondente como uma **auto-verificação interna**. + +**Regra de manifestação (importante):** +- A validação é **silenciosa por padrão**: se todos os itens da fase passarem, **não imprima** o + checklist nem qualquer mensagem — apenas siga para a próxima fase. +- A validação só se **manifesta quando algo está fora do padrão**: se algum item falhar, informe + qual item falhou e **corrija antes de prosseguir** (refaça a parte da fase que falhou). Só avance + quando todos os itens passarem. + +Ou seja: o console permanece limpo no caminho feliz; o checklist só aparece para sinalizar e +resolver um desvio. + +--- + +## Fase 1 — Análise +- [ ] Linguagem detectada corretamente +- [ ] Framework detectado corretamente +- [ ] Domínio da aplicação descrito corretamente +- [ ] Número de arquivos analisados condiz com a realidade + +Se algum falhar: releia os manifestos/fontes e refaça a detecção antes de seguir para a Fase 2. + +## Fase 2 — Auditoria +- [ ] Relatório segue o template definido nos arquivos de referência +- [ ] Cada finding tem arquivo e linhas exatos +- [ ] Findings ordenados por severidade (CRITICAL → LOW) +- [ ] Mínimo de 5 findings identificados +- [ ] Detecção de APIs deprecated incluída (se aplicável) +- [ ] Skill pausa e pede confirmação antes da Fase 3 + +Se "mínimo de 5 findings" falhar: **re-audite com mais profundidade** — os projetos-alvo têm vários +problemas; menos de 5 quase sempre indica auditoria rasa, não projeto limpo. Reveja o catálogo +inteiro (`02-antipattern-catalog.md`) e a aderência a MVC (`04-mvc-guidelines.md`). +Se qualquer outro item falhar: ajuste o relatório antes de exibir o gate de confirmação. + +## Fase 3 — Refatoração +- [ ] Estrutura de diretórios segue padrão MVC +- [ ] Configuração extraída para módulo de config (sem hardcoded) +- [ ] Models criados para abstrair dados +- [ ] Views/Routes separadas para visualização ou roteamento +- [ ] Controllers concentram o fluxo da aplicação +- [ ] Error handling centralizado +- [ ] Entry point claro +- [ ] Aplicação inicia sem erros +- [ ] Endpoints originais respondem corretamente + +Lembre do layout por linguagem (`04-mvc-guidelines.md`): **Python sem `src/`** (camadas na raiz), +**Node com `src/`**. Se "aplicação inicia sem erros" ou "endpoints respondem" falharem, **não +finalize** — corrija e revalide (subir app + bater endpoints) até passar. diff --git a/.gitignore b/.gitignore index 97d5de914..277358e97 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ venv/ # SQLite *.db instance/ + +# Diário de decisões (notas internas de trabalho, não faz parte do entregável) +DECISIONS.md diff --git a/README.md b/README.md index 431e6ffb7..bef1a6eac 100644 --- a/README.md +++ b/README.md @@ -445,4 +445,384 @@ A skill deve atingir os seguintes mínimos em **todos os 3 projetos**: - **A skill deve ser copiável** — se ela só funciona em um projeto específico, está acoplada demais. Teste nos 3 projetos para validar. - **Projetos diferentes exigem adaptação** — a Fase 3 de um projeto já parcialmente organizado não vai ter as mesmas transformações de um monolito. Sua skill deve se adaptar ao contexto. - **Pedir confirmação na Fase 2 é obrigatório** — o humano deve revisar o relatório antes de qualquer modificação. -- **Consulte as referências do curso** — revise a documentação oficial da ferramenta escolhida e os materiais das aulas para relembrar a estrutura e anatomia de uma skill. \ No newline at end of file +- **Consulte as referências do curso** — revise a documentação oficial da ferramenta escolhida e os materiais das aulas para relembrar a estrutura e anatomia de uma skill. + +--- + +## Análise Manual + +Análise feita **manualmente**, lendo o código-fonte legado dos três projetos antes de +qualquer refatoração. Para cada projeto esta seção entrega: + +1. **Lista dos problemas identificados** — com arquivo e linha exatos do código original. +2. **Classificação por severidade** — segundo a escala definida na seção *Contexto* + (CRITICAL / HIGH / MEDIUM / LOW), sinalizada na tag de cada achado e resumida na tabela abaixo. +3. **Justificativa de relevância** — o campo **"Por que importa"** em cada item explica o + impacto (segurança, arquitetura, integridade de dados ou manutenibilidade) e por que ele + merece aquela severidade. + +Os achados de cada projeto estão ordenados do **mais crítico ao menos crítico**. O foco é o +impacto arquitetural e de segurança — não é uma lista exaustiva. + +| Projeto | Stack | CRITICAL | HIGH | MEDIUM | LOW | +|---|---|:-:|:-:|:-:|:-:| +| code-smells-project | Python / Flask | 4 | 1 | 2 | 2 | +| ecommerce-api-legacy | Node.js / Express | 3 | 2 | 2 | 2 | +| task-manager-api | Python / Flask | 2 | 1 | 3 | 2 | + +### Projeto 1 — `code-smells-project/` (Python / Flask — API de E-commerce) + +#### [CRITICAL] SQL Injection generalizado +**Arquivo:** `models.py` (ex.: `:28`, `:47-50`, `:109-111`, `:140`, `:289-297`) +Todas as queries são montadas por concatenação de strings com dados do usuário, sem +parâmetros vinculados. Em `login_usuario` (`:109-111`) o payload +`' OR '1'='1` já derruba a autenticação. **Por que importa:** permite ler/alterar/apagar +qualquer dado e burlar login — a falha de segurança mais grave e mais espalhada do projeto. + +#### [CRITICAL] Endpoint de execução arbitrária de SQL +**Arquivo:** `app.py:59-78` (`POST /admin/query`) +A rota executa qualquer SQL enviado no corpo da requisição, sem autenticação. **Por que +importa:** é um backdoor de comprometimento total do banco exposto na API pública. + +#### [CRITICAL] Senhas em texto puro e expostas na resposta +**Arquivo:** `database.py:76-78`, `models.py:83` e `:99` +As senhas são gravadas em claro (seed) e devolvidas no JSON de `GET /usuarios`; o login +compara texto puro. **Por que importa:** vaza credenciais de todos os usuários e viola +qualquer requisito mínimo de proteção de dados. + +#### [CRITICAL] Segredos hardcoded e vazados pela API +**Arquivo:** `app.py:7-8` e `controllers.py:289` +`SECRET_KEY` fixa no código, `DEBUG=True` em "produção" (`app.py:88`) e o `/health` +devolve a própria `secret_key` no JSON. **Por que importa:** o segredo da aplicação está +versionado e ainda é servido a qualquer cliente; o modo debug expõe stack traces e o +console interativo do Werkzeug. + +#### [HIGH] Estado global de conexão + God module + admin sem proteção +**Arquivo:** `database.py:4,10`; `models.py` (arquivo inteiro); `app.py:47-57` +Conexão SQLite global compartilhada entre threads (`check_same_thread=False`), um único +módulo `models.py` misturando acesso a dados e regra de negócio (ex.: cálculo de desconto +em `relatorio_vendas`), e `POST /admin/reset-db` apagando todas as tabelas sem auth. **Por +que importa:** acopla tudo, quebra o isolamento de camadas exigido pelo MVC e torna testes +unitários praticamente impossíveis. + +#### [MEDIUM] Queries N+1 na listagem de pedidos +**Arquivo:** `models.py:187-199` e `:219-231` +Para cada pedido abre-se um cursor por item e mais um por produto. **Por que importa:** o +número de queries cresce linearmente com itens/pedidos, virando gargalo de performance. + +#### [MEDIUM] Validação duplicada e efeito colateral no controller +**Arquivo:** `controllers.py:24-96` e `:208-210` +As mesmas validações se repetem entre `criar_produto` e `atualizar_produto`, e o envio de +notificações é feito com `print` dentro do controller de pedido. **Por que importa:** +duplicação dificulta manutenção e mistura responsabilidades (validação + notificação) na +camada errada. + +#### [LOW] `print()` usado como mecanismo de log +**Arquivo:** `controllers.py:8,11,57,106,...` +Sem níveis, timestamp ou destino configurável. **Por que importa:** dificulta observar a +aplicação em produção; o certo é o módulo `logging`. + +#### [LOW] Magic numbers na regra de desconto +**Arquivo:** `models.py:256-262` +Faixas `10000/5000/1000` e taxas `0.1/0.05/0.02` soltas no código. **Por que importa:** +regra de negócio ilegível e difícil de ajustar; deveria estar em constantes nomeadas. + +### Projeto 2 — `ecommerce-api-legacy/` (Node.js / Express — LMS com checkout) + +#### [CRITICAL] Credenciais de produção hardcoded +**Arquivo:** `src/utils.js:1-7` +Senha do banco, chave **live** do gateway de pagamento (`pk_live_...`) e usuário SMTP no +código. **Por que importa:** segredos de produção versionados — vazam no repositório e +permitem cobrar cartões e acessar a infraestrutura real. + +#### [CRITICAL] Número de cartão e chave do gateway logados +**Arquivo:** `src/AppManager.js:45` +`console.log` imprime o número completo do cartão e a chave live a cada checkout. **Por que +importa:** violação direta de PCI-DSS; dados de cartão nunca podem ir para logs. + +#### [CRITICAL] Hash de senha quebrado +**Arquivo:** `src/utils.js:17-23` e `src/AppManager.js:68` +`badCrypto` é determinístico, sem salt e truncado a 10 caracteres; o cadastro ainda usa +`"123456"` como senha padrão. **Por que importa:** não é hash de verdade — senhas são +trivialmente reversíveis/colidíveis e contas nascem com senha conhecida. + +#### [HIGH] God Class `AppManager` + banco in-memory volátil +**Arquivo:** `src/AppManager.js:4-7` +Uma única classe concentra init do banco, rotas, pagamento e auditoria; e o SQLite é +`:memory:`, perdendo todos os dados a cada restart. **Por que importa:** viola totalmente a +separação de camadas e torna o sistema inutilizável como persistência real. + +#### [HIGH] Checkout sem transação e em callback hell +**Arquivo:** `src/AppManager.js:28-78` +Matrícula, pagamento e auditoria são gravados em callbacks aninhados, sem transação. Se um +passo falha no meio, sobram matrículas sem pagamento. **Por que importa:** compromete a +consistência financeira — o problema mais grave de um fluxo de checkout. + +#### [MEDIUM] N+1 e coordenação assíncrona manual no relatório +**Arquivo:** `src/AppManager.js:80-129` +O relatório financeiro dispara uma query por curso, por matrícula e por usuário, coordenadas +com contadores manuais (`coursesPending`/`enrPending`). **Por que importa:** frágil (fácil +errar a contagem e nunca responder) e com performance ruim. + +#### [MEDIUM] Delete de usuário deixa órfãos e "pagamento" fake +**Arquivo:** `src/AppManager.js:131-137` e `:47` +Ao deletar usuário, matrículas e pagamentos ficam órfãos (o próprio código admite isso na +resposta), e a aprovação de pagamento é `cc.startsWith("4")`. **Por que importa:** +integridade referencial furada e regra de negócio fictícia no lugar do gateway. + +#### [LOW] Exports mortos / estado global inútil +**Arquivo:** `src/utils.js:9-10,25` +`globalCache` e `totalRevenue` são exportados mas nunca cumprem função real. **Por que +importa:** código morto que confunde e sugere estado global compartilhado indevido. + +#### [LOW] `console.log` como log e magic strings de status +**Arquivo:** `src/AppManager.js` (`"PAID"`/`"DENIED"` espalhados) +**Por que importa:** logging inadequado e strings mágicas que deveriam ser constantes/enum. + +### Projeto 3 — `task-manager-api/` (Python / Flask — API de Task Manager) + +> Projeto já com alguma separação de camadas (models, routes, services, utils), mas com +> problemas de segurança, arquitetura e código morto. + +#### [CRITICAL] Hash MD5 sem salt e hash exposto na API +**Arquivo:** `models/user.py:29,32` e `:22` +Senhas são "protegidas" com MD5 sem salt, e o `to_dict` inclui o campo `password` (o hash), +devolvido em `GET /users`, `POST /users` e no `/login`. **Por que importa:** MD5 é quebrável +por rainbow tables e o hash ainda vaza pela API — dupla falha de credenciais. + +#### [CRITICAL] Credenciais SMTP hardcoded +**Arquivo:** `services/notification_service.py:9-10` +Usuário e senha de e-mail fixos no código. **Por que importa:** segredo versionado que dá +acesso à conta de envio; deveria vir de variável de ambiente/config. + +#### [HIGH] Segredo fixo e autenticação falsa +**Arquivo:** `app.py:13` e `routes/user_routes.py:210` +`SECRET_KEY` hardcoded e o login retorna `'fake-jwt-token-' + id` — um "token" previsível, +sem assinatura nem expiração. **Por que importa:** a autenticação é decorativa; qualquer um +forja o token de outro usuário. + +#### [MEDIUM] Regra de negócio no controller e duplicada +**Arquivo:** `routes/task_routes.py:30-39`, `routes/user_routes.py:171-180`, `routes/report_routes.py:34-43` +O cálculo de "overdue" é reescrito à mão em três rotas, enquanto `Task.is_overdue()` +(`models/task.py:50`) existe e nunca é usado. **Por que importa:** lógica na camada errada e +triplicada — corrigir a regra exige mexer em três lugares. + +#### [MEDIUM] Queries N+1 e ausência de paginação +**Arquivo:** `routes/report_routes.py:53-68`, `routes/user_routes.py:22`, `routes/task_routes.py:11` +O relatório faz uma query por usuário, `get_users` dispara lazy-load com `len(u.tasks)`, e +nenhuma listagem tem paginação. **Por que importa:** performance degrada com o volume de +dados e as respostas ficam ilimitadas. + +#### [MEDIUM] `except:` nu engolindo erros +**Arquivo:** `routes/task_routes.py:62`, `:236`; `routes/user_routes.py:130`, `:149` +Blocos `except:` capturam tudo e devolvem "Erro interno" genérico. **Por que importa:** +esconde bugs reais e dificulta o diagnóstico em produção. + +#### [LOW] Imports e código mortos +**Arquivo:** `routes/task_routes.py:7`; `utils/helpers.py`; `services/notification_service.py` +Imports não usados (`os, sys, json, time`), funções nunca chamadas em `helpers.py` +(`process_task_data`, `sanitize_string`, `generate_id`) e o `NotificationService` que nunca +é ligado a nenhuma rota. **Por que importa:** ruído que aparenta funcionalidade inexistente. + +#### [LOW] Construção manual de dict duplicando `to_dict` +**Arquivo:** `routes/task_routes.py:16-59` e `routes/user_routes.py:162-181` +As rotas remontam o dicionário campo a campo em vez de usar o `to_dict()` já existente no +model. **Por que importa:** duplicação que sai de sincronia quando o schema muda. + +--- + +## Construção da Skill + +### Estrutura do `SKILL.md` e arquivos de referência + +O `SKILL.md` é o **prompt orquestrador**: define a persona (engenheiro sênior com 30+ anos), +uma tabela de *quando ler cada referência* e as **3 fases sequenciais** — Análise → Auditoria → +Refatoração — com um **gate humano obrigatório** entre a Fase 2 e a Fase 3 (nada é modificado antes +do `y`). O conhecimento de domínio foi fatiado em **6 arquivos de referência**, carregados sob +demanda (não tudo em memória de uma vez): + +| Arquivo | Área de conhecimento exigida | Papel na skill | +|---|---|---| +| `references/01-project-analysis.md` | Análise de projeto | Heurísticas de detecção de linguagem, framework, banco, domínio e arquitetura | +| `references/02-antipattern-catalog.md` | Catálogo de anti-patterns | 16 anti-patterns com sinais de detecção e severidade (inclui APIs deprecated) | +| `references/03-report-templates.md` | Template de relatório | Formato exato das 3 saídas (Fases 1, 2 e 3) | +| `references/04-mvc-guidelines.md` | Guidelines de arquitetura | Camadas MVC-alvo (Models, Views/Routes, Controllers) e responsabilidades | +| `references/05-refactoring-playbook.md` | Playbook de refatoração | 14 transformações antes/depois, uma por anti-pattern | +| `references/06-validation-checklist.md` | (extra) | Auto-verificação silenciosa ao fim de cada fase | + +### Anti-patterns incluídos no catálogo (e por quê) + +O catálogo tem **16 anti-patterns** (o desafio pede no mínimo 8), com severidade distribuída, e o +playbook tem **14 transformações** (mínimo pedido: 8). A escolha cobre as classes de problema que a +análise manual revelou nos 3 projetos: + +| ID | Anti-pattern | Severidade | Motivo de estar no catálogo | +|---|---|---|---| +| AP-01 | God Class / God Module | CRITICAL | `models.py` e `AppManager.js` concentravam tudo | +| AP-02 | Hardcoded Credentials / Secrets | CRITICAL | `SECRET_KEY`, chave de pagamento e SMTP no código | +| AP-03 | SQL Injection / query dinâmica | CRITICAL | queries por concatenação de string | +| AP-04 | Senha em texto puro / cripto fraca | CRITICAL | texto puro, `badCrypto`, MD5 sem salt | +| AP-05 | Estado global mutável | HIGH | conexão global, `globalCache`/`totalRevenue` | +| AP-06 | Regra de negócio na rota/controller | HIGH | lógica pesada presa nos handlers | +| AP-07 | Callback hell / acoplamento assíncrono | HIGH | checkout aninhado no Node | +| AP-08 | Query N+1 | MEDIUM | listagem de pedidos e relatórios | +| AP-09 | Validação ausente nas rotas | MEDIUM | inputs sem schema | +| AP-10 | Duplicação de código | MEDIUM | validações e `overdue` repetidos | +| AP-11 | **API deprecated / recurso obsoleto** | MEDIUM | `datetime.utcnow()`, `Model.query.get()` — **exigido pelo desafio** | +| AP-12 | Recursos vazando / falta de transação | MEDIUM | checkout sem transação, deletes órfãos | +| AP-13 | Endpoint perigoso sem proteção | CRITICAL | `/admin/query`, `/admin/reset-db`, rotas admin abertas | +| AP-14 | Magic numbers / strings mágicas | LOW | faixas de desconto, `"PAID"`/`"DENIED"` | +| AP-15 | Nomenclatura ruim / baixa legibilidade | LOW | variáveis `u, e, p, cc` | +| AP-16 | Debug/verbosidade em produção | LOW/MEDIUM | `DEBUG=True`, `print()` como log | + +### Como garantimos que a skill é agnóstica de tecnologia + +- **Detecção por conteúdo, nunca por nome fixo:** a Fase 1 lê manifestos e arquivos-fonte; não há + nome de arquivo, framework ou porta hardcoded na skill. +- **Layout idiomático por linguagem:** as camadas MVC são as mesmas, mas o *layout* se adapta — + **Python fica na raiz do projeto (sem `src/`)** e **Node.js usa `src/`**. As mesmas + responsabilidades, na convenção de cada stack. +- **Preservação de contrato externo:** mesmos paths, métodos HTTP e schema de resposta; a + refatoração é interna. +- **Distribuição reutilizável:** fonte única em `.claude/skills/refactor-arch/` na raiz, **espelhada + via symlink** dentro dos 3 projetos — uma cópia lógica, sem divergência. +- **Prova nas 3 stacks:** a mesma skill rodou em dois Flask (um monólito plano, um parcialmente em + camadas) e um Node/Express (God Class), com resultado válido em todos. + +### Desafios encontrados e como resolvemos + +- **Tensão "preservar contrato" × finding CRITICAL de segurança:** alguns findings (senha vazada no + JSON, backdoor `/admin/query`) só se corrigem **mudando** o contrato. Optamos por corrigir e + **documentar como mudança intencional de segurança** (remoção de `password` do `to_dict`, remoção + do endpoint de SQL arbitrário). +- **Hashing sem dependência nova:** usamos hashing nativo (`werkzeug.security` / scrypt) em vez de + adicionar `bcrypt`, mantendo o projeto leve. +- **Bug real de path do SQLite:** o `instance_path` do Flask fazia seed e app gravarem em arquivos + diferentes conforme o `cwd`; resolvido fixando um path absoluto e determinístico. +- **Ambiente:** a porta 5000 é ocupada pelo *AirPlay Receiver* do macOS e o Node 12 do shell tinha + `npm` quebrado — deixamos a porta configurável via env e validamos o Node com a versão 20. + +--- + +## Resultados + +### Resumo dos relatórios de auditoria (findings por severidade) + +| Projeto | Stack | CRITICAL | HIGH | MEDIUM | LOW | Total | MVC (antes) | +|---|---|:-:|:-:|:-:|:-:|:-:|:-:| +| code-smells-project | Python/Flask | 6 | 2 | 4 | 1 | **13** | 4/10 | +| ecommerce-api-legacy | Node/Express | 4 | 3 | 4 | 2 | **13** | 1/10 | +| task-manager-api | Python/Flask | 3 | 3 | 6 | 2 | **14** | 4/10 | + +Relatórios completos em `reports/audit-project-{1,2,3}.md`. + +### Comparação antes/depois da estrutura + +**Projeto 1 — code-smells-project** (4 arquivos planos → camadas na raiz): +``` +ANTES DEPOIS +app.py config/settings.py +controllers.py models/{produto,usuario,pedido,relatorio}_model.py +models.py controllers/{produto,usuario,pedido,relatorio,sistema}_controller.py +database.py views/routes.py + middlewares/error_handler.py + services/notificacao_service.py + database.py · app.py (composition root) +``` + +**Projeto 2 — ecommerce-api-legacy** (God Class → `src/` em camadas): +``` +ANTES DEPOIS +src/app.js src/config/index.js +src/AppManager.js src/models/{user,course,enrollment,payment,auditLog,report}Model.js +src/utils.js src/services/{checkout,report,password,paymentGateway}Service.js + src/controllers/{checkout,report,user}Controller.js + src/routes/index.js + src/middlewares/{errorHandler,requireAdmin}.js + src/db/{database,init}.js · src/errors/AppError.js + src/app.js (composition root) +``` + +**Projeto 3 — task-manager-api** (parcial → MVC completo): +``` +ANTES DEPOIS (acrescenta) +models/ config/settings.py +routes/ controllers/{auth,user,task,category,report}_controller.py +services/ views/{task,user,report,category,health}_routes.py +utils/ middlewares/{auth,error_handler}.py + schemas/{user,task,category}_schema.py + services/ (user, category, report reais) · utils/{constants,datetime_utils}.py +``` + +### Checklist de Validação preenchido + +| Item | P1 | P2 | P3 | +|---|:-:|:-:|:-:| +| **Fase 1** — linguagem, framework, domínio e nº de arquivos corretos | ✅ | ✅ | ✅ | +| **Fase 2** — relatório no template, findings com arquivo:linha, ordenados por severidade, ≥5 findings, APIs deprecated, gate de confirmação | ✅ | ✅ | ✅ | +| **Fase 3** — estrutura MVC, config sem hardcoded, models, views/routes, controllers, error handling central, entry point claro | ✅ | ✅ | ✅ | +| **Fase 3** — aplicação inicia sem erros | ✅ | ✅ | ✅ | +| **Fase 3** — endpoints originais respondem | ✅ | ✅ | ✅ | + +### Logs das aplicações após refatoração (evidência real) + +Evidência auditável completa em `reports/validation-project-{1,2,3}.md`. Resumo: + +- **P1 code-smells — PASS 22/22 endpoints.** Boot sem stack trace (debug off). Correções provadas em + runtime: injeção SQL no login → `401`, backdoor `/admin/query` → `404`, `senha` fora de + `GET /usuarios`, `secret_key` fora de `/health`. +- **P2 ecommerce — PASS 8/8 endpoints.** Rotas admin agora exigem `x-admin-token` (`401` sem token); + após `DELETE /api/users/1`, o relatório mostra `revenue 0 / students []` — **prova de que a + cascata removeu os dados órfãos** (bug original corrigido); número do cartão não aparece nos logs. +- **P3 task-manager — PASS 23/23 verificações.** `/login` devolve **token assinado** (itsdangerous) + no lugar do `fake-jwt-token-`; `password` (hash) removido de todas as respostas; `DELETE` de + tasks/users/categories agora exige `Authorization: Bearer `. + +### Como a skill se comportou em stacks diferentes + +Mesma skill, transformações adaptadas ao contexto: em Flask as camadas ficaram na raiz, em Node no +`src/`. O ponto de partida também variou — monólito plano (P1), God Class (P2) e projeto +parcialmente em camadas (P3) — e em todos a Fase 3 convergiu para as mesmas camadas MVC preservando +o contrato externo. + +--- + +## Como Executar + +### Pré-requisitos + +- **Claude Code** instalado e configurado. +- **Python 3.13** com `venv` (projetos Flask) e **Node.js 20** com `npm` (projeto Express). + +### Executar a skill em cada projeto + +```bash +# Projeto 1 — Python/Flask +cd code-smells-project && claude "/refactor-arch" # Fase 1 → 2 → [y] → 3 + +# Projeto 2 — Node.js/Express +cd ../ecommerce-api-legacy && claude "/refactor-arch" + +# Projeto 3 — Python/Flask (parcialmente organizado) +cd ../task-manager-api && claude "/refactor-arch" +``` + +A skill pausa ao fim da Fase 2 e só refatora após o `y`. A saída da Fase 2 é salva em +`reports/audit-project-.md` e a evidência de validação da Fase 3 em +`reports/validation-project-.md`. + +### Validar manualmente que a refatoração funciona + +```bash +# Flask (P1 e P3) — porta configurável (5000 costuma estar ocupada no macOS) +PORT=5001 .venv/bin/python app.py & +curl -s http://localhost:5001/health + +# Node/Express (P2) +ADMIN_TOKEN=dev-admin-token node src/app.js & +curl -s -X POST http://localhost:3000/api/checkout \ + -H 'Content-Type: application/json' \ + -d '{"usr":"Ana","eml":"ana@x.com","pwd":"segredo","c_id":2,"card":"4111222233334444"}' +``` + +Compare os status HTTP com as tabelas de `reports/validation-project-{1,2,3}.md`. \ No newline at end of file diff --git a/code-smells-project/.claude/skills/refactor-arch b/code-smells-project/.claude/skills/refactor-arch new file mode 120000 index 000000000..2378dd5de --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch @@ -0,0 +1 @@ +../../../.claude/skills/refactor-arch \ No newline at end of file diff --git a/code-smells-project/app.py b/code-smells-project/app.py index 70458e653..883766973 100644 --- a/code-smells-project/app.py +++ b/code-smells-project/app.py @@ -1,88 +1,41 @@ -from flask import Flask, jsonify, request +"""Composition root — monta a aplicação: config, middlewares, rotas e conexão.""" +import logging + +from flask import Flask from flask_cors import CORS -import controllers -from database import get_db -app = Flask(__name__) -app.config["SECRET_KEY"] = "minha-chave-super-secreta-123" -app.config["DEBUG"] = True -CORS(app) +from config import settings +from database import close_db, init_db +from middlewares.error_handler import register_error_handlers +from views.routes import register_routes -app.add_url_rule("/produtos", "listar_produtos", controllers.listar_produtos, methods=["GET"]) -app.add_url_rule("/produtos/busca", "buscar_produtos", controllers.buscar_produtos, methods=["GET"]) -app.add_url_rule("/produtos/", "buscar_produto", controllers.buscar_produto, methods=["GET"]) -app.add_url_rule("/produtos", "criar_produto", controllers.criar_produto, methods=["POST"]) -app.add_url_rule("/produtos/", "atualizar_produto", controllers.atualizar_produto, methods=["PUT"]) -app.add_url_rule("/produtos/", "deletar_produto", controllers.deletar_produto, methods=["DELETE"]) -app.add_url_rule("/usuarios", "listar_usuarios", controllers.listar_usuarios, methods=["GET"]) -app.add_url_rule("/usuarios/", "buscar_usuario", controllers.buscar_usuario, methods=["GET"]) -app.add_url_rule("/usuarios", "criar_usuario", controllers.criar_usuario, methods=["POST"]) -app.add_url_rule("/login", "login", controllers.login, methods=["POST"]) +def create_app(): + app = Flask(__name__) + app.config["SECRET_KEY"] = settings.SECRET_KEY + app.config["DEBUG"] = settings.DEBUG + CORS(app) -app.add_url_rule("/pedidos", "criar_pedido", controllers.criar_pedido, methods=["POST"]) -app.add_url_rule("/pedidos", "listar_todos_pedidos", controllers.listar_todos_pedidos, methods=["GET"]) -app.add_url_rule("/pedidos/usuario/", "listar_pedidos_usuario", controllers.listar_pedidos_usuario, methods=["GET"]) -app.add_url_rule("/pedidos//status", "atualizar_status_pedido", controllers.atualizar_status_pedido, methods=["PUT"]) + # Conexão com escopo por request (fecha ao fim de cada request). + app.teardown_appcontext(close_db) -app.add_url_rule("/relatorios/vendas", "relatorio_vendas", controllers.relatorio_vendas, methods=["GET"]) + register_error_handlers(app) + register_routes(app) -app.add_url_rule("/health", "health_check", controllers.health_check, methods=["GET"]) + # Cria schema e semeia dados iniciais. + with app.app_context(): + init_db() -@app.route("/") -def index(): - return jsonify({ - "mensagem": "Bem-vindo à API da Loja", - "versao": "1.0.0", - "endpoints": { - "produtos": "/produtos", - "usuarios": "/usuarios", - "pedidos": "/pedidos", - "login": "/login", - "relatorios": "/relatorios/vendas", - "health": "/health" - } - }) + return app -@app.route("/admin/reset-db", methods=["POST"]) -def reset_database(): - db = get_db() - cursor = db.cursor() - cursor.execute("DELETE FROM itens_pedido") - cursor.execute("DELETE FROM pedidos") - cursor.execute("DELETE FROM produtos") - cursor.execute("DELETE FROM usuarios") - db.commit() - print("!!! BANCO DE DADOS RESETADO !!!") - return jsonify({"mensagem": "Banco de dados resetado", "sucesso": True}), 200 -@app.route("/admin/query", methods=["POST"]) -def executar_query(): - dados = request.get_json() - query = dados.get("sql", "") - if not query: - return jsonify({"erro": "Query não informada"}), 400 +logging.basicConfig(level=logging.INFO) +app = create_app() - db = get_db() - cursor = db.cursor() - try: - cursor.execute(query) - if query.strip().upper().startswith("SELECT"): - rows = cursor.fetchall() - result = [dict(row) for row in rows] - return jsonify({"dados": result, "sucesso": True}), 200 - else: - db.commit() - return jsonify({"mensagem": "Query executada", "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 if __name__ == "__main__": - - get_db() print("=" * 50) print("SERVIDOR INICIADO") - print("Rodando em http://localhost:5000") + print("Rodando em http://localhost:" + str(settings.PORT)) print("=" * 50) - - app.run(host="0.0.0.0", port=5000, debug=True) + app.run(host=settings.HOST, port=settings.PORT, debug=settings.DEBUG) diff --git a/code-smells-project/config/__init__.py b/code-smells-project/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/config/settings.py b/code-smells-project/config/settings.py new file mode 100644 index 000000000..9b93859d7 --- /dev/null +++ b/code-smells-project/config/settings.py @@ -0,0 +1,25 @@ +"""Configuração central da aplicação — segredos via ambiente (nunca hardcoded).""" +import os +import secrets + +# Segredo carregado do ambiente; em dev, gera um valor aleatório (não fica fixo no código). +SECRET_KEY = os.environ.get("SECRET_KEY") or secrets.token_hex(32) + +# Debug controlado por ambiente; desligado por padrão (nunca ligado em produção). +DEBUG = os.environ.get("DEBUG", "false").lower() == "true" + +# Caminho do banco e servidor. +DB_PATH = os.environ.get("DB_PATH", "loja.db") +HOST = os.environ.get("HOST", "0.0.0.0") +PORT = int(os.environ.get("PORT", "5000")) +AMBIENTE = os.environ.get("AMBIENTE", "producao") + +# Token exigido para operações administrativas destrutivas (vazio => bloqueado por padrão). +ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "") + +# Regras de negócio — faixas de desconto do relatório de vendas (maior faixa primeiro). +DESCONTO_FAIXAS = ( + (10000, 0.10), + (5000, 0.05), + (1000, 0.02), +) diff --git a/code-smells-project/controllers.py b/code-smells-project/controllers.py deleted file mode 100644 index 51ca25477..000000000 --- a/code-smells-project/controllers.py +++ /dev/null @@ -1,292 +0,0 @@ -from flask import request, jsonify -import models -from database import get_db - -def listar_produtos(): - try: - produtos = models.get_todos_produtos() - print("Listando " + str(len(produtos)) + " produtos") - return jsonify({"dados": produtos, "sucesso": True}), 200 - except Exception as e: - print("ERRO: " + str(e)) - return jsonify({"erro": str(e)}), 500 - -def buscar_produto(id): - try: - produto = models.get_produto_por_id(id) - if produto: - return jsonify({"dados": produto, "sucesso": True}), 200 - else: - return jsonify({"erro": "Produto não encontrado", "sucesso": False}), 404 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def criar_produto(): - try: - dados = request.get_json() - - if not dados: - return jsonify({"erro": "Dados inválidos"}), 400 - if "nome" not in dados: - return jsonify({"erro": "Nome é obrigatório"}), 400 - if "preco" not in dados: - return jsonify({"erro": "Preço é obrigatório"}), 400 - if "estoque" not in dados: - return jsonify({"erro": "Estoque é obrigatório"}), 400 - - nome = dados["nome"] - descricao = dados.get("descricao", "") - preco = dados["preco"] - estoque = dados["estoque"] - categoria = dados.get("categoria", "geral") - - if preco < 0: - return jsonify({"erro": "Preço não pode ser negativo"}), 400 - if estoque < 0: - return jsonify({"erro": "Estoque não pode ser negativo"}), 400 - if len(nome) < 2: - return jsonify({"erro": "Nome muito curto"}), 400 - if len(nome) > 200: - return jsonify({"erro": "Nome muito longo"}), 400 - - categorias_validas = ["informatica", "moveis", "vestuario", "geral", "eletronicos", "livros"] - if categoria not in categorias_validas: - return jsonify({"erro": "Categoria inválida. Válidas: " + str(categorias_validas)}), 400 - - id = models.criar_produto(nome, descricao, preco, estoque, categoria) - print("Produto criado com ID: " + str(id)) - return jsonify({"dados": {"id": id}, "sucesso": True, "mensagem": "Produto criado"}), 201 - - except Exception as e: - print("ERRO ao criar produto: " + str(e)) - return jsonify({"erro": str(e)}), 500 - -def atualizar_produto(id): - try: - dados = request.get_json() - - produto_existente = models.get_produto_por_id(id) - if not produto_existente: - return jsonify({"erro": "Produto não encontrado"}), 404 - - if not dados: - return jsonify({"erro": "Dados inválidos"}), 400 - if "nome" not in dados: - return jsonify({"erro": "Nome é obrigatório"}), 400 - if "preco" not in dados: - return jsonify({"erro": "Preço é obrigatório"}), 400 - if "estoque" not in dados: - return jsonify({"erro": "Estoque é obrigatório"}), 400 - - nome = dados["nome"] - descricao = dados.get("descricao", "") - preco = dados["preco"] - estoque = dados["estoque"] - categoria = dados.get("categoria", "geral") - - if preco < 0: - return jsonify({"erro": "Preço não pode ser negativo"}), 400 - if estoque < 0: - return jsonify({"erro": "Estoque não pode ser negativo"}), 400 - - models.atualizar_produto(id, nome, descricao, preco, estoque, categoria) - return jsonify({"sucesso": True, "mensagem": "Produto atualizado"}), 200 - - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def deletar_produto(id): - try: - - produto = models.get_produto_por_id(id) - if not produto: - return jsonify({"erro": "Produto não encontrado"}), 404 - - models.deletar_produto(id) - print("Produto " + str(id) + " deletado") - return jsonify({"sucesso": True, "mensagem": "Produto deletado"}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def buscar_produtos(): - try: - termo = request.args.get("q", "") - categoria = request.args.get("categoria", None) - preco_min = request.args.get("preco_min", None) - preco_max = request.args.get("preco_max", None) - - if preco_min: - preco_min = float(preco_min) - if preco_max: - preco_max = float(preco_max) - - resultados = models.buscar_produtos(termo, categoria, preco_min, preco_max) - return jsonify({"dados": resultados, "total": len(resultados), "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def listar_usuarios(): - try: - usuarios = models.get_todos_usuarios() - - return jsonify({"dados": usuarios, "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def buscar_usuario(id): - try: - usuario = models.get_usuario_por_id(id) - if usuario: - return jsonify({"dados": usuario, "sucesso": True}), 200 - else: - return jsonify({"erro": "Usuário não encontrado"}), 404 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def criar_usuario(): - try: - dados = request.get_json() - - if not dados: - return jsonify({"erro": "Dados inválidos"}), 400 - - nome = dados.get("nome", "") - email = dados.get("email", "") - senha = dados.get("senha", "") - - if not nome or not email or not senha: - return jsonify({"erro": "Nome, email e senha são obrigatórios"}), 400 - - id = models.criar_usuario(nome, email, senha) - print("Usuário criado: " + email) - return jsonify({"dados": {"id": id}, "sucesso": True}), 201 - - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def login(): - try: - dados = request.get_json() - email = dados.get("email", "") - senha = dados.get("senha", "") - - if not email or not senha: - return jsonify({"erro": "Email e senha são obrigatórios"}), 400 - - usuario = models.login_usuario(email, senha) - if usuario: - - print("Login bem-sucedido: " + email) - return jsonify({"dados": usuario, "sucesso": True, "mensagem": "Login OK"}), 200 - else: - print("Login falhou: " + email) - return jsonify({"erro": "Email ou senha inválidos", "sucesso": False}), 401 - - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def criar_pedido(): - try: - dados = request.get_json() - - if not dados: - return jsonify({"erro": "Dados inválidos"}), 400 - - usuario_id = dados.get("usuario_id") - itens = dados.get("itens", []) - - if not usuario_id: - return jsonify({"erro": "Usuario ID é obrigatório"}), 400 - if not itens or len(itens) == 0: - return jsonify({"erro": "Pedido deve ter pelo menos 1 item"}), 400 - - resultado = models.criar_pedido(usuario_id, itens) - - if "erro" in resultado: - return jsonify({"erro": resultado["erro"], "sucesso": False}), 400 - - print("ENVIANDO EMAIL: Pedido " + str(resultado["pedido_id"]) + " criado para usuario " + str(usuario_id)) - print("ENVIANDO SMS: Seu pedido foi recebido!") - print("ENVIANDO PUSH: Novo pedido recebido pelo sistema") - - return jsonify({ - "dados": resultado, - "sucesso": True, - "mensagem": "Pedido criado com sucesso" - }), 201 - - except Exception as e: - print("ERRO CRITICO ao criar pedido: " + str(e)) - return jsonify({"erro": str(e)}), 500 - -def listar_pedidos_usuario(usuario_id): - try: - pedidos = models.get_pedidos_usuario(usuario_id) - return jsonify({"dados": pedidos, "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def listar_todos_pedidos(): - try: - - pedidos = models.get_todos_pedidos() - return jsonify({"dados": pedidos, "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def atualizar_status_pedido(pedido_id): - try: - dados = request.get_json() - novo_status = dados.get("status", "") - - if novo_status not in ["pendente", "aprovado", "enviado", "entregue", "cancelado"]: - return jsonify({"erro": "Status inválido"}), 400 - - models.atualizar_status_pedido(pedido_id, novo_status) - - if novo_status == "aprovado": - print("NOTIFICAÇÃO: Pedido " + str(pedido_id) + " foi aprovado! Preparar envio.") - if novo_status == "cancelado": - print("NOTIFICAÇÃO: Pedido " + str(pedido_id) + " cancelado. Devolver estoque.") - - return jsonify({"sucesso": True, "mensagem": "Status atualizado"}), 200 - - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def relatorio_vendas(): - try: - relatorio = models.relatorio_vendas() - return jsonify({"dados": relatorio, "sucesso": True}), 200 - except Exception as e: - return jsonify({"erro": str(e)}), 500 - -def health_check(): - try: - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT 1") - cursor.execute("SELECT COUNT(*) FROM produtos") - produtos = cursor.fetchone()[0] - cursor.execute("SELECT COUNT(*) FROM usuarios") - usuarios = cursor.fetchone()[0] - cursor.execute("SELECT COUNT(*) FROM pedidos") - pedidos = cursor.fetchone()[0] - - return jsonify({ - "status": "ok", - "database": "connected", - "counts": { - "produtos": produtos, - "usuarios": usuarios, - "pedidos": pedidos - }, - - "versao": "1.0.0", - "ambiente": "producao", - "db_path": "loja.db", - "debug": True, - "secret_key": "minha-chave-super-secreta-123" - }), 200 - except Exception as e: - return jsonify({"status": "erro", "detalhes": str(e)}), 500 diff --git a/code-smells-project/controllers/__init__.py b/code-smells-project/controllers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/controllers/pedido_controller.py b/code-smells-project/controllers/pedido_controller.py new file mode 100644 index 000000000..c469bb7b0 --- /dev/null +++ b/code-smells-project/controllers/pedido_controller.py @@ -0,0 +1,51 @@ +"""Controller de Pedidos — orquestra validação, model e notificações.""" +from flask import jsonify, request + +from models import pedido_model +from services import notificacao_service + +STATUS_VALIDOS = ["pendente", "aprovado", "enviado", "entregue", "cancelado"] + + +def criar(): + dados = request.get_json() + + if not dados: + return jsonify({"erro": "Dados inválidos"}), 400 + + usuario_id = dados.get("usuario_id") + itens = dados.get("itens", []) + + if not usuario_id: + return jsonify({"erro": "Usuario ID é obrigatório"}), 400 + if not itens or len(itens) == 0: + return jsonify({"erro": "Pedido deve ter pelo menos 1 item"}), 400 + + resultado = pedido_model.criar(usuario_id, itens) + if "erro" in resultado: + return jsonify({"erro": resultado["erro"], "sucesso": False}), 400 + + notificacao_service.notificar_pedido_criado(resultado["pedido_id"], usuario_id) + return jsonify({"dados": resultado, "sucesso": True, "mensagem": "Pedido criado com sucesso"}), 201 + + +def listar_por_usuario(usuario_id): + pedidos = pedido_model.listar_por_usuario(usuario_id) + return jsonify({"dados": pedidos, "sucesso": True}), 200 + + +def listar_todos(): + pedidos = pedido_model.listar_todos() + return jsonify({"dados": pedidos, "sucesso": True}), 200 + + +def atualizar_status(pedido_id): + dados = request.get_json() + novo_status = dados.get("status", "") + + if novo_status not in STATUS_VALIDOS: + return jsonify({"erro": "Status inválido"}), 400 + + pedido_model.atualizar_status(pedido_id, novo_status) + notificacao_service.notificar_status_pedido(pedido_id, novo_status) + return jsonify({"sucesso": True, "mensagem": "Status atualizado"}), 200 diff --git a/code-smells-project/controllers/produto_controller.py b/code-smells-project/controllers/produto_controller.py new file mode 100644 index 000000000..4242f24e0 --- /dev/null +++ b/code-smells-project/controllers/produto_controller.py @@ -0,0 +1,106 @@ +"""Controller de Produtos — magro: valida entrada, delega ao model e monta a resposta.""" +from flask import jsonify, request + +from models import produto_model + +CATEGORIAS_VALIDAS = ["informatica", "moveis", "vestuario", "geral", "eletronicos", "livros"] + + +def listar(): + produtos = produto_model.listar_todos() + return jsonify({"dados": produtos, "sucesso": True}), 200 + + +def buscar(id): + produto = produto_model.buscar_por_id(id) + if produto: + return jsonify({"dados": produto, "sucesso": True}), 200 + return jsonify({"erro": "Produto não encontrado", "sucesso": False}), 404 + + +def criar(): + dados = request.get_json() + + if not dados: + return jsonify({"erro": "Dados inválidos"}), 400 + if "nome" not in dados: + return jsonify({"erro": "Nome é obrigatório"}), 400 + if "preco" not in dados: + return jsonify({"erro": "Preço é obrigatório"}), 400 + if "estoque" not in dados: + return jsonify({"erro": "Estoque é obrigatório"}), 400 + + nome = dados["nome"] + descricao = dados.get("descricao", "") + preco = dados["preco"] + estoque = dados["estoque"] + categoria = dados.get("categoria", "geral") + + if preco < 0: + return jsonify({"erro": "Preço não pode ser negativo"}), 400 + if estoque < 0: + return jsonify({"erro": "Estoque não pode ser negativo"}), 400 + if len(nome) < 2: + return jsonify({"erro": "Nome muito curto"}), 400 + if len(nome) > 200: + return jsonify({"erro": "Nome muito longo"}), 400 + if categoria not in CATEGORIAS_VALIDAS: + return jsonify({"erro": "Categoria inválida. Válidas: " + str(CATEGORIAS_VALIDAS)}), 400 + + id = produto_model.criar(nome, descricao, preco, estoque, categoria) + return jsonify({"dados": {"id": id}, "sucesso": True, "mensagem": "Produto criado"}), 201 + + +def atualizar(id): + dados = request.get_json() + + produto_existente = produto_model.buscar_por_id(id) + if not produto_existente: + return jsonify({"erro": "Produto não encontrado"}), 404 + + if not dados: + return jsonify({"erro": "Dados inválidos"}), 400 + if "nome" not in dados: + return jsonify({"erro": "Nome é obrigatório"}), 400 + if "preco" not in dados: + return jsonify({"erro": "Preço é obrigatório"}), 400 + if "estoque" not in dados: + return jsonify({"erro": "Estoque é obrigatório"}), 400 + + nome = dados["nome"] + descricao = dados.get("descricao", "") + preco = dados["preco"] + estoque = dados["estoque"] + categoria = dados.get("categoria", "geral") + + if preco < 0: + return jsonify({"erro": "Preço não pode ser negativo"}), 400 + if estoque < 0: + return jsonify({"erro": "Estoque não pode ser negativo"}), 400 + + produto_model.atualizar(id, nome, descricao, preco, estoque, categoria) + return jsonify({"sucesso": True, "mensagem": "Produto atualizado"}), 200 + + +def deletar(id): + produto = produto_model.buscar_por_id(id) + if not produto: + return jsonify({"erro": "Produto não encontrado"}), 404 + + produto_model.deletar(id) + return jsonify({"sucesso": True, "mensagem": "Produto deletado"}), 200 + + +def buscar_lista(): + termo = request.args.get("q", "") + categoria = request.args.get("categoria", None) + preco_min = request.args.get("preco_min", None) + preco_max = request.args.get("preco_max", None) + + if preco_min: + preco_min = float(preco_min) + if preco_max: + preco_max = float(preco_max) + + resultados = produto_model.buscar(termo, categoria, preco_min, preco_max) + return jsonify({"dados": resultados, "total": len(resultados), "sucesso": True}), 200 diff --git a/code-smells-project/controllers/relatorio_controller.py b/code-smells-project/controllers/relatorio_controller.py new file mode 100644 index 000000000..6da15db60 --- /dev/null +++ b/code-smells-project/controllers/relatorio_controller.py @@ -0,0 +1,9 @@ +"""Controller de Relatórios — delega a agregação ao model.""" +from flask import jsonify + +from models import relatorio_model + + +def vendas(): + relatorio = relatorio_model.gerar() + return jsonify({"dados": relatorio, "sucesso": True}), 200 diff --git a/code-smells-project/controllers/sistema_controller.py b/code-smells-project/controllers/sistema_controller.py new file mode 100644 index 000000000..c8534cfe1 --- /dev/null +++ b/code-smells-project/controllers/sistema_controller.py @@ -0,0 +1,56 @@ +"""Controller de sistema — index, health check e operação administrativa protegida.""" +from flask import jsonify, request + +from config import settings +from database import get_db + + +def index(): + return jsonify( + { + "mensagem": "Bem-vindo à API da Loja", + "versao": "1.0.0", + "endpoints": { + "produtos": "/produtos", + "usuarios": "/usuarios", + "pedidos": "/pedidos", + "login": "/login", + "relatorios": "/relatorios/vendas", + "health": "/health", + }, + } + ) + + +def health(): + db = get_db() + db.execute("SELECT 1") + produtos = db.execute("SELECT COUNT(*) FROM produtos").fetchone()[0] + usuarios = db.execute("SELECT COUNT(*) FROM usuarios").fetchone()[0] + pedidos = db.execute("SELECT COUNT(*) FROM pedidos").fetchone()[0] + + return jsonify( + { + "status": "ok", + "database": "connected", + "counts": {"produtos": produtos, "usuarios": usuarios, "pedidos": pedidos}, + "versao": "1.0.0", + "ambiente": settings.AMBIENTE, + "db_path": settings.DB_PATH, + } + ), 200 + + +def reset_database(): + """Operação destrutiva protegida por token administrativo (header X-Admin-Token).""" + token = request.headers.get("X-Admin-Token", "") + if not settings.ADMIN_TOKEN or token != settings.ADMIN_TOKEN: + return jsonify({"erro": "Não autorizado"}), 403 + + db = get_db() + db.execute("DELETE FROM itens_pedido") + db.execute("DELETE FROM pedidos") + db.execute("DELETE FROM produtos") + db.execute("DELETE FROM usuarios") + db.commit() + return jsonify({"mensagem": "Banco de dados resetado", "sucesso": True}), 200 diff --git a/code-smells-project/controllers/usuario_controller.py b/code-smells-project/controllers/usuario_controller.py new file mode 100644 index 000000000..f1c4917f0 --- /dev/null +++ b/code-smells-project/controllers/usuario_controller.py @@ -0,0 +1,47 @@ +"""Controller de Usuários — valida entrada e delega autenticação/persistência ao model.""" +from flask import jsonify, request + +from models import usuario_model + + +def listar(): + usuarios = usuario_model.listar_todos() + return jsonify({"dados": usuarios, "sucesso": True}), 200 + + +def buscar(id): + usuario = usuario_model.buscar_por_id(id) + if usuario: + return jsonify({"dados": usuario, "sucesso": True}), 200 + return jsonify({"erro": "Usuário não encontrado"}), 404 + + +def criar(): + dados = request.get_json() + + if not dados: + return jsonify({"erro": "Dados inválidos"}), 400 + + nome = dados.get("nome", "") + email = dados.get("email", "") + senha = dados.get("senha", "") + + if not nome or not email or not senha: + return jsonify({"erro": "Nome, email e senha são obrigatórios"}), 400 + + id = usuario_model.criar(nome, email, senha) + return jsonify({"dados": {"id": id}, "sucesso": True}), 201 + + +def login(): + dados = request.get_json() + email = dados.get("email", "") + senha = dados.get("senha", "") + + if not email or not senha: + return jsonify({"erro": "Email e senha são obrigatórios"}), 400 + + usuario = usuario_model.autenticar(email, senha) + if usuario: + return jsonify({"dados": usuario, "sucesso": True, "mensagem": "Login OK"}), 200 + return jsonify({"erro": "Email ou senha inválidos", "sucesso": False}), 401 diff --git a/code-smells-project/database.py b/code-smells-project/database.py index 798587644..68ede7891 100644 --- a/code-smells-project/database.py +++ b/code-smells-project/database.py @@ -1,86 +1,102 @@ +"""Camada de conexão: fábrica com escopo por request (via flask.g) + schema + seed. + +Substitui o antigo singleton global mutável (`db_connection`) e o `check_same_thread=False`. +""" import sqlite3 -import os -db_connection = None -db_path = "loja.db" +from flask import g +from werkzeug.security import generate_password_hash + +from config import settings + def get_db(): - global db_connection - if db_connection is None: - db_connection = sqlite3.connect(db_path, check_same_thread=False) - db_connection.row_factory = sqlite3.Row - cursor = db_connection.cursor() + """Retorna a conexão do request atual, criando-a sob demanda (escopo por request).""" + if "db" not in g: + g.db = sqlite3.connect(settings.DB_PATH) + g.db.row_factory = sqlite3.Row + return g.db + + +def close_db(exception=None): + """Fecha a conexão ao fim do request (registrado em teardown_appcontext).""" + db = g.pop("db", None) + if db is not None: + db.close() + + +def init_db(): + """Cria o schema e semeia dados iniciais. Executar dentro de um app context.""" + db = get_db() + db.executescript( + """ + CREATE TABLE IF NOT EXISTS produtos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nome TEXT, + descricao TEXT, + preco REAL, + estoque INTEGER, + categoria TEXT, + ativo INTEGER DEFAULT 1, + criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS usuarios ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + nome TEXT, + email TEXT, + senha TEXT, + tipo TEXT DEFAULT 'cliente', + criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS pedidos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + usuario_id INTEGER, + status TEXT DEFAULT 'pendente', + total REAL, + criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS itens_pedido ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pedido_id INTEGER, + produto_id INTEGER, + quantidade INTEGER, + preco_unitario REAL + ); + """ + ) + db.commit() + _seed(db) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS produtos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - nome TEXT, - descricao TEXT, - preco REAL, - estoque INTEGER, - categoria TEXT, - ativo INTEGER DEFAULT 1, - criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS usuarios ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - nome TEXT, - email TEXT, - senha TEXT, - tipo TEXT DEFAULT 'cliente', - criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS pedidos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - usuario_id INTEGER, - status TEXT DEFAULT 'pendente', - total REAL, - criado_em TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - cursor.execute(""" - CREATE TABLE IF NOT EXISTS itens_pedido ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - pedido_id INTEGER, - produto_id INTEGER, - quantidade INTEGER, - preco_unitario REAL - ) - """) - db_connection.commit() - cursor.execute("SELECT COUNT(*) FROM produtos") - if cursor.fetchone()[0] == 0: - produtos = [ - ("Notebook Gamer", "Notebook potente para jogos", 5999.99, 10, "informatica"), - ("Mouse Wireless", "Mouse sem fio ergonômico", 89.90, 50, "informatica"), - ("Teclado Mecânico", "Teclado mecânico RGB", 299.90, 30, "informatica"), - ("Monitor 27''", "Monitor 27 polegadas 144hz", 1899.90, 15, "informatica"), - ("Headset Gamer", "Headset com microfone", 199.90, 25, "informatica"), - ("Cadeira Gamer", "Cadeira ergonômica", 1299.90, 8, "moveis"), - ("Webcam HD", "Webcam 1080p", 249.90, 20, "informatica"), - ("Hub USB", "Hub USB 3.0 7 portas", 79.90, 40, "informatica"), - ("SSD 1TB", "SSD NVMe 1TB", 449.90, 35, "informatica"), - ("Camiseta Dev", "Camiseta estampa código", 59.90, 100, "vestuario"), - ] - cursor.executemany( - "INSERT INTO produtos (nome, descricao, preco, estoque, categoria) VALUES (?, ?, ?, ?, ?)", - produtos - ) +def _seed(db): + if db.execute("SELECT COUNT(*) FROM produtos").fetchone()[0] != 0: + return - usuarios = [ - ("Admin", "admin@loja.com", "admin123", "admin"), - ("João Silva", "joao@email.com", "123456", "cliente"), - ("Maria Santos", "maria@email.com", "senha123", "cliente"), - ] - cursor.executemany( - "INSERT INTO usuarios (nome, email, senha, tipo) VALUES (?, ?, ?, ?)", - usuarios - ) - db_connection.commit() + produtos = [ + ("Notebook Gamer", "Notebook potente para jogos", 5999.99, 10, "informatica"), + ("Mouse Wireless", "Mouse sem fio ergonômico", 89.90, 50, "informatica"), + ("Teclado Mecânico", "Teclado mecânico RGB", 299.90, 30, "informatica"), + ("Monitor 27''", "Monitor 27 polegadas 144hz", 1899.90, 15, "informatica"), + ("Headset Gamer", "Headset com microfone", 199.90, 25, "informatica"), + ("Cadeira Gamer", "Cadeira ergonômica", 1299.90, 8, "moveis"), + ("Webcam HD", "Webcam 1080p", 249.90, 20, "informatica"), + ("Hub USB", "Hub USB 3.0 7 portas", 79.90, 40, "informatica"), + ("SSD 1TB", "SSD NVMe 1TB", 449.90, 35, "informatica"), + ("Camiseta Dev", "Camiseta estampa código", 59.90, 100, "vestuario"), + ] + db.executemany( + "INSERT INTO produtos (nome, descricao, preco, estoque, categoria) VALUES (?, ?, ?, ?, ?)", + produtos, + ) - return db_connection + # Senhas semeadas já com hash (nunca em texto puro). + usuarios = [ + ("Admin", "admin@loja.com", generate_password_hash("admin123"), "admin"), + ("João Silva", "joao@email.com", generate_password_hash("123456"), "cliente"), + ("Maria Santos", "maria@email.com", generate_password_hash("senha123"), "cliente"), + ] + db.executemany( + "INSERT INTO usuarios (nome, email, senha, tipo) VALUES (?, ?, ?, ?)", + usuarios, + ) + db.commit() diff --git a/code-smells-project/middlewares/__init__.py b/code-smells-project/middlewares/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/middlewares/error_handler.py b/code-smells-project/middlewares/error_handler.py new file mode 100644 index 000000000..21fae8abc --- /dev/null +++ b/code-smells-project/middlewares/error_handler.py @@ -0,0 +1,18 @@ +"""Tratamento de erro centralizado — remove o try/except duplicado de cada controller.""" +import logging + +from flask import jsonify +from werkzeug.exceptions import HTTPException + +logger = logging.getLogger(__name__) + + +def register_error_handlers(app): + @app.errorhandler(HTTPException) + def handle_http_exception(exc): + return jsonify({"erro": exc.description}), exc.code + + @app.errorhandler(Exception) + def handle_unexpected(exc): + logger.exception("Erro não tratado") + return jsonify({"erro": str(exc)}), 500 diff --git a/code-smells-project/models.py b/code-smells-project/models.py deleted file mode 100644 index 6cf62fe2e..000000000 --- a/code-smells-project/models.py +++ /dev/null @@ -1,314 +0,0 @@ -from database import get_db -import sqlite3 - -def get_todos_produtos(): - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT * FROM produtos") - rows = cursor.fetchall() - result = [] - for row in rows: - - result.append({ - "id": row["id"], - "nome": row["nome"], - "descricao": row["descricao"], - "preco": row["preco"], - "estoque": row["estoque"], - "categoria": row["categoria"], - "ativo": row["ativo"], - "criado_em": row["criado_em"] - }) - return result - -def get_produto_por_id(id): - db = get_db() - cursor = db.cursor() - - cursor.execute("SELECT * FROM produtos WHERE id = " + str(id)) - row = cursor.fetchone() - if row: - return { - "id": row["id"], - "nome": row["nome"], - "descricao": row["descricao"], - "preco": row["preco"], - "estoque": row["estoque"], - "categoria": row["categoria"], - "ativo": row["ativo"], - "criado_em": row["criado_em"] - } - return None - -def criar_produto(nome, descricao, preco, estoque, categoria): - db = get_db() - cursor = db.cursor() - - cursor.execute( - "INSERT INTO produtos (nome, descricao, preco, estoque, categoria) VALUES ('" + - nome + "', '" + descricao + "', " + str(preco) + ", " + str(estoque) + ", '" + categoria + "')" - ) - db.commit() - return cursor.lastrowid - -def atualizar_produto(id, nome, descricao, preco, estoque, categoria): - db = get_db() - cursor = db.cursor() - cursor.execute( - "UPDATE produtos SET nome = '" + nome + "', descricao = '" + descricao + - "', preco = " + str(preco) + ", estoque = " + str(estoque) + - ", categoria = '" + categoria + "' WHERE id = " + str(id) - ) - db.commit() - return True - -def deletar_produto(id): - db = get_db() - cursor = db.cursor() - cursor.execute("DELETE FROM produtos WHERE id = " + str(id)) - db.commit() - return True - -def get_todos_usuarios(): - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT * FROM usuarios") - rows = cursor.fetchall() - result = [] - for row in rows: - result.append({ - "id": row["id"], - "nome": row["nome"], - "email": row["email"], - "senha": row["senha"], - "tipo": row["tipo"], - "criado_em": row["criado_em"] - }) - return result - -def get_usuario_por_id(id): - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT * FROM usuarios WHERE id = " + str(id)) - row = cursor.fetchone() - if row: - return { - "id": row["id"], - "nome": row["nome"], - "email": row["email"], - "senha": row["senha"], - "tipo": row["tipo"], - "criado_em": row["criado_em"] - } - return None - -def login_usuario(email, senha): - db = get_db() - cursor = db.cursor() - - cursor.execute( - "SELECT * FROM usuarios WHERE email = '" + email + "' AND senha = '" + senha + "'" - ) - row = cursor.fetchone() - if row: - return { - "id": row["id"], - "nome": row["nome"], - "email": row["email"], - "tipo": row["tipo"] - } - return None - -def criar_usuario(nome, email, senha, tipo="cliente"): - db = get_db() - cursor = db.cursor() - - cursor.execute( - "INSERT INTO usuarios (nome, email, senha, tipo) VALUES ('" + - nome + "', '" + email + "', '" + senha + "', '" + tipo + "')" - ) - db.commit() - return cursor.lastrowid - -def criar_pedido(usuario_id, itens): - db = get_db() - cursor = db.cursor() - - total = 0 - - for item in itens: - cursor.execute("SELECT * FROM produtos WHERE id = " + str(item["produto_id"])) - produto = cursor.fetchone() - if produto is None: - return {"erro": "Produto " + str(item["produto_id"]) + " não encontrado"} - if produto["estoque"] < item["quantidade"]: - return {"erro": "Estoque insuficiente para " + produto["nome"]} - total = total + (produto["preco"] * item["quantidade"]) - - cursor.execute( - "INSERT INTO pedidos (usuario_id, status, total) VALUES (" + - str(usuario_id) + ", 'pendente', " + str(total) + ")" - ) - pedido_id = cursor.lastrowid - - for item in itens: - cursor.execute("SELECT preco FROM produtos WHERE id = " + str(item["produto_id"])) - produto = cursor.fetchone() - cursor.execute( - "INSERT INTO itens_pedido (pedido_id, produto_id, quantidade, preco_unitario) VALUES (" + - str(pedido_id) + ", " + str(item["produto_id"]) + ", " + - str(item["quantidade"]) + ", " + str(produto["preco"]) + ")" - ) - - cursor.execute( - "UPDATE produtos SET estoque = estoque - " + str(item["quantidade"]) + - " WHERE id = " + str(item["produto_id"]) - ) - - db.commit() - return {"pedido_id": pedido_id, "total": total} - -def get_pedidos_usuario(usuario_id): - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT * FROM pedidos WHERE usuario_id = " + str(usuario_id)) - rows = cursor.fetchall() - result = [] - for row in rows: - pedido = { - "id": row["id"], - "usuario_id": row["usuario_id"], - "status": row["status"], - "total": row["total"], - "criado_em": row["criado_em"], - "itens": [] - } - - cursor2 = db.cursor() - cursor2.execute("SELECT * FROM itens_pedido WHERE pedido_id = " + str(row["id"])) - itens = cursor2.fetchall() - for item in itens: - cursor3 = db.cursor() - cursor3.execute("SELECT nome FROM produtos WHERE id = " + str(item["produto_id"])) - prod = cursor3.fetchone() - pedido["itens"].append({ - "produto_id": item["produto_id"], - "produto_nome": prod["nome"] if prod else "Desconhecido", - "quantidade": item["quantidade"], - "preco_unitario": item["preco_unitario"] - }) - result.append(pedido) - return result - -def get_todos_pedidos(): - db = get_db() - cursor = db.cursor() - cursor.execute("SELECT * FROM pedidos") - rows = cursor.fetchall() - result = [] - for row in rows: - - pedido = { - "id": row["id"], - "usuario_id": row["usuario_id"], - "status": row["status"], - "total": row["total"], - "criado_em": row["criado_em"], - "itens": [] - } - cursor2 = db.cursor() - cursor2.execute("SELECT * FROM itens_pedido WHERE pedido_id = " + str(row["id"])) - itens = cursor2.fetchall() - for item in itens: - cursor3 = db.cursor() - cursor3.execute("SELECT nome FROM produtos WHERE id = " + str(item["produto_id"])) - prod = cursor3.fetchone() - pedido["itens"].append({ - "produto_id": item["produto_id"], - "produto_nome": prod["nome"] if prod else "Desconhecido", - "quantidade": item["quantidade"], - "preco_unitario": item["preco_unitario"] - }) - result.append(pedido) - return result - -def relatorio_vendas(): - db = get_db() - cursor = db.cursor() - - cursor.execute("SELECT COUNT(*) FROM pedidos") - total_pedidos = cursor.fetchone()[0] - - cursor.execute("SELECT SUM(total) FROM pedidos") - faturamento = cursor.fetchone()[0] - if faturamento is None: - faturamento = 0 - - cursor.execute("SELECT COUNT(*) FROM pedidos WHERE status = 'pendente'") - pendentes = cursor.fetchone()[0] - - cursor.execute("SELECT COUNT(*) FROM pedidos WHERE status = 'aprovado'") - aprovados = cursor.fetchone()[0] - - cursor.execute("SELECT COUNT(*) FROM pedidos WHERE status = 'cancelado'") - cancelados = cursor.fetchone()[0] - - desconto = 0 - if faturamento > 10000: - desconto = faturamento * 0.1 - elif faturamento > 5000: - desconto = faturamento * 0.05 - elif faturamento > 1000: - desconto = faturamento * 0.02 - - return { - "total_pedidos": total_pedidos, - "faturamento_bruto": round(faturamento, 2), - "desconto_aplicavel": round(desconto, 2), - "faturamento_liquido": round(faturamento - desconto, 2), - "pedidos_pendentes": pendentes, - "pedidos_aprovados": aprovados, - "pedidos_cancelados": cancelados, - "ticket_medio": round(faturamento / total_pedidos, 2) if total_pedidos > 0 else 0 - } - -def atualizar_status_pedido(pedido_id, novo_status): - db = get_db() - cursor = db.cursor() - - cursor.execute( - "UPDATE pedidos SET status = '" + novo_status + "' WHERE id = " + str(pedido_id) - ) - db.commit() - return True - -def buscar_produtos(termo, categoria=None, preco_min=None, preco_max=None): - db = get_db() - cursor = db.cursor() - - query = "SELECT * FROM produtos WHERE 1=1" - if termo: - query += " AND (nome LIKE '%" + termo + "%' OR descricao LIKE '%" + termo + "%')" - if categoria: - query += " AND categoria = '" + categoria + "'" - if preco_min: - query += " AND preco >= " + str(preco_min) - if preco_max: - query += " AND preco <= " + str(preco_max) - - cursor.execute(query) - rows = cursor.fetchall() - result = [] - for row in rows: - - result.append({ - "id": row["id"], - "nome": row["nome"], - "descricao": row["descricao"], - "preco": row["preco"], - "estoque": row["estoque"], - "categoria": row["categoria"], - "ativo": row["ativo"], - "criado_em": row["criado_em"] - }) - return result diff --git a/code-smells-project/models/__init__.py b/code-smells-project/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/models/pedido_model.py b/code-smells-project/models/pedido_model.py new file mode 100644 index 000000000..9246b2b6e --- /dev/null +++ b/code-smells-project/models/pedido_model.py @@ -0,0 +1,112 @@ +"""Model de Pedido — regra de negócio (estoque/total), transação atômica e leitura sem N+1.""" +from database import get_db + + +def _validar_e_calcular_total(db, itens): + """Valida existência/estoque e calcula o total. Retorna (total, erro).""" + total = 0 + for item in itens: + produto = db.execute( + "SELECT * FROM produtos WHERE id = ?", (item["produto_id"],) + ).fetchone() + if produto is None: + return None, {"erro": "Produto " + str(item["produto_id"]) + " não encontrado"} + if produto["estoque"] < item["quantidade"]: + return None, {"erro": "Estoque insuficiente para " + produto["nome"]} + total += produto["preco"] * item["quantidade"] + return total, None + + +def criar(usuario_id, itens): + db = get_db() + total, erro = _validar_e_calcular_total(db, itens) + if erro: + return erro + + try: + cursor = db.execute( + "INSERT INTO pedidos (usuario_id, status, total) VALUES (?, 'pendente', ?)", + (usuario_id, total), + ) + pedido_id = cursor.lastrowid + + for item in itens: + produto = db.execute( + "SELECT preco FROM produtos WHERE id = ?", (item["produto_id"],) + ).fetchone() + db.execute( + "INSERT INTO itens_pedido (pedido_id, produto_id, quantidade, preco_unitario) " + "VALUES (?, ?, ?, ?)", + (pedido_id, item["produto_id"], item["quantidade"], produto["preco"]), + ) + db.execute( + "UPDATE produtos SET estoque = estoque - ? WHERE id = ?", + (item["quantidade"], item["produto_id"]), + ) + db.commit() + except Exception: + db.rollback() + raise + + return {"pedido_id": pedido_id, "total": total} + + +def _carregar_itens(db, pedido_ids): + """Carrega todos os itens de vários pedidos numa única query (evita N+1).""" + if not pedido_ids: + return {} + placeholders = ",".join("?" for _ in pedido_ids) + rows = db.execute( + "SELECT ip.pedido_id, ip.produto_id, ip.quantidade, ip.preco_unitario, " + "p.nome AS produto_nome " + "FROM itens_pedido ip LEFT JOIN produtos p ON p.id = ip.produto_id " + "WHERE ip.pedido_id IN (" + placeholders + ")", + pedido_ids, + ).fetchall() + + itens_por_pedido = {} + for r in rows: + itens_por_pedido.setdefault(r["pedido_id"], []).append( + { + "produto_id": r["produto_id"], + "produto_nome": r["produto_nome"] if r["produto_nome"] else "Desconhecido", + "quantidade": r["quantidade"], + "preco_unitario": r["preco_unitario"], + } + ) + return itens_por_pedido + + +def _montar_pedidos(rows, itens_por_pedido): + return [ + { + "id": row["id"], + "usuario_id": row["usuario_id"], + "status": row["status"], + "total": row["total"], + "criado_em": row["criado_em"], + "itens": itens_por_pedido.get(row["id"], []), + } + for row in rows + ] + + +def listar_por_usuario(usuario_id): + db = get_db() + rows = db.execute("SELECT * FROM pedidos WHERE usuario_id = ?", (usuario_id,)).fetchall() + itens = _carregar_itens(db, [r["id"] for r in rows]) + return _montar_pedidos(rows, itens) + + +def listar_todos(): + db = get_db() + rows = db.execute("SELECT * FROM pedidos").fetchall() + itens = _carregar_itens(db, [r["id"] for r in rows]) + return _montar_pedidos(rows, itens) + + +def atualizar_status(pedido_id, novo_status): + db = get_db() + db.execute("UPDATE pedidos SET status = ? WHERE id = ?", (novo_status, pedido_id)) + db.commit() + return True diff --git a/code-smells-project/models/produto_model.py b/code-smells-project/models/produto_model.py new file mode 100644 index 000000000..084680dcb --- /dev/null +++ b/code-smells-project/models/produto_model.py @@ -0,0 +1,69 @@ +"""Model de Produto — acesso a dados com SQL parametrizado.""" +from database import get_db + +_CAMPOS = ("id", "nome", "descricao", "preco", "estoque", "categoria", "ativo", "criado_em") + + +def _serializar(row): + return {campo: row[campo] for campo in _CAMPOS} + + +def listar_todos(): + db = get_db() + rows = db.execute("SELECT * FROM produtos").fetchall() + return [_serializar(r) for r in rows] + + +def buscar_por_id(id): + db = get_db() + row = db.execute("SELECT * FROM produtos WHERE id = ?", (id,)).fetchone() + return _serializar(row) if row else None + + +def criar(nome, descricao, preco, estoque, categoria): + db = get_db() + cursor = db.execute( + "INSERT INTO produtos (nome, descricao, preco, estoque, categoria) VALUES (?, ?, ?, ?, ?)", + (nome, descricao, preco, estoque, categoria), + ) + db.commit() + return cursor.lastrowid + + +def atualizar(id, nome, descricao, preco, estoque, categoria): + db = get_db() + db.execute( + "UPDATE produtos SET nome = ?, descricao = ?, preco = ?, estoque = ?, categoria = ? WHERE id = ?", + (nome, descricao, preco, estoque, categoria, id), + ) + db.commit() + return True + + +def deletar(id): + db = get_db() + db.execute("DELETE FROM produtos WHERE id = ?", (id,)) + db.commit() + return True + + +def buscar(termo, categoria=None, preco_min=None, preco_max=None): + db = get_db() + query = "SELECT * FROM produtos WHERE 1=1" + params = [] + if termo: + query += " AND (nome LIKE ? OR descricao LIKE ?)" + like = "%" + termo + "%" + params.extend([like, like]) + if categoria: + query += " AND categoria = ?" + params.append(categoria) + if preco_min: + query += " AND preco >= ?" + params.append(preco_min) + if preco_max: + query += " AND preco <= ?" + params.append(preco_max) + + rows = db.execute(query, params).fetchall() + return [_serializar(r) for r in rows] diff --git a/code-smells-project/models/relatorio_model.py b/code-smells-project/models/relatorio_model.py new file mode 100644 index 000000000..354b3f6bf --- /dev/null +++ b/code-smells-project/models/relatorio_model.py @@ -0,0 +1,36 @@ +"""Model de Relatório — agregações de vendas e regra de desconto (constantes em config).""" +from config import settings +from database import get_db + + +def gerar(): + db = get_db() + + total_pedidos = db.execute("SELECT COUNT(*) FROM pedidos").fetchone()[0] + faturamento = db.execute("SELECT SUM(total) FROM pedidos").fetchone()[0] or 0 + pendentes = db.execute( + "SELECT COUNT(*) FROM pedidos WHERE status = ?", ("pendente",) + ).fetchone()[0] + aprovados = db.execute( + "SELECT COUNT(*) FROM pedidos WHERE status = ?", ("aprovado",) + ).fetchone()[0] + cancelados = db.execute( + "SELECT COUNT(*) FROM pedidos WHERE status = ?", ("cancelado",) + ).fetchone()[0] + + desconto = 0 + for limite, taxa in settings.DESCONTO_FAIXAS: + if faturamento > limite: + desconto = faturamento * taxa + break + + return { + "total_pedidos": total_pedidos, + "faturamento_bruto": round(faturamento, 2), + "desconto_aplicavel": round(desconto, 2), + "faturamento_liquido": round(faturamento - desconto, 2), + "pedidos_pendentes": pendentes, + "pedidos_aprovados": aprovados, + "pedidos_cancelados": cancelados, + "ticket_medio": round(faturamento / total_pedidos, 2) if total_pedidos > 0 else 0, + } diff --git a/code-smells-project/models/usuario_model.py b/code-smells-project/models/usuario_model.py new file mode 100644 index 000000000..e576bb669 --- /dev/null +++ b/code-smells-project/models/usuario_model.py @@ -0,0 +1,50 @@ +"""Model de Usuário — hashing de senha e SQL parametrizado. Nunca expõe a senha.""" +from werkzeug.security import check_password_hash, generate_password_hash + +from database import get_db + + +def _serializar_publico(row): + # A senha (hash) jamais é serializada na resposta. + return { + "id": row["id"], + "nome": row["nome"], + "email": row["email"], + "tipo": row["tipo"], + "criado_em": row["criado_em"], + } + + +def listar_todos(): + db = get_db() + rows = db.execute("SELECT * FROM usuarios").fetchall() + return [_serializar_publico(r) for r in rows] + + +def buscar_por_id(id): + db = get_db() + row = db.execute("SELECT * FROM usuarios WHERE id = ?", (id,)).fetchone() + return _serializar_publico(row) if row else None + + +def autenticar(email, senha): + db = get_db() + row = db.execute("SELECT * FROM usuarios WHERE email = ?", (email,)).fetchone() + if row and check_password_hash(row["senha"], senha): + return { + "id": row["id"], + "nome": row["nome"], + "email": row["email"], + "tipo": row["tipo"], + } + return None + + +def criar(nome, email, senha, tipo="cliente"): + db = get_db() + cursor = db.execute( + "INSERT INTO usuarios (nome, email, senha, tipo) VALUES (?, ?, ?, ?)", + (nome, email, generate_password_hash(senha), tipo), + ) + db.commit() + return cursor.lastrowid diff --git a/code-smells-project/services/__init__.py b/code-smells-project/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/services/notificacao_service.py b/code-smells-project/services/notificacao_service.py new file mode 100644 index 000000000..c2bba58a2 --- /dev/null +++ b/code-smells-project/services/notificacao_service.py @@ -0,0 +1,20 @@ +"""Serviço de notificações — desacopla os efeitos colaterais (email/sms/push) do controller. + +Usa logging estruturado no lugar de `print`. Em produção, plugar aqui os provedores reais. +""" +import logging + +logger = logging.getLogger(__name__) + + +def notificar_pedido_criado(pedido_id, usuario_id): + logger.info( + "Pedido %s criado para usuário %s — disparando email/sms/push", pedido_id, usuario_id + ) + + +def notificar_status_pedido(pedido_id, status): + if status == "aprovado": + logger.info("Pedido %s aprovado — preparar envio", pedido_id) + elif status == "cancelado": + logger.info("Pedido %s cancelado — devolver estoque", pedido_id) diff --git a/code-smells-project/views/__init__.py b/code-smells-project/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code-smells-project/views/routes.py b/code-smells-project/views/routes.py new file mode 100644 index 000000000..7a57e6b0b --- /dev/null +++ b/code-smells-project/views/routes.py @@ -0,0 +1,38 @@ +"""Camada de rotas (view) — apenas mapeia caminho + método HTTP para um controller.""" +from controllers import ( + pedido_controller, + produto_controller, + relatorio_controller, + sistema_controller, + usuario_controller, +) + + +def register_routes(app): + # Produtos + app.add_url_rule("/produtos", "listar_produtos", produto_controller.listar, methods=["GET"]) + app.add_url_rule("/produtos/busca", "buscar_produtos", produto_controller.buscar_lista, methods=["GET"]) + app.add_url_rule("/produtos/", "buscar_produto", produto_controller.buscar, methods=["GET"]) + app.add_url_rule("/produtos", "criar_produto", produto_controller.criar, methods=["POST"]) + app.add_url_rule("/produtos/", "atualizar_produto", produto_controller.atualizar, methods=["PUT"]) + app.add_url_rule("/produtos/", "deletar_produto", produto_controller.deletar, methods=["DELETE"]) + + # Usuários + app.add_url_rule("/usuarios", "listar_usuarios", usuario_controller.listar, methods=["GET"]) + app.add_url_rule("/usuarios/", "buscar_usuario", usuario_controller.buscar, methods=["GET"]) + app.add_url_rule("/usuarios", "criar_usuario", usuario_controller.criar, methods=["POST"]) + app.add_url_rule("/login", "login", usuario_controller.login, methods=["POST"]) + + # Pedidos + app.add_url_rule("/pedidos", "criar_pedido", pedido_controller.criar, methods=["POST"]) + app.add_url_rule("/pedidos", "listar_todos_pedidos", pedido_controller.listar_todos, methods=["GET"]) + app.add_url_rule("/pedidos/usuario/", "listar_pedidos_usuario", pedido_controller.listar_por_usuario, methods=["GET"]) + app.add_url_rule("/pedidos//status", "atualizar_status_pedido", pedido_controller.atualizar_status, methods=["PUT"]) + + # Relatórios + app.add_url_rule("/relatorios/vendas", "relatorio_vendas", relatorio_controller.vendas, methods=["GET"]) + + # Sistema + app.add_url_rule("/health", "health_check", sistema_controller.health, methods=["GET"]) + app.add_url_rule("/", "index", sistema_controller.index, methods=["GET"]) + app.add_url_rule("/admin/reset-db", "reset_database", sistema_controller.reset_database, methods=["POST"]) diff --git a/ecommerce-api-legacy/.claude/skills/refactor-arch b/ecommerce-api-legacy/.claude/skills/refactor-arch new file mode 120000 index 000000000..2378dd5de --- /dev/null +++ b/ecommerce-api-legacy/.claude/skills/refactor-arch @@ -0,0 +1 @@ +../../../.claude/skills/refactor-arch \ No newline at end of file diff --git a/ecommerce-api-legacy/.env.example b/ecommerce-api-legacy/.env.example new file mode 100644 index 000000000..cab6555bd --- /dev/null +++ b/ecommerce-api-legacy/.env.example @@ -0,0 +1,14 @@ +# Copie para .env e ajuste. NUNCA versione o .env real. +PORT=3000 + +# Banco (SQLite in-memory por padrão; credenciais só se migrar para outro engine) +DB_PATH=:memory: +DB_USER= +DB_PASS= + +# Segredos de integração +PAYMENT_GATEWAY_KEY=pk_test_xxxxxxxxxxxxxxxx +SMTP_USER=no-reply@example.com + +# Token para as rotas administrativas (header: x-admin-token) +ADMIN_TOKEN=defina-um-token-forte-aqui diff --git a/ecommerce-api-legacy/README.md b/ecommerce-api-legacy/README.md index 46f6896a6..0c69de588 100644 --- a/ecommerce-api-legacy/README.md +++ b/ecommerce-api-legacy/README.md @@ -11,4 +11,8 @@ npm start A aplicação sobe em `http://localhost:3000`. O banco SQLite é em memória e já carrega seeds automaticamente no boot. +Configuração e segredos vêm do ambiente (veja `.env.example`). As rotas administrativas +(`GET /api/admin/financial-report` e `DELETE /api/users/:id`) exigem o header +`x-admin-token` igual ao `ADMIN_TOKEN` (default de desenvolvimento: `dev-admin-token`). + Exemplos de requisições estão em `api.http`. diff --git a/ecommerce-api-legacy/api.http b/ecommerce-api-legacy/api.http index 916f6a110..b639111d3 100644 --- a/ecommerce-api-legacy/api.http +++ b/ecommerce-api-legacy/api.http @@ -24,8 +24,10 @@ Content-Type: application/json "card": "5111222233334444" } -### Relatório financeiro administrativo +### Relatório financeiro administrativo (rota protegida — exige token de admin) GET {{baseUrl}}/api/admin/financial-report +x-admin-token: dev-admin-token -### Deletar usuário +### Deletar usuário (rota protegida — exige token de admin) DELETE {{baseUrl}}/api/users/1 +x-admin-token: dev-admin-token diff --git a/ecommerce-api-legacy/src/AppManager.js b/ecommerce-api-legacy/src/AppManager.js deleted file mode 100644 index 8eb886282..000000000 --- a/ecommerce-api-legacy/src/AppManager.js +++ /dev/null @@ -1,141 +0,0 @@ -const sqlite3 = require('sqlite3').verbose(); -const { config, logAndCache, badCrypto, totalRevenue } = require('./utils'); - -class AppManager { - constructor() { - - this.db = new sqlite3.Database(':memory:'); - } - - initDb() { - this.db.serialize(() => { - this.db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, pass TEXT)"); - this.db.run("CREATE TABLE courses (id INTEGER PRIMARY KEY, title TEXT, price REAL, active INTEGER)"); - this.db.run("CREATE TABLE enrollments (id INTEGER PRIMARY KEY, user_id INTEGER, course_id INTEGER)"); - this.db.run("CREATE TABLE payments (id INTEGER PRIMARY KEY, enrollment_id INTEGER, amount REAL, status TEXT)"); - this.db.run("CREATE TABLE audit_logs (id INTEGER PRIMARY KEY, action TEXT, created_at DATETIME)"); - - this.db.run("INSERT INTO users (name, email, pass) VALUES ('Leonan', 'leonan@fullcycle.com.br', '123')"); - this.db.run("INSERT INTO courses (title, price, active) VALUES ('Clean Architecture', 997.00, 1), ('Docker', 497.00, 1)"); - this.db.run("INSERT INTO enrollments (user_id, course_id) VALUES (1, 1)"); - this.db.run("INSERT INTO payments (enrollment_id, amount, status) VALUES (1, 997.00, 'PAID')"); - }); - } - - setupRoutes(app) { - const self = this; - - app.post('/api/checkout', (req, res) => { - let u = req.body.usr; - let e = req.body.eml; - let p = req.body.pwd; - let cid = req.body.c_id; - let cc = req.body.card; - - if (!u || !e || !cid || !cc) return res.status(400).send("Bad Request"); - - this.db.get("SELECT * FROM courses WHERE id = ? AND active = 1", [cid], (err, course) => { - if (err || !course) return res.status(404).send("Curso não encontrado"); - - this.db.get("SELECT id FROM users WHERE email = ?", [e], (err, user) => { - if (err) return res.status(500).send("Erro DB"); - - let processPaymentAndEnroll = (userId) => { - - console.log(`Processando cartão ${cc} na chave ${config.paymentGatewayKey}`); - let status = cc.startsWith("4") ? "PAID" : "DENIED"; - - if (status === "DENIED") return res.status(400).send("Pagamento recusado"); - - this.db.run("INSERT INTO enrollments (user_id, course_id) VALUES (?, ?)", [userId, cid], function(err) { - if (err) return res.status(500).send("Erro Matrícula"); - let enrId = this.lastID; - - self.db.run("INSERT INTO payments (enrollment_id, amount, status) VALUES (?, ?, ?)", [enrId, course.price, status], function(err) { - if (err) return res.status(500).send("Erro Pagamento"); - - self.db.run("INSERT INTO audit_logs (action, created_at) VALUES (?, datetime('now'))", [`Checkout curso ${cid} por ${userId}`], (err) => { - - logAndCache(`last_checkout_${userId}`, course.title); - res.status(200).json({ msg: "Sucesso", enrollment_id: enrId }); - }); - }); - }); - }; - - if (!user) { - - let hash = badCrypto(p || "123456"); - this.db.run("INSERT INTO users (name, email, pass) VALUES (?, ?, ?)", [u, e, hash], function(err) { - if (err) return res.status(500).send("Erro ao criar usuário"); - processPaymentAndEnroll(this.lastID); - }); - } else { - processPaymentAndEnroll(user.id); - } - }); - }); - }); - - app.get('/api/admin/financial-report', (req, res) => { - let report = []; - - this.db.all("SELECT * FROM courses", [], (err, courses) => { - if (err) return res.status(500).send("Erro DB"); - - let coursesPending = courses.length; - if (coursesPending === 0) return res.json(report); - - courses.forEach(c => { - let courseData = { course: c.title, revenue: 0, students: [] }; - - this.db.all("SELECT * FROM enrollments WHERE course_id = ?", [c.id], (err, enrollments) => { - let enrPending = enrollments.length; - - if (enrPending === 0) { - report.push(courseData); - coursesPending--; - if (coursesPending === 0) res.json(report); - return; - } - - enrollments.forEach(enr => { - - this.db.get("SELECT name, email FROM users WHERE id = ?", [enr.user_id], (err, user) => { - - this.db.get("SELECT amount, status FROM payments WHERE enrollment_id = ?", [enr.id], (err, payment) => { - - if (payment && payment.status === 'PAID') { - courseData.revenue += payment.amount; - } - - courseData.students.push({ - student: user ? user.name : 'Unknown', - paid: payment ? payment.amount : 0 - }); - - enrPending--; - if (enrPending === 0) { - report.push(courseData); - coursesPending--; - if (coursesPending === 0) res.json(report); - } - }); - }); - }); - }); - }); - }); - }); - - app.delete('/api/users/:id', (req, res) => { - let id = req.params.id; - this.db.run("DELETE FROM users WHERE id = ?", [id], (err) => { - - res.send("Usuário deletado, mas as matrículas e pagamentos ficaram sujos no banco."); - }); - }); - } -} - -module.exports = AppManager; diff --git a/ecommerce-api-legacy/src/app.js b/ecommerce-api-legacy/src/app.js index 406632614..b2343f427 100644 --- a/ecommerce-api-legacy/src/app.js +++ b/ecommerce-api-legacy/src/app.js @@ -1,14 +1,77 @@ const express = require('express'); -const AppManager = require('./AppManager'); -const { config } = require('./utils'); -const app = express(); -app.use(express.json()); +const config = require('./config'); +const Database = require('./db/database'); +const initDb = require('./db/init'); -const manager = new AppManager(); -manager.initDb(); -manager.setupRoutes(app); +const UserModel = require('./models/userModel'); +const CourseModel = require('./models/courseModel'); +const EnrollmentModel = require('./models/enrollmentModel'); +const PaymentModel = require('./models/paymentModel'); +const AuditLogModel = require('./models/auditLogModel'); +const ReportModel = require('./models/reportModel'); -app.listen(config.port, () => { - console.log(`Frankenstein LMS rodando na porta ${config.port}...`); -}); +const CheckoutService = require('./services/checkoutService'); +const ReportService = require('./services/reportService'); + +const CheckoutController = require('./controllers/checkoutController'); +const ReportController = require('./controllers/reportController'); +const UserController = require('./controllers/userController'); + +const buildRouter = require('./routes'); +const errorHandler = require('./middlewares/errorHandler'); + +// Composition root: cria a app, injeta a conexão nos models/services/controllers, +// registra rotas e o middleware de erro, e sobe o servidor. +async function createApp() { + const db = new Database(config.db.path); + await initDb(db); + + // Models (data access) com a conexão injetada — sem estado global. + const userModel = new UserModel(db); + const courseModel = new CourseModel(db); + const enrollmentModel = new EnrollmentModel(db); + const paymentModel = new PaymentModel(db); + const auditLogModel = new AuditLogModel(db); + const reportModel = new ReportModel(db); + + // Services (regra de negócio / orquestração de domínio). + const checkoutService = new CheckoutService({ + db, + userModel, + courseModel, + enrollmentModel, + paymentModel, + auditLogModel, + }); + const reportService = new ReportService({ reportModel }); + + // Controllers (magros). + const checkoutController = new CheckoutController({ checkoutService }); + const reportController = new ReportController({ reportService }); + const userController = new UserController({ userModel }); + + const app = express(); + app.use(express.json()); + app.use('/api', buildRouter({ checkoutController, reportController, userController })); + app.use(errorHandler); + + return app; +} + +async function start() { + const app = await createApp(); + app.listen(config.port, () => { + console.log(`LMS API rodando na porta ${config.port}...`); + }); +} + +// Só inicia o servidor quando executado diretamente (facilita testes). +if (require.main === module) { + start().catch((err) => { + console.error('Falha ao iniciar a aplicação:', err); + process.exit(1); + }); +} + +module.exports = { createApp }; diff --git a/ecommerce-api-legacy/src/config/index.js b/ecommerce-api-legacy/src/config/index.js new file mode 100644 index 000000000..2aa6f28b3 --- /dev/null +++ b/ecommerce-api-legacy/src/config/index.js @@ -0,0 +1,15 @@ +// Configuração centralizada — segredos vêm do ambiente (process.env), nunca hardcoded. +// Veja .env.example para as variáveis esperadas. +module.exports = { + port: parseInt(process.env.PORT, 10) || 3000, + db: { + // Mantidos por compatibilidade; hoje o banco é SQLite in-memory e não usa credenciais. + user: process.env.DB_USER || '', + pass: process.env.DB_PASS || '', + path: process.env.DB_PATH || ':memory:', + }, + paymentGatewayKey: process.env.PAYMENT_GATEWAY_KEY || '', + smtpUser: process.env.SMTP_USER || '', + // Token administrativo para proteger rotas sensíveis. Defina ADMIN_TOKEN em produção. + adminToken: process.env.ADMIN_TOKEN || 'dev-admin-token', +}; diff --git a/ecommerce-api-legacy/src/controllers/checkoutController.js b/ecommerce-api-legacy/src/controllers/checkoutController.js new file mode 100644 index 000000000..30dbc9aff --- /dev/null +++ b/ecommerce-api-legacy/src/controllers/checkoutController.js @@ -0,0 +1,39 @@ +const AppError = require('../errors/AppError'); + +// Controller magro: valida a entrada, delega ao service e formata a resposta. +class CheckoutController { + constructor({ checkoutService }) { + this.checkoutService = checkoutService; + this.checkout = this.checkout.bind(this); + } + + async checkout(req, res, next) { + try { + const { usr, eml, pwd, c_id, card } = req.body || {}; + + // Contrato original: usr, eml, c_id e card são obrigatórios (pwd é opcional). + if (!usr || !eml || !c_id || !card) { + throw new AppError('Bad Request', 400); + } + + const courseId = Number(c_id); + if (!Number.isInteger(courseId)) { + throw new AppError('Bad Request', 400); + } + + const result = await this.checkoutService.checkout({ + name: usr, + email: eml, + password: pwd, + courseId, + card, + }); + + res.status(200).json(result); + } catch (err) { + next(err); + } + } +} + +module.exports = CheckoutController; diff --git a/ecommerce-api-legacy/src/controllers/reportController.js b/ecommerce-api-legacy/src/controllers/reportController.js new file mode 100644 index 000000000..9abe2bb23 --- /dev/null +++ b/ecommerce-api-legacy/src/controllers/reportController.js @@ -0,0 +1,18 @@ +// Controller magro do relatório financeiro: delega ao service e serializa o JSON. +class ReportController { + constructor({ reportService }) { + this.reportService = reportService; + this.financialReport = this.financialReport.bind(this); + } + + async financialReport(req, res, next) { + try { + const report = await this.reportService.financialReport(); + res.status(200).json(report); + } catch (err) { + next(err); + } + } +} + +module.exports = ReportController; diff --git a/ecommerce-api-legacy/src/controllers/userController.js b/ecommerce-api-legacy/src/controllers/userController.js new file mode 100644 index 000000000..d1ac251c0 --- /dev/null +++ b/ecommerce-api-legacy/src/controllers/userController.js @@ -0,0 +1,19 @@ +// Controller de usuários: orquestra a remoção (com limpeza de dependências no model). +class UserController { + constructor({ userModel }) { + this.userModel = userModel; + this.remove = this.remove.bind(this); + } + + async remove(req, res, next) { + try { + const id = Number(req.params.id); + await this.userModel.deleteWithDependencies(id); + res.status(200).send('Usuário e dados relacionados (matrículas e pagamentos) removidos.'); + } catch (err) { + next(err); + } + } +} + +module.exports = UserController; diff --git a/ecommerce-api-legacy/src/db/database.js b/ecommerce-api-legacy/src/db/database.js new file mode 100644 index 000000000..b9a2b43d4 --- /dev/null +++ b/ecommerce-api-legacy/src/db/database.js @@ -0,0 +1,51 @@ +const sqlite3 = require('sqlite3').verbose(); + +// Wrapper fino sobre o driver sqlite3 que expõe uma API baseada em Promises, +// eliminando o callback hell e permitindo async/await + transações explícitas. +class Database { + constructor(path = ':memory:') { + this.db = new sqlite3.Database(path); + } + + run(sql, params = []) { + return new Promise((resolve, reject) => { + this.db.run(sql, params, function (err) { + if (err) return reject(err); + resolve({ lastID: this.lastID, changes: this.changes }); + }); + }); + } + + get(sql, params = []) { + return new Promise((resolve, reject) => { + this.db.get(sql, params, (err, row) => (err ? reject(err) : resolve(row))); + }); + } + + all(sql, params = []) { + return new Promise((resolve, reject) => { + this.db.all(sql, params, (err, rows) => (err ? reject(err) : resolve(rows))); + }); + } + + exec(sql) { + return new Promise((resolve, reject) => { + this.db.exec(sql, (err) => (err ? reject(err) : resolve())); + }); + } + + // Executa `work` dentro de uma transação atômica (commit no sucesso, rollback no erro). + async transaction(work) { + await this.exec('BEGIN'); + try { + const result = await work(); + await this.exec('COMMIT'); + return result; + } catch (err) { + await this.exec('ROLLBACK'); + throw err; + } + } +} + +module.exports = Database; diff --git a/ecommerce-api-legacy/src/db/init.js b/ecommerce-api-legacy/src/db/init.js new file mode 100644 index 000000000..1bf3d0d69 --- /dev/null +++ b/ecommerce-api-legacy/src/db/init.js @@ -0,0 +1,28 @@ +const passwordService = require('../services/passwordService'); + +// Cria o schema e carrega os seeds. Recebe a conexão injetada (sem estado global). +// A senha do seed é hasheada (nunca em texto puro). +async function initDb(db) { + await db.exec(` + CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, pass TEXT); + CREATE TABLE courses (id INTEGER PRIMARY KEY, title TEXT, price REAL, active INTEGER); + CREATE TABLE enrollments (id INTEGER PRIMARY KEY, user_id INTEGER, course_id INTEGER); + CREATE TABLE payments (id INTEGER PRIMARY KEY, enrollment_id INTEGER, amount REAL, status TEXT); + CREATE TABLE audit_logs (id INTEGER PRIMARY KEY, action TEXT, created_at DATETIME); + `); + + const seedPass = passwordService.hash('123'); + await db.run('INSERT INTO users (name, email, pass) VALUES (?, ?, ?)', [ + 'Leonan', + 'leonan@fullcycle.com.br', + seedPass, + ]); + await db.run( + 'INSERT INTO courses (title, price, active) VALUES (?, ?, 1), (?, ?, 1)', + ['Clean Architecture', 997.0, 'Docker', 497.0] + ); + await db.run('INSERT INTO enrollments (user_id, course_id) VALUES (1, 1)'); + await db.run("INSERT INTO payments (enrollment_id, amount, status) VALUES (1, 997.00, 'PAID')"); +} + +module.exports = initDb; diff --git a/ecommerce-api-legacy/src/errors/AppError.js b/ecommerce-api-legacy/src/errors/AppError.js new file mode 100644 index 000000000..4959bcba3 --- /dev/null +++ b/ecommerce-api-legacy/src/errors/AppError.js @@ -0,0 +1,11 @@ +// Erro de domínio com código HTTP associado. Controllers repassam via next(err) +// e o middleware central de erro traduz para a resposta HTTP correta. +class AppError extends Error { + constructor(message, statusCode = 500) { + super(message); + this.name = 'AppError'; + this.statusCode = statusCode; + } +} + +module.exports = AppError; diff --git a/ecommerce-api-legacy/src/middlewares/errorHandler.js b/ecommerce-api-legacy/src/middlewares/errorHandler.js new file mode 100644 index 000000000..98963cd22 --- /dev/null +++ b/ecommerce-api-legacy/src/middlewares/errorHandler.js @@ -0,0 +1,11 @@ +const AppError = require('../errors/AppError'); + +// Tratamento de erro centralizado — evita try/catch repetido e vazamento de stack trace. +// eslint-disable-next-line no-unused-vars +module.exports = function errorHandler(err, req, res, next) { + if (err instanceof AppError) { + return res.status(err.statusCode).send(err.message); + } + console.error('[ERROR]', err.message); + res.status(500).send('Erro interno'); +}; diff --git a/ecommerce-api-legacy/src/middlewares/requireAdmin.js b/ecommerce-api-legacy/src/middlewares/requireAdmin.js new file mode 100644 index 000000000..7aa9cbaa7 --- /dev/null +++ b/ecommerce-api-legacy/src/middlewares/requireAdmin.js @@ -0,0 +1,11 @@ +const config = require('../config'); +const AppError = require('../errors/AppError'); + +// Protege rotas administrativas/destrutivas. Exige o header x-admin-token válido. +module.exports = function requireAdmin(req, res, next) { + const token = req.get('x-admin-token'); + if (!token || token !== config.adminToken) { + return next(new AppError('Unauthorized', 401)); + } + next(); +}; diff --git a/ecommerce-api-legacy/src/models/auditLogModel.js b/ecommerce-api-legacy/src/models/auditLogModel.js new file mode 100644 index 000000000..2b8b10d8d --- /dev/null +++ b/ecommerce-api-legacy/src/models/auditLogModel.js @@ -0,0 +1,15 @@ +// Model de logs de auditoria — acesso a dados da entidade AuditLog. +class AuditLogModel { + constructor(db) { + this.db = db; + } + + create(action) { + return this.db.run( + "INSERT INTO audit_logs (action, created_at) VALUES (?, datetime('now'))", + [action] + ); + } +} + +module.exports = AuditLogModel; diff --git a/ecommerce-api-legacy/src/models/courseModel.js b/ecommerce-api-legacy/src/models/courseModel.js new file mode 100644 index 000000000..fd3a86274 --- /dev/null +++ b/ecommerce-api-legacy/src/models/courseModel.js @@ -0,0 +1,16 @@ +// Model de cursos — acesso a dados da entidade Course. +class CourseModel { + constructor(db) { + this.db = db; + } + + findActiveById(id) { + return this.db.get('SELECT * FROM courses WHERE id = ? AND active = 1', [id]); + } + + findAll() { + return this.db.all('SELECT * FROM courses', []); + } +} + +module.exports = CourseModel; diff --git a/ecommerce-api-legacy/src/models/enrollmentModel.js b/ecommerce-api-legacy/src/models/enrollmentModel.js new file mode 100644 index 000000000..a03d62184 --- /dev/null +++ b/ecommerce-api-legacy/src/models/enrollmentModel.js @@ -0,0 +1,16 @@ +// Model de matrículas — acesso a dados da entidade Enrollment. +class EnrollmentModel { + constructor(db) { + this.db = db; + } + + async create(userId, courseId) { + const { lastID } = await this.db.run( + 'INSERT INTO enrollments (user_id, course_id) VALUES (?, ?)', + [userId, courseId] + ); + return lastID; + } +} + +module.exports = EnrollmentModel; diff --git a/ecommerce-api-legacy/src/models/paymentModel.js b/ecommerce-api-legacy/src/models/paymentModel.js new file mode 100644 index 000000000..558bffb70 --- /dev/null +++ b/ecommerce-api-legacy/src/models/paymentModel.js @@ -0,0 +1,16 @@ +// Model de pagamentos — acesso a dados da entidade Payment. +class PaymentModel { + constructor(db) { + this.db = db; + } + + async create(enrollmentId, amount, status) { + const { lastID } = await this.db.run( + 'INSERT INTO payments (enrollment_id, amount, status) VALUES (?, ?, ?)', + [enrollmentId, amount, status] + ); + return lastID; + } +} + +module.exports = PaymentModel; diff --git a/ecommerce-api-legacy/src/models/reportModel.js b/ecommerce-api-legacy/src/models/reportModel.js new file mode 100644 index 000000000..a2600d1b9 --- /dev/null +++ b/ecommerce-api-legacy/src/models/reportModel.js @@ -0,0 +1,26 @@ +// Model de relatório — busca todos os dados do relatório financeiro numa única query +// com JOINs, eliminando o N+1 (antes: uma query por curso, matrícula, usuário e pagamento). +class ReportModel { + constructor(db) { + this.db = db; + } + + financialRows() { + return this.db.all( + `SELECT c.id AS course_id, + c.title AS course_title, + e.id AS enrollment_id, + u.name AS user_name, + p.amount AS pay_amount, + p.status AS pay_status + FROM courses c + LEFT JOIN enrollments e ON e.course_id = c.id + LEFT JOIN users u ON u.id = e.user_id + LEFT JOIN payments p ON p.enrollment_id = e.id + ORDER BY c.id`, + [] + ); + } +} + +module.exports = ReportModel; diff --git a/ecommerce-api-legacy/src/models/userModel.js b/ecommerce-api-legacy/src/models/userModel.js new file mode 100644 index 000000000..6a6bdc650 --- /dev/null +++ b/ecommerce-api-legacy/src/models/userModel.js @@ -0,0 +1,33 @@ +// Model de usuários — todo acesso a dados da entidade User vive aqui. +class UserModel { + constructor(db) { + this.db = db; + } + + findByEmail(email) { + return this.db.get('SELECT id, name, email FROM users WHERE email = ?', [email]); + } + + async create(name, email, passHash) { + const { lastID } = await this.db.run( + 'INSERT INTO users (name, email, pass) VALUES (?, ?, ?)', + [name, email, passHash] + ); + return lastID; + } + + // Remove o usuário e seus dados dependentes de forma atômica (sem deixar órfãos). + async deleteWithDependencies(id) { + return this.db.transaction(async () => { + await this.db.run( + 'DELETE FROM payments WHERE enrollment_id IN (SELECT id FROM enrollments WHERE user_id = ?)', + [id] + ); + await this.db.run('DELETE FROM enrollments WHERE user_id = ?', [id]); + const { changes } = await this.db.run('DELETE FROM users WHERE id = ?', [id]); + return changes; + }); + } +} + +module.exports = UserModel; diff --git a/ecommerce-api-legacy/src/routes/index.js b/ecommerce-api-legacy/src/routes/index.js new file mode 100644 index 000000000..3891854f1 --- /dev/null +++ b/ecommerce-api-legacy/src/routes/index.js @@ -0,0 +1,18 @@ +const express = require('express'); +const requireAdmin = require('../middlewares/requireAdmin'); + +// Camada "view" das APIs: apenas mapeia caminho + método HTTP → controller. +// Não contém regra de negócio nem acesso a dados. Preserva os contratos originais. +function buildRouter({ checkoutController, reportController, userController }) { + const router = express.Router(); + + router.post('/checkout', checkoutController.checkout); + + // Rotas administrativas/destrutivas protegidas por autenticação de admin. + router.get('/admin/financial-report', requireAdmin, reportController.financialReport); + router.delete('/users/:id', requireAdmin, userController.remove); + + return router; +} + +module.exports = buildRouter; diff --git a/ecommerce-api-legacy/src/services/checkoutService.js b/ecommerce-api-legacy/src/services/checkoutService.js new file mode 100644 index 000000000..f46951dd5 --- /dev/null +++ b/ecommerce-api-legacy/src/services/checkoutService.js @@ -0,0 +1,49 @@ +const AppError = require('../errors/AppError'); +const passwordService = require('./passwordService'); +const paymentGateway = require('./paymentGateway'); + +const DEFAULT_PASSWORD = '123456'; + +// Orquestra a regra de negócio do checkout: valida curso, resolve/cria usuário, +// cobra o pagamento e persiste matrícula + pagamento + auditoria de forma atômica. +class CheckoutService { + constructor({ db, userModel, courseModel, enrollmentModel, paymentModel, auditLogModel }) { + this.db = db; + this.userModel = userModel; + this.courseModel = courseModel; + this.enrollmentModel = enrollmentModel; + this.paymentModel = paymentModel; + this.auditLogModel = auditLogModel; + } + + async checkout({ name, email, password, courseId, card }) { + const course = await this.courseModel.findActiveById(courseId); + if (!course) throw new AppError('Curso não encontrado', 404); + + let user = await this.userModel.findByEmail(email); + let userId; + if (!user) { + const passHash = passwordService.hash(password || DEFAULT_PASSWORD); + userId = await this.userModel.create(name, email, passHash); + } else { + userId = user.id; + } + + const status = paymentGateway.charge(card); + if (status === paymentGateway.PaymentStatus.DENIED) { + throw new AppError('Pagamento recusado', 400); + } + + // Matrícula + pagamento + log numa única transação (atomicidade). + const enrollmentId = await this.db.transaction(async () => { + const enrId = await this.enrollmentModel.create(userId, courseId); + await this.paymentModel.create(enrId, course.price, status); + await this.auditLogModel.create(`Checkout curso ${courseId} por ${userId}`); + return enrId; + }); + + return { msg: 'Sucesso', enrollment_id: enrollmentId }; + } +} + +module.exports = CheckoutService; diff --git a/ecommerce-api-legacy/src/services/passwordService.js b/ecommerce-api-legacy/src/services/passwordService.js new file mode 100644 index 000000000..e63fda29b --- /dev/null +++ b/ecommerce-api-legacy/src/services/passwordService.js @@ -0,0 +1,22 @@ +const crypto = require('crypto'); + +// Hashing forte com salt usando scrypt (KDF nativo do Node, resistente a brute-force). +// Formato armazenado: ":". Substitui o antigo badCrypto inseguro. +const KEY_LENGTH = 64; + +function hash(password) { + const salt = crypto.randomBytes(16).toString('hex'); + const derived = crypto.scryptSync(String(password), salt, KEY_LENGTH).toString('hex'); + return `${salt}:${derived}`; +} + +function verify(password, stored) { + if (!stored || !stored.includes(':')) return false; + const [salt, expected] = stored.split(':'); + const derived = crypto.scryptSync(String(password), salt, KEY_LENGTH).toString('hex'); + const a = Buffer.from(derived, 'hex'); + const b = Buffer.from(expected, 'hex'); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +module.exports = { hash, verify }; diff --git a/ecommerce-api-legacy/src/services/paymentGateway.js b/ecommerce-api-legacy/src/services/paymentGateway.js new file mode 100644 index 000000000..02e3f2f14 --- /dev/null +++ b/ecommerce-api-legacy/src/services/paymentGateway.js @@ -0,0 +1,20 @@ +const config = require('../config'); + +// Encapsula a integração com o gateway de pagamento. +// Regra de aprovação isolada aqui (antes era um cc.startsWith("4") mágico no handler). +const PaymentStatus = Object.freeze({ PAID: 'PAID', DENIED: 'DENIED' }); + +// Cartões que começam com este prefixo são aprovados (simulação do gateway legado). +const APPROVED_CARD_PREFIX = '4'; + +function charge(card) { + // Não logamos o número do cartão nem a chave do gateway (dados sensíveis / PCI). + if (!config.paymentGatewayKey) { + // Sem chave configurada, seguimos a simulação sem expor segredos. + } + return String(card).startsWith(APPROVED_CARD_PREFIX) + ? PaymentStatus.PAID + : PaymentStatus.DENIED; +} + +module.exports = { charge, PaymentStatus }; diff --git a/ecommerce-api-legacy/src/services/reportService.js b/ecommerce-api-legacy/src/services/reportService.js new file mode 100644 index 000000000..5f393d362 --- /dev/null +++ b/ecommerce-api-legacy/src/services/reportService.js @@ -0,0 +1,36 @@ +const { PaymentStatus } = require('./paymentGateway'); + +// Monta o relatório financeiro a partir das linhas achatadas do JOIN, +// preservando o formato de saída original: [{ course, revenue, students: [{ student, paid }] }]. +class ReportService { + constructor({ reportModel }) { + this.reportModel = reportModel; + } + + async financialReport() { + const rows = await this.reportModel.financialRows(); + + const byCourse = new Map(); + for (const row of rows) { + if (!byCourse.has(row.course_id)) { + byCourse.set(row.course_id, { course: row.course_title, revenue: 0, students: [] }); + } + const data = byCourse.get(row.course_id); + + // Linha sem matrícula (curso sem alunos) — só registra o curso. + if (row.enrollment_id == null) continue; + + if (row.pay_status === PaymentStatus.PAID) { + data.revenue += row.pay_amount; + } + data.students.push({ + student: row.user_name || 'Unknown', + paid: row.pay_amount != null ? row.pay_amount : 0, + }); + } + + return Array.from(byCourse.values()); + } +} + +module.exports = ReportService; diff --git a/ecommerce-api-legacy/src/utils.js b/ecommerce-api-legacy/src/utils.js deleted file mode 100644 index 28a07a64e..000000000 --- a/ecommerce-api-legacy/src/utils.js +++ /dev/null @@ -1,25 +0,0 @@ -const config = { - dbUser: "admin_master", - dbPass: "senha_super_secreta_prod_123", - paymentGatewayKey: "pk_live_1234567890abcdef", - smtpUser: "no-reply@fullcycle.com.br", - port: 3000 -}; - -let globalCache = {}; -let totalRevenue = 0; - -function logAndCache(key, data) { - console.log(`[LOG] Salvando no cache: ${key}`); - globalCache[key] = data; -} - -function badCrypto(pwd) { - let hash = ""; - for(let i = 0; i < 10000; i++) { - hash += Buffer.from(pwd).toString('base64').substring(0, 2); - } - return hash.substring(0, 10); -} - -module.exports = { config, logAndCache, badCrypto, globalCache, totalRevenue }; diff --git a/reports/audit-project-1.md b/reports/audit-project-1.md new file mode 100644 index 000000000..7abd57531 --- /dev/null +++ b/reports/audit-project-1.md @@ -0,0 +1,99 @@ +================================ +ARCHITECTURE AUDIT REPORT +================================ +Project: code-smells-project +Stack: Python + Flask 3.1.1 +Files: 4 analyzed | ~780 lines of code + +Summary +CRITICAL: 6 | HIGH: 2 | MEDIUM: 4 | LOW: 1 + +MVC Adherence: 4/10 (Parcial) + Models: parcial — models.py isola acesso a dados, mas concentra regra de negócio, múltiplos domínios e SQL inseguro + Views/Routes: parcial — app.py mapeia rotas, porém traz handlers com lógica e SQL inline (index, /admin/*) + Controllers: parcial — controllers.py orquestra e valida, mas com try/except duplicado e efeitos colaterais (notificações via print) + Separation: violada — estado global de conexão, SQL concatenado e ausência de camadas de config/middlewares + +Findings + +[CRITICAL] SQL Injection generalizado por concatenação de strings +File: models.py:28,48-49,58-60,68,92,110,127-128,140,149-150,155,158-160,164-165,174,188,192,220,224,280,289-297 +Description: Praticamente toda a camada de dados monta SQL concatenando input do usuário diretamente na query (ex.: "SELECT * FROM produtos WHERE id = " + str(id); "... WHERE email = '" + email + "'"; busca com termo/categoria via concatenação). Nenhuma query usa parâmetros vinculados. +Impact: Injeção de SQL trivial em busca, login e CRUD — leitura, alteração e destruição arbitrária do banco; bypass de autenticação no login_usuario. +Recommendation: Usar exclusivamente queries parametrizadas (placeholders "?" com tupla de parâmetros) em toda a camada de dados. Ver AP-03 e o playbook de refatoração. + +[CRITICAL] Endpoint /admin/query executa SQL arbitrário do cliente +File: app.py:59-78 +Description: A rota POST /admin/query recebe uma string SQL no corpo e a executa diretamente (cursor.execute(query)), com commit para não-SELECT, sem qualquer autenticação. +Impact: Qualquer cliente pode ler ou destruir todo o banco (DROP/DELETE/UPDATE) e exfiltrar dados sensíveis (inclusive senhas). +Recommendation: Remover o endpoint. Necessidades administrativas devem passar por operações específicas, parametrizadas e autenticadas. Ver AP-13. + +[CRITICAL] Endpoint /admin/reset-db destrutivo e sem autenticação +File: app.py:47-57 +Description: POST /admin/reset-db apaga todas as tabelas (DELETE FROM itens_pedido/pedidos/produtos/usuarios) sem verificação de identidade ou permissão. +Impact: Qualquer um pode zerar o banco de produção em uma única requisição. +Recommendation: Remover em produção ou proteger com autenticação/role administrativa e confirmação explícita. Ver AP-13. + +[CRITICAL] Senhas em texto puro (armazenamento, comparação e exposição) +File: models.py:83,99,105-131; database.py:75-83 +Description: Senhas são gravadas em texto puro (criar_usuario e seed), comparadas em texto puro no login (WHERE senha = '...') e ainda retornadas nas respostas de get_todos_usuarios e get_usuario_por_id (campo "senha" no JSON). +Impact: Vazamento direto de credenciais em qualquer breach ou em uma simples chamada GET /usuarios; sem qualquer proteção criptográfica. +Recommendation: Fazer hash com salt (werkzeug.security / bcrypt / argon2) na criação e verificar hash no login; nunca serializar o campo senha nas respostas. Ver AP-04. + +[CRITICAL] SECRET_KEY hardcoded e vazada pelo /health +File: app.py:7; controllers.py:289 +Description: A SECRET_KEY está fixa no código ("minha-chave-super-secreta-123") e é devolvida em texto no payload do endpoint /health (junto com debug e db_path). +Impact: Segredo exposto em todo clone/commit e publicado por um endpoint público, permitindo forjar/assinar dados da aplicação. +Recommendation: Carregar a chave de variável de ambiente num módulo de config; remover secret_key/debug do retorno de /health. Ver AP-02. + +[CRITICAL] God module e violação estrutural do MVC +File: models.py:1-315; controllers.py:1-293; database.py:14-84 +Description: models.py concentra acesso a dados, regra de negócio (cálculo de total/estoque em criar_pedido, regras de desconto em relatorio_vendas) e múltiplos domínios (produtos, usuários, pedidos, relatórios) num único arquivo; database.py mistura conexão, DDL (CREATE TABLE) e seed; não há camadas de config nem middlewares. A separação existente é apenas por tipo técnico, não por responsabilidade. +Impact: Impossível testar/reusar regras em isolamento; qualquer mudança arrisca quebrar domínios não relacionados; viola SRP e o padrão MVC-alvo. +Recommendation: Separar models por entidade (produto/usuario/pedido), extrair regra de negócio para model/service, isolar config e schema, adicionar middleware de erro. Ver AP-01 e 04-mvc-guidelines. + +[HIGH] Estado global mutável de conexão com check_same_thread=False +File: database.py:4-10 +Description: A conexão SQLite é guardada numa variável global (db_connection) reatribuída via singleton implícito e aberta com check_same_thread=False para contornar o compartilhamento entre threads do Flask. +Impact: Condições de corrida entre requisições concorrentes, acoplamento forte e código praticamente impossível de testar/paralelizar. +Recommendation: Conexão por request (factory) injetada no composition root; usar app context (g) ou pool. Ver AP-05. + +[HIGH] Regra de negócio na camada de dados +File: models.py:133-169,235-273 +Description: criar_pedido faz validação de estoque, cálculo de total e baixa de estoque diretamente no acesso a dados; relatorio_vendas embute regras de desconto por faixa de faturamento. A lógica de domínio está presa ao SQL. +Impact: Regra não reutilizável nem testável isoladamente; controllers ficam anêmicos e a regra fica acoplada ao banco, dificultando manutenção e evolução. +Recommendation: Extrair a regra para model/service de pedido e de relatório, deixando o acesso a dados separado. Ver AP-06. + +[MEDIUM] Query N+1 na listagem de pedidos +File: models.py:187-199,219-231 +Description: get_pedidos_usuario e get_todos_pedidos iteram sobre pedidos e, para cada um, abrem novo cursor para buscar itens e, dentro deste, mais uma query por item para o nome do produto. +Impact: Número de queries cresce linearmente com pedidos e itens; degradação de performance e carga desnecessária no banco. +Recommendation: Substituir por JOINs (pedidos + itens_pedido + produtos) ou por consultas agregadas com IN (...). Ver AP-08. + +[MEDIUM] criar_pedido sem transação atômica +File: models.py:133-169 +Description: A criação de pedido faz INSERT em pedidos, múltiplos INSERT em itens_pedido e UPDATE de estoque com um único commit ao final, sem transação explícita nem rollback em caso de falha no meio. +Impact: Falha parcial deixa dados inconsistentes (pedido sem itens, estoque baixado sem pedido); integridade comprometida. +Recommendation: Envolver a operação numa transação explícita com rollback no erro (try/except + BEGIN/ROLLBACK ou context manager). Ver AP-12. + +[MEDIUM] Duplicação massiva de código +File: models.py:9-22,77-87,203-233; controllers.py:6-12,21-22,etc. +Description: O mapeamento row→dict de produto/usuário/pedido é repetido em várias funções; get_pedidos_usuario e get_todos_pedidos são quase idênticas; o bloco try/except que formata erro 500 é copiado em praticamente todos os controllers. +Impact: Manutenção multiplicada e risco de divergência silenciosa entre cópias. +Recommendation: Extrair serializadores/reutilizar funções de query e centralizar o tratamento de erro num middleware. Ver AP-10. + +[MEDIUM] Debug ligado e servidor de desenvolvimento em produção +File: app.py:8,88; controllers.py:208-210,248-250 +Description: DEBUG=True na config e debug=True no app.run(host="0.0.0.0"), expondo o debugger interativo do Werkzeug; efeitos colaterais (envio de email/sms/push, notificações) são simulados com print espalhados pelos controllers. +Impact: Debugger interativo exposto permite execução de código remoto; logs poluídos e lógica de notificação acoplada ao controller sem tratamento real. +Recommendation: Controlar debug por ambiente (config), servir via WSGI de produção e mover notificações para um serviço dedicado com logging estruturado. Ver AP-16. + +[LOW] Magic numbers nas regras de desconto +File: models.py:256-262 +Description: As faixas e taxas de desconto (10000/0.1, 5000/0.05, 1000/0.02) estão como literais soltos no meio do cálculo de relatorio_vendas. +Impact: Intenção de negócio obscura e mudança arriscada/duplicada. +Recommendation: Extrair para constantes nomeadas em config ou no topo do módulo. Ver AP-14. + +================================ +Total: 13 findings +================================ diff --git a/reports/audit-project-2.md b/reports/audit-project-2.md new file mode 100644 index 000000000..116b3e0ea --- /dev/null +++ b/reports/audit-project-2.md @@ -0,0 +1,99 @@ +================================ +ARCHITECTURE AUDIT REPORT +================================ +Project: ecommerce-api-legacy +Stack: JavaScript (Node.js) + Express 4 +Files: 3 analyzed | ~180 lines of code + +Summary +CRITICAL: 4 | HIGH: 3 | MEDIUM: 4 | LOW: 2 + +MVC Adherence: 1/10 (Monolítico) + Models: ausente — SQL cru espalhado nos handlers; nenhuma entidade encapsula dados/regra + Views/Routes: ausente — rotas declaradas dentro de um método da God Class, misturadas à lógica + Controllers: ausente — não há camada de orquestração; handlers fazem tudo inline + Separation: violada — AppManager concentra DB, regra de negócio e roteamento (God Class) + +Findings + +[CRITICAL] God Class — AppManager concentra dados, regra e roteamento +File: src/AppManager.js:4-141 +Description: A classe AppManager faz tudo: abre a conexão SQLite, cria schema e seeds, declara TODAS as rotas e implementa a regra de negócio de checkout e relatório inline. É um único módulo com responsabilidades de model, controller e router de múltiplos domínios (users, courses, enrollments, payments). +Impact: Impossível testar qualquer regra em isolamento; qualquer alteração arrisca quebrar toda a aplicação; viola SRP e o padrão MVC por completo. +Recommendation: Separar em camadas — models/repositórios por entidade, controllers magros por recurso, rotas isoladas e composition root fino. (AP-01) + +[CRITICAL] Segredos hardcoded no código-fonte +File: src/utils.js:1-7 +Description: O objeto config traz credenciais e chaves em texto literal: dbPass "senha_super_secreta_prod_123", paymentGatewayKey "pk_live_1234567890abcdef", smtpUser e dbUser. São segredos de produção versionados no repositório. +Impact: Qualquer clone/commit expõe as credenciais; comprometimento imediato do gateway de pagamento e do banco em caso de vazamento do código. +Recommendation: Mover para variáveis de ambiente (process.env) carregadas em um módulo de config; remover os valores do fonte e rotacionar as chaves expostas. (AP-02) + +[CRITICAL] Senha em texto puro e hashing caseiro inseguro +File: src/utils.js:17-23 +Description: badCrypto faz um "hash" caseiro (base64 repetido, truncado em 10 chars) — não é criptografia real e é trivialmente reversível/colidível. Além disso, o seed insere senha em texto puro ('123') em src/AppManager.js:18, e o checkout usa badCrypto para novos usuários (src/AppManager.js:68). +Impact: Vazamento direto de credenciais dos usuários em caso de breach; senhas efetivamente sem proteção. +Recommendation: Usar hashing forte com salt (bcrypt ou argon2); nunca armazenar/seedar senha em texto puro. (AP-04) + +[CRITICAL] Endpoints administrativos e destrutivos sem autenticação +File: src/AppManager.js:80,131 +Description: GET /api/admin/financial-report expõe receita e lista de alunos/pagamentos de todos os cursos sem qualquer auth. DELETE /api/users/:id apaga usuário sem autenticação nem autorização. +Impact: Qualquer um lê todo o faturamento e dados de alunos, e pode deletar usuários arbitrariamente — vazamento de dados sensíveis e destruição de dados. +Recommendation: Proteger com autenticação/autorização (role admin); mover a rota destrutiva para trás de middleware de auth. (AP-13) + +[HIGH] Regra de negócio presa no handler de rota (sem controllers/models) +File: src/AppManager.js:28-137 +Description: Os handlers de checkout e relatório abrem cursores, executam SQL, aplicam regras de pagamento/matrícula e formatam a resposta — tudo inline no arquivo de rotas. Não há model nem controller. +Impact: Viola MVC e SRP; a regra não pode ser reusada nem testada isoladamente; handlers gigantes e frágeis. +Recommendation: Extrair regra para services/models; controller apenas orquestra; rota apenas mapeia caminho+método. (AP-06) + +[HIGH] Estado global mutável compartilhado +File: src/utils.js:9-10 +Description: globalCache e totalRevenue são variáveis de módulo mutáveis, compartilhadas por toda a app (globalCache alimentado em logAndCache, src/utils.js:12-15). A conexão de banco também vive presa na God Class (src/AppManager.js:7), compartilhada implicitamente. +Impact: Acoplamento forte, condições de corrida entre requisições, e código praticamente impossível de testar/paralelizar. +Recommendation: Injeção de dependência; escopo de cache/estado por request ou store dedicado; injetar a conexão no composition root. (AP-05) + +[HIGH] Callback hell / I/O sequencial aninhado sem camadas +File: src/AppManager.js:37-77,83-128 +Description: O checkout aninha db.get → db.get → db.run → db.run → db.run em callbacks; o relatório aninha db.all → forEach → db.all → forEach → db.get → db.get. Usa `self = this` (linha 26) para driblar escopo e espalha o tratamento de erro por dezenas de callbacks. +Impact: Ilegível, difícil tratar erro de forma consistente, propenso a bugs de fluxo e sem separação de camadas. +Recommendation: Promisify o driver sqlite3 (ou util.promisify) e usar async/await; extrair repositório/serviço; transações explícitas. (AP-07) + +[MEDIUM] Query N+1 no relatório financeiro +File: src/AppManager.js:89-125 +Description: Para cada curso busca enrollments, e para cada enrollment dispara uma query de usuário e outra de pagamento — uma consulta por item em vez de um JOIN/agregação. +Impact: Número de queries cresce linearmente com alunos/matrículas; degradação de performance e carga desnecessária no banco. +Recommendation: Substituir por um JOIN entre courses/enrollments/users/payments (ou IN(...)) agregando no banco. (AP-08) + +[MEDIUM] Falta de transação e dados órfãos na deleção +File: src/AppManager.js:50-62,131-137 +Description: O checkout faz INSERTs relacionados (enrollments, payments, audit_logs) sem transação — falha no meio deixa estado inconsistente. DELETE /api/users/:id apaga o usuário mas deixa enrollments e payments órfãos (a própria resposta admite: "ficaram sujos no banco"). +Impact: Integridade referencial quebrada e estado inconsistente do banco. +Recommendation: Envolver operações relacionadas em transação; deletar em cascata (ou bloquear delete com dependências); ON DELETE CASCADE / limpeza explícita. (AP-12) + +[MEDIUM] Dados sensíveis em log (número de cartão + chave do gateway) +File: src/AppManager.js:45 +Description: console.log imprime o número completo do cartão (cc) e a paymentGatewayKey a cada checkout. +Impact: Vazamento de PAN de cartão e da chave de pagamento nos logs — violação de compliance (PCI) e superfície de ataque. +Recommendation: Remover o log; se necessário, logar apenas dados mascarados via logging estruturado, sem segredos nem PAN. (AP-16) + +[MEDIUM] Validação de entrada ausente/insuficiente no checkout +File: src/AppManager.js:29-35 +Description: O checkout lê usr/eml/pwd/c_id/card do body sem validar tipo ou formato; a checagem (linha 35) nem exige pwd e não valida formato de e-mail nem numérico de c_id/card. Sem schema de validação. +Impact: Erros 500, dados inconsistentes (ex.: e-mail inválido persistido) e superfície para abuso. +Recommendation: Validar por schema na entrada (ex.: joi/zod) com retornos 400 claros; normalizar tipos. (AP-09) + +[LOW] Nomenclatura críptica de variáveis +File: src/AppManager.js:29-33 +Description: Variáveis de negócio nomeadas u, e, p, cid, cc; campos do contrato usr/eml/pwd/c_id abreviados. +Impact: Leitura mais lenta e maior chance de bug por mal-entendido. +Recommendation: Renomear para nomes intencionais (name, email, password, courseId, card). (AP-15) + +[LOW] Regra de pagamento em valor mágico +File: src/AppManager.js:47 +Description: A aprovação do pagamento é decidida por cc.startsWith("4") ("PAID"/"DENIED") — string mágica com significado de negócio embutido no handler. +Impact: Intenção obscura e regra de negócio escondida num literal, difícil de manter/alterar. +Recommendation: Extrair para um gateway/serviço de pagamento nomeado com constantes de status. (AP-14) + +================================ +Total: 13 findings +================================ diff --git a/reports/audit-project-3.md b/reports/audit-project-3.md new file mode 100644 index 000000000..ae0c23844 --- /dev/null +++ b/reports/audit-project-3.md @@ -0,0 +1,105 @@ +================================ +ARCHITECTURE AUDIT REPORT +================================ +Project: task-manager-api +Stack: Python (CPython 3.13) + Flask 3.0.0 +Files: 15 analyzed | ~1158 lines of code + +Summary +CRITICAL: 3 | HIGH: 3 | MEDIUM: 6 | LOW: 2 + +MVC Adherence: 4/10 (Parcial) + Models: parcial — entidades e regra simples isoladas (models/*.py), mas quase toda a regra de negócio e acesso a dados vive nas rotas + Views/Routes: parcial — blueprints existem, porém os handlers concentram validação, queries, regra e serialização + Controllers: ausente — não há camada de controller/service em uso; pastas controllers/ e services/ não têm código ativo (só __pycache__ órfão e um NotificationService morto) + Separation: violada — rota = view + controller + model; utils/helpers e schemas subutilizados; segredos no entrypoint + +Findings + +[CRITICAL] Hash de senha fraco (MD5 sem salt) +File: models/user.py:27-32 +Description: set_password/check_password usam hashlib.md5 sem salt para armazenar e comparar senhas. +Impact: MD5 é quebrável por rainbow tables e brute force; um vazamento do banco expõe as senhas dos usuários quase imediatamente. +Recommendation: usar werkzeug.security (generate_password_hash/check_password_hash) ou bcrypt/argon2 com salt. (AP-04) + +[CRITICAL] SECRET_KEY hardcoded no código +File: app.py:13 +Description: app.config['SECRET_KEY'] = 'super-secret-key-123' fixo no fonte, versionado em git. +Impact: qualquer pessoa com acesso ao repositório conhece a chave usada para assinar sessões/tokens; comprometimento total de integridade de sessão. +Recommendation: carregar de os.environ via módulo config/settings.py, com .env fora do versionamento. (AP-02) + +[CRITICAL] Credenciais de SMTP hardcoded +File: services/notification_service.py:7-10 +Description: host, usuário e senha de e-mail ('taskmanager@gmail.com' / 'senha123') fixos na classe. +Impact: exposição direta de credenciais de e-mail em qualquer clone/commit. +Recommendation: mover para variáveis de ambiente / config; nunca versionar segredos. (AP-02) + +[HIGH] Regra de negócio e acesso a dados dentro das rotas (sem controller/service) +File: routes/task_routes.py:11-299, routes/user_routes.py:10-211, routes/report_routes.py:12-223 +Description: os handlers fazem tudo — validação de input, queries SQLAlchemy, regra de domínio (overdue, completion_rate, agregações de relatório) e montagem da resposta — inline. Handlers de 40-100+ linhas. +Impact: viola MVC e SRP; a regra não é reutilizável nem testável em isolamento; qualquer mudança de contrato mexe em rota, dados e regra ao mesmo tempo. +Recommendation: extrair regra/queries para models/services; introduzir controllers magros que orquestram; rotas apenas mapeiam e delegam. (AP-06) + +[HIGH] Hash de senha exposto nas respostas da API +File: models/user.py:16-25 +Description: User.to_dict() inclui o campo 'password' (o hash), e é retornado em create_user (user_routes.py:85-86) e em login (user_routes.py:209). +Impact: vaza material de credencial para qualquer cliente; facilita ataques offline caso o hash seja capturado. +Recommendation: remover 'password' da serialização; usar um schema de saída (marshmallow, já em requirements) que nunca exponha o hash. (relacionado a AP-04) + +[HIGH] Autenticação de fachada e ausência de autorização +File: routes/user_routes.py:185-211, routes/task_routes.py:225-238, routes/report_routes.py:211-223 +Description: /login retorna um token fixo 'fake-jwt-token-' sem assinatura; nenhum endpoint valida token ou papel. DELETE de tasks, users e categories está aberto a qualquer requisição. +Impact: controle de acesso quebrado — qualquer um cria, altera ou apaga dados; o "token" não protege nada. +Recommendation: emitir JWT real assinado (SECRET_KEY via env) e aplicar middleware de auth/autorização por papel nas rotas sensíveis/destrutivas. (AP-13) + +[MEDIUM] APIs deprecated: datetime.utcnow() e Model.query.get() +File: models/user.py:14; models/task.py:15-16,52; routes/task_routes.py:31,285; routes/report_routes.py:35,42,45; routes/user_routes.py:29 (e demais ocorrências) +Description: datetime.utcnow() está deprecated (Python 3.12+; usar datetime.now(timezone.utc)); Model.query.get() é API legada no SQLAlchemy 2.x (usar db.session.get(Model, id)). +Impact: quebra/avisos em upgrades futuros; datas naïve sem timezone geram bugs de comparação de overdue. +Recommendation: migrar para datetime.now(timezone.utc) e db.session.get(); centralizar a lógica de data em utils. (AP-11) + +[MEDIUM] Debug ligado, side-effects no import e logging por print +File: app.py:30-34; routes/task_routes.py:149,153,219,234; routes/user_routes.py:83,89,147 +Description: app.run(debug=True) expõe o debugger interativo; db.create_all() roda no import do módulo; logs feitos com print espalhado pelos handlers. +Impact: debug=True em produção é RCE via console Werkzeug; efeitos colaterais no import dificultam teste; prints não são logging estruturado. +Recommendation: controlar debug por ambiente (config), mover create_all/bootstrap para um ponto explícito, usar logging. (AP-16) + +[MEDIUM] Lógica de "overdue" e serialização duplicadas +File: routes/task_routes.py:30-39,71-80,283-287; routes/user_routes.py:171-180; routes/report_routes.py:34-37,132-135; models/task.py:50-60 +Description: o mesmo bloco aninhado que decide se a task está atrasada é reescrito em 6 lugares (havendo Task.is_overdue() no model); a montagem manual do dict de task repete Task.to_dict(). +Impact: manutenção multiplicada e risco de divergência silenciosa entre cópias. +Recommendation: usar/estender Task.is_overdue() e Task.to_dict() (ou um serializer/schema) como fonte única. (AP-10) + +[MEDIUM] Tratamento de erro engolido e casts sem validação +File: routes/task_routes.py:62-63,261,264; routes/task_routes.py:236-238; routes/user_routes.py:130-132; routes/report_routes.py:186-188,207-209,221-223 +Description: vários `except:` nus retornam 500 genérico e escondem a causa real; em /tasks/search, int(priority) e int(user_id) não são protegidos e estouram 500 com input inválido. +Impact: erros mascarados dificultam diagnóstico; entradas malformadas derrubam o handler em vez de retornar 400. +Recommendation: validar entrada por schema (marshmallow já está disponível), capturar exceções específicas e centralizar o tratamento num middleware de erro. (AP-09) + +[MEDIUM] Delete de categoria deixa tasks órfãs +File: routes/report_routes.py:211-223 +Description: delete_category remove a categoria sem tratar as tasks que a referenciam; category_id fica apontando para um id inexistente. +Impact: integridade referencial quebrada — tasks com categoria fantasma; get_task passa a expor category_name inconsistente. +Recommendation: definir o comportamento (setar category_id=NULL ou bloquear delete com tasks vinculadas) via cascade/ON DELETE explícito ou regra no service. (AP-12) + +[MEDIUM] Abstrações mortas e dependências não usadas +File: services/notification_service.py:1-48; utils/helpers.py:57-108; requirements.txt:4 (marshmallow) +Description: NotificationService nunca é instanciado/chamado; helpers.process_task_data reimplementa a validação das rotas mas não é usado; marshmallow está declarado e não é utilizado (validação é manual); a pasta schemas/ não tem código ativo. +Impact: código morto engana o leitor sobre o que está em uso e mascara a ausência real de camada de serviço/validação. +Recommendation: ou integrar essas abstrações no fluxo (schemas para validação, service para orquestração) ou removê-las. (AP-10 / dívida técnica) + +[LOW] Strings/números mágicos espalhados com constantes ignoradas +File: routes/task_routes.py:110,113,177,182; routes/user_routes.py:71,120; utils/helpers.py:110-116 +Description: listas de status válidos, faixas de prioridade (1..5) e papéis são repetidos literalmente nos handlers, enquanto VALID_STATUSES/VALID_ROLES/limites já existem em utils/helpers.py e não são importados. +Impact: intenção obscura e risco de divergência ao alterar um valor em só um lugar. +Recommendation: centralizar em config/constants e importar; remover as duplicações literais. (AP-14) + +[LOW] Nomenclatura pobre e booleanos verbosos +File: models/user.py:34-38; models/task.py:38-48; routes/task_routes.py:16-59; routes/report_routes.py:55-68 +Description: variáveis de uma letra (u, t, c, p, td, cat) e padrões `if cond: return True else: return False` em is_admin/validate_status/validate_priority. +Impact: legibilidade menor e mais chance de erro de leitura/manutenção. +Recommendation: nomes intencionais e retorno booleano direto (return self.role == 'admin'). (AP-15) + +================================ +Total: 14 findings +================================ diff --git a/reports/validation-project-1.md b/reports/validation-project-1.md new file mode 100644 index 000000000..a323fddc0 --- /dev/null +++ b/reports/validation-project-1.md @@ -0,0 +1,57 @@ +================================ +VALIDATION EVIDENCE — code-smells-project +================================ +Date: 2026-07-13 +Stack: Python + Flask 3.1.1 +Port: 5055 + +## Boot +$ .venv/bin/python -c "import flask, flask_cors, werkzeug; print('deps ok', flask.__version__)" # deps já instaladas no .venv +$ ADMIN_TOKEN=segredo-teste PORT=5055 .venv/bin/python app.py +[log] ================================================== +[log] SERVIDOR INICIADO +[log] Rodando em http://localhost:5055 +[log] * Serving Flask app 'app' +[log] * Debug mode: off +[log] * Running on http://127.0.0.1:5055 +[log] Press CTRL+C to quit +Boot: ✓ OK (sem stack trace; debug off) + +## Endpoints +| Método | Path | Payload | Status | Esperado | OK | +|--------|-----------------------------------|------------------------------------------|--------|----------|----| +| GET | / | — | 200 | 200 | ✓ | +| GET | /health | — | 200 | 200 | ✓ | +| GET | /produtos | — | 200 | 200 | ✓ | +| GET | /produtos/1 | — | 200 | 200 | ✓ | +| GET | /produtos/busca?q=SSD&preco_min=100 | — | 200 | 200 | ✓ | +| POST | /produtos | {nome,preco,estoque,categoria} | 201 | 201 | ✓ | +| PUT | /produtos/11 | {nome,preco,estoque,categoria} | 200 | 200 | ✓ | +| DELETE | /produtos/11 | — | 200 | 200 | ✓ | +| GET | /usuarios | — | 200 | 200 | ✓ | +| GET | /usuarios/1 | — | 200 | 200 | ✓ | +| POST | /usuarios | {nome,email,senha} | 201 | 201 | ✓ | +| POST | /login | {email:admin@loja.com,senha:admin123} | 200 | 200 | ✓ | +| POST | /login (injeção SQL clássica) | {email:"... OR '1'='1", senha:"..."} | 401 | 401 | ✓ | +| POST | /pedidos | {usuario_id,itens[]} | 201 | 201 | ✓ | +| GET | /pedidos | — | 200 | 200 | ✓ | +| GET | /pedidos/usuario/2 | — | 200 | 200 | ✓ | +| PUT | /pedidos/1/status | {status:aprovado} | 200 | 200 | ✓ | +| GET | /relatorios/vendas | — | 200 | 200 | ✓ | +| GET | /produtos/0 OR 1=1 (probe SQLi) | — | 404 | 404 | ✓ | +| POST | /admin/query (removido) | {sql:"SELECT * FROM usuarios"} | 404 | 404 | ✓ | +| POST | /admin/reset-db (sem token) | — | 403 | 403 | ✓ | +| POST | /admin/reset-db (com token) | header X-Admin-Token | 200 | 200 | ✓ | + +## Amostras de resposta (trechos reais) +- GET /health → 200 : {"ambiente":"producao","counts":{"pedidos":0,"produtos":10,"usuarios":3},"database":"connected","db_path":"loja.db","status":"ok","versao":"1.0.0"} (sem secret_key/debug) +- GET /usuarios → 200 : [{"criado_em":"...","email":"admin@loja.com","id":1,"nome":"Admin","tipo":"admin"}, ...] (campo "senha" não é mais exposto) +- POST /login → 200 : {"dados":{"email":"admin@loja.com","id":1,"nome":"Admin","tipo":"admin"},"mensagem":"Login OK","sucesso":true} (verificação por hash) +- POST /login (SQLi) → 401 : {"erro":"Email ou senha inválidos","sucesso":false} (bypass antigo neutralizado) +- POST /pedidos → 201 : {"dados":{"pedido_id":1,"total":479.7},"mensagem":"Pedido criado com sucesso","sucesso":true} +- GET /pedidos → 200 : itens com produto_nome resolvido via JOIN (sem N+1) +- POST /admin/reset-db sem token → 403 : {"erro":"Não autorizado"} + +================================ +RESULT: PASS (22/22 endpoints) +================================ diff --git a/reports/validation-project-2.md b/reports/validation-project-2.md new file mode 100644 index 000000000..02e2c72bc --- /dev/null +++ b/reports/validation-project-2.md @@ -0,0 +1,50 @@ +================================ +VALIDATION EVIDENCE — ecommerce-api-legacy +================================ +Date: 2026-07-13 +Stack: JavaScript (Node.js 20) + Express 4 +Port: 3000 + +## Boot +$ npm install +$ ADMIN_TOKEN=dev-admin-token node src/app.js +[log] LMS API rodando na porta 3000... +Boot: ✓ OK (sem stack trace; schema + seed carregados no boot) + +## Endpoints +| Método | Path | Payload | Status | Esperado | OK | +|--------|-------------------------------|----------------------------------------------------------------|--------|----------|----| +| POST | /api/checkout | {usr,eml,pwd,c_id:2,card:"4111..."} (aprovado) | 200 | 200 | ✓ | +| POST | /api/checkout | {usr,eml,pwd,c_id:1,card:"5111..."} (recusado) | 400 | 400 | ✓ | +| POST | /api/checkout | {} (campos obrigatórios faltando) | 400 | 400 | ✓ | +| GET | /api/admin/financial-report | — (sem x-admin-token) | 401 | 401 | ✓ | +| GET | /api/admin/financial-report | header x-admin-token válido | 200 | 200 | ✓ | +| DELETE | /api/users/1 | — (sem x-admin-token) | 401 | 401 | ✓ | +| DELETE | /api/users/1 | header x-admin-token válido | 200 | 200 | ✓ | +| GET | /api/admin/financial-report | header válido (pós-delete, checa integridade) | 200 | 200 | ✓ | + +## Amostras de resposta (trechos reais) +- POST /api/checkout (aprovado) → 200 : {"msg":"Sucesso","enrollment_id":2} +- POST /api/checkout (recusado) → 400 : Pagamento recusado +- POST /api/checkout (vazio) → 400 : Bad Request +- GET /api/admin/financial-report (sem token) → 401 : Unauthorized +- GET /api/admin/financial-report (com token) → 200 : + [{"course":"Clean Architecture","revenue":997,"students":[{"student":"Leonan","paid":997}]}, + {"course":"Docker","revenue":497,"students":[{"student":"Guilherme","paid":497}]}] +- DELETE /api/users/1 (sem token) → 401 : Unauthorized +- DELETE /api/users/1 (com token) → 200 : Usuário e dados relacionados (matrículas e pagamentos) removidos. +- GET /api/admin/financial-report (pós-delete) → 200 : + [{"course":"Clean Architecture","revenue":0,"students":[]}, + {"course":"Docker","revenue":497,"students":[{"student":"Guilherme","paid":497}]}] + ↑ Prova da correção AP-12: após remover o usuário 1, a matrícula/pagamento dele saíram junto + (revenue 0, students []). Nenhum dado órfão — o bug original ("ficaram sujos no banco") foi corrigido. + +## Notas +- Formato de resposta do relatório preservado: [{course, revenue, students:[{student, paid}]}]. +- Rotas admin agora exigem header x-admin-token (correção AP-13); o 401 sem token é o + comportamento esperado e conta como OK ao comparar com a coluna "Esperado". +- Node 12 (default do shell) tem npm quebrado; validação feita com Node 20 via nvm. + +================================ +RESULT: PASS (8/8 endpoints) +================================ diff --git a/reports/validation-project-3.md b/reports/validation-project-3.md new file mode 100644 index 000000000..4c1ef1447 --- /dev/null +++ b/reports/validation-project-3.md @@ -0,0 +1,59 @@ +================================ +VALIDATION EVIDENCE — task-manager-api +================================ +Date: 2026-07-13 23:22 (local) +Stack: Python (CPython 3.13) + Flask 3.0.0 +Port: 5001 (5000 estava ocupada pelo AirPlay Receiver do macOS; porta configurável via env PORT) + +## Boot +$ pip install -r requirements.txt # deps já resolvidas no .venv +$ python seed.py # re-seed: 3 usuários, 4 categorias, 10 tasks (senhas com hash werkzeug) +$ PORT=5001 python app.py +[log] * Serving Flask app 'app' +[log] * Debug mode: off +[log] * Running on http://127.0.0.1:5001 +[log] 127.0.0.1 - - [13/Jul/2026 20:20:33] "GET /health HTTP/1.1" 200 - +Boot: ✓ OK (sem stack trace; debug desligado por padrão via config) + +## Endpoints +| Método | Path | Payload | Status | Esperado | OK | +|--------|-----------------------------|------------------------------------------|--------|----------|----| +| GET | /health | — | 200 | 200 | ✓ | +| GET | / | — | 200 | 200 | ✓ | +| GET | /tasks | — | 200 | 200 | ✓ | +| GET | /tasks/1 | — | 200 | 200 | ✓ | +| GET | /tasks/search?q=bug | — | 200 | 200 | ✓ | +| GET | /tasks/stats | — | 200 | 200 | ✓ | +| POST | /tasks | {title,priority,user_id,category_id,...} | 201 | 201 | ✓ | +| PUT | /tasks/12 | {status,title} | 200 | 200 | ✓ | +| GET | /users | — | 200 | 200 | ✓ | +| GET | /users/1 | — | 200 | 200 | ✓ | +| POST | /users | {name,email,password,role} | 201 | 201 | ✓ | +| PUT | /users/5 | {name,active} | 200 | 200 | ✓ | +| GET | /users/1/tasks | — | 200 | 200 | ✓ | +| POST | /login | {email,password} | 200 | 200 | ✓ | +| GET | /reports/summary | — | 200 | 200 | ✓ | +| GET | /reports/user/1 | — | 200 | 200 | ✓ | +| GET | /categories | — | 200 | 200 | ✓ | +| POST | /categories | {name,color} | 201 | 201 | ✓ | +| PUT | /categories/6 | {name} | 200 | 200 | ✓ | +| DELETE | /categories/6 (sem token) | — | 401 | 401 | ✓ | +| DELETE | /categories/6 (com token) | Authorization: Bearer | 200 | 200 | ✓ | +| DELETE | /tasks/12 (com token) | Authorization: Bearer | 200 | 200 | ✓ | +| DELETE | /users/5 (com token) | Authorization: Bearer | 200 | 200 | ✓ | + +## Amostras de resposta (trechos reais) +- GET /tasks/stats → 200 : {"cancelled":1,"completion_rate":10.0,"done":1,"in_progress":2,"overdue":2,"pending":6,"total":10} +- POST /tasks → 201 : {"id":11,"title":"Nova task de teste","status":"pending","priority":2,"user_id":1,"category_id":1,"due_date":"2026-12-31 00:00:00","tags":["a","b"], ...} +- POST /login → 200 : {"message":"Login realizado com sucesso","token":"eyJ1c2VyX2lkIjoxfQ.alVzGA.E_yz69pYvecVQNLjDa3fvNeKiJQ","user":{...SEM campo password...}} +- GET /users → 200 : [{"id":1,"name":"João Silva","email":"joao@email.com","role":"admin","active":true,"task_count":5}, ...] (sem hash de senha) +- DELETE /categories/6 (sem token) → 401 : {"error":"Token ausente ou inválido"} + +## Observações de contrato (mudanças intencionais de segurança) +- Campo `password` (hash) REMOVIDO de todas as respostas (era vazado em /users/, /login, POST/PUT users). +- `/login` agora devolve token assinado (itsdangerous) no lugar do token de fachada 'fake-jwt-token-'; o campo `token` (string) permanece. +- DELETE de /tasks, /users e /categories agora exige `Authorization: Bearer ` (401 sem token). Demais métodos e paths inalterados. + +================================ +RESULT: PASS (23/23 verificações — 22 endpoints + gate de auth) +================================ diff --git a/task-manager-api/.claude/skills/refactor-arch b/task-manager-api/.claude/skills/refactor-arch new file mode 120000 index 000000000..2378dd5de --- /dev/null +++ b/task-manager-api/.claude/skills/refactor-arch @@ -0,0 +1 @@ +../../../.claude/skills/refactor-arch \ No newline at end of file diff --git a/task-manager-api/.env.example b/task-manager-api/.env.example new file mode 100644 index 000000000..9f36b89b2 --- /dev/null +++ b/task-manager-api/.env.example @@ -0,0 +1,12 @@ +# Copie para .env e ajuste. NÃO versione o .env real. +SECRET_KEY=troque-por-uma-chave-forte-aleatoria +DATABASE_URI=sqlite:///tasks.db +DEBUG=false +HOST=0.0.0.0 +PORT=5000 + +# Notificações por e-mail (opcional) +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 +EMAIL_USER= +EMAIL_PASSWORD= diff --git a/task-manager-api/app.py b/task-manager-api/app.py index e89e0af99..85b95137a 100644 --- a/task-manager-api/app.py +++ b/task-manager-api/app.py @@ -1,34 +1,42 @@ +"""Composition root — monta a aplicação, registra camadas e sobe o servidor.""" from flask import Flask from flask_cors import CORS + +from config.settings import Config from database import db -from routes.task_routes import task_bp -from routes.user_routes import user_bp -from routes.report_routes import report_bp -import os, sys, json, datetime +from middlewares.error_handler import register_error_handlers +from views.task_routes import task_bp +from views.user_routes import user_bp +from views.report_routes import report_bp +from views.category_routes import category_bp +from views.health_routes import health_bp + + +def create_app(config_object=Config): + app = Flask(__name__) + app.config.from_object(config_object) + + CORS(app) + db.init_app(app) -app = Flask(__name__) + # Garante que os models estão importados/registrados no metadata. + import models # noqa: F401 -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tasks.db' -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -app.config['SECRET_KEY'] = 'super-secret-key-123' + app.register_blueprint(task_bp) + app.register_blueprint(user_bp) + app.register_blueprint(report_bp) + app.register_blueprint(category_bp) + app.register_blueprint(health_bp) -CORS(app) -db.init_app(app) + register_error_handlers(app) -app.register_blueprint(task_bp) -app.register_blueprint(user_bp) -app.register_blueprint(report_bp) + with app.app_context(): + db.create_all() -@app.route('/health') -def health(): - return {'status': 'ok', 'timestamp': str(datetime.datetime.now())} + return app -@app.route('/') -def index(): - return {'message': 'Task Manager API', 'version': '1.0'} -with app.app_context(): - db.create_all() +app = create_app() if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0', port=5000) + app.run(host=Config.HOST, port=Config.PORT, debug=Config.DEBUG) diff --git a/task-manager-api/config/__init__.py b/task-manager-api/config/__init__.py new file mode 100644 index 000000000..691b0a88f --- /dev/null +++ b/task-manager-api/config/__init__.py @@ -0,0 +1 @@ +from config.settings import Config diff --git a/task-manager-api/config/settings.py b/task-manager-api/config/settings.py new file mode 100644 index 000000000..754faf8e2 --- /dev/null +++ b/task-manager-api/config/settings.py @@ -0,0 +1,28 @@ +"""Configuração central da aplicação — segredos via ambiente, nunca hardcoded.""" +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parents[1] + + +class Config: + # Segredo de assinatura de sessão/token — obrigatório vir do ambiente em produção. + SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-only-change-me') + + # Path absoluto e determinístico do SQLite — evita seed/app gravarem em arquivos + # diferentes conforme o cwd/instance_path do flask-sqlalchemy. + SQLALCHEMY_DATABASE_URI = os.environ.get( + 'DATABASE_URI', f'sqlite:///{BASE_DIR / "tasks.db"}') + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # debug controlado por ambiente (nunca ligado por padrão). + DEBUG = os.environ.get('DEBUG', 'false').lower() == 'true' + + HOST = os.environ.get('HOST', '0.0.0.0') + PORT = int(os.environ.get('PORT', '5000')) + + # Credenciais de e-mail para notificações — via ambiente. + EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.gmail.com') + EMAIL_PORT = int(os.environ.get('EMAIL_PORT', '587')) + EMAIL_USER = os.environ.get('EMAIL_USER', '') + EMAIL_PASSWORD = os.environ.get('EMAIL_PASSWORD', '') diff --git a/task-manager-api/controllers/__init__.py b/task-manager-api/controllers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/task-manager-api/controllers/auth_controller.py b/task-manager-api/controllers/auth_controller.py new file mode 100644 index 000000000..1289cdd01 --- /dev/null +++ b/task-manager-api/controllers/auth_controller.py @@ -0,0 +1,28 @@ +from flask import request, jsonify + +from models.user import User +from middlewares.error_handler import ApiError +from middlewares.auth import generate_token + + +def login(): + payload = request.get_json(silent=True) + if not payload: + raise ApiError('Dados inválidos', 400) + + email = payload.get('email') + password = payload.get('password') + if not email or not password: + raise ApiError('Email e senha são obrigatórios', 400) + + user = User.get_by_email(email) + if not user or not user.check_password(password): + raise ApiError('Credenciais inválidas', 401) + if not user.active: + raise ApiError('Usuário inativo', 403) + + return jsonify({ + 'message': 'Login realizado com sucesso', + 'user': user.to_dict(), + 'token': generate_token(user), + }), 200 diff --git a/task-manager-api/controllers/category_controller.py b/task-manager-api/controllers/category_controller.py new file mode 100644 index 000000000..9928248a3 --- /dev/null +++ b/task-manager-api/controllers/category_controller.py @@ -0,0 +1,70 @@ +from flask import request, jsonify +from marshmallow import ValidationError + +from models.category import Category +from models.task import Task +from schemas import first_error +from schemas.category_schema import CategoryCreateSchema, CategoryUpdateSchema +from services import category_service +from middlewares.error_handler import ApiError + +_create_schema = CategoryCreateSchema() +_update_schema = CategoryUpdateSchema() + + +def list_categories(): + result = [] + for category in Category.all(): + data = category.to_dict() + data['task_count'] = Task.count_by_category(category.id) + result.append(data) + return jsonify(result), 200 + + +def create_category(): + payload = request.get_json(silent=True) + if not payload: + raise ApiError('Dados inválidos', 400) + try: + data = _create_schema.load(payload) + except ValidationError as err: + raise ApiError(first_error(err), 400) + + category = Category() + category.name = data['name'] + category.description = data['description'] + category.color = data['color'] + category.save() + return jsonify(category.to_dict()), 201 + + +def update_category(cat_id): + category = Category.get(cat_id) + if not category: + raise ApiError('Categoria não encontrada', 404) + + payload = request.get_json(silent=True) + if not payload: + raise ApiError('Dados inválidos', 400) + try: + data = _update_schema.load(payload) + except ValidationError as err: + raise ApiError(first_error(err), 400) + + if 'name' in data: + category.name = data['name'] + if 'description' in data: + category.description = data['description'] + if 'color' in data: + category.color = data['color'] + + category.save() + return jsonify(category.to_dict()), 200 + + +def delete_category(cat_id): + category = Category.get(cat_id) + if not category: + raise ApiError('Categoria não encontrada', 404) + category_service.delete_category_safely(category) + return jsonify({'message': 'Categoria deletada'}), 200 diff --git a/task-manager-api/controllers/report_controller.py b/task-manager-api/controllers/report_controller.py new file mode 100644 index 000000000..3517db0ea --- /dev/null +++ b/task-manager-api/controllers/report_controller.py @@ -0,0 +1,16 @@ +from flask import jsonify + +from models.user import User +from services import report_service +from middlewares.error_handler import ApiError + + +def summary_report(): + return jsonify(report_service.build_summary()), 200 + + +def user_report(user_id): + user = User.get(user_id) + if not user: + raise ApiError('Usuário não encontrado', 404) + return jsonify(report_service.build_user_report(user)), 200 diff --git a/task-manager-api/controllers/task_controller.py b/task-manager-api/controllers/task_controller.py new file mode 100644 index 000000000..1e753ea2e --- /dev/null +++ b/task-manager-api/controllers/task_controller.py @@ -0,0 +1,141 @@ +from datetime import datetime + +from flask import request, jsonify +from marshmallow import ValidationError + +from models.task import Task +from models.user import User +from models.category import Category +from schemas import first_error +from schemas.task_schema import TaskCreateSchema, TaskUpdateSchema +from services import report_service +from middlewares.error_handler import ApiError +from utils.constants import DATE_FORMAT + +_create_schema = TaskCreateSchema() +_update_schema = TaskUpdateSchema() + + +def _parse_due_date(value, message): + try: + return datetime.strptime(value, DATE_FORMAT) + except (ValueError, TypeError): + raise ApiError(message, 400) + + +def _normalize_tags(tags): + if isinstance(tags, list): + return ','.join(tags) + return tags + + +def _require_related(model, entity_id, not_found_message): + if entity_id and not model.get(entity_id): + raise ApiError(not_found_message, 404) + + +def list_tasks(): + return jsonify([t.to_dict_full() for t in Task.all()]), 200 + + +def get_task(task_id): + task = Task.get(task_id) + if not task: + raise ApiError('Task não encontrada', 404) + return jsonify(task.to_dict_with_overdue()), 200 + + +def create_task(): + payload = request.get_json(silent=True) + if not payload: + raise ApiError('Dados inválidos', 400) + try: + data = _create_schema.load(payload) + except ValidationError as err: + raise ApiError(first_error(err), 400) + + _require_related(User, data['user_id'], 'Usuário não encontrado') + _require_related(Category, data['category_id'], 'Categoria não encontrada') + + task = Task() + task.title = data['title'] + task.description = data['description'] + task.status = data['status'] + task.priority = data['priority'] + task.user_id = data['user_id'] + task.category_id = data['category_id'] + if data['due_date']: + task.due_date = _parse_due_date(data['due_date'], 'Formato de data inválido. Use YYYY-MM-DD') + if data['tags']: + task.tags = _normalize_tags(data['tags']) + + task.save() + return jsonify(task.to_dict()), 201 + + +def update_task(task_id): + task = Task.get(task_id) + if not task: + raise ApiError('Task não encontrada', 404) + + payload = request.get_json(silent=True) + if not payload: + raise ApiError('Dados inválidos', 400) + try: + data = _update_schema.load(payload) + except ValidationError as err: + raise ApiError(first_error(err), 400) + + if 'title' in data: + task.title = data['title'] + if 'description' in data: + task.description = data['description'] + if 'status' in data: + task.status = data['status'] + if 'priority' in data: + task.priority = data['priority'] + if 'user_id' in data: + _require_related(User, data['user_id'], 'Usuário não encontrado') + task.user_id = data['user_id'] + if 'category_id' in data: + _require_related(Category, data['category_id'], 'Categoria não encontrada') + task.category_id = data['category_id'] + if 'due_date' in data: + task.due_date = _parse_due_date(data['due_date'], 'Formato de data inválido') if data['due_date'] else None + if 'tags' in data: + task.tags = _normalize_tags(data['tags']) + + task.touch() + task.save() + return jsonify(task.to_dict()), 200 + + +def delete_task(task_id): + task = Task.get(task_id) + if not task: + raise ApiError('Task não encontrada', 404) + task.delete() + return jsonify({'message': 'Task deletada com sucesso'}), 200 + + +def _optional_int(value, message): + if value in (None, ''): + return None + try: + return int(value) + except (ValueError, TypeError): + raise ApiError(message, 400) + + +def search_tasks(): + query = request.args.get('q', '') + status = request.args.get('status', '') + priority = _optional_int(request.args.get('priority', ''), 'Prioridade inválida') + user_id = _optional_int(request.args.get('user_id', ''), 'user_id inválido') + + tasks = Task.search(query=query, status=status, priority=priority, user_id=user_id) + return jsonify([t.to_dict() for t in tasks]), 200 + + +def stats(): + return jsonify(report_service.task_stats()), 200 diff --git a/task-manager-api/controllers/user_controller.py b/task-manager-api/controllers/user_controller.py new file mode 100644 index 000000000..bd5cc50c8 --- /dev/null +++ b/task-manager-api/controllers/user_controller.py @@ -0,0 +1,92 @@ +from flask import request, jsonify +from marshmallow import ValidationError + +from models.user import User +from models.task import Task +from schemas import first_error +from schemas.user_schema import UserCreateSchema, UserUpdateSchema +from services import user_service +from middlewares.error_handler import ApiError + +_create_schema = UserCreateSchema() +_update_schema = UserUpdateSchema() + + +def list_users(): + return jsonify([u.to_summary_dict() for u in User.all()]), 200 + + +def get_user(user_id): + user = User.get(user_id) + if not user: + raise ApiError('Usuário não encontrado', 404) + data = user.to_dict() + data['tasks'] = [t.to_dict() for t in Task.for_user(user_id)] + return jsonify(data), 200 + + +def create_user(): + payload = request.get_json(silent=True) + if not payload: + raise ApiError('Dados inválidos', 400) + try: + data = _create_schema.load(payload) + except ValidationError as err: + raise ApiError(first_error(err), 400) + + if User.get_by_email(data['email']): + raise ApiError('Email já cadastrado', 409) + + user = User() + user.name = data['name'] + user.email = data['email'] + user.set_password(data['password']) + user.role = data['role'] + user.save() + return jsonify(user.to_dict()), 201 + + +def update_user(user_id): + user = User.get(user_id) + if not user: + raise ApiError('Usuário não encontrado', 404) + + payload = request.get_json(silent=True) + if not payload: + raise ApiError('Dados inválidos', 400) + try: + data = _update_schema.load(payload) + except ValidationError as err: + raise ApiError(first_error(err), 400) + + if 'name' in data: + user.name = data['name'] + if 'email' in data: + existing = User.get_by_email(data['email']) + if existing and existing.id != user_id: + raise ApiError('Email já cadastrado', 409) + user.email = data['email'] + if 'password' in data: + user.set_password(data['password']) + if 'role' in data: + user.role = data['role'] + if 'active' in data: + user.active = data['active'] + + user.save() + return jsonify(user.to_dict()), 200 + + +def delete_user(user_id): + user = User.get(user_id) + if not user: + raise ApiError('Usuário não encontrado', 404) + user_service.delete_user_cascade(user) + return jsonify({'message': 'Usuário deletado com sucesso'}), 200 + + +def get_user_tasks(user_id): + user = User.get(user_id) + if not user: + raise ApiError('Usuário não encontrado', 404) + return jsonify([t.to_summary_dict() for t in Task.for_user(user_id)]), 200 diff --git a/task-manager-api/middlewares/__init__.py b/task-manager-api/middlewares/__init__.py new file mode 100644 index 000000000..9ccdad112 --- /dev/null +++ b/task-manager-api/middlewares/__init__.py @@ -0,0 +1 @@ +from middlewares.error_handler import ApiError, register_error_handlers diff --git a/task-manager-api/middlewares/auth.py b/task-manager-api/middlewares/auth.py new file mode 100644 index 000000000..2d8fed001 --- /dev/null +++ b/task-manager-api/middlewares/auth.py @@ -0,0 +1,49 @@ +"""Autenticação por token assinado (substitui o token de fachada original). + +Usa itsdangerous (dependência já trazida pelo Flask) para emitir e verificar +tokens assinados com a SECRET_KEY — sem adicionar dependências novas.""" +from functools import wraps + +from flask import request, g, current_app +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired + +from middlewares.error_handler import ApiError +from models.user import User + +_MAX_AGE_SECONDS = 60 * 60 * 24 # 24h + + +def _serializer(): + return URLSafeTimedSerializer(current_app.config['SECRET_KEY'], salt='auth-token') + + +def generate_token(user): + return _serializer().dumps({'user_id': user.id}) + + +def _load_user_from_request(): + header = request.headers.get('Authorization', '') + if not header.startswith('Bearer '): + raise ApiError('Token ausente ou inválido', 401) + token = header.split(' ', 1)[1].strip() + try: + data = _serializer().loads(token, max_age=_MAX_AGE_SECONDS) + except SignatureExpired: + raise ApiError('Token expirado', 401) + except BadSignature: + raise ApiError('Token inválido', 401) + + user = User.get(data.get('user_id')) + if not user: + raise ApiError('Token inválido', 401) + if not user.active: + raise ApiError('Usuário inativo', 403) + return user + + +def require_auth(view): + @wraps(view) + def wrapper(*args, **kwargs): + g.current_user = _load_user_from_request() + return view(*args, **kwargs) + return wrapper diff --git a/task-manager-api/middlewares/error_handler.py b/task-manager-api/middlewares/error_handler.py new file mode 100644 index 000000000..aa48a84b5 --- /dev/null +++ b/task-manager-api/middlewares/error_handler.py @@ -0,0 +1,30 @@ +"""Tratamento de erro centralizado — substitui try/except repetido nas rotas.""" +from flask import jsonify + +from database import db + + +class ApiError(Exception): + """Erro de domínio com código HTTP e mensagem já no contrato ({'error': ...}).""" + + def __init__(self, message, status_code=400): + super().__init__(message) + self.message = message + self.status_code = status_code + + +def register_error_handlers(app): + @app.errorhandler(ApiError) + def handle_api_error(err): + return jsonify({'error': err.message}), err.status_code + + @app.errorhandler(404) + def handle_not_found(err): + return jsonify({'error': 'Recurso não encontrado'}), 404 + + @app.errorhandler(Exception) + def handle_unexpected(err): + # Qualquer falha não tratada volta o estado do banco e responde 500 genérico. + db.session.rollback() + app.logger.exception('Erro não tratado: %s', err) + return jsonify({'error': 'Erro interno'}), 500 diff --git a/task-manager-api/models/category.py b/task-manager-api/models/category.py index df543ef8d..1b566e07a 100644 --- a/task-manager-api/models/category.py +++ b/task-manager-api/models/category.py @@ -1,5 +1,7 @@ from database import db -from datetime import datetime +from utils.constants import DEFAULT_COLOR +from utils.datetime_utils import now_utc + class Category(db.Model): __tablename__ = 'categories' @@ -7,15 +9,35 @@ class Category(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) description = db.Column(db.String(300), nullable=True) - color = db.Column(db.String(7), default='#000000') - created_at = db.Column(db.DateTime, default=datetime.utcnow) + color = db.Column(db.String(7), default=DEFAULT_COLOR) + created_at = db.Column(db.DateTime, default=now_utc) def to_dict(self): - d = { + return { 'id': self.id, 'name': self.name, 'description': self.description, 'color': self.color, 'created_at': str(self.created_at), } - return d + + # ------- acesso a dados ------- + @classmethod + def get(cls, category_id): + return db.session.get(cls, category_id) + + @classmethod + def all(cls): + return cls.query.all() + + @classmethod + def count(cls): + return cls.query.count() + + def save(self): + db.session.add(self) + db.session.commit() + + def delete(self): + db.session.delete(self) + db.session.commit() diff --git a/task-manager-api/models/task.py b/task-manager-api/models/task.py index f8f9227bb..8865ac4a6 100644 --- a/task-manager-api/models/task.py +++ b/task-manager-api/models/task.py @@ -1,6 +1,7 @@ from database import db -from datetime import datetime -import json +from utils.constants import CLOSED_STATUSES +from utils.datetime_utils import now_utc + class Task(db.Model): __tablename__ = 'tasks' @@ -12,49 +13,125 @@ class Task(db.Model): priority = db.Column(db.Integer, default=3) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = db.Column(db.DateTime, default=now_utc) + updated_at = db.Column(db.DateTime, default=now_utc, onupdate=now_utc) due_date = db.Column(db.DateTime, nullable=True) tags = db.Column(db.String(500), nullable=True) user = db.relationship('User', backref='tasks') category = db.relationship('Category', backref='tasks') + # ------- regra de domínio (fonte única de "overdue") ------- + def is_overdue(self): + if not self.due_date: + return False + if self.status in CLOSED_STATUSES: + return False + return self.due_date < now_utc() + + # ------- serialização (variantes preservam os contratos originais) ------- def to_dict(self): - data = {} - data['id'] = self.id - data['title'] = self.title - data['description'] = self.description - data['status'] = self.status - data['priority'] = self.priority - data['user_id'] = self.user_id - data['category_id'] = self.category_id - data['created_at'] = str(self.created_at) - data['updated_at'] = str(self.updated_at) - data['due_date'] = str(self.due_date) if self.due_date else None - data['tags'] = self.tags.split(',') if self.tags else [] + return { + 'id': self.id, + 'title': self.title, + 'description': self.description, + 'status': self.status, + 'priority': self.priority, + 'user_id': self.user_id, + 'category_id': self.category_id, + 'created_at': str(self.created_at), + 'updated_at': str(self.updated_at), + 'due_date': str(self.due_date) if self.due_date else None, + 'tags': self.tags.split(',') if self.tags else [], + } + + def to_dict_with_overdue(self): + data = self.to_dict() + data['overdue'] = self.is_overdue() return data - def validate_status(self, new_status): - valid = ['pending', 'in_progress', 'done', 'cancelled'] - if new_status in valid: - return True - else: - return False + def to_dict_full(self): + data = self.to_dict_with_overdue() + data['user_name'] = self.user.name if self.user else None + data['category_name'] = self.category.name if self.category else None + return data - def validate_priority(self, p): - if p >= 1 and p <= 5: - return True - return False + def to_summary_dict(self): + return { + 'id': self.id, + 'title': self.title, + 'description': self.description, + 'status': self.status, + 'priority': self.priority, + 'created_at': str(self.created_at), + 'due_date': str(self.due_date) if self.due_date else None, + 'overdue': self.is_overdue(), + } - def is_overdue(self): - if self.due_date: - if self.due_date < datetime.utcnow(): - if self.status != 'done' and self.status != 'cancelled': - return True - else: - return False - else: - return False - else: - return False + def touch(self): + self.updated_at = now_utc() + + # ------- acesso a dados ------- + @classmethod + def get(cls, task_id): + return db.session.get(cls, task_id) + + @classmethod + def all(cls): + return cls.query.all() + + @classmethod + def count(cls): + return cls.query.count() + + @classmethod + def count_by_status(cls, status): + return cls.query.filter_by(status=status).count() + + @classmethod + def count_by_priority(cls, priority): + return cls.query.filter_by(priority=priority).count() + + @classmethod + def count_by_category(cls, category_id): + return cls.query.filter_by(category_id=category_id).count() + + @classmethod + def for_category(cls, category_id): + return cls.query.filter_by(category_id=category_id).all() + + @classmethod + def for_user(cls, user_id): + return cls.query.filter_by(user_id=user_id).all() + + @classmethod + def created_since(cls, since): + return cls.query.filter(cls.created_at >= since).count() + + @classmethod + def completed_since(cls, since): + return cls.query.filter(cls.status == 'done', cls.updated_at >= since).count() + + @classmethod + def search(cls, query=None, status=None, priority=None, user_id=None): + q = cls.query + if query: + q = q.filter(db.or_( + cls.title.like(f'%{query}%'), + cls.description.like(f'%{query}%'), + )) + if status: + q = q.filter(cls.status == status) + if priority: + q = q.filter(cls.priority == int(priority)) + if user_id: + q = q.filter(cls.user_id == int(user_id)) + return q.all() + + def save(self): + db.session.add(self) + db.session.commit() + + def delete(self): + db.session.delete(self) + db.session.commit() diff --git a/task-manager-api/models/user.py b/task-manager-api/models/user.py index 44c296371..5cdfd31cf 100644 --- a/task-manager-api/models/user.py +++ b/task-manager-api/models/user.py @@ -1,6 +1,8 @@ +from werkzeug.security import generate_password_hash, check_password_hash + from database import db -from datetime import datetime -import hashlib +from utils.datetime_utils import now_utc + class User(db.Model): __tablename__ = 'users' @@ -11,28 +13,55 @@ class User(db.Model): password = db.Column(db.String(255), nullable=False) role = db.Column(db.String(50), default='user') active = db.Column(db.Boolean, default=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=now_utc) + + # ------- regra de domínio ------- + def set_password(self, raw_password): + self.password = generate_password_hash(raw_password) + + def check_password(self, raw_password): + return check_password_hash(self.password, raw_password) + + def is_admin(self): + return self.role == 'admin' + # ------- serialização (nunca expõe o hash de senha) ------- def to_dict(self): return { 'id': self.id, 'name': self.name, 'email': self.email, - 'password': self.password, 'role': self.role, 'active': self.active, - 'created_at': str(self.created_at) + 'created_at': str(self.created_at), } - def set_password(self, pwd): + def to_summary_dict(self): + data = self.to_dict() + data['task_count'] = len(self.tasks) + return data - self.password = hashlib.md5(pwd.encode()).hexdigest() + # ------- acesso a dados ------- + @classmethod + def get(cls, user_id): + return db.session.get(cls, user_id) - def check_password(self, pwd): - return self.password == hashlib.md5(pwd.encode()).hexdigest() + @classmethod + def all(cls): + return cls.query.all() - def is_admin(self): - if self.role == 'admin': - return True - else: - return False + @classmethod + def count(cls): + return cls.query.count() + + @classmethod + def get_by_email(cls, email): + return cls.query.filter_by(email=email).first() + + def save(self): + db.session.add(self) + db.session.commit() + + def delete(self): + db.session.delete(self) + db.session.commit() diff --git a/task-manager-api/routes/__init__.py b/task-manager-api/routes/__init__.py deleted file mode 100644 index 8b1378917..000000000 --- a/task-manager-api/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/task-manager-api/routes/report_routes.py b/task-manager-api/routes/report_routes.py deleted file mode 100644 index 164045281..000000000 --- a/task-manager-api/routes/report_routes.py +++ /dev/null @@ -1,223 +0,0 @@ -from flask import Blueprint, request, jsonify -from database import db -from models.task import Task -from models.user import User -from models.category import Category -from datetime import datetime, timedelta -from utils.helpers import format_date, calculate_percentage -import json - -report_bp = Blueprint('reports', __name__) - -@report_bp.route('/reports/summary', methods=['GET']) -def summary_report(): - - total_tasks = Task.query.count() - total_users = User.query.count() - total_categories = Category.query.count() - - pending = Task.query.filter_by(status='pending').count() - in_progress = Task.query.filter_by(status='in_progress').count() - done = Task.query.filter_by(status='done').count() - cancelled = Task.query.filter_by(status='cancelled').count() - - p1 = Task.query.filter_by(priority=1).count() - p2 = Task.query.filter_by(priority=2).count() - p3 = Task.query.filter_by(priority=3).count() - p4 = Task.query.filter_by(priority=4).count() - p5 = Task.query.filter_by(priority=5).count() - - all_tasks = Task.query.all() - overdue_count = 0 - overdue_list = [] - for t in all_tasks: - if t.due_date: - if t.due_date < datetime.utcnow(): - if t.status != 'done' and t.status != 'cancelled': - overdue_count = overdue_count + 1 - overdue_list.append({ - 'id': t.id, - 'title': t.title, - 'due_date': str(t.due_date), - 'days_overdue': (datetime.utcnow() - t.due_date).days - }) - - seven_days_ago = datetime.utcnow() - timedelta(days=7) - recent_tasks = Task.query.filter(Task.created_at >= seven_days_ago).count() - - recent_done = Task.query.filter( - Task.status == 'done', - Task.updated_at >= seven_days_ago - ).count() - - users = User.query.all() - user_stats = [] - for u in users: - user_tasks = Task.query.filter_by(user_id=u.id).all() - total = len(user_tasks) - completed = 0 - for t in user_tasks: - if t.status == 'done': - completed = completed + 1 - user_stats.append({ - 'user_id': u.id, - 'user_name': u.name, - 'total_tasks': total, - 'completed_tasks': completed, - 'completion_rate': round((completed / total) * 100, 2) if total > 0 else 0 - }) - - report = { - 'generated_at': str(datetime.utcnow()), - 'overview': { - 'total_tasks': total_tasks, - 'total_users': total_users, - 'total_categories': total_categories, - }, - 'tasks_by_status': { - 'pending': pending, - 'in_progress': in_progress, - 'done': done, - 'cancelled': cancelled, - }, - 'tasks_by_priority': { - 'critical': p1, - 'high': p2, - 'medium': p3, - 'low': p4, - 'minimal': p5, - }, - 'overdue': { - 'count': overdue_count, - 'tasks': overdue_list, - }, - 'recent_activity': { - 'tasks_created_last_7_days': recent_tasks, - 'tasks_completed_last_7_days': recent_done, - }, - 'user_productivity': user_stats, - } - - return jsonify(report), 200 - -@report_bp.route('/reports/user/', methods=['GET']) -def user_report(user_id): - user = User.query.get(user_id) - if not user: - return jsonify({'error': 'Usuário não encontrado'}), 404 - - tasks = Task.query.filter_by(user_id=user_id).all() - - total = len(tasks) - done = 0 - pending = 0 - in_progress = 0 - cancelled = 0 - overdue = 0 - high_priority = 0 - - for t in tasks: - if t.status == 'done': - done = done + 1 - elif t.status == 'pending': - pending = pending + 1 - elif t.status == 'in_progress': - in_progress = in_progress + 1 - elif t.status == 'cancelled': - cancelled = cancelled + 1 - - if t.priority <= 2: - high_priority = high_priority + 1 - - if t.due_date: - if t.due_date < datetime.utcnow(): - if t.status != 'done' and t.status != 'cancelled': - overdue = overdue + 1 - - report = { - 'user': { - 'id': user.id, - 'name': user.name, - 'email': user.email, - }, - 'statistics': { - 'total_tasks': total, - 'done': done, - 'pending': pending, - 'in_progress': in_progress, - 'cancelled': cancelled, - 'overdue': overdue, - 'high_priority': high_priority, - 'completion_rate': round((done / total) * 100, 2) if total > 0 else 0 - } - } - - return jsonify(report), 200 - -@report_bp.route('/categories', methods=['GET']) -def get_categories(): - categories = Category.query.all() - result = [] - for c in categories: - cat_data = c.to_dict() - cat_data['task_count'] = Task.query.filter_by(category_id=c.id).count() - result.append(cat_data) - return jsonify(result), 200 - -@report_bp.route('/categories', methods=['POST']) -def create_category(): - data = request.get_json() - if not data: - return jsonify({'error': 'Dados inválidos'}), 400 - - name = data.get('name') - if not name: - return jsonify({'error': 'Nome é obrigatório'}), 400 - - category = Category() - category.name = name - category.description = data.get('description', '') - category.color = data.get('color', '#000000') - - try: - db.session.add(category) - db.session.commit() - return jsonify(category.to_dict()), 201 - except: - db.session.rollback() - return jsonify({'error': 'Erro ao criar categoria'}), 500 - -@report_bp.route('/categories/', methods=['PUT']) -def update_category(cat_id): - cat = Category.query.get(cat_id) - if not cat: - return jsonify({'error': 'Categoria não encontrada'}), 404 - - data = request.get_json() - if 'name' in data: - cat.name = data['name'] - if 'description' in data: - cat.description = data['description'] - if 'color' in data: - cat.color = data['color'] - - try: - db.session.commit() - return jsonify(cat.to_dict()), 200 - except: - db.session.rollback() - return jsonify({'error': 'Erro ao atualizar'}), 500 - -@report_bp.route('/categories/', methods=['DELETE']) -def delete_category(cat_id): - cat = Category.query.get(cat_id) - if not cat: - return jsonify({'error': 'Categoria não encontrada'}), 404 - - try: - db.session.delete(cat) - db.session.commit() - return jsonify({'message': 'Categoria deletada'}), 200 - except: - db.session.rollback() - return jsonify({'error': 'Erro ao deletar'}), 500 diff --git a/task-manager-api/routes/task_routes.py b/task-manager-api/routes/task_routes.py deleted file mode 100644 index 29d1e98fb..000000000 --- a/task-manager-api/routes/task_routes.py +++ /dev/null @@ -1,299 +0,0 @@ -from flask import Blueprint, request, jsonify -from database import db -from models.task import Task -from models.user import User -from models.category import Category -from datetime import datetime -import json, os, sys, time - -task_bp = Blueprint('tasks', __name__) - -@task_bp.route('/tasks', methods=['GET']) -def get_tasks(): - try: - tasks = Task.query.all() - result = [] - for t in tasks: - task_data = {} - task_data['id'] = t.id - task_data['title'] = t.title - task_data['description'] = t.description - task_data['status'] = t.status - task_data['priority'] = t.priority - task_data['user_id'] = t.user_id - task_data['category_id'] = t.category_id - task_data['created_at'] = str(t.created_at) - task_data['updated_at'] = str(t.updated_at) - task_data['due_date'] = str(t.due_date) if t.due_date else None - task_data['tags'] = t.tags.split(',') if t.tags else [] - - if t.due_date: - if t.due_date < datetime.utcnow(): - if t.status != 'done' and t.status != 'cancelled': - task_data['overdue'] = True - else: - task_data['overdue'] = False - else: - task_data['overdue'] = False - else: - task_data['overdue'] = False - - if t.user_id: - user = User.query.get(t.user_id) - if user: - task_data['user_name'] = user.name - else: - task_data['user_name'] = None - else: - task_data['user_name'] = None - - if t.category_id: - cat = Category.query.get(t.category_id) - if cat: - task_data['category_name'] = cat.name - else: - task_data['category_name'] = None - else: - task_data['category_name'] = None - - result.append(task_data) - - return jsonify(result), 200 - except: - return jsonify({'error': 'Erro interno'}), 500 - -@task_bp.route('/tasks/', methods=['GET']) -def get_task(task_id): - task = Task.query.get(task_id) - if task: - data = task.to_dict() - - if task.due_date: - if task.due_date < datetime.utcnow(): - if task.status != 'done' and task.status != 'cancelled': - data['overdue'] = True - else: - data['overdue'] = False - else: - data['overdue'] = False - else: - data['overdue'] = False - return jsonify(data), 200 - else: - return jsonify({'error': 'Task não encontrada'}), 404 - -@task_bp.route('/tasks', methods=['POST']) -def create_task(): - data = request.get_json() - - if not data: - return jsonify({'error': 'Dados inválidos'}), 400 - - title = data.get('title') - if not title: - return jsonify({'error': 'Título é obrigatório'}), 400 - - if len(title) < 3: - return jsonify({'error': 'Título muito curto'}), 400 - - if len(title) > 200: - return jsonify({'error': 'Título muito longo'}), 400 - - description = data.get('description', '') - status = data.get('status', 'pending') - priority = data.get('priority', 3) - user_id = data.get('user_id') - category_id = data.get('category_id') - due_date = data.get('due_date') - tags = data.get('tags') - - if status not in ['pending', 'in_progress', 'done', 'cancelled']: - return jsonify({'error': 'Status inválido'}), 400 - - if priority < 1 or priority > 5: - return jsonify({'error': 'Prioridade deve ser entre 1 e 5'}), 400 - - if user_id: - user = User.query.get(user_id) - if not user: - return jsonify({'error': 'Usuário não encontrado'}), 404 - - if category_id: - cat = Category.query.get(category_id) - if not cat: - return jsonify({'error': 'Categoria não encontrada'}), 404 - - task = Task() - task.title = title - task.description = description - task.status = status - task.priority = priority - task.user_id = user_id - task.category_id = category_id - - if due_date: - try: - task.due_date = datetime.strptime(due_date, '%Y-%m-%d') - except: - return jsonify({'error': 'Formato de data inválido. Use YYYY-MM-DD'}), 400 - - if tags: - if type(tags) == list: - task.tags = ','.join(tags) - else: - task.tags = tags - - try: - db.session.add(task) - db.session.commit() - print(f"Task criada: {task.id} - {task.title}") - return jsonify(task.to_dict()), 201 - except Exception as e: - db.session.rollback() - print(f"Erro ao criar task: {str(e)}") - return jsonify({'error': 'Erro ao criar task'}), 500 - -@task_bp.route('/tasks/', methods=['PUT']) -def update_task(task_id): - task = Task.query.get(task_id) - if not task: - return jsonify({'error': 'Task não encontrada'}), 404 - - data = request.get_json() - if not data: - return jsonify({'error': 'Dados inválidos'}), 400 - - if 'title' in data: - if len(data['title']) < 3: - return jsonify({'error': 'Título muito curto'}), 400 - if len(data['title']) > 200: - return jsonify({'error': 'Título muito longo'}), 400 - task.title = data['title'] - - if 'description' in data: - task.description = data['description'] - - if 'status' in data: - if data['status'] not in ['pending', 'in_progress', 'done', 'cancelled']: - return jsonify({'error': 'Status inválido'}), 400 - task.status = data['status'] - - if 'priority' in data: - if data['priority'] < 1 or data['priority'] > 5: - return jsonify({'error': 'Prioridade deve ser entre 1 e 5'}), 400 - task.priority = data['priority'] - - if 'user_id' in data: - if data['user_id']: - user = User.query.get(data['user_id']) - if not user: - return jsonify({'error': 'Usuário não encontrado'}), 404 - task.user_id = data['user_id'] - - if 'category_id' in data: - if data['category_id']: - cat = Category.query.get(data['category_id']) - if not cat: - return jsonify({'error': 'Categoria não encontrada'}), 404 - task.category_id = data['category_id'] - - if 'due_date' in data: - if data['due_date']: - try: - task.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d') - except: - return jsonify({'error': 'Formato de data inválido'}), 400 - else: - task.due_date = None - - if 'tags' in data: - if type(data['tags']) == list: - task.tags = ','.join(data['tags']) - else: - task.tags = data['tags'] - - task.updated_at = datetime.utcnow() - - try: - db.session.commit() - print(f"Task atualizada: {task.id}") - return jsonify(task.to_dict()), 200 - except Exception as e: - db.session.rollback() - return jsonify({'error': 'Erro ao atualizar'}), 500 - -@task_bp.route('/tasks/', methods=['DELETE']) -def delete_task(task_id): - task = Task.query.get(task_id) - if not task: - return jsonify({'error': 'Task não encontrada'}), 404 - - try: - db.session.delete(task) - db.session.commit() - print(f"Task deletada: {task_id}") - return jsonify({'message': 'Task deletada com sucesso'}), 200 - except: - db.session.rollback() - return jsonify({'error': 'Erro ao deletar'}), 500 - -@task_bp.route('/tasks/search', methods=['GET']) -def search_tasks(): - query = request.args.get('q', '') - status = request.args.get('status', '') - priority = request.args.get('priority', '') - user_id = request.args.get('user_id', '') - - tasks = Task.query - - if query: - tasks = tasks.filter( - db.or_( - Task.title.like(f'%{query}%'), - Task.description.like(f'%{query}%') - ) - ) - - if status: - tasks = tasks.filter(Task.status == status) - - if priority: - tasks = tasks.filter(Task.priority == int(priority)) - - if user_id: - tasks = tasks.filter(Task.user_id == int(user_id)) - - results = tasks.all() - output = [] - for t in results: - output.append(t.to_dict()) - - return jsonify(output), 200 - -@task_bp.route('/tasks/stats', methods=['GET']) -def task_stats(): - total = Task.query.count() - pending = Task.query.filter_by(status='pending').count() - in_progress = Task.query.filter_by(status='in_progress').count() - done = Task.query.filter_by(status='done').count() - cancelled = Task.query.filter_by(status='cancelled').count() - - all_tasks = Task.query.all() - overdue_count = 0 - for t in all_tasks: - if t.due_date: - if t.due_date < datetime.utcnow(): - if t.status != 'done' and t.status != 'cancelled': - overdue_count = overdue_count + 1 - - stats = { - 'total': total, - 'pending': pending, - 'in_progress': in_progress, - 'done': done, - 'cancelled': cancelled, - 'overdue': overdue_count, - 'completion_rate': round((done / total) * 100, 2) if total > 0 else 0 - } - - return jsonify(stats), 200 diff --git a/task-manager-api/routes/user_routes.py b/task-manager-api/routes/user_routes.py deleted file mode 100644 index 00d7d7d56..000000000 --- a/task-manager-api/routes/user_routes.py +++ /dev/null @@ -1,211 +0,0 @@ -from flask import Blueprint, request, jsonify -from database import db -from models.user import User -from models.task import Task -from datetime import datetime -import hashlib, json, re - -user_bp = Blueprint('users', __name__) - -@user_bp.route('/users', methods=['GET']) -def get_users(): - users = User.query.all() - result = [] - for u in users: - user_data = { - 'id': u.id, - 'name': u.name, - 'email': u.email, - 'role': u.role, - 'active': u.active, - 'created_at': str(u.created_at), - 'task_count': len(u.tasks) - } - result.append(user_data) - return jsonify(result), 200 - -@user_bp.route('/users/', methods=['GET']) -def get_user(user_id): - user = User.query.get(user_id) - if not user: - return jsonify({'error': 'Usuário não encontrado'}), 404 - - data = user.to_dict() - - tasks = Task.query.filter_by(user_id=user_id).all() - data['tasks'] = [] - for t in tasks: - data['tasks'].append(t.to_dict()) - - return jsonify(data), 200 - -@user_bp.route('/users', methods=['POST']) -def create_user(): - data = request.get_json() - - if not data: - return jsonify({'error': 'Dados inválidos'}), 400 - - name = data.get('name') - email = data.get('email') - password = data.get('password') - role = data.get('role', 'user') - - if not name: - return jsonify({'error': 'Nome é obrigatório'}), 400 - if not email: - return jsonify({'error': 'Email é obrigatório'}), 400 - if not password: - return jsonify({'error': 'Senha é obrigatória'}), 400 - - if not re.match(r'^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$', email): - return jsonify({'error': 'Email inválido'}), 400 - - if len(password) < 4: - return jsonify({'error': 'Senha deve ter no mínimo 4 caracteres'}), 400 - - existing = User.query.filter_by(email=email).first() - if existing: - return jsonify({'error': 'Email já cadastrado'}), 409 - - if role not in ['user', 'admin', 'manager']: - return jsonify({'error': 'Role inválido'}), 400 - - user = User() - user.name = name - user.email = email - user.set_password(password) - user.role = role - - try: - db.session.add(user) - db.session.commit() - print(f"Usuário criado: {user.id} - {user.name}") - - response_data = user.to_dict() - return jsonify(response_data), 201 - except Exception as e: - db.session.rollback() - print(f"ERRO: {str(e)}") - return jsonify({'error': 'Erro ao criar usuário'}), 500 - -@user_bp.route('/users/', methods=['PUT']) -def update_user(user_id): - user = User.query.get(user_id) - if not user: - return jsonify({'error': 'Usuário não encontrado'}), 404 - - data = request.get_json() - if not data: - return jsonify({'error': 'Dados inválidos'}), 400 - - if 'name' in data: - user.name = data['name'] - - if 'email' in data: - if not re.match(r'^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$', data['email']): - return jsonify({'error': 'Email inválido'}), 400 - - existing = User.query.filter_by(email=data['email']).first() - if existing and existing.id != user_id: - return jsonify({'error': 'Email já cadastrado'}), 409 - user.email = data['email'] - - if 'password' in data: - if len(data['password']) < 4: - return jsonify({'error': 'Senha muito curta'}), 400 - user.set_password(data['password']) - - if 'role' in data: - if data['role'] not in ['user', 'admin', 'manager']: - return jsonify({'error': 'Role inválido'}), 400 - user.role = data['role'] - - if 'active' in data: - user.active = data['active'] - - try: - db.session.commit() - return jsonify(user.to_dict()), 200 - except: - db.session.rollback() - return jsonify({'error': 'Erro ao atualizar'}), 500 - -@user_bp.route('/users/', methods=['DELETE']) -def delete_user(user_id): - user = User.query.get(user_id) - if not user: - return jsonify({'error': 'Usuário não encontrado'}), 404 - - tasks = Task.query.filter_by(user_id=user_id).all() - for t in tasks: - db.session.delete(t) - - try: - db.session.delete(user) - db.session.commit() - print(f"Usuário deletado: {user_id}") - return jsonify({'message': 'Usuário deletado com sucesso'}), 200 - except: - db.session.rollback() - return jsonify({'error': 'Erro ao deletar'}), 500 - -@user_bp.route('/users//tasks', methods=['GET']) -def get_user_tasks(user_id): - user = User.query.get(user_id) - if not user: - return jsonify({'error': 'Usuário não encontrado'}), 404 - - tasks = Task.query.filter_by(user_id=user_id).all() - result = [] - for t in tasks: - task_data = {} - task_data['id'] = t.id - task_data['title'] = t.title - task_data['description'] = t.description - task_data['status'] = t.status - task_data['priority'] = t.priority - task_data['created_at'] = str(t.created_at) - task_data['due_date'] = str(t.due_date) if t.due_date else None - - if t.due_date: - if t.due_date < datetime.utcnow(): - if t.status != 'done' and t.status != 'cancelled': - task_data['overdue'] = True - else: - task_data['overdue'] = False - else: - task_data['overdue'] = False - else: - task_data['overdue'] = False - result.append(task_data) - - return jsonify(result), 200 - -@user_bp.route('/login', methods=['POST']) -def login(): - data = request.get_json() - if not data: - return jsonify({'error': 'Dados inválidos'}), 400 - - email = data.get('email') - password = data.get('password') - - if not email or not password: - return jsonify({'error': 'Email e senha são obrigatórios'}), 400 - - user = User.query.filter_by(email=email).first() - if not user: - return jsonify({'error': 'Credenciais inválidas'}), 401 - - if not user.check_password(password): - return jsonify({'error': 'Credenciais inválidas'}), 401 - - if not user.active: - return jsonify({'error': 'Usuário inativo'}), 403 - - return jsonify({ - 'message': 'Login realizado com sucesso', - 'user': user.to_dict(), - 'token': 'fake-jwt-token-' + str(user.id) - }), 200 diff --git a/task-manager-api/schemas/__init__.py b/task-manager-api/schemas/__init__.py new file mode 100644 index 000000000..7352e71dd --- /dev/null +++ b/task-manager-api/schemas/__init__.py @@ -0,0 +1,8 @@ +def first_error(validation_error): + """Extrai a primeira mensagem de erro de um ValidationError do marshmallow.""" + for messages in validation_error.messages.values(): + if isinstance(messages, list) and messages: + return messages[0] + if isinstance(messages, str): + return messages + return 'Dados inválidos' diff --git a/task-manager-api/schemas/category_schema.py b/task-manager-api/schemas/category_schema.py new file mode 100644 index 000000000..2d36ddb13 --- /dev/null +++ b/task-manager-api/schemas/category_schema.py @@ -0,0 +1,22 @@ +from marshmallow import Schema, fields, EXCLUDE + +from utils.constants import DEFAULT_COLOR + + +class CategoryCreateSchema(Schema): + class Meta: + unknown = EXCLUDE + + name = fields.Str(required=True, + error_messages={'required': 'Nome é obrigatório', 'null': 'Nome é obrigatório'}) + description = fields.Str(load_default='') + color = fields.Str(load_default=DEFAULT_COLOR) + + +class CategoryUpdateSchema(Schema): + class Meta: + unknown = EXCLUDE + + name = fields.Str() + description = fields.Str() + color = fields.Str() diff --git a/task-manager-api/schemas/task_schema.py b/task-manager-api/schemas/task_schema.py new file mode 100644 index 000000000..8367f8196 --- /dev/null +++ b/task-manager-api/schemas/task_schema.py @@ -0,0 +1,49 @@ +from marshmallow import Schema, fields, validate, EXCLUDE + +from utils.constants import ( + VALID_STATUSES, MIN_TITLE_LENGTH, MAX_TITLE_LENGTH, + MIN_PRIORITY, MAX_PRIORITY, DEFAULT_PRIORITY, +) + +_PRIORITY_MSG = 'Prioridade deve ser entre 1 e 5' +_STATUS_MSG = 'Status inválido' + + +class TaskCreateSchema(Schema): + class Meta: + unknown = EXCLUDE + + title = fields.Str( + required=True, + error_messages={'required': 'Título é obrigatório', 'null': 'Título é obrigatório'}, + validate=[ + validate.Length(min=MIN_TITLE_LENGTH, error='Título muito curto'), + validate.Length(max=MAX_TITLE_LENGTH, error='Título muito longo'), + ], + ) + description = fields.Str(load_default='') + status = fields.Str(load_default='pending', + validate=validate.OneOf(VALID_STATUSES, error=_STATUS_MSG)) + priority = fields.Int(load_default=DEFAULT_PRIORITY, + validate=validate.Range(min=MIN_PRIORITY, max=MAX_PRIORITY, error=_PRIORITY_MSG)) + user_id = fields.Int(load_default=None, allow_none=True) + category_id = fields.Int(load_default=None, allow_none=True) + due_date = fields.Str(load_default=None, allow_none=True) + tags = fields.Raw(load_default=None, allow_none=True) + + +class TaskUpdateSchema(Schema): + class Meta: + unknown = EXCLUDE + + title = fields.Str(validate=[ + validate.Length(min=MIN_TITLE_LENGTH, error='Título muito curto'), + validate.Length(max=MAX_TITLE_LENGTH, error='Título muito longo'), + ]) + description = fields.Str() + status = fields.Str(validate=validate.OneOf(VALID_STATUSES, error=_STATUS_MSG)) + priority = fields.Int(validate=validate.Range(min=MIN_PRIORITY, max=MAX_PRIORITY, error=_PRIORITY_MSG)) + user_id = fields.Int(allow_none=True) + category_id = fields.Int(allow_none=True) + due_date = fields.Str(allow_none=True) + tags = fields.Raw(allow_none=True) diff --git a/task-manager-api/schemas/user_schema.py b/task-manager-api/schemas/user_schema.py new file mode 100644 index 000000000..1b69f77fc --- /dev/null +++ b/task-manager-api/schemas/user_schema.py @@ -0,0 +1,47 @@ +from marshmallow import Schema, fields, validate, EXCLUDE + +from utils.constants import VALID_ROLES, MIN_PASSWORD_LENGTH + +_EMAIL_REGEX = r'^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$' +_ROLE_MSG = 'Role inválido' +_EMAIL_MSG = 'Email inválido' + + +class UserCreateSchema(Schema): + class Meta: + unknown = EXCLUDE + + name = fields.Str(required=True, + error_messages={'required': 'Nome é obrigatório', 'null': 'Nome é obrigatório'}) + email = fields.Str( + required=True, + error_messages={'required': 'Email é obrigatório', 'null': 'Email é obrigatório'}, + validate=validate.Regexp(_EMAIL_REGEX, error=_EMAIL_MSG), + ) + password = fields.Str( + required=True, + error_messages={'required': 'Senha é obrigatória', 'null': 'Senha é obrigatória'}, + validate=validate.Length(min=MIN_PASSWORD_LENGTH, + error='Senha deve ter no mínimo 4 caracteres'), + ) + role = fields.Str(load_default='user', + validate=validate.OneOf(VALID_ROLES, error=_ROLE_MSG)) + + +class UserUpdateSchema(Schema): + class Meta: + unknown = EXCLUDE + + name = fields.Str() + email = fields.Str(validate=validate.Regexp(_EMAIL_REGEX, error=_EMAIL_MSG)) + password = fields.Str(validate=validate.Length(min=MIN_PASSWORD_LENGTH, error='Senha muito curta')) + role = fields.Str(validate=validate.OneOf(VALID_ROLES, error=_ROLE_MSG)) + active = fields.Bool() + + +class LoginSchema(Schema): + class Meta: + unknown = EXCLUDE + + email = fields.Str(required=True) + password = fields.Str(required=True) diff --git a/task-manager-api/seed.py b/task-manager-api/seed.py index b5c15a727..f2da26ea2 100644 --- a/task-manager-api/seed.py +++ b/task-manager-api/seed.py @@ -3,7 +3,9 @@ from models.task import Task from models.user import User from models.category import Category -from datetime import datetime, timedelta +from datetime import timedelta + +from utils.datetime_utils import now_utc def seed_data(): with app.app_context(): @@ -63,15 +65,15 @@ def seed_data(): db.session.commit() tasks_data = [ - {'title': 'Implementar autenticação JWT', 'description': 'Adicionar autenticação real com JWT', 'status': 'pending', 'priority': 1, 'user_id': u1.id, 'category_id': c1.id, 'due_date': datetime.utcnow() - timedelta(days=3)}, - {'title': 'Criar tela de login', 'description': 'Tela de login responsiva', 'status': 'in_progress', 'priority': 2, 'user_id': u2.id, 'category_id': c2.id, 'due_date': datetime.utcnow() + timedelta(days=5)}, + {'title': 'Implementar autenticação JWT', 'description': 'Adicionar autenticação real com JWT', 'status': 'pending', 'priority': 1, 'user_id': u1.id, 'category_id': c1.id, 'due_date': now_utc() - timedelta(days=3)}, + {'title': 'Criar tela de login', 'description': 'Tela de login responsiva', 'status': 'in_progress', 'priority': 2, 'user_id': u2.id, 'category_id': c2.id, 'due_date': now_utc() + timedelta(days=5)}, {'title': 'Configurar CI/CD', 'description': 'Pipeline com GitHub Actions', 'status': 'done', 'priority': 2, 'user_id': u3.id, 'category_id': c3.id, 'tags': 'devops,ci,github'}, - {'title': 'Corrigir bug no filtro de busca', 'description': 'Filtro não funciona com caracteres especiais', 'status': 'pending', 'priority': 1, 'user_id': u1.id, 'category_id': c4.id, 'due_date': datetime.utcnow() - timedelta(days=1)}, - {'title': 'Adicionar paginação na API', 'description': 'Endpoints retornam todos os registros', 'status': 'pending', 'priority': 3, 'user_id': u1.id, 'category_id': c1.id, 'due_date': datetime.utcnow() + timedelta(days=10)}, + {'title': 'Corrigir bug no filtro de busca', 'description': 'Filtro não funciona com caracteres especiais', 'status': 'pending', 'priority': 1, 'user_id': u1.id, 'category_id': c4.id, 'due_date': now_utc() - timedelta(days=1)}, + {'title': 'Adicionar paginação na API', 'description': 'Endpoints retornam todos os registros', 'status': 'pending', 'priority': 3, 'user_id': u1.id, 'category_id': c1.id, 'due_date': now_utc() + timedelta(days=10)}, {'title': 'Escrever testes unitários', 'description': 'Cobertura mínima de 80%', 'status': 'pending', 'priority': 2, 'user_id': u2.id, 'category_id': c1.id}, {'title': 'Documentar API com Swagger', 'description': 'Gerar documentação automática', 'status': 'cancelled', 'priority': 4, 'user_id': u3.id, 'category_id': c1.id}, {'title': 'Refatorar models', 'description': 'Melhorar organização dos models', 'status': 'in_progress', 'priority': 3, 'user_id': u2.id, 'category_id': c1.id, 'tags': 'refactor,tech-debt'}, - {'title': 'Configurar monitoramento', 'description': 'Prometheus + Grafana', 'status': 'pending', 'priority': 4, 'user_id': u3.id, 'category_id': c3.id, 'due_date': datetime.utcnow() + timedelta(days=20)}, + {'title': 'Configurar monitoramento', 'description': 'Prometheus + Grafana', 'status': 'pending', 'priority': 4, 'user_id': u3.id, 'category_id': c3.id, 'due_date': now_utc() + timedelta(days=20)}, {'title': 'Melhorar validações de input', 'description': 'Usar marshmallow ou pydantic', 'status': 'pending', 'priority': 3, 'user_id': u1.id, 'category_id': c1.id, 'tags': 'improvement,validation'}, ] diff --git a/task-manager-api/services/category_service.py b/task-manager-api/services/category_service.py new file mode 100644 index 000000000..00a4ca68e --- /dev/null +++ b/task-manager-api/services/category_service.py @@ -0,0 +1,12 @@ +"""Orquestração de operações de categoria com integridade referencial.""" +from database import db +from models.task import Task + + +def delete_category_safely(category): + """Remove a categoria após desvincular as tasks (category_id -> NULL), + evitando referências órfãs. Tudo numa única transação.""" + for task in Task.for_category(category.id): + task.category_id = None + db.session.delete(category) + db.session.commit() diff --git a/task-manager-api/services/notification_service.py b/task-manager-api/services/notification_service.py index 7df57d2b8..f42d7f779 100644 --- a/task-manager-api/services/notification_service.py +++ b/task-manager-api/services/notification_service.py @@ -1,48 +1,50 @@ +"""Serviço de notificações por e-mail. Credenciais vêm da config (ambiente).""" import smtplib -from datetime import datetime + +from config.settings import Config +from utils.datetime_utils import now_utc + class NotificationService: def __init__(self): self.notifications = [] - self.email_host = 'smtp.gmail.com' - self.email_port = 587 - self.email_user = 'taskmanager@gmail.com' - self.email_password = 'senha123' + self.email_host = Config.EMAIL_HOST + self.email_port = Config.EMAIL_PORT + self.email_user = Config.EMAIL_USER + self.email_password = Config.EMAIL_PASSWORD def send_email(self, to, subject, body): + if not self.email_user or not self.email_password: + # Sem credenciais configuradas: não tenta enviar. + return False try: - server = smtplib.SMTP(self.email_host, self.email_port) server.starttls() server.login(self.email_user, self.email_password) message = f"Subject: {subject}\n\n{body}" server.sendmail(self.email_user, to, message) server.quit() - print(f"Email enviado para {to}") return True - except Exception as e: - print(f"Erro ao enviar email: {str(e)}") + except Exception: return False def notify_task_assigned(self, user, task): subject = f"Nova task atribuída: {task.title}" - body = f"Olá {user.name},\n\nA task '{task.title}' foi atribuída a você.\n\nPrioridade: {task.priority}\nStatus: {task.status}" + body = (f"Olá {user.name},\n\nA task '{task.title}' foi atribuída a você.\n\n" + f"Prioridade: {task.priority}\nStatus: {task.status}") self.send_email(user.email, subject, body) self.notifications.append({ 'type': 'task_assigned', 'user_id': user.id, 'task_id': task.id, - 'timestamp': datetime.utcnow() + 'timestamp': now_utc(), }) def notify_task_overdue(self, user, task): subject = f"Task atrasada: {task.title}" - body = f"Olá {user.name},\n\nA task '{task.title}' está atrasada!\n\nData limite: {task.due_date}" + body = (f"Olá {user.name},\n\nA task '{task.title}' está atrasada!\n\n" + f"Data limite: {task.due_date}") self.send_email(user.email, subject, body) def get_notifications(self, user_id): - result = [] - for n in self.notifications: - if n['user_id'] == user_id: - result.append(n) - return result + return [n for n in self.notifications if n['user_id'] == user_id] diff --git a/task-manager-api/services/report_service.py b/task-manager-api/services/report_service.py new file mode 100644 index 000000000..ed65e6d55 --- /dev/null +++ b/task-manager-api/services/report_service.py @@ -0,0 +1,150 @@ +"""Regras de agregação de relatórios (fora das rotas). + +Carrega os conjuntos uma vez e agrega em memória, evitando o N+1 da versão +original (que disparava uma query de tasks por usuário).""" +from datetime import timedelta + +from models.task import Task +from models.user import User +from models.category import Category +from utils.datetime_utils import now_utc +from utils.constants import CLOSED_STATUSES + + +def _completion_rate(done, total): + return round((done / total) * 100, 2) if total > 0 else 0 + + +def build_summary(): + now = now_utc() + all_tasks = Task.all() + users = User.all() + + status_counts = {'pending': 0, 'in_progress': 0, 'done': 0, 'cancelled': 0} + priority_counts = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0} + overdue_list = [] + seven_days_ago = now - timedelta(days=7) + recent_created = 0 + recent_done = 0 + tasks_by_user = {} + + for task in all_tasks: + if task.status in status_counts: + status_counts[task.status] += 1 + if task.priority in priority_counts: + priority_counts[task.priority] += 1 + + if task.is_overdue(): + overdue_list.append({ + 'id': task.id, + 'title': task.title, + 'due_date': str(task.due_date), + 'days_overdue': (now - task.due_date).days, + }) + + if task.created_at and task.created_at >= seven_days_ago: + recent_created += 1 + if task.status == 'done' and task.updated_at and task.updated_at >= seven_days_ago: + recent_done += 1 + + tasks_by_user.setdefault(task.user_id, []).append(task) + + user_stats = [] + for user in users: + user_tasks = tasks_by_user.get(user.id, []) + total = len(user_tasks) + completed = sum(1 for t in user_tasks if t.status == 'done') + user_stats.append({ + 'user_id': user.id, + 'user_name': user.name, + 'total_tasks': total, + 'completed_tasks': completed, + 'completion_rate': _completion_rate(completed, total), + }) + + return { + 'generated_at': str(now), + 'overview': { + 'total_tasks': len(all_tasks), + 'total_users': len(users), + 'total_categories': Category.count(), + }, + 'tasks_by_status': { + 'pending': status_counts['pending'], + 'in_progress': status_counts['in_progress'], + 'done': status_counts['done'], + 'cancelled': status_counts['cancelled'], + }, + 'tasks_by_priority': { + 'critical': priority_counts[1], + 'high': priority_counts[2], + 'medium': priority_counts[3], + 'low': priority_counts[4], + 'minimal': priority_counts[5], + }, + 'overdue': { + 'count': len(overdue_list), + 'tasks': overdue_list, + }, + 'recent_activity': { + 'tasks_created_last_7_days': recent_created, + 'tasks_completed_last_7_days': recent_done, + }, + 'user_productivity': user_stats, + } + + +def build_user_report(user): + tasks = Task.for_user(user.id) + + counts = {'done': 0, 'pending': 0, 'in_progress': 0, 'cancelled': 0} + overdue = 0 + high_priority = 0 + + for task in tasks: + if task.status in counts: + counts[task.status] += 1 + if task.priority <= 2: + high_priority += 1 + if task.is_overdue(): + overdue += 1 + + total = len(tasks) + return { + 'user': { + 'id': user.id, + 'name': user.name, + 'email': user.email, + }, + 'statistics': { + 'total_tasks': total, + 'done': counts['done'], + 'pending': counts['pending'], + 'in_progress': counts['in_progress'], + 'cancelled': counts['cancelled'], + 'overdue': overdue, + 'high_priority': high_priority, + 'completion_rate': _completion_rate(counts['done'], total), + }, + } + + +def task_stats(): + all_tasks = Task.all() + total = len(all_tasks) + counts = {'pending': 0, 'in_progress': 0, 'done': 0, 'cancelled': 0} + overdue = 0 + for task in all_tasks: + if task.status in counts: + counts[task.status] += 1 + if task.is_overdue(): + overdue += 1 + return { + 'total': total, + 'pending': counts['pending'], + 'in_progress': counts['in_progress'], + 'done': counts['done'], + 'cancelled': counts['cancelled'], + 'overdue': overdue, + 'completion_rate': _completion_rate(counts['done'], total), + } diff --git a/task-manager-api/services/user_service.py b/task-manager-api/services/user_service.py new file mode 100644 index 000000000..37a2f4885 --- /dev/null +++ b/task-manager-api/services/user_service.py @@ -0,0 +1,11 @@ +"""Orquestração de operações de usuário que exigem atomicidade.""" +from database import db +from models.task import Task + + +def delete_user_cascade(user): + """Remove o usuário e suas tasks numa única transação (sem deixar órfãos).""" + for task in Task.for_user(user.id): + db.session.delete(task) + db.session.delete(user) + db.session.commit() diff --git a/task-manager-api/utils/constants.py b/task-manager-api/utils/constants.py new file mode 100644 index 000000000..92e7e2ebd --- /dev/null +++ b/task-manager-api/utils/constants.py @@ -0,0 +1,18 @@ +"""Constantes de domínio centralizadas (fim dos magic strings/numbers).""" + +VALID_STATUSES = ['pending', 'in_progress', 'done', 'cancelled'] +VALID_ROLES = ['user', 'admin', 'manager'] + +MIN_TITLE_LENGTH = 3 +MAX_TITLE_LENGTH = 200 +MIN_PASSWORD_LENGTH = 4 + +MIN_PRIORITY = 1 +MAX_PRIORITY = 5 +DEFAULT_PRIORITY = 3 + +DEFAULT_COLOR = '#000000' +DATE_FORMAT = '%Y-%m-%d' + +# status que "encerram" a task e portanto nunca contam como atrasadas. +CLOSED_STATUSES = ['done', 'cancelled'] diff --git a/task-manager-api/utils/datetime_utils.py b/task-manager-api/utils/datetime_utils.py new file mode 100644 index 000000000..6c0ccba2b --- /dev/null +++ b/task-manager-api/utils/datetime_utils.py @@ -0,0 +1,15 @@ +"""Utilitários de data/hora. + +Usa a API moderna `datetime.now(timezone.utc)` (em vez de `datetime.utcnow()`, +deprecated no Python 3.12+) mas retorna um datetime *naive* em UTC para manter +compatibilidade com as colunas SQLAlchemy DateTime (sem timezone) já persistidas. +""" +from datetime import datetime, timezone + + +def now_utc(): + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def to_iso(value): + return str(value) if value else None diff --git a/task-manager-api/utils/helpers.py b/task-manager-api/utils/helpers.py deleted file mode 100644 index eed3bc24d..000000000 --- a/task-manager-api/utils/helpers.py +++ /dev/null @@ -1,116 +0,0 @@ -from datetime import datetime -import re -import os -import json -import sys -import math -import hashlib - -def format_date(date_obj): - if date_obj: - return str(date_obj) - return None - -def calculate_percentage(part, total): - if total == 0: - return 0 - return round((part / total) * 100, 2) - -def validate_email(email): - - if re.match(r'^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$', email): - return True - return False - -def sanitize_string(s): - - if s: - return s.strip() - return s - -def generate_id(): - - import uuid - return str(uuid.uuid4()) - -def log_action(action, details=None): - - timestamp = datetime.utcnow() - print(f"[{timestamp}] ACTION: {action}") - if details: - print(f" DETAILS: {details}") - -def parse_date(date_string): - try: - return datetime.strptime(date_string, '%Y-%m-%d') - except: - try: - return datetime.strptime(date_string, '%d/%m/%Y') - except: - return None - -def is_valid_color(color): - if color and len(color) == 7 and color[0] == '#': - return True - return False - -def process_task_data(data, existing_task=None): - result = {} - - if 'title' in data: - title = data['title'] - if title: - title = title.strip() - if len(title) >= 3 and len(title) <= 200: - result['title'] = title - else: - return None, 'Título deve ter entre 3 e 200 caracteres' - else: - return None, 'Título não pode ser vazio' - - if 'description' in data: - result['description'] = data['description'] - - if 'status' in data: - valid_statuses = ['pending', 'in_progress', 'done', 'cancelled'] - if data['status'] in valid_statuses: - result['status'] = data['status'] - else: - return None, 'Status inválido' - - if 'priority' in data: - try: - p = int(data['priority']) - if p >= 1 and p <= 5: - result['priority'] = p - else: - return None, 'Prioridade deve ser entre 1 e 5' - except: - return None, 'Prioridade inválida' - - if 'due_date' in data: - if data['due_date']: - parsed = parse_date(data['due_date']) - if parsed: - result['due_date'] = parsed - else: - return None, 'Data inválida' - else: - result['due_date'] = None - - if 'tags' in data: - tags = data['tags'] - if type(tags) == list: - result['tags'] = ','.join(tags) - else: - result['tags'] = tags - - return result, None - -VALID_STATUSES = ['pending', 'in_progress', 'done', 'cancelled'] -VALID_ROLES = ['user', 'admin', 'manager'] -MAX_TITLE_LENGTH = 200 -MIN_TITLE_LENGTH = 3 -MIN_PASSWORD_LENGTH = 4 -DEFAULT_PRIORITY = 3 -DEFAULT_COLOR = '#000000' diff --git a/task-manager-api/views/__init__.py b/task-manager-api/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/task-manager-api/views/category_routes.py b/task-manager-api/views/category_routes.py new file mode 100644 index 000000000..e1084b2a0 --- /dev/null +++ b/task-manager-api/views/category_routes.py @@ -0,0 +1,15 @@ +from flask import Blueprint + +from controllers import category_controller +from middlewares.auth import require_auth + +category_bp = Blueprint('categories', __name__) + +category_bp.add_url_rule('/categories', 'list_categories', + category_controller.list_categories, methods=['GET']) +category_bp.add_url_rule('/categories', 'create_category', + category_controller.create_category, methods=['POST']) +category_bp.add_url_rule('/categories/', 'update_category', + category_controller.update_category, methods=['PUT']) +category_bp.add_url_rule('/categories/', 'delete_category', + require_auth(category_controller.delete_category), methods=['DELETE']) diff --git a/task-manager-api/views/health_routes.py b/task-manager-api/views/health_routes.py new file mode 100644 index 000000000..7c35a7019 --- /dev/null +++ b/task-manager-api/views/health_routes.py @@ -0,0 +1,15 @@ +from flask import Blueprint, jsonify + +from utils.datetime_utils import now_utc + +health_bp = Blueprint('health', __name__) + + +@health_bp.route('/health') +def health(): + return jsonify({'status': 'ok', 'timestamp': str(now_utc())}) + + +@health_bp.route('/') +def index(): + return jsonify({'message': 'Task Manager API', 'version': '1.0'}) diff --git a/task-manager-api/views/report_routes.py b/task-manager-api/views/report_routes.py new file mode 100644 index 000000000..f37686d8e --- /dev/null +++ b/task-manager-api/views/report_routes.py @@ -0,0 +1,10 @@ +from flask import Blueprint + +from controllers import report_controller + +report_bp = Blueprint('reports', __name__) + +report_bp.add_url_rule('/reports/summary', 'summary_report', + report_controller.summary_report, methods=['GET']) +report_bp.add_url_rule('/reports/user/', 'user_report', + report_controller.user_report, methods=['GET']) diff --git a/task-manager-api/views/task_routes.py b/task-manager-api/views/task_routes.py new file mode 100644 index 000000000..42fb2b2bb --- /dev/null +++ b/task-manager-api/views/task_routes.py @@ -0,0 +1,15 @@ +from flask import Blueprint + +from controllers import task_controller +from middlewares.auth import require_auth + +task_bp = Blueprint('tasks', __name__) + +task_bp.add_url_rule('/tasks', 'list_tasks', task_controller.list_tasks, methods=['GET']) +task_bp.add_url_rule('/tasks/search', 'search_tasks', task_controller.search_tasks, methods=['GET']) +task_bp.add_url_rule('/tasks/stats', 'task_stats', task_controller.stats, methods=['GET']) +task_bp.add_url_rule('/tasks/', 'get_task', task_controller.get_task, methods=['GET']) +task_bp.add_url_rule('/tasks', 'create_task', task_controller.create_task, methods=['POST']) +task_bp.add_url_rule('/tasks/', 'update_task', task_controller.update_task, methods=['PUT']) +task_bp.add_url_rule('/tasks/', 'delete_task', + require_auth(task_controller.delete_task), methods=['DELETE']) diff --git a/task-manager-api/views/user_routes.py b/task-manager-api/views/user_routes.py new file mode 100644 index 000000000..5f0303b8c --- /dev/null +++ b/task-manager-api/views/user_routes.py @@ -0,0 +1,16 @@ +from flask import Blueprint + +from controllers import user_controller, auth_controller +from middlewares.auth import require_auth + +user_bp = Blueprint('users', __name__) + +user_bp.add_url_rule('/users', 'list_users', user_controller.list_users, methods=['GET']) +user_bp.add_url_rule('/users/', 'get_user', user_controller.get_user, methods=['GET']) +user_bp.add_url_rule('/users', 'create_user', user_controller.create_user, methods=['POST']) +user_bp.add_url_rule('/users/', 'update_user', user_controller.update_user, methods=['PUT']) +user_bp.add_url_rule('/users/', 'delete_user', + require_auth(user_controller.delete_user), methods=['DELETE']) +user_bp.add_url_rule('/users//tasks', 'get_user_tasks', + user_controller.get_user_tasks, methods=['GET']) +user_bp.add_url_rule('/login', 'login', auth_controller.login, methods=['POST'])