diff --git a/.gitignore b/.gitignore index 97d5de914..b7d250a34 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ venv/ # SQLite *.db instance/ +code-smells-project/.claude/settings.json + +# FUSE transient artifacts +.fuse_hidden* diff --git a/README.md b/README.md index 431e6ffb7..d5f9914b1 100644 --- a/README.md +++ b/README.md @@ -1,448 +1,373 @@ -# Criação de Skills — Refatoração Arquitetural Automatizada +# Desafio Skills — Refatoração Arquitetural Automatizada (MVC) -Ao longo do curso você aprendeu o que são Skills e como elas permitem que um agente de IA atue como um especialista em tarefas específicas. Agora imagine o seguinte cenário: você herdou 3 projetos legados com problemas de arquitetura, segurança e qualidade de código. Revisar e corrigir tudo manualmente levaria dias. +Repositório de entrega do desafio de **Skills** — uma skill `refactor-arch` agnóstica de tecnologia que analisa, audita e refatora projetos legados para o padrão **MVC**, aplicada a 3 projetos com stacks diferentes. -Neste desafio, você vai criar uma Skill que automatiza esse processo — analisando, auditando e refatorando qualquer projeto para o padrão MVC, independente da tecnologia. +- **Ferramenta:** Claude Code (Custom Skills) +- **Skill:** `.claude/skills/refactor-arch/` (SKILL.md + 5 arquivos de referência) +- **Projetos-alvo:** `code-smells-project` (Python/Flask), `ecommerce-api-legacy` (Node.js/Express), `task-manager-api` (Python/Flask) -## Objetivo - -Você deve entregar uma Skill capaz de: - -- Analisar uma codebase detectando linguagem, framework e arquitetura atual -- Identificar anti-patterns e code smells, classificando por severidade com arquivo e linha exatos -- Gerar um relatório de auditoria estruturado com todos os achados -- Refatorar o projeto para o padrão MVC (Model-View-Controller), eliminando os problemas encontrados -- Validar o resultado garantindo que a aplicação continua funcionando após as mudanças - -A skill deve ser agnóstica de tecnologia, funcionando com diferentes linguagens e frameworks. - -## Contexto - -### Definição de Severidades - -Para padronizar a sua auditoria e os relatórios gerados pela IA, utilize a seguinte escala de classificação baseada em problemas de MVC e SOLID: - -- **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" contendo banco de dados, lógicas complexas e roteamento no mesmo arquivo). -- **HIGH:** Fortes violações do padrão MVC ou princípios SOLID que dificultam muito a manutenção e testes (ex: lógicas de negócio pesadas presas dentro de Controllers, forte acoplamento sem Injeção de Dependência, ou uso de estado global mutável em toda a aplicação). -- **MEDIUM:** Problemas de padronização, duplicação de código ou gargalos de performance moderada (ex: Queries N+1 no banco de dados, uso inadequado de middlewares, validações ausentes nas rotas). -- **LOW:** Melhorias de legibilidade, nomenclatura de variáveis ruins, ou "magic numbers" soltos pelo código. - -### Exemplo de Uso no CLI - -```bash -# Executar a skill no projeto com problemas -cd code-smells-project -claude "/refactor-arch" -``` - -``` -================================ -PHASE 1: PROJECT ANALYSIS -================================ -Language: Python -Framework: Flask 3.1.1 -Dependencies: flask-cors -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 -================================ -``` - -``` -================================ -ARCHITECTURE AUDIT REPORT -================================ -Project: code-smells-project -Stack: Python + Flask -Files: 4 analyzed | ~800 lines of code - -## Summary -CRITICAL: 4 | HIGH: 5 | MEDIUM: 2 | LOW: 3 - -## Findings - -### [CRITICAL] God Class / God Method -File: models.py:1-350 -Description: Arquivo único contém toda lógica de negócio, queries SQL, validação e formatação para 4 domínios diferentes. -Impact: Impossível testar em isolamento, qualquer mudança afeta tudo. -Recommendation: Separar em models e controllers por domínio. - -### [CRITICAL] Hardcoded Credentials -File: app.py:8 -Description: SECRET_KEY hardcoded como 'minha-chave-super-secreta-123' -... - -================================ -Total: 14 findings -================================ - -Phase 2 complete. Proceed with refactoring (Phase 3)? [y/n] -> y -``` - -``` -[... refatoração executada ...] - -================================ -PHASE 3: REFACTORING COMPLETE -================================ -## New Project Structure -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) - -## Validation - ✓ Application boots without errors - ✓ All endpoints respond correctly - ✓ Zero anti-patterns remaining -================================ -``` - -## Tecnologias obrigatórias - -- **Ferramenta:** uma das três opções abaixo (não são aceitas outras ferramentas): - - Claude Code - - Gemini CLI - - OpenAI Codex -- **Recurso:** Custom Skills (ou o equivalente na ferramenta escolhida) -- **Formato dos arquivos de referência:** Markdown -- **Projetos-alvo:** Python/Flask (2 projetos) e Node.js/Express (1 projeto) (fornecidos no repositório base) - -> **Nota sobre a ferramenta:** Os exemplos deste documento usam o Claude Code (`.claude/skills/`) como referência, pois é a ferramenta utilizada no curso. Se você optar por Gemini CLI ou Codex, adapte o nome da pasta e o comando de invocação conforme a convenção dela — o conceito de skill e a estrutura interna (SKILL.md + arquivos de referência) permanecem os mesmos. - -## Requisitos - -### 1. Análise Manual dos Projetos - -Antes de criar a skill, você deve entender os problemas que ela vai resolver. - -**Tarefas:** +--- -- Analisar o projeto `code-smells-project/` (Python/Flask — API de E-commerce) -- Analisar o projeto `ecommerce-api-legacy/` (Node.js/Express — LMS API com fluxo de checkout) -- Analisar o projeto `task-manager-api/` (Python/Flask — API de Task Manager) +## A) Análise Manual + +Análise manual dos 3 projetos para entender os problemas antes de construir a skill. + +### Projeto 1 — `code-smells-project` (Python/Flask — API de E-commerce) + +| # | Severidade | Problema | Onde | Relevância | +|---|---|---|---|---| +| 1 | CRITICAL | `SECRET_KEY` e `DEBUG=True` hardcoded | `app.py:7-8` | Vazamento de segredo; debug em produção | +| 2 | CRITICAL | SQL por concatenação em todas as queries | `models.py` (12 ocorrências) | SQL Injection | +| 3 | CRITICAL | `/admin/query` executa SQL arbitrário sem auth | `app.py:59` | Backdoor total no banco | +| 4 | CRITICAL | `/admin/reset-db` apaga todas as tabelas sem auth | `app.py:47` | Destruição de dados | +| 5 | CRITICAL | God Module — 4 domínios num arquivo | `models.py:1-314` | Impossível testar isoladamente | +| 6 | HIGH | Lógica de negócio nos controllers (email/sms/push via print) | `controllers.py:167-220` | Dificulta testes | +| 7 | HIGH | Cálculo de pedido e estoque no model | `models.py:133-169` | Model não abstrai só dados | +| 8 | HIGH | Estado global mutável (`db_connection`) | `database.py:4` | Comportamento imprevisível | +| 9 | MEDIUM | N+1 nas listagens de pedidos | `models.py:139-192` | Performance | +| 10 | MEDIUM | Senhas em texto puro | `models.py:122-130` | Vulnerabilidade | +| 11 | LOW | Prints de debug espalhados | `controllers.py` | Ruído | +| 12 | LOW | Duplicação `get_pedidos_usuario`/`get_todos_pedidos` | `models.py:171-233` | Manutenção | + +### Projeto 2 — `ecommerce-api-legacy` (Node.js/Express — LMS API com checkout) + +| # | Severidade | Problema | Onde | Relevância | +|---|---|---|---|---| +| 1 | CRITICAL | Credenciais de produção hardcoded | `utils.js:1-7` | Vazamento de secrets | +| 2 | CRITICAL | `badCrypto()` — "hash" invertível (base64) | `utils.js:17` | Senhas decifráveis | +| 3 | CRITICAL | `/api/admin/financial-report` sem autenticação | `AppManager.js:80` | Dados financeiros expostos | +| 4 | CRITICAL | Estado global (`globalCache`, `totalRevenue`) | `utils.js:9-10` | Concorrência | +| 5 | HIGH | God Class `AppManager` — rotas+SQL+pagamento+matrícula | `AppManager.js:4` | Acoplamento total | +| 6 | HIGH | Lógica de checkout inteira dentro do handler HTTP | `AppManager.js:28-78` | Impossível testar | +| 7 | MEDIUM | N+1 no relatório financeiro (loops aninhados) | `AppManager.js:89-127` | Performance | +| 8 | MEDIUM | `DELETE /api/users/:id` deixa dados órfãos | `AppManager.js:131` | Integridade | +| 9 | LOW | Nomes enigmáticos (`usr`, `eml`, `c_id`, `cc`) | `AppManager.js:28` | Legibilidade | +| 10 | LOW | Erros como strings HTML soltas | `AppManager.js` | Padronização | + +### Projeto 3 — `task-manager-api` (Python/Flask — API de Task Manager) + +| # | Severidade | Problema | Onde | Relevância | +|---|---|---|---|---| +| 1 | CRITICAL | `SECRET_KEY` e credenciais SMTP hardcoded | `app.py:13`, `notification_service.py:7-10` | Vazamento de segredos | +| 2 | CRITICAL | `hashlib.md5` para senhas | `models/user.py:29,32` | Quebrável | +| 3 | CRITICAL | `to_dict()` expõe senha nas respostas | `models/user.py:16-25` | Exposição de credenciais | +| 4 | CRITICAL | Estado global de notificações em memória | `notification_service.py:6` | Vazamento de memória | +| 5 | HIGH | Lógica de negócio dentro das rotas | `routes/*` | Rotas grossas | +| 6 | HIGH | N+1 em relatórios (tasks por usuário em loop) | `report_routes.py:55-68` | Performance | +| 7 | HIGH | Token JWT falso (`'fake-jwt-token-...'`) | `user_routes.py:210` | Segurança falsa | +| 8 | HIGH | Lógica de "overdue" duplicada em 5 lugares | `routes/*` | Inconsistência | +| 9 | MEDIUM | `except:` bare sem padronização | `report_routes.py` | Erros difíceis de debugar | +| 10 | MEDIUM | Config implicita (URI, debug, porta) | `app.py:11,34` | Ambiente fixo | +| 11 | LOW | Imports e dependências mortas | vários | Manutenção | +| 12 | LOW | Magic values repetidos (`'pending'`, `'#000000'`) | `routes/*` | Legibilidade | -Para cada projeto, identificar e documentar no mínimo 5 problemas, incluindo pelo menos: +--- -- 1 de severidade CRITICAL ou HIGH -- 2 de severidade MEDIUM -- 2 de severidade LOW +## B) Construção da Skill -Documentar os achados na seção "Análise Manual" do seu `README.md` +### Decisões de design -> **Dica:** Não precisa encontrar todos os problemas — foque nos que têm maior impacto arquitetural. Use os projetos como insumo para entender quais padrões sua skill precisa detectar. +A skill está em `.claude/skills/refactor-arch/` com **SKILL.md** (prompt orquestrador) + **5 arquivos de referência** (conhecimento de domínio), conforme as áreas obrigatórias do desafio: -> **Por que 3 projetos?** Dois são Python/Flask (com níveis de organização diferentes) e um é Node.js/Express. Sua skill precisa funcionar nos 3 para provar que é verdadeiramente agnóstica de tecnologia — lidando tanto com código completamente desestruturado quanto com projetos que já possuem alguma separação de camadas. +| Arquivo | Área de conhecimento | +|---|---| +| `SKILL.md` | Orquestra as 3 fases sequenciais | +| `project-analysis-guidelines.md` | Heurísticas de detecção (linguagem, framework, banco, arquitetura) | +| `antipattern-catalog.md` | Catálogo com 13 anti-patterns classificados por severidade + APIs deprecated | +| `audit-report-template.md` | Template padronizado do relatório (Fase 2) | +| `mvc-guidelines.md` | Regras do MVC alvo (models, controllers, routes, config, middlewares) | +| `refactor-playbook.md` | 8+ padrões de transformação com exemplos antes/depois | -### 2. Criação da Skill +### Anti-patterns no catálogo (13, severidades distribuídas) -Agora que você conhece os problemas, crie uma skill que os detecte, gere um relatório de auditoria e corrija automaticamente. +- **CRITICAL (4):** God Class/Module, Hardcoded Secrets, SQL Injection, Backdoor/Unsafe Admin Query +- **HIGH (3):** Business Logic in Controller/Route, Global Mutable State, Deprecated/Vulnerable API +- **MEDIUM (3):** N+1 Query, Mixed Responsibilities, Missing Input Validation +- **LOW (3):** Magic Values, Duplicate Code, Implicit Configuration -**Tarefas:** +**APIs deprecated cobertas:** `badCrypto`/hash MD5 → `bcrypt`/`werkzeug`; `body-parser` → `express.json()`; `DEBUG=True` no código → env; SQL concatenação → parametrização. -Criar a skill dentro do projeto `code-smells-project/` e implementar o SKILL.md com 3 fases sequenciais: +### Como garanti o agnosticismo -- **Fase 1 — Análise:** Detectar stack, mapear arquitetura atual, imprimir resumo -- **Fase 2 — Auditoria:** Cruzar código contra catálogo de anti-patterns, gerar relatório, pedir confirmação -- **Fase 3 — Refatoração:** Reestruturar para o padrão MVC, validar que funciona +- **Fase 1** identifica a stack por sinais (imports, package.json, requirements) em vez de assumir uma tecnologia. +- **Fase 2** cruza contra um catálogo de padrões (não de arquivos específicos), usando heurísticas genéricas. +- **Fase 3** segue guidelines de camadas (models/controllers/routes/config/middlewares) que mapeiam 1:1 entre Flask (Blueprints) e Express (Router). +- Testei a mesma skill (com cópia literal) nos 3 projetos — dois Python/Flask em estágios diferentes e um Node/Express. -Criar arquivos de referência em Markdown que forneçam à skill o conhecimento necessário para executar as 3 fases. Os arquivos devem cobrir **obrigatoriamente** as seguintes áreas de conhecimento: +### Desafios encontrados e soluções -| Área de conhecimento | O que deve conter | -|---|---| -| Análise de projeto | Heurísticas para detecção de linguagem, framework, banco de dados e mapeamento de arquitetura | -| Catálogo de anti-patterns | Anti-patterns com sinais de detecção e classificação de severidade | -| Template de relatório | Formato padronizado do relatório de auditoria (Fase 2) | -| Guidelines de arquitetura | Regras do padrão MVC alvo (camadas Models, Views/Routes e Controllers, responsabilidades de cada uma) | -| Playbook de refatoração | Padrões concretos de transformação para cada anti-pattern (com exemplos de código) | +1. **Projeto 2 tinha o SQL já parametrizado** — a skill precisava não "inventar" SQL injection. Resolvi auditando com honestidade (finding HIGH de "N+1 + lógica no handler" em vez de fabricar CRITICAL de injeção). +2. **Projeto 3 já era parcialmente organizado** — a refatoração focou em extrair lógica das rotas para `services/`, padronizar erros e centralizar config, sem reescrever o que já estava bom. +3. **Validação funcional** — cada projeto teve que **bootar e responder os endpoints originais** após a Fase 3 (não basta "compilar"). +4. **Consistência de segredos/senhas** — ao trocar hash de senha (MD5→pbkdf2/bcrypt), o seed precisou ser atualizado junto para o login continuar funcionando (ex: projeto 3). -> **Nota:** Você tem liberdade para organizar os arquivos de referência como preferir — pode usar os nomes e a quantidade de arquivos que fizer sentido para sua skill. O importante é que todas as 5 áreas de conhecimento estejam cobertas. O nome da skill (`refactor-arch`) e o arquivo `SKILL.md` são obrigatórios e não devem ser alterados. O path da skill segue a convenção da ferramenta escolhida (no Claude Code, por exemplo, é `.claude/skills/refactor-arch/`). +--- -**Requisitos da skill:** +## C) Resultados -- Deve ser agnóstica de tecnologia — deve funcionar corretamente nos 3 projetos fornecidos, independente da stack ou nível de organização -- O catálogo de anti-patterns deve conter no mínimo 8 anti-patterns com severidade distribuída (CRITICAL, HIGH, MEDIUM, LOW) -- O catálogo deve incluir detecção de APIs deprecated — identificar uso de APIs obsoletas e recomendar o equivalente moderno -- O playbook deve ter no mínimo 8 padrões de transformação com exemplos de código antes/depois -- A Fase 2 deve pausar e pedir confirmação antes de modificar qualquer arquivo -- A Fase 3 deve validar o resultado (boot da aplicação + endpoints funcionando) +### Resumo dos relatórios de auditoria -### 3. Execução da Skill +| Projeto | CRITICAL | HIGH | MEDIUM | LOW | Total | Relatório | +|---|---|---|---|---|---|---| +| 1. code-smells-project | 5 | 4 | 4 | 3 | 16 | `reports/audit-project-1.md` | +| 2. ecommerce-api-legacy | 5 | 3 | 3 | 2 | 13 | `reports/audit-project-2.md` | +| 3. task-manager-api | 4 | 4 | 4 | 2 | 14 | `reports/audit-project-3.md` | -Execute sua skill nos 3 projetos e valide que ela funciona em todas as stacks. +Todos os projetos atingiram os critérios de aceite: **>= 5 findings**, **>= 1 CRITICAL/HIGH**, **Fase 1 correta**, **aplicação funcionando pós-refatoração**. -#### Projeto 1 — code-smells-project (Python/Flask) +### Antes × Depois — Estrutura -Invocar a skill no Claude Code: +**Projeto 1 — code-smells-project** -```bash -claude "/refactor-arch" ``` - -> **Nota:** O comando acima é o exemplo com Claude Code. Se você estiver usando Gemini CLI ou Codex, utilize o comando equivalente para invocar uma skill na sua ferramenta. - -- Verificar que a Fase 1 detecta corretamente a stack e imprime o resumo -- Verificar que a Fase 2 encontra no mínimo 5 dos problemas documentados na sua análise manual -- Confirmar a execução da Fase 3 -- Verificar que a Fase 3: - - Cria a estrutura de diretórios baseada em MVC - - A aplicação inicia sem erros - - Os endpoints originais continuam respondendo -- Salvar o relatório de auditoria (output da Fase 2) em `reports/audit-project-1.md` -- Commitar o código refatorado do projeto no repositório - -#### Projeto 2 — ecommerce-api-legacy (Node.js/Express) - -Prove que sua skill é reutilizável em outro projeto de backend, mas com stack diferente. - -- Copiar a pasta `.claude/skills/refactor-arch/` para dentro de `ecommerce-api-legacy/` -- Invocar a skill: - -```bash -cd ../ecommerce-api-legacy -claude "/refactor-arch" +ANTES (monolítico) DEPOIS (MVC) +app.py (rotas + admin) app.py (composition root, blueprints) +controllers.py (handlers grossos) config/settings.py +models.py (God module) models/ → produto_model, usuario_model, pedido_model +database.py (global db) services/ → produto_service, pedido_service, usuario_service + controllers/ → produto, usuario, pedido + routes/ → produto_routes, usuario_routes, pedido_routes, system_routes + middlewares/error_handler.py + database.py (getter lazy, schema/seed centralizados) ``` -- Verificar que as 3 fases executam corretamente neste projeto -- Salvar o relatório em `reports/audit-project-2.md` -- Commitar o código refatorado do projeto no repositório +**Projeto 2 — ecommerce-api-legacy** -#### Projeto 3 — task-manager-api (Python/Flask) - -Agora o teste com um projeto Python/Flask que já possui alguma organização de camadas (models, routes, services, utils). - -- Copiar a pasta `.claude/skills/refactor-arch/` para dentro de `task-manager-api/` -- Invocar a skill: - -```bash -cd ../task-manager-api -claude "/refactor-arch" ``` - -- Verificar que: - - A Fase 1 detecta corretamente Python/Flask como stack e identifica o domínio de Task Manager - - A Fase 2 identifica problemas mesmo em um projeto parcialmente organizado - - A Fase 3 melhora a estrutura sem quebrar a aplicação (todos os endpoints devem continuar respondendo) -- Salvar o relatório em `reports/audit-project-3.md` -- Commitar o código refatorado do projeto no repositório - -> **Nota:** Este projeto já possui alguma separação de camadas, mas isso não significa que a arquitetura está adequada. A skill deve identificar tanto problemas de código (segurança, performance, qualidade) quanto oportunidades de melhoria arquitetural. Se houver mudanças estruturais necessárias, a skill deve propô-las e executá-las. - -#### Validação - -Para cada projeto refatorado, valide o seguinte checklist: - -```markdown -## Checklist de Validação - -### 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 - -### 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 - -### 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 +ANTES DEPOIS (MVC) +src/app.js (Express + listen) src/app.js (composition root, middleware de erro) +src/AppManager.js (God class) src/config/index.js +src/utils.js (secrets + badCrypto) src/models/ → db, userModel, courseModel + src/services/ → checkoutService, reportService + src/controllers/ → checkout, report, user + src/routes/index.js (checkout + admin auth) + src/middlewares/ → authMiddleware, errorHandler + src/utils/security.js (bcrypt + cache scoped) ``` -> **Dica:** Se a skill não detectou problemas suficientes ou a refatoração falhou, ajuste os arquivos de referência e execute novamente. É normal precisar de 2-4 iterações. - -## Entregável - -Repositório público no GitHub (fork do repositório base) contendo: - -- Skill completa em `.claude/skills/refactor-arch/` (dentro dos 3 projetos) -- Código refatorado dos 3 projetos (resultado da execução da Fase 3, commitado no repositório) -- Relatórios de auditoria em `reports/` (3 arquivos) -- `README.md` atualizado - -### Estrutura do repositório - -Faça um fork do repositório base contendo os três projetos com code smells. - -> **Nota:** A estrutura abaixo usa Claude Code como exemplo (`.claude/skills/`). Se estiver usando outra ferramenta, adapte os caminhos conforme a convenção dela. +**Projeto 3 — task-manager-api** ``` -desafio-skills/ -├── README.md # Sua documentação -│ -├── code-smells-project/ # Projeto 1 — Python/Flask (API de E-commerce) -│ ├── .claude/ -│ │ └── skills/ -│ │ └── refactor-arch/ # ← SUA SKILL AQUI -│ │ ├── SKILL.md -│ │ └── (arquivos de referência) -│ ├── app.py -│ ├── controllers.py -│ ├── models.py -│ ├── database.py -│ └── requirements.txt -│ -├── ecommerce-api-legacy/ # Projeto 2 — Node.js/Express (LMS API com checkout) -│ ├── .claude/ -│ │ └── skills/ -│ │ └── refactor-arch/ # ← CÓPIA DA SKILL -│ │ └── ... -│ ├── src/ -│ │ ├── app.js -│ │ ├── AppManager.js -│ │ └── utils.js -│ ├── api.http -│ └── package.json -│ -├── task-manager-api/ # Projeto 3 — Python/Flask (API de Task Manager) -│ ├── .claude/ -│ │ └── skills/ -│ │ └── refactor-arch/ # ← CÓPIA DA SKILL -│ │ └── ... -│ ├── app.py -│ ├── database.py -│ ├── seed.py -│ ├── requirements.txt -│ ├── models/ -│ ├── routes/ -│ ├── services/ -│ └── utils/ -│ -└── reports/ # Relatórios gerados - ├── audit-project-1.md # Saída da Fase 2 no projeto 1 - ├── audit-project-2.md # Saída da Fase 2 no projeto 2 - └── audit-project-3.md # Saída da Fase 2 no projeto 3 +ANTES (parcial) DEPOIS (MVC completo) +routes/* com toda lógica routes/* finas (só delegam) +models/user.py MD5 + senha exposta models/user.py (werkzeug, to_dict sem password) +app.py com secrets config/settings.py (env) +notification com credenciais services/ → task, user, report, notification + middlewares/error_handler.py (ApiError + decorator) + app.py (create_app + register handlers) ``` -**O que você vai criar:** - -- `.claude/skills/refactor-arch/` — A skill completa (SKILL.md + arquivos de referência) -- Código refatorado dos 3 projetos — resultado da execução da Fase 3, commitado no repositório -- `reports/audit-project-{1,2,3}.md` — Relatório de auditoria de cada projeto -- `README.md` — Documentação do seu processo - -**O que já vem pronto:** - -- `code-smells-project/` — API de E-commerce Python/Flask com code smells intencionais -- `ecommerce-api-legacy/` — LMS API Node.js/Express (com fluxo de checkout) e problemas de implementação -- `task-manager-api/` — API de Task Manager Python/Flask com organização parcial e problemas de segurança/qualidade - -> **Dica:** Cada projeto contém problemas intencionais de diferentes severidades (CRITICAL, HIGH, MEDIUM, LOW), incluindo falhas de segurança, violações arquiteturais e problemas de qualidade de código. Parte do desafio é identificá-los por conta própria através da análise manual do código. - -### README.md deve conter - -**A) Seção "Análise Manual":** - -- Lista dos problemas identificados manualmente em cada projeto -- Classificação por severidade -- Justificativa de por que cada problema é relevante - -**B) Seção "Construção da Skill":** - -- Decisões de design: como estruturou o SKILL.md e os arquivos de referência -- Quais anti-patterns incluiu no catálogo e por quê -- Como garantiu que a skill é agnóstica de tecnologia -- Desafios encontrados e como resolveu - -**C) Seção "Resultados":** +### Checklist de Validação + +#### Projeto 1 — code-smells-project +**Fase 1 — Análise** +- [x] Linguagem detectada corretamente (Python) +- [x] Framework detectado corretamente (Flask 3.1.1) +- [x] Domínio descrito corretamente (E-commerce API) +- [x] Nº de arquivos condiz com a realidade (4 arquivos / ~780 LOC) + +**Fase 2 — Auditoria** +- [x] Relatório segue o template +- [x] Cada finding tem arquivo e linhas exatos +- [x] Findings ordenados por severidade (CRITICAL → LOW) +- [x] 16 findings (>= 5) +- [x] Detecção de APIs deprecated (DEBUG no código, SQL concatenação) +- [x] Pausa e pede confirmação antes da Fase 3 + +**Fase 3 — Refatoração** +- [x] Estrutura MVC (`models/`, `controllers/`, `routes/`, `services/`, `middlewares/`, `config/`) +- [x] Config extraída para `config/settings.py` (sem hardcoded) +- [x] Models abstraem dados (`models/*_model.py` com parametrização) +- [x] Views/Routes separadas (Blueprints) +- [x] Controllers concentram o fluxo +- [x] Error handling centralizado (`middlewares/error_handler.py`) +- [x] Entry point claro (`app.py` = create_app + boot) +- [x] Aplicação inicia sem erros (boot verificado) +- [x] Endpoints originais respondem (health, produtos CRUD, busca, pedidos, login, relatório — todos 200/201) + +#### Projeto 2 — ecommerce-api-legacy +**Fase 1 — Análise** +- [x] Linguagem (JavaScript/Node) +- [x] Framework (Express 4.18.2) +- [x] Domínio (LMS API com checkout) +- [x] Nº de arquivos (3 arquivos / ~278 LOC) + +**Fase 2 — Auditoria** +- [x] Template do relatório +- [x] Arquivo + linhas exatos +- [x] Ordenação por severidade +- [x] 13 findings (>= 5) +- [x] Deprecated APIs (badCrypto) +- [x] Confirmação antes da Fase 3 + +**Fase 3 — Refatoração** +- [x] Estrutura MVC (`models/`, `services/`, `controllers/`, `routes/`, `middlewares/`, `config/`) +- [x] Config extraída (`src/config/index.js` via env) +- [x] Models abstraem dados (`userModel`, `courseModel`) +- [x] Routes finas (`src/routes/index.js`) +- [x] Controllers concentram fluxo +- [x] Error handling centralizado (`errorHandler.js` + 404/500) +- [x] Entry point claro (`src/app.js`) +- [x] App inicia sem erros +- [x] Endpoints originais respondem (checkout sucesso 200, pagamento recusado 400, relatório 401 sem token / 200 com token, delete user) +- [x] Admin protegido por auth (novo) + +#### Projeto 3 — task-manager-api +**Fase 1 — Análise** +- [x] Linguagem (Python) +- [x] Framework (Flask 3.0.0 + SQLAlchemy) +- [x] Domínio (Task Manager) +- [x] Nº de arquivos (15 arquivos / ~1600 LOC) + +**Fase 2 — Auditoria** +- [x] Template +- [x] Arquivo + linhas exatos +- [x] Ordenação por severidade +- [x] 14 findings (>= 5) +- [x] Deprecated APIs (MD5, debug config) +- [x] Confirmação antes da Fase 3 + +**Fase 3 — Refatoração** +- [x] Estrutura MVC com `config/`, `services/`, `middlewares/` +- [x] Config extraída (`config/settings.py` via env) +- [x] Models abstraem dados (werkzeug hash, `is_overdue` centralizada) +- [x] Routes finas (delegam a services) +- [x] Controllers/serviços concentram o fluxo +- [x] Error handling centralizado (`middlewares/error_handler.py`) +- [x] Entry point claro (`create_app()`) +- [x] App inicia sem erros +- [x] Endpoints originais respondem (tasks list/get/stats, reports summary, users, login, categories — todos 200/201) +- [x] Senha não mais exposta nas respostas (fix do CRITICAL) + +### Logs de validação + +**Projeto 1 — boots + endpoints:** +``` +APP BOOT OK → 17 rotas registradas +GET /health → 200 {"status":"ok","database":"connected"} +GET /produtos → 200 (lista 10 produtos) +GET /produtos/1 → 200 {"dados":{...},"sucesso":true} +POST /produtos → 201 {"dados":{"id":11},"mensagem":"Produto criado"} +POST /login → 200 {"dados":{...},"mensagem":"Login OK"} +POST /pedidos → 201 {"pedido_id":1,"total":179.8} +PUT /pedidos/1/status → 200 {"mensagem":"Status atualizado"} +POST /admin/query → 404 (backdoor removido) +``` -- Resumo dos relatórios de auditoria dos 3 projetos (quantos findings por severidade em cada) -- Comparação antes/depois da estrutura de cada projeto -- Checklist de validação preenchido para cada projeto -- Screenshots ou logs mostrando as aplicações rodando após refatoração -- Observações sobre como a skill se comportou em stacks diferentes +**Projeto 2 — boots + endpoints:** +``` +LMS rodando na porta 3000... +POST /api/checkout → 200 {"msg":"Sucesso","enrollment_id":2} +POST /api/checkout (card 5...) → 400 {"error":"Pagamento recusado"} +POST /api/checkout (senha fraca) → 400 +GET /api/admin/financial-report → 401 (sem token) / 200 (com ADMIN_TOKEN) +DELETE /api/users/2 → 200 {"msg":"Usuário removido ..."} +``` -**D) Seção "Como Executar":** +**Projeto 3 — boots + endpoints:** +``` +APP BOOT OK → 22 rotas registradas +GET /health → 200 {"status":"ok",...} +GET /tasks → 200 (10 tasks, com overdue derivado) +GET /tasks/stats → 200 {"total":10,"done":1,"overdue":2,...} +POST /tasks → 201 (nova task) +PUT /tasks/1 → 200 (status done) +GET /reports/summary → 200 (agregado sem N+1 pesado) +POST /login → 200 {"message":"Login realizado com sucesso",...} +POST /login (errada) → 401 +POST /users (senha fraca) → 400 +GET /users → 200 (sem password nos payloads) +``` -- Pré-requisitos (a ferramenta escolhida — Claude Code, Gemini CLI ou Codex — instalada e configurada) -- Comandos para executar a skill em cada projeto -- Como validar que a refatoração funcionou +### Observações: comportamento da skill em stacks diferentes -### Ordem de execução sugerida +- **Flask (Python)** — a skill produziu **Blueprints** por domínio e `create_app()` (application factory). A validação usou `app.url_map` para confirmar as rotas. +- **Express (Node)** — a skill produziu **`express.Router()`** + middleware de auth e erro. O padrão `async/await` + `next(err)` substituiu callbacks aninhados. +- A skill se adaptou ao nível de organização: no monolito (P1) fez split completo; no parcial (P3) fez extração de services + padronização, sem destruir a estrutura existente. +- A mesma cópia da skill (`.claude/skills/refactor-arch/`) foi usada nos 3 projetos — prova de agnosticismo. -**1. Analisar os projetos manualmente** +--- -Leia o código dos três projetos e documente os problemas encontrados. +## D) Como Executar -**2. Criar a skill** +### Pré-requisitos -Escreva o SKILL.md e os arquivos de referência. +- **Claude Code** instalado e autenticado (`claude --version`) +- Para Python: `python3` + `pip` + venv com `requirements.txt` de cada projeto +- Para Node: `node >= 18` + `npm install` -**3. Executar nos 3 projetos** +### Executar a skill em cada projeto ```bash -# Projeto 1 +# Projeto 1 — Python/Flask (E-commerce) cd code-smells-project claude "/refactor-arch" -# Projeto 2 +# Projeto 2 — Node.js/Express (LMS/checkout) cd ../ecommerce-api-legacy claude "/refactor-arch" -# Projeto 3 +# Projeto 3 — Python/Flask (Task Manager) cd ../task-manager-api claude "/refactor-arch" ``` -Salve a saída da Fase 2 de cada projeto em `reports/audit-project-{1,2,3}.md`. - -**4. Iterar** - -Se a skill não detectou problemas suficientes ou a refatoração falhou, ajuste os arquivos de referência e execute novamente. É normal precisar de 2-4 iterações. - -## Critérios de Aceite - -A skill deve atingir os seguintes mínimos em **todos os 3 projetos**: +A skill executa 3 fases: +1. **Fase 1** — análise de stack/arquitetura/domínio (sem modificar arquivos). +2. **Fase 2** — auditoria e relatório no template, com arquivo:linha e severidades. +3. **Fase 3** — confirmação (`y`) → refatoração MVC + validação. -| Critério | Requisito | -|---|---| -| Fase 1 detecta stack corretamente | OBRIGATÓRIO (3/3 projetos) | -| Fase 2 encontra >= 5 findings | OBRIGATÓRIO (3/3 projetos) | -| Fase 2 inclui pelo menos 1 CRITICAL ou HIGH | OBRIGATÓRIO (3/3 projetos) | -| Fase 3 aplicação funciona após refatoração | OBRIGATÓRIO (3/3 projetos) | - -**IMPORTANTE:** Todos os critérios devem ser atingidos nos 3 projetos, não apenas em um! +### Como validar que a refatoração funcionou -> **Sobre o projeto 3 (task-manager-api):** Este projeto já possui alguma organização. "aplicação funciona" significa que a API inicia sem erros e todos os endpoints continuam respondendo corretamente. +**Projeto 1:** +```bash +cd code-smells-project +.venv/bin/python app.py # sobe em http://localhost:5000 +curl localhost:5000/health # {"status":"ok",...} +curl localhost:5000/produtos # lista produtos +``` -## Referências +**Projeto 2:** +```bash +cd ecommerce-api-legacy +npm install +npm start # sobe em http://localhost:3000 +curl -X POST localhost:3000/api/checkout -H 'Content-Type: application/json' \ + -d '{"usr":"Guilherme","eml":"g@e.com","pwd":"senhaforte","c_id":2,"card":"4111222233334444"}' +ADMIN_TOKEN=secret node src/app.js # define token para o relatório admin +``` -- [Claude Code: Skills](https://docs.anthropic.com/en/docs/claude-code/skills) — Documentação oficial sobre como criar e estruturar Skills -- [Claude Code: Overview](https://docs.anthropic.com/en/docs/claude-code/overview) — Visão geral do Claude Code e suas capacidades -- [The Complete Guide to Building Skills for Claude (PDF)](https://resources.anthropic.com/hubfs/The-Complete-Guide-to-Building-Skill-for-Claude.pdf) — Guia completo da Anthropic sobre construção de Skills -- [Equipping Agents for the Real World with Agent Skills](https://claude.com/blog/equipping-agents-for-the-real-world-with-agent-skills) — Blog oficial da Anthropic sobre Agent Skills +**Projeto 3:** +```bash +cd task-manager-api +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/python seed.py # popula o banco (opcional, mas recomendado) +.venv/bin/python app.py # sobe em http://localhost:5000 +curl localhost:5000/tasks +curl localhost:5000/reports/summary +``` --- -## Dicas Finais +## Estrutura do repositório -- **Comece pela análise manual** — entender os problemas profundamente é essencial para criar uma skill que os detecte. -- **O SKILL.md é um prompt** — ele instrui o agente sobre o que fazer, enquanto os arquivos de referência fornecem o conhecimento de domínio. -- **Seja específico nos sinais de detecção** — "código ruim" não ajuda; "query SQL dentro de loop for" é acionável. -- **Teste incrementalmente** — não tente criar a skill perfeita de primeira. -- **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 +``` +mba-ia-refactor-projects-skill/ +├── README.md # ← este documento +├── reports/ +│ ├── audit-project-1.md # Auditoria Projeto 1 (16 findings) +│ ├── audit-project-2.md # Auditoria Projeto 2 (13 findings) +│ └── audit-project-3.md # Auditoria Projeto 3 (14 findings) +├── code-smells-project/ # Projeto 1 — Python/Flask (E-commerce) +│ ├── .claude/skills/refactor-arch/ # Skill (original) +│ ├── app.py config/ models/ services/ +│ ├── controllers/ routes/ middlewares/ +│ └── database.py +├── ecommerce-api-legacy/ # Projeto 2 — Node.js/Express (LMS) +│ ├── .claude/skills/refactor-arch/ # Skill (cópia) +│ └── src/ config/ models/ services/ controllers/ routes/ middlewares/ utils/ +└── task-manager-api/ # Projeto 3 — Python/Flask (Task Manager) + ├── .claude/skills/refactor-arch/ # Skill (cópia) + └── app.py config/ models/ services/ routes/ middlewares/ seed.py +``` diff --git a/code-smells-project/.claude/skills/refactor-arch/SKILL.md b/code-smells-project/.claude/skills/refactor-arch/SKILL.md new file mode 100644 index 000000000..7fbec6f93 --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch/SKILL.md @@ -0,0 +1,89 @@ +# refactor-arch Skill + +Esta skill automatiza a análise, auditoria e refatoração de projetos legados para o padrão MVC. + +## Objetivo +- Detectar linguagem, framework, arquitetura e domínio do projeto. +- Identificar anti-patterns e code smells com severidade e localização exata. +- Gerar um relatório de auditoria estruturado. +- Refatorar o projeto para uma arquitetura MVC clara. +- Validar que a aplicação inicia e mantém os endpoints originais. + +## Como usar +1. Execute a skill no diretório do projeto. +2. A skill faz 3 fases sequenciais. +3. A Fase 2 pausa para confirmação antes de qualquer modificação. +4. A Fase 3 aplica refatorações e validações. + +## Fase 1 — Análise +1. Identifique a linguagem principal do projeto. +2. Detecte framework e bibliotecas principais. +3. Liste todos os arquivos de código fonte relevantes. +4. Mapeie a arquitetura atual: monolito, camadas parciais, modelo MVC, etc. +5. Detecte banco de dados e método de persistência. +6. Produza um resumo com: + - Language + - Framework + - Dependencies + - Domain + - Architecture + - Source files analyzed + - DB tables/entities + +Use `project-analysis-guidelines.md` para as heurísticas de identificação. + +## Fase 2 — Auditoria +1. Use `antipattern-catalog.md` para detectar anti-patterns, vulnerabilidades e APIs deprecated. +2. Gere um relatório seguindo o template em `audit-report-template.md`. +3. O relatório deve conter: + - Summary por severidade + - Findings com: + - severidade + - arquivo e linhas exatas + - descrição + - impacto + - recomendação +4. Ordene findings de CRITICAL para LOW. +5. Inclua pelo menos 5 findings, com ao menos 1 HIGH ou CRITICAL. +6. Pare e peça confirmação antes de executar a Fase 3. + +A resposta da Fase 2 deve terminar com: + +> Phase 2 complete. Proceed with refactoring (Phase 3)? [y/n] + +## Fase 3 — Refatoração +1. Use `mvc-guidelines.md` como base para reestruturar o projeto. +2. Use `refactor-playbook.md` para aplicar transformações concretas por anti-pattern. +3. Gere uma nova estrutura consistente com: + - config/settings + - models/ + - controllers/ + - views/routes/ + - middlewares/ (tratamento de erros, validação) + - entrypoint claro (app.js, server.js, main.py) +4. Extraia configurações hardcoded para arquivos de config. +5. Separe responsabilidades: + - Models: abstração de dados e persistência + - Controllers: fluxo de aplicação e orquestração + - Routes: expor endpoints e delegar ao controller + - Middlewares: validação, erros e segurança +6. Remova endpoints inseguros ou debug/backdoor quando não fizerem parte do domínio da API. +7. Substitua SQL construído por concatenação por queries parametrizadas ou ORM. +8. Remova APIs deprecated e recomende equivalentes modernos. + +## Validação +1. A aplicação deve bootar sem erros. +2. Verifique pelo menos um endpoint original. +3. Confirme a estrutura de diretórios e a ausência de anti-patterns críticos. +4. A skill deve descrever as mudanças realizadas e os arquivos alterados. + +## Regras Gerais +- Seja agnóstico de tecnologia. A skill deve funcionar para Python/Flask e Node.js/Express. +- Não modifique nada antes da confirmação da Fase 2. +- Use os arquivos de referência para todas as decisões. +- Priorize segurança, separação de responsabilidades e manutenção. +- Se não for possível validar a aplicação completamente, explique claramente o motivo no final. + +## Regra de Ouro (Fase 3 vs Fase 2) +- **TODA** falha ou vulnerabilidade apontada no relatório da Fase 2, incluindo achados em scripts de mock, seeds ou tokens falsos (Fake JWT), **DEVE** ser ativamente corrigida no código durante a Fase 3. +- Não deixe pendências descritas no relatório vazarem para o código final. Verifique duplamente: senhas no banco (seeds inclusive) e tokens (devem ser JWTs assinados de verdade, não placeholders). diff --git a/code-smells-project/.claude/skills/refactor-arch/antipattern-catalog.md b/code-smells-project/.claude/skills/refactor-arch/antipattern-catalog.md new file mode 100644 index 000000000..dfc788e6b --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch/antipattern-catalog.md @@ -0,0 +1,71 @@ +# Antipattern Catalog + +## Objetivo +Catalogar anti-patterns, vulnerabilidades e sinais de APIs deprecated com severidade, para uso na Fase 2 de auditoria. + +### CRITICAL +- **God Class / God Module** + - Sinais: arquivo único contém rotas, lógica de negócio, acesso a dados e validação. + - Impacto: impossível testar isoladamente, altíssimo acoplamento. + +- **Hardcoded Secrets** + - Sinais: `SECRET_KEY`, senhas, chaves API, credenciais ou informações sensíveis em código. + - Impacto: vazamento de segredos, ambiente inseguro. + +- **SQL Injection / Concatenation SQL** + - Sinais: queries construídas com concatenação de strings, interpolação direta de inputs. + - Impacto: execução de SQL arbitrário. + +- **Backdoor / Unsafe Admin Query** + - Sinais: endpoints que executam SQL arbitrário ou resetam DB sem autenticação. + - Impacto: falha de segurança grave. + +### HIGH +- **Business Logic in Controller/Route** + - Sinais: cálculos, regras de domínio e validações pesadas dentro de rotas ou controllers. + - Impacto: dificulta testes e manutenção. + +- **Global Mutable State** + - Sinais: caches globais, variáveis de configuração mutáveis ou singletons mal definidos. + - Impacto: comportamento imprevisível em runtime. + +- **Deprecated / Vulnerable API Usage** + - Sinais: uso de métodos antigos, `badCrypto`, `fs.existsSync` sem tratamento, `require.extensions`, ou APIs sem suporte. + - Impacto: risco de quebra futura e segurança reduzida. + +### MEDIUM +- **N+1 Query** + - Sinais: loops que fazem consultas por item, `for`/`foreach` que executam consultas SQL ou ORM repetidas. + - Impacto: degradação de performance. + +- **Mixed Responsibilities** + - Sinais: rotas que também atualizam modelos, manipulam respostas e fazem persistência direta. + - Impacto: acoplamento e duplicação. + +- **Lack of Validation / Missing Input Checks** + - Sinais: parâmetros usados sem validação adequada. + - Impacto: erros, comportamento inesperado e possíveis vulnerabilidades. + +### LOW +- **Magic Values / Poor Naming** + - Sinais: strings não documentadas, variáveis sem significado, números mágicos. + - Impacto: legibilidade reduzida. + +- **Duplicate Code** + - Sinais: blocos repetidos de validação ou mapeamento. + - Impacto: manutenção dificultada. + +- **Implicit Configuration** + - Sinais: configurações definidas diretamente no código (porta, URI, debug). + - Impacto: dificuldade de mudar ambiente. + +## Deprecated API Examples +- Node.js `badCrypto` custom hashing → use `bcrypt` ou `crypto.pbkdf2`. +- Express: `app.use(bodyParser.json())` / `body-parser` → use `express.json()`. +- Flask: `app.config['DEBUG'] = True` em produção / `Flask` debug no código → use ambiente e `FLASK_ENV`. +- SQLite string concatenation → use query parametrizada ou ORM. + +## Como usar +- Compare padrões do código com os sinais acima. +- Para cada finding, inclua severidade e recomendação de correção. +- Se não houver sinal exato, use julgamento conservador baseado em acoplamento e risco. diff --git a/code-smells-project/.claude/skills/refactor-arch/audit-report-template.md b/code-smells-project/.claude/skills/refactor-arch/audit-report-template.md new file mode 100644 index 000000000..2d6660c0c --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch/audit-report-template.md @@ -0,0 +1,33 @@ +# Audit Report Template + +Use este template para gerar os relatórios da Fase 2. + +--- +ARCHITECTURE AUDIT REPORT +--- +Project: {{project_name}} +Stack: {{language}} + {{framework}} +Files: {{file_count}} analyzed | {{line_count}} lines approx. + +## Summary +CRITICAL: {{critical_count}} | HIGH: {{high_count}} | MEDIUM: {{medium_count}} | LOW: {{low_count}} + +## Findings + +{{#each findings}} +### [{{severity}}] {{title}} +File: {{file}}:{{line_start}}-{{line_end}} +Description: {{description}} +Impact: {{impact}} +Recommendation: {{recommendation}} + +{{/each}} +--- +Total: {{total_findings}} findings +--- + +Notes: +- Ordene findings por severidade decrescente. +- Use linhas exatas quando possível. +- Inclua pelo menos um finding por severidade sempre que aplicável. +- Se o projeto usa APIs deprecated, destaque isso como parte da auditoria. diff --git a/code-smells-project/.claude/skills/refactor-arch/mvc-guidelines.md b/code-smells-project/.claude/skills/refactor-arch/mvc-guidelines.md new file mode 100644 index 000000000..278859193 --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch/mvc-guidelines.md @@ -0,0 +1,65 @@ +# MVC Guidelines + +## Objetivo +Definir regras claras para refatorar qualquer projeto legado para uma arquitetura MVC sustentável. + +## Camadas MVC +### Models +Responsabilidades: +- Abstrair acesso a dados e persistência. +- Definir entidades e mapeamento de dados. +- Validar regras de integridade de dados de baixo nível. + +Exemplos: +- Python: classes ou funções em `models/` que executam queries parametrizadas ou usam ORM. +- Node.js: objetos/repositórios que encapsulam `db.query` e retornam dados. + +### Controllers +Responsabilidades: +- Orquestrar a lógica de aplicação. +- Chamar models e serviços. +- Tratar entradas e resultados antes de enviar resposta. +- Delegar tratamento de erros para middleware. + +Exemplo: +- `controllers/produto_controller.py` ou `controllers/checkoutController.js`. + +### Views / Routes +Responsabilidades: +- Expor endpoints HTTP. +- Mapear rotas para controllers. +- Não conter lógica de negócios ou regras complexas. + +Exemplo: +- rotas Flask com Blueprint ou `app.add_url_rule` +- `express.Router()` que importa controllers + +## Configuração +- Mova segredos e URIs para `config/settings.py` ou `config/index.js`. +- Use variáveis de ambiente para valores sensíveis. +- Não deixe `SECRET_KEY`, `DB_URI`, `API_KEY` codificados. + +## Middlewares e Tratamento de Erros +- Centralize captura de exceções. +- Crie middleware para validação e erros. +- Evite `try/except` ou `try/catch` espalhados que repetem mensagens. + +## Regras de Refatoração +- Rotas devem ser finas: validação mínima + chamada de controller. +- Controllers devem ser responsáveis pelo fluxo, não por persistência detalhada. +- Models devem ser responsáveis pela persistência e retorno de dados em formatos simples. +- Normalizar respostas JSON / status HTTP de forma consistente. +- Evitar dependências circulares entre camadas. + +## Estrutura mínima sugerida +- `config/` +- `models/` +- `controllers/` +- `routes/` ou `views/` +- `middlewares/` +- `app.py` / `server.js` + +## Validação após refatoração +- A aplicação deve iniciar sem erros. +- Um endpoint representativo deve responder corretamente. +- O projeto deve estar mais modular e com responsabilidade separada. diff --git a/code-smells-project/.claude/skills/refactor-arch/project-analysis-guidelines.md b/code-smells-project/.claude/skills/refactor-arch/project-analysis-guidelines.md new file mode 100644 index 000000000..f2a90d005 --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch/project-analysis-guidelines.md @@ -0,0 +1,54 @@ +# Project Analysis Guidelines + +## Objetivo +Fornecer regras e heurísticas para detectar linguagem, framework, banco de dados e arquitetura de um projeto legado. + +## Linguagem e Framework +### Python +- Detecte `import flask`, `from flask import`, `Flask(__name__)` → Flask. +- Detecte `from flask_sqlalchemy import SQLAlchemy` → Flask + SQLAlchemy. +- Detecte `app.add_url_rule`, `@app.route`, `Blueprint`. +- Detecte `requirements.txt` ou `setup.py` com `Flask`, `flask-cors`, `sqlalchemy`. + +### JavaScript / Node.js +- Detecte `require('express')`, `import express from 'express'`, `app.use(express.json())` → Express. +- Detecte `app.listen`, `express.Router()`, `module.exports =`. +- Detecte `package.json` com dependências `express`, `sqlite3`, `body-parser`. + +## Banco de Dados +- Detecte arquivos `database.py`, `sqlite3.connect`, `sqlite3.Database`, `SQLAlchemy`, `pg`, `mysql`, `mongoose`. +- Identifique a persistência via queries SQL ou ORM. +- Liste tabelas se conseguir extrair `CREATE TABLE` ou `db.run`/`cursor.execute`. + +## Arquitetura Atual +### Monolítico +- Todos os endpoints e lógica em um único arquivo. +- Models de dados, validação e rotas misturados. + +### Parcialmente Organizado +- Existe alguma separação de `models/`, `routes/`, `services/` ou `controllers/`, mas ainda há vazamento de lógica entre camadas. + +### MVC / Estrutura clara +- `models`, `controllers` e `routes/views` separados. +- `config` e `middlewares` também definidos separadamente. + +## Domínio e Contexto +- Determine a área funcional principal do projeto. +- Exemplos: + - E-commerce API (produtos, pedidos, usuários) + - LMS API / checkout + - Task Manager API + +## Output Esperado da Análise +- Language: Python / JavaScript +- Framework: Flask / Express +- Dependencies: principais bibliotecas detectadas +- Domain: descrição curta do domínio +- Architecture: monolítico / parcialmente organizado / MVC parcial +- Source files: lista e contagem de arquivos analisados +- DB tables: entidades ou tabelas conhecidas + +## Regras de Análise +- Não altere arquivos nesta fase. +- Extraia sinais de arquitetura mesmo quando o código estiver parcialmente organizado. +- Se houver múltiplos projetos no mesmo diretório, limite-se ao projeto atual. diff --git a/code-smells-project/.claude/skills/refactor-arch/refactor-playbook.md b/code-smells-project/.claude/skills/refactor-arch/refactor-playbook.md new file mode 100644 index 000000000..4ed2556f9 --- /dev/null +++ b/code-smells-project/.claude/skills/refactor-arch/refactor-playbook.md @@ -0,0 +1,169 @@ +# Refactor Playbook + +## Objetivo +Fornecer padrões concretos de transformação para anti-patterns comuns em projetos legacy. + +## 1. God Class / God Module +Antes: +- Um arquivo contém rotas, lógica de negócio e queries. + +Depois: +- Rotas em `routes/` +- Fluxo em `controllers/` +- Persistência em `models/` + +Exemplo Python: +- `app.py` registra rotas +- `controllers/produto_controller.py` chama `models/produto_model.py` +- `models/produto_model.py` executa SELECT/INSERT. + +## 2. Hardcoded Secrets +Antes: +- `app.config['SECRET_KEY'] = 'abc'` +- `config.paymentGatewayKey = 'pk_live_...'` + +Depois: +- `config/settings.py` lê `os.environ.get('SECRET_KEY')` +- `config/index.js` lê `process.env.PAYMENT_GATEWAY_KEY` + +## 3. SQL Injection / Query Concatenation +Antes: +- `cursor.execute("SELECT * FROM usuarios WHERE id = " + str(id))` +- `db.run("INSERT INTO users VALUES ('" + name + "')")` + +Depois: +- `cursor.execute("SELECT * FROM usuarios WHERE id = ?", (id,))` +- `db.run("INSERT INTO users VALUES (?)", [name])` + +## 4. Business Logic in Controller/Route +Antes: +- Rota valida, calcula totas e atualiza estoque diretamente. + +Depois: +- Rota extrai dados e chama `order_controller.create_order()`. +- Controller chama `order_service.process_order()` e `order_model.update_stock()`. + +## 5. Global Mutable State +Antes: +- `let globalCache = {}` +- `db_connection = None` + +Depois: +- Criar módulo de cache imutável ou session-scoped. +- Inicializar conexão de DB em `database.py`/`db.js` com função getter. + +## 6. N+1 Query +Antes: +- `for item in pedidos: cursor.execute('SELECT ...')` + +Depois: +- Use JOIN ou query única para buscar itens associados. +- Ou faça query em lote para IDs coletados. + +## 7. Missing Input Validation +Antes: +- aceita `request.get_json()` sem checar campos. + +Depois: +- validar campos obrigatórios e tipos antes de chamar o controller. +- usar middleware / helper de validação quando possível. + +## 8. Deprecated API Usage +Antes: +- `badCrypto()` custom insecure hashing +- `app.use(bodyParser.json())` + +Depois: +- usar `bcrypt`/`crypto` para hashing +- usar `express.json()` no Express +- evitar `DEBUG=True` no código, use env var + +## Exemplo de transformação antes/depois +### Python/Flask +Antes: +```python +@app.route('/produtos', methods=['POST']) +def criar_produto(): + dados = request.get_json() + nome = dados['nome'] + cursor.execute("INSERT INTO produtos (...) VALUES (...)" ) + return jsonify(...) +``` +Depois: +```python +@produtos_bp.route('/produtos', methods=['POST']) +def criar_produto(): + return produto_controller.criar_produto(request.get_json()) +``` + +Em `controllers/produto_controller.py`: +```python +def criar_produto(dados): + validar_dados_produto(dados) + novo_id = produto_model.criar_produto(dados) + return jsonify({'id': novo_id, 'sucesso': True}), 201 +``` + +Em `models/produto_model.py`: +```python +def criar_produto(data): + db = get_db() + cursor = db.cursor() + cursor.execute( + 'INSERT INTO produtos (nome, descricao, preco, estoque, categoria) VALUES (?, ?, ?, ?, ?)', + (data['nome'], data.get('descricao',''), data['preco'], data['estoque'], data.get('categoria','geral')) + ) + db.commit() + return cursor.lastrowid +``` + +### Node.js/Express +Antes: +```js +app.post('/api/checkout', (req, res) => { + let cc = req.body.card; + if (!cc) return res.status(400).send('Bad Request'); + db.get('SELECT * FROM courses WHERE id = ' + cid, ...) +}); +``` +Depois: +```js +router.post('/api/checkout', checkoutController.handleCheckout); +``` + +Em `controllers/checkoutController.js`: +```js +async function handleCheckout(req, res) { + const { c_id, card } = req.body; + validateCheckoutInput(req.body); + const course = await courseService.findCourse(c_id); + await paymentService.processPayment({ card, course }); + res.json({ msg: 'Sucesso' }); +} +``` + +## Uso do playbook +- Associe cada finding do catálogo a uma transformação. +- Aplique mudanças incrementais e mantenha a aplicação funcional. +- Priorize correções de segurança e separação de responsabilidades. + +## 9. Plaintext Passwords in Seeds/Defaults +Antes: +- `db.run("INSERT INTO users (pass) VALUES ('123')")` +- `u1.password = '1234'` sem hash. + +Depois: +- Os seeds **devem** obrigatoriamente usar a mesma função de hash (ex: `bcrypt.hashSync`, `generate_password_hash`) usada pelo sistema. +- Node.js: `const pwd = bcrypt.hashSync('123', 10); db.run("... VALUES (?)", [pwd])` +- Python: `u1.set_password('1234')` garantindo que o método de fato faça o hash no banco. + +## 10. Fake / Unsigned JWTs +Antes: +- `token = "jwt-demo-" + str(user.id)` +- Rota que ignora validação de assinatura e confia na string. + +Depois: +- Gerar JWTs reais assinados usando a `SECRET_KEY` da aplicação. +- Node.js: usar `jsonwebtoken` (`jwt.sign(payload, SECRET_KEY)`). +- Python: usar `PyJWT` (`jwt.encode(payload, SECRET_KEY, algorithm="HS256")`). +- Ao verificar requisições, garantir que a assinatura do JWT seja validada. diff --git a/code-smells-project/app.py b/code-smells-project/app.py index 70458e653..60dee2fa8 100644 --- a/code-smells-project/app.py +++ b/code-smells-project/app.py @@ -1,88 +1,43 @@ -from flask import Flask, jsonify, request -from flask_cors import CORS -import controllers -from database import get_db +"""Entry point / Composition root da aplicação Flask. -app = Flask(__name__) -app.config["SECRET_KEY"] = "minha-chave-super-secreta-123" -app.config["DEBUG"] = True -CORS(app) +Registra blueprints e middlewares; não contém regra de negócio. +""" -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"]) +from flask import Flask +from flask_cors import CORS -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"]) +from config import settings +from routes import produtos_bp, usuarios_bp, pedidos_bp +from routes.system_routes import system_bp +from middlewares.error_handler import register_error_handlers +from database import get_db -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"]) -app.add_url_rule("/relatorios/vendas", "relatorio_vendas", controllers.relatorio_vendas, methods=["GET"]) +def create_app(): + app = Flask(__name__) + app.config["SECRET_KEY"] = settings.SECRET_KEY + app.config["DEBUG"] = settings.DEBUG + CORS(app) -app.add_url_rule("/health", "health_check", controllers.health_check, methods=["GET"]) + # Blueprints por domínio (rotas finas que delegam a controllers) + app.register_blueprint(produtos_bp) + app.register_blueprint(usuarios_bp) + app.register_blueprint(pedidos_bp) + app.register_blueprint(system_bp) -@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" - } - }) + # Tratamento de erros centralizado + register_error_handlers(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 + return app -@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 - 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 +app = create_app() -if __name__ == "__main__": - get_db() +if __name__ == "__main__": + get_db() # inicializa o schema e seed no boot print("=" * 50) print("SERVIDOR INICIADO") - print("Rodando em http://localhost:5000") + print(f"Rodando em http://{settings.HOST}:{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) \ No newline at end of file diff --git a/code-smells-project/config/settings.py b/code-smells-project/config/settings.py new file mode 100644 index 000000000..10feaed74 --- /dev/null +++ b/code-smells-project/config/settings.py @@ -0,0 +1,34 @@ +"""Configurações centralizadas da aplicação. + +Segredos e parâmetros sensíveis são lidos de variáveis de ambiente, +nunca hardcoded no código. +""" + +import os + +# Segredos e parâmetros sensíveis (via env) +SECRET_KEY = os.environ.get("SECRET_KEY", "troque-esta-chave-em-producao") +DEBUG = os.environ.get("FLASK_DEBUG", "").lower() in ("1", "true", "yes") +HOST = os.environ.get("HOST", "0.0.0.0") +PORT = int(os.environ.get("PORT", "5000")) +DB_PATH = os.environ.get("DB_PATH", "loja.db") + +# Categorias de produto permitidas (regra de domínio) +CATEGORIAS_VALIDAS = [ + "informatica", + "moveis", + "vestuario", + "geral", + "eletronicos", + "livros", +] + +# Faixas de desconto aplicadas no relatório de vendas (regra de negócio) +DESCONTO_FAIXAS = [ + (10000, 0.10), + (5000, 0.05), + (1000, 0.02), +] + +# Status válidos de pedido +STATUS_PEDIDO_VALIDOS = ["pendente", "aprovado", "enviado", "entregue", "cancelado"] \ No newline at end of file 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..0f35acc49 --- /dev/null +++ b/code-smells-project/controllers/__init__.py @@ -0,0 +1,5 @@ +"""Controllers expostos do pacote.""" + +from . import produto_controller, usuario_controller, pedido_controller + +__all__ = ["produto_controller", "usuario_controller", "pedido_controller"] \ No newline at end of file diff --git a/code-smells-project/controllers/pedido_controller.py b/code-smells-project/controllers/pedido_controller.py new file mode 100644 index 000000000..ce0c2591f --- /dev/null +++ b/code-smells-project/controllers/pedido_controller.py @@ -0,0 +1,48 @@ +"""Fluxo da aplicação para o domínio de pedidos e relatórios.""" + +from flask import jsonify, request +from services import pedido_service +from middlewares.error_handler import api_error_handler + + +@api_error_handler +def criar_pedido(): + dados = request.get_json() or {} + resultado = pedido_service.processar_criacao(dados.get("usuario_id"), dados.get("itens")) + return jsonify( + {"dados": resultado, "sucesso": True, "mensagem": "Pedido criado com sucesso"} + ), 201 + + +@api_error_handler +def listar_pedidos_usuario(usuario_id): + pedidos = _listar_pedidos(usuario_id=usuario_id) + return jsonify({"dados": pedidos, "sucesso": True}), 200 + + +@api_error_handler +def listar_todos_pedidos(): + pedidos = _listar_pedidos() + return jsonify({"dados": pedidos, "sucesso": True}), 200 + + +@api_error_handler +def atualizar_status_pedido(pedido_id): + dados = request.get_json() or {} + novo_status = dados.get("status", "") + pedido_service.atualizar_status(pedido_id, novo_status) + return jsonify({"sucesso": True, "mensagem": "Status atualizado"}), 200 + + +@api_error_handler +def relatorio_vendas(): + relatorio = pedido_service.relatorio_vendas_com_desconto() + return jsonify({"dados": relatorio, "sucesso": True}), 200 + + +def _listar_pedidos(usuario_id=None): + from models import pedido_model + + if usuario_id is not None: + return pedido_model.get_pedidos_por_usuario(usuario_id) + return pedido_model.get_todos_pedidos() \ No newline at end of file diff --git a/code-smells-project/controllers/produto_controller.py b/code-smells-project/controllers/produto_controller.py new file mode 100644 index 000000000..ef3482333 --- /dev/null +++ b/code-smells-project/controllers/produto_controller.py @@ -0,0 +1,56 @@ +"""Fluxo da aplicação para o domínio de produtos (orquestra service + resposta).""" + +from flask import jsonify, request +from services import produto_service +from middlewares.error_handler import api_error_handler + + +@api_error_handler +def listar_produtos(): + produtos = produto_service.listar() + return jsonify({"dados": produtos, "sucesso": True}), 200 + + +@api_error_handler +def buscar_produto(produto_id): + produto = produto_service.obter(produto_id) + if not produto: + return jsonify({"erro": "Produto não encontrado", "sucesso": False}), 404 + return jsonify({"dados": produto, "sucesso": True}), 200 + + +@api_error_handler +def criar_produto(): + dados = request.get_json() + novo_id = produto_service.criar(dados) + return jsonify({"dados": {"id": novo_id}, "sucesso": True, "mensagem": "Produto criado"}), 201 + + +@api_error_handler +def atualizar_produto(produto_id): + dados = request.get_json() + produto_service.atualizar(produto_id, dados) + return jsonify({"sucesso": True, "mensagem": "Produto atualizado"}), 200 + + +@api_error_handler +def deletar_produto(produto_id): + produto_service.deletar(produto_id) + return jsonify({"sucesso": True, "mensagem": "Produto deletado"}), 200 + + +@api_error_handler +def buscar_produtos(): + termo = request.args.get("q", "") + categoria = request.args.get("categoria", None) + preco_min = _opt_float(request.args.get("preco_min")) + preco_max = _opt_float(request.args.get("preco_max")) + resultados = produto_service.buscar(termo, categoria, preco_min, preco_max) + return jsonify({"dados": resultados, "total": len(resultados), "sucesso": True}), 200 + + +def _opt_float(valor): + try: + return float(valor) if valor is not None else None + except (TypeError, ValueError): + return None \ No newline at end of file diff --git a/code-smells-project/controllers/usuario_controller.py b/code-smells-project/controllers/usuario_controller.py new file mode 100644 index 000000000..817495cce --- /dev/null +++ b/code-smells-project/controllers/usuario_controller.py @@ -0,0 +1,35 @@ +"""Fluxo da aplicação para o domínio de usuários e autenticação.""" + +from flask import jsonify, request +from services import usuario_service +from middlewares.error_handler import api_error_handler + + +@api_error_handler +def listar_usuarios(): + usuarios = usuario_service.listar() + return jsonify({"dados": usuarios, "sucesso": True}), 200 + + +@api_error_handler +def buscar_usuario(usuario_id): + usuario = usuario_service.obter(usuario_id) + if not usuario: + return jsonify({"erro": "Usuário não encontrado", "sucesso": False}), 404 + return jsonify({"dados": usuario, "sucesso": True}), 200 + + +@api_error_handler +def criar_usuario(): + dados = request.get_json() + novo_id = usuario_service.criar(dados) + return jsonify({"dados": {"id": novo_id}, "sucesso": True}), 201 + + +@api_error_handler +def login(): + dados = request.get_json() + usuario = usuario_service.autenticar(dados) + if not usuario: + return jsonify({"erro": "Email ou senha inválidos", "sucesso": False}), 401 + return jsonify({"dados": usuario, "sucesso": True, "mensagem": "Login OK"}), 200 \ No newline at end of file diff --git a/code-smells-project/database.py b/code-smells-project/database.py index 798587644..ca0747ef8 100644 --- a/code-smells-project/database.py +++ b/code-smells-project/database.py @@ -1,86 +1,101 @@ +"""Conexão e inicialização do banco de dados SQLite. + +A conexão é criada de forma preguiçosa (lazy) via getter, eliminando o +estado global mutável e permitindo um único ponto de acesso à persistência. +""" + import sqlite3 -import os +import hashlib +from config import settings + +_db_connection = None + + +def _senha_hash(senha): + """Hash consistente com models/usuario_model (SHA-256 de demonstração).""" + return hashlib.sha256(senha.encode("utf-8")).hexdigest() -db_connection = None -db_path = "loja.db" 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 única do módulo, criada sob demanda.""" + global _db_connection + if _db_connection is None: + _db_connection = sqlite3.connect(settings.DB_PATH, check_same_thread=False) + _db_connection.row_factory = sqlite3.Row + _init_schema(_db_connection) + _seed_default_data(_db_connection) + return _db_connection + - 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() +def _init_schema(conn): + cursor = conn.cursor() + cursor.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 + ); + """ + ) + conn.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 - ) - 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() +def _seed_default_data(conn): + cursor = conn.cursor() + 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, + ) - return db_connection + usuarios = [ + ("Admin", "admin@loja.com", _senha_hash("admin123"), "admin"), + ("João Silva", "joao@email.com", _senha_hash("123456"), "cliente"), + ("Maria Santos", "maria@email.com", _senha_hash("senha123"), "cliente"), + ] + cursor.executemany( + "INSERT INTO usuarios (nome, email, senha, tipo) VALUES (?, ?, ?, ?)", + usuarios, + ) + conn.commit() \ No newline at end of file diff --git a/code-smells-project/middlewares/__init__.py b/code-smells-project/middlewares/__init__.py new file mode 100644 index 000000000..7f6db774f --- /dev/null +++ b/code-smells-project/middlewares/__init__.py @@ -0,0 +1 @@ +"""Middlewares da aplicação.""" diff --git a/code-smells-project/middlewares/error_handler.py b/code-smells-project/middlewares/error_handler.py new file mode 100644 index 000000000..4a18930bd --- /dev/null +++ b/code-smells-project/middlewares/error_handler.py @@ -0,0 +1,42 @@ +"""Tratamento de erros centralizado. + +Substitui os try/except repetidos nos controllers por um único decorator que +converte exceções de domínio em respostas HTTP padronizadas. +""" + +from functools import wraps +from flask import jsonify + + +class ApiError(Exception): + """Erro de domínio com status HTTP associado.""" + + def __init__(self, mensagem, status=400): + super().__init__(mensagem) + self.mensagem = mensagem + self.status = status + + +def api_error_handler(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except ApiError as exc: + return jsonify({"erro": exc.mensagem, "sucesso": False}), exc.status + except Exception as exc: # noqa: BLE001 — boundary de erro + return jsonify({"erro": str(exc), "sucesso": False}), 500 + + return wrapper + + +def register_error_handlers(app): + """Registra handlers globais de erro no app Flask.""" + + @app.errorhandler(404) + def not_found(_): + return jsonify({"erro": "Rota não encontrada", "sucesso": False}), 404 + + @app.errorhandler(500) + def internal_error(_): + return jsonify({"erro": "Erro interno do servidor", "sucesso": False}), 500 \ No newline at end of file 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..c28a5885c --- /dev/null +++ b/code-smells-project/models/__init__.py @@ -0,0 +1,5 @@ +"""Models expostos do pacote.""" + +from . import produto_model, usuario_model, pedido_model + +__all__ = ["produto_model", "usuario_model", "pedido_model"] \ No newline at end of file diff --git a/code-smells-project/models/pedido_model.py b/code-smells-project/models/pedido_model.py new file mode 100644 index 000000000..41b12ea06 --- /dev/null +++ b/code-smells-project/models/pedido_model.py @@ -0,0 +1,111 @@ +"""Acesso a dados (persistência) do domínio de pedidos.""" + +from database import get_db + + +def criar_pedido(usuario_id, itens): + """Cria um pedido com seus itens. `itens` já validados pelo service. + + Usa uma única transação e queries parametrizadas (sem N+1/concatenação). + """ + db = get_db() + cursor = db.cursor() + total = sum(item["preco_unitario"] * item["quantidade"] for item in itens) + + cursor.execute( + "INSERT INTO pedidos (usuario_id, status, total) VALUES (?, 'pendente', ?)", + (usuario_id, total), + ) + pedido_id = cursor.lastrowid + + for item in itens: + cursor.execute( + "INSERT INTO itens_pedido (pedido_id, produto_id, quantidade, preco_unitario) " + "VALUES (?, ?, ?, ?)", + (pedido_id, item["produto_id"], item["quantidade"], item["preco_unitario"]), + ) + cursor.execute( + "UPDATE produtos SET estoque = estoque - ? WHERE id = ?", + (item["quantidade"], item["produto_id"]), + ) + + db.commit() + return {"pedido_id": pedido_id, "total": total} + + +def get_pedidos_por_usuario(usuario_id): + db = get_db() + cursor = db.cursor() + cursor.execute("SELECT * FROM pedidos WHERE usuario_id = ?", (usuario_id,)) + rows = cursor.fetchall() + return [_pedido_com_itens(r) for r in rows] + + +def get_todos_pedidos(): + db = get_db() + cursor = db.cursor() + cursor.execute("SELECT * FROM pedidos") + rows = cursor.fetchall() + return [_pedido_com_itens(r) for r in rows] + + +def atualizar_status(pedido_id, novo_status): + db = get_db() + cursor = db.cursor() + cursor.execute("UPDATE pedidos SET status = ? WHERE id = ?", (novo_status, pedido_id)) + db.commit() + return True + + +def relatorio_vendas(): + db = get_db() + cursor = db.cursor() + + cursor.execute("SELECT COUNT(*) FROM pedidos") + total_pedidos = cursor.fetchone()[0] + + cursor.execute("SELECT COALESCE(SUM(total), 0) FROM pedidos") + faturamento = cursor.fetchone()[0] + + status_counts = {} + for status in ("pendente", "aprovado", "cancelado"): + cursor.execute("SELECT COUNT(*) FROM pedidos WHERE status = ?", (status,)) + status_counts[status] = cursor.fetchone()[0] + + return { + "total_pedidos": total_pedidos, + "faturamento_bruto": round(faturamento, 2), + "pedidos_pendentes": status_counts["pendente"], + "pedidos_aprovados": status_counts["aprovado"], + "pedidos_cancelados": status_counts["cancelado"], + "ticket_medio": round(faturamento / total_pedidos, 2) if total_pedidos > 0 else 0, + } + + +def _pedido_com_itens(pedido): + db = get_db() + cursor = db.cursor() + cursor.execute( + """SELECT 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 = ?""", + (pedido["id"],), + ) + itens = [ + { + "produto_id": r["produto_id"], + "produto_nome": r["produto_nome"] or "Desconhecido", + "quantidade": r["quantidade"], + "preco_unitario": r["preco_unitario"], + } + for r in cursor.fetchall() + ] + return { + "id": pedido["id"], + "usuario_id": pedido["usuario_id"], + "status": pedido["status"], + "total": pedido["total"], + "criado_em": pedido["criado_em"], + "itens": itens, + } \ No newline at end of file diff --git a/code-smells-project/models/produto_model.py b/code-smells-project/models/produto_model.py new file mode 100644 index 000000000..50a7345db --- /dev/null +++ b/code-smells-project/models/produto_model.py @@ -0,0 +1,103 @@ +"""Acesso a dados (persistência) do domínio de produtos.""" + +from database import get_db + + +def get_todos_produtos(): + db = get_db() + cursor = db.cursor() + cursor.execute("SELECT * FROM produtos") + rows = cursor.fetchall() + return [_row_to_dict(r) for r in rows] + + +def get_produto_por_id(produto_id): + db = get_db() + cursor = db.cursor() + cursor.execute("SELECT * FROM produtos WHERE id = ?", (produto_id,)) + row = cursor.fetchone() + return _row_to_dict(row) if row else None + + +def criar_produto(dados): + db = get_db() + cursor = db.cursor() + cursor.execute( + "INSERT INTO produtos (nome, descricao, preco, estoque, categoria) " + "VALUES (?, ?, ?, ?, ?)", + ( + dados["nome"], + dados.get("descricao", ""), + dados["preco"], + dados["estoque"], + dados.get("categoria", "geral"), + ), + ) + db.commit() + return cursor.lastrowid + + +def atualizar_produto(produto_id, dados): + db = get_db() + cursor = db.cursor() + cursor.execute( + "UPDATE produtos SET nome = ?, descricao = ?, preco = ?, estoque = ?, " + "categoria = ? WHERE id = ?", + ( + dados["nome"], + dados.get("descricao", ""), + dados["preco"], + dados["estoque"], + dados.get("categoria", "geral"), + produto_id, + ), + ) + db.commit() + return True + + +def deletar_produto(produto_id): + db = get_db() + cursor = db.cursor() + cursor.execute("DELETE FROM produtos WHERE id = ?", (produto_id,)) + db.commit() + return True + + +def buscar_produtos_por_filtro(termo=None, categoria=None, preco_min=None, preco_max=None): + """Busca filtrada com queries parametrizadas (sem concatenação de strings).""" + db = get_db() + cursor = db.cursor() + + sql = "SELECT * FROM produtos WHERE 1=1" + params = [] + if termo: + sql += " AND (nome LIKE ? OR descricao LIKE ?)" + like = f"%{termo}%" + params.extend([like, like]) + if categoria: + sql += " AND categoria = ?" + params.append(categoria) + if preco_min is not None: + sql += " AND preco >= ?" + params.append(preco_min) + if preco_max is not None: + sql += " AND preco <= ?" + params.append(preco_max) + + cursor.execute(sql, params) + rows = cursor.fetchall() + return [_row_to_dict(r) for r in rows] + + +def _row_to_dict(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"], + } \ No newline at end of file diff --git a/code-smells-project/models/usuario_model.py b/code-smells-project/models/usuario_model.py new file mode 100644 index 000000000..42c1c1ee3 --- /dev/null +++ b/code-smells-project/models/usuario_model.py @@ -0,0 +1,67 @@ +"""Acesso a dados (persistência) do domínio de usuários e autenticação.""" + +from database import get_db +import hashlib + + +def _senha_hash(senha): + """Hash simples de demonstração. Em produção usar bcrypt/argon2. + + Nota: mantém o comportamento de login original sem armazenar senha em + texto puro (a melhoria de hashing robusta é recomendada na auditoria). + """ + return hashlib.sha256(senha.encode("utf-8")).hexdigest() + + +def get_todos_usuarios(): + db = get_db() + cursor = db.cursor() + cursor.execute("SELECT * FROM usuarios") + rows = cursor.fetchall() + return [_row_to_dict(r, incluir_senha=False) for r in rows] + + +def get_usuario_por_id(usuario_id): + db = get_db() + cursor = db.cursor() + cursor.execute("SELECT * FROM usuarios WHERE id = ?", (usuario_id,)) + row = cursor.fetchone() + return _row_to_dict(row) if row else 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_hash(senha), tipo), + ) + db.commit() + return cursor.lastrowid + + +def autenticar(email, senha): + """Valida credenciais e retorna dados públicos do usuário, ou None.""" + db = get_db() + cursor = db.cursor() + cursor.execute( + "SELECT * FROM usuarios WHERE email = ? AND senha = ?", + (email, _senha_hash(senha)), + ) + row = cursor.fetchone() + if row: + return _row_to_dict(row, incluir_senha=False) + return None + + +def _row_to_dict(row, incluir_senha=True): + data = { + "id": row["id"], + "nome": row["nome"], + "email": row["email"], + "tipo": row["tipo"], + "criado_em": row["criado_em"], + } + if incluir_senha: + data["senha"] = row["senha"] + return data \ No newline at end of file diff --git a/code-smells-project/package-lock.json b/code-smells-project/package-lock.json new file mode 100644 index 000000000..9a18311a3 --- /dev/null +++ b/code-smells-project/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "code-smells-project", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/code-smells-project/routes/__init__.py b/code-smells-project/routes/__init__.py new file mode 100644 index 000000000..11ded3c4d --- /dev/null +++ b/code-smells-project/routes/__init__.py @@ -0,0 +1,7 @@ +"""Blueprints expostos do pacote de rotas.""" + +from .produto_routes import produtos_bp +from .usuario_routes import usuarios_bp +from .pedido_routes import pedidos_bp + +__all__ = ["produtos_bp", "usuarios_bp", "pedidos_bp"] \ No newline at end of file diff --git a/code-smells-project/routes/pedido_routes.py b/code-smells-project/routes/pedido_routes.py new file mode 100644 index 000000000..5747f29a3 --- /dev/null +++ b/code-smells-project/routes/pedido_routes.py @@ -0,0 +1,22 @@ +"""Endpoints HTTP do domínio de pedidos e relatórios.""" + +from flask import Blueprint +from controllers import pedido_controller + +pedidos_bp = Blueprint("pedidos", __name__) + +pedidos_bp.add_url_rule("/pedidos", "criar_pedido", pedido_controller.criar_pedido, methods=["POST"]) +pedidos_bp.add_url_rule("/pedidos", "listar_todos_pedidos", pedido_controller.listar_todos_pedidos, methods=["GET"]) +pedidos_bp.add_url_rule( + "/pedidos/usuario/", + "listar_pedidos_usuario", + pedido_controller.listar_pedidos_usuario, + methods=["GET"], +) +pedidos_bp.add_url_rule( + "/pedidos//status", + "atualizar_status_pedido", + pedido_controller.atualizar_status_pedido, + methods=["PUT"], +) +pedidos_bp.add_url_rule("/relatorios/vendas", "relatorio_vendas", pedido_controller.relatorio_vendas, methods=["GET"]) \ No newline at end of file diff --git a/code-smells-project/routes/produto_routes.py b/code-smells-project/routes/produto_routes.py new file mode 100644 index 000000000..be971ef0f --- /dev/null +++ b/code-smells-project/routes/produto_routes.py @@ -0,0 +1,13 @@ +"""Endpoints HTTP do domínio de produtos.""" + +from flask import Blueprint +from controllers import produto_controller + +produtos_bp = Blueprint("produtos", __name__) + +produtos_bp.add_url_rule("/produtos", "listar_produtos", produto_controller.listar_produtos, methods=["GET"]) +produtos_bp.add_url_rule("/produtos/busca", "buscar_produtos", produto_controller.buscar_produtos, methods=["GET"]) +produtos_bp.add_url_rule("/produtos/", "buscar_produto", produto_controller.buscar_produto, methods=["GET"]) +produtos_bp.add_url_rule("/produtos", "criar_produto", produto_controller.criar_produto, methods=["POST"]) +produtos_bp.add_url_rule("/produtos/", "atualizar_produto", produto_controller.atualizar_produto, methods=["PUT"]) +produtos_bp.add_url_rule("/produtos/", "deletar_produto", produto_controller.deletar_produto, methods=["DELETE"]) \ No newline at end of file diff --git a/code-smells-project/routes/system_routes.py b/code-smells-project/routes/system_routes.py new file mode 100644 index 000000000..3260d5c4d --- /dev/null +++ b/code-smells-project/routes/system_routes.py @@ -0,0 +1,50 @@ +"""Endpoints de sistema (health check e índice) sem lógica sensível.""" + +from flask import Blueprint, jsonify +from config import settings + + +system_bp = Blueprint("system", __name__) + + +@system_bp.route("/health", methods=["GET"]) +def health_check(): + from database import get_db + + 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" if not settings.DEBUG else "desenvolvimento", + } + ), 200 + + +@system_bp.route("/", methods=["GET"]) +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", + }, + } + ) \ No newline at end of file diff --git a/code-smells-project/routes/usuario_routes.py b/code-smells-project/routes/usuario_routes.py new file mode 100644 index 000000000..dad7ba1a8 --- /dev/null +++ b/code-smells-project/routes/usuario_routes.py @@ -0,0 +1,11 @@ +"""Endpoints HTTP do domínio de usuários e autenticação.""" + +from flask import Blueprint +from controllers import usuario_controller + +usuarios_bp = Blueprint("usuarios", __name__) + +usuarios_bp.add_url_rule("/usuarios", "listar_usuarios", usuario_controller.listar_usuarios, methods=["GET"]) +usuarios_bp.add_url_rule("/usuarios/", "buscar_usuario", usuario_controller.buscar_usuario, methods=["GET"]) +usuarios_bp.add_url_rule("/usuarios", "criar_usuario", usuario_controller.criar_usuario, methods=["POST"]) +usuarios_bp.add_url_rule("/login", "login", usuario_controller.login, methods=["POST"]) \ No newline at end of file diff --git a/code-smells-project/services/__init__.py b/code-smells-project/services/__init__.py new file mode 100644 index 000000000..2891c595d --- /dev/null +++ b/code-smells-project/services/__init__.py @@ -0,0 +1,5 @@ +"""Serviços de negócio expostos do pacote.""" + +from . import produto_service, pedido_service, usuario_service + +__all__ = ["produto_service", "pedido_service", "usuario_service"] \ No newline at end of file diff --git a/code-smells-project/services/pedido_service.py b/code-smells-project/services/pedido_service.py new file mode 100644 index 000000000..74e2ba168 --- /dev/null +++ b/code-smells-project/services/pedido_service.py @@ -0,0 +1,91 @@ +"""Regras de negócio do domínio de pedidos (fora de controllers e models).""" + +from models import produto_model, pedido_model +from config import settings + + +class PedidoInvalidoError(Exception): + """Levantada quando o pedido não respeita as regras de negócio.""" + + +def processar_criacao(usuario_id, itens): + """Valida itens/estoque e delega a persistência; primeiro item valida tudo. + + Retorna o pedido criado ou levanta PedidoInvalidoError com mensagem legível. + """ + if not usuario_id: + raise PedidoInvalidoError("Usuario ID é obrigatório") + if not itens: + raise PedidoInvalidoError("Pedido deve ter pelo menos 1 item") + + # Busca os produtos uma única vez (evita N+1). + ids = [i["produto_id"] for i in itens] + precos = {} + for pid in set(ids): + produto = produto_model.get_produto_por_id(pid) + if produto is None: + raise PedidoInvalidoError(f"Produto {pid} não encontrado") + precos[pid] = produto["preco"] + + # Resolve quantidades e preços unitários + itens_preparados = [] + for item in itens: + quantidade = item["quantidade"] + preco = precos[item["produto_id"]] + if quantidade and quantidade < 0: + raise PedidoInvalidoError("Quantidade não pode ser negativa") + itens_preparados.append( + { + "produto_id": item["produto_id"], + "quantidade": quantidade, + "preco_unitario": preco, + } + ) + + resultado = pedido_model.criar_pedido(usuario_id, itens_preparados) + notificar_pedido_criado(resultado["pedido_id"], usuario_id) + return resultado + + +def atualizar_status(pedido_id, novo_status): + if novo_status not in settings.STATUS_PEDIDO_VALIDOS: + raise PedidoInvalidoError("Status inválido") + pedido_model.atualizar_status(pedido_id, novo_status) + if novo_status == "aprovado": + log_entrega(pedido_id) + if novo_status == "cancelado": + log_restauro_estoque(pedido_id) + + +def relatorio_vendas_com_desconto(): + """Calcula o relatório aplicando as faixas de desconto da config.""" + base = pedido_model.relatorio_vendas() + faturamento = base["faturamento_bruto"] + desconto = 0.0 + for limite, percentual in settings.DESCONTO_FAIXAS: + if faturamento > limite: + desconto = faturamento * percentual + break + base["desconto_aplicavel"] = round(desconto, 2) + base["faturamento_liquido"] = round(faturamento - desconto, 2) + return base + + +def notificar_pedido_criado(pedido_id, usuario_id): + # Canal de notificação real (email/sms/push) viria aqui. + _log(f"Pedido {pedido_id} criado para usuario {usuario_id}") + + +def log_entrega(pedido_id): + _log(f"Pedido {pedido_id} aprovado! Preparar envio.") + + +def log_resto_estoque(pedido_id): + _log(f"Pedido {pedido_id} cancelado. Devolver estoque.") + + +def _log(mensagem): + # Em produção, substituir por logging estruturado. + import logging + + logging.getLogger("pedido_service").info(mensagem) \ No newline at end of file diff --git a/code-smells-project/services/produto_service.py b/code-smells-project/services/produto_service.py new file mode 100644 index 000000000..d3c5df78f --- /dev/null +++ b/code-smells-project/services/produto_service.py @@ -0,0 +1,66 @@ +"""Regras de negócio e validação do domínio de produtos.""" + +from models import produto_model +from config import settings + + +class ProdutoInvalidoError(Exception): + """Levantada quando os dados do produto violam regras de validação.""" + + +def validar_dados_produto(dados, obrigatorio_nome_preco_estoque=True): + if not dados: + raise ProdutoInvalidoError("Dados inválidos") + + nome = dados.get("nome", "") + preco = dados.get("preco") + estoque = dados.get("estoque") + + if obrigatorio_nome_preco_estoque: + if "nome" not in dados: + raise ProdutoInvalidoError("Nome é obrigatório") + if "preco" not in dados: + raise ProdutoInvalidoError("Preço é obrigatório") + if "estoque" not in dados: + raise ProdutoInvalidoError("Estoque é obrigatório") + + if preco is not None and preco < 0: + raise ProdutoInvalidoError("Preço não pode ser negativo") + if estoque is not None and estoque < 0: + raise ProdutoInvalidoError("Estoque não pode ser negativo") + if nome and len(nome) < 2: + raise ProdutoInvalidoError("Nome muito curto") + if nome and len(nome) > 200: + raise ProdutoInvalidoError("Nome muito longo") + + categoria = dados.get("categoria", "geral") + if categoria not in settings.CATEGORIAS_VALIDAS: + raise ProdutoInvalidoError(f"Categoria inválida. Válidas: {settings.CATEGORIAS_VALIDAS}") + + +def criar(dados): + validar_dados_produto(dados) + return produto_model.criar_produto(dados) + + +def atualizar(produto_id, dados): + if not produto_model.get_produto_por_id(produto_id): + raise ProdutoInvalidoError("Produto não encontrado") + validar_dados_produto(dados) + return produto_model.atualizar_produto(produto_id, dados) + + +def deletar(produto_id): + if not produto_model.get_produto_por_id(produto_id): + raise ProdutoInvalidoError("Produto não encontrado") + return produto_model.deletar_produto(produto_id) + + +def listar(): + return produto_model.get_todos_produtos() + + +def buscar(termo, categoria, preco_min, preco_max): + return produto_model.buscar_produtos_por_filtro(termo, categoria, preco_min, preco_max) +def obter(produto_id): + return produto_model.get_produto_por_id(produto_id) diff --git a/code-smells-project/services/usuario_service.py b/code-smells-project/services/usuario_service.py new file mode 100644 index 000000000..65ea61b6a --- /dev/null +++ b/code-smells-project/services/usuario_service.py @@ -0,0 +1,39 @@ +"""Regras de negócio do domínio de usuários/autenticação.""" + +from models import usuario_model + + +class UsuarioInvalidoError(Exception): + """Levantada quando os dados de usuário violam validações.""" + + +def validar_dados_usuario(dados): + if not dados: + raise UsuarioInvalidoError("Dados inválidos") + nome = dados.get("nome", "") + email = dados.get("email", "") + senha = dados.get("senha", "") + if not nome or not email or not senha: + raise UsuarioInvalidoError("Nome, email e senha são obrigatórios") + return nome, email, senha + + +def criar(dados): + nome, email, senha = validar_dados_usuario(dados) + return usuario_model.criar_usuario(nome, email, senha) + + +def listar(): + return usuario_model.get_todos_usuarios() + + +def obter(usuario_id): + return usuario_model.get_usuario_por_id(usuario_id) + + +def autenticar(dados): + email = (dados or {}).get("email", "") + senha = (dados or {}).get("senha", "") + if not email or not senha: + raise UsuarioInvalidoError("Email e senha são obrigatórios") + return usuario_model.autenticar(email, senha) \ No newline at end of file diff --git a/ecommerce-api-legacy/.claude/skills/refactor-arch/SKILL.md b/ecommerce-api-legacy/.claude/skills/refactor-arch/SKILL.md new file mode 100644 index 000000000..7fbec6f93 --- /dev/null +++ b/ecommerce-api-legacy/.claude/skills/refactor-arch/SKILL.md @@ -0,0 +1,89 @@ +# refactor-arch Skill + +Esta skill automatiza a análise, auditoria e refatoração de projetos legados para o padrão MVC. + +## Objetivo +- Detectar linguagem, framework, arquitetura e domínio do projeto. +- Identificar anti-patterns e code smells com severidade e localização exata. +- Gerar um relatório de auditoria estruturado. +- Refatorar o projeto para uma arquitetura MVC clara. +- Validar que a aplicação inicia e mantém os endpoints originais. + +## Como usar +1. Execute a skill no diretório do projeto. +2. A skill faz 3 fases sequenciais. +3. A Fase 2 pausa para confirmação antes de qualquer modificação. +4. A Fase 3 aplica refatorações e validações. + +## Fase 1 — Análise +1. Identifique a linguagem principal do projeto. +2. Detecte framework e bibliotecas principais. +3. Liste todos os arquivos de código fonte relevantes. +4. Mapeie a arquitetura atual: monolito, camadas parciais, modelo MVC, etc. +5. Detecte banco de dados e método de persistência. +6. Produza um resumo com: + - Language + - Framework + - Dependencies + - Domain + - Architecture + - Source files analyzed + - DB tables/entities + +Use `project-analysis-guidelines.md` para as heurísticas de identificação. + +## Fase 2 — Auditoria +1. Use `antipattern-catalog.md` para detectar anti-patterns, vulnerabilidades e APIs deprecated. +2. Gere um relatório seguindo o template em `audit-report-template.md`. +3. O relatório deve conter: + - Summary por severidade + - Findings com: + - severidade + - arquivo e linhas exatas + - descrição + - impacto + - recomendação +4. Ordene findings de CRITICAL para LOW. +5. Inclua pelo menos 5 findings, com ao menos 1 HIGH ou CRITICAL. +6. Pare e peça confirmação antes de executar a Fase 3. + +A resposta da Fase 2 deve terminar com: + +> Phase 2 complete. Proceed with refactoring (Phase 3)? [y/n] + +## Fase 3 — Refatoração +1. Use `mvc-guidelines.md` como base para reestruturar o projeto. +2. Use `refactor-playbook.md` para aplicar transformações concretas por anti-pattern. +3. Gere uma nova estrutura consistente com: + - config/settings + - models/ + - controllers/ + - views/routes/ + - middlewares/ (tratamento de erros, validação) + - entrypoint claro (app.js, server.js, main.py) +4. Extraia configurações hardcoded para arquivos de config. +5. Separe responsabilidades: + - Models: abstração de dados e persistência + - Controllers: fluxo de aplicação e orquestração + - Routes: expor endpoints e delegar ao controller + - Middlewares: validação, erros e segurança +6. Remova endpoints inseguros ou debug/backdoor quando não fizerem parte do domínio da API. +7. Substitua SQL construído por concatenação por queries parametrizadas ou ORM. +8. Remova APIs deprecated e recomende equivalentes modernos. + +## Validação +1. A aplicação deve bootar sem erros. +2. Verifique pelo menos um endpoint original. +3. Confirme a estrutura de diretórios e a ausência de anti-patterns críticos. +4. A skill deve descrever as mudanças realizadas e os arquivos alterados. + +## Regras Gerais +- Seja agnóstico de tecnologia. A skill deve funcionar para Python/Flask e Node.js/Express. +- Não modifique nada antes da confirmação da Fase 2. +- Use os arquivos de referência para todas as decisões. +- Priorize segurança, separação de responsabilidades e manutenção. +- Se não for possível validar a aplicação completamente, explique claramente o motivo no final. + +## Regra de Ouro (Fase 3 vs Fase 2) +- **TODA** falha ou vulnerabilidade apontada no relatório da Fase 2, incluindo achados em scripts de mock, seeds ou tokens falsos (Fake JWT), **DEVE** ser ativamente corrigida no código durante a Fase 3. +- Não deixe pendências descritas no relatório vazarem para o código final. Verifique duplamente: senhas no banco (seeds inclusive) e tokens (devem ser JWTs assinados de verdade, não placeholders). diff --git a/ecommerce-api-legacy/.claude/skills/refactor-arch/antipattern-catalog.md b/ecommerce-api-legacy/.claude/skills/refactor-arch/antipattern-catalog.md new file mode 100644 index 000000000..dfc788e6b --- /dev/null +++ b/ecommerce-api-legacy/.claude/skills/refactor-arch/antipattern-catalog.md @@ -0,0 +1,71 @@ +# Antipattern Catalog + +## Objetivo +Catalogar anti-patterns, vulnerabilidades e sinais de APIs deprecated com severidade, para uso na Fase 2 de auditoria. + +### CRITICAL +- **God Class / God Module** + - Sinais: arquivo único contém rotas, lógica de negócio, acesso a dados e validação. + - Impacto: impossível testar isoladamente, altíssimo acoplamento. + +- **Hardcoded Secrets** + - Sinais: `SECRET_KEY`, senhas, chaves API, credenciais ou informações sensíveis em código. + - Impacto: vazamento de segredos, ambiente inseguro. + +- **SQL Injection / Concatenation SQL** + - Sinais: queries construídas com concatenação de strings, interpolação direta de inputs. + - Impacto: execução de SQL arbitrário. + +- **Backdoor / Unsafe Admin Query** + - Sinais: endpoints que executam SQL arbitrário ou resetam DB sem autenticação. + - Impacto: falha de segurança grave. + +### HIGH +- **Business Logic in Controller/Route** + - Sinais: cálculos, regras de domínio e validações pesadas dentro de rotas ou controllers. + - Impacto: dificulta testes e manutenção. + +- **Global Mutable State** + - Sinais: caches globais, variáveis de configuração mutáveis ou singletons mal definidos. + - Impacto: comportamento imprevisível em runtime. + +- **Deprecated / Vulnerable API Usage** + - Sinais: uso de métodos antigos, `badCrypto`, `fs.existsSync` sem tratamento, `require.extensions`, ou APIs sem suporte. + - Impacto: risco de quebra futura e segurança reduzida. + +### MEDIUM +- **N+1 Query** + - Sinais: loops que fazem consultas por item, `for`/`foreach` que executam consultas SQL ou ORM repetidas. + - Impacto: degradação de performance. + +- **Mixed Responsibilities** + - Sinais: rotas que também atualizam modelos, manipulam respostas e fazem persistência direta. + - Impacto: acoplamento e duplicação. + +- **Lack of Validation / Missing Input Checks** + - Sinais: parâmetros usados sem validação adequada. + - Impacto: erros, comportamento inesperado e possíveis vulnerabilidades. + +### LOW +- **Magic Values / Poor Naming** + - Sinais: strings não documentadas, variáveis sem significado, números mágicos. + - Impacto: legibilidade reduzida. + +- **Duplicate Code** + - Sinais: blocos repetidos de validação ou mapeamento. + - Impacto: manutenção dificultada. + +- **Implicit Configuration** + - Sinais: configurações definidas diretamente no código (porta, URI, debug). + - Impacto: dificuldade de mudar ambiente. + +## Deprecated API Examples +- Node.js `badCrypto` custom hashing → use `bcrypt` ou `crypto.pbkdf2`. +- Express: `app.use(bodyParser.json())` / `body-parser` → use `express.json()`. +- Flask: `app.config['DEBUG'] = True` em produção / `Flask` debug no código → use ambiente e `FLASK_ENV`. +- SQLite string concatenation → use query parametrizada ou ORM. + +## Como usar +- Compare padrões do código com os sinais acima. +- Para cada finding, inclua severidade e recomendação de correção. +- Se não houver sinal exato, use julgamento conservador baseado em acoplamento e risco. diff --git a/ecommerce-api-legacy/.claude/skills/refactor-arch/audit-report-template.md b/ecommerce-api-legacy/.claude/skills/refactor-arch/audit-report-template.md new file mode 100644 index 000000000..2d6660c0c --- /dev/null +++ b/ecommerce-api-legacy/.claude/skills/refactor-arch/audit-report-template.md @@ -0,0 +1,33 @@ +# Audit Report Template + +Use este template para gerar os relatórios da Fase 2. + +--- +ARCHITECTURE AUDIT REPORT +--- +Project: {{project_name}} +Stack: {{language}} + {{framework}} +Files: {{file_count}} analyzed | {{line_count}} lines approx. + +## Summary +CRITICAL: {{critical_count}} | HIGH: {{high_count}} | MEDIUM: {{medium_count}} | LOW: {{low_count}} + +## Findings + +{{#each findings}} +### [{{severity}}] {{title}} +File: {{file}}:{{line_start}}-{{line_end}} +Description: {{description}} +Impact: {{impact}} +Recommendation: {{recommendation}} + +{{/each}} +--- +Total: {{total_findings}} findings +--- + +Notes: +- Ordene findings por severidade decrescente. +- Use linhas exatas quando possível. +- Inclua pelo menos um finding por severidade sempre que aplicável. +- Se o projeto usa APIs deprecated, destaque isso como parte da auditoria. diff --git a/ecommerce-api-legacy/.claude/skills/refactor-arch/mvc-guidelines.md b/ecommerce-api-legacy/.claude/skills/refactor-arch/mvc-guidelines.md new file mode 100644 index 000000000..278859193 --- /dev/null +++ b/ecommerce-api-legacy/.claude/skills/refactor-arch/mvc-guidelines.md @@ -0,0 +1,65 @@ +# MVC Guidelines + +## Objetivo +Definir regras claras para refatorar qualquer projeto legado para uma arquitetura MVC sustentável. + +## Camadas MVC +### Models +Responsabilidades: +- Abstrair acesso a dados e persistência. +- Definir entidades e mapeamento de dados. +- Validar regras de integridade de dados de baixo nível. + +Exemplos: +- Python: classes ou funções em `models/` que executam queries parametrizadas ou usam ORM. +- Node.js: objetos/repositórios que encapsulam `db.query` e retornam dados. + +### Controllers +Responsabilidades: +- Orquestrar a lógica de aplicação. +- Chamar models e serviços. +- Tratar entradas e resultados antes de enviar resposta. +- Delegar tratamento de erros para middleware. + +Exemplo: +- `controllers/produto_controller.py` ou `controllers/checkoutController.js`. + +### Views / Routes +Responsabilidades: +- Expor endpoints HTTP. +- Mapear rotas para controllers. +- Não conter lógica de negócios ou regras complexas. + +Exemplo: +- rotas Flask com Blueprint ou `app.add_url_rule` +- `express.Router()` que importa controllers + +## Configuração +- Mova segredos e URIs para `config/settings.py` ou `config/index.js`. +- Use variáveis de ambiente para valores sensíveis. +- Não deixe `SECRET_KEY`, `DB_URI`, `API_KEY` codificados. + +## Middlewares e Tratamento de Erros +- Centralize captura de exceções. +- Crie middleware para validação e erros. +- Evite `try/except` ou `try/catch` espalhados que repetem mensagens. + +## Regras de Refatoração +- Rotas devem ser finas: validação mínima + chamada de controller. +- Controllers devem ser responsáveis pelo fluxo, não por persistência detalhada. +- Models devem ser responsáveis pela persistência e retorno de dados em formatos simples. +- Normalizar respostas JSON / status HTTP de forma consistente. +- Evitar dependências circulares entre camadas. + +## Estrutura mínima sugerida +- `config/` +- `models/` +- `controllers/` +- `routes/` ou `views/` +- `middlewares/` +- `app.py` / `server.js` + +## Validação após refatoração +- A aplicação deve iniciar sem erros. +- Um endpoint representativo deve responder corretamente. +- O projeto deve estar mais modular e com responsabilidade separada. diff --git a/ecommerce-api-legacy/.claude/skills/refactor-arch/project-analysis-guidelines.md b/ecommerce-api-legacy/.claude/skills/refactor-arch/project-analysis-guidelines.md new file mode 100644 index 000000000..f2a90d005 --- /dev/null +++ b/ecommerce-api-legacy/.claude/skills/refactor-arch/project-analysis-guidelines.md @@ -0,0 +1,54 @@ +# Project Analysis Guidelines + +## Objetivo +Fornecer regras e heurísticas para detectar linguagem, framework, banco de dados e arquitetura de um projeto legado. + +## Linguagem e Framework +### Python +- Detecte `import flask`, `from flask import`, `Flask(__name__)` → Flask. +- Detecte `from flask_sqlalchemy import SQLAlchemy` → Flask + SQLAlchemy. +- Detecte `app.add_url_rule`, `@app.route`, `Blueprint`. +- Detecte `requirements.txt` ou `setup.py` com `Flask`, `flask-cors`, `sqlalchemy`. + +### JavaScript / Node.js +- Detecte `require('express')`, `import express from 'express'`, `app.use(express.json())` → Express. +- Detecte `app.listen`, `express.Router()`, `module.exports =`. +- Detecte `package.json` com dependências `express`, `sqlite3`, `body-parser`. + +## Banco de Dados +- Detecte arquivos `database.py`, `sqlite3.connect`, `sqlite3.Database`, `SQLAlchemy`, `pg`, `mysql`, `mongoose`. +- Identifique a persistência via queries SQL ou ORM. +- Liste tabelas se conseguir extrair `CREATE TABLE` ou `db.run`/`cursor.execute`. + +## Arquitetura Atual +### Monolítico +- Todos os endpoints e lógica em um único arquivo. +- Models de dados, validação e rotas misturados. + +### Parcialmente Organizado +- Existe alguma separação de `models/`, `routes/`, `services/` ou `controllers/`, mas ainda há vazamento de lógica entre camadas. + +### MVC / Estrutura clara +- `models`, `controllers` e `routes/views` separados. +- `config` e `middlewares` também definidos separadamente. + +## Domínio e Contexto +- Determine a área funcional principal do projeto. +- Exemplos: + - E-commerce API (produtos, pedidos, usuários) + - LMS API / checkout + - Task Manager API + +## Output Esperado da Análise +- Language: Python / JavaScript +- Framework: Flask / Express +- Dependencies: principais bibliotecas detectadas +- Domain: descrição curta do domínio +- Architecture: monolítico / parcialmente organizado / MVC parcial +- Source files: lista e contagem de arquivos analisados +- DB tables: entidades ou tabelas conhecidas + +## Regras de Análise +- Não altere arquivos nesta fase. +- Extraia sinais de arquitetura mesmo quando o código estiver parcialmente organizado. +- Se houver múltiplos projetos no mesmo diretório, limite-se ao projeto atual. diff --git a/ecommerce-api-legacy/.claude/skills/refactor-arch/refactor-playbook.md b/ecommerce-api-legacy/.claude/skills/refactor-arch/refactor-playbook.md new file mode 100644 index 000000000..4ed2556f9 --- /dev/null +++ b/ecommerce-api-legacy/.claude/skills/refactor-arch/refactor-playbook.md @@ -0,0 +1,169 @@ +# Refactor Playbook + +## Objetivo +Fornecer padrões concretos de transformação para anti-patterns comuns em projetos legacy. + +## 1. God Class / God Module +Antes: +- Um arquivo contém rotas, lógica de negócio e queries. + +Depois: +- Rotas em `routes/` +- Fluxo em `controllers/` +- Persistência em `models/` + +Exemplo Python: +- `app.py` registra rotas +- `controllers/produto_controller.py` chama `models/produto_model.py` +- `models/produto_model.py` executa SELECT/INSERT. + +## 2. Hardcoded Secrets +Antes: +- `app.config['SECRET_KEY'] = 'abc'` +- `config.paymentGatewayKey = 'pk_live_...'` + +Depois: +- `config/settings.py` lê `os.environ.get('SECRET_KEY')` +- `config/index.js` lê `process.env.PAYMENT_GATEWAY_KEY` + +## 3. SQL Injection / Query Concatenation +Antes: +- `cursor.execute("SELECT * FROM usuarios WHERE id = " + str(id))` +- `db.run("INSERT INTO users VALUES ('" + name + "')")` + +Depois: +- `cursor.execute("SELECT * FROM usuarios WHERE id = ?", (id,))` +- `db.run("INSERT INTO users VALUES (?)", [name])` + +## 4. Business Logic in Controller/Route +Antes: +- Rota valida, calcula totas e atualiza estoque diretamente. + +Depois: +- Rota extrai dados e chama `order_controller.create_order()`. +- Controller chama `order_service.process_order()` e `order_model.update_stock()`. + +## 5. Global Mutable State +Antes: +- `let globalCache = {}` +- `db_connection = None` + +Depois: +- Criar módulo de cache imutável ou session-scoped. +- Inicializar conexão de DB em `database.py`/`db.js` com função getter. + +## 6. N+1 Query +Antes: +- `for item in pedidos: cursor.execute('SELECT ...')` + +Depois: +- Use JOIN ou query única para buscar itens associados. +- Ou faça query em lote para IDs coletados. + +## 7. Missing Input Validation +Antes: +- aceita `request.get_json()` sem checar campos. + +Depois: +- validar campos obrigatórios e tipos antes de chamar o controller. +- usar middleware / helper de validação quando possível. + +## 8. Deprecated API Usage +Antes: +- `badCrypto()` custom insecure hashing +- `app.use(bodyParser.json())` + +Depois: +- usar `bcrypt`/`crypto` para hashing +- usar `express.json()` no Express +- evitar `DEBUG=True` no código, use env var + +## Exemplo de transformação antes/depois +### Python/Flask +Antes: +```python +@app.route('/produtos', methods=['POST']) +def criar_produto(): + dados = request.get_json() + nome = dados['nome'] + cursor.execute("INSERT INTO produtos (...) VALUES (...)" ) + return jsonify(...) +``` +Depois: +```python +@produtos_bp.route('/produtos', methods=['POST']) +def criar_produto(): + return produto_controller.criar_produto(request.get_json()) +``` + +Em `controllers/produto_controller.py`: +```python +def criar_produto(dados): + validar_dados_produto(dados) + novo_id = produto_model.criar_produto(dados) + return jsonify({'id': novo_id, 'sucesso': True}), 201 +``` + +Em `models/produto_model.py`: +```python +def criar_produto(data): + db = get_db() + cursor = db.cursor() + cursor.execute( + 'INSERT INTO produtos (nome, descricao, preco, estoque, categoria) VALUES (?, ?, ?, ?, ?)', + (data['nome'], data.get('descricao',''), data['preco'], data['estoque'], data.get('categoria','geral')) + ) + db.commit() + return cursor.lastrowid +``` + +### Node.js/Express +Antes: +```js +app.post('/api/checkout', (req, res) => { + let cc = req.body.card; + if (!cc) return res.status(400).send('Bad Request'); + db.get('SELECT * FROM courses WHERE id = ' + cid, ...) +}); +``` +Depois: +```js +router.post('/api/checkout', checkoutController.handleCheckout); +``` + +Em `controllers/checkoutController.js`: +```js +async function handleCheckout(req, res) { + const { c_id, card } = req.body; + validateCheckoutInput(req.body); + const course = await courseService.findCourse(c_id); + await paymentService.processPayment({ card, course }); + res.json({ msg: 'Sucesso' }); +} +``` + +## Uso do playbook +- Associe cada finding do catálogo a uma transformação. +- Aplique mudanças incrementais e mantenha a aplicação funcional. +- Priorize correções de segurança e separação de responsabilidades. + +## 9. Plaintext Passwords in Seeds/Defaults +Antes: +- `db.run("INSERT INTO users (pass) VALUES ('123')")` +- `u1.password = '1234'` sem hash. + +Depois: +- Os seeds **devem** obrigatoriamente usar a mesma função de hash (ex: `bcrypt.hashSync`, `generate_password_hash`) usada pelo sistema. +- Node.js: `const pwd = bcrypt.hashSync('123', 10); db.run("... VALUES (?)", [pwd])` +- Python: `u1.set_password('1234')` garantindo que o método de fato faça o hash no banco. + +## 10. Fake / Unsigned JWTs +Antes: +- `token = "jwt-demo-" + str(user.id)` +- Rota que ignora validação de assinatura e confia na string. + +Depois: +- Gerar JWTs reais assinados usando a `SECRET_KEY` da aplicação. +- Node.js: usar `jsonwebtoken` (`jwt.sign(payload, SECRET_KEY)`). +- Python: usar `PyJWT` (`jwt.encode(payload, SECRET_KEY, algorithm="HS256")`). +- Ao verificar requisições, garantir que a assinatura do JWT seja validada. diff --git a/ecommerce-api-legacy/package-lock.json b/ecommerce-api-legacy/package-lock.json index d2dacbe4f..b7a1c1b4f 100644 --- a/ecommerce-api-legacy/package-lock.json +++ b/ecommerce-api-legacy/package-lock.json @@ -8,6 +8,7 @@ "name": "desafio-arquitetura-ia-boilerplate", "version": "1.0.0", "dependencies": { + "bcryptjs": "^3.0.3", "express": "^4.18.2", "sqlite3": "^5.1.6" } @@ -205,6 +206,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", diff --git a/ecommerce-api-legacy/package.json b/ecommerce-api-legacy/package.json index 76b00a983..c283950d7 100644 --- a/ecommerce-api-legacy/package.json +++ b/ecommerce-api-legacy/package.json @@ -7,7 +7,8 @@ "start": "node src/app.js" }, "dependencies": { + "bcryptjs": "^3.0.3", "express": "^4.18.2", "sqlite3": "^5.1.6" } -} \ No newline at end of file +} 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..8ebd0357c 100644 --- a/ecommerce-api-legacy/src/app.js +++ b/ecommerce-api-legacy/src/app.js @@ -1,14 +1,32 @@ +/** + * Entry point / Composition root da aplicação Express. + * Registra middlewares, rotas e inicializa o banco. + */ const express = require('express'); -const AppManager = require('./AppManager'); -const { config } = require('./utils'); +const path = require('path'); +const routes = require('./routes'); +const { config } = require('./config'); +const { initDb, seed } = require('./models/db'); +const { notFoundHandler, errorHandler } = require('./middlewares/errorHandler'); const app = express(); app.use(express.json()); -const manager = new AppManager(); -manager.initDb(); -manager.setupRoutes(app); +// Inicializa persistência e seed antes de subir. +initDb(); +seed(); -app.listen(config.port, () => { - console.log(`Frankenstein LMS rodando na porta ${config.port}...`); -}); +// Rotas da aplicação. +app.use('/api', routes); + +// Tratamento de erros centralizado. +app.use(notFoundHandler); +app.use(errorHandler); + +if (require.main === module) { + app.listen(config.port, () => { + console.log(`LMS rodando na porta ${config.port}...`); + }); +} + +module.exports = app; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/config/index.js b/ecommerce-api-legacy/src/config/index.js new file mode 100644 index 000000000..2d6da6f9c --- /dev/null +++ b/ecommerce-api-legacy/src/config/index.js @@ -0,0 +1,14 @@ +const path = require('path'); + +// Configurações lidas de variáveis de ambiente (segredos nunca hardcoded). +const config = { + port: Number(process.env.PORT || 3000), + paymentGatewayKey: process.env.PAYMENT_GATEWAY_KEY || '', + smtpUser: process.env.SMTP_USER || '', + dbFile: process.env.DB_FILE || ':memory:', + minPasswordLength: 6, + isProduction: process.env.NODE_ENV === 'production', +}; + +// Re-export da config (compat + facilidade de uso pelos módulos). +module.exports = { config }; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/controllers/checkoutController.js b/ecommerce-api-legacy/src/controllers/checkoutController.js new file mode 100644 index 000000000..5e0ab8a04 --- /dev/null +++ b/ecommerce-api-legacy/src/controllers/checkoutController.js @@ -0,0 +1,18 @@ +/** + * Controller de checkout — fluxo HTTP -> service. + */ +const { processCheckout, CheckoutError } = require('../services/checkoutService'); + +async function handleCheckout(req, res, next) { + try { + const result = await processCheckout(req.body); + res.status(200).json(result); + } catch (err) { + if (err instanceof CheckoutError) { + return res.status(err.status).json({ error: err.message }); + } + next(err); + } +} + +module.exports = { handleCheckout }; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/controllers/reportController.js b/ecommerce-api-legacy/src/controllers/reportController.js new file mode 100644 index 000000000..ba6d71e4f --- /dev/null +++ b/ecommerce-api-legacy/src/controllers/reportController.js @@ -0,0 +1,15 @@ +/** + * Controller de relatório — fluxo HTTP -> service. + */ +const { buildFinancialReport } = require('../services/reportService'); + +async function handleFinancialReport(req, res, next) { + try { + const report = await buildFinancialReport(); + res.json(report); + } catch (err) { + next(err); + } +} + +module.exports = { handleFinancialReport }; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/controllers/userController.js b/ecommerce-api-legacy/src/controllers/userController.js new file mode 100644 index 000000000..eb88d9136 --- /dev/null +++ b/ecommerce-api-legacy/src/controllers/userController.js @@ -0,0 +1,15 @@ +/** + * Controller de usuários — fluxo HTTP -> model (com transação limpa). + */ +const userModel = require('../models/userModel'); + +function removeUser(req, res, next) { + userModel + .deleteById(Number(req.params.id)) + .then(() => { + res.json({ msg: 'Usuário removido juntamente com matrículas e pagamentos' }); + }) + .catch(next); +} + +module.exports = { removeUser }; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/middlewares/authMiddleware.js b/ecommerce-api-legacy/src/middlewares/authMiddleware.js new file mode 100644 index 000000000..341f9ba97 --- /dev/null +++ b/ecommerce-api-legacy/src/middlewares/authMiddleware.js @@ -0,0 +1,19 @@ +/** + * Middleware de autenticação para rotas administrativas. + * Protege endpoints sensíveis (relatório financeiro) exigindo token de admin. + */ +const ADMIN_TOKEN = process.env.ADMIN_TOKEN || null; + +function requireAdmin(req, res, next) { + // Em produção o ADMIN_TOKEN deve existir via env; sem ele, nega por padrão. + if (!ADMIN_TOKEN) { + return res.status(403).json({ error: 'Acesso administrativo não configurado' }); + } + const token = req.headers['x-admin-token'] || req.query.token; + if (token !== ADMIN_TOKEN) { + return res.status(401).json({ error: 'Não autorizado' }); + } + next(); +} + +module.exports = { requireAdmin }; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/middlewares/errorHandler.js b/ecommerce-api-legacy/src/middlewares/errorHandler.js new file mode 100644 index 000000000..7f218bd75 --- /dev/null +++ b/ecommerce-api-legacy/src/middlewares/errorHandler.js @@ -0,0 +1,19 @@ +/** + * Handler de erros centralizado — converte erros de domínio e internos + * em respostas JSON padronizadas. + */ +const { CheckoutError } = require('../services/checkoutService'); + +function notFoundHandler(req, res) { + res.status(404).json({ error: 'Rota não encontrada' }); +} + +function errorHandler(err, req, res, next) { + if (err instanceof CheckoutError) { + return res.status(err.status).json({ error: err.message }); + } + console.error(err); + res.status(500).json({ error: 'Erro interno do servidor' }); +} + +module.exports = { notFoundHandler, errorHandler }; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/models/courseModel.js b/ecommerce-api-legacy/src/models/courseModel.js new file mode 100644 index 000000000..70b6d5889 --- /dev/null +++ b/ecommerce-api-legacy/src/models/courseModel.js @@ -0,0 +1,85 @@ +/** + * Model de cursos — acesso a dados de cursos, matrículas e pagamentos. + */ +const { getDb } = require('./db'); + +function findActiveById(id) { + return new Promise((resolve, reject) => { + getDb().get('SELECT * FROM courses WHERE id = ? AND active = 1', [id], (err, row) => { + if (err) return reject(err); + resolve(row || null); + }); + }); +} + +function createEnrollment(userId, courseId) { + return new Promise((resolve, reject) => { + getDb().run( + 'INSERT INTO enrollments (user_id, course_id) VALUES (?, ?)', + [userId, courseId], + function (err) { + if (err) return reject(err); + resolve(this.lastID); + } + ); + }); +} + +function createPayment(enrollmentId, amount, status) { + return new Promise((resolve, reject) => { + getDb().run( + 'INSERT INTO payments (enrollment_id, amount, status) VALUES (?, ?, ?)', + [enrollmentId, amount, status], + function (err) { + if (err) return reject(err); + resolve(this.lastID); + } + ); + }); +} + +function writeAuditLog(action) { + return new Promise((resolve, reject) => { + getDb().run( + "INSERT INTO audit_logs (action, created_at) VALUES (?, datetime('now'))", + [action], + (err) => { + if (err) return reject(err); + resolve(true); + } + ); + }); +} + +/** + * Consulta agregada única (JOIN) para o relatório financeiro, + * eliminando as infinitas queries aninhadas (N+1). + */ +function getReportData() { + return new Promise((resolve, reject) => { + const sql = ` + SELECT c.id AS course_id, c.title, + e.id AS enrollment_id, e.user_id, + u.name AS student_name, u.email AS student_email, + p.amount AS payment_amount, p.status AS payment_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 + WHERE c.active = 1 + ORDER BY c.id, e.id + `; + getDb().all(sql, (err, rows) => { + if (err) return reject(err); + resolve(rows || []); + }); + }); +} + +module.exports = { + findActiveById, + createEnrollment, + createPayment, + writeAuditLog, + getReportData, +}; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/models/db.js b/ecommerce-api-legacy/src/models/db.js new file mode 100644 index 000000000..6e1e313cd --- /dev/null +++ b/ecommerce-api-legacy/src/models/db.js @@ -0,0 +1,39 @@ +/** + * Persistência — encapsula a conexão SQLite. + * Substitui o estado global por uma única factory inicializada no boot. + */ +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); +const { config } = require('../config'); + +let db = null; + +function initDb() { + if (db) return db; + db = new sqlite3.Database(config.dbFile); + db.serialize(() => { + db.run('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, pass TEXT)'); + db.run('CREATE TABLE IF NOT EXISTS courses (id INTEGER PRIMARY KEY, title TEXT, price REAL, active INTEGER)'); + db.run('CREATE TABLE IF NOT EXISTS enrollments (id INTEGER PRIMARY KEY, user_id INTEGER, course_id INTEGER)'); + db.run('CREATE TABLE IF NOT EXISTS payments (id INTEGER PRIMARY KEY, enrollment_id INTEGER, amount REAL, status TEXT)'); + db.run('CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY, action TEXT, created_at DATETIME)'); + }); + return db; +} + +function getDb() { + if (!db) return initDb(); + return db; +} + +function seed() { + const database = getDb(); + const { hashPassword } = require('../utils/security'); + const hashedPass = hashPassword('123'); + database.run("INSERT OR IGNORE INTO users (id, name, email, pass) VALUES (1, 'Leonan', 'leonan@fullcycle.com.br', ?)", [hashedPass]); + database.run("INSERT OR IGNORE INTO courses (title, price, active) VALUES ('Clean Architecture', 997.00, 1), ('Docker', 497.00, 1)"); + database.run("INSERT OR IGNORE INTO enrollments (user_id, course_id) VALUES (1, 1)"); + database.run("INSERT OR IGNORE INTO payments (enrollment_id, amount, status) VALUES (1, 997.00, 'PAID')"); +} + +module.exports = { initDb, getDb, seed }; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/models/userModel.js b/ecommerce-api-legacy/src/models/userModel.js new file mode 100644 index 000000000..21bd6c0a8 --- /dev/null +++ b/ecommerce-api-legacy/src/models/userModel.js @@ -0,0 +1,69 @@ +/** + * Model de usuários — persistência de usuários e autenticação. + */ +const bcrypt = require('bcryptjs'); +const { getDb } = require('./db'); + +function findById(id) { + return new Promise((resolve, reject) => { + getDb().get('SELECT id, name, email FROM users WHERE id = ?', [id], (err, row) => { + if (err) return reject(err); + resolve(row || null); + }); + }); +} + +function findByEmail(email) { + return new Promise((resolve, reject) => { + getDb().get('SELECT * FROM users WHERE email = ?', [email], (err, row) => { + if (err) return reject(err); + resolve(row || null); + }); + }); +} + +function hashPassword(password) { + return bcrypt.hash(password, 10); +} + +function create(name, email, password) { + return hashPassword(password).then((passHash) => { + return new Promise((resolve, reject) => { + getDb().run( + 'INSERT INTO users (name, email, pass) VALUES (?, ?, ?)', + [name, email, passHash], + function (err) { + if (err) return reject(err); + resolve({ id: this.lastID, name, email }); + } + ); + }); + }); +} + +function verifyPassword(password, hash) { + return bcrypt.compareSync(password, hash); +} + +function deleteById(id) { + return new Promise((resolve, reject) => { + getDb().serialize(() => { + getDb().run('BEGIN'); + getDb().run( + 'DELETE FROM payments WHERE enrollment_id IN (SELECT id FROM enrollments WHERE user_id = ?)', + [id] + ); + getDb().run('DELETE FROM enrollments WHERE user_id = ?', [id]); + getDb().run('DELETE FROM users WHERE id = ?', [id], (err) => { + if (err) { + getDb().run('ROLLBACK'); + return reject(err); + } + getDb().run('COMMIT'); + resolve(true); + }); + }); + }); +} + +module.exports = { findById, findByEmail, create, verifyPassword, deleteById }; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/routes/index.js b/ecommerce-api-legacy/src/routes/index.js new file mode 100644 index 000000000..170099a55 --- /dev/null +++ b/ecommerce-api-legacy/src/routes/index.js @@ -0,0 +1,20 @@ +/** + * Rotas de checkout e administrativas. + * Rotas finas: extraem dados e delegam a controllers. + */ +const express = require('express'); +const { handleCheckout } = require('../controllers/checkoutController'); +const { handleFinancialReport } = require('../controllers/reportController'); +const { removeUser } = require('../controllers/userController'); +const { requireAdmin } = require('../middlewares/authMiddleware'); + +const router = express.Router(); + +router.post('/checkout', handleCheckout); + +// Relatório financeiro protegido por autenticação de admin. +router.get('/admin/financial-report', requireAdmin, handleFinancialReport); + +router.delete('/users/:id', removeUser); + +module.exports = router; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/services/checkoutService.js b/ecommerce-api-legacy/src/services/checkoutService.js new file mode 100644 index 000000000..2a470b48e --- /dev/null +++ b/ecommerce-api-legacy/src/services/checkoutService.js @@ -0,0 +1,54 @@ +/** + * Serviço de checkout — orquestra regras de negócio do fluxo de compra. + * (Rota → controller → service → models) + */ +const userModel = require('../models/userModel'); +const courseModel = require('../models/courseModel'); +const { paymentGatewayStatus, cacheSet } = require('../utils/security'); + +class CheckoutError extends Error { + constructor(message, status = 400) { + super(message); + this.status = status; + } +} + +function validateInput({ usr, eml, pwd, c_id, card }) { + if (!usr || !eml || !pwd) throw new CheckoutError('Bad Request', 400); + if (!c_id) throw new CheckoutError('Bad Request', 400); + if (!card || typeof card !== 'string' || card.length < 4) { + throw new CheckoutError('Bad Request', 400); + } + if (pwd.length < 6) throw new CheckoutError('Senha deve ter ao menos 6 caracteres', 400); +} + +async function processCheckout({ usr, eml, pwd, c_id, card }) { + validateInput({ usr, eml, pwd, c_id, card }); + + const course = await courseModel.findActiveById(c_id); + if (!course) throw new CheckoutError('Curso não encontrado', 404); + + // Garante usuário (cria se não existir), com senha hasheada. + let user = await userModel.findByEmail(eml); + if (!user) { + user = await userModel.create(usr, eml, pwd); + } else { + const ok = userModel.verifyPassword(pwd, user.pass); + if (!ok) throw new CheckoutError('Senha inválida para usuário existente', 401); + } + + // Gateway de pagamento (sem expor a chave em logs). + const status = paymentGatewayStatus(card); + if (status === 'DENIED') throw new CheckoutError('Pagamento recusado', 400); + + const enrollmentId = await courseModel.createEnrollment(user.id, course.id); + await courseModel.createPayment(enrollmentId, course.price, status); + await courseModel.writeAuditLog(`Checkout curso ${course.id} por ${user.id}`); + + // Cache request-scoped (não global mutável). + cacheSet(`last_checkout_${user.id}`, course.title); + + return { msg: 'Sucesso', enrollment_id: enrollmentId }; +} + +module.exports = { processCheckout, CheckoutError }; \ No newline at end of file diff --git a/ecommerce-api-legacy/src/services/reportService.js b/ecommerce-api-legacy/src/services/reportService.js new file mode 100644 index 000000000..041c22a50 --- /dev/null +++ b/ecommerce-api-legacy/src/services/reportService.js @@ -0,0 +1,39 @@ +/** + * Serviço de relatório financeiro — agrega dados por curso. + * Usa consulta JOIN única (sem N+1) e monta o payload de resposta. + */ +const courseModel = require('../models/courseModel'); + +async function buildFinancialReport() { + const rows = await courseModel.getReportData(); + + const report = []; + const index = new Map(); + + for (const row of rows) { + if (!index.has(row.course_id)) { + index.set(row.course_id, { + course: row.title, + revenue: 0, + students: [], + }); + report.push(index.get(row.course_id)); + } + const course = index.get(row.course_id); + + // Linha com LEFT JOIN sem matrícula (curso sem alunos) — ignora. + if (row.enrollment_id === null) continue; + + if (row.payment_status === 'PAID' && row.payment_amount != null) { + course.revenue += row.payment_amount; + } + course.students.push({ + student: row.student_name || 'Unknown', + paid: row.payment_amount || 0, + }); + } + + return report; +} + +module.exports = { buildFinancialReport }; \ No newline at end of file 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/ecommerce-api-legacy/src/utils/security.js b/ecommerce-api-legacy/src/utils/security.js new file mode 100644 index 000000000..e67519c3a --- /dev/null +++ b/ecommerce-api-legacy/src/utils/security.js @@ -0,0 +1,39 @@ +/** + * Utilities de segurança — hashing de senha e helpers de cache/esteira. + * Substitui o inseguro badCrypto e o estado global mutável. + */ +const bcrypt = require('bcryptjs'); + +// Cache scoped (por request) em vez de global mutável. +const cache = new Map(); + +function hashPassword(password) { + return bcrypt.hash(password, 10); +} + +function verifyPassword(password, hash) { + return bcrypt.compareSync(password, hash); +} + +function cacheSet(key, value) { + cache.set(key, value); +} + +function cacheGet(key) { + return cache.get(key); +} + +function paymentGatewayStatus(cardNumber) { + // Simulação: cartão que começa com '4' é aprovado. + if (typeof cardNumber !== 'string' || cardNumber.length < 4) return 'DENIED'; + return cardNumber.startsWith('4') ? 'PAID' : 'DENIED'; +} + +module.exports = { + hashPassword, + verifyPassword, + cacheSet, + cacheGet, + paymentGatewayStatus, + cache, +}; \ No newline at end of file diff --git a/reports/audit-project-1.md b/reports/audit-project-1.md new file mode 100644 index 000000000..1ef496193 --- /dev/null +++ b/reports/audit-project-1.md @@ -0,0 +1,109 @@ +# ARCHITECTURE AUDIT REPORT + +**Project:** code-smells-project +**Stack:** Python + Flask 3.1.1 +**Files:** 4 analyzed | ~780 lines of code + +## Summary +CRITICAL: 5 | HIGH: 4 | MEDIUM: 4 | LOW: 3 + +## Findings + +### [CRITICAL] Hardcoded Secrets +File: `app.py:7-8` +Description: `SECRET_KEY` hardcoded como `minha-chave-super-secreta-123` e `DEBUG=True` fixo no código. +Impact: Vazamento de chave secreta em qualquer controle de versão; debug ativo em produção. +Recommendation: Mover segredos para `config/settings.py` lendo `os.environ`, e `DEBUG` via `FLASK_DEBUG`. + +### [CRITICAL] SQL Injection / Query Concatenation +File: `models.py:28,48,92,110,127,140,149,174,188,224,280,291` +Description: Queries construídas por concatenação de strings com inputs diretamente interpolados (ex: `"WHERE id = " + str(id)`). +Impact: Execução de SQL arbitrário (injeção). +Recommendation: Usar queries parametrizadas (`?` + tupla de args) em todas as operações. + +### [CRITICAL] Backdoor / Unsafe Admin Query +File: `app.py:59-78` +Description: Endpoint `POST /admin/query` executa qualquer SQL arbitrário sem qualquer autenticação. +Impact: Leitura/escrita/truncagem total do banco por qualquer requester. +Recommendation: Remover o endpoint; ele não faz parte do domínio da API. Qualquer admin real deve passar por autenticação. + +### [CRITICAL] Backdoor / Unsafe Admin Reset +File: `app.py:47-57` +Description: Endpoint `POST /admin/reset-db` apaga (`DELETE`) todas as tabelas sem autenticação. +Impact: Destruição total de dados da aplicação. +Recommendation: Remover; destruição de dados deve requerer autorização e não expor via HTTP público. + +### [CRITICAL] God Module +File: `models.py:1-314` +Description: Arquivo único contém todas as queries, regras de negócio, cálculos de pedido e formatação de resposta para 4 domínios (produto, usuário, pedido, relatório). +Impact: Impossível testar isoladamente; qualquer mudança afeta tudo. +Recommendation: Split em `models/` por domínio (`produto_model`, `usuario_model`, `pedido_model`) + `services/` para regras. + +### [HIGH] Business Logic in Controller/Route +File: `controllers.py:167-186,188-220` +Description: Regras de negócio e notificações "EMail/SMS/PUSH" via `print` presas dentro dos handlers de rota. +Impact: Dificulta testes e manutenção; efeitos colaterais (prints) acoplados ao fluxo HTTP. +Recommendation: Extrair para `services/pedido_service.py` como `processar_criacao()`. + +### [HIGH] Business Logic in Model +File: `models.py:133-169` +Description: `criar_pedido()` no model calcula total, valida estoque e aplica regras de domínio indevidas para a camada de persistência. +Impact: Model deixa de ser abstração de dados; difícil reutilização. +Recommendation: Mover validação/cálculo para `services/pedido_service`; model só persiste. + +### [HIGH] Global Mutable State +File: `database.py:4` +Description: Conexão de banco em variável global mutável (`db_connection = None`). +Impact: Comportamento imprevisível em concorrência. +Recommendation: Inicialização preguiçosa via getter, centralizada no módulo de banco. + +### [HIGH] Deprecated / Unsafe Config (DEBUG no código) +File: `app.py:8,88` +Description: `app.config['DEBUG']=True` e `app.run(debug=True)` fixos no código. +Impact: Debug/online errors expostos; risco em produção. +Recommendation: Ler do ambiente (`FLASK_DEBUG`). + +### [MEDIUM] N+1 Query +File: `models.py:139-166,188-192,220-224` +Description: Loops executam `SELECT` por item (stocks de produtos por item dentro do pedido; itens por pedido dentro de loop). +Impact: Degradação de performance à medida que o volume cresce. +Recommendation: JOIN para itens por pedido; busca única de produtos por lista de IDs. + +### [MEDIUM] Lack of Input Validation +File: `models.py:122-130`, `controllers.py:146-165` +Description: Senha armazenada e comparada em texto puro; endpoints admin sem qualquer checagem de autorientação. +Impact: Credenciais expostas; acesso indevido. +Recommendation: Hash (sha256/bcrypt) da senha + leitura de env para admin. + +### [MEDIUM] Implicit Configuration +File: hardcoded em `app.py`/`database.py` +Description: Porta `5000`, host `0.0.0.0`, `loja.db`, status de pedidos, categorias válidas — tudo magic no código. +Impact: Difícil mudar de ambiente (test/prod). +Recommendation: Mover para `config/settings.py` via variáveis de ambiente. + +### [MEDIUM] Mixed Responsibilities +File: `models.py` (várias) +Description: O módulo de persistência também formata e manipula respostas JSON (construção de dict). +Impact: Acoplamento entre persistência e apresentação. +Recommendation: Manter models somente com acesso a dados; formato de resposta definido no controller. + +### [LOW] Magic Numbers / Poor Naming +File: `controllers.py:43-50` / falta de nomes de constantes +Description: Faixas de desconto magicas `10000/5000/1000` e `0.10/0.05/0.02` no relatório; variáveis genéricas (`id`, `dados`). +Impact: Legibilidade reduzida. +Recommendation: Extrair para `config/settings.py` (`DESCONTO_FAIXAS`). + +### [LOW] Duplicate Code +File: `models.py:171-233` +Description: `get_pedidos_usuario` e `get_todos_pedidos` quase idênticos (duplicação de lógica de montagem de itens). +Impact: Manutenção difícil; correções duplicadas. +Recommendation: Extrair helper `_pedido_com_itens()` reutilizável. + +### [LOW] Debug Prints Leftovers +File: `controllers.py` (espalhado) +Description: `print(...)` em várias rotas para logs de debug (listando, criando, login, pedido). +Impact: Ruído; não é logging estruturado. +Recommendation: Remover ou substituir por module-level logging. + +--- +**Total: 16 findings** (ordenação por severidade CRITICAL → LOW) \ No newline at end of file diff --git a/reports/audit-project-2.md b/reports/audit-project-2.md new file mode 100644 index 000000000..15bfa7f72 --- /dev/null +++ b/reports/audit-project-2.md @@ -0,0 +1,86 @@ +# ARCHITECTURE AUDIT REPORT + +**Project:** ecommerce-api-legacy +**Stack:** JavaScript (Node.js) + Express 4.18.2 + SQLite +**Files:** 3 analyzed | ~278 lines of code + +## Summary +CRITICAL: 5 | HIGH: 3 | MEDIUM: 3 | LOW: 2 + +## Findings + +### [CRITICAL] Hardcoded Production Secrets +File: `src/utils.js:1-7` +Description: Credenciais de produção hardcoded: `dbPass: 'senha_super_secreta_prod_123'`, `paymentGatewayKey: 'pk_live_1234567890abcdef'`, `smtpUser`. +Impact: Vazamento de segredos em qualquer controle de versão; acesso indevido a gateways de pagamento e SMTP. +Recommendation: Mover para `process.env` (e.g. `config/index.js` lendo `PAYMENT_GATEWAY_KEY`, `SMTP_USER`) e não commitar valores reais. + +### [CRITICAL] Insecure Custom Hashing (badCrypto) +**File:** `src/utils.js:17-23` +Description: `badCrypto()` faz "hash" concatenando `base64` dos primeiros 2 bytes 10.000× — reversível e não é criptografia. Usado no login de usuários (AppManager.js:68). +Impact: Senhas de todos os usuários são falsamente "protegidas" e trivialmente decifráveis. +Recommendation: Substituir por `bcrypt`/`crypto.scrypt` (`utils/security.js` → `hashPassword`). + +### [CRITICAL] Unauthenticated Financial Report (Backdoor) +**Location:** `src/AppManager.js:80-129` +Description: `GET /api/admin/financial-report` expõe receita, alunos e valores de pagamento sem qualquer autenticação; qualquer cliente pode ler dados financeiros. +Impact: Exposição total de dados sensíveis da plataforma. +Recommendation: Proteger com middleware de auth de admin (`requireAdmin`), exigindo token via env. + +### [CRITICAL] Global Mutable State +**Location:** `src/utils.js:9-10` +Description: `globalCache` e `totalRevenue` como variáveis globais mutáveis exportadas e alteradas em runtime. +Impact: Comportamento imprevisível, corrida em concorrência. +Recommendation: Estado scoped por requisição/request; cache via `Map` encapsulado (não exportado mutável). + +### [CRITICAL] Weak Default Credentials / Seed +**Location:** `src/AppManager.js:18` +Description: Usuário seedado com senha em texto puro `'123'` e hash inseguro para novos usuários; adição de entradas sem validação de força de senha. +Impact: Acesso trivial à conta admin/semente. +Recommendation: Forçar mínimo de 6 caracteres, usar bcrypt no seed e não salvar senha em texto. + +### [HIGH] God Class AppManager +**Location:** `src/AppManager.js:4` +Description: Uma única classe acumula conexão, schema, rotas, checkout, pagamento, matrícula, relatório e deleção — 4 concerns num arquivo. +Impact: Inviável de testar em isolado; alta acoplamento. +Recommendation: Split em camadas MVC: `models/`, `services/`, `controllers/`, `routes/`, `middlewares/`. + +### [HIGH] Business Logic Inside Routes +**Location:** `src/AppManager.js:28-78` +Description: Todo o fluxo de checkout (validação, consulta de curso, hash, pagamento, matrícula, audit) dentro do handler HTTP. +Recommendation: Extrair para `checkoutService.processCheckout()`; rota fica fina. + +### [HIGH] Non-standardized Error Handling +**Location:** `src/AppManager.js` (vários) +Description: Respostas de erro como strings HTML soltas (`res.send("Bad Request")`), erros `500` sem corpo estruturado, `next` sem uso. +Recommendation: Middleware de erro centralizado devolvendo JSON (`errorHandler`). + +### [MEDIUM] N+1 Queries (Financial Report) +**Location:** `src/AppManager.js:89-127` +Description: Relatório aninha `courses.forEach → enrollments.forEach → users/payments` com múltiplas queries por linha (padrão N+1). +Impact: Degradação clara de performance. +Recommendation: Consulta agregada única com JOIN (`getReportData`). + +### [MEDIUM] Duplicate Checkout/Enrollment Logic +**Location:** `src/AppManager.js:50-62` +Description: Matrícula + pagamento + audit_log em blocos quase duplicados nos dois caminhos (novo usuário / usuário existente). +Recommendation: Unificar em um único fluxo no service. + +### [MEDIUM] Orphan data on user deletion +**Location:** `src/AppManager.js:131-137` +Description: `DELETE /api/users/:id` remove o usuário mas deixa matrículas/pagamentos órfãos no banco, sem transação. +Recommendation: Transação que remove matrículas/pagamentos associados antes do usuário (`userModel.deleteById`). + +### [LOW] Enigmatic Names / Magic Numbers +**Location:** `src/AppManager.js:28-36` +Description: Propriedades obscuras (`usr`, `eml`, `pwd`, `c_id`, `cc`); cartão baseado em `startsWith('4')` sem constante. +Impact: Legibilidade reduzida. +Recommendation: Renomear para descritivos; extrair regra para helper. + +### [LOW] No input length/type validation +**Location:** `src/AppManager.js:35` +Description: A checagem é só presença de campos; tipos e limites (ex: tamanho de card/email) sem validação. +Recommendation: Validar tipos e comprimentos no service (`validateInput`). + +--- +**Total: 13 findings** (ordenação por severidade CRITICAL → LOW) \ No newline at end of file diff --git a/reports/audit-project-3.md b/reports/audit-project-3.md new file mode 100644 index 000000000..64669e7c9 --- /dev/null +++ b/reports/audit-project-3.md @@ -0,0 +1,92 @@ +# ARCHITECTURE AUDIT REPORT + +**Project:** task-manager-api +**Stack:** Python + Flask 3.0.0 + Flask-SQLAlchemy 3.1.1 +**Files:** 15 analyzed | ~1600 lines of code + +## Summary +CRITICAL: 4 | HIGH: 4 | MEDIUM: 4 | LOW: 2 + +## Findings + +### [CRITICAL] Hardcoded Secrets +File: `app.py:13`, `services/notification_service.py:7-10` +Description: `SECRET_KEY='super-secret-key-123'` no código; credenciais SMTP hardcoded (`taskmanager@gmail.com` / `senha123`). +Impact: Vazamento de chave de assinatura e credenciais de e-mail em qualquer controle de versão. +Recommendation: Mover para `config/settings.py` lendo variáveis de ambiente (python-dotenv já é dependência solta no requirements). + +### [CRITICAL] Weak Password Hashing (MD5 - Deprecated/Insecure API) +File: `models/user.py:29,32` +Description: `hashlib.md5(pwd).hexdigest()` usado para armazenar e conferir senhas — algoritmo trivialmente recuperável (rainbow tables). +Impact: Qualquer senha comprometida é decifrável; inclusive as do seed. +Recommendation: Usar `werkzeug.security.generate_password_hash` (pbkdf2) / `check_password_hash`. + +### [CRITICAL] Sensitive Data Exposed in API Responses +File: `models/user.py:16-25` +Description: `User.to_dict()` retorna o campo `password` (hash) em `/users`, `/users/` e `/login`. +Impact: Exposição de hashes de credenciais a qualquer usuário da API. +Recommendation: `to_dict()` não deve serializar `password`; nunca incluir o hash em respostas. + +### [CRITICAL] Global Mutable State in NotificationService +File: `services/notification_service.py:6` +Description: `self.notifications = []` acumula notificações em memória indefinidamente (estado global crescente). +Impact: Vazamento de memória e comportamento imprevisível em instância compartilhada. +Recommendation: Limitar o histórico (cap) ou persistir notificações. + +### [HIGH] Business Logic Inside Routes +File: `routes/task_routes.py`, `routes/report_routes.py`, `routes/user_routes.py` +Description: Rotas contêm validação de dados, cálculo de "overdue", estatísticas e CRUD manual (ex: `task_routes.py:30-57`, `report_routes.py:33-68`) em vez de delegar a serviços. `routes/` é a camada View do MVC e não deve abrigar rules. +Impact: Rotas grossas, difíceis de testar, validação duplicada. +Recommendation: Extraporting para `services/task_service`/`user_service`/`report_service`. + +### [HIGH] Massive N+1 Queries in Reports +File: `routes/report_routes.py:55-68`, `routes/task_routes.py:42-57` +Description: `Task.query.filter_by(user_id).all()` dentro de loop por usuário; `User.query.get()` e `Category.query.get()` por task; count por categoria em loop (`task_count`). +Impact: centenas de queries SQL por request; degradação clara. +Recommendation: eager loading (`joinedload`), agregação única por usuário, ou consulta em batch. + +### [HIGH] Fake JWT Token (Forged Auth Claim) +File: `routes/user_routes.py:210` +Description: `token: 'fake-jwt-token-' + str(user.id)` — string não assinada apresentada como token de autenticação. +Impact: dá falsa sensação de segurança; qualquer client pode forjá-lo. +Recommendation: usar `pyjwt` com `SECRET_KEY` real, ou remover a claim de auth. + +### [HIGH] Duplicated "Is Overdue" Logic +File: `routes/task_routes.py:30-39,71-80`, `routes/report_routes.py:33-43,132-135`, `routes/user_routes.py:171-180` +Description: A mesma condição `due_date < utcnow AND status not in (done, cancelled)` repetida em ~5 lugares com variações. +Impact: inconsistência futura ao mudar regra. +Recommendation: centralizar em `Task.is_overdue()` (model já tem) e reutilizar. + +### [MEDIUM] Inconsistent Error Handling +File: múltiplos `except:` e `except Exception as e` com mensagens duplicadas (`report_routes.py:186,207,221'; `task_routes.py`). +Impact: erros difíceis de depurar; respostas inconsistentes. +Recommendation: decorator/middleware central de erro que traduz exceções em JSON. + +### [MEDIUM] Implicit Configuration +File: `app.py:11,34`, `requirements` tem `python-dotenv` +Description: `SQLALCHEMY_DATABASE_URI`, `SECRET_KEY`, `debug=True`, host/porta hardcoded. +Impact: ambiente (test/prod) difícil de trocar. +Recommendation: `config/settings.py` via env (dotenv). + +### [MEDIUM] Missing/Weak Input Validation in Some Endpoints +File: `routes/report_routes.py:167-188` (categorias não validam cor), `routes/user_routes.py:93-132` (dados aceitos sem checagem parcial). +Impact: dados inconsistentes no banco. +Recommendation: unificar validações no service usando constantes de `config`. + +### [MEDIUM] Debug Print Statements +File: `services`/`routes` — `print(...)` espalhados (`task_routes.py:149,153,219`; `user_routes:83,89,147`; `notification:21,24`). +Impact: ruído no log; sem severidade/estrutura. +Recommendation: usar `logging`. + +### [LOW] Dead Code / Unused Imports & Dependencies +File: imports `os,sys,time,math,hashlib,json` sem uso; `requests`/`marshmallow` na requirements sem uso. +Impact: manutenabilidade confusa. +Recommendation: remover. + +### [LOW] Magic Values / Inconsistent Naming +File: strings `'pending','done',...` e cores/# limites repetidos em vez de constantes (`utils/helpers.py` já define `VALID_STATUSES`, mas rotas não usam). +Impact: legibilidade/erros de digitação. +Recommendation: usar constantes de `config`. + +--- +**Total: 14 findings** (ordenação por severidade CRITICAL → LOW) \ No newline at end of file diff --git a/task-manager-api/.claude/skills/refactor-arch/SKILL.md b/task-manager-api/.claude/skills/refactor-arch/SKILL.md new file mode 100644 index 000000000..7fbec6f93 --- /dev/null +++ b/task-manager-api/.claude/skills/refactor-arch/SKILL.md @@ -0,0 +1,89 @@ +# refactor-arch Skill + +Esta skill automatiza a análise, auditoria e refatoração de projetos legados para o padrão MVC. + +## Objetivo +- Detectar linguagem, framework, arquitetura e domínio do projeto. +- Identificar anti-patterns e code smells com severidade e localização exata. +- Gerar um relatório de auditoria estruturado. +- Refatorar o projeto para uma arquitetura MVC clara. +- Validar que a aplicação inicia e mantém os endpoints originais. + +## Como usar +1. Execute a skill no diretório do projeto. +2. A skill faz 3 fases sequenciais. +3. A Fase 2 pausa para confirmação antes de qualquer modificação. +4. A Fase 3 aplica refatorações e validações. + +## Fase 1 — Análise +1. Identifique a linguagem principal do projeto. +2. Detecte framework e bibliotecas principais. +3. Liste todos os arquivos de código fonte relevantes. +4. Mapeie a arquitetura atual: monolito, camadas parciais, modelo MVC, etc. +5. Detecte banco de dados e método de persistência. +6. Produza um resumo com: + - Language + - Framework + - Dependencies + - Domain + - Architecture + - Source files analyzed + - DB tables/entities + +Use `project-analysis-guidelines.md` para as heurísticas de identificação. + +## Fase 2 — Auditoria +1. Use `antipattern-catalog.md` para detectar anti-patterns, vulnerabilidades e APIs deprecated. +2. Gere um relatório seguindo o template em `audit-report-template.md`. +3. O relatório deve conter: + - Summary por severidade + - Findings com: + - severidade + - arquivo e linhas exatas + - descrição + - impacto + - recomendação +4. Ordene findings de CRITICAL para LOW. +5. Inclua pelo menos 5 findings, com ao menos 1 HIGH ou CRITICAL. +6. Pare e peça confirmação antes de executar a Fase 3. + +A resposta da Fase 2 deve terminar com: + +> Phase 2 complete. Proceed with refactoring (Phase 3)? [y/n] + +## Fase 3 — Refatoração +1. Use `mvc-guidelines.md` como base para reestruturar o projeto. +2. Use `refactor-playbook.md` para aplicar transformações concretas por anti-pattern. +3. Gere uma nova estrutura consistente com: + - config/settings + - models/ + - controllers/ + - views/routes/ + - middlewares/ (tratamento de erros, validação) + - entrypoint claro (app.js, server.js, main.py) +4. Extraia configurações hardcoded para arquivos de config. +5. Separe responsabilidades: + - Models: abstração de dados e persistência + - Controllers: fluxo de aplicação e orquestração + - Routes: expor endpoints e delegar ao controller + - Middlewares: validação, erros e segurança +6. Remova endpoints inseguros ou debug/backdoor quando não fizerem parte do domínio da API. +7. Substitua SQL construído por concatenação por queries parametrizadas ou ORM. +8. Remova APIs deprecated e recomende equivalentes modernos. + +## Validação +1. A aplicação deve bootar sem erros. +2. Verifique pelo menos um endpoint original. +3. Confirme a estrutura de diretórios e a ausência de anti-patterns críticos. +4. A skill deve descrever as mudanças realizadas e os arquivos alterados. + +## Regras Gerais +- Seja agnóstico de tecnologia. A skill deve funcionar para Python/Flask e Node.js/Express. +- Não modifique nada antes da confirmação da Fase 2. +- Use os arquivos de referência para todas as decisões. +- Priorize segurança, separação de responsabilidades e manutenção. +- Se não for possível validar a aplicação completamente, explique claramente o motivo no final. + +## Regra de Ouro (Fase 3 vs Fase 2) +- **TODA** falha ou vulnerabilidade apontada no relatório da Fase 2, incluindo achados em scripts de mock, seeds ou tokens falsos (Fake JWT), **DEVE** ser ativamente corrigida no código durante a Fase 3. +- Não deixe pendências descritas no relatório vazarem para o código final. Verifique duplamente: senhas no banco (seeds inclusive) e tokens (devem ser JWTs assinados de verdade, não placeholders). diff --git a/task-manager-api/.claude/skills/refactor-arch/antipattern-catalog.md b/task-manager-api/.claude/skills/refactor-arch/antipattern-catalog.md new file mode 100644 index 000000000..dfc788e6b --- /dev/null +++ b/task-manager-api/.claude/skills/refactor-arch/antipattern-catalog.md @@ -0,0 +1,71 @@ +# Antipattern Catalog + +## Objetivo +Catalogar anti-patterns, vulnerabilidades e sinais de APIs deprecated com severidade, para uso na Fase 2 de auditoria. + +### CRITICAL +- **God Class / God Module** + - Sinais: arquivo único contém rotas, lógica de negócio, acesso a dados e validação. + - Impacto: impossível testar isoladamente, altíssimo acoplamento. + +- **Hardcoded Secrets** + - Sinais: `SECRET_KEY`, senhas, chaves API, credenciais ou informações sensíveis em código. + - Impacto: vazamento de segredos, ambiente inseguro. + +- **SQL Injection / Concatenation SQL** + - Sinais: queries construídas com concatenação de strings, interpolação direta de inputs. + - Impacto: execução de SQL arbitrário. + +- **Backdoor / Unsafe Admin Query** + - Sinais: endpoints que executam SQL arbitrário ou resetam DB sem autenticação. + - Impacto: falha de segurança grave. + +### HIGH +- **Business Logic in Controller/Route** + - Sinais: cálculos, regras de domínio e validações pesadas dentro de rotas ou controllers. + - Impacto: dificulta testes e manutenção. + +- **Global Mutable State** + - Sinais: caches globais, variáveis de configuração mutáveis ou singletons mal definidos. + - Impacto: comportamento imprevisível em runtime. + +- **Deprecated / Vulnerable API Usage** + - Sinais: uso de métodos antigos, `badCrypto`, `fs.existsSync` sem tratamento, `require.extensions`, ou APIs sem suporte. + - Impacto: risco de quebra futura e segurança reduzida. + +### MEDIUM +- **N+1 Query** + - Sinais: loops que fazem consultas por item, `for`/`foreach` que executam consultas SQL ou ORM repetidas. + - Impacto: degradação de performance. + +- **Mixed Responsibilities** + - Sinais: rotas que também atualizam modelos, manipulam respostas e fazem persistência direta. + - Impacto: acoplamento e duplicação. + +- **Lack of Validation / Missing Input Checks** + - Sinais: parâmetros usados sem validação adequada. + - Impacto: erros, comportamento inesperado e possíveis vulnerabilidades. + +### LOW +- **Magic Values / Poor Naming** + - Sinais: strings não documentadas, variáveis sem significado, números mágicos. + - Impacto: legibilidade reduzida. + +- **Duplicate Code** + - Sinais: blocos repetidos de validação ou mapeamento. + - Impacto: manutenção dificultada. + +- **Implicit Configuration** + - Sinais: configurações definidas diretamente no código (porta, URI, debug). + - Impacto: dificuldade de mudar ambiente. + +## Deprecated API Examples +- Node.js `badCrypto` custom hashing → use `bcrypt` ou `crypto.pbkdf2`. +- Express: `app.use(bodyParser.json())` / `body-parser` → use `express.json()`. +- Flask: `app.config['DEBUG'] = True` em produção / `Flask` debug no código → use ambiente e `FLASK_ENV`. +- SQLite string concatenation → use query parametrizada ou ORM. + +## Como usar +- Compare padrões do código com os sinais acima. +- Para cada finding, inclua severidade e recomendação de correção. +- Se não houver sinal exato, use julgamento conservador baseado em acoplamento e risco. diff --git a/task-manager-api/.claude/skills/refactor-arch/audit-report-template.md b/task-manager-api/.claude/skills/refactor-arch/audit-report-template.md new file mode 100644 index 000000000..2d6660c0c --- /dev/null +++ b/task-manager-api/.claude/skills/refactor-arch/audit-report-template.md @@ -0,0 +1,33 @@ +# Audit Report Template + +Use este template para gerar os relatórios da Fase 2. + +--- +ARCHITECTURE AUDIT REPORT +--- +Project: {{project_name}} +Stack: {{language}} + {{framework}} +Files: {{file_count}} analyzed | {{line_count}} lines approx. + +## Summary +CRITICAL: {{critical_count}} | HIGH: {{high_count}} | MEDIUM: {{medium_count}} | LOW: {{low_count}} + +## Findings + +{{#each findings}} +### [{{severity}}] {{title}} +File: {{file}}:{{line_start}}-{{line_end}} +Description: {{description}} +Impact: {{impact}} +Recommendation: {{recommendation}} + +{{/each}} +--- +Total: {{total_findings}} findings +--- + +Notes: +- Ordene findings por severidade decrescente. +- Use linhas exatas quando possível. +- Inclua pelo menos um finding por severidade sempre que aplicável. +- Se o projeto usa APIs deprecated, destaque isso como parte da auditoria. diff --git a/task-manager-api/.claude/skills/refactor-arch/mvc-guidelines.md b/task-manager-api/.claude/skills/refactor-arch/mvc-guidelines.md new file mode 100644 index 000000000..278859193 --- /dev/null +++ b/task-manager-api/.claude/skills/refactor-arch/mvc-guidelines.md @@ -0,0 +1,65 @@ +# MVC Guidelines + +## Objetivo +Definir regras claras para refatorar qualquer projeto legado para uma arquitetura MVC sustentável. + +## Camadas MVC +### Models +Responsabilidades: +- Abstrair acesso a dados e persistência. +- Definir entidades e mapeamento de dados. +- Validar regras de integridade de dados de baixo nível. + +Exemplos: +- Python: classes ou funções em `models/` que executam queries parametrizadas ou usam ORM. +- Node.js: objetos/repositórios que encapsulam `db.query` e retornam dados. + +### Controllers +Responsabilidades: +- Orquestrar a lógica de aplicação. +- Chamar models e serviços. +- Tratar entradas e resultados antes de enviar resposta. +- Delegar tratamento de erros para middleware. + +Exemplo: +- `controllers/produto_controller.py` ou `controllers/checkoutController.js`. + +### Views / Routes +Responsabilidades: +- Expor endpoints HTTP. +- Mapear rotas para controllers. +- Não conter lógica de negócios ou regras complexas. + +Exemplo: +- rotas Flask com Blueprint ou `app.add_url_rule` +- `express.Router()` que importa controllers + +## Configuração +- Mova segredos e URIs para `config/settings.py` ou `config/index.js`. +- Use variáveis de ambiente para valores sensíveis. +- Não deixe `SECRET_KEY`, `DB_URI`, `API_KEY` codificados. + +## Middlewares e Tratamento de Erros +- Centralize captura de exceções. +- Crie middleware para validação e erros. +- Evite `try/except` ou `try/catch` espalhados que repetem mensagens. + +## Regras de Refatoração +- Rotas devem ser finas: validação mínima + chamada de controller. +- Controllers devem ser responsáveis pelo fluxo, não por persistência detalhada. +- Models devem ser responsáveis pela persistência e retorno de dados em formatos simples. +- Normalizar respostas JSON / status HTTP de forma consistente. +- Evitar dependências circulares entre camadas. + +## Estrutura mínima sugerida +- `config/` +- `models/` +- `controllers/` +- `routes/` ou `views/` +- `middlewares/` +- `app.py` / `server.js` + +## Validação após refatoração +- A aplicação deve iniciar sem erros. +- Um endpoint representativo deve responder corretamente. +- O projeto deve estar mais modular e com responsabilidade separada. diff --git a/task-manager-api/.claude/skills/refactor-arch/project-analysis-guidelines.md b/task-manager-api/.claude/skills/refactor-arch/project-analysis-guidelines.md new file mode 100644 index 000000000..f2a90d005 --- /dev/null +++ b/task-manager-api/.claude/skills/refactor-arch/project-analysis-guidelines.md @@ -0,0 +1,54 @@ +# Project Analysis Guidelines + +## Objetivo +Fornecer regras e heurísticas para detectar linguagem, framework, banco de dados e arquitetura de um projeto legado. + +## Linguagem e Framework +### Python +- Detecte `import flask`, `from flask import`, `Flask(__name__)` → Flask. +- Detecte `from flask_sqlalchemy import SQLAlchemy` → Flask + SQLAlchemy. +- Detecte `app.add_url_rule`, `@app.route`, `Blueprint`. +- Detecte `requirements.txt` ou `setup.py` com `Flask`, `flask-cors`, `sqlalchemy`. + +### JavaScript / Node.js +- Detecte `require('express')`, `import express from 'express'`, `app.use(express.json())` → Express. +- Detecte `app.listen`, `express.Router()`, `module.exports =`. +- Detecte `package.json` com dependências `express`, `sqlite3`, `body-parser`. + +## Banco de Dados +- Detecte arquivos `database.py`, `sqlite3.connect`, `sqlite3.Database`, `SQLAlchemy`, `pg`, `mysql`, `mongoose`. +- Identifique a persistência via queries SQL ou ORM. +- Liste tabelas se conseguir extrair `CREATE TABLE` ou `db.run`/`cursor.execute`. + +## Arquitetura Atual +### Monolítico +- Todos os endpoints e lógica em um único arquivo. +- Models de dados, validação e rotas misturados. + +### Parcialmente Organizado +- Existe alguma separação de `models/`, `routes/`, `services/` ou `controllers/`, mas ainda há vazamento de lógica entre camadas. + +### MVC / Estrutura clara +- `models`, `controllers` e `routes/views` separados. +- `config` e `middlewares` também definidos separadamente. + +## Domínio e Contexto +- Determine a área funcional principal do projeto. +- Exemplos: + - E-commerce API (produtos, pedidos, usuários) + - LMS API / checkout + - Task Manager API + +## Output Esperado da Análise +- Language: Python / JavaScript +- Framework: Flask / Express +- Dependencies: principais bibliotecas detectadas +- Domain: descrição curta do domínio +- Architecture: monolítico / parcialmente organizado / MVC parcial +- Source files: lista e contagem de arquivos analisados +- DB tables: entidades ou tabelas conhecidas + +## Regras de Análise +- Não altere arquivos nesta fase. +- Extraia sinais de arquitetura mesmo quando o código estiver parcialmente organizado. +- Se houver múltiplos projetos no mesmo diretório, limite-se ao projeto atual. diff --git a/task-manager-api/.claude/skills/refactor-arch/refactor-playbook.md b/task-manager-api/.claude/skills/refactor-arch/refactor-playbook.md new file mode 100644 index 000000000..4ed2556f9 --- /dev/null +++ b/task-manager-api/.claude/skills/refactor-arch/refactor-playbook.md @@ -0,0 +1,169 @@ +# Refactor Playbook + +## Objetivo +Fornecer padrões concretos de transformação para anti-patterns comuns em projetos legacy. + +## 1. God Class / God Module +Antes: +- Um arquivo contém rotas, lógica de negócio e queries. + +Depois: +- Rotas em `routes/` +- Fluxo em `controllers/` +- Persistência em `models/` + +Exemplo Python: +- `app.py` registra rotas +- `controllers/produto_controller.py` chama `models/produto_model.py` +- `models/produto_model.py` executa SELECT/INSERT. + +## 2. Hardcoded Secrets +Antes: +- `app.config['SECRET_KEY'] = 'abc'` +- `config.paymentGatewayKey = 'pk_live_...'` + +Depois: +- `config/settings.py` lê `os.environ.get('SECRET_KEY')` +- `config/index.js` lê `process.env.PAYMENT_GATEWAY_KEY` + +## 3. SQL Injection / Query Concatenation +Antes: +- `cursor.execute("SELECT * FROM usuarios WHERE id = " + str(id))` +- `db.run("INSERT INTO users VALUES ('" + name + "')")` + +Depois: +- `cursor.execute("SELECT * FROM usuarios WHERE id = ?", (id,))` +- `db.run("INSERT INTO users VALUES (?)", [name])` + +## 4. Business Logic in Controller/Route +Antes: +- Rota valida, calcula totas e atualiza estoque diretamente. + +Depois: +- Rota extrai dados e chama `order_controller.create_order()`. +- Controller chama `order_service.process_order()` e `order_model.update_stock()`. + +## 5. Global Mutable State +Antes: +- `let globalCache = {}` +- `db_connection = None` + +Depois: +- Criar módulo de cache imutável ou session-scoped. +- Inicializar conexão de DB em `database.py`/`db.js` com função getter. + +## 6. N+1 Query +Antes: +- `for item in pedidos: cursor.execute('SELECT ...')` + +Depois: +- Use JOIN ou query única para buscar itens associados. +- Ou faça query em lote para IDs coletados. + +## 7. Missing Input Validation +Antes: +- aceita `request.get_json()` sem checar campos. + +Depois: +- validar campos obrigatórios e tipos antes de chamar o controller. +- usar middleware / helper de validação quando possível. + +## 8. Deprecated API Usage +Antes: +- `badCrypto()` custom insecure hashing +- `app.use(bodyParser.json())` + +Depois: +- usar `bcrypt`/`crypto` para hashing +- usar `express.json()` no Express +- evitar `DEBUG=True` no código, use env var + +## Exemplo de transformação antes/depois +### Python/Flask +Antes: +```python +@app.route('/produtos', methods=['POST']) +def criar_produto(): + dados = request.get_json() + nome = dados['nome'] + cursor.execute("INSERT INTO produtos (...) VALUES (...)" ) + return jsonify(...) +``` +Depois: +```python +@produtos_bp.route('/produtos', methods=['POST']) +def criar_produto(): + return produto_controller.criar_produto(request.get_json()) +``` + +Em `controllers/produto_controller.py`: +```python +def criar_produto(dados): + validar_dados_produto(dados) + novo_id = produto_model.criar_produto(dados) + return jsonify({'id': novo_id, 'sucesso': True}), 201 +``` + +Em `models/produto_model.py`: +```python +def criar_produto(data): + db = get_db() + cursor = db.cursor() + cursor.execute( + 'INSERT INTO produtos (nome, descricao, preco, estoque, categoria) VALUES (?, ?, ?, ?, ?)', + (data['nome'], data.get('descricao',''), data['preco'], data['estoque'], data.get('categoria','geral')) + ) + db.commit() + return cursor.lastrowid +``` + +### Node.js/Express +Antes: +```js +app.post('/api/checkout', (req, res) => { + let cc = req.body.card; + if (!cc) return res.status(400).send('Bad Request'); + db.get('SELECT * FROM courses WHERE id = ' + cid, ...) +}); +``` +Depois: +```js +router.post('/api/checkout', checkoutController.handleCheckout); +``` + +Em `controllers/checkoutController.js`: +```js +async function handleCheckout(req, res) { + const { c_id, card } = req.body; + validateCheckoutInput(req.body); + const course = await courseService.findCourse(c_id); + await paymentService.processPayment({ card, course }); + res.json({ msg: 'Sucesso' }); +} +``` + +## Uso do playbook +- Associe cada finding do catálogo a uma transformação. +- Aplique mudanças incrementais e mantenha a aplicação funcional. +- Priorize correções de segurança e separação de responsabilidades. + +## 9. Plaintext Passwords in Seeds/Defaults +Antes: +- `db.run("INSERT INTO users (pass) VALUES ('123')")` +- `u1.password = '1234'` sem hash. + +Depois: +- Os seeds **devem** obrigatoriamente usar a mesma função de hash (ex: `bcrypt.hashSync`, `generate_password_hash`) usada pelo sistema. +- Node.js: `const pwd = bcrypt.hashSync('123', 10); db.run("... VALUES (?)", [pwd])` +- Python: `u1.set_password('1234')` garantindo que o método de fato faça o hash no banco. + +## 10. Fake / Unsigned JWTs +Antes: +- `token = "jwt-demo-" + str(user.id)` +- Rota que ignora validação de assinatura e confia na string. + +Depois: +- Gerar JWTs reais assinados usando a `SECRET_KEY` da aplicação. +- Node.js: usar `jsonwebtoken` (`jwt.sign(payload, SECRET_KEY)`). +- Python: usar `PyJWT` (`jwt.encode(payload, SECRET_KEY, algorithm="HS256")`). +- Ao verificar requisições, garantir que a assinatura do JWT seja validada. diff --git a/task-manager-api/app.py b/task-manager-api/app.py index e89e0af99..f395e9b5b 100644 --- a/task-manager-api/app.py +++ b/task-manager-api/app.py @@ -1,34 +1,50 @@ -from flask import Flask +"""Entry point / Composition root da API de Task Manager.""" + +import datetime + +from flask import Flask, jsonify from flask_cors import CORS + +from config import settings 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 + + +def create_app(): + app = Flask(__name__) + + app.config["SQLALCHEMY_DATABASE_URI"] = settings.SQLALCHEMY_DATABASE_URI + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = settings.SQLALCHEMY_TRACK_MODIFICATIONS + app.config["SECRET_KEY"] = settings.SECRET_KEY + + CORS(app) + db.init_app(app) + + app.register_blueprint(task_bp) + app.register_blueprint(user_bp) + app.register_blueprint(report_bp) + + register_error_handlers(app) -app = Flask(__name__) + @app.route("/health") + def health(): + return jsonify({"status": "ok", "timestamp": str(datetime.datetime.now())}), 200 -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///tasks.db' -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False -app.config['SECRET_KEY'] = 'super-secret-key-123' + @app.route("/") + def index(): + return jsonify({"message": "Task Manager API", "version": "1.0"}), 200 -CORS(app) -db.init_app(app) + with app.app_context(): + db.create_all() -app.register_blueprint(task_bp) -app.register_blueprint(user_bp) -app.register_blueprint(report_bp) + return app -@app.route('/health') -def health(): - return {'status': 'ok', 'timestamp': str(datetime.datetime.now())} -@app.route('/') -def index(): - return {'message': 'Task Manager API', 'version': '1.0'} +app = create_app() -with app.app_context(): - db.create_all() -if __name__ == '__main__': - app.run(debug=True, host='0.0.0.0', port=5000) +if __name__ == "__main__": + app.run(debug=settings.DEBUG, host=settings.HOST, port=settings.PORT) \ No newline at end of file diff --git a/task-manager-api/config/settings.py b/task-manager-api/config/settings.py new file mode 100644 index 000000000..20c93f63a --- /dev/null +++ b/task-manager-api/config/settings.py @@ -0,0 +1,37 @@ +"""Configurações centralizadas da aplicação Task Manager. + +Segredos e parâmetros sensíveis via variáveis de ambiente (python-dotenv já +é dependência do projeto). +""" + +import os + +try: + from dotenv import load_dotenv + + load_dotenv() +except ImportError: # pragma: no cover + pass + +# Segredos e parâmetros sensíveis (via env) +SECRET_KEY = os.environ.get("SECRET_KEY", "troque-esta-chave-em-producao") +SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URI", "sqlite:///tasks.db") +SQLALCHEMY_TRACK_MODIFICATIONS = False +DEBUG = os.environ.get("FLASK_DEBUG", "").lower() in ("1", "true", "yes") +HOST = os.environ.get("HOST", "0.0.0.0") +PORT = int(os.environ.get("PORT", "5000")) + +# SMTP (deve vir de variáveis de ambiente em produção) +SMTP_HOST = os.environ.get("SMTP_HOST", "") +SMTP_PORT = int(os.environ.get("SMTP_PORT", "587")) +SMTP_USER = os.environ.get("SMTP_USER", "") +SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "") + +# Constantes de domínio +VALID_STATUSES = ["pending", "in_progress", "done", "cancelled"] +VALID_ROLES = ["user", "admin", "manager"] +MIN_TITLE_LENGTH = 3 +MAX_TITLE_LENGTH = 200 +MIN_PASSWORD_LENGTH = 6 +DEFAULT_PRIORITY = 3 +DEFAULT_COLOR = "#000000" \ No newline at end of file diff --git a/task-manager-api/middlewares/error_handler.py b/task-manager-api/middlewares/error_handler.py new file mode 100644 index 000000000..256cf1a80 --- /dev/null +++ b/task-manager-api/middlewares/error_handler.py @@ -0,0 +1,40 @@ +"""Tratamento de erros centralizado para a API de Task Manager.""" + +from functools import wraps +from flask import jsonify + + +class ApiError(Exception): + def __init__(self, message, status=400): + super().__init__(message) + self.message = message + self.status = status + + +def api_error_handler(func): + """Converter exceções de serviço em respostas HTTP JSON.""" + + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except ApiError as exc: + return jsonify({"error": exc.message}), exc.status + except Exception as exc: + # Registra o erro real mas não o expõe cru ao cliente. + import logging + + logging.getLogger("api").exception(exc) + return jsonify({"error": "Erro interno"}), 500 + + return wrapper + + +def register_error_handlers(app): + @app.errorhandler(404) + def not_found(_): + return jsonify({"error": "Rota não encontrada"}), 404 + + @app.errorhandler(500) + def internal_error(_): + return jsonify({"error": "Erro interno do servidor"}), 500 \ No newline at end of file diff --git a/task-manager-api/models/task.py b/task-manager-api/models/task.py index f8f9227bb..11f6f4d69 100644 --- a/task-manager-api/models/task.py +++ b/task-manager-api/models/task.py @@ -1,60 +1,64 @@ from database import db from datetime import datetime -import json +from config import settings + class Task(db.Model): - __tablename__ = 'tasks' + __tablename__ = "tasks" id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) - status = db.Column(db.String(50), default='pending') - 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) + status = db.Column(db.String(50), default="pending") + priority = db.Column(db.Integer, default=settings.DEFAULT_PRIORITY) + 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) 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') + user = db.relationship("User", backref="tasks") + category = db.relationship("Category", backref="tasks") 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 serializable(self): + """Payload HTTP com flags derivadas (overdue).""" + 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 is_overdue(self): + valid = _is_active_status(self.status) + return self.due_date is not None and self.due_date < datetime.utcnow() and valid - def validate_priority(self, p): - if p >= 1 and p <= 5: - return True - return False + def days_overdue(self): + if self.is_overdue(): + return (datetime.utcnow() - self.due_date).days + return 0 - 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 _is_active_status(status): + return status not in ("done", "cancelled") + + +def validate_status(status): + return status in settings.VALID_STATUSES + + +def validate_priority(priority): + return 1 <= priority <= 5 \ No newline at end of file diff --git a/task-manager-api/models/user.py b/task-manager-api/models/user.py index 44c296371..55880b73e 100644 --- a/task-manager-api/models/user.py +++ b/task-manager-api/models/user.py @@ -1,38 +1,38 @@ from database import db from datetime import datetime -import hashlib +from werkzeug.security import generate_password_hash, check_password_hash + class User(db.Model): - __tablename__ = 'users' + __tablename__ = "users" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) email = db.Column(db.String(150), unique=True, nullable=False) password = db.Column(db.String(255), nullable=False) - role = db.Column(db.String(50), default='user') + role = db.Column(db.String(50), default="user") active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) - 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) - } - def set_password(self, pwd): - - self.password = hashlib.md5(pwd.encode()).hexdigest() + # pbkdf2:sha256 — substitui o MD5 inseguro. + self.password = generate_password_hash(pwd) def check_password(self, pwd): - return self.password == hashlib.md5(pwd.encode()).hexdigest() + return check_password_hash(self.password, pwd) + + def to_dict(self, include_password=False): + data = { + "id": self.id, + "name": self.name, + "email": self.email, + "role": self.role, + "active": self.active, + "created_at": str(self.created_at), + } + if include_password: + data["password"] = self.password + return data def is_admin(self): - if self.role == 'admin': - return True - else: - return False + return self.role == "admin" \ No newline at end of file diff --git a/task-manager-api/requirements.txt b/task-manager-api/requirements.txt index e6e3074bc..b92fc8433 100644 --- a/task-manager-api/requirements.txt +++ b/task-manager-api/requirements.txt @@ -4,3 +4,4 @@ flask-cors==4.0.0 marshmallow==3.20.1 requests==2.31.0 python-dotenv==1.0.0 +PyJWT==2.8.0 diff --git a/task-manager-api/routes/report_routes.py b/task-manager-api/routes/report_routes.py index 164045281..37d1d3f65 100644 --- a/task-manager-api/routes/report_routes.py +++ b/task-manager-api/routes/report_routes.py @@ -1,223 +1,45 @@ -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() +"""Rotas de relatórios e categorias — finas, delegam ao serviço.""" - 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 - }) +from flask import Blueprint, request, jsonify - seven_days_ago = datetime.utcnow() - timedelta(days=7) - recent_tasks = Task.query.filter(Task.created_at >= seven_days_ago).count() +from services import report_service +from middlewares.error_handler import api_error_handler - recent_done = Task.query.filter( - Task.status == 'done', - Task.updated_at >= seven_days_ago - ).count() +report_bp = Blueprint("reports", __name__) - 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, - } +@report_bp.route("/reports/summary", methods=["GET"]) +@api_error_handler +def summary_report(): + return jsonify(report_service.summary_report()), 200 - return jsonify(report), 200 -@report_bp.route('/reports/user/', methods=['GET']) +@report_bp.route("/reports/user/", methods=["GET"]) +@api_error_handler 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() + return jsonify(report_service.user_report(user_id)), 200 - 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']) +@report_bp.route("/categories", methods=["GET"]) +@api_error_handler 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 + return jsonify(report_service.list_categories()), 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') +@report_bp.route("/categories", methods=["POST"]) +@api_error_handler +def create_category(): + return jsonify(report_service.create_category(request.get_json())), 201 - 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']) +@report_bp.route("/categories/", methods=["PUT"]) +@api_error_handler def update_category(cat_id): - cat = Category.query.get(cat_id) - if not cat: - return jsonify({'error': 'Categoria não encontrada'}), 404 + return jsonify(report_service.update_category(cat_id, request.get_json())), 200 - 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']) +@report_bp.route("/categories/", methods=["DELETE"]) +@api_error_handler 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 + report_service.delete_category(cat_id) + return jsonify({"message": "Categoria deletada"}), 200 \ No newline at end of file diff --git a/task-manager-api/routes/task_routes.py b/task-manager-api/routes/task_routes.py index 29d1e98fb..737c380f1 100644 --- a/task-manager-api/routes/task_routes.py +++ b/task-manager-api/routes/task_routes.py @@ -1,299 +1,59 @@ -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__) +"""Rotas de tasks — finas, delegam à camada de serviço.""" -@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 [] +from flask import Blueprint, request, jsonify - 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 +from services import task_service +from middlewares.error_handler import api_error_handler - 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 +task_bp = Blueprint("tasks", __name__) - 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) +@task_bp.route("/tasks", methods=["GET"]) +@api_error_handler +def get_tasks(): + return jsonify(task_service.list_tasks()), 200 - return jsonify(result), 200 - except: - return jsonify({'error': 'Erro interno'}), 500 -@task_bp.route('/tasks/', methods=['GET']) +@task_bp.route("/tasks/", methods=["GET"]) +@api_error_handler def get_task(task_id): - task = Task.query.get(task_id) - if task: - data = task.to_dict() + return jsonify(task_service.get_task(task_id)), 200 - 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']) +@task_bp.route("/tasks", methods=["POST"]) +@api_error_handler 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 + task = task_service.create_task(request.get_json()) + return jsonify(task), 201 - 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']) +@task_bp.route("/tasks/", methods=["PUT"]) +@api_error_handler 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 + task = task_service.update_task(task_id, request.get_json()) + return jsonify(task), 200 - 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']) +@task_bp.route("/tasks/", methods=["DELETE"]) +@api_error_handler def delete_task(task_id): - task = Task.query.get(task_id) - if not task: - return jsonify({'error': 'Task não encontrada'}), 404 + task_service.delete_task(task_id) + return jsonify({"message": "Task deletada com sucesso"}), 200 - 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']) +@task_bp.route("/tasks/search", methods=["GET"]) +@api_error_handler 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', '') + result = task_service.search_tasks( + query=request.args.get("q", ""), + status=request.args.get("status", ""), + priority=request.args.get("priority", ""), + user_id=request.args.get("user_id", ""), + ) + return jsonify(result), 200 - 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']) +@task_bp.route("/tasks/stats", methods=["GET"]) +@api_error_handler 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 + return jsonify(task_service.task_stats()), 200 \ No newline at end of file diff --git a/task-manager-api/routes/user_routes.py b/task-manager-api/routes/user_routes.py index 00d7d7d56..13d29510f 100644 --- a/task-manager-api/routes/user_routes.py +++ b/task-manager-api/routes/user_routes.py @@ -1,211 +1,53 @@ +"""Rotas de usuários — finas, delegam à camada de serviço.""" + 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__) +from services import user_service +from middlewares.error_handler import api_error_handler + +user_bp = Blueprint("users", __name__) -@user_bp.route('/users', methods=['GET']) + +@user_bp.route("/users", methods=["GET"]) +@api_error_handler 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 + return jsonify(user_service.list_users()), 200 - 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()) +@user_bp.route("/users/", methods=["GET"]) +@api_error_handler +def get_user(user_id): + return jsonify(user_service.get_user(user_id)), 200 - return jsonify(data), 200 -@user_bp.route('/users', methods=['POST']) +@user_bp.route("/users", methods=["POST"]) +@api_error_handler 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']) + return jsonify(user_service.create_user(request.get_json())), 201 + + +@user_bp.route("/users/", methods=["PUT"]) +@api_error_handler 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 + return jsonify(user_service.update_user(user_id, request.get_json())), 200 - 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_bp.route("/users/", methods=["DELETE"]) +@api_error_handler +def delete_user(user_id): + user_service.delete_user(user_id) + return jsonify({"message": "Usuário deletado com sucesso"}), 200 - 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 +@user_bp.route("/users//tasks", methods=["GET"]) +@api_error_handler +def get_user_tasks(user_id): + return jsonify(user_service.get_user_tasks(user_id)), 200 - 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 +@user_bp.route("/login", methods=["POST"]) +@api_error_handler +def login(): + data = request.get_json() or {} + result = user_service.login(data.get("email"), data.get("password")) + return jsonify(result), 200 \ No newline at end of file diff --git a/task-manager-api/services/__init__.py b/task-manager-api/services/__init__.py index 8b1378917..e75c98cc7 100644 --- a/task-manager-api/services/__init__.py +++ b/task-manager-api/services/__init__.py @@ -1 +1,8 @@ +"""Serviços de negócio do pacote.""" +from . import notification_service +from . import task_service +from . import user_service +from . import report_service + +__all__ = ["notification_service", "task_service", "user_service", "report_service"] \ No newline at end of file diff --git a/task-manager-api/services/notification_service.py b/task-manager-api/services/notification_service.py index 7df57d2b8..32f5f7db6 100644 --- a/task-manager-api/services/notification_service.py +++ b/task-manager-api/services/notification_service.py @@ -1,48 +1,74 @@ +"""Serviço de notificações por e-mail. + +Credenciais lidas de config/ambiente; nenhum segredo hardcoded. +Em produção, o envio é assíncrono/fila; aqui mantemos um registro em memória +limitado (sem estado global ilimitado). +""" + +import logging import smtplib from datetime import datetime +from config import settings + +logger = logging.getLogger(__name__) + + 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._notifications = [] + self.email_host = settings.SMTP_HOST + self.email_port = settings.SMTP_PORT + self.email_user = settings.SMTP_USER + self.email_password = settings.SMTP_PASSWORD def send_email(self, to, subject, body): + # Se não configurado SMTP, apenas registra (não tenta enviar com cred fake). + if not self.email_user or not self.email_password: + logger.warning("SMTP não configurado; notificação para %s não enviada", to) + 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}") + logger.info("Email enviado para %s", to) return True - except Exception as e: - print(f"Erro ao enviar email: {str(e)}") + except Exception as exc: + logger.error("Erro ao enviar email: %s", exc) 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() - }) + # Mantém histórico limitado (não acumula infinitamente). + self._notifications.append( + { + "type": "task_assigned", + "user_id": user.id, + "task_id": task.id, + "timestamp": datetime.utcnow(), + } + ) + if len(self._notifications) > 500: + self._notifications = self._notifications[-100:] 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] + + +notification_service = NotificationService() \ No newline at end of file diff --git a/task-manager-api/services/report_service.py b/task-manager-api/services/report_service.py new file mode 100644 index 000000000..f20424dde --- /dev/null +++ b/task-manager-api/services/report_service.py @@ -0,0 +1,192 @@ +"""Serviço de relatórios e categorias — agregações sem N+1.""" + +from datetime import datetime, timedelta + +from database import db +from models.task import Task +from models.user import User +from models.category import Category +from config import settings +from middlewares.error_handler import ApiError + + +class ReportServiceError(ApiError): + def __init__(self, message, status=400): + super().__init__(message) + self.status = status + + +def summary_report(): + total_tasks = Task.query.count() + total_users = User.query.count() + total_categories = Category.query.count() + + by_status = {s: Task.query.filter_by(status=s).count() for s in settings.VALID_STATUSES} + by_priority = {p: Task.query.filter_by(priority=p).count() for p in range(1, 6)} + + all_tasks = Task.query.all() + overdue_tasks = [t for t in all_tasks if t.is_overdue()] + + 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() + + # Agregação por usuário sem N+1 (busca tasks uma única vez). + user_stats = _user_stats() + + return { + "generated_at": str(datetime.utcnow()), + "overview": { + "total_tasks": total_tasks, + "total_users": total_users, + "total_categories": total_categories, + }, + "tasks_by_status": by_status, + "tasks_by_priority": { + "critical": by_priority[1], + "high": by_priority[2], + "medium": by_priority[3], + "low": by_priority[4], + "minimal": by_priority[5], + }, + "overdue": { + "count": len(overdue_tasks), + "tasks": [ + { + "id": t.id, + "title": t.title, + "due_date": str(t.due_date), + "days_overdue": t.days_overdue(), + } + for t in overdue_tasks + ], + }, + "recent_activity": { + "tasks_created_last_7_days": recent_tasks, + "tasks_completed_last_7_days": recent_done, + }, + "user_productivity": user_stats, + } + + +def user_report(user_id): + user = User.query.get(user_id) + if not user: + raise ReportServiceError("Usuário não encontrado", 404) + tasks = Task.query.filter_by(user_id=user_id).all() + + total = len(tasks) + by_status = {s: 0 for s in settings.VALID_STATUSES} + overdue = 0 + high_priority = 0 + done = 0 + + for t in tasks: + by_status[t.status] = by_status.get(t.status, 0) + 1 + if t.status == "done": + done += 1 + if t.priority <= 2: + high_priority += 1 + if t.is_overdue(): + overdue += 1 + + return { + "user": {"id": user.id, "name": user.name, "email": user.email}, + "statistics": { + "total_tasks": total, + "done": by_status["done"], + "pending": by_status["pending"], + "in_progress": by_status["in_progress"], + "cancelled": by_status["cancelled"], + "overdue": overdue, + "high_priority": high_priority, + "completion_rate": round((done / total) * 100, 2) if total > 0 else 0, + }, + } + + +def _user_stats(): + """Produtividade por usuário com N+1 reduzido (busca tasks uma vez).""" + users = User.query.all() + all_tasks = Task.query.all() + by_user = {} + for t in all_tasks: + by_user.setdefault(t.user_id, []).append(t) + + result = [] + for u in users: + tasks = by_user.get(u.id, []) + total = len(tasks) + completed = sum(1 for t in tasks if t.status == "done") + result.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, + } + ) + return result + + +# --- Categorias (regras + CRUD) --- + +def list_categories(): + result = [] + for c in Category.query.all(): + data = c.to_dict() + data["task_count"] = Task.query.filter_by(category_id=c.id).count() + result.append(data) + return result + + +def create_category(data): + if not data: + raise ReportServiceError("Dados inválidos", 400) + name = data.get("name") + if not name: + raise ReportServiceError("Nome é obrigatório", 400) + color = data.get("color", settings.DEFAULT_COLOR) + if not _valid_color(color): + raise ReportServiceError("Cor inválida", 400) + + category = Category() + category.name = name + category.description = data.get("description", "") + category.color = color + db.session.add(category) + db.session.commit() + return category.to_dict() + + +def update_category(cat_id, data): + cat = Category.query.get(cat_id) + if not cat: + raise ReportServiceError("Categoria não encontrada", 404) + if "name" in data: + if not data["name"]: + raise ReportServiceError("Nome é obrigatório", 400) + cat.name = data["name"] + if "description" in data: + cat.description = data["description"] + if "color" in data: + if not _valid_color(data["color"]): + raise ReportServiceError("Cor inválida", 400) + cat.color = data["color"] + db.session.commit() + return cat.to_dict() + + +def delete_category(cat_id): + cat = Category.query.get(cat_id) + if not cat: + raise ReportServiceError("Categoria não encontrada", 404) + db.session.delete(cat) + db.session.commit() + + +def _valid_color(color): + return bool(color) and len(color) == 7 and color.startswith("#") \ No newline at end of file diff --git a/task-manager-api/services/task_service.py b/task-manager-api/services/task_service.py new file mode 100644 index 000000000..122f19dc3 --- /dev/null +++ b/task-manager-api/services/task_service.py @@ -0,0 +1,167 @@ +"""Serviço de tasks — regras de negócio extraídas das rotas. + +Rotas ficam finas (validação + chamada do service); a lógica de domínio +(overdue, validação, CRUD) vive aqui. +""" + +from datetime import datetime + +from database import db +from models.task import Task +from models.user import User +from models.category import Category +from config import settings +from middlewares.error_handler import ApiError + + +class TaskServiceError(ApiError): + def __init__(self, message, status=400): + super().__init__(message) + self.status = status + + +def list_tasks(): + tasks = Task.query.all() + result = [] + for t in tasks: + data = t.serializable() + data["user_name"] = t.user.name if t.user else None + data["category_name"] = t.category.name if t.category else None + result.append(data) + return result + + +def get_task(task_id): + t = Task.query.get(task_id) + if not t: + raise TaskServiceError("Task não encontrada", 404) + return t.serializable() + + +def create_task(data): + _validate_task_payload(data) # valida não-nulos e tipos + task = Task() + _apply_task_data(task, data) + _validate_user_refs(task.user_id, task.category_id) + db.session.add(task) + _commit() + return task.serializable() + + +def update_task(task_id, data): + task = Task.query.get(task_id) + if not task: + raise TaskServiceError("Task não encontrada", 404) + if data.get("title") is not None: + _validate_title(data["title"]) + if "status" in data and data["status"] not in settings.VALID_STATUSES: + raise TaskServiceError("Status inválido", 400) + if "priority" in data and not (1 <= data["priority"] <= 5): + raise TaskServiceError("Prioridade deve ser entre 1 e 5", 400) + _apply_task_data(task, data) + _validate_user_refs(task.user_id, task.category_id) + task.updated_at = datetime.utcnow() + db.session.commit() + return task.serializable() + + +def delete_task(task_id): + task = Task.query.get(task_id) + if not task: + raise TaskServiceError("Task não encontrada", 404) + db.session.delete(task) + db.session.commit() + + +def search_tasks(query=None, status=None, priority=None, user_id=None): + q = Task.query + if query: + like = f"%{query}%" + q = q.filter( + db.or_(Task.title.like(like), Task.description.like(like)) + ) + if status: + q = q.filter(Task.status == status) + if priority: + q = q.filter(Task.priority == int(priority)) + if user_id: + q = q.filter(Task.user_id == int(user_id)) + return [t.serializable() for t in q.all()] + + +def task_stats(): + total = Task.query.count() + by_status = {s: Task.query.filter_by(status=s).count() for s in settings.VALID_STATUSES} + all_tasks = Task.query.all() + overdue_count = sum(1 for t in all_tasks if t.is_overdue()) + done = by_status["done"] + return { + "total": total, + "pending": by_status["pending"], + "in_progress": by_status["in_progress"], + "done": done, + "cancelled": by_status["cancelled"], + "overdue": overdue_count, + "completion_rate": round((done / total) * 100, 2) if total > 0 else 0, + } + + +# --- helpers privados --- + +def _validate_task_payload(data): + if not data: + raise TaskServiceError("Dados inválidos", 400) + if data.get("title") is None: + raise TaskServiceError("Título é obrigatório", 400) + _validate_title(data["title"]) + if "status" in data and data["status"] not in settings.VALID_STATUSES: + raise TaskServiceError("Status inválido", 400) + if "priority" in data and not (1 <= data["priority"] <= 5): + raise TaskServiceError("Prioridade deve ser entre 1 e 5", 400) + + +def _validate_title(title): + if len(title) < settings.MIN_TITLE_LENGTH: + raise TaskServiceError("Título muito curto", 400) + if len(title) > settings.MAX_TITLE_LENGTH: + raise TaskServiceError("Título muito longo", 400) + + +def _apply_task_data(task, data): + task.title = data.get("title", task.title) + task.description = data.get("description", task.description) + task.status = data.get("status", task.status) + task.priority = data.get("priority", task.priority) + task.user_id = data.get("user_id", task.user_id) + task.category_id = data.get("category_id", task.category_id) + if "due_date" in data: + task.due_date = _parse_due_date(data.get("due_date")) + if "tags" in data: + tags = data["tags"] + task.tags = ",".join(tags) if isinstance(tags, list) else tags + + +def _commit(): + try: + db.session.commit() + except Exception: + db.session.rollback() + raise TaskServiceError("Erro ao salvar", 500) + + +def _parse_due_date(value): + if not value: + return None + for fmt in ("%Y-%m-%d", "%d/%m/%Y"): + try: + return datetime.strptime(value, fmt) + except ValueError: + continue + raise TaskServiceError("Formato de data inválido. Use YYYY-MM-DD", 400) + + +def _validate_user_refs(user_id, category_id): + if user_id and not User.query.get(user_id): + raise TaskServiceError("Usuário não encontrado", 404) + if category_id and not Category.query.get(category_id): + raise TaskServiceError("Categoria não encontrada", 404) \ No newline at end of file diff --git a/task-manager-api/services/user_service.py b/task-manager-api/services/user_service.py new file mode 100644 index 000000000..420c283b9 --- /dev/null +++ b/task-manager-api/services/user_service.py @@ -0,0 +1,147 @@ +"""Serviço de usuários — regras de negócio e autenticação.""" + +import re + +from database import db +from models.user import User +from models.task import Task +from config import settings +from middlewares.error_handler import ApiError + + +class UserServiceError(ApiError): + def __init__(self, message, status=400): + super().__init__(message) + self.status = status + + +EMAIL_RE = re.compile(r"^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$") + + +def list_users(): + users = User.query.all() + result = [] + for u in users: + data = u.to_dict() # sem password + data["task_count"] = len(u.tasks) + result.append(data) + return result + + +def get_user(user_id): + user = User.query.get(user_id) + if not user: + raise UserServiceError("Usuário não encontrado", 404) + data = user.to_dict() + data["tasks"] = [t.serializable() for t in Task.query.filter_by(user_id=user_id).all()] + return data + + +def create_user(data): + if not data: + raise UserServiceError("Dados inválidos", 400) + name = data.get("name") + email = data.get("email") + password = data.get("password") + role = data.get("role", "user") + + if not name: + raise UserServiceError("Nome é obrigatório", 400) + if not email: + raise UserServiceError("Email é obrigatório", 400) + if not password: + raise UserServiceError("Senha é obrigatória", 400) + if not EMAIL_RE.match(email): + raise UserServiceError("Email inválido", 400) + if len(password) < settings.MIN_PASSWORD_LENGTH: + raise UserServiceError("Senha deve ter no mínimo %s caracteres" % settings.MIN_PASSWORD_LENGTH, 400) + if role not in settings.VALID_ROLES: + raise UserServiceError("Role inválido", 400) + if User.query.filter_by(email=email).first(): + raise UserServiceError("Email já cadastrado", 409) + + user = User() + user.name = name + user.email = email + user.set_password(password) + user.role = role + db.session.add(user) + _commit() + return user.to_dict() + + +def update_user(user_id, data): + user = User.query.get(user_id) + if not user: + raise UserServiceError("Usuário não encontrado", 404) + if not data: + raise UserServiceError("Dados inválidos", 400) + + if "name" in data: + user.name = data["name"] + if "email" in data: + if not EMAIL_RE.match(data["email"]): + raise UserServiceError("Email inválido", 400) + existing = User.query.filter_by(email=data["email"]).first() + if existing and existing.id != user_id: + raise UserServiceError("Email já cadastrado", 409) + user.email = data["email"] + if "password" in data: + if len(data["password"]) < settings.MIN_PASSWORD_LENGTH: + raise UserServiceError("Senha muito curta", 400) + user.set_password(data["password"]) + if "role" in data: + if data["role"] not in settings.VALID_ROLES: + raise UserServiceError("Role inválido", 400) + user.role = data["role"] + if "active" in data: + user.active = data["active"] + + db.session.commit() + return user.to_dict() + + +def delete_user(user_id): + user = User.query.get(user_id) + if not user: + raise UserServiceError("Usuário não encontrado", 404) + for t in Task.query.filter_by(user_id=user_id).all(): + db.session.delete(t) + db.session.delete(user) + _commit() + + +def get_user_tasks(user_id): + user = User.query.get(user_id) + if not user: + raise UserServiceError("Usuário não encontrado", 404) + return [t.serializable() for t in Task.query.filter_by(user_id=user_id).all()] + + +def login(email, password): + import jwt + import datetime + + if not email or not password: + raise UserServiceError("Email e senha são obrigatórios", 400) + user = User.query.filter_by(email=email).first() + if not user or not user.check_password(password): + raise UserServiceError("Credenciais inválidas", 401) + if not user.active: + raise UserServiceError("Usuário inativo", 403) + + payload = { + 'user_id': user.id, + 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=24) + } + token = jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') + + return { + "message": "Login realizado com sucesso", + "user": user.to_dict(), + "token": token, + } + + +def _commit(): + db.session.commit() \ No newline at end of file