diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index ea88fd2a5..6d5efc42a 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -2,8 +2,10 @@ name: Pull Request Validation "on": - pull_request: + push: branches: [master, develop] + pull_request: + branches: ['**'] # Manual trigger for testing workflow changes workflow_dispatch: diff --git a/.gitignore b/.gitignore index 2d0451d90..d31a327cf 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,5 @@ legacy-analysis-report.* # MkDocs build output site/ + +/src/Web/MeAjudaAi.Web.Customer/layout.css diff --git a/Directory.Packages.props b/Directory.Packages.props index 149dc15ee..4e7bafa7d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -167,6 +167,7 @@ + diff --git a/docs/roadmap.md b/docs/roadmap.md index 7e5ac46f9..684008dae 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,41 +7,16 @@ Este documento consolida o planejamento estratégico e tático da plataforma MeA ## 📊 Sumário Executivo **Projeto**: MeAjudaAi - Plataforma de Conexão entre Clientes e Prestadores de Serviços -**Status Geral**: Fase 1 ✅ | Sprint 0-5.5 ✅ | Sprint 6 ✅ | Sprint 7-7.15 ✅ CONCLUÍDO | MVP Target: 14/Março/2026 +**Status Geral**: Consulte a [Tabela de Sprints](#cronograma-de-sprints) para o status detalhado atualizado. **Cobertura de Testes**: Backend 90.56% | Frontend 30 testes bUnit **Stack**: .NET 10 LTS + Aspire 13 + PostgreSQL + Blazor WASM (Admin) + React 19 + Next.js 15 (Customer) + Tailwind v4 ### Marcos Principais -- ✅ **Janeiro 2025**: Fase 1 concluída - 6 módulos core implementados -- ✅ **Jan 20 - 21 Nov**: Sprint 0 - Migration .NET 10 + Aspire 13 (CONCLUÍDO e MERGED) -- ✅ **22 Nov - 2 Dez**: Sprint 1 - Geographic Restriction + Module Integration (CONCLUÍDO e MERGED) -- ✅ **3 Dez - 10 Dez**: Sprint 2 - Test Coverage 90.56% (CONCLUÍDO - META 35% SUPERADA!) -- ✅ **10 Dez - 11 Dez**: Sprint 3 Parte 1 - GitHub Pages Migration (CONCLUÍDO - DEPLOYED!) -- ✅ **11 Dez - 13 Dez**: Sprint 3 Parte 2 - Admin Endpoints & Tools (CONCLUÍDO - MERGED!) -- ✅ **14 Dez - 18 Dez**: Sprint 4 - Health Checks + Data Seeding + Code Review (CONCLUÍDO - MERGED!) -- ✅ **Sprint 5**: Tarefas completadas antecipadamente (NSubstitute→Moq, .slnx, UuidGenerator, Design Patterns, Bruno) -- ✅ **19 Dez - 30 Dez**: Sprint 5.5 - Refactor & Cleanup (CONCLUÍDO - Technical Debt Reduction) -- ✅ **30 Dez - 5 Jan 2026**: Sprint 6 - Blazor Admin Portal Setup (CONCLUÍDO - 5 Jan 2026, MERGED!) -- ✅ **6 Jan - 7 Jan 2026**: Sprint 7 - Blazor Admin Portal Features (CONCLUÍDO - 7 Jan 2026, 100%) -- ✅ **9 Jan 2026**: Sprint 7.5 - Correções de Inicialização e Build (CONCLUÍDO - 0 warnings, 0 erros) -- ✅ **12 Jan 2026**: Sprint 7.6 - Otimização de Testes de Integração (CONCLUÍDO - 83% faster) -- ✅ **15-16 Jan 2026**: Sprint 7.7 - Flux Pattern Refactoring (CONCLUÍDO - 5 páginas refatoradas, 87% code reduction) -- ✅ **16 Jan 2026**: Sprint 7.8 - Dialog Implementation Verification (CONCLUÍDO - 5 dialogs verificados, build fixes) -- ✅ **16 Jan 2026**: Sprint 7.9 - Magic Strings Elimination (CONCLUÍDO - 30+ strings eliminados, constants centralizados) -- ✅ **16 Jan 2026**: Sprint 7.10 - Accessibility Features (CONCLUÍDO - WCAG 2.1 AA compliance, ARIA labels, screen reader support) -- ✅ **16 Jan 2026**: Sprint 7.11 - Error Boundaries (CONCLUÍDO - Global error handling, Fluxor error state, recovery options) -- ✅ **16 Jan 2026**: Sprint 7.12 - Performance Optimizations (CONCLUÍDO - Virtualization, debounced search, memoization) -- ✅ **16 Jan 2026**: Sprint 7.13 - Standardized Error Handling (CONCLUÍDO - Retry logic, correlation IDs, HTTP status mapping) -- ✅ **16 Jan 2026**: Sprint 7.14 - Complete Localization (CONCLUÍDO - pt-BR/en-US, 140+ strings, culture switching) -- ✅ **16 Jan 2026**: Sprint 7.15 - Package Updates & Resilience Migration (CONCLUÍDO - .NET 10.0.2, deprecated packages removed) -- ✅ **17-21 Jan 2026**: Sprint 7.16 - Technical Debt Sprint (CONCLUÍDO - Keycloak automation, warnings, tests, records) -- ✅ **5 Fev 2026**: Sprint 7.20 - Dashboard Charts & Data Mapping Fixes (CONCLUÍDO - JSON property mapping, debug messages removed) -- ✅ **5 Fev 2026**: Sprint 7.21 - Package Updates & Bug Fixes (CONCLUÍDO - Microsoft.OpenApi 2.6.1, Aspire.Hosting.Redis 13.1.0, SonarAnalyzer.CSharp 10.19.0) -- ✅ **5-13 Fev 2026**: Sprint 8A - Customer Web App (React + Next.js) (CONCLUÍDO - Features & Test Optimization) -- ⏳ **19 Fev - 4 Mar 2026**: Sprint 8B - Mobile App (React Native + Expo) -- ⏳ **5-11 Mar 2026**: Sprint 9 - BUFFER (Polishing, Risk Mitigation, Final Testing) -- 🎯 **14 Março 2026**: MVP Launch (Admin Portal + Customer App Web + Mobile) -- 🔮 **Março 2026+**: Fase 3 - Reviews, Assinaturas, Agendamentos + +Consulte a seção [Cronograma de Sprints](#cronograma-de-sprints) abaixo para o status detalhado e atualizado de cada sprint, e datas alvo (incluindo o MVP Launch). + +**Procedimento de Revisão de Sprints** +As futuras atualizações da tabela de sprints devem observar a política: análise commit-by-commit newest-first, apresentando um veredicto conciso e resolvendo os follow-ups. ## ⚠️ Notas de Risco @@ -49,11 +24,53 @@ Este documento consolida o planejamento estratégico e tático da plataforma MeA - Primeiro projeto Blazor WASM pode revelar complexidade não prevista - Sprint 9 reservado como buffer de contingência (não para novas features) +## 🏗️ Decisões Arquiteturais Futuras + +### NX Monorepo (Frontend) + +**Status**: ⏳ Planejado pós-MVP +**Branch**: `infra/nx-monorepo` (separada do Sprint 8B) + +**Motivação**: Com Customer Web App (Next.js), Provider App (futuro) e Mobile (React Native + Expo), o compartilhamento de código (componentes, hooks, tipos TypeScript, schemas Zod) entre os projetos se torna crítico. NX oferece: +- Workspace unificado com `libs/` compartilhadas +- Build cache inteligente (só reconstrói o que mudou) +- Dependency graph entre projetos +- Geração de código consistente + +**Escopo da Sprint NX**: +- Migrar `MeAjudaAi.Web.Customer` para workspace NX +- Criar `apps/customer-web`, `apps/provider-web` (futuro), `apps/mobile` +- Criar `libs/ui` (componentes compartilhados), `libs/auth`, `libs/api-client` +- Atualizar `.NET Aspire AppHost` para apontar para nova estrutura +- Atualizar CI/CD para usar `nx affected` + +**Timing recomendado**: Pós-MVP (Sprint Infra), pois o NX facilita exatamente o compartilhamento entre Customer e Provider. + +--- + +### Migração Admin Portal: Blazor WASM → React? + +**Status**: ⚪ Não planejado — decisão deliberada manter Blazor + +**Análise**: + +| Fator | Manter Blazor | Migrar para React | +|-------|--------------|-------------------| +| Custo | ✅ Zero | ❌ Alto (reescrever ~5000+ linhas) | +| Compartilhamento C# DTOs | ✅ Nativo | ❌ Requer geração OpenAPI | +| Uso interno (não SEO) | ✅ Blazor adequado | ⚠️ React seria over-engineering | +| Unificação de stack | ⚠️ Dual-stack | ✅ Single-stack | +| Hiring | ⚠️ Blazor nicho | ✅ React mais fácil | + +**Decisão**: **Manter Blazor WASM** para o Admin Portal. O Admin é uma ferramenta interna sem requisitos de SEO ou performance de carga inicial. A vantagem de compartilhar C# DTOs diretamente supera o custo de manter dual-stack. Migração só faria sentido se o time crescer e a curva de aprendizado Blazor se tornar um gargalo real de contratação. + +**Revisitar se**: time crescer >5 devs frontend e Blazor se tornar bloqueador de contratação. + --- ## 🎯 Status Atual -**📅 Sprint 8B pré-início**: Fevereiro de 2026 +**📅 Sprint 8B concluído**: Fevereiro/Março de 2026 (Finalizado em 4 de Março de 2026) ### ✅ Sprint 8A - Customer Web App & Test Optimization - CONCLUÍDA (5-13 Fev 2026) @@ -73,7 +90,60 @@ Este documento consolida o planejamento estratégico e tático da plataforma MeA - **Problema**: Testes E2E lentos devido a acúmulo de dados (40m+). - **Solução**: Implementado `IAsyncLifetime` e `CleanupDatabaseAsync()` em **todas** as classes de teste E2E (`Documents`, `Locations`, `Providers`, `ServiceCatalogs`, `Users`). - **Resultado**: Testes rodam com banco limpo a cada execução, prevenindo degradação de performance e falhas por dados sujos (Race Conditions). -- **Parallelization**: Confirmado que `parallelizeTestCollections: false` é necessário devido ao uso de container de banco compartilhado. +- `parallelizeTestCollections`: Controla se coleções de teste executam em paralelo no xUnit. Confirmado que `parallelizeTestCollections: false` é necessário para DbContext com TestContainers, pois banco compartilhado causa lock conflicts. +--- + +### ✅ Sprint 8B.1 - Provider Onboarding & Registration Experience - CONCLUÍDA (Março 2026) + +**Objetivos**: +1. ✅ **Multi-step Provider Registration**: Implementar UI de "Torne-se um Prestador" com Stepper unificado. +2. ✅ **Fix Backend Reliability**: Resolver erros 500 nos endpoints críticos de prestador. +3. ✅ **Visual Alignment**: Alinhar design do prestador com o fluxo de cliente. + +**Avanços Entregues**: +- **Stepper UI**: Componente de linha do tempo implementado em `/cadastro/prestador`, guiando o usuário pelas etapas de Dados Básicos, Endereço e Documentos. +- **Correção de API (Critical)**: Resolvido erro de resolução de DI para `RegisterProviderCommandHandler`, permitindo a criação de perfis sem falhas internas (500). +- **Onboarding Flow**: Implementação da lógica de transição entre passos 1 (Dados Básicos) e 2 (Endereço), com persistência correta no banco de dados. +- **Validation**: Integração com esquema de validação existente e tratamento de erros amigável no frontend. + +**Próximos Passos (Pendentes)**: +- ⏳ **Document Upload (Step 3)**: Implementar componente de upload de documentos no fluxo de onboarding do prestador. +- ⏳ **Review Dashboard**: Criar interface para o prestador acompanhar o status de sua verificação (hoje parado em `pendingBasicInfo`). +- ⏳ **Professional Profile Setup**: Permitir que o prestador selecione categorias e serviços logo após o credenciamento básico. + +--- + +### ⏳ Sprint 8B.2 - Technical Excellence & Infrastructure (Planejado - Antes do Mobile) + +**Objetivos**: +1. ⏳ **Slug Implementation**: Substituir IDs por Slugs nas rotas de perfil de prestador para maior segurança e SEO. + - **Execução**: + - Backend: Adicionar `Slug` ao `BusinessProfile` entity. + - Backend: Implementar `slugify` logic e garantir unicidade no Persistence layer. + - UI: Alterar rotas de `/prestador/[id]` para `/prestador/[slug]`. + - SEO: Adicionar canonical tags e metadados dinâmicos baseados no slug. + - **Sucesso**: Navegar via slug e manter compatibilidade com IDs antigos (301 redirect). +2. ⏳ **Messaging Unification (RabbitMQ Only)**: Remover completamente o Azure Service Bus da solução. + - **Execução**: + - Remover pacotes `.Azure.ServiceBus` de todos os projetos. + - Unificar `MassTransit` configuration em `ServiceDefaults`. + - Atualizar scripts de infra (`docker-compose.yaml`) para foco total em RabbitMQ. + - Remover segredos e vars de ambiente do ASB no Azure/Staging. + - **Sucesso**: Aplicação funcionando sem dependência do Azure Service Bus local ou remoto. +3. ⏳ **Frontend Testing & CI/CD Suite**: Implementar suíte completa de testes no Next.js. + - **Contexto**: Baseado no [Plano de Testes Robusto](./testing/frontend-testing-plan.md). + - **Execução**: + - Setup do projeto `tests/MeAjudaAi.Web.Customer.Tests`. + - Implementar Mocks de API com MSW para os fluxos de busca e perfil. + - Criar o primeiro pipeline `.github/workflows/frontend-quality.yml`. + - Integrar SonarCloud (SonarQube) para análise estática de TS/React. + - **Sucesso**: Pipeline falhando se testes não passarem ou qualidade cair. +4. ⏳ **Backend Integration Test Optimization**: Reduzir o tempo de execução (hoje ~30 min). + - **Execução**: + - Migrar os ~20 projetos de teste restantes para o padrão `RequiredModules`. + - Implementar `Respawn` ou similar para limpeza ultra-rápida de banco em vez de migrations completas. + - Otimizar recursos do TestContainers (reuse containers entre runs se possível). + - **Sucesso**: Suíte completa de integração rodando em < 10 minutos. --- @@ -1460,7 +1530,7 @@ Get-ChildItem -Recurse -Include *.cs | Select-String "record " --- -### ⏳ Sprint 8B - Authentication & Onboarding Flow (Novo) +### ✅ Sprint 8B - Authentication & Onboarding Flow - CONCLUÍDO **Periodo Estimado**: 19 Fev - 4 Mar 2026 **Foco**: Fluxos de Cadastro e Login Segmentados (Cliente vs Prestador) @@ -1508,7 +1578,7 @@ Get-ChildItem -Recurse -Include *.cs | Select-String "record " ### ⏳ Sprint 8C - Mobile App (React Native) -**Periodo Estimado**: 5 Mar - 18 Mar 2026 (Deslocado) +**Periodo Estimado**: 5 Mar - 18 Mar 2026 **Foco**: App Mobile Nativo (iOS/Android) com Expo **Escopo**: @@ -2081,11 +2151,12 @@ Frontend Blazor WASM + MAUI Hybrid: - Sprint 7: Blazor Admin Portal Features (6-24 Jan 2026) - ✅ CONCLUÍDO - Sprint 7.16: Technical Debt Sprint (17-21 Jan 2026) - 🔄 EM PROGRESSO (Task 5 movida p/ Sprint 9) - Sprint 8: Customer App (5-18 Fev 2026) - ✅ Concluído -- Sprint 8B: Mobile App (19 Fev - 4 Mar 2026) - ⏳ Planejado -- Sprint 8D: Admin Portal Migration (19 Mar - 1 Abr 2026) - ⏳ Planejado -- Sprint 9: Buffer/Polishing (5-11 Mar 2026) - ⏳ Planejado -- MVP Final: 14 de Março de 2026 -- *Nota: Data de MVP atualizada para 14 de Março de 2026 para acomodar migração Nx e Mobile App.* +- Sprint 8B: Authentication & Onboarding (19 Fev - 4 Mar 2026) - ✅ CONCLUÍDO +- Sprint 8C: Mobile App (5-18 Mar 2026) - ⏳ Planejado +- Sprint 8D: Admin Portal Migration - 🚫 **CANCELADO** +- ⏳ **19-25 Mar 2026**: Sprint 9 - BUFFER (Polishing, Risk Mitigation, Final Testing) +- MVP Final: 28 de Março de 2026 +- *Nota: Data de MVP atualizada para 28 de Março de 2026 para acomodar buffer do Sprint 9 e Mobile App.* **⚠️ Risk Assessment**: Estimativas assumem velocidade consistente. Primeiro projeto Blazor WASM pode revelar complexidades não previstas (integração Keycloak, curva de aprendizado MudBlazor). Sprint 9 reservado como buffer de contingência. @@ -2105,6 +2176,7 @@ A implementação segue os princípios arquiteturais definidos em `architecture. --- + ## 📅 Cronograma de Sprints (Novembro 2025-Março 2026) | Sprint | Duração | Período | Objetivo | Status | @@ -2121,14 +2193,15 @@ A implementação segue os princípios arquiteturais definidos em `architecture. | **Sprint 7** | 3 semanas | 6 - 24 Jan | Blazor Admin Portal - Features | ✅ CONCLUÍDO | | **Sprint 7.16** | 1 semana | 17-21 Jan | Technical Debt Sprint | 🔄 EM PROGRESSO | | **Sprint 8** | 2 semanas | 5 - 18 Fev | Customer Web App (Web) | ✅ CONCLUÍDO | -| **Sprint 8B** | 2 semanas | 19 Fev - 4 Mar | Mobile App (React Native) | ⏳ Planejado | -| **Sprint 9** | 1 semana | 5-11 Mar | **BUFFER: Polishing, Refactoring & Risk Mitigation** | ⏳ Planejado | -| **MVP Launch** | - | 14 Mar | Final deployment & launch preparation | 🎯 Target | +| **Sprint 8B** | 2 semanas | 19 Fev - 4 Mar | Authentication & Onboarding | ✅ CONCLUÍDO | +| **Sprint 8C** | 2 semanas | 5-18 Mar | Mobile App | ⏳ Planejado | +| **Sprint 9** | 1 semana | 19-25 Mar | **BUFFER: Polishing, Refactoring & Risk Mitigation** | ⏳ Planejado | +| **MVP Launch** | - | 28 de Março de 2026 | Final deployment & launch preparation | 🎯 Target | -**MVP Launch Target**: 14 de Março de 2026 🎯 -*Atualizado para 14 de Março de 2026.* +**MVP Launch Target**: 28 de Março de 2026 🎯 +*Atualizado para 28 de Março de 2026.* -**Post-MVP (Fase 3+)**: Reviews, Assinaturas, Agendamentos (Fevereiro 2026+) +**Post-MVP (Fase 3+)**: Reviews, Assinaturas, Agendamentos (Abril 2026+) --- @@ -4187,15 +4260,6 @@ public class GeographicRestrictionMiddleware --- -### ⏳ Sprint 8D - Admin Portal Migration (Novo) - -**Periodo Estimado**: 19 Mar - 1 Abr 2026 -**Foco**: Migração do Admin Portal para React e integração no monorepo Nx. - -**Objetivos**: -1. **Nx Workspace Setup**: Garantir suporte a múltiplos apps (Admin + Customer). -2. **Admin Portal Migration**: Portar Blazor Admin para React (`apps/admin-portal`). -3. **Shared Components**: Extrair UI kit para `libs/shared-ui`. 4. **Auth Migration**: Configurar Keycloak no novo app React. **Entregáveis**: @@ -4352,10 +4416,10 @@ Durante o processo de atualização automática de dependências pelo Dependabot --- -### 📅 Sprint 9: Buffer - Polishing, Risk Mitigation & Refactoring (3 semanas) 🎯 +### ⏳ **19-25 Mar 2026**: Sprint 9 - BUFFER (Polishing, Risk Mitigation, Final Testing) **Status**: 📋 PLANEJADO PARA MARÇO 2026 -**Duração**: 1 semana (5-11 Mar 2026) +**Duração**: 1 semana (19-25 Mar 2026) **Dependências**: Sprints 6-8 completos **Natureza**: **BUFFER DE CONTINGÊNCIA** - não alocar novas features @@ -4485,7 +4549,7 @@ Durante o processo de atualização automática de dependências pelo Dependabot - ✅ Segurança e performance hardened - ✅ Documentação completa para usuários e desenvolvedores - ✅ Monitoring e observabilidade configurados -- 🎯 **PRONTO PARA LAUNCH EM 14 DE MARÇO DE 2026** +- 🎯 **PRONTO PARA LAUNCH EM 28 DE MARÇO DE 2026** > **⚠️ CRITICAL**: Se Sprint 9 não for suficiente para completar todos os itens, considerar delay do MVP launch ou reduzir escopo (mover features não-críticas para post-MVP). A qualidade e estabilidade do MVP são mais importantes que a data de lançamento. @@ -4950,7 +5014,7 @@ public class ActivityHub : Hub 4. 📋 **Sprint 8: Customer Portal** - React/Next.js (Planejado - Fev/Mar 2026) - Busca de prestadores - Gestão de perfil -5. 📋 **Sprint 8D: Admin Portal Migration** - React/Nx (Planejado - Mar/Abr 2026) +5. 🚫 **Sprint 8D: Admin Portal Migration** - **CANCELADO** - Migração completa de Blazor para React 6. 📋 API Collections - Bruno .bru files para todos os módulos @@ -5012,6 +5076,6 @@ public class ActivityHub : Hub --- -*📅 Última atualização: 9 de Janeiro de 2026 (Sprint 7.5 - Correções de Inicialização e Build)* +*📅 Última atualização: 5 de Março de 2026 (Sprint 8B Conclusion Review)* *🔄 Roadmap em constante evolução baseado em feedback, métricas e aprendizados* -*📊 Status atual: Sprint 6 CONCLUÍDA (5 Jan 2026) | Sprint 7 - Blazor Admin Portal Features (próxima)* +*📊 Status atual: Sprint 8B ✅ CONCLUÍDO | MVP Launch em 28 de Março de 2026* diff --git a/docs/testing/frontend-testing-plan.md b/docs/testing/frontend-testing-plan.md new file mode 100644 index 000000000..def157fd4 --- /dev/null +++ b/docs/testing/frontend-testing-plan.md @@ -0,0 +1,1790 @@ +# Plano de Implementação de Testes - React 19 + TypeScript +## Projeto: MeAjudaAi.Web.Consumer (Monorepo .NET) + +## 📋 Sumário + +1. [Contexto do Projeto](#contexto-do-projeto) +2. [Decisão Arquitetural](#decisão-arquitetural) +3. [Bibliotecas e Dependências](#bibliotecas-e-dependências) +4. [Estrutura de Pastas](#estrutura-de-pastas) +5. [Configuração](#configuração) +6. [Estrutura dos Arquivos de Teste](#estrutura-dos-arquivos-de-teste) +7. [Pipeline CI/CD Robusta (Ref. Medium)](#pipeline-cicd-robusta-ref-medium) +8. [Integração com Pipeline CI/CD](#integração-com-pipeline-cicd) +9. [Comandos Úteis](#comandos-úteis) +10. [Boas Práticas](#boas-práticas) + +--- + +## 🏗️ Contexto do Projeto + +O projeto está integrado em um **monorepo .NET** com arquitetura de **monolito modular**. A estrutura atual já possui: + +- **Backend .NET** com testes completos organizados por camada: + - `MeAjudaAi.ApiService.Tests` + - `MeAjudaAi.Architecture.Tests` + - `MeAjudaAi.E2E.Tests` + - `MeAjudaAi.Integration.Tests` + - `MeAjudaAi.Shared.Tests` + - `MeAjudaAi.Web.Admin.Tests` (Blazor WASM com bUnit) + +- **Frontend React** localizado em: + - `src/Web/MeAjudaAi.Web.Consumer` + +--- + +## 🎯 Decisão Arquitetural + +### ✅ Recomendação: Criar Projeto Separado de Testes + +**Criar:** `tests/MeAjudaAi.Web.Consumer.Tests` + +### Justificativa + +1. **Consistência com a arquitetura existente**: Todos os projetos de teste já estão separados na pasta `tests/` +2. **Separação de responsabilidades**: Backend (.NET) e Frontend (React) mantêm seus testes isolados +3. **Pipeline CI/CD independente**: Permite executar testes frontend/backend separadamente +4. **Mesma abordagem do Web.Admin**: O portal admin Blazor já segue este padrão com `MeAjudaAi.Web.Admin.Tests` +5. **Facilita manutenção**: Dependências JavaScript não poluem projetos .NET +6. **Clareza organizacional**: Fica explícito que são testes de frontend + +### Estrutura Completa do Monorepo + +```text +MeAjudaAi/ +├── src/ +│ ├── Web/ +│ │ ├── MeAjudaAi.Web.Consumer/ # ← Projeto React +│ │ │ ├── src/ +│ │ │ ├── public/ +│ │ │ ├── package.json +│ │ │ ├── vite.config.ts +│ │ │ └── tsconfig.json +│ │ └── MeAjudaAi.Web.Admin/ # Blazor WASM +│ ├── ApiService/ +│ ├── Architecture/ +│ └── Shared/ +├── tests/ +│ ├── MeAjudaAi.ApiService.Tests/ # .NET +│ ├── MeAjudaAi.Architecture.Tests/ # .NET +│ ├── MeAjudaAi.E2E.Tests/ # .NET +│ ├── MeAjudaAi.Integration.Tests/ # .NET +│ ├── MeAjudaAi.Shared.Tests/ # .NET +│ ├── MeAjudaAi.Web.Admin.Tests/ # bUnit (Blazor) +│ └── MeAjudaAi.Web.Consumer.Tests/ # ← NOVO: Vitest/React Testing Library +│ ├── src/ +│ │ ├── components/ +│ │ ├── hooks/ +│ │ ├── pages/ +│ │ ├── utils/ +│ │ └── __tests__/ +│ ├── e2e/ +│ ├── package.json +│ ├── vitest.config.ts +│ ├── playwright.config.ts +│ └── tsconfig.json +├── .editorconfig +├── parallel.runsettings +├── sequential.runsettings +├── xunit.runner.json +└── MeAjudaAi.sln +``` + +--- + +## 📦 Bibliotecas e Dependências + +### Instalação Básica + +```bash +# Testing framework e runners +npm install --save-dev vitest @vitest/ui jsdom + +# React Testing Library +npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event + +# Suporte a TypeScript para testes +npm install --save-dev @testing-library/dom@^10.x + +# Cobertura de código +npm install --save-dev @vitest/coverage-v8 + +# Mock Service Worker (para mock de APIs) +npm install --save-dev msw + +# Testes E2E (opcional mas recomendado) +npm install --save-dev @playwright/test +``` + +### Pacotes Adicionais (Opcionais) + +```bash +# Para testes de acessibilidade +npm install --save-dev jest-axe + +# Para snapshots visuais +npm install --save-dev @storybook/test-runner + +# Nota: O pacote @testing-library/react-hooks está depreciado. +# Para testar hooks, use renderHook diretamente de '@testing-library/react' (v13.1+). +``` + +--- + +## 📁 Estrutura de Pastas + +### Estrutura do Projeto de Testes: `tests/MeAjudaAi.Web.Consumer.Tests/` + +```text +MeAjudaAi.Web.Consumer.Tests/ +├── src/ +│ ├── components/ +│ │ ├── Button/ +│ │ │ ├── Button.test.tsx +│ │ │ └── Button.integration.test.tsx +│ │ ├── Input/ +│ │ │ ├── Input.test.tsx +│ │ │ └── Input.accessibility.test.tsx +│ │ ├── Card/ +│ │ │ └── Card.test.tsx +│ │ └── Layout/ +│ │ ├── Header.test.tsx +│ │ └── Sidebar.test.tsx +│ ├── hooks/ +│ │ ├── useAuth.test.ts +│ │ ├── useLocalStorage.test.ts +│ │ └── useDebounce.test.ts +│ ├── pages/ +│ │ ├── Home/ +│ │ │ └── Home.test.tsx +│ │ ├── Dashboard/ +│ │ │ └── Dashboard.test.tsx +│ │ └── Profile/ +│ │ └── Profile.test.tsx +│ ├── services/ +│ │ ├── api.test.ts +│ │ ├── auth.service.test.ts +│ │ └── storage.service.test.ts +│ ├── utils/ +│ │ ├── formatters.test.ts +│ │ ├── validators.test.ts +│ │ └── helpers.test.ts +│ └── __tests__/ +│ ├── setup.ts +│ ├── helpers/ +│ │ ├── test-utils.tsx +│ │ ├── mock-data.ts +│ │ ├── custom-matchers.ts +│ │ └── test-providers.tsx +│ └── mocks/ +│ ├── handlers.ts +│ ├── server.ts +│ └── browser.ts +├── e2e/ +│ ├── tests/ +│ │ ├── auth/ +│ │ │ ├── login.spec.ts +│ │ │ ├── register.spec.ts +│ │ │ └── logout.spec.ts +│ │ ├── navigation/ +│ │ │ ├── menu.spec.ts +│ │ │ └── routes.spec.ts +│ │ └── user-flows/ +│ │ ├── complete-profile.spec.ts +│ │ └── request-help.spec.ts +│ ├── fixtures/ +│ │ ├── users.ts +│ │ └── requests.ts +│ └── utils/ +│ └── helpers.ts +├── coverage/ +│ └── (gerado automaticamente) +├── node_modules/ +├── package.json +├── package-lock.json +├── vitest.config.ts +├── playwright.config.ts +├── tsconfig.json +├── .gitignore +└── README.md +``` + +### Mapeamento para o Código Fonte + +Os testes espelham a estrutura do projeto principal: + +```text +src/Web/MeAjudaAi.Web.Consumer/src/ → tests/MeAjudaAi.Web.Consumer.Tests/src/ + components/Button/Button.tsx → components/Button/Button.test.tsx + hooks/useAuth.ts → hooks/useAuth.test.ts + pages/Home/Home.tsx → pages/Home/Home.test.tsx + utils/formatters.ts → utils/formatters.test.ts +``` + +### Estrutura de Nomenclatura + +- **Testes unitários**: `*.test.tsx` ou `*.test.ts` +- **Testes de integração**: `*.integration.test.tsx` +- **Testes de acessibilidade**: `*.accessibility.test.tsx` +- **Testes E2E**: `*.spec.ts` (dentro de `e2e/tests/`) + +### Vantagens desta Estrutura + +✅ **Separação clara**: Testes não poluem o código de produção +✅ **Consistência**: Segue o padrão do monorepo (.NET tests separados) +✅ **Build otimizado**: Fácil excluir testes do bundle de produção +✅ **CI/CD independente**: Pode rodar testes frontend/backend separadamente +✅ **Organização**: Mesma abordagem do `MeAjudaAi.Web.Admin.Tests` + +--- + +## ⚙️ Configuração + +### 1. `vitest.config.ts` + +```typescript +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/__tests__/setup.ts', + css: true, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'src/__tests__/', + '**/*.d.ts', + '**/*.config.*', + '**/mockData', + 'src/main.tsx', + ], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@src': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src'), + '@components': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/components'), + '@hooks': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/hooks'), + '@utils': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/utils'), + '@pages': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/pages'), + }, + }, +}); +``` + +### 2. `src/__tests__/setup.ts` + +```typescript +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach, beforeAll, afterAll } from 'vitest'; +import { server } from './mocks/server'; + +// Estabelece requisições de API mockadas +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + +// Reseta handlers entre testes +afterEach(() => { + cleanup(); + server.resetHandlers(); +}); + +// Limpa após todos os testes +afterAll(() => server.close()); + +// Mock do matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, // deprecated + removeListener: () => {}, // deprecated + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => {}, + }), +}); + +// Mock do IntersectionObserver +global.IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + takeRecords() { + return []; + } + unobserve() {} +} as any; +``` + +### 3. `src/__tests__/helpers/test-utils.tsx` + +```typescript +import { ReactElement } from 'react'; +import { render, RenderOptions } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; + +// Provider customizado se você tiver (Context, Theme, etc) +interface AllTheProvidersProps { + children: React.ReactNode; +} + +const AllTheProviders = ({ children }: AllTheProvidersProps) => { + return ( + + {/* Adicione seus providers aqui */} + {/* */} + {/* */} + {children} + {/* */} + {/* */} + + ); +}; + +const customRender = ( + ui: ReactElement, + options?: Omit +) => render(ui, { wrapper: AllTheProviders, ...options }); + +// Re-exporta tudo +export * from '@testing-library/react'; +export { customRender as render }; +``` + +### 4. `src/__tests__/mocks/handlers.ts` + +```typescript +import { http, HttpResponse } from 'msw'; + +export const handlers = [ + // GET exemplo + http.get('/api/users', () => { + return HttpResponse.json([ + { id: 1, name: 'John Doe', email: 'john@example.com' }, + { id: 2, name: 'Jane Smith', email: 'jane@example.com' }, + ]); + }), + + // POST exemplo + http.post('/api/login', async ({ request }) => { + const { email, password } = await request.json(); + + if (email === 'test@test.com' && password === 'password123') { + return HttpResponse.json({ + token: 'fake-jwt-token', + user: { id: 1, email, name: 'Test User' }, + }); + } + + return HttpResponse.json( + { message: 'Invalid credentials' }, + { status: 401 } + ); + }), + + // Erro exemplo + http.get('/api/error', () => { + return HttpResponse.json( + { message: 'Internal Server Error' }, + { status: 500 } + ); + }), +]; +``` + +### 5. `src/__tests__/mocks/server.ts` + +```typescript +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); +``` + +### 6. `playwright.config.ts` + +```typescript +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e/tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}); +``` + +### 7. `package.json` - Scripts + +```json +{ + "scripts": { + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report" + } +} +``` + +--- + +## 📝 Estrutura dos Arquivos de Teste + +### Teste de Componente com Base UI React + +**`src/components/Button/Button.tsx`** + +```typescript +import { Button as BaseButton } from '@base-ui/react/Button'; +import { tv, type VariantProps } from 'tailwind-variants'; +import { twMerge } from 'tailwind-merge'; + +const button = tv({ + base: 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50', + variants: { + variant: { + primary: 'bg-blue-600 text-white hover:bg-blue-700', + secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300', + destructive: 'bg-red-600 text-white hover:bg-red-700', + ghost: 'hover:bg-gray-100', + }, + size: { + sm: 'h-9 px-3 text-sm', + md: 'h-10 px-4', + lg: 'h-11 px-8 text-lg', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, +}); + +export type ButtonProps = VariantProps & { + children: React.ReactNode; + className?: string; + disabled?: boolean; + type?: 'button' | 'submit' | 'reset'; + onClick?: () => void; +}; + +export function Button({ + children, + variant, + size, + className, + ...props +}: ButtonProps) { + return ( + + {children} + + ); +} +``` + +**`src/components/Button/Button.test.tsx`** + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@/__tests__/helpers/test-utils'; +import userEvent from '@testing-library/user-event'; +import { Button } from '@src/components/Button/Button'; + +describe('Button Component', () => { + it('deve renderizar corretamente', () => { + render(); + + expect(screen.getByRole('button', { name: /clique aqui/i })).toBeInTheDocument(); + }); + + it('deve aplicar a variante primary por padrão', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('bg-blue-600'); + }); + + it('deve aplicar diferentes variantes', () => { + const { rerender } = render(); + expect(screen.getByRole('button')).toHaveClass('bg-gray-200'); + + rerender(); + expect(screen.getByRole('button')).toHaveClass('bg-red-600'); + + rerender(); + expect(screen.getByRole('button')).toHaveClass('hover:bg-gray-100'); + }); + + it('deve aplicar diferentes tamanhos', () => { + const { rerender } = render(); + expect(screen.getByRole('button')).toHaveClass('h-9'); + + rerender(); + expect(screen.getByRole('button')).toHaveClass('h-10'); + + rerender(); + expect(screen.getByRole('button')).toHaveClass('h-11'); + }); + + it('deve aceitar className customizada', () => { + render(); + + expect(screen.getByRole('button')).toHaveClass('custom-class'); + }); + + it('deve estar desabilitado quando disabled=true', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + expect(button).toHaveClass('opacity-50'); + }); + + it('deve chamar onClick quando clicado', async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button')); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('não deve chamar onClick quando desabilitado', async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button')); + + expect(handleClick).not.toHaveBeenCalled(); + }); + + it('deve renderizar diferentes tipos de button', () => { + const { rerender } = render(); + expect(screen.getByRole('button')).toHaveAttribute('type', 'submit'); + + rerender(); + expect(screen.getByRole('button')).toHaveAttribute('type', 'reset'); + }); +}); +``` + +### Teste de Hook Customizado + +**`src/hooks/useLocalStorage.ts`** + +```typescript +import { useState, useEffect } from 'react'; + +export function useLocalStorage(key: string, initialValue: T) { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(error); + return initialValue; + } + }); + + const setValue = (value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } catch (error) { + console.error(error); + } + }; + + return [storedValue, setValue] as const; +} +``` + +**`src/hooks/useLocalStorage.test.ts`** + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useLocalStorage } from '@src/hooks/useLocalStorage'; + +describe('useLocalStorage Hook', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('deve retornar o valor inicial quando não há valor armazenado', () => { + const { result } = renderHook(() => useLocalStorage('test-key', 'initial')); + + expect(result.current[0]).toBe('initial'); + }); + + it('deve retornar o valor armazenado do localStorage', () => { + localStorage.setItem('test-key', JSON.stringify('stored-value')); + + const { result } = renderHook(() => useLocalStorage('test-key', 'initial')); + + expect(result.current[0]).toBe('stored-value'); + }); + + it('deve atualizar o localStorage quando setValue é chamado', () => { + const { result } = renderHook(() => useLocalStorage('test-key', 'initial')); + + act(() => { + result.current[1]('new-value'); + }); + + expect(result.current[0]).toBe('new-value'); + expect(localStorage.getItem('test-key')).toBe(JSON.stringify('new-value')); + }); + + it('deve funcionar com objetos', () => { + const initialObject = { name: 'John', age: 30 }; + const { result } = renderHook(() => useLocalStorage('user', initialObject)); + + const newObject = { name: 'Jane', age: 25 }; + + act(() => { + result.current[1](newObject); + }); + + expect(result.current[0]).toEqual(newObject); + }); + + it('deve funcionar com função updater', () => { + const { result } = renderHook(() => useLocalStorage('counter', 0)); + + act(() => { + result.current[1](prev => prev + 1); + }); + + expect(result.current[0]).toBe(1); + + act(() => { + result.current[1](prev => prev + 5); + }); + + expect(result.current[0]).toBe(6); + }); +}); +``` + +### Teste de Página com API + +**`src/pages/Users/Users.tsx`** + +```typescript +import { useState, useEffect } from 'react'; +import { Button } from '@src/components/Button/Button'; + +interface User { + id: number; + name: string; + email: string; +} + +export function Users() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch('/api/users') + .then(res => res.json()) + .then(data => { + setUsers(data); + setLoading(false); + }) + .catch(err => { + setError(err.message); + setLoading(false); + }); + }, []); + + if (loading) return
Carregando...
; + if (error) return
Erro: {error}
; + + return ( +
+

Usuários

+
    + {users.map(user => ( +
  • + {user.name} - {user.email} +
  • + ))} +
+ +
+ ); +} +``` + +**`src/pages/Users/Users.test.tsx`** + +```typescript +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@/__tests__/helpers/test-utils'; +import { Users } from '@src/pages/Users/Users'; + +describe('Users Page', () => { + it('deve mostrar loading inicialmente', () => { + render(); + + expect(screen.getByText(/carregando/i)).toBeInTheDocument(); + }); + + it('deve renderizar lista de usuários após carregamento', async () => { + render(); + + await waitFor(() => { + expect(screen.queryByText(/carregando/i)).not.toBeInTheDocument(); + }); + + expect(screen.getByText(/john doe/i)).toBeInTheDocument(); + expect(screen.getByText(/jane smith/i)).toBeInTheDocument(); + }); + + it('deve renderizar botão de atualizar', async () => { + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /atualizar/i })).toBeInTheDocument(); + }); + }); +}); +``` + +### Teste de Utilidade + +**`src/utils/formatters.ts`** + +```typescript +export function formatCurrency(value: number): string { + return new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + }).format(value); +} + +export function formatDate(date: Date): string { + return new Intl.DateTimeFormat('pt-BR').format(date); +} + +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; +} +``` + +**`src/utils/formatters.test.ts`** + +```typescript +import { describe, it, expect } from 'vitest'; +import { formatCurrency, formatDate, truncateText } from '@src/utils/formatters'; + +describe('formatCurrency', () => { + it('deve formatar número como moeda brasileira', () => { + expect(formatCurrency(1000)).toBe('R$ 1.000,00'); + expect(formatCurrency(50.5)).toBe('R$ 50,50'); + }); +}); + +describe('formatDate', () => { + it('deve formatar data no padrão brasileiro', () => { + const date = new Date('2024-01-15'); + expect(formatDate(date)).toBe('15/01/2024'); + }); +}); + +describe('truncateText', () => { + it('deve retornar texto original se menor que maxLength', () => { + expect(truncateText('Hello', 10)).toBe('Hello'); + }); + + it('deve truncar texto e adicionar reticências', () => { + expect(truncateText('Hello World', 5)).toBe('Hello...'); + }); + + it('deve funcionar com maxLength exato', () => { + expect(truncateText('Hello', 5)).toBe('Hello'); + }); +}); +``` + +### Teste E2E com Playwright + +**`e2e/tests/user-flow.spec.ts`** + +```typescript +import { test, expect } from '@playwright/test'; + +test.describe('Fluxo de Usuário', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + test('deve navegar para página de usuários', async ({ page }) => { + await page.click('text=Usuários'); + + await expect(page).toHaveURL(/.*users/); + await expect(page.locator('h1')).toContainText('Usuários'); + }); + + test('deve mostrar lista de usuários', async ({ page }) => { + await page.goto('/users'); + + await expect(page.locator('li')).toHaveCount(2); + await expect(page.locator('text=John Doe')).toBeVisible(); + }); + + test('deve clicar no botão de atualizar', async ({ page }) => { + await page.goto('/users'); + + const button = page.getByRole('button', { name: /atualizar/i }); + await button.click(); + + // Verificar algum comportamento esperado + }); +}); +``` + +--- + +--- + +## 🔄 Integração com Pipeline CI/CD + +### Integração com o Monorepo .NET + +O projeto está em um monorepo .NET e usa GitHub Actions para CI/CD. A integração dos testes JavaScript no pipeline existente garante que todos os testes (backend e frontend) sejam executados automaticamente. + +### 1. Adicionar Scripts ao `package.json` + +**`tests/MeAjudaAi.Web.Consumer.Tests/package.json`** + +```json +{ + "name": "meajudaai.web.consumer.tests", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest", + "test:run": "vitest run", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", + "test:ci": "vitest run --coverage --reporter=junit", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:ci": "playwright test --reporter=html,junit", + "test:e2e:report": "playwright show-report" + }, + "dependencies": {}, + "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.1.5", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.5.1", + "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^3.0.0", + "@vitest/ui": "^3.0.0", + "@playwright/test": "^1.50.0", + "jsdom": "^26.0.0", + "msw": "^2.0.11", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vitest": "^3.0.0" + } +} +``` + +### 2. Configuração do GitHub Actions + +**`.github/workflows/tests.yml`** + +```yaml +name: Tests + +on: + push: + branches: [master, main, develop] + paths: + - 'src/Web/MeAjudaAi.Web.Consumer/**' + - 'tests/MeAjudaAi.Web.Consumer.Tests/**' + pull_request: + branches: [master, main, develop] + +jobs: + backend-tests: + name: Backend Tests (.NET) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Run tests + run: dotnet test --no-restore --verbosity normal --collect:"XPlat Code Coverage" + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: '**/coverage.cobertura.xml' + flags: backend + token: ${{ secrets.CODECOV_TOKEN }} + + frontend-unit-tests: + name: Frontend Unit Tests (React) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tests/MeAjudaAi.Web.Consumer.Tests/package-lock.json + + - name: Install dependencies + working-directory: tests/MeAjudaAi.Web.Consumer.Tests + run: npm ci + + - name: Run tests + working-directory: tests/MeAjudaAi.Web.Consumer.Tests + run: npm run test:ci + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: tests/MeAjudaAi.Web.Consumer.Tests/coverage/coverage-final.json + flags: frontend + token: ${{ secrets.CODECOV_TOKEN }} + + frontend-e2e-tests: + name: Frontend E2E Tests (Playwright) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: tests/MeAjudaAi.Web.Consumer.Tests/package-lock.json + + - name: Install dependencies + working-directory: tests/MeAjudaAi.Web.Consumer.Tests + run: npm ci + + - name: Install Playwright browsers + working-directory: tests/MeAjudaAi.Web.Consumer.Tests + run: npx playwright install --with-deps + + - name: Run E2E tests + working-directory: tests/MeAjudaAi.Web.Consumer.Tests + run: npm run test:e2e:ci + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: tests/MeAjudaAi.Web.Consumer.Tests/playwright-report/ + retention-days: 30 +``` + +--- + +## 🚀 Pipeline CI/CD Robusta (Ref. Medium) + +Baseado no guia "[Building a Robust CI/CD Pipeline for React Apps](https://medium.com/@lamjed.gaidi070/building-a-robust-ci-cd-pipeline-for-react-apps-with-testing-and-static-analysis-05e14735f8f0)", nossa estratégia inclui as seguintes considerações: + +### 1. Análise Estática com SonarQube +Além do ESLint, o projeto deve integrar o **SonarScanner** no pipeline para: +- Monitorar a saúde do código a longo prazo. +- Definir **Quality Gates** (ex: falhar build se cobertura cair de 80%). +- Detectar vulnerabilidades de segurança (Security Hotspots). + +### 2. Fluxo Completo do Pipeline +Diferente de um pipeline simples de build, o fluxo robusto implementado seguirá: +1. **Lint & Static Analysis**: ESLint + Prettier + SonarQube Scan. +2. **Unit & Integration Tests**: Execução com Vitest (com geração de relatório LCOV para o Sonar). +3. **Build & Package**: Geração da build de produção do Vite para MeAjudaAi.Web.Consumer. +4. **Containerization (Contexto Aspire)**: O `dotnet aspire` facilita a geração de imagens Docker que serão enviadas para o Registry (Azure Container Registry). +5. **E2E Testing**: Execução do Playwright contra o container de staging. +6. **Deployment**: Via `azd deploy` para Azure Container Apps. + +### 3. Comparativo de Ferramentas + +| Ferramenta Artigo | Nossa Escolha | Justificativa | +|-------------------|---------------|---------------| +| Jest | **Vitest** | Nativo para Vite, performance significativamente superior. | +| Cypress / Selenium | **Playwright** | Melhor suporte a múltiplos browsers, mais rápido e resiliente. | +| SonarQube | **SonarQube** | Mantido como padrão para métricas de qualidade. | +| Docker / K8s | **Docker / Aspire** | Usamos Docker via Aspire, que abstrai a complexidade do K8s facilitando o deploy. | + + +--- + +### 3. Configuração de Reports para CI/CD + +**`tests/MeAjudaAi.Web.Consumer.Tests/vitest.config.ts`** + +```typescript +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/__tests__/setup.ts', + css: true, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov', 'cobertura', 'json-summary'], + reportsDirectory: './coverage', + exclude: [ + 'node_modules/', + 'src/__tests__/', + '**/*.d.ts', + '**/*.config.*', + '**/mockData', + ], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + reporters: ['default', 'junit', 'json'], + outputFile: { + junit: './test-results/junit.xml', + json: './test-results/results.json', + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@src': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src'), + }, + }, +}); +``` + +### 4. Executar Testes Localmente + +```bash +# Navegar para o projeto de testes +cd tests/MeAjudaAi.Web.Consumer.Tests + +# Instalar dependências (primeira vez) +npm install + +# Executar testes unitários +npm test + +# Executar testes com cobertura +npm run test:coverage + +# Executar testes E2E +npm run test:e2e + +# Executar todos os testes (CI mode) +npm run test:ci && npm run test:e2e:ci +``` + +### 5. Scripts Helper no Root do Monorepo + +Criar script para facilitar execução de testes: + +**`scripts/run-frontend-tests.sh`** + +```bash +#!/bin/bash + +echo "🧪 Executando testes do Frontend (React)..." + +cd tests/MeAjudaAi.Web.Consumer.Tests + +# Verificar se node_modules existe +if [ ! -d "node_modules" ]; then + echo "📦 Instalando dependências..." + npm install +fi + +# Executar testes unitários +echo "🔬 Executando testes unitários..." +npm run test:run + +# Executar testes E2E +echo "🎭 Executando testes E2E..." +npm run test:e2e + +echo "✅ Testes concluídos!" +``` + +**`scripts/run-all-tests.sh`** + +```bash +#!/bin/bash + +echo "🧪 Executando TODOS os testes do monorepo..." + +# Backend tests +echo "🔵 Executando testes Backend (.NET)..." +dotnet test + +# Frontend tests +echo "🟢 Executando testes Frontend (React)..." +./scripts/run-frontend-tests.sh + +echo "✅ Todos os testes concluídos!" +``` + +--- + +## 🎯 Comandos Úteis + +### Navegação e Setup Inicial + +```bash +# Navegar para o projeto de testes +cd tests/MeAjudaAi.Web.Consumer.Tests + +# Instalar dependências (primeira vez ou após pull) +npm install + +# Instalar browsers do Playwright +npx playwright install +``` + +### Testes Unitários (Vitest) + +```bash +# Executar todos os testes em modo watch +npm test + +# Executar testes uma única vez +npm run test:run + +# Executar testes com interface visual +npm run test:ui + +# Executar apenas um arquivo específico +npm test -- src/components/Button/Button.test.tsx + +# Executar testes que correspondem a um padrão +npm test -- Button + +# Executar testes em modo CI (sem watch) +npm run test:ci +``` + +### Cobertura de Código + +```bash +# Gerar relatório de cobertura +npm run test:coverage + +# Ver relatório HTML no navegador +open coverage/index.html + +# Cobertura com threshold definido (falha se < 80%) +npm run test:coverage -- --coverage.thresholds.lines=80 +``` + +### Testes E2E (Playwright) + +```bash +# Executar todos os testes E2E +npm run test:e2e + +# Executar em modo UI (debug visual) +npm run test:e2e:ui + +# Executar apenas um arquivo +npm run test:e2e -- e2e/tests/auth/login.spec.ts + +# Executar em modo headed (ver o browser) +npm run test:e2e -- --headed + +# Executar em modo debug +npm run test:e2e -- --debug + +# Executar em browser específico +npm run test:e2e -- --project=chromium +npm run test:e2e -- --project=firefox + +# Ver relatório após execução +npm run test:e2e:report +``` + +### Executar do Root do Monorepo + +```bash +# A partir da raiz do projeto MeAjudaAi/ + +# Executar apenas testes frontend +./scripts/run-frontend-tests.sh + +# Executar todos os testes (Backend + Frontend) +./scripts/run-all-tests.sh + +# Executar testes Backend (.NET) +dotnet test + +# Executar testes Frontend específicos +cd tests/MeAjudaAi.Web.Consumer.Tests && npm test +``` + +### Debugging e Troubleshooting + +```bash +# Ver output detalhado +npm test -- --reporter=verbose + +# Executar com logs de debug +DEBUG=* npm test + +# Limpar cache e node_modules +rm -rf node_modules coverage .vitest +npm install + +# Atualizar snapshots +npm test -- -u + +# Executar apenas testes modificados +npm test -- --changed +``` + +### Integração com IDE (VS Code) + +Instale as extensões recomendadas: + +```json +// .vscode/extensions.json +{ + "recommendations": [ + "vitest.explorer", + "ms-playwright.playwright", + "firsttris.vscode-jest-runner" + ] +} +``` + +Configuração de tasks: + +```json +// .vscode/tasks.json +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Frontend Tests", + "type": "shell", + "command": "cd tests/MeAjudaAi.Web.Consumer.Tests && npm test", + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + }, + { + "label": "Run Frontend Tests with Coverage", + "type": "shell", + "command": "cd tests/MeAjudaAi.Web.Consumer.Tests && npm run test:coverage", + "group": "test" + }, + { + "label": "Run E2E Tests", + "type": "shell", + "command": "cd tests/MeAjudaAi.Web.Consumer.Tests && npm run test:e2e", + "group": "test" + } + ] +} +``` + +--- + +## ✅ Boas Práticas + +### 1. **Nomenclatura** +- Arquivos de teste: `ComponentName.test.tsx` +- Describes: `describe('ComponentName', ...)` +- Tests: `it('deve fazer algo específico', ...)` + +### 2. **Organização dos Testes** +```typescript +describe('ComponentName', () => { + // Testes de renderização + describe('Rendering', () => { + it('deve renderizar corretamente', () => {}); + }); + + // Testes de interação + describe('Interactions', () => { + it('deve chamar callback ao clicar', () => {}); + }); + + // Testes de estados + describe('States', () => { + it('deve mostrar loading', () => {}); + }); + + // Testes de acessibilidade + describe('Accessibility', () => { + it('deve ter roles corretos', () => {}); + }); +}); +``` + +### 3. **AAA Pattern (Arrange, Act, Assert)** +```typescript +it('deve incrementar contador', async () => { + // Arrange + const user = userEvent.setup(); + render(); + + // Act + await user.click(screen.getByRole('button')); + + // Assert + expect(screen.getByText('1')).toBeInTheDocument(); +}); +``` + +### 4. **Queries Prioritárias** +1. `getByRole` (preferencial) +2. `getByLabelText` +3. `getByPlaceholderText` +4. `getByText` +5. `getByTestId` (último recurso) + +### 5. **Evitar Detalhes de Implementação** +```typescript +// ❌ Ruim - testa implementação +expect(component.state.count).toBe(1); + +// ✅ Bom - testa comportamento +expect(screen.getByText('1')).toBeInTheDocument(); +``` + +### 6. **Testes Assíncronos** +```typescript +// Use waitFor para operações assíncronas +await waitFor(() => { + expect(screen.getByText('Dados carregados')).toBeInTheDocument(); +}); + +// Use findBy* quando esperar elemento aparecer +const element = await screen.findByText('Dados carregados'); +``` + +### 7. **Mocks Limpos** +```typescript +import { vi } from 'vitest'; + +// Mock de função +const mockFn = vi.fn(); + +// Mock de módulo +vi.mock('./api', () => ({ + fetchUsers: vi.fn(() => Promise.resolve([])) +})); +``` + +### 8. **Cobertura de Teste** +- **Componentes**: 80%+ de cobertura +- **Hooks**: 90%+ de cobertura +- **Utils**: 95%+ de cobertura +- **Integração**: Fluxos principais +- **E2E**: Jornadas críticas do usuário + +### 9. **Testes de Acessibilidade** +```typescript +import { axe, toHaveNoViolations } from 'jest-axe'; + +expect.extend(toHaveNoViolations); + +it('não deve ter violações de acessibilidade', async () => { + const { container } = render(); + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); +``` + +### 10. **Snapshot Testing (com moderação)** +```typescript +it('deve corresponder ao snapshot', () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); +}); +``` + +--- + +## 📊 Métricas de Qualidade + +### Cobertura Mínima Recomendada +- **Statements**: 80% +- **Branches**: 80% +- **Functions**: 80% +- **Lines**: 80% + +### Pirâmide de Testes +```text + /\ + /E2E\ 10% - Testes E2E (fluxos críticos) + /------\ + /Integration\ 20% - Testes de Integração + /------------\ + / Unit \ 70% - Testes Unitários + /----------------\ +``` + +--- + +## 🔧 Troubleshooting + +### Problema: Testes não encontram elementos +**Solução**: Use `screen.debug()` para ver a estrutura DOM + +```typescript +render(); +screen.debug(); // Mostra HTML renderizado +``` + +### Problema: Testes assíncronos falhando +**Solução**: Sempre use `await` com queries assíncronas + +```typescript +// ❌ Errado +const element = screen.findByText('Text'); + +// ✅ Correto +const element = await screen.findByText('Text'); +``` + +### Problema: Mock não está sendo aplicado +**Solução**: Verifique se o mock está antes do import do componente + +```typescript +// ✅ Correto +vi.mock('./api'); +import { Component } from './Component'; +``` + +--- + +## 📚 Recursos Adicionais + +- [Vitest Documentation](https://vitest.dev/) +- [Testing Library](https://testing-library.com/react) +- [Playwright](https://playwright.dev/) +- [MSW](https://mswjs.io/) +- [Kent C. Dodds - Testing Blog](https://kentcdodds.com/blog) + +--- + +**Última atualização**: Fevereiro 2026 +**Versão**: 2.0.0 (Adaptado para Monorepo .NET) + +--- + +## 📋 Checklist de Implementação + +### Fase 1: Setup Inicial ✅ + +- [ ] Criar pasta `tests/MeAjudaAi.Web.Consumer.Tests/` +- [ ] Criar `package.json` com dependências +- [ ] Instalar todas as bibliotecas necessárias +- [ ] Configurar `vitest.config.ts` +- [ ] Configurar `playwright.config.ts` +- [ ] Criar estrutura de pastas (`src/`, `e2e/`, etc.) +- [ ] Configurar `tsconfig.json` + +### Fase 2: Configuração de Testes ✅ + +- [ ] Criar `src/__tests__/setup.ts` +- [ ] Criar `src/__tests__/helpers/test-utils.tsx` +- [ ] Configurar MSW (`handlers.ts`, `server.ts`) +- [ ] Adicionar mocks de APIs necessárias +- [ ] Configurar aliases de imports + +### Fase 3: Primeiros Testes 🎯 + +- [ ] Escrever teste para componente Button +- [ ] Escrever teste para hook useLocalStorage +- [ ] Escrever teste para uma página simples +- [ ] Escrever teste para utility function +- [ ] Validar cobertura mínima (80%) + +### Fase 4: Testes E2E 🎭 + +- [ ] Configurar Playwright com app local +- [ ] Criar primeiro teste E2E de login +- [ ] Criar teste de navegação +- [ ] Criar teste de fluxo completo +- [ ] Configurar screenshots e vídeos + +### Fase 5: Integração CI/CD 🔄 + +- [ ] Adicionar scripts no `package.json` +- [ ] Configurar Azure DevOps pipeline ou GitHub Actions +- [ ] Configurar reports de cobertura +- [ ] Criar scripts helper no root do monorepo +- [ ] Testar pipeline completo + +### Fase 6: Documentação e Padrões 📚 + +- [ ] Documentar padrões de teste no README +- [ ] Criar templates de testes +- [ ] Configurar pre-commit hooks (Husky) +- [ ] Treinar equipe nos padrões +- [ ] Estabelecer code review guidelines + +--- + +## 🚀 Próximos Passos Recomendados + +### 1. Criar o Projeto de Testes + +```bash +# Na raiz do monorepo +mkdir -p tests/MeAjudaAi.Web.Consumer.Tests +cd tests/MeAjudaAi.Web.Consumer.Tests + +# Inicializar projeto Node +npm init -y + +# Instalar dependências conforme documentado +# (veja seção "Bibliotecas e Dependências") +``` + +### 2. Configurar Alias de Imports + +Adicionar ao `vitest.config.ts` para referenciar o código fonte: + +```typescript +resolve: { + alias: { + '@': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src'), + '@components': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/components'), + '@hooks': path.resolve(__dirname, '../../src/Web/MeAjudaAi.Web.Consumer/src/hooks'), + }, +}, +``` + +### 3. Criar README.md no Projeto de Testes + +```markdown +# MeAjudaAi.Web.Consumer.Tests + +Testes automatizados para o projeto React Consumer. + +## Stack de Testes + +- **Vitest**: Framework de testes +- **React Testing Library**: Testes de componentes +- **Playwright**: Testes E2E +- **MSW**: Mock de APIs + +## Executar Testes + +\`\`\`bash +# Testes unitários +npm test + +# Testes E2E +npm run test:e2e + +# Cobertura +npm run test:coverage +\`\`\` + +## Estrutura + +- `src/`: Testes unitários e de integração +- `e2e/`: Testes end-to-end +- `__tests__/`: Setup e helpers + +## Cobertura Mínima + +- Lines: 80% +- Functions: 80% +- Branches: 80% +- Statements: 80% +``` + +### 4. Adicionar ao .gitignore + +```gitignore +# tests/MeAjudaAi.Web.Consumer.Tests/.gitignore + +# Dependencies +node_modules/ + +# Coverage +coverage/ +.nyc_output/ + +# Test results +test-results/ +playwright-report/ +playwright/.cache/ + +# Build +dist/ +build/ + +# Environment +.env +.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db +``` + +### 5. Configurar Pre-commit Hooks (Opcional) + +```bash +# Instalar Husky +npm install --save-dev husky lint-staged + +# Configurar package.json +{ + "lint-staged": { + "*.{ts,tsx}": [ + "npm run test:run -- --related" + ] + } +} + +# Criar hook +npx husky install +npx husky add .husky/pre-commit "cd tests/MeAjudaAi.Web.Consumer.Tests && npx lint-staged" +``` + +--- + +## 💡 Dicas de Implementação + +### Comece Pequeno + +1. **Primeiro componente**: Escolha um componente simples (Button, Input) +2. **Primeiro hook**: Teste um hook utilitário (useLocalStorage) +3. **Primeira página**: Teste uma página sem muitas dependências +4. **Primeiro E2E**: Fluxo de login básico + +### Mantenha Consistência + +- Use o mesmo padrão de nomenclatura do backend (.NET) +- Siga as convenções de teste já estabelecidas +- Mantenha a mesma estrutura de pastas espelhada + +### Integre Gradualmente + +1. Configure CI/CD desde o início +2. Estabeleça métricas de cobertura progressivas +3. Documente decisões e padrões +4. Faça code review de testes também + +--- + +## 🤝 Alinhamento com Backend (.NET) + +### Similaridades Intencionais + +| Backend (.NET) | Frontend (React) | +|---|---| +| xUnit | Vitest | +| FluentAssertions | Jest-DOM matchers | +| Moq | MSW | +| Integration Tests | E2E Tests (Playwright) | +| Code Coverage (Coverlet) | Code Coverage (v8) | +| Projetos separados em `tests/` | Projeto separado em `tests/` | + +### Benefícios desta Abordagem + +✅ **Equipe unificada**: Mesma estrutura mental para backend e frontend +✅ **CI/CD consistente**: Pipelines similares, fácil manutenção +✅ **Onboarding simplificado**: Novos devs entendem rapidamente +✅ **Qualidade padronizada**: Mesmos critérios de cobertura e qualidade diff --git a/infrastructure/compose/environments/.env.example b/infrastructure/compose/environments/.env.example index 3d6619493..ee9128050 100644 --- a/infrastructure/compose/environments/.env.example +++ b/infrastructure/compose/environments/.env.example @@ -23,6 +23,15 @@ PGADMIN_DEFAULT_PASSWORD=your_secure_pgadmin_password_here RABBITMQ_USER=your_rabbitmq_user RABBITMQ_PASS=your_secure_rabbitmq_password_here +# Social Login Credentials +# Configure these in your Google/Meta Developer Console +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret +FACEBOOK_APP_ID=your_facebook_app_id +FACEBOOK_APP_SECRET=your_facebook_app_secret +INSTAGRAM_CLIENT_ID=your_instagram_client_id +INSTAGRAM_CLIENT_SECRET=your_instagram_client_secret + # Security Notes: # - Use different passwords for each service # - Passwords should be at least 16 characters with mixed characters diff --git a/infrastructure/compose/environments/development.yml b/infrastructure/compose/environments/development.yml index 59deb20c3..ac67f3619 100644 --- a/infrastructure/compose/environments/development.yml +++ b/infrastructure/compose/environments/development.yml @@ -47,21 +47,32 @@ services: image: quay.io/keycloak/keycloak:${KEYCLOAK_VERSION:-26.0.2} container_name: meajudaai-keycloak-dev environment: - KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} - KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Missing KEYCLOAK_ADMIN_PASSWORD environment variable} KC_DB: postgres - KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak - KC_DB_USERNAME: keycloak - KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?KEYCLOAK_DB_PASSWORD must be set} + KC_DB_URL: jdbc:postgresql://postgres:5432/meajudaai + KC_DB_USERNAME: postgres + KC_DB_PASSWORD: ${DB_PASSWORD:-postgres} + KC_DB_SCHEMA: identity + KC_HTTP_ENABLED: true + KC_HOSTNAME: localhost KC_HOSTNAME_STRICT: false KC_HOSTNAME_STRICT_HTTPS: false - KC_HTTP_ENABLED: true - command: ["start-dev", "--import-realm"] # Override base production command for development + KC_LOG_LEVEL: info + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin123 + # Identity Providers (Optional for local dev - fail fast if needed by using ${VAR:?error}) + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + FACEBOOK_APP_ID: ${FACEBOOK_APP_ID} + FACEBOOK_APP_SECRET: ${FACEBOOK_APP_SECRET} + INSTAGRAM_CLIENT_ID: ${INSTAGRAM_CLIENT_ID} + INSTAGRAM_CLIENT_SECRET: ${INSTAGRAM_CLIENT_SECRET} + command: ["start-dev", "--import-realm"] ports: - "8080:8080" + # - "8443:8443" # HTTPS disabled in dev volumes: - keycloak_data:/opt/keycloak/data - - ../../keycloak/realms:/opt/keycloak/data/import + - ./keycloak/imports:/opt/keycloak/data/import depends_on: - keycloak-db networks: diff --git a/infrastructure/compose/standalone/keycloak-only.yml b/infrastructure/compose/standalone/keycloak-only.yml index 0b7f3c7cb..3839b21e0 100644 --- a/infrastructure/compose/standalone/keycloak-only.yml +++ b/infrastructure/compose/standalone/keycloak-only.yml @@ -1,14 +1,13 @@ # Standalone Keycloak for development -# Minimal setup with embedded H2 database for quick testing +# Minimal setup with Postgres database for quick testing # -# REQUIRED: Set KEYCLOAK_ADMIN_PASSWORD before running -# OPTIONAL: Set KEYCLOAK_ADMIN (defaults to 'admin', consider using a custom username) +# REQUIRED: Set KEYCLOAK_ADMIN_PASSWORD and KEYCLOAK_ADMIN in environment or .env # OPTIONAL: Set KEYCLOAK_PORT (defaults to 8081 to avoid conflicts with development.yml) # # Usage: # export KEYCLOAK_ADMIN_PASSWORD=$(openssl rand -base64 32) -# export KEYCLOAK_ADMIN="meajudaai_admin" # Recommended: avoid 'admin' -# export KEYCLOAK_PORT=8081 # Avoid port conflicts +# export KEYCLOAK_ADMIN="meajudaai_admin" +# export KEYCLOAK_PORT=8081 # docker compose -f standalone/keycloak-only.yml up -d # # Or use .env file with secure credentials @@ -18,17 +17,35 @@ services: image: quay.io/keycloak/keycloak:${KEYCLOAK_VERSION:-26.0.2} container_name: meajudaai-keycloak-standalone environment: - KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} - KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Missing KEYCLOAK_ADMIN_PASSWORD environment variable} + KC_DB: postgres + # Aponta para o host porque o Postgres está rodando fora deste compose (via Aspire ou outro) + KC_DB_URL: jdbc:postgresql://host.docker.internal:5432/meajudaai + KC_DB_USERNAME: postgres + KC_DB_PASSWORD: ${DB_PASSWORD:-postgres} + KC_DB_SCHEMA: identity + KC_HTTP_ENABLED: true + KC_HOSTNAME: localhost KC_HOSTNAME_STRICT: false KC_HOSTNAME_STRICT_HTTPS: false - KC_HTTP_ENABLED: true + KC_LOG_LEVEL: info + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin123} + # Identity Providers (Optional for standalone) + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + FACEBOOK_APP_ID: ${FACEBOOK_APP_ID:-} + FACEBOOK_APP_SECRET: ${FACEBOOK_APP_SECRET:-} + INSTAGRAM_CLIENT_ID: ${INSTAGRAM_CLIENT_ID:-} + INSTAGRAM_CLIENT_SECRET: ${INSTAGRAM_CLIENT_SECRET:-} command: ["start-dev", "--import-realm"] ports: - "${KEYCLOAK_PORT:-8081}:8080" + # - "8443:8443" # HTTPS disabled volumes: - keycloak_standalone_data:/opt/keycloak/data - - ../../keycloak/realms:/opt/keycloak/data/import + - ../environments/keycloak/imports:/opt/keycloak/data/import + extra_hosts: + - "host.docker.internal:host-gateway" restart: unless-stopped healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health/ready || exit 1"] diff --git a/infrastructure/database/01-init-meajudaai.sh b/infrastructure/database/01-init-meajudaai.sh index 7909e5243..d9cb380ea 100644 --- a/infrastructure/database/01-init-meajudaai.sh +++ b/infrastructure/database/01-init-meajudaai.sh @@ -31,12 +31,6 @@ if [ -d "${MODULES_DIR}" ]; then done fi -# Execute cross-module views -echo "🔗 Setting up cross-module views..." -if [ -f "/docker-entrypoint-initdb.d/views/cross-module-views.sql" ]; then - execute_sql "/docker-entrypoint-initdb.d/views/cross-module-views.sql" -fi - # Execute data seeds (essential domain data) echo "🌱 Seeding essential domain data..." SEEDS_DIR="/docker-entrypoint-initdb.d/seeds" diff --git a/infrastructure/database/modules/providers/01-permissions.sql b/infrastructure/database/modules/providers/01-permissions.sql index 5409d1cf6..6b44d886f 100644 --- a/infrastructure/database/modules/providers/01-permissions.sql +++ b/infrastructure/database/modules/providers/01-permissions.sql @@ -20,10 +20,6 @@ BEGIN GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA providers TO meajudaai_app_role; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA providers TO meajudaai_app_role; - -- Grant read-only access to existing tables for catalogs_role (cross-module read access) - GRANT SELECT ON ALL TABLES IN SCHEMA providers TO catalogs_role; - GRANT SELECT ON ALL SEQUENCES IN SCHEMA providers TO catalogs_role; - -- Set default privileges for future tables and sequences created by providers_owner ALTER DEFAULT PRIVILEGES FOR ROLE providers_owner IN SCHEMA providers GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO providers_role; ALTER DEFAULT PRIVILEGES FOR ROLE providers_owner IN SCHEMA providers GRANT USAGE, SELECT ON SEQUENCES TO providers_role; @@ -32,10 +28,6 @@ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE providers_owner IN SCHEMA providers GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO meajudaai_app_role; ALTER DEFAULT PRIVILEGES FOR ROLE providers_owner IN SCHEMA providers GRANT USAGE, SELECT ON SEQUENCES TO meajudaai_app_role; - -- Set default privileges for catalogs_role on future tables (cross-module read access) - ALTER DEFAULT PRIVILEGES FOR ROLE providers_owner IN SCHEMA providers GRANT SELECT ON TABLES TO catalogs_role; - ALTER DEFAULT PRIVILEGES FOR ROLE providers_owner IN SCHEMA providers GRANT SELECT ON SEQUENCES TO catalogs_role; - -- Set default search path ALTER ROLE providers_role SET search_path = providers, public; END IF; diff --git a/infrastructure/database/modules/search_providers/01-permissions.sql b/infrastructure/database/modules/search_providers/01-permissions.sql index 2bce9e713..ba34d318d 100644 --- a/infrastructure/database/modules/search_providers/01-permissions.sql +++ b/infrastructure/database/modules/search_providers/01-permissions.sql @@ -28,10 +28,6 @@ BEGIN GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA search_providers TO meajudaai_app_role; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA search_providers TO meajudaai_app_role; - -- Grant read-only access to existing tables for catalogs_role (cross-module read access) - GRANT SELECT ON ALL TABLES IN SCHEMA search_providers TO catalogs_role; - GRANT SELECT ON ALL SEQUENCES IN SCHEMA search_providers TO catalogs_role; - -- Set default privileges for future tables and sequences created by search_owner ALTER DEFAULT PRIVILEGES FOR ROLE search_owner IN SCHEMA search_providers GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO search_role; ALTER DEFAULT PRIVILEGES FOR ROLE search_owner IN SCHEMA search_providers GRANT USAGE, SELECT ON SEQUENCES TO search_role; @@ -40,10 +36,6 @@ BEGIN ALTER DEFAULT PRIVILEGES FOR ROLE search_owner IN SCHEMA search_providers GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO meajudaai_app_role; ALTER DEFAULT PRIVILEGES FOR ROLE search_owner IN SCHEMA search_providers GRANT USAGE, SELECT ON SEQUENCES TO meajudaai_app_role; - -- Set default privileges for catalogs_role on future tables (cross-module read access) - ALTER DEFAULT PRIVILEGES FOR ROLE search_owner IN SCHEMA search_providers GRANT SELECT ON TABLES TO catalogs_role; - ALTER DEFAULT PRIVILEGES FOR ROLE search_owner IN SCHEMA search_providers GRANT SELECT ON SEQUENCES TO catalogs_role; - -- Set default search path for search_role ALTER ROLE search_role SET search_path = search_providers, public; END IF; @@ -61,9 +53,32 @@ BEGIN END; $$ LANGUAGE plpgsql; --- PostGIS spatial reference system access -GRANT SELECT ON TABLE public.spatial_ref_sys TO search_role; -GRANT SELECT ON TABLE public.spatial_ref_sys TO meajudaai_app_role; +-- PostGIS spatial reference system access (dynamically detect containing schema) +DO $$ +DECLARE + rec RECORD; +BEGIN + FOR rec IN + SELECT n.nspname AS schema_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid + JOIN pg_catalog.pg_depend d ON c.oid = d.objid + JOIN pg_catalog.pg_extension e ON d.refobjid = e.oid + WHERE c.relname = 'spatial_ref_sys' + AND c.relkind = 'r' + AND d.deptype = 'e' + LOOP + EXECUTE format('GRANT SELECT ON %I.spatial_ref_sys TO search_role', rec.schema_name); + EXECUTE format('GRANT SELECT ON %I.spatial_ref_sys TO meajudaai_app_role', rec.schema_name); + END LOOP; +END; +$$ LANGUAGE plpgsql; --- Search Providers schema purpose -COMMENT ON SCHEMA search_providers IS 'Search & Discovery module - Geospatial provider search with PostGIS'; +-- Search Providers schema purpose (only if schema exists) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'search_providers') THEN + COMMENT ON SCHEMA search_providers IS 'Search & Discovery module - Geospatial provider search with PostGIS'; + END IF; +END; +$$ LANGUAGE plpgsql; diff --git a/infrastructure/database/modules/users/01-permissions.sql b/infrastructure/database/modules/users/01-permissions.sql index 1d1583150..d1d9b3018 100644 --- a/infrastructure/database/modules/users/01-permissions.sql +++ b/infrastructure/database/modules/users/01-permissions.sql @@ -35,22 +35,7 @@ BEGIN END; $$ LANGUAGE plpgsql; --- Create dedicated application schema for cross-cutting objects -CREATE SCHEMA IF NOT EXISTS meajudaai_app; - --- Set explicit schema ownership -ALTER SCHEMA meajudaai_app OWNER TO meajudaai_app_owner; - --- Grant permissions on dedicated application schema -GRANT USAGE, CREATE ON SCHEMA meajudaai_app TO meajudaai_app_role; - --- NOTE: search_path for meajudaai_app_role is set in documents/01-permissions.sql --- to avoid conflicts. Documents module runs last alphabetically and sets the --- complete path: meajudaai_app, documents, users, providers, hangfire, public - --- Grant limited permissions on public schema (read-only) -GRANT USAGE ON SCHEMA public TO users_role; -GRANT USAGE ON SCHEMA public TO meajudaai_app_role; - --- Harden public schema by revoking CREATE from PUBLIC (security best practice) -REVOKE CREATE ON SCHEMA public FROM PUBLIC; \ No newline at end of file +-- NOTE: The meajudaai_app schema, its ownership, grants, search_path, and public +-- schema hardening are all managed by documents/01-permissions.sql, which +-- precedes users/01-permissions.sql alphabetically (d < u) and is the +-- authoritative location for these cross-cutting settings. \ No newline at end of file diff --git a/infrastructure/database/views/cross-module-views.sql b/infrastructure/database/views/cross-module-views.sql deleted file mode 100644 index 050681f59..000000000 --- a/infrastructure/database/views/cross-module-views.sql +++ /dev/null @@ -1,69 +0,0 @@ --- Cross-Module Database Views --- These views allow controlled access across module boundaries - --- ============================ --- User Summary View --- ============================ --- User Summary View (for other modules that need user information) --- CREATE VIEW public.user_summary AS --- SELECT --- id, --- username, --- email, --- created_at --- FROM users.users --- WHERE is_active = true; - --- Grant read access to other modules when implemented --- GRANT SELECT ON public.user_summary TO other_module_role; - --- ============================ --- Documents Module Views --- ============================ - --- Document Status Summary (for providers module to check verification status) -CREATE OR REPLACE VIEW meajudaai_app.document_status_summary AS -SELECT - d.id, - d.provider_id, - d.document_type, - d.status, - d.uploaded_at, - d.verified_at, - CASE - WHEN d.status = 3 THEN true -- EDocumentStatus.Verified - ELSE false - END AS is_verified, - CASE - WHEN d.status = 4 THEN true -- EDocumentStatus.Rejected - ELSE false - END AS is_rejected, - CASE - WHEN d.status IN (1, 2) THEN true -- EDocumentStatus.Uploaded, PendingVerification - ELSE false - END AS is_pending -FROM documents.documents d; - --- Grant read access to providers and app roles -GRANT SELECT ON meajudaai_app.document_status_summary TO providers_role; -GRANT SELECT ON meajudaai_app.document_status_summary TO meajudaai_app_role; - --- Provider Documents Summary (aggregate count by provider and status) -CREATE OR REPLACE VIEW meajudaai_app.provider_documents_summary AS -SELECT - d.provider_id, - COUNT(*) AS total_documents, - COUNT(*) FILTER (WHERE d.status = 3) AS verified_count, - COUNT(*) FILTER (WHERE d.status = 4) AS rejected_count, - COUNT(*) FILTER (WHERE d.status IN (1, 2)) AS pending_count, - MAX(d.uploaded_at) AS last_upload_date, - MAX(d.verified_at) AS last_verification_date -FROM documents.documents d -GROUP BY d.provider_id; - --- Grant read access to providers and app roles -GRANT SELECT ON meajudaai_app.provider_documents_summary TO providers_role; -GRANT SELECT ON meajudaai_app.provider_documents_summary TO meajudaai_app_role; - -COMMENT ON VIEW meajudaai_app.document_status_summary IS 'Cross-module view for document verification status (used by providers module)'; -COMMENT ON VIEW meajudaai_app.provider_documents_summary IS 'Aggregated document statistics per provider'; \ No newline at end of file diff --git a/infrastructure/keycloak/realms/meajudaai-realm.dev.json b/infrastructure/keycloak/realms/meajudaai-realm.dev.json index 5d36b309f..e9b834d84 100644 --- a/infrastructure/keycloak/realms/meajudaai-realm.dev.json +++ b/infrastructure/keycloak/realms/meajudaai-realm.dev.json @@ -93,6 +93,38 @@ } ] }, + "identityProviders": [ + { + "alias": "google", + "displayName": "Google", + "providerId": "google", + "enabled": true, + "trustEmail": true, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "firstBrokerLoginFlowAlias": "first broker login", + "config": { + "clientId": "${GOOGLE_CLIENT_ID}", + "clientSecret": "${GOOGLE_CLIENT_SECRET}" + } + }, + { + "alias": "facebook", + "displayName": "Facebook", + "providerId": "facebook", + "enabled": true, + "trustEmail": true, + "storeToken": false, + "addReadTokenRoleOnCreate": false, + "authenticateByDefault": false, + "firstBrokerLoginFlowAlias": "first broker login", + "config": { + "clientId": "${FACEBOOK_APP_ID}", + "clientSecret": "${FACEBOOK_APP_SECRET}" + } + } + ], "clients": [ { "clientId": "admin-portal", @@ -147,7 +179,7 @@ "publicClient": true, "standardFlowEnabled": true, "implicitFlowEnabled": false, - "directAccessGrantsEnabled": false, + "directAccessGrantsEnabled": true, "serviceAccountsEnabled": false, "protocol": "openid-connect", "redirectUris": [ diff --git a/infrastructure/keycloak/themes/meajudaai/login/resources/css/login.css b/infrastructure/keycloak/themes/meajudaai/login/resources/css/login.css index 49d45b6bf..0b784a3e6 100644 --- a/infrastructure/keycloak/themes/meajudaai/login/resources/css/login.css +++ b/infrastructure/keycloak/themes/meajudaai/login/resources/css/login.css @@ -7,17 +7,18 @@ @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&display=swap'); :root { - --primary-blue: #3D5A80; - --primary-blue-dark: #2E4057; - --secondary-orange: #E07A5F; - --secondary-orange-dark: #C66850; - --background-cream: #F4F1DE; - --text-dark: #3D5A80; + --primary-blue: #395873; + --primary-blue-dark: #2E4760; + --secondary-orange: #E0702B; + --secondary-orange-dark: #c56226; + --background-cream: #FFFFFF; + --text-dark: #2e2e2e; --text-light: #FFFFFF; - --border-color: #CED4DA; - --error-red: #DC2626; + --border-color: #e0e0e0; + --error-red: #dc2626; --success-green: #10B981; - --border-radius: 8px; + --border-radius: 12px; + --ma-login-hover-bg: rgba(57, 88, 115, 0.05); } * { @@ -60,7 +61,7 @@ html { font-size: 0; text-indent: -9999px; overflow: hidden; - + /* Mostrar logo - responsivo */ display: block; max-width: 100%; @@ -102,6 +103,7 @@ html { opacity: 0; transform: translateY(-20px); } + to { opacity: 1; transform: translateY(0); @@ -159,7 +161,7 @@ input:focus { } /* ============================================ - BOTÃO PRINCIPAL - LARANJA + BOTÃO PRINCIPAL E SECUNDÁRIO ============================================ */ .pf-c-button.pf-m-primary, #kc-login, @@ -184,7 +186,7 @@ button[type="submit"] { button[type="submit"]:hover { background: linear-gradient(135deg, var(--secondary-orange-dark) 0%, var(--secondary-orange) 100%); transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); + box-shadow: 0 4px 12px rgba(224, 112, 43, 0.3); } .pf-c-button.pf-m-primary:active, @@ -192,6 +194,27 @@ button[type="submit"]:hover { transform: translateY(0); } +/* Override para o botão secundário de Revisar Perfil na tela de conflito de conta */ +#updateProfile { + background: transparent !important; + border: 2px solid var(--primary-blue) !important; + color: var(--primary-blue) !important; + box-shadow: none !important; +} + +#updateProfile:hover { + background: var(--ma-login-hover-bg, rgba(57, 88, 115, 0.05)) !important; + border-color: var(--primary-blue-dark) !important; + color: var(--primary-blue-dark) !important; +} + +#updateProfile:focus-visible { + outline: 2px solid var(--primary-blue-dark) !important; + outline-offset: 2px; + background: var(--ma-login-hover-bg, rgba(57, 88, 115, 0.05)) !important; + box-shadow: 0 0 0 4px rgba(46, 71, 96, 0.1) !important; +} + /* ============================================ LINKS ============================================ */ @@ -329,17 +352,18 @@ a:hover, RESPONSIVIDADE ============================================ */ @media (max-width: 768px) { + #kc-content-wrapper, .login-pf-page .card-pf { padding: 32px 24px; margin: 20px; max-width: calc(100% - 40px); } - + #kc-header-wrapper { font-size: 24px; } - + #kc-form-login h1, #kc-page-title { font-size: 20px; @@ -347,6 +371,7 @@ a:hover, } @media (max-width: 480px) { + #kc-content-wrapper, .login-pf-page .card-pf { padding: 24px 20px; @@ -395,4 +420,4 @@ a:hover, #kc-locale-dropdown { display: none !important; -} +} \ No newline at end of file diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 index 41fe9ec72..4fd0125ce 100644 --- a/scripts/dev.ps1 +++ b/scripts/dev.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Inicia o ambiente de desenvolvimento do MeAjudaAi .DESCRIPTION @@ -15,6 +15,51 @@ $env:DOTNET_ENVIRONMENT = "Development" $env:POSTGRES_PASSWORD = "postgres" $env:DB_PASSWORD = $env:POSTGRES_PASSWORD # Program.cs reads DB_PASSWORD +# Add social login variables from .env if present +$baseDir = $PSScriptRoot +if ([string]::IsNullOrEmpty($baseDir)) { + $baseDir = $PWD +} +$envFilePath = Join-Path $baseDir "..\infrastructure\compose\environments\.env" +$envFilePath = [System.IO.Path]::GetFullPath($envFilePath) + +Write-Host "🔍 Procurando .env em: $envFilePath" -ForegroundColor Gray + +if (Test-Path $envFilePath) { + Write-Host "🔧 Carregando variáveis de ambiente do .env..." -ForegroundColor Cyan + Get-Content $envFilePath | Where-Object { $_ -match '^\s*[\w-]+\s*=' } | ForEach-Object { + $parts = $_.Split('=', 2) + $name = $parts[0].Trim() + $value = $parts[1].Trim() + + # Strip inline comments + if ($value -match '#') { + # Find first # not inside quotes + $inSingle = $false + $inDouble = $false + for ($i = 0; $i -lt $value.Length; $i++) { + $char = $value[$i] + if ($char -eq "'" -and -not $inDouble) { $inSingle = -not $inSingle } + elseif ($char -eq '"' -and -not $inSingle) { $inDouble = -not $inDouble } + elseif ($char -eq '#' -and -not $inSingle -and -not $inDouble) { + $value = $value.Substring(0, $i).Trim() + break + } + } + } + + $cleanValue = $value + if (($cleanValue.StartsWith('"') -and $cleanValue.EndsWith('"')) -or ($cleanValue.StartsWith("'") -and $cleanValue.EndsWith("'"))) { + if ($cleanValue.Length -ge 2) { + $cleanValue = $cleanValue.Substring(1, $cleanValue.Length - 2) + } + } + Set-Item -Path "env:$name" -Value $cleanValue + } +} else { + Write-Host "⚠️ Arquivo .env não encontrado em $envFilePath. Lógicas que dependem dele podem falhar." -ForegroundColor Yellow +} + Write-Host "🚀 Iniciando MeAjudaAi - Ambiente de Desenvolvimento" -ForegroundColor Cyan Write-Host "=================================================" -ForegroundColor Cyan Write-Host "" diff --git a/src/Aspire/MeAjudaAi.AppHost/Extensions/KeycloakExtensions.cs b/src/Aspire/MeAjudaAi.AppHost/Extensions/KeycloakExtensions.cs index 82c54535a..58b63d647 100644 --- a/src/Aspire/MeAjudaAi.AppHost/Extensions/KeycloakExtensions.cs +++ b/src/Aspire/MeAjudaAi.AppHost/Extensions/KeycloakExtensions.cs @@ -55,8 +55,12 @@ public static MeAjudaAiKeycloakResult AddMeAjudaAiKeycloak( .WithEnvironment("KC_DB_PASSWORD", options.DatabasePassword) .WithEnvironment("KC_DB_SCHEMA", options.DatabaseSchema) // Credenciais do admin + // NOTA: Keycloak 26+ usa KC_BOOTSTRAP_ADMIN_* em vez dos legados KEYCLOAK_ADMIN_* + // Aspire.Hosting.Keycloak auto-gera KC_BOOTSTRAP_ADMIN_PASSWORD, precisamos sobrescrever .WithEnvironment("KEYCLOAK_ADMIN", options.AdminUsername) .WithEnvironment("KEYCLOAK_ADMIN_PASSWORD", options.AdminPassword) + .WithEnvironment("KC_BOOTSTRAP_ADMIN_USERNAME", options.AdminUsername) + .WithEnvironment("KC_BOOTSTRAP_ADMIN_PASSWORD", options.AdminPassword) // Configurações de desenvolvimento .WithEnvironment("KC_HOSTNAME_STRICT", "false") .WithEnvironment("KC_HOSTNAME_STRICT_HTTPS", "false") @@ -174,6 +178,8 @@ public static MeAjudaAiKeycloakResult AddMeAjudaAiKeycloakProduction( // Credenciais do admin usando parâmetros secretos .WithEnvironment("KEYCLOAK_ADMIN", options.AdminUsername) .WithEnvironment("KEYCLOAK_ADMIN_PASSWORD", keycloakAdminPassword) + .WithEnvironment("KC_BOOTSTRAP_ADMIN_USERNAME", options.AdminUsername) + .WithEnvironment("KC_BOOTSTRAP_ADMIN_PASSWORD", keycloakAdminPassword) // Configurações de produção .WithEnvironment("KC_HOSTNAME_STRICT", "true") .WithEnvironment("KC_HOSTNAME_STRICT_HTTPS", "true") diff --git a/src/Aspire/MeAjudaAi.AppHost/Program.cs b/src/Aspire/MeAjudaAi.AppHost/Program.cs index 4019ea86b..de3c6a77f 100644 --- a/src/Aspire/MeAjudaAi.AppHost/Program.cs +++ b/src/Aspire/MeAjudaAi.AppHost/Program.cs @@ -136,6 +136,26 @@ private static void ConfigureDevelopmentEnvironment(IDistributedApplicationBuild // Importar realm de desenvolvimento automaticamente options.ImportRealm = "/opt/keycloak/data/import/meajudaai-realm.dev.json"; }); + + void AddSocialProviderEnv(string providerName, string clientIdKey, string clientSecretKey) + { + var clientId = builder.Configuration[clientIdKey]; + var clientSecret = builder.Configuration[clientSecretKey]; + if (!string.IsNullOrWhiteSpace(clientId) && !string.IsNullOrWhiteSpace(clientSecret)) + { + keycloak.Keycloak + .WithEnvironment(clientIdKey, clientId) + .WithEnvironment(clientSecretKey, clientSecret); + } + else + { + Console.WriteLine($"⚠️ WARNING: {providerName} OAuth credentials are missing. {providerName} Login may fail."); + } + } + + AddSocialProviderEnv("Google", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"); + AddSocialProviderEnv("Facebook", "FACEBOOK_APP_ID", "FACEBOOK_APP_SECRET"); + // Garantir que Keycloak aguarde o Postgres estar pronto keycloak.Keycloak.WaitFor(postgresql.MainDatabase); diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Endpoints/ProviderRegistrationEndpoints.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Endpoints/ProviderRegistrationEndpoints.cs new file mode 100644 index 000000000..a1259073f --- /dev/null +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Endpoints/ProviderRegistrationEndpoints.cs @@ -0,0 +1,142 @@ +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Application.DTOs; +using MeAjudaAi.Modules.Providers.Application.DTOs.Requests; +using MeAjudaAi.Modules.Users.Application.Commands; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Utilities; +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Shared.Utilities.Constants; + +namespace MeAjudaAi.ApiService.Endpoints; + +/// +/// Endpoints públicos de registro de prestadores de serviços. +/// Orquestra a criação de usuário (módulo Users) + entidade Provider (módulo Providers). +/// Fica no ApiService pois é o único projeto que referencia ambos os módulos. +/// +public static class ProviderRegistrationEndpoints +{ + public static IEndpointRouteBuilder MapProviderRegistrationEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/v1/providers") + .WithTags("Providers - Public"); + + group.MapPost("/register", RegisterProviderAsync) + .WithName("RegisterProvider") + .WithSummary("Auto-registro de prestador de serviços") + .WithDescription( + "Inicia o cadastro de um prestador. Cria usuário no Keycloak com role 'provider-standard' " + + "e a entidade Provider com Tier=Standard. Endpoint público, sem autenticação.") + .Produces>(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .AllowAnonymous() + .RequireRateLimiting(RateLimitPolicies.ProviderRegistration); + + return endpoints; + } + + private static async Task RegisterProviderAsync( + [FromBody] RegisterProviderRequest request, + ICommandDispatcher commandDispatcher, + ILoggerFactory loggerFactory, + CancellationToken cancellationToken) + { + var logger = loggerFactory.CreateLogger(typeof(ProviderRegistrationEndpoints).FullName!); + + if (!request.AcceptedTerms || !request.AcceptedPrivacyPolicy) + return Results.BadRequest("Você deve aceitar os Termos de Uso e a Política de Privacidade para se cadastrar."); + + // Passo 1: Criar usuário no Keycloak com role provider-standard (módulo Users) + // Sanitiza telefone mantendo apenas números + var phone = System.Text.RegularExpressions.Regex.Replace(request.PhoneNumber, @"\D", ""); + var username = string.IsNullOrEmpty(phone) ? $"provider_{Guid.NewGuid():N}" : $"provider_{phone}"; + + // Usa o Primeiro Nome como fallback para o Sobrenome para satisfazer a validação do Keycloak + var nameParts = request.Name.Trim().Split(' ', 2); + var firstName = nameParts[0]; + var lastName = nameParts.Length > 1 ? nameParts[1] : firstName; // Fallback para firstname se não houver lastname + + var createUserCommand = new CreateUserCommand( + Username: username, + Email: request.Email, + FirstName: firstName, + LastName: lastName, + Password: GenerateTemporaryPassword(), // Senha temporária forte gerada dinamicamente + Roles: [EProviderTier.Standard.ToRoleString()], + PhoneNumber: request.PhoneNumber + ); + + var userResult = await commandDispatcher.SendAsync>( + createUserCommand, cancellationToken); + + if (userResult.IsFailure) + { + // Logar erro detalhado internamente + logger.LogError("Failed to create Keycloak user for provider registration. Error: {Error}", userResult.Error.Message); + return Results.BadRequest("Ocorreu um erro ao registrar o usuário."); + } + + // Passo 2: Criar entidade Provider vinculada ao usuário (módulo Providers) + var createProviderCommand = new CreateProviderCommand( + UserId: userResult.Value!.Id, + Name: request.Name, + Type: request.Type, + BusinessProfile: new BusinessProfileDto( + LegalName: request.Name, + FantasyName: null, + Description: null, + ContactInfo: new ContactInfoDto( + Email: request.Email, + PhoneNumber: request.PhoneNumber, + Website: null), + PrimaryAddress: new AddressDto( + Street: string.Empty, + Number: string.Empty, + Complement: null, + Neighborhood: string.Empty, + City: string.Empty, + State: string.Empty, + ZipCode: string.Empty, + Country: "BR") + ) + ); + + var providerResult = await commandDispatcher.SendAsync>( + createProviderCommand, cancellationToken); + + if (providerResult.IsFailure) + { + // Compensação: Tentar remover o usuário criado para evitar orfãos + try + { + var deleteUserCommand = new DeleteUserCommand(userResult.Value!.Id); + var deleteResult = await commandDispatcher.SendAsync(deleteUserCommand, cancellationToken); + + if (deleteResult.IsFailure) + { + logger.LogError("Compensation failed: Could not delete orphaned user {UserId}. Error: {Error}", userResult.Value!.Id, deleteResult.Error.Message); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Compensation failed: Could not delete orphaned user {UserId} after provider creation failed.", userResult.Value!.Id); + } + + return Results.BadRequest("Ocorreu um erro ao registrar o provedor."); + } + + return Results.Created( + $"/api/v1/providers/{providerResult.Value!.Id}", + new Response(providerResult.Value)); + } + + private static string GenerateTemporaryPassword() + { + // Gera uma senha forte aleatória que satisfaz requisitos do Keycloak (Maiúscula, Minúscula, Número, Especial) + return $"Temp{Guid.NewGuid().ToString("N")[..8]}!123"; + } +} diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs index 9cb2702e1..b3b48ef68 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs @@ -288,6 +288,7 @@ public static IServiceCollection AddKeycloakAuthentication( services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { + options.MapInboundClaims = false; options.Authority = keycloakOptions.AuthorityUrl; options.Audience = keycloakOptions.ClientId; options.RequireHttpsMetadata = keycloakOptions.RequireHttpsMetadata; @@ -451,6 +452,30 @@ public static IServiceCollection AddCustomRateLimiting(this IServiceCollection s opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; }); + // Política para registro de clientes (restritiva para evitar spam de contas) + options.AddPolicy(RateLimitPolicies.Registration, context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? context.Connection.Id, + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 2, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + })); + + // Política específica para registro de prestadores (mais restritiva para evitar spam) + options.AddPolicy(RateLimitPolicies.ProviderRegistration, context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? context.Connection.Id, + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 2, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + })); + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; }); diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Handlers/SelfOrAdminHandler.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Handlers/SelfOrAdminHandler.cs index 9ebde7600..6cf707aff 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Handlers/SelfOrAdminHandler.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Handlers/SelfOrAdminHandler.cs @@ -17,13 +17,18 @@ protected override Task HandleRequirementAsync( return Task.CompletedTask; } - var userIdClaim = context.User.FindFirst("sub")?.Value; - var roles = context.User.FindAll("roles").Select(c => c.Value); + var userIdClaim = context.User.FindFirst(MeAjudaAi.Shared.Utilities.Constants.AuthConstants.Claims.Subject)?.Value + ?? context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + + var roles = context.User.FindAll(MeAjudaAi.Shared.Utilities.Constants.AuthConstants.Claims.Roles) + .Concat(context.User.FindAll(System.Security.Claims.ClaimTypes.Role)) + .Select(c => c.Value) + .Distinct(); // Verifica se o usuário é admin if (roles.Any(r => - string.Equals(r, "admin", StringComparison.OrdinalIgnoreCase) || - string.Equals(r, "super-admin", StringComparison.OrdinalIgnoreCase))) + string.Equals(r, MeAjudaAi.Shared.Utilities.UserRoles.Admin, StringComparison.OrdinalIgnoreCase) || + string.Equals(r, MeAjudaAi.Shared.Utilities.UserRoles.SuperAdmin, StringComparison.OrdinalIgnoreCase))) { context.Succeed(requirement); return Task.CompletedTask; @@ -38,7 +43,7 @@ protected override Task HandleRequirementAsync( // Só permite acesso se ambos os IDs estão presentes e são iguais if (!string.IsNullOrWhiteSpace(userIdClaim) && !string.IsNullOrWhiteSpace(routeUserId) && - string.Equals(userIdClaim, routeUserId, StringComparison.Ordinal)) + string.Equals(userIdClaim, routeUserId, StringComparison.OrdinalIgnoreCase)) { context.Succeed(requirement); return Task.CompletedTask; diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs index 02fcad357..02e86332f 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs @@ -26,35 +26,45 @@ public class SecurityHeadersMiddleware(RequestDelegate next, IWebHostEnvironment "frame-ancestors 'none';") ]; + private const string HstsHeaderName = "Strict-Transport-Security"; private const string HstsHeader = "max-age=31536000; includeSubDomains"; // Cabeçalhos para remover - usando array para iteração mais rápida private static readonly string[] HeadersToRemove = ["Server", "X-Powered-By", "X-AspNet-Version"]; - public async Task InvokeAsync(HttpContext context) + public Task InvokeAsync(HttpContext context) { ArgumentNullException.ThrowIfNull(context); - var headers = context.Response.Headers; - - // Adiciona cabeçalhos de segurança estáticos eficientemente - foreach (var header in StaticHeaders) + context.Response.OnStarting(state => { - headers.Append(header.Key, header.Value); - } + var ctx = (HttpContext)state; + var headers = ctx.Response.Headers; - // HSTS apenas em produção e HTTPS - usando verificação de ambiente em cache - if (context.Request.IsHttps && !_isDevelopment) - { - headers.Append("Strict-Transport-Security", HstsHeader); - } + // Adiciona cabeçalhos de segurança estáticos eficientemente + foreach (var header in StaticHeaders) + { + if (!headers.ContainsKey(header.Key)) + { + headers.Append(header.Key, header.Value); + } + } - // Remove cabeçalhos de exposição de informações eficientemente - foreach (var headerName in HeadersToRemove) - { - headers.Remove(headerName); - } + // HSTS apenas em produção e HTTPS - usando verificação de ambiente em cache + if (ctx.Request.IsHttps && !_isDevelopment && !headers.ContainsKey(HstsHeaderName)) + { + headers.Append(HstsHeaderName, HstsHeader); + } + + // Remove cabeçalhos de exposição de informações eficientemente + foreach (var headerName in HeadersToRemove) + { + headers.Remove(headerName); + } + + return Task.CompletedTask; + }, context); - await _next(context); + return _next(context); } } diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/MigrationExtensions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/MigrationExtensions.cs index d22bf031f..5627af7a3 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/MigrationExtensions.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/MigrationExtensions.cs @@ -19,6 +19,23 @@ public static async Task ApplyModuleMigrationsAsync(this IHost app, Cancellation var dbContextTypes = DiscoverDbContextTypes(logger); + // Garantir que ServiceCatalogs rode antes de Providers (dependência SQL entre módulos nas migrations) + var modulePriority = new Dictionary + { + { "Users", 1 }, + { "ServiceCatalogs", 2 }, + { "Locations", 3 }, + { "Documents", 4 }, + { "Providers", 5 }, + { "SearchProviders", 6 } + }; + + dbContextTypes = dbContextTypes.OrderBy(t => + { + var moduleName = ExtractModuleName(t); + return modulePriority.TryGetValue(moduleName, out var p) ? p : 99; + }).ThenBy(t => t.FullName).ToList(); + if (dbContextTypes.Count == 0) { logger.LogWarning("⚠️ No DbContext found for migration"); @@ -151,7 +168,7 @@ private static async Task ApplyPendingMigrationsAsync( logger.LogInformation("📦 {Module}: {Count} pending migrations", moduleName, pendingMigrations.Count); foreach (var migration in pendingMigrations) { - logger.LogDebug(" - {Migration}", migration); + logger.LogInformation(" - Applying {Migration}", migration); } await dbContext.Database.MigrateAsync(cancellationToken); diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs index 62bfb5c3d..f5b59c388 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using MeAjudaAi.ApiService.Endpoints; using MeAjudaAi.ApiService.Extensions; using MeAjudaAi.Modules.Documents.API; using MeAjudaAi.Modules.Locations.API; @@ -25,6 +26,8 @@ public static async Task Main(string[] args) { var builder = WebApplication.CreateBuilder(args); + builder.WebHost.ConfigureKestrel(opts => opts.AddServerHeader = false); + ConfigureLogging(builder); // Configurações via ServiceDefaults e Shared (sem duplicar Serilog) @@ -122,6 +125,9 @@ private static async Task ConfigureMiddlewareAsync(WebApplication app) app.UseSearchProvidersModule(); app.UseLocationsModule(); app.UseServiceCatalogsModule(); + + // Endpoints de orquestração cross-módulo (ficam no ApiService) + app.MapProviderRegistrationEndpoints(); } private static void LogStartupComplete(WebApplication app) diff --git a/src/Contracts/Identity/Enums/EAuthProvider.cs b/src/Contracts/Identity/Enums/EAuthProvider.cs new file mode 100644 index 000000000..7e5622620 --- /dev/null +++ b/src/Contracts/Identity/Enums/EAuthProvider.cs @@ -0,0 +1,28 @@ +namespace MeAjudaAi.Contracts.Identity.Enums; + +/// +/// Especifica os diferentes provedores de identidade disponíveis via Keycloak. +/// Utilizado para tipar as indicações de provedor de identidade entre o frontend e o backend. +/// +public enum EAuthProvider +{ + /// + /// Login social do Google + /// + Google, + + /// + /// Login social da Microsoft + /// + Microsoft, + + /// + /// Login social do Facebook + /// + Facebook, + + /// + /// Login social da Apple + /// + Apple +} diff --git a/src/Modules/Documents/API/Extensions.cs b/src/Modules/Documents/API/Extensions.cs index a2adfaa26..1c4a34979 100644 --- a/src/Modules/Documents/API/Extensions.cs +++ b/src/Modules/Documents/API/Extensions.cs @@ -34,71 +34,8 @@ public static IServiceCollection AddDocumentsModule( /// public static WebApplication UseDocumentsModule(this WebApplication app) { - // Garantir que as migrações estão aplicadas - EnsureDatabaseMigrations(app); - app.MapDocumentsEndpoints(); return app; } - - private static void EnsureDatabaseMigrations(WebApplication app) - { - // Só aplica migrações se não estivermos em ambiente de testes unitários - if (app?.Services == null) return; - - // Em ambiente de teste E2E, pular migrações automáticas - elas são gerenciadas pelo TestContainer - if (app.Environment.IsEnvironment("Test") || app.Environment.IsEnvironment("Testing")) - { - return; - } - - // Permite desabilitar migrações automáticas via variável de ambiente - // Útil para produção onde migrações devem ser executadas via pipeline de deployment - var applyMigrations = Environment.GetEnvironmentVariable("APPLY_MIGRATIONS"); - if (!string.IsNullOrEmpty(applyMigrations) && bool.TryParse(applyMigrations, out var shouldApply) && !shouldApply) - { - var logger = app.Services.GetService>(); - logger?.LogInformation("Migrações automáticas desabilitadas via APPLY_MIGRATIONS=false"); - return; - } - - using var scope = app.Services.CreateScope(); - var context = scope.ServiceProvider.GetService(); - if (context == null) - { - var logger = scope.ServiceProvider.GetService>(); - logger?.LogWarning("DocumentsDbContext não registrado. Pulando migrações."); - return; - } - - var contextLogger = scope.ServiceProvider.GetService>(); - - try - { - // Em produção, usar migrações normais - // Nota: Para ambientes com múltiplas instâncias, defina APPLY_MIGRATIONS=false - // e execute migrações via pipeline de deployment - context.Database.Migrate(); - } - catch (Exception ex) - { - // Log do erro, mas tenta fallback - contextLogger?.LogWarning(ex, "Falha ao aplicar migrações do módulo Documents. Usando EnsureCreated como fallback."); - - // Tenta EnsureCreated como fallback (apenas em desenvolvimento) - if (app.Environment.IsDevelopment()) - { - context.Database.EnsureCreated(); - } - else - { - // Em produção, não fazer fallback silencioso - relançar para visão do problema - contextLogger?.LogError(ex, "Erro crítico ao aplicar migrações do módulo Documents em ambiente de produção."); - throw new InvalidOperationException( - "Critical error applying Documents module database migrations in production environment", - ex); - } - } - } } diff --git a/src/Modules/Documents/Tests/packages.lock.json b/src/Modules/Documents/Tests/packages.lock.json index b6e987f1b..da31b4dca 100644 --- a/src/Modules/Documents/Tests/packages.lock.json +++ b/src/Modules/Documents/Tests/packages.lock.json @@ -1367,6 +1367,7 @@ "Scrutor": "[7.0.0, )", "Testcontainers.Azurite": "[4.10.0, )", "Testcontainers.PostgreSql": "[4.10.0, )", + "Testcontainers.RabbitMq": "[4.10.0, )", "xunit.v3": "[3.2.2, )" } }, @@ -2234,6 +2235,15 @@ "dependencies": { "Testcontainers": "4.10.0" } + }, + "Testcontainers.RabbitMq": { + "type": "CentralTransitive", + "requested": "[4.10.0, )", + "resolved": "4.10.0", + "contentHash": "psuDUrJbqaFeZ6T+kNL9rEiafyPVlQuk5xeHswuiFVMlBaihpobKTlUxWy0k9hLy6xR5Op8kGayLmsb32gDXkA==", + "dependencies": { + "Testcontainers": "4.10.0" + } } } } diff --git a/src/Modules/Locations/Tests/packages.lock.json b/src/Modules/Locations/Tests/packages.lock.json index edd575b4e..dbaa7d77f 100644 --- a/src/Modules/Locations/Tests/packages.lock.json +++ b/src/Modules/Locations/Tests/packages.lock.json @@ -1345,6 +1345,7 @@ "Scrutor": "[7.0.0, )", "Testcontainers.Azurite": "[4.10.0, )", "Testcontainers.PostgreSql": "[4.10.0, )", + "Testcontainers.RabbitMq": "[4.10.0, )", "xunit.v3": "[3.2.2, )" } }, @@ -2223,6 +2224,15 @@ "dependencies": { "Testcontainers": "4.10.0" } + }, + "Testcontainers.RabbitMq": { + "type": "CentralTransitive", + "requested": "[4.10.0, )", + "resolved": "4.10.0", + "contentHash": "psuDUrJbqaFeZ6T+kNL9rEiafyPVlQuk5xeHswuiFVMlBaihpobKTlUxWy0k9hLy6xR5Op8kGayLmsb32gDXkA==", + "dependencies": { + "Testcontainers": "4.10.0" + } } } } diff --git a/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs b/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs index db7ee6f02..5c3b4d2a9 100644 --- a/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs +++ b/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs @@ -55,6 +55,7 @@ public static void MapProvidersEndpoints(this WebApplication app) .MapEndpoint() .MapEndpoint() .MapEndpoint() // Novo endpoint público + .MapEndpoint() // Endpoint para usuário autenticado virar prestador .MapEndpoint() .MapEndpoint() .MapEndpoint() @@ -67,7 +68,9 @@ public static void MapProvidersEndpoints(this WebApplication app) .MapEndpoint() .MapEndpoint() .MapEndpoint() - .MapEndpoint(); + .MapEndpoint() + .MapEndpoint() + .MapEndpoint(); // Endpoints de associação de serviços endpoints.MapEndpoint() diff --git a/src/Modules/Providers/API/Endpoints/Public/BecomeProviderEndpoint.cs b/src/Modules/Providers/API/Endpoints/Public/BecomeProviderEndpoint.cs new file mode 100644 index 000000000..256eca8da --- /dev/null +++ b/src/Modules/Providers/API/Endpoints/Public/BecomeProviderEndpoint.cs @@ -0,0 +1,70 @@ +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Models; +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Application.DTOs; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using System.ComponentModel.DataAnnotations; + +namespace MeAjudaAi.Modules.Providers.API.Endpoints.Public; + +public class BecomeProviderEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("become", BecomeProviderAsync) + .WithName("BecomeProvider") + .WithTags("Providers") + .WithSummary("Tornar-se prestador (usuário já autenticado)") + .WithDescription("Transforma o usuário autenticado em um prestador de serviços. Requer token de usuário.") + .RequireAuthorization() + .Produces>(StatusCodes.Status201Created) + .Produces>(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status401Unauthorized); + + private static async Task BecomeProviderAsync( + HttpContext context, + [FromBody] RegisterProviderApiRequest request, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var userIdString = GetUserId(context); + if (!Guid.TryParse(userIdString, out var userId)) + return BadRequest("Formato de ID de usuário inválido"); + + // Obter email do token (obrigatório). Não há fallback para o corpo da requisição. + var email = context.User?.FindFirst("email")?.Value; + + if (string.IsNullOrEmpty(email)) + return BadRequest("Email é obrigatório e não foi encontrado no token."); + + var command = new RegisterProviderCommand( + userId, + request.Name, + email, + request.PhoneNumber, + request.Type, + request.DocumentNumber + ); + + var result = await commandDispatcher.SendAsync>( + command, cancellationToken); + + if (result.IsFailure) + return Handle(result); + + // Retorna 201 Created com a localização do recurso (perfil do prestador) + return Results.CreatedAtRoute("GetMyProviderProfile", null, new Response(result.Value!)); + } +} + +public record RegisterProviderApiRequest( + [Required, StringLength(100)] string Name, + [Required, EnumDataType(typeof(EProviderType))] EProviderType Type, + [Required, StringLength(20)] string DocumentNumber, + [Phone, StringLength(20)] string? PhoneNumber +); diff --git a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs index 47ac89bb2..ef073e8a4 100644 --- a/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs +++ b/src/Modules/Providers/API/Endpoints/Public/GetPublicProviderByIdEndpoint.cs @@ -45,9 +45,11 @@ Recupera dados públicos e seguros de um prestador para exibição no site. private static async Task GetPublicProviderAsync( Guid id, IQueryDispatcher queryDispatcher, + HttpContext httpContext, CancellationToken cancellationToken) { - var query = new GetPublicProviderByIdQuery(id); + var isAuthenticated = httpContext.User.Identity?.IsAuthenticated ?? false; + var query = new GetPublicProviderByIdQuery(id, isAuthenticated); var result = await queryDispatcher.QueryAsync>( query, cancellationToken); diff --git a/src/Modules/Providers/API/Endpoints/Public/Me/GetMyProviderStatusEndpoint.cs b/src/Modules/Providers/API/Endpoints/Public/Me/GetMyProviderStatusEndpoint.cs new file mode 100644 index 000000000..10ed83de2 --- /dev/null +++ b/src/Modules/Providers/API/Endpoints/Public/Me/GetMyProviderStatusEndpoint.cs @@ -0,0 +1,61 @@ +using MeAjudaAi.Modules.Providers.Application.DTOs; +using MeAjudaAi.Modules.Providers.Application.Queries; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Queries; +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.Providers.API.Endpoints.Public.Me; + +/// +/// Endpoint para o prestador consultar seu status de aprovação e tier atual. +/// +/// +/// Retorna um subconjunto do ProviderDto focado em status — evita expor dados sensíveis. +/// Usado pelo frontend para polling durante o onboarding (aguardando aprovação). +/// +public class GetMyProviderStatusEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapGet("me/status", GetMyStatusAsync) + .WithName("GetMyProviderStatus") + .WithTags("Providers - Me") + .WithSummary("Status de aprovação do prestador") + .WithDescription("Retorna o status atual de aprovação e tier do prestador autenticado.") + .RequireAuthorization() + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + private static async Task GetMyStatusAsync( + HttpContext context, + IQueryDispatcher queryDispatcher, + CancellationToken cancellationToken) + { + var userIdString = GetUserId(context); + if (!Guid.TryParse(userIdString, out var userId)) + return BadRequest("Formato de ID de usuário inválido"); + + var query = new GetProviderByUserIdQuery(userId); + var result = await queryDispatcher.QueryAsync>( + query, cancellationToken); + + if (result.IsFailure) + return BadRequest("Não foi possível consultar o status do prestador."); + + if (result.Value is null) + return NotFound("Perfil do prestador não encontrado."); + + var statusDto = new ProviderStatusDto( + Status: result.Value.Status, + Tier: result.Value.Tier, + VerificationStatus: result.Value.VerificationStatus, + RejectionReason: result.Value.RejectionReason + ); + + return Results.Ok(new Response(statusDto)); + } +} diff --git a/src/Modules/Providers/API/Endpoints/Public/Me/UploadMyDocumentEndpoint.cs b/src/Modules/Providers/API/Endpoints/Public/Me/UploadMyDocumentEndpoint.cs new file mode 100644 index 000000000..056c698c3 --- /dev/null +++ b/src/Modules/Providers/API/Endpoints/Public/Me/UploadMyDocumentEndpoint.cs @@ -0,0 +1,73 @@ +using MeAjudaAi.Modules.Providers.API.Mappers; +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Application.DTOs; +using MeAjudaAi.Modules.Providers.Application.DTOs.Requests; +using MeAjudaAi.Modules.Providers.Application.Queries; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Queries; +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.Providers.API.Endpoints.Public.Me; + +/// +/// Endpoint para o próprio prestador fazer upload de documentos. +/// Requer autenticação com role provider-*. +/// +/// +/// Versão self-service do (admin-only). +/// Reutiliza o mesmo e . +/// +// TODO: Enforce "ProviderPolicy" or specific roles when authorization policies are defined globally. +// Currently allows any authenticated user, but logic verifies if they have a Provider profile. +public class UploadMyDocumentEndpoint : BaseEndpoint, IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("me/documents", UploadMyDocumentAsync) + .WithName("UploadMyDocument") + .WithTags("Providers - Me") + .WithSummary("Upload de documento pelo próprio prestador") + .WithDescription("Permite que o prestador adicione documentos ao seu próprio perfil.") + .RequireAuthorization() + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + private static async Task UploadMyDocumentAsync( + HttpContext context, + [FromBody] AddDocumentRequest request, + IQueryDispatcher queryDispatcher, + ICommandDispatcher commandDispatcher, + CancellationToken cancellationToken) + { + var userIdString = GetUserId(context); + if (!Guid.TryParse(userIdString, out var userId)) + return BadRequest("Formato de ID de usuário inválido"); + + // Busca o provider do usuário autenticado + var query = new GetProviderByUserIdQuery(userId); + var providerResult = await queryDispatcher.QueryAsync>( + query, cancellationToken); + + if (providerResult.IsFailure) + { + // Retorna mensagem genérica para não expor interna + return BadRequest("Não foi possível validar o perfil do prestador. Tente novamente."); + } + + if (providerResult.Value is null) + return NotFound("Perfil do prestador não encontrado para o usuário atual."); + + // Reutiliza AddDocumentCommand — mesma lógica do endpoint admin + var command = request.ToCommand(providerResult.Value.Id); + var result = await commandDispatcher.SendAsync>( + command, cancellationToken); + + return Handle(result); + } +} diff --git a/src/Modules/Providers/API/Extensions.cs b/src/Modules/Providers/API/Extensions.cs index 9c5bd678d..ad86859f7 100644 --- a/src/Modules/Providers/API/Extensions.cs +++ b/src/Modules/Providers/API/Extensions.cs @@ -35,55 +35,9 @@ public static IServiceCollection AddProvidersModule( /// Aplicação web para encadeamento public static WebApplication UseProvidersModule(this WebApplication app) { - // Garantir que as migrações estão aplicadas - EnsureDatabaseMigrations(app); - app.MapProvidersEndpoints(); return app; } - - private static void EnsureDatabaseMigrations(WebApplication app) - { - // Só aplica migrações se não estivermos em ambiente de testes unitários - if (app?.Services == null) return; - - try - { - // Criar um escopo para obter o context e aplicar migrações - using var scope = app.Services.CreateScope(); - var context = scope.ServiceProvider.GetService(); - if (context == null) return; - - // Em ambiente de teste E2E, pular migrações automáticas - elas são gerenciadas pelo TestContainer - if (app.Environment.IsEnvironment("Test") || app.Environment.IsEnvironment("Testing")) - { - return; - } - - // Em produção, usar migrações normais - context.Database.Migrate(); - } - catch (Exception ex) - { - // Em caso de erro, log mas não quebra a aplicação - try - { - using var scope = app.Services.CreateScope(); - var logger = scope.ServiceProvider.GetService>(); - logger?.LogWarning(ex, "Failed to apply migrations for Providers module. Using EnsureCreated as fallback."); - - var context = scope.ServiceProvider.GetService(); - if (context != null) - { - context.Database.EnsureCreated(); - } - } - catch - { - // Se ainda falhar, ignora silenciosamente para não quebrar testes unitários - } - } - } } diff --git a/src/Modules/Providers/API/Mappers/RequestMapperExtensions.cs b/src/Modules/Providers/API/Mappers/RequestMapperExtensions.cs index e06bc07bf..e74b9dabb 100644 --- a/src/Modules/Providers/API/Mappers/RequestMapperExtensions.cs +++ b/src/Modules/Providers/API/Mappers/RequestMapperExtensions.cs @@ -43,7 +43,8 @@ public static UpdateProviderProfileCommand ToCommand(this UpdateProviderProfileR return new UpdateProviderProfileCommand( providerId, request.Name, - request.BusinessProfile + request.BusinessProfile, + request.Services ); } @@ -58,7 +59,9 @@ public static AddDocumentCommand ToCommand(this AddDocumentRequest request, Guid return new AddDocumentCommand( providerId, request.Number, - request.DocumentType + request.DocumentType, + request.FileName, + request.FileUrl ); } diff --git a/src/Modules/Providers/Application/Commands/AddDocumentCommand.cs b/src/Modules/Providers/Application/Commands/AddDocumentCommand.cs index 42847dfc8..097c7c8d0 100644 --- a/src/Modules/Providers/Application/Commands/AddDocumentCommand.cs +++ b/src/Modules/Providers/Application/Commands/AddDocumentCommand.cs @@ -8,8 +8,15 @@ namespace MeAjudaAi.Modules.Providers.Application.Commands; /// /// Comando para adição de documento ao prestador de serviços. /// +/// O ID do prestador. +/// O número do documento. +/// O tipo do documento. +/// Nome do ficheiro, opcional. +/// URL do ficheiro, opcional. public sealed record AddDocumentCommand( Guid ProviderId, string DocumentNumber, - EDocumentType DocumentType + EDocumentType DocumentType, + string? FileName = null, + string? FileUrl = null ) : Command>; diff --git a/src/Modules/Providers/Application/Commands/RegisterProviderCommand.cs b/src/Modules/Providers/Application/Commands/RegisterProviderCommand.cs new file mode 100644 index 000000000..bc7fe110c --- /dev/null +++ b/src/Modules/Providers/Application/Commands/RegisterProviderCommand.cs @@ -0,0 +1,18 @@ +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Modules.Providers.Application.DTOs; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Shared.Commands; + +namespace MeAjudaAi.Modules.Providers.Application.Commands; + +public record RegisterProviderCommand( + Guid UserId, + string Name, + string Email, + string? PhoneNumber, + EProviderType Type, + string DocumentNumber +) : ICommand> +{ + public Guid CorrelationId { get; init; } = Guid.NewGuid(); +} diff --git a/src/Modules/Providers/Application/Commands/UpdateProviderProfileCommand.cs b/src/Modules/Providers/Application/Commands/UpdateProviderProfileCommand.cs index 401dccb02..3560df981 100644 --- a/src/Modules/Providers/Application/Commands/UpdateProviderProfileCommand.cs +++ b/src/Modules/Providers/Application/Commands/UpdateProviderProfileCommand.cs @@ -11,5 +11,6 @@ public sealed record UpdateProviderProfileCommand( Guid ProviderId, string Name, BusinessProfileDto BusinessProfile, + List? Services, string? UpdatedBy = null ) : Command>; diff --git a/src/Modules/Providers/Application/DTOs/DocumentDto.cs b/src/Modules/Providers/Application/DTOs/DocumentDto.cs index 540fa9f11..8b081e7dc 100644 --- a/src/Modules/Providers/Application/DTOs/DocumentDto.cs +++ b/src/Modules/Providers/Application/DTOs/DocumentDto.cs @@ -8,5 +8,7 @@ namespace MeAjudaAi.Modules.Providers.Application.DTOs; public sealed record DocumentDto( string Number, EDocumentType DocumentType, + string? FileName, + string? FileUrl, bool IsPrimary ); diff --git a/src/Modules/Providers/Application/DTOs/ProviderDto.cs b/src/Modules/Providers/Application/DTOs/ProviderDto.cs index 0fe1815fa..1699b56af 100644 --- a/src/Modules/Providers/Application/DTOs/ProviderDto.cs +++ b/src/Modules/Providers/Application/DTOs/ProviderDto.cs @@ -13,6 +13,7 @@ public sealed record ProviderDto( BusinessProfileDto BusinessProfile, EProviderStatus Status, EVerificationStatus VerificationStatus, + EProviderTier Tier, IReadOnlyList Documents, IReadOnlyList Qualifications, IReadOnlyList Services, diff --git a/src/Modules/Providers/Application/DTOs/ProviderStatusDto.cs b/src/Modules/Providers/Application/DTOs/ProviderStatusDto.cs new file mode 100644 index 000000000..7f64262ee --- /dev/null +++ b/src/Modules/Providers/Application/DTOs/ProviderStatusDto.cs @@ -0,0 +1,14 @@ +using MeAjudaAi.Modules.Providers.Domain.Enums; + +namespace MeAjudaAi.Modules.Providers.Application.DTOs; + +/// +/// DTO leve para consulta de status de aprovação e tier do prestador. +/// Usado pelo endpoint GET /api/v1/providers/me/status. +/// +public sealed record ProviderStatusDto( + EProviderStatus Status, + EProviderTier Tier, + EVerificationStatus VerificationStatus, + string? RejectionReason +); diff --git a/src/Modules/Providers/Application/DTOs/Requests/AddDocumentRequest.cs b/src/Modules/Providers/Application/DTOs/Requests/AddDocumentRequest.cs index e8c6cb51d..0b51bf495 100644 --- a/src/Modules/Providers/Application/DTOs/Requests/AddDocumentRequest.cs +++ b/src/Modules/Providers/Application/DTOs/Requests/AddDocumentRequest.cs @@ -6,15 +6,8 @@ namespace MeAjudaAi.Modules.Providers.Application.DTOs.Requests; /// /// Request para adição de documento a um prestador de serviços. /// -public record AddDocumentRequest -{ - /// - /// Número do documento. - /// - public string Number { get; init; } = string.Empty; - - /// - /// Tipo do documento. - /// - public EDocumentType DocumentType { get; init; } -} +/// Número do documento +/// Tipo do documento +/// Nome do arquivo (opcional para documentos apenas numéricos) +/// URL do arquivo (opcional para documentos apenas numéricos) +public sealed record AddDocumentRequest(string Number, EDocumentType DocumentType, string? FileName = null, string? FileUrl = null); diff --git a/src/Modules/Providers/Application/DTOs/Requests/RegisterProviderRequest.cs b/src/Modules/Providers/Application/DTOs/Requests/RegisterProviderRequest.cs new file mode 100644 index 000000000..bb96e89fb --- /dev/null +++ b/src/Modules/Providers/Application/DTOs/Requests/RegisterProviderRequest.cs @@ -0,0 +1,50 @@ +using MeAjudaAi.Modules.Providers.Domain.Enums; + +namespace MeAjudaAi.Modules.Providers.Application.DTOs.Requests; + +/// +/// Request para auto-registro de um novo prestador de serviços na plataforma. +/// Endpoint público — não requer autenticação. +/// +/// +/// Este é o passo inicial do onboarding. Após o registro, o prestador receberá +/// o role 'provider-standard' no Keycloak e será redirecionado para o wizard +/// de completar o perfil (PUT /api/v1/providers/me/profile). +/// +public record RegisterProviderRequest +{ + /// + /// Nome completo ou nome fantasia do prestador. + /// + public string Name { get; init; } = string.Empty; + + /// + /// Tipo do prestador de serviços. + /// + public EProviderType Type { get; init; } + + /// + /// Número de telefone profissional (obrigatório). + /// + public string PhoneNumber { get; init; } = string.Empty; + + /// + /// Email profissional do prestador. + /// + public string Email { get; init; } = string.Empty; + + /// + /// Indica que o prestador aceitou os Termos de Uso (obrigatório). + /// + public bool AcceptedTerms { get; init; } + + /// + /// Indica que o prestador aceitou a Política de Privacidade/LGPD (obrigatório). + /// + public bool AcceptedPrivacyPolicy { get; init; } + + /// + /// Número do documento (CPF/CNPJ) do prestador. + /// + public string DocumentNumber { get; init; } = string.Empty; +} diff --git a/src/Modules/Providers/Application/DTOs/Requests/UpdateProviderProfileRequest.cs b/src/Modules/Providers/Application/DTOs/Requests/UpdateProviderProfileRequest.cs index ffacf8307..c2c7fdb58 100644 --- a/src/Modules/Providers/Application/DTOs/Requests/UpdateProviderProfileRequest.cs +++ b/src/Modules/Providers/Application/DTOs/Requests/UpdateProviderProfileRequest.cs @@ -22,4 +22,9 @@ public record UpdateProviderProfileRequest new ContactInfoDto(string.Empty, null, null), new AddressDto(string.Empty, string.Empty, null, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty) ); + + /// + /// Lista de serviços oferecidos pelo prestador. Pode ser nulo se não houver alteração. + /// + public List? Services { get; init; } } diff --git a/src/Modules/Providers/Application/Extensions.cs b/src/Modules/Providers/Application/Extensions.cs index 6e859b093..770010b80 100644 --- a/src/Modules/Providers/Application/Extensions.cs +++ b/src/Modules/Providers/Application/Extensions.cs @@ -33,6 +33,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services // Command Handlers - registro manual para garantir disponibilidade services.AddScoped>, CreateProviderCommandHandler>(); + services.AddScoped>, RegisterProviderCommandHandler>(); services.AddScoped>, UpdateProviderProfileCommandHandler>(); services.AddScoped, DeleteProviderCommandHandler>(); services.AddScoped>, AddDocumentCommandHandler>(); diff --git a/src/Modules/Providers/Application/Handlers/Commands/AddDocumentCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/AddDocumentCommandHandler.cs index 4fc8856a5..ba529e501 100644 --- a/src/Modules/Providers/Application/Handlers/Commands/AddDocumentCommandHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Commands/AddDocumentCommandHandler.cs @@ -34,10 +34,15 @@ public async Task> HandleAsync(AddDocumentCommand command, C if (provider == null) { logger.LogWarning("Provider {ProviderId} not found", command.ProviderId); - return Result.Failure("Provider not found"); + return Result.Failure("Fornecedor não encontrado"); } - var document = new Document(command.DocumentNumber, command.DocumentType); + var document = new Document( + command.DocumentNumber, + command.DocumentType, + command.FileName, + command.FileUrl + ); provider.AddDocument(document); await providerRepository.UpdateAsync(provider, cancellationToken); @@ -48,7 +53,7 @@ public async Task> HandleAsync(AddDocumentCommand command, C catch (Exception ex) { logger.LogError(ex, "Error adding document to provider {ProviderId}", command.ProviderId); - return Result.Failure("An error occurred while adding the document"); + return Result.Failure("Ocorreu um erro ao adicionar o documento"); } } } diff --git a/src/Modules/Providers/Application/Handlers/Commands/RegisterProviderCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/RegisterProviderCommandHandler.cs new file mode 100644 index 000000000..9e50c836b --- /dev/null +++ b/src/Modules/Providers/Application/Handlers/Commands/RegisterProviderCommandHandler.cs @@ -0,0 +1,98 @@ +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Utilities.Constants; +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Application.DTOs; +using MeAjudaAi.Modules.Providers.Application.Mappers; +using MeAjudaAi.Modules.Providers.Domain.Entities; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Modules.Providers.Domain.Repositories; +using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Exceptions; +using MeAjudaAi.Shared.Database.Exceptions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; + +public sealed class RegisterProviderCommandHandler( + IProviderRepository providerRepository, + ILogger logger +) : ICommandHandler> +{ + public async Task> HandleAsync(RegisterProviderCommand command, CancellationToken cancellationToken) + { + var existingProvider = await providerRepository.GetByUserIdAsync(command.UserId, cancellationToken); + if (existingProvider is not null) + { + return Result.Success(existingProvider.ToDto()); + } + + try + { + var contactInfo = new ContactInfo(command.Email, command.PhoneNumber); + + // Endereço e BusinessProfile inicialmente placeholders para permitir cadastro em etapas + // Usando valores sentinela claros para indicar pendência + var address = new Address("Pending", "0", "Pending", "XX", "00000-000", "00000000"); + + var businessProfile = new BusinessProfile( + command.Name, // LegalName + contactInfo, + address, + command.Name, // FantasyName default + "Prestador de serviços" // Description default + ); + + var provider = new Provider( + command.UserId, + command.Name, + command.Type, + businessProfile + ); + + var docType = (command.Type == EProviderType.Individual || command.Type == EProviderType.Freelancer) + ? EDocumentType.CPF + : EDocumentType.CNPJ; + var doc = new Document(command.DocumentNumber, docType, isPrimary: true); + provider.AddDocument(doc); + + await providerRepository.AddAsync(provider, cancellationToken); + + logger.LogInformation("Provider {ProviderId} created successfully via registration for user {UserId}", + provider.Id.Value, command.UserId); + + return Result.Success(provider.ToDto()); + } + catch (DbUpdateException ex) + { + var processedEx = PostgreSqlExceptionProcessor.ProcessException(ex); + + if (processedEx is UniqueConstraintException) + { + logger.LogWarning(ex, "Duplicate provider registration attempt for user {UserId}", command.UserId); + var existing = await providerRepository.GetByUserIdAsync(command.UserId, cancellationToken); + if (existing is not null) + { + return Result.Success(existing.ToDto()); + } + + // Se houver violação de constraint única mas não encontrarmos o prestador, + // isso implica numa condição de corrida ou inconsistência de dados que devemos reportar como falha + return Result.Failure(Error.Conflict("Um prestador já está registrado para este usuário.")); + } + // Para outros erros de banco de dados, relança para ser tratado pelo handler global de exceções ou catch externo + throw processedEx; + } + catch (Exception ex) when (ex is DomainException || ex is ArgumentException) + { + logger.LogWarning(ex, "Validation error in RegisterProvider for user {UserId}: {Message}", command.UserId, ex.Message); + return Result.Failure(new Error("Erro ao processar a requisição. Verifique os dados informados.", 400)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling RegisterProviderCommand for user {UserId}", command.UserId); + return Result.Failure(new Error("Erro inesperado ao registrar prestador.", 500)); + } + } +} diff --git a/src/Modules/Providers/Application/Handlers/Commands/UpdateProviderProfileCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/UpdateProviderProfileCommandHandler.cs index c9a0267cc..ae5a9795a 100644 --- a/src/Modules/Providers/Application/Handlers/Commands/UpdateProviderProfileCommandHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Commands/UpdateProviderProfileCommandHandler.cs @@ -35,6 +35,11 @@ public async Task> HandleAsync(UpdateProviderProfileCommand var businessProfile = command.BusinessProfile.ToDomain(); provider.UpdateProfile(command.Name, businessProfile, command.UpdatedBy); + if (command.Services != null) + { + provider.UpdateServices(command.Services.Select(s => (s.ServiceId, s.ServiceName))); + } + await providerRepository.UpdateAsync(provider, cancellationToken); logger.LogInformation("Provider profile {ProviderId} updated successfully", command.ProviderId); diff --git a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs index e1c9d497f..043809b11 100644 --- a/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs +++ b/src/Modules/Providers/Application/Handlers/Queries/GetPublicProviderByIdQueryHandler.cs @@ -51,9 +51,12 @@ public GetPublicProviderByIdQueryHandler(IProviderRepository providerRepository, // Verifica se a privacidade restrita está habilitada via feature flag var isPrivacyEnabled = await _featureManager.IsEnabledAsync(FeatureFlags.PublicProfilePrivacy); - var phoneNumbers = ResolvePhoneNumbers(isPrivacyEnabled, businessProfile); + // A privacidade é forçada se a feature flag estiver ligada OU se o usuário não estiver autenticado + var shouldRedactContactInfo = isPrivacyEnabled || !query.IsAuthenticated; + + var phoneNumbers = ResolvePhoneNumbers(shouldRedactContactInfo, businessProfile); - var email = !isPrivacyEnabled && businessProfile.ContactInfo != null + var email = !shouldRedactContactInfo && businessProfile.ContactInfo != null ? businessProfile.ContactInfo.Email : null; diff --git a/src/Modules/Providers/Application/Mappers/ProviderMapper.cs b/src/Modules/Providers/Application/Mappers/ProviderMapper.cs index 505f0e9f1..797e312ac 100644 --- a/src/Modules/Providers/Application/Mappers/ProviderMapper.cs +++ b/src/Modules/Providers/Application/Mappers/ProviderMapper.cs @@ -22,6 +22,7 @@ public static ProviderDto ToDto(this Provider provider) provider.BusinessProfile.ToDto(), provider.Status, provider.VerificationStatus, + provider.Tier, provider.Documents.Select(d => d.ToDto()).ToList(), provider.Qualifications.Select(q => q.ToDto()).ToList(), provider.Services.Select(s => s.ToDto()).ToList(), @@ -93,6 +94,8 @@ public static DocumentDto ToDto(this Document document) return new DocumentDto( document.Number, document.DocumentType, + document.FileName, + document.FileUrl, document.IsPrimary ); } @@ -154,7 +157,7 @@ public static BusinessProfile ToDomain(this BusinessProfileDto dto) /// public static Document ToDomain(this DocumentDto dto) { - return new Document(dto.Number, dto.DocumentType, dto.IsPrimary); + return new Document(dto.Number, dto.DocumentType, dto.FileName, dto.FileUrl, dto.IsPrimary); } /// diff --git a/src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs b/src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs index d2bbdffa1..47ee4901f 100644 --- a/src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs +++ b/src/Modules/Providers/Application/Queries/GetPublicProviderByIdQuery.cs @@ -9,9 +9,9 @@ namespace MeAjudaAi.Modules.Providers.Application.Queries; /// Query para buscar dados públicos de um prestador pelo ID. /// Acessível sem autenticação. /// -public sealed record GetPublicProviderByIdQuery(Guid Id) : Query>, ICacheableQuery +public sealed record GetPublicProviderByIdQuery(Guid Id, bool IsAuthenticated = false) : Query>, ICacheableQuery { - public string GetCacheKey() => $"provider:public:{Id}"; + public string GetCacheKey() => $"provider:public:{Id}:{(IsAuthenticated ? "auth" : "anon")}"; // Cache de 10 minutos para dados públicos (bom para SEO e performance) public TimeSpan GetCacheExpiration() => TimeSpan.FromMinutes(10); diff --git a/src/Modules/Providers/Application/Validators/AddDocumentRequestValidator.cs b/src/Modules/Providers/Application/Validators/AddDocumentRequestValidator.cs index 35e30e479..bd1693516 100644 --- a/src/Modules/Providers/Application/Validators/AddDocumentRequestValidator.cs +++ b/src/Modules/Providers/Application/Validators/AddDocumentRequestValidator.cs @@ -14,17 +14,17 @@ public AddDocumentRequestValidator() { RuleFor(x => x.Number) .NotEmpty() - .WithMessage("Document number is required") + .WithMessage("Número do documento é obrigatório") .MinimumLength(3) - .WithMessage("Document number must be at least 3 characters long") + .WithMessage("Número do documento deve ter pelo menos 3 caracteres") .MaximumLength(50) - .WithMessage("Document number cannot exceed 50 characters") + .WithMessage("Número do documento não pode exceder 50 caracteres") .Matches(@"^[a-zA-Z0-9\-\.]+$") - .WithMessage("Document number can only contain letters, numbers, hyphens and dots"); + .WithMessage("Número do documento deve conter apenas letras, números, hífens e pontos"); RuleFor(x => x.DocumentType) .Must(BeValidDocumentType) - .WithMessage($"DocumentType must be a valid document type. {EnumExtensions.GetValidValuesDescription()}"); + .WithMessage("Tipo de documento inválido. Valores aceitos: None, CPF, CNPJ, RG, CNH, Passport, Other"); } private static bool BeValidDocumentType(EDocumentType documentType) diff --git a/src/Modules/Providers/Application/Validators/RegisterProviderCommandValidator.cs b/src/Modules/Providers/Application/Validators/RegisterProviderCommandValidator.cs new file mode 100644 index 000000000..e324bbab1 --- /dev/null +++ b/src/Modules/Providers/Application/Validators/RegisterProviderCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using MeAjudaAi.Modules.Providers.Application.Commands; + +namespace MeAjudaAi.Modules.Providers.Application.Validators; + +/// +/// Validador para o comando de registro inicial de prestador de serviços. +/// +public class RegisterProviderCommandValidator : AbstractValidator +{ + public RegisterProviderCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("O nome é obrigatório") + .MaximumLength(100).WithMessage("O nome não pode exceder 100 caracteres"); + + RuleFor(x => x.DocumentNumber) + .NotEmpty().WithMessage("O número do documento é obrigatório") + .MaximumLength(20).WithMessage("O número do documento não pode exceder 20 caracteres"); + + RuleFor(x => x.PhoneNumber) + .MaximumLength(20).WithMessage("O número de telefone não pode exceder 20 caracteres") + .Matches(@"^\+?[1-9]\d{1,14}$").WithMessage("Número de telefone inválido") + .When(x => !string.IsNullOrEmpty(x.PhoneNumber)); + + RuleFor(x => x.Type) + .IsInEnum().WithMessage("Tipo de prestador inválido"); + } +} diff --git a/src/Modules/Providers/Domain/Entities/Provider.cs b/src/Modules/Providers/Domain/Entities/Provider.cs index 825c3df8a..d40b3bf6a 100644 --- a/src/Modules/Providers/Domain/Entities/Provider.cs +++ b/src/Modules/Providers/Domain/Entities/Provider.cs @@ -60,6 +60,14 @@ public sealed class Provider : AggregateRoot /// public EVerificationStatus VerificationStatus { get; private set; } + /// + /// Tier de assinatura do prestador de serviços. + /// + /// + /// Padrão: Standard (plano gratuito). Promovido automaticamente via Stripe webhook. + /// + public EProviderTier Tier { get; private set; } + /// /// Coleção de documentos validados do prestador de serviços. /// @@ -125,6 +133,7 @@ public Provider( BusinessProfile = businessProfile; Status = EProviderStatus.PendingBasicInfo; VerificationStatus = EVerificationStatus.Pending; + Tier = EProviderTier.Standard; // Não adiciona eventos de domínio para testes } @@ -158,6 +167,7 @@ public Provider( BusinessProfile = businessProfile; Status = EProviderStatus.PendingBasicInfo; VerificationStatus = EVerificationStatus.Pending; + Tier = EProviderTier.Standard; AddDomainEvent(new ProviderRegisteredDomainEvent( Id.Value, @@ -492,6 +502,36 @@ public void Reactivate(string? updatedBy = null) UpdateVerificationStatus(EVerificationStatus.Verified, updatedBy, skipMarkAsUpdated: true); } + /// + /// Promove ou rebaixa o tier do prestador de serviços. + /// + /// Novo tier a ser atribuído + /// Quem está fazendo a atualização (ex: "stripe-webhook") + /// + /// Normalmente chamado via webhook do Stripe após confirmação de pagamento. + /// Não há restrição de progressão — pode promover ou rebaixar livremente. + /// + public void PromoteTier(EProviderTier newTier, string? updatedBy = null) + { + if (IsDeleted) + throw new ProviderDomainException("Cannot update tier of deleted provider"); + + if (Tier == newTier) + return; + + var previousTier = Tier; + Tier = newTier; + MarkAsUpdated(); + + AddDomainEvent(new ProviderTierUpdatedDomainEvent( + Id.Value, + 1, + UserId, + previousTier, + newTier, + updatedBy)); + } + /// /// Suspende o prestador de serviços. /// @@ -612,6 +652,38 @@ public Guid[] GetServiceIds() return _services.Select(s => s.ServiceId).ToArray(); } + /// + /// Atualiza a lista de serviços oferecidos pelo prestador. + /// + /// Nova lista de serviços (ID e Nome) + public void UpdateServices(IEnumerable<(Guid ServiceId, string ServiceName)> newServices) + { + if (IsDeleted) + throw new ProviderDomainException("Cannot update services of deleted provider"); + + var currentServiceIds = _services.Select(s => s.ServiceId).ToHashSet(); + var newServiceList = newServices.GroupBy(s => s.ServiceId).Select(g => g.First()).ToList(); + var newServiceIds = newServiceList.Select(s => s.ServiceId).ToHashSet(); + + // Identify services to remove + var servicesToRemove = currentServiceIds.Except(newServiceIds).ToList(); + foreach (var serviceId in servicesToRemove) + { + RemoveService(serviceId); + } + + // Identify services to add + // Note: For existing services, we don't update names here as they come from catalog + // Only add new ones that are not present + foreach (var (serviceId, serviceName) in newServiceList) + { + if (!currentServiceIds.Contains(serviceId)) + { + AddService(serviceId, serviceName); + } + } + } + /// /// Exclui logicamente o prestador de serviços do sistema. /// diff --git a/src/Modules/Providers/Domain/Enums/EProviderTier.cs b/src/Modules/Providers/Domain/Enums/EProviderTier.cs new file mode 100644 index 000000000..442b46372 --- /dev/null +++ b/src/Modules/Providers/Domain/Enums/EProviderTier.cs @@ -0,0 +1,33 @@ +namespace MeAjudaAi.Modules.Providers.Domain.Enums; + +/// +/// Tier de assinatura do prestador de serviços. +/// +/// +/// Controla o nível de visibilidade e benefícios do prestador na plataforma. +/// A promoção de tier é automática via webhook do Stripe (módulo de pagamentos futuro). +/// - Standard: plano gratuito (padrão no cadastro) +/// - Silver/Gold/Platinum: planos pagos +/// +public enum EProviderTier +{ + /// + /// Plano gratuito — padrão para todos os novos prestadores. + /// + Standard = 0, + + /// + /// Plano pago nível 1 — maior visibilidade nos resultados de busca. + /// + Silver = 1, + + /// + /// Plano pago nível 2 — destaque premium nos resultados de busca. + /// + Gold = 2, + + /// + /// Plano pago nível 3 — máxima visibilidade e benefícios exclusivos. + /// + Platinum = 3 +} diff --git a/src/Modules/Providers/Domain/Enums/ProviderTierExtensions.cs b/src/Modules/Providers/Domain/Enums/ProviderTierExtensions.cs new file mode 100644 index 000000000..169dbd2e3 --- /dev/null +++ b/src/Modules/Providers/Domain/Enums/ProviderTierExtensions.cs @@ -0,0 +1,53 @@ +using MeAjudaAi.Shared.Utilities; + +namespace MeAjudaAi.Modules.Providers.Domain.Enums; + +/// +/// Métodos de extensão para conversão entre e os papéis de Keycloak . +/// Centraliza a conversão para evitar deriva de strings e quebras de integridade caso novos planos sejam adicionados. +/// +public static class ProviderTierExtensions +{ + /// + /// Retorna a representação de string canônica definida em para um dado nível. + /// + public static string ToRoleString(this EProviderTier tier) => tier switch + { + EProviderTier.Standard => UserRoles.ProviderStandard, + EProviderTier.Silver => UserRoles.ProviderSilver, + EProviderTier.Gold => UserRoles.ProviderGold, + EProviderTier.Platinum => UserRoles.ProviderPlatinum, + _ => throw new ArgumentOutOfRangeException(nameof(tier), tier, null) + }; + + /// + /// Tenta interpretar uma string de Papel no Keycloak na entidade de Nível correta do Domínio . + /// + public static bool TryParseRole(string role, out EProviderTier tier) + { + var trimmed = role?.Trim(); + if (string.Equals(trimmed, UserRoles.ProviderStandard, StringComparison.OrdinalIgnoreCase)) + { + tier = EProviderTier.Standard; + return true; + } + else if (string.Equals(trimmed, UserRoles.ProviderSilver, StringComparison.OrdinalIgnoreCase)) + { + tier = EProviderTier.Silver; + return true; + } + else if (string.Equals(trimmed, UserRoles.ProviderGold, StringComparison.OrdinalIgnoreCase)) + { + tier = EProviderTier.Gold; + return true; + } + else if (string.Equals(trimmed, UserRoles.ProviderPlatinum, StringComparison.OrdinalIgnoreCase)) + { + tier = EProviderTier.Platinum; + return true; + } + + tier = default; + return false; + } +} diff --git a/src/Modules/Providers/Domain/Events/ProviderTierUpdatedDomainEvent.cs b/src/Modules/Providers/Domain/Events/ProviderTierUpdatedDomainEvent.cs new file mode 100644 index 000000000..65c130010 --- /dev/null +++ b/src/Modules/Providers/Domain/Events/ProviderTierUpdatedDomainEvent.cs @@ -0,0 +1,20 @@ +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Modules.Providers.Domain.Events; + +/// +/// Evento de domínio disparado quando o tier de um prestador é atualizado. +/// +/// +/// Normalmente disparado via webhook do Stripe após confirmação de pagamento +/// de um plano Silver, Gold ou Platinum. Também pode ser disparado ao rebaixar +/// para Standard (cancelamento de assinatura). +/// +public sealed record ProviderTierUpdatedDomainEvent( + Guid ProviderId, + int Version, + Guid UserId, + EProviderTier PreviousTier, + EProviderTier NewTier, + string? UpdatedBy) : DomainEvent(ProviderId, Version); diff --git a/src/Modules/Providers/Domain/ValueObjects/Document.cs b/src/Modules/Providers/Domain/ValueObjects/Document.cs index 4597c3aa1..615fb8146 100644 --- a/src/Modules/Providers/Domain/ValueObjects/Document.cs +++ b/src/Modules/Providers/Domain/ValueObjects/Document.cs @@ -14,6 +14,8 @@ public sealed class Document : ValueObject { public string Number { get; private set; } public EDocumentType DocumentType { get; private set; } + public string? FileName { get; private set; } + public string? FileUrl { get; private set; } public bool IsPrimary { get; private set; } /// @@ -27,7 +29,7 @@ private Document() } [JsonConstructor] - public Document(string number, EDocumentType documentType, bool isPrimary = false) + public Document(string number, EDocumentType documentType, string? fileName = null, string? fileUrl = null, bool isPrimary = false) { if (string.IsNullOrWhiteSpace(number)) throw new ArgumentException("Número do documento não pode ser vazio", nameof(number)); @@ -40,6 +42,8 @@ public Document(string number, EDocumentType documentType, bool isPrimary = fals }; DocumentType = documentType; + FileName = fileName; + FileUrl = fileUrl; IsPrimary = isPrimary; if (!IsValid()) @@ -53,7 +57,7 @@ public Document(string number, EDocumentType documentType, bool isPrimary = fals /// Nova instância do documento com o status primário atualizado public Document WithPrimaryStatus(bool isPrimary) { - return new Document(Number, DocumentType, isPrimary); + return new Document(Number, DocumentType, FileName, FileUrl, isPrimary); } private bool IsValid() @@ -104,6 +108,8 @@ protected override IEnumerable GetEqualityComponents() { yield return Number; yield return DocumentType; + yield return FileName ?? string.Empty; + yield return FileUrl ?? string.Empty; yield return IsPrimary; } diff --git a/src/Modules/Providers/Infrastructure/Database/Migrations/20260219205050_AddDocumentFileProperties.Designer.cs b/src/Modules/Providers/Infrastructure/Database/Migrations/20260219205050_AddDocumentFileProperties.Designer.cs new file mode 100644 index 000000000..cd5164e25 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Database/Migrations/20260219205050_AddDocumentFileProperties.Designer.cs @@ -0,0 +1,420 @@ +// +using System; +using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Database.Migrations +{ + [DbContext(typeof(ProvidersDbContext))] + [Migration("20260219205050_AddDocumentFileProperties")] + partial class AddDocumentFileProperties + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("providers") + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status"); + + b.Property("SuspensionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("suspension_reason"); + + b.Property("Tier") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Standard") + .HasColumnName("tier"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VerificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("verification_status"); + + b.HasKey("Id") + .HasName("pk_providers"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("ix_providers_is_deleted"); + + b.HasIndex("Name") + .HasDatabaseName("ix_providers_name"); + + b.HasIndex("Status") + .HasDatabaseName("ix_providers_status"); + + b.HasIndex("Type") + .HasDatabaseName("ix_providers_type"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_providers_user_id"); + + b.HasIndex("VerificationStatus") + .HasDatabaseName("ix_providers_verification_status"); + + b.ToTable("providers", "providers"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.ProviderService", b => + { + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("service_name"); + + b.HasKey("ProviderId", "ServiceId") + .HasName("pk_provider_services"); + + b.HasIndex("ServiceId") + .HasDatabaseName("ix_provider_services_service_id"); + + b.ToTable("provider_services", "providers"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.BusinessProfile", "BusinessProfile", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("FantasyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("fantasy_name"); + + b1.Property("LegalName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("legal_name"); + + b1.HasKey("ProviderId"); + + b1.ToTable("providers", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId") + .HasConstraintName("fk_providers_providers_id"); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Address", "PrimaryAddress", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b2.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("city"); + + b2.Property("Complement") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("complement"); + + b2.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("country"); + + b2.Property("Neighborhood") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("neighborhood"); + + b2.Property("Number") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("number"); + + b2.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("state"); + + b2.Property("Street") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("street"); + + b2.Property("ZipCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("zip_code"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId") + .HasConstraintName("fk_providers_providers_id"); + }); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.ContactInfo", "ContactInfo", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid") + .HasColumnName("id"); + + b2.Property("AdditionalPhoneNumbers") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_phone_numbers"); + + b2.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b2.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("phone_number"); + + b2.Property("Website") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("website"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId") + .HasConstraintName("fk_providers_providers_id"); + }); + + b1.Navigation("ContactInfo") + .IsRequired(); + + b1.Navigation("PrimaryAddress") + .IsRequired(); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Document", "Documents", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("DocumentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("document_type"); + + b1.Property("FileName") + .HasColumnType("text") + .HasColumnName("file_name"); + + b1.Property("FileUrl") + .HasColumnType("text") + .HasColumnName("file_url"); + + b1.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_primary"); + + b1.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("number"); + + b1.HasKey("ProviderId", "Id") + .HasName("pk_document"); + + b1.HasIndex("ProviderId", "DocumentType") + .IsUnique() + .HasDatabaseName("ix_document_provider_id_document_type"); + + b1.ToTable("document", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId") + .HasConstraintName("fk_document_providers_provider_id"); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Qualification", "Qualifications", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("DocumentNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("document_number"); + + b1.Property("ExpirationDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_date"); + + b1.Property("IssueDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("issue_date"); + + b1.Property("IssuingOrganization") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("issuing_organization"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b1.HasKey("ProviderId", "Id") + .HasName("pk_qualification"); + + b1.ToTable("qualification", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId") + .HasConstraintName("fk_qualification_providers_provider_id"); + }); + + b.Navigation("BusinessProfile") + .IsRequired(); + + b.Navigation("Documents"); + + b.Navigation("Qualifications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.ProviderService", b => + { + b.HasOne("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", "Provider") + .WithMany("Services") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_provider_services_providers_provider_id"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.Navigation("Services"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Database/Migrations/20260219205050_AddDocumentFileProperties.cs b/src/Modules/Providers/Infrastructure/Database/Migrations/20260219205050_AddDocumentFileProperties.cs new file mode 100644 index 000000000..e09b26de7 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Database/Migrations/20260219205050_AddDocumentFileProperties.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Database.Migrations +{ + /// + public partial class AddDocumentFileProperties : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_document_provider_id_document_type", + schema: "providers", + table: "document", + newName: "ix_document_provider_id_document_type"); + + migrationBuilder.AddColumn( + name: "file_name", + schema: "providers", + table: "document", + type: "character varying(255)", + maxLength: 255, + nullable: true); + + migrationBuilder.AddColumn( + name: "file_url", + schema: "providers", + table: "document", + type: "character varying(2048)", + maxLength: 2048, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "file_name", + schema: "providers", + table: "document"); + + migrationBuilder.DropColumn( + name: "file_url", + schema: "providers", + table: "document"); + + migrationBuilder.RenameIndex( + name: "ix_document_provider_id_document_type", + schema: "providers", + table: "document", + newName: "IX_document_provider_id_document_type"); + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Database/Migrations/20260220000000_NormalizeProviderConstraints.cs b/src/Modules/Providers/Infrastructure/Database/Migrations/20260220000000_NormalizeProviderConstraints.cs new file mode 100644 index 000000000..e02dfafbf --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Database/Migrations/20260220000000_NormalizeProviderConstraints.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Database.Migrations +{ + /// + public partial class NormalizeProviderConstraints : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Renomear chaves estrangeiras + migrationBuilder.Sql("ALTER TABLE providers.document RENAME CONSTRAINT \"FK_document_providers_provider_id\" TO fk_document_providers_provider_id;"); + migrationBuilder.Sql("ALTER TABLE providers.provider_services RENAME CONSTRAINT \"FK_provider_services_providers_provider_id\" TO fk_provider_services_providers_provider_id;"); + migrationBuilder.Sql("ALTER TABLE providers.qualification RENAME CONSTRAINT \"FK_qualification_providers_provider_id\" TO fk_qualification_providers_provider_id;"); + + // Renomear chaves primárias + migrationBuilder.Sql("ALTER TABLE providers.qualification RENAME CONSTRAINT \"PK_qualification\" TO pk_qualification;"); + + migrationBuilder.Sql("ALTER TABLE providers.providers RENAME CONSTRAINT \"PK_providers\" TO pk_providers;"); + + migrationBuilder.Sql("ALTER TABLE providers.provider_services RENAME CONSTRAINT \"PK_provider_services\" TO pk_provider_services;"); + + migrationBuilder.Sql("ALTER TABLE providers.document RENAME CONSTRAINT \"PK_document\" TO pk_document;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Reversão de Up: renomear de minúsculas para maiúsculas + + // Renomear chaves estrangeiras + migrationBuilder.Sql("ALTER TABLE providers.document RENAME CONSTRAINT fk_document_providers_provider_id TO \"FK_document_providers_provider_id\";"); + migrationBuilder.Sql("ALTER TABLE providers.provider_services RENAME CONSTRAINT fk_provider_services_providers_provider_id TO \"FK_provider_services_providers_provider_id\";"); + migrationBuilder.Sql("ALTER TABLE providers.qualification RENAME CONSTRAINT fk_qualification_providers_provider_id TO \"FK_qualification_providers_provider_id\";"); + + // Renomear chaves primárias + migrationBuilder.Sql("ALTER TABLE providers.qualification RENAME CONSTRAINT pk_qualification TO \"PK_qualification\";"); + + migrationBuilder.Sql("ALTER TABLE providers.providers RENAME CONSTRAINT pk_providers TO \"PK_providers\";"); + + migrationBuilder.Sql("ALTER TABLE providers.provider_services RENAME CONSTRAINT pk_provider_services TO \"PK_provider_services\";"); + + migrationBuilder.Sql("ALTER TABLE providers.document RENAME CONSTRAINT pk_document TO \"PK_document\";"); + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs index edbe2fc58..a24e92fe9 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs @@ -54,6 +54,15 @@ public void Configure(EntityTypeBuilder builder) .IsRequired() .HasColumnName("verification_status"); + builder.Property(p => p.Tier) + .HasConversion( + tier => tier.ToString(), + value => Enum.Parse(value)) + .HasMaxLength(20) + .IsRequired() + .HasDefaultValue(EProviderTier.Standard) + .HasColumnName("tier"); + builder.Property(p => p.IsDeleted) .IsRequired() .HasColumnName("is_deleted"); diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260218190733_AddProviderTier.Designer.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260218190733_AddProviderTier.Designer.cs new file mode 100644 index 000000000..328a14ae3 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260218190733_AddProviderTier.Designer.cs @@ -0,0 +1,398 @@ +// +using System; +using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ProvidersDbContext))] + [Migration("20260218190733_AddProviderTier")] + partial class AddProviderTier + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("providers") + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status"); + + b.Property("SuspensionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("suspension_reason"); + + b.Property("Tier") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Standard") + .HasColumnName("tier"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VerificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("verification_status"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("ix_providers_is_deleted"); + + b.HasIndex("Name") + .HasDatabaseName("ix_providers_name"); + + b.HasIndex("Status") + .HasDatabaseName("ix_providers_status"); + + b.HasIndex("Type") + .HasDatabaseName("ix_providers_type"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_providers_user_id"); + + b.HasIndex("VerificationStatus") + .HasDatabaseName("ix_providers_verification_status"); + + b.ToTable("providers", "providers"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.ProviderService", b => + { + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("added_at"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("service_name"); + + b.HasKey("ProviderId", "ServiceId"); + + b.HasIndex("ServiceId") + .HasDatabaseName("ix_provider_services_service_id"); + + b.ToTable("provider_services", "providers"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.BusinessProfile", "BusinessProfile", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid"); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("FantasyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("fantasy_name"); + + b1.Property("LegalName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("legal_name"); + + b1.HasKey("ProviderId"); + + b1.ToTable("providers", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Address", "PrimaryAddress", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid"); + + b2.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("city"); + + b2.Property("Complement") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("complement"); + + b2.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("country"); + + b2.Property("Neighborhood") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("neighborhood"); + + b2.Property("Number") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("number"); + + b2.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("state"); + + b2.Property("Street") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("street"); + + b2.Property("ZipCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("zip_code"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId"); + }); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.ContactInfo", "ContactInfo", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid"); + + b2.Property("AdditionalPhoneNumbers") + .IsRequired() + .HasColumnType("text") + .HasColumnName("additional_phone_numbers"); + + b2.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b2.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("phone_number"); + + b2.Property("Website") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("website"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId"); + }); + + b1.Navigation("ContactInfo") + .IsRequired(); + + b1.Navigation("PrimaryAddress") + .IsRequired(); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Document", "Documents", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("DocumentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("document_type"); + + b1.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_primary"); + + b1.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("number"); + + b1.HasKey("ProviderId", "Id"); + + b1.HasIndex("ProviderId", "DocumentType") + .IsUnique(); + + b1.ToTable("document", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Qualification", "Qualifications", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("DocumentNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("document_number"); + + b1.Property("ExpirationDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_date"); + + b1.Property("IssueDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("issue_date"); + + b1.Property("IssuingOrganization") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("issuing_organization"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b1.HasKey("ProviderId", "Id"); + + b1.ToTable("qualification", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + }); + + b.Navigation("BusinessProfile") + .IsRequired(); + + b.Navigation("Documents"); + + b.Navigation("Qualifications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.ProviderService", b => + { + b.HasOne("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", "Provider") + .WithMany("Services") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.Navigation("Services"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260218190733_AddProviderTier.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260218190733_AddProviderTier.cs new file mode 100644 index 000000000..5de77ded4 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20260218190733_AddProviderTier.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddProviderTier : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "tier", + schema: "providers", + table: "providers", + type: "character varying(20)", + maxLength: 20, + nullable: false, + defaultValue: "Standard"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "tier", + schema: "providers", + table: "providers"); + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs index 38956d050..12aaba786 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("providers") - .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("ProductVersion", "10.0.3") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -63,6 +63,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(1000)") .HasColumnName("suspension_reason"); + b.Property("Tier") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Standard") + .HasColumnName("tier"); + b.Property("Type") .IsRequired() .HasMaxLength(20) @@ -83,7 +91,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(20)") .HasColumnName("verification_status"); - b.HasKey("Id"); + b.HasKey("Id") + .HasName("pk_providers"); b.HasIndex("IsDeleted") .HasDatabaseName("ix_providers_is_deleted"); @@ -127,7 +136,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(100)") .HasColumnName("service_name"); - b.HasKey("ProviderId", "ServiceId"); + b.HasKey("ProviderId", "ServiceId") + .HasName("pk_provider_services"); b.HasIndex("ServiceId") .HasDatabaseName("ix_provider_services_service_id"); @@ -140,7 +150,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.BusinessProfile", "BusinessProfile", b1 => { b1.Property("ProviderId") - .HasColumnType("uuid"); + .HasColumnType("uuid") + .HasColumnName("id"); b1.Property("Description") .HasMaxLength(1000) @@ -163,12 +174,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.ToTable("providers", "providers"); b1.WithOwner() - .HasForeignKey("ProviderId"); + .HasForeignKey("ProviderId") + .HasConstraintName("fk_providers_providers_id"); b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Address", "PrimaryAddress", b2 => { b2.Property("BusinessProfileProviderId") - .HasColumnType("uuid"); + .HasColumnType("uuid") + .HasColumnName("id"); b2.Property("City") .IsRequired() @@ -222,13 +235,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b2.ToTable("providers", "providers"); b2.WithOwner() - .HasForeignKey("BusinessProfileProviderId"); + .HasForeignKey("BusinessProfileProviderId") + .HasConstraintName("fk_providers_providers_id"); }); b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.ContactInfo", "ContactInfo", b2 => { b2.Property("BusinessProfileProviderId") - .HasColumnType("uuid"); + .HasColumnType("uuid") + .HasColumnName("id"); b2.Property("AdditionalPhoneNumbers") .IsRequired() @@ -256,7 +271,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b2.ToTable("providers", "providers"); b2.WithOwner() - .HasForeignKey("BusinessProfileProviderId"); + .HasForeignKey("BusinessProfileProviderId") + .HasConstraintName("fk_providers_providers_id"); }); b1.Navigation("ContactInfo") @@ -285,6 +301,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(20)") .HasColumnName("document_type"); + b1.Property("FileName") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("file_name"); + + b1.Property("FileUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("file_url"); + b1.Property("IsPrimary") .ValueGeneratedOnAdd() .HasColumnType("boolean") @@ -297,15 +323,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(50)") .HasColumnName("number"); - b1.HasKey("ProviderId", "Id"); + b1.HasKey("ProviderId", "Id") + .HasName("pk_document"); b1.HasIndex("ProviderId", "DocumentType") - .IsUnique(); + .IsUnique() + .HasDatabaseName("ix_document_provider_id_document_type"); b1.ToTable("document", "providers"); b1.WithOwner() - .HasForeignKey("ProviderId"); + .HasForeignKey("ProviderId") + .HasConstraintName("fk_document_providers_provider_id"); }); b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Qualification", "Qualifications", b1 => @@ -350,12 +379,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(200)") .HasColumnName("name"); - b1.HasKey("ProviderId", "Id"); + b1.HasKey("ProviderId", "Id") + .HasName("pk_qualification"); b1.ToTable("qualification", "providers"); b1.WithOwner() - .HasForeignKey("ProviderId"); + .HasForeignKey("ProviderId") + .HasConstraintName("fk_qualification_providers_provider_id"); }); b.Navigation("BusinessProfile") @@ -372,7 +403,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .WithMany("Services") .HasForeignKey("ProviderId") .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .IsRequired() + .HasConstraintName("fk_provider_services_providers_provider_id"); b.Navigation("Provider"); }); diff --git a/src/Modules/Providers/Infrastructure/Persistence/ProvidersDbContextFactory.cs b/src/Modules/Providers/Infrastructure/Persistence/ProvidersDbContextFactory.cs index dae75508a..add316f5d 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/ProvidersDbContextFactory.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/ProvidersDbContextFactory.cs @@ -36,7 +36,8 @@ public ProvidersDbContext CreateDbContext(string[] args) optionsBuilder.UseNpgsql(connectionString, options => { options.MigrationsHistoryTable("__EFMigrationsHistory", "providers"); - }); + }) + .UseSnakeCaseNamingConvention(); return new ProvidersDbContext(optionsBuilder.Options); } diff --git a/src/Modules/Providers/Tests/Application/Handlers/Commands/UpdateProviderProfileCommandHandlerTests.cs b/src/Modules/Providers/Tests/Application/Handlers/Commands/UpdateProviderProfileCommandHandlerTests.cs index b9af03396..3502ac96b 100644 --- a/src/Modules/Providers/Tests/Application/Handlers/Commands/UpdateProviderProfileCommandHandlerTests.cs +++ b/src/Modules/Providers/Tests/Application/Handlers/Commands/UpdateProviderProfileCommandHandlerTests.cs @@ -40,6 +40,7 @@ public async Task HandleAsync_WhenProviderExists_ShouldUpdateAndReturnSuccess() new ContactInfoDto("test@example.com", "1234567890", "site.com"), new AddressDto("Street", "123", "Comp", "Neighborhood", "City", "ST", "12345678", "Country") ), + new List(), "Tester" ); @@ -78,6 +79,7 @@ public async Task HandleAsync_WhenProviderNotFound_ShouldReturnFailure() new ContactInfoDto("e@e.com", null, null), new AddressDto("S", "1", null, "N", "C", "S", "Z", "C") ), + new List(), "Tester" ); @@ -106,6 +108,7 @@ public async Task HandleAsync_WhenRepositoryThrowsException_ShouldCatchAndReturn new ContactInfoDto("e@e.com", null, null), new AddressDto("S", "1", null, "N", "C", "S", "Z", "C") ), + new List(), "Tester" ); diff --git a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs index 1ad0a42cf..d87d5ab01 100644 --- a/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs +++ b/src/Modules/Providers/Tests/Builders/ProviderBuilder.cs @@ -15,6 +15,7 @@ public class ProviderBuilder : BaseBuilder private BusinessProfile? _businessProfile; private ProviderId? _providerId; private EVerificationStatus? _verificationStatus; + private EProviderTier? _tier; private readonly List _documents = new(); private readonly List _qualifications = new(); @@ -68,10 +69,27 @@ public ProviderBuilder() provider.UpdateVerificationStatus(_verificationStatus.Value); } + // Define tier se especificado + if (_tier.HasValue) + { + var prop = typeof(Provider).GetProperty(nameof(Provider.Tier)); + if (prop == null) + { + throw new InvalidOperationException($"Property '{nameof(Provider.Tier)}' was not found on class {nameof(Provider)}."); + } + prop.SetValue(provider, _tier.Value); + } + return provider; }); } + public ProviderBuilder WithTier(EProviderTier tier) + { + _tier = tier; + return this; + } + public ProviderBuilder WithUserId(Guid userId) { _userId = userId; @@ -120,6 +138,12 @@ public ProviderBuilder WithDocument(string number, EDocumentType type) return this; } + public ProviderBuilder WithDocument(string number, EDocumentType type, string fileName, string fileUrl) + { + _documents.Add(new Document(number, type, fileName, fileUrl)); + return this; + } + public ProviderBuilder WithQualification(string name, string description, string organization, DateTime issueDate, DateTime expirationDate, string documentNumber) { _qualifications.Add(new Qualification(name, description, organization, issueDate, expirationDate, documentNumber)); diff --git a/src/Modules/Providers/Tests/Integration/UpdateMyProviderProfileIntegrationTests.cs b/src/Modules/Providers/Tests/Integration/UpdateMyProviderProfileIntegrationTests.cs index 8275c6e31..1569b657a 100644 --- a/src/Modules/Providers/Tests/Integration/UpdateMyProviderProfileIntegrationTests.cs +++ b/src/Modules/Providers/Tests/Integration/UpdateMyProviderProfileIntegrationTests.cs @@ -39,7 +39,7 @@ public async Task UpdateMyProfile_WithValidData_ShouldUpdateProvider() new AddressDto("Street", "1", "Comp", "Neigh", "City", "ST", "12345678", "BR") ); - var command = new UpdateProviderProfileCommand(provider.Id.Value, newName, businessProfileDto); + var command = new UpdateProviderProfileCommand(provider.Id.Value, newName, businessProfileDto, new List()); using var scope = CreateScope(); var commandDispatcher = scope.ServiceProvider.GetRequiredService(); diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs index e78c88f93..7a6d5540a 100644 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderProfileEndpointTests.cs @@ -43,7 +43,7 @@ public async Task GetMyProfileAsync_WithValidUserId_ShouldDispatchQuery() var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); var providerDto = new ProviderDto( Guid.NewGuid(), userId, "Test", EProviderType.Individual, null!, - EProviderStatus.Active, EVerificationStatus.Verified, + EProviderStatus.Active, EVerificationStatus.Verified, EProviderTier.Standard, new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); var dispatchResult = Result.Success(providerDto); diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs new file mode 100644 index 000000000..59ae6b54e --- /dev/null +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/GetMyProviderStatusEndpointTests.cs @@ -0,0 +1,95 @@ +using FluentAssertions; +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Models; +using MeAjudaAi.Modules.Providers.API.Endpoints.Public.Me; +using MeAjudaAi.Modules.Providers.Application.DTOs; +using MeAjudaAi.Modules.Providers.Application.Queries; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Shared.Queries; +using MeAjudaAi.Shared.Utilities.Constants; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Moq; +using System.Security.Claims; +using Xunit; + +namespace MeAjudaAi.Modules.Providers.Tests.Unit.API.Endpoints; + +[Trait("Category", "Unit")] +public class GetMyProviderStatusEndpointTests +{ + private readonly Mock _queryDispatcherMock; + + public GetMyProviderStatusEndpointTests() + { + _queryDispatcherMock = new Mock(); + } + + private static System.Reflection.MethodInfo GetMyStatusMethod() + { + // O método no endpoint é GetMyStatusAsync e é private static + var method = typeof(GetMyProviderStatusEndpoint).GetMethod( + "GetMyStatusAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + method.Should().NotBeNull("GetMyStatusAsync must exist as a private static method on GetMyProviderStatusEndpoint"); + return method!; + } + + [Fact] + public async Task GetMyStatusAsync_WithValidUserId_ShouldReturnStatus() + { + // Arrange + var userId = Guid.NewGuid(); + var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); + + var providerDto = new ProviderDto( + Guid.NewGuid(), userId, "Test", EProviderType.Individual, null!, + EProviderStatus.Active, EVerificationStatus.Verified, EProviderTier.Gold, + new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + + var dispatchResult = Result.Success(providerDto); + + _queryDispatcherMock + .Setup(x => x.QueryAsync>( + It.Is(q => q.UserId == userId), It.IsAny())) + .ReturnsAsync(dispatchResult); + + // Act + var methodInfo = GetMyStatusMethod(); + var task = (Task)methodInfo.Invoke(null, new object[] { context, _queryDispatcherMock.Object, CancellationToken.None })!; + var result = await task; + + // Assert + result.Should().BeOfType>>(); + var okResult = (Ok>)result; + okResult.Value!.Data.Status.Should().Be(EProviderStatus.Active); + okResult.Value.Data.VerificationStatus.Should().Be(EVerificationStatus.Verified); + okResult.Value.Data.Tier.Should().Be(EProviderTier.Gold); + + _queryDispatcherMock.Verify(x => x.QueryAsync>( + It.Is(q => q.UserId == userId), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetMyStatusAsync_WithNonExistentProvider_ShouldReturnNotFound() + { + // Arrange + var userId = Guid.NewGuid(); + var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); + var dispatchResult = Result.Success(null); + + _queryDispatcherMock + .Setup(x => x.QueryAsync>( + It.Is(q => q.UserId == userId), It.IsAny())) + .ReturnsAsync(dispatchResult); + + // Act + var methodInfo = GetMyStatusMethod(); + var task = (Task)methodInfo.Invoke(null, new object[] { context, _queryDispatcherMock.Object, CancellationToken.None })!; + var result = await task; + + // Assert + result.Should().BeOfType>>(); + } +} diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs index 23270d6ae..2d307563e 100644 --- a/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/UpdateMyProviderProfileEndpointTests.cs @@ -48,8 +48,8 @@ public async Task UpdateMyProfileAsync_WithValidRequest_ShouldDispatchUpdateComm // Setup Query to return ProviderId var providerDto = new ProviderDto( - providerId, userId, "Old Name", EProviderType.Individual, null!, - EProviderStatus.Active, EVerificationStatus.Verified, + providerId, userId, "Test", EProviderType.Individual, null!, + EProviderStatus.Active, EVerificationStatus.Verified, EProviderTier.Standard, new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); _queryDispatcherMock diff --git a/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs b/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs new file mode 100644 index 000000000..bc2909e0e --- /dev/null +++ b/src/Modules/Providers/Tests/Unit/API/Endpoints/UploadMyDocumentEndpointTests.cs @@ -0,0 +1,153 @@ +using FluentAssertions; +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Models; +using MeAjudaAi.Modules.Providers.API.Endpoints.Public.Me; +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Application.DTOs; +using MeAjudaAi.Modules.Providers.Application.DTOs.Requests; +using MeAjudaAi.Modules.Providers.Application.Queries; +using MeAjudaAi.Modules.Providers.Domain.Entities; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Queries; +using MeAjudaAi.Shared.Utilities.Constants; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Moq; +using System.Security.Claims; +using Xunit; + +namespace MeAjudaAi.Modules.Providers.Tests.Unit.API.Endpoints; + +[Trait("Category", "Unit")] +public class UploadMyDocumentEndpointTests +{ + private readonly Mock _commandDispatcherMock; + private readonly Mock _queryDispatcherMock; + + public UploadMyDocumentEndpointTests() + { + _commandDispatcherMock = new Mock(); + _queryDispatcherMock = new Mock(); + } + + private static System.Reflection.MethodInfo UploadDocumentMethod() + { + var method = typeof(UploadMyDocumentEndpoint).GetMethod( + "UploadMyDocumentAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + method.Should().NotBeNull("UploadMyDocumentAsync must exist as a private static method on UploadMyDocumentEndpoint"); + return method!; + } + + [Fact] + public async Task UploadDocumentAsync_WithValidRequest_ShouldUploadAndReturnOk() + { + // Arrange + var userId = Guid.NewGuid(); + var providerId = Guid.NewGuid(); + var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); + + var request = new AddDocumentRequest("12345678909", EDocumentType.CPF); + + var providerDto = new ProviderDto( + providerId, userId, "Test", EProviderType.Individual, null!, + EProviderStatus.PendingBasicInfo, EVerificationStatus.Pending, EProviderTier.Standard, + new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + + // Mock Query (Get provider by user id) + _queryDispatcherMock + .Setup(x => x.QueryAsync>( + It.Is(q => q.UserId == userId), It.IsAny())) + .ReturnsAsync(Result.Success(providerDto)); + + // Mock Command (Add document) + var commandResult = Result.Success(providerDto); + _commandDispatcherMock + .Setup(x => x.SendAsync>( + It.Is(c => c.ProviderId == providerId && c.DocumentNumber == request.Number), It.IsAny())) + .ReturnsAsync(commandResult); + + // Act + var methodInfo = UploadDocumentMethod(); + // Corrected parameter order: Context, Request, QueryDispatcher, CommandDispatcher, CancellationToken + var task = (Task)methodInfo.Invoke(null, new object[] { context, request, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; + var result = await task; + + // Assert + result.Should().BeOfType>>(); + var okResult = (Ok>)result; + okResult.Value.Should().NotBeNull(); + okResult.Value!.IsSuccess.Should().BeTrue(); + // okResult.Value.Value.Should().BeEquivalentTo(providerDto); // Optional verification + + _queryDispatcherMock.Verify(x => x.QueryAsync>( + It.Is(q => q.UserId == userId), It.IsAny()), Times.Once); + + _commandDispatcherMock.Verify(x => x.SendAsync>( + It.Is(c => c.ProviderId == providerId), It.IsAny()), Times.Once); + } + + [Fact] + public async Task UploadDocumentAsync_WithNonExistentProvider_ShouldReturnNotFound() + { + // Arrange + var userId = Guid.NewGuid(); + var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); + var request = new AddDocumentRequest("12345678909", EDocumentType.CPF); + + _queryDispatcherMock + .Setup(x => x.QueryAsync>( + It.Is(q => q.UserId == userId), It.IsAny())) + .ReturnsAsync(Result.Success(null)); + + // Act + var methodInfo = UploadDocumentMethod(); + // Corrected parameter order + var task = (Task)methodInfo.Invoke(null, new object[] { context, request, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; + var result = await task; + + // Assert + // Corrected expected type: NotFound> instead of NotFound + result.Should().BeOfType>>(); + + _commandDispatcherMock.Verify(x => x.SendAsync>( + It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task UploadDocumentAsync_WhenCommandFails_ShouldReturnBadRequest() + { + // Arrange + var userId = Guid.NewGuid(); + var providerId = Guid.NewGuid(); + var context = EndpointTestHelpers.CreateHttpContextWithUserId(userId); + var request = new AddDocumentRequest("12345678909", EDocumentType.CPF); + + var providerDto = new ProviderDto( + providerId, userId, "Test", EProviderType.Individual, null!, + EProviderStatus.PendingBasicInfo, EVerificationStatus.Pending, EProviderTier.Standard, + new List(), new List(), new List(), DateTime.UtcNow, null, false, null, null, null); + + _queryDispatcherMock.Setup(x => x.QueryAsync>( + It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(providerDto)); + + _commandDispatcherMock.Setup(x => x.SendAsync>( + It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Failure("Invalid document")); + + // Act + var methodInfo = UploadDocumentMethod(); + // Corrected parameter order + var task = (Task)methodInfo.Invoke(null, new object[] { context, request, _queryDispatcherMock.Object, _commandDispatcherMock.Object, CancellationToken.None })!; + var result = await task; + + // Assert + // Corrected expected type: BadRequest> + result.Should().BeOfType>>(); + } +} diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Commands/AddDocumentCommandHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Commands/AddDocumentCommandHandlerTests.cs index ab9dfc748..7913ad331 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Commands/AddDocumentCommandHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Commands/AddDocumentCommandHandlerTests.cs @@ -34,7 +34,9 @@ public async Task HandleAsync_WithValidCommand_ShouldReturnSuccessResult() var command = new AddDocumentCommand( ProviderId: providerId, DocumentNumber: "11144477735", - DocumentType: EDocumentType.CPF + DocumentType: EDocumentType.CPF, + FileName: "doc.pdf", + FileUrl: "https://storage/doc.pdf" ); _providerRepositoryMock @@ -52,6 +54,10 @@ public async Task HandleAsync_WithValidCommand_ShouldReturnSuccessResult() result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNull(); result.Value!.Id.Should().Be(providerId); + result.Value.Documents.Should().NotBeEmpty(); + var doc = result.Value.Documents.First(); + doc.FileName.Should().Be("doc.pdf"); + doc.FileUrl.Should().Be("https://storage/doc.pdf"); _providerRepositoryMock.Verify( r => r.GetByIdAsync(It.IsAny(), It.IsAny()), @@ -82,7 +88,7 @@ public async Task HandleAsync_WhenProviderNotFound_ShouldReturnFailureResult() // Assert result.IsFailure.Should().BeTrue(); - result.Error.Message.Should().Contain("Provider not found"); + result.Error.Message.Should().Contain("Fornecedor não encontrado"); _providerRepositoryMock.Verify( r => r.GetByIdAsync(It.IsAny(), It.IsAny()), @@ -117,7 +123,7 @@ public async Task HandleAsync_WithInvalidDocumentNumber_ShouldReturnFailureResul // Assert result.IsFailure.Should().BeTrue(); - result.Error.Message.Should().Contain("An error occurred while adding the document"); + result.Error.Message.Should().Contain("Ocorreu um erro ao adicionar o documento"); _providerRepositoryMock.Verify( r => r.GetByIdAsync(It.IsAny(), It.IsAny()), @@ -148,7 +154,7 @@ public async Task HandleAsync_WhenRepositoryThrowsException_ShouldReturnFailureR // Assert result.IsFailure.Should().BeTrue(); - result.Error.Message.Should().Contain("An error occurred while adding the document"); + result.Error.Message.Should().Contain("Ocorreu um erro ao adicionar o documento"); _providerRepositoryMock.Verify( r => r.UpdateAsync(It.IsAny(), It.IsAny()), @@ -179,7 +185,7 @@ public async Task HandleAsync_WhenDuplicateDocumentType_ShouldReturnFailureResul // Assert result.IsFailure.Should().BeTrue(); - result.Error.Message.Should().Contain("An error occurred while adding the document"); + result.Error.Message.Should().Contain("Ocorreu um erro ao adicionar o documento"); _providerRepositoryMock.Verify( r => r.GetByIdAsync(It.IsAny(), It.IsAny()), diff --git a/src/Modules/Providers/Tests/Unit/Application/Handlers/Commands/UpdateProviderProfileCommandHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Handlers/Commands/UpdateProviderProfileCommandHandlerTests.cs index f2e2611af..80a806162 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Handlers/Commands/UpdateProviderProfileCommandHandlerTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Handlers/Commands/UpdateProviderProfileCommandHandlerTests.cs @@ -58,6 +58,7 @@ public async Task HandleAsync_WithValidCommand_ShouldReturnSuccessResult() ProviderId: providerId, Name: "Prestador Atualizado", BusinessProfile: businessProfileDto, + Services: null, UpdatedBy: updatedBy.ToString() ); @@ -119,6 +120,7 @@ public async Task HandleAsync_WhenProviderNotFound_ShouldReturnFailureResult() ProviderId: providerId, Name: "Prestador Atualizado", BusinessProfile: businessProfileDto, + Services: null, UpdatedBy: updatedBy.ToString() ); @@ -177,6 +179,7 @@ public async Task HandleAsync_WithInvalidName_ShouldReturnFailureResult(string i ProviderId: providerId, Name: invalidName, BusinessProfile: businessProfileDto, + Services: null, UpdatedBy: updatedBy.ToString() ); @@ -232,6 +235,7 @@ public async Task HandleAsync_WhenRepositoryThrowsException_ShouldReturnFailureR ProviderId: providerId, Name: "Prestador Atualizado", BusinessProfile: businessProfileDto, + Services: null, UpdatedBy: updatedBy.ToString() ); @@ -250,4 +254,61 @@ public async Task HandleAsync_WhenRepositoryThrowsException_ShouldReturnFailureR r => r.UpdateAsync(It.IsAny(), It.IsAny()), Times.Never); } + + [Fact] + public async Task HandleAsync_WithServices_ShouldUpdateServices() + { + // Arrange + var providerId = Guid.NewGuid(); + var updatedBy = Guid.NewGuid(); + Provider provider = ProviderBuilder.Create().WithId(providerId); + + var businessProfileDto = new BusinessProfileDto( + LegalName: "Prestador", + FantasyName: "Prestador", + Description: "Descrição", + ContactInfo: new ContactInfoDto("test@test.com", "(11) 99999-9999", null), + PrimaryAddress: new AddressDto("Rua", "123", null, "Bairro", "Cidade", "SP", "00000-000", "Brasil") + ); + + var servicesList = new List + { + new ProviderServiceDto(Guid.NewGuid(), "Service A"), + new ProviderServiceDto(Guid.NewGuid(), "Service B") + }; + + var command = new UpdateProviderProfileCommand( + ProviderId: providerId, + Name: "Prestador Atualizado", + BusinessProfile: businessProfileDto, + Services: servicesList, + UpdatedBy: updatedBy.ToString() + ); + + _providerRepositoryMock + .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(provider); + + _providerRepositoryMock + .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + provider.Services.Should().HaveCount(2); + provider.Services.Select(s => s.ServiceName).Should().Contain(new[] { "Service A", "Service B" }); + provider.Services.Select(s => s.ServiceId).Should().Contain(servicesList.Select(s => s.ServiceId)); + + _providerRepositoryMock.Verify( + r => r.GetByIdAsync(It.IsAny(), It.IsAny()), + Times.Once); + + _providerRepositoryMock.Verify( + r => r.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Once); + } } diff --git a/src/Modules/Providers/Tests/Unit/Application/Mappers/ProviderMapperTests.cs b/src/Modules/Providers/Tests/Unit/Application/Mappers/ProviderMapperTests.cs index 86bc3085d..d2bdf90c9 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Mappers/ProviderMapperTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Mappers/ProviderMapperTests.cs @@ -2,8 +2,8 @@ using MeAjudaAi.Modules.Providers.Application.DTOs; using MeAjudaAi.Modules.Providers.Application.Mappers; using MeAjudaAi.Modules.Providers.Domain.Entities; -using MeAjudaAi.Modules.Providers.Domain.Enums; using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Modules.Providers.Domain.Enums; using MeAjudaAi.Modules.Providers.Tests.Builders; using Xunit; @@ -46,6 +46,7 @@ public void ToDto_WithCompleteProvider_ShouldMapAllProperties() dto.DeletedAt.Should().Be(provider.DeletedAt); dto.SuspensionReason.Should().Be(provider.SuspensionReason); dto.RejectionReason.Should().Be(provider.RejectionReason); + dto.Tier.Should().Be(provider.Tier); // Assert nested BusinessProfile mapping dto.BusinessProfile.Should().NotBeNull(); @@ -57,6 +58,21 @@ public void ToDto_WithCompleteProvider_ShouldMapAllProperties() dto.BusinessProfile.PrimaryAddress!.City.Should().Be("São Paulo"); } + [Fact] + public void ToDto_WithNonStandardTier_ShouldMapTierCorrectly() + { + // Arrange + var provider = ProviderBuilder.Create() + .WithTier(EProviderTier.Gold) + .Build(); + + // Act + var dto = provider.ToDto(); + + // Assert + dto.Tier.Should().Be(EProviderTier.Gold); + } + [Fact] public void ToDto_WithBusinessProfile_ShouldMapNestedProperties() { @@ -121,7 +137,7 @@ public void ToDto_WithAddress_ShouldMapAllFields() public void ToDto_WithDocument_ShouldMapAllFields() { // Arrange - var document = new Document("12345678900", EDocumentType.CPF, isPrimary: true); + var document = new Document("12345678900", EDocumentType.CPF, "doc.pdf", "https://storage/doc.pdf", isPrimary: true); // Act var dto = document.ToDto(); @@ -130,6 +146,8 @@ public void ToDto_WithDocument_ShouldMapAllFields() dto.Should().NotBeNull(); dto.Number.Should().Be("12345678900"); dto.DocumentType.Should().Be(EDocumentType.CPF); + dto.FileName.Should().Be("doc.pdf"); + dto.FileUrl.Should().Be("https://storage/doc.pdf"); dto.IsPrimary.Should().BeTrue(); } @@ -231,7 +249,13 @@ public void ToDomain_WithBusinessProfileDto_ShouldMapAllProperties() public void ToDomain_WithDocumentDto_ShouldMapAllProperties() { // Arrange - var dto = new DocumentDto("12345678900", EDocumentType.CPF, true); + var dto = new DocumentDto( + Number: "12345678900", + DocumentType: EDocumentType.CPF, + FileName: "doc.pdf", + FileUrl: "https://storage/doc.pdf", + IsPrimary: true + ); // Act var document = dto.ToDomain(); @@ -240,6 +264,8 @@ public void ToDomain_WithDocumentDto_ShouldMapAllProperties() document.Should().NotBeNull(); document.Number.Should().Be("12345678900"); document.DocumentType.Should().Be(EDocumentType.CPF); + document.FileName.Should().Be("doc.pdf"); + document.FileUrl.Should().Be("https://storage/doc.pdf"); document.IsPrimary.Should().BeTrue(); } @@ -303,8 +329,8 @@ public void ToDto_WithProviderWithDocuments_ShouldMapDocuments() { // Arrange var provider = ProviderBuilder.Create() - .WithDocument("12345678900", EDocumentType.CPF) - .WithDocument("12345678000100", EDocumentType.CNPJ) + .WithDocument("12345678900", EDocumentType.CPF, "doc1.pdf", "https://storage/doc1.pdf") + .WithDocument("12345678000100", EDocumentType.CNPJ, "doc2.pdf", "https://storage/doc2.pdf") .Build(); // Act @@ -312,8 +338,16 @@ public void ToDto_WithProviderWithDocuments_ShouldMapDocuments() // Assert dto.Documents.Should().HaveCount(2); - dto.Documents.Should().Contain(d => d.DocumentType == EDocumentType.CPF); - dto.Documents.Should().Contain(d => d.DocumentType == EDocumentType.CNPJ); + + var cpfDoc = dto.Documents.Single(d => d.DocumentType == EDocumentType.CPF); + cpfDoc.Number.Should().Be("12345678900"); + cpfDoc.FileName.Should().Be("doc1.pdf"); + cpfDoc.FileUrl.Should().Be("https://storage/doc1.pdf"); + + var cnpjDoc = dto.Documents.Single(d => d.DocumentType == EDocumentType.CNPJ); + cnpjDoc.Number.Should().Be("12345678000100"); + cnpjDoc.FileName.Should().Be("doc2.pdf"); + cnpjDoc.FileUrl.Should().Be("https://storage/doc2.pdf"); } [Fact] diff --git a/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs b/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs index 677883949..b86798442 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs @@ -240,11 +240,14 @@ private static ProviderDto CreateTestProviderDto(Guid id) ), Status: EProviderStatus.PendingBasicInfo, VerificationStatus: EVerificationStatus.Pending, + Tier: EProviderTier.Standard, Documents: new List { new DocumentDto( Number: "12345678901", DocumentType: EDocumentType.CPF, + FileName: "cpf.pdf", + FileUrl: "https://storage.blob.core.windows.net/docs/cpf.pdf", IsPrimary: true ) }, diff --git a/src/Modules/Providers/Tests/Unit/Application/Validators/AddDocumentRequestValidatorTests.cs b/src/Modules/Providers/Tests/Unit/Application/Validators/AddDocumentRequestValidatorTests.cs index 054e2ebdf..6d34e8dfd 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Validators/AddDocumentRequestValidatorTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Validators/AddDocumentRequestValidatorTests.cs @@ -1,7 +1,9 @@ +using FluentAssertions; using FluentValidation.TestHelper; using MeAjudaAi.Modules.Providers.Application.DTOs.Requests; using MeAjudaAi.Modules.Providers.Application.Validators; using MeAjudaAi.Modules.Providers.Domain.Enums; +using Xunit; namespace MeAjudaAi.Modules.Providers.Tests.Unit.Application.Validators; @@ -18,11 +20,7 @@ public AddDocumentRequestValidatorTests() public async Task Validate_WithValidRequest_ShouldNotHaveValidationErrors() { // Arrange - var request = new AddDocumentRequest - { - Number = "ABC123456", - DocumentType = EDocumentType.CPF - }; + var request = new AddDocumentRequest("ABC123456", EDocumentType.CPF); // Act var result = await _validator.TestValidateAsync(request); @@ -38,38 +36,30 @@ public async Task Validate_WithValidRequest_ShouldNotHaveValidationErrors() public async Task Validate_WithEmptyNumber_ShouldHaveValidationError(string? number) { // Arrange -#pragma warning disable CS8601 // Possible null reference assignment - intentional for test - var request = new AddDocumentRequest - { - Number = number, - DocumentType = EDocumentType.CPF - }; -#pragma warning restore CS8601 +#pragma warning disable CS8604 // Possible null reference argument - intentional for test + var request = new AddDocumentRequest(number, EDocumentType.CPF); +#pragma warning restore CS8604 // Act var result = await _validator.TestValidateAsync(request); // Assert result.ShouldHaveValidationErrorFor(x => x.Number) - .WithErrorMessage("Document number is required"); + .WithErrorMessage("Número do documento é obrigatório"); } [Fact] public async Task Validate_WithNumberLessThan3Characters_ShouldHaveValidationError() { // Arrange - var request = new AddDocumentRequest - { - Number = "AB", - DocumentType = EDocumentType.CPF - }; + var request = new AddDocumentRequest("AB", EDocumentType.CPF); // Act var result = await _validator.TestValidateAsync(request); // Assert result.ShouldHaveValidationErrorFor(x => x.Number) - .WithErrorMessage("Document number must be at least 3 characters long"); + .WithErrorMessage("Número do documento deve ter pelo menos 3 caracteres"); } [Fact] @@ -77,18 +67,14 @@ public async Task Validate_WithNumberExceeding50Characters_ShouldHaveValidationE { // Arrange var longNumber = new string('A', 51); - var request = new AddDocumentRequest - { - Number = longNumber, - DocumentType = EDocumentType.CPF - }; + var request = new AddDocumentRequest(longNumber, EDocumentType.CPF); // Act var result = await _validator.TestValidateAsync(request); // Assert result.ShouldHaveValidationErrorFor(x => x.Number) - .WithErrorMessage("Document number cannot exceed 50 characters"); + .WithErrorMessage("Número do documento não pode exceder 50 caracteres"); } [Theory] @@ -99,18 +85,14 @@ public async Task Validate_WithNumberExceeding50Characters_ShouldHaveValidationE public async Task Validate_WithInvalidCharactersInNumber_ShouldHaveValidationError(string number) { // Arrange - var request = new AddDocumentRequest - { - Number = number, - DocumentType = EDocumentType.CPF - }; + var request = new AddDocumentRequest(number, EDocumentType.CPF); // Act var result = await _validator.TestValidateAsync(request); // Assert result.ShouldHaveValidationErrorFor(x => x.Number) - .WithErrorMessage("Document number can only contain letters, numbers, hyphens and dots"); + .WithErrorMessage("Número do documento deve conter apenas letras, números, hífens e pontos"); } [Theory] @@ -122,11 +104,7 @@ public async Task Validate_WithInvalidCharactersInNumber_ShouldHaveValidationErr public async Task Validate_WithValidNumberFormats_ShouldNotHaveValidationErrors(string number) { // Arrange - var request = new AddDocumentRequest - { - Number = number, - DocumentType = EDocumentType.CPF - }; + var request = new AddDocumentRequest(number, EDocumentType.CPF); // Act var result = await _validator.TestValidateAsync(request); @@ -139,17 +117,13 @@ public async Task Validate_WithValidNumberFormats_ShouldNotHaveValidationErrors( public async Task Validate_WithInvalidDocumentType_ShouldHaveValidationError() { // Arrange - var request = new AddDocumentRequest - { - Number = "ABC123456", - DocumentType = (EDocumentType)999 - }; + var request = new AddDocumentRequest("ABC123456", (EDocumentType)999); // Act var result = await _validator.TestValidateAsync(request); // Assert result.ShouldHaveValidationErrorFor(x => x.DocumentType) - .WithErrorMessage("DocumentType must be a valid document type. Valid EDocumentType values: None, CPF, CNPJ, RG, CNH, Passport, Other"); + .WithErrorMessage("Tipo de documento inválido. Valores aceitos: None, CPF, CNPJ, RG, CNH, Passport, Other"); } } diff --git a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs index d1c38abc0..1ccf3bb61 100644 --- a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs +++ b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs @@ -790,6 +790,66 @@ public void RequireBasicInfoCorrection_WithInvalidReason_ShouldThrowException(st #endregion + #region Tier Tests + + [Fact] + public void PromoteTier_ShouldUpdateTierAndAddEvent() + { + // Arrange + var provider = CreateValidProvider(); + var initialTier = provider.Tier; + var newTier = EProviderTier.Gold; + var updatedBy = "stripe-webhook"; + + // Act + provider.PromoteTier(newTier, updatedBy); + + // Assert + provider.Tier.Should().Be(newTier); + + var tierEvent = provider.DomainEvents.Last(); + tierEvent.Should().BeOfType(); + + var updatedEvent = (ProviderTierUpdatedDomainEvent)tierEvent; + updatedEvent.PreviousTier.Should().Be(initialTier); + updatedEvent.NewTier.Should().Be(newTier); + updatedEvent.UpdatedBy.Should().Be(updatedBy); + } + + [Fact] + public void PromoteTier_WithSameTier_ShouldDoNothing() + { + // Arrange + var provider = CreateValidProvider(); + var initialTier = provider.Tier; + var initialEventsCount = provider.DomainEvents.Count; + + // Act + provider.PromoteTier(initialTier, "system"); + + // Assert + provider.Tier.Should().Be(initialTier); + provider.DomainEvents.Should().HaveCount(initialEventsCount); + } + + [Fact] + public void PromoteTier_WhenDeleted_ShouldThrowException() + { + // Arrange + var provider = CreateValidProvider(); + var dateTimeProvider = CreateMockDateTimeProvider(); + provider.Delete(dateTimeProvider); + + // Act + var act = () => provider.PromoteTier(EProviderTier.Platinum, "system"); + + // Assert + act.Should().Throw() + .WithMessage("Cannot update tier of deleted provider"); + } + + #endregion + #region ProviderServices Tests [Fact] diff --git a/src/Modules/Providers/Tests/packages.lock.json b/src/Modules/Providers/Tests/packages.lock.json index e60d8456f..8d6279f0c 100644 --- a/src/Modules/Providers/Tests/packages.lock.json +++ b/src/Modules/Providers/Tests/packages.lock.json @@ -1376,6 +1376,7 @@ "Scrutor": "[7.0.0, )", "Testcontainers.Azurite": "[4.10.0, )", "Testcontainers.PostgreSql": "[4.10.0, )", + "Testcontainers.RabbitMq": "[4.10.0, )", "xunit.v3": "[3.2.2, )" } }, @@ -2234,6 +2235,15 @@ "dependencies": { "Testcontainers": "4.10.0" } + }, + "Testcontainers.RabbitMq": { + "type": "CentralTransitive", + "requested": "[4.10.0, )", + "resolved": "4.10.0", + "contentHash": "psuDUrJbqaFeZ6T+kNL9rEiafyPVlQuk5xeHswuiFVMlBaihpobKTlUxWy0k9hLy6xR5Op8kGayLmsb32gDXkA==", + "dependencies": { + "Testcontainers": "4.10.0" + } } } } diff --git a/src/Modules/SearchProviders/Tests/packages.lock.json b/src/Modules/SearchProviders/Tests/packages.lock.json index b6e987f1b..da31b4dca 100644 --- a/src/Modules/SearchProviders/Tests/packages.lock.json +++ b/src/Modules/SearchProviders/Tests/packages.lock.json @@ -1367,6 +1367,7 @@ "Scrutor": "[7.0.0, )", "Testcontainers.Azurite": "[4.10.0, )", "Testcontainers.PostgreSql": "[4.10.0, )", + "Testcontainers.RabbitMq": "[4.10.0, )", "xunit.v3": "[3.2.2, )" } }, @@ -2234,6 +2235,15 @@ "dependencies": { "Testcontainers": "4.10.0" } + }, + "Testcontainers.RabbitMq": { + "type": "CentralTransitive", + "requested": "[4.10.0, )", + "resolved": "4.10.0", + "contentHash": "psuDUrJbqaFeZ6T+kNL9rEiafyPVlQuk5xeHswuiFVMlBaihpobKTlUxWy0k9hLy6xR5Op8kGayLmsb32gDXkA==", + "dependencies": { + "Testcontainers": "4.10.0" + } } } } diff --git a/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetAllServicesEndpoint.cs b/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetAllServicesEndpoint.cs index 4cf2664aa..7dff22438 100644 --- a/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetAllServicesEndpoint.cs +++ b/src/Modules/ServiceCatalogs/API/Endpoints/Service/GetAllServicesEndpoint.cs @@ -33,7 +33,7 @@ public static void Map(IEndpointRouteBuilder app) - Administração do catálogo completo """) .Produces>>(StatusCodes.Status200OK) - .RequirePermission(EPermission.ServiceCatalogsRead); + .AllowAnonymous(); private static async Task GetAllAsync( [AsParameters] GetAllServicesQuery query, diff --git a/src/Modules/ServiceCatalogs/API/Extensions.cs b/src/Modules/ServiceCatalogs/API/Extensions.cs index b235e7912..c771e788c 100644 --- a/src/Modules/ServiceCatalogs/API/Extensions.cs +++ b/src/Modules/ServiceCatalogs/API/Extensions.cs @@ -34,65 +34,8 @@ public static IServiceCollection AddServiceCatalogsModule( /// public static WebApplication UseServiceCatalogsModule(this WebApplication app) { - // Garantir que as migrações estão aplicadas - EnsureDatabaseMigrations(app); - app.MapServiceCatalogsEndpoints(); return app; } - - private static void EnsureDatabaseMigrations(WebApplication app) - { - if (app?.Services == null) return; - - try - { - using var scope = app.Services.CreateScope(); - var logger = scope.ServiceProvider.GetService>(); - var context = scope.ServiceProvider.GetService(); - - if (context == null) - { - logger?.LogWarning("ServiceCatalogsDbContext not found in DI container. Skipping migrations."); - return; - } - - // Em ambiente de teste, pular migrações automáticas - if (app.Environment.IsEnvironment("Test") || app.Environment.IsEnvironment("Testing")) - { - logger?.LogInformation("Skipping ServiceCatalogs migrations in test environment: {Environment}", app.Environment.EnvironmentName); - return; - } - - context.Database.Migrate(); - } - catch (Exception ex) - { - using var scope = app.Services.CreateScope(); - var logger = scope.ServiceProvider.GetService>(); - - // Only fallback to EnsureCreated in Development - if (app.Environment.IsDevelopment()) - { - logger?.LogWarning(ex, "Failed to apply migrations for ServiceCatalogs module. Using EnsureCreated as fallback in Development."); - try - { - var context = scope.ServiceProvider.GetService(); - context?.Database.EnsureCreated(); - } - catch (Exception fallbackEx) - { - logger?.LogError(fallbackEx, "Critical failure initializing ServiceCatalogs module database."); - throw new InvalidOperationException("Falha crítica ao inicializar o banco de dados do módulo ServiceCatalogs após tentativa de fallback.", fallbackEx); - } - } - else - { - // Fail fast in non-development environments - logger?.LogError(ex, "Critical failure applying migrations for ServiceCatalogs module in production environment."); - throw new InvalidOperationException("Falha ao aplicar migrações do módulo ServiceCatalogs em ambiente de produção. Verifique a conexão com o banco de dados.", ex); - } - } - } } diff --git a/src/Modules/ServiceCatalogs/Tests/packages.lock.json b/src/Modules/ServiceCatalogs/Tests/packages.lock.json index b6e987f1b..da31b4dca 100644 --- a/src/Modules/ServiceCatalogs/Tests/packages.lock.json +++ b/src/Modules/ServiceCatalogs/Tests/packages.lock.json @@ -1367,6 +1367,7 @@ "Scrutor": "[7.0.0, )", "Testcontainers.Azurite": "[4.10.0, )", "Testcontainers.PostgreSql": "[4.10.0, )", + "Testcontainers.RabbitMq": "[4.10.0, )", "xunit.v3": "[3.2.2, )" } }, @@ -2234,6 +2235,15 @@ "dependencies": { "Testcontainers": "4.10.0" } + }, + "Testcontainers.RabbitMq": { + "type": "CentralTransitive", + "requested": "[4.10.0, )", + "resolved": "4.10.0", + "contentHash": "psuDUrJbqaFeZ6T+kNL9rEiafyPVlQuk5xeHswuiFVMlBaihpobKTlUxWy0k9hLy6xR5Op8kGayLmsb32gDXkA==", + "dependencies": { + "Testcontainers": "4.10.0" + } } } } diff --git a/src/Modules/Users/API/Endpoints/Public/GetAuthProvidersEndpoint.cs b/src/Modules/Users/API/Endpoints/Public/GetAuthProvidersEndpoint.cs new file mode 100644 index 000000000..78731717c --- /dev/null +++ b/src/Modules/Users/API/Endpoints/Public/GetAuthProvidersEndpoint.cs @@ -0,0 +1,23 @@ +using MeAjudaAi.Contracts.Identity.Enums; +using MeAjudaAi.Shared.Endpoints; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.Users.API.Endpoints.Public; + +public class GetAuthProvidersEndpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapGet("auth/providers", () => + { + var providers = Enum.GetNames(); + return Results.Ok(providers); + }) + .AllowAnonymous() + .WithTags("Auth") + .WithName("GetAuthProviders") + .WithSummary("Lista os provedores de identidade social disponíveis."); + } +} diff --git a/src/Modules/Users/API/Endpoints/Public/RegisterCustomerEndpoint.cs b/src/Modules/Users/API/Endpoints/Public/RegisterCustomerEndpoint.cs new file mode 100644 index 000000000..b4e283457 --- /dev/null +++ b/src/Modules/Users/API/Endpoints/Public/RegisterCustomerEndpoint.cs @@ -0,0 +1,50 @@ +using MeAjudaAi.Modules.Users.Application.Commands; +using MeAjudaAi.Modules.Users.Application.DTOs; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Utilities.Constants; +using MeAjudaAi.Contracts.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.Users.API.Endpoints.Public; + +public class RegisterCustomerEndpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) + { + app.MapPost("register", async ( + RegisterCustomerRequest request, + ICommandDispatcher dispatcher, + CancellationToken cancellationToken) => + { + var command = new RegisterCustomerCommand( + request.Name, + request.Email, + request.Password, + request.PhoneNumber, + request.TermsAccepted, + request.AcceptedPrivacyPolicy + ); + + var result = await dispatcher.SendAsync>(command, cancellationToken); + + return EndpointExtensions.Handle(result); + }) + .WithTags("Users") + .WithSummary("Registers a new customer") + .WithDescription("Creates a new user account with 'customer' role.") + .RequireRateLimiting(RateLimitPolicies.Registration) + .AllowAnonymous(); // Endpoint público + } +} + +public record RegisterCustomerRequest( + string Name, + string Email, + string Password, + string PhoneNumber, + bool TermsAccepted, + bool AcceptedPrivacyPolicy +); diff --git a/src/Modules/Users/API/Endpoints/UsersModuleEndpoints.cs b/src/Modules/Users/API/Endpoints/UsersModuleEndpoints.cs index 996b7909e..eb86fa425 100644 --- a/src/Modules/Users/API/Endpoints/UsersModuleEndpoints.cs +++ b/src/Modules/Users/API/Endpoints/UsersModuleEndpoints.cs @@ -1,4 +1,5 @@ using MeAjudaAi.Modules.Users.API.Endpoints.UserAdmin; +using MeAjudaAi.Modules.Users.API.Endpoints.Public; using MeAjudaAi.Shared.Endpoints; using Microsoft.AspNetCore.Builder; @@ -17,6 +18,8 @@ public static void MapUsersEndpoints(this WebApplication app) .MapEndpoint() .MapEndpoint() .MapEndpoint() - .MapEndpoint(); + .MapEndpoint() + .MapEndpoint() + .MapEndpoint(); } } diff --git a/src/Modules/Users/API/Extensions.cs b/src/Modules/Users/API/Extensions.cs index 3a1da8ca1..d650b5486 100644 --- a/src/Modules/Users/API/Extensions.cs +++ b/src/Modules/Users/API/Extensions.cs @@ -49,53 +49,8 @@ public static async Task AddUsersModuleWithSchemaIsolationAs public static WebApplication UseUsersModule(this WebApplication app) { - // Garantir que as migrações estão aplicadas - EnsureDatabaseMigrations(app); - app.MapUsersEndpoints(); return app; } - - private static void EnsureDatabaseMigrations(WebApplication app) - { - // Só aplica migrações se não estivermos em ambiente de testes unitários - if (app?.Services == null) return; - - // Em ambiente de teste E2E, pular migrações automáticas - elas são gerenciadas pelo TestContainer - if (app.Environment.IsEnvironment("Test") || app.Environment.IsEnvironment("Testing")) - { - return; - } - - try - { - // Criar um escopo para obter o context e aplicar migrações - using var scope = app.Services.CreateScope(); - var context = scope.ServiceProvider.GetService(); - if (context == null) return; - - context.Database.Migrate(); - } - catch (Exception ex) - { - // Em caso de erro, log mas não quebra a aplicação - try - { - using var scope = app.Services.CreateScope(); - var logger = scope.ServiceProvider.GetService>(); - logger?.LogWarning(ex, "Failed to apply migrations for Users module. Using EnsureCreated as fallback."); - - var context = scope.ServiceProvider.GetService(); - if (context != null) - { - context.Database.EnsureCreated(); - } - } - catch - { - // Se ainda falhar, ignora silenciosamente para não quebrar testes unitários - } - } - } } diff --git a/src/Modules/Users/Application/Commands/RegisterCustomerCommand.cs b/src/Modules/Users/Application/Commands/RegisterCustomerCommand.cs new file mode 100644 index 000000000..aab145a61 --- /dev/null +++ b/src/Modules/Users/Application/Commands/RegisterCustomerCommand.cs @@ -0,0 +1,14 @@ +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Modules.Users.Application.DTOs; +using MeAjudaAi.Shared.Commands; + +namespace MeAjudaAi.Modules.Users.Application.Commands; + +public sealed record RegisterCustomerCommand( + string Name, + string Email, + string Password, + string PhoneNumber, + bool TermsAccepted, + bool AcceptedPrivacyPolicy +) : Command>; diff --git a/src/Modules/Users/Application/Extensions.cs b/src/Modules/Users/Application/Extensions.cs index a0db6d97c..f130d32cf 100644 --- a/src/Modules/Users/Application/Extensions.cs +++ b/src/Modules/Users/Application/Extensions.cs @@ -38,6 +38,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddScoped, DeleteUserCommandHandler>(); services.AddScoped>, ChangeUserEmailCommandHandler>(); services.AddScoped>, ChangeUserUsernameCommandHandler>(); + services.AddScoped>, RegisterCustomerCommandHandler>(); // Cache Services específicos do módulo services.AddScoped(); diff --git a/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs new file mode 100644 index 000000000..e91cd10cd --- /dev/null +++ b/src/Modules/Users/Application/Handlers/Commands/RegisterCustomerCommandHandler.cs @@ -0,0 +1,173 @@ +using System.Text.RegularExpressions; +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Shared.Utilities; +using MeAjudaAi.Shared.Utilities.Constants; +using MeAjudaAi.Modules.Users.Application.Commands; +using MeAjudaAi.Modules.Users.Application.DTOs; +using MeAjudaAi.Modules.Users.Application.Mappers; +using MeAjudaAi.Modules.Users.Domain.Repositories; +using MeAjudaAi.Modules.Users.Domain.Services; +using MeAjudaAi.Modules.Users.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Users.Application.Handlers.Commands; + +public sealed partial class RegisterCustomerCommandHandler( + IUserDomainService userDomainService, + IUserRepository userRepository, + ILogger logger +) : ICommandHandler> +{ + [GeneratedRegex(@"[^a-zA-Z0-9._\-]")] + private static partial Regex SanitizationRegex(); + + public async Task> HandleAsync(RegisterCustomerCommand command, CancellationToken cancellationToken = default) + { + if (!command.TermsAccepted) + { + return Result.Failure(Error.BadRequest("Você deve aceitar os termos de uso para se cadastrar.")); + } + + if (!command.AcceptedPrivacyPolicy) + { + return Result.Failure(Error.BadRequest("Você deve aceitar a política de privacidade para se cadastrar.")); + } + + Email emailAsValueObject; + Username validUsername; + + try + { + emailAsValueObject = new Email(command.Email); + + var fullLocalPart = emailAsValueObject.Value.Split('@')[0]; + var noTagLocalPart = fullLocalPart.Split('+')[0]; + var sanitizedLocalPart = SanitizationRegex().Replace(noTagLocalPart, ""); + + if (string.IsNullOrWhiteSpace(sanitizedLocalPart) || sanitizedLocalPart.Length < 3) + { + sanitizedLocalPart = $"usr{Guid.NewGuid().ToString("N").Substring(0, 5)}"; + } + + // UsernameMaxLength é 30 em ValidationConstants; deduz 1 para '_' e 6 para GUID => localPartMax = UsernameMaxLength - 7 + int maxLocalPartLength = ValidationConstants.UserLimits.UsernameMaxLength - 7; + if (sanitizedLocalPart.Length > maxLocalPartLength) + { + sanitizedLocalPart = sanitizedLocalPart.Substring(0, maxLocalPartLength); + } + + var slug = $"{sanitizedLocalPart}_{Guid.NewGuid().ToString("N").Substring(0, 6)}"; + validUsername = new Username(slug); + } + catch (ArgumentException ex) + { + return Result.Failure(Error.BadRequest(ex.Message)); + } + + var emailParts = command.Email.Split('@'); + var maskedEmail = emailParts.Length == 2 + ? $"{new string('*', Math.Min(3, emailParts[0].Length))}@{emailParts[1]}" + : "***@***"; + + // Valida unicidade primeiro + var existingEmail = await userRepository.GetByEmailAsync(emailAsValueObject, cancellationToken); + if (existingEmail is not null) + { + return Result.Failure(Error.Conflict("Este email já está em uso.")); + } + + // Cria usuário com papel de "cliente" + var names = command.Name.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var firstName = names.FirstOrDefault() ?? command.Name; + + if (firstName.Length < ValidationConstants.UserLimits.FirstNameMinLength) + { + return Result.Failure(Error.BadRequest($"O primeiro nome deve ter pelo menos {ValidationConstants.UserLimits.FirstNameMinLength} caracteres.")); + } + + if (names.Length < 2 || string.IsNullOrWhiteSpace(names[1])) + { + return Result.Failure(Error.BadRequest($"O sobrenome é obrigatório e deve ter pelo menos {ValidationConstants.UserLimits.LastNameMinLength} caracteres.")); + } + + var lastName = names[1]; + if (lastName.Length < ValidationConstants.UserLimits.LastNameMinLength) + { + return Result.Failure(Error.BadRequest($"O sobrenome deve ter pelo menos {ValidationConstants.UserLimits.LastNameMinLength} caracteres.")); + } + + var userResult = await userDomainService.CreateUserAsync( + validUsername, + emailAsValueObject, + firstName, + lastName, + command.Password, + new[] { UserRoles.Customer }, // papel de cliente + command.PhoneNumber, + cancellationToken + ); + + if (userResult.IsFailure) + { + logger.LogWarning("Failed to register customer {Email}: {Error}", maskedEmail, userResult.Error); + return Result.Failure(userResult.Error); + } + + try + { + await userRepository.AddAsync(userResult.Value, cancellationToken); + } + catch (Exception ex) + { + if (ex is OperationCanceledException) + { + logger.LogWarning("RegisterCustomerCommand was canceled during repository persistence. Starting compensation."); + } + else + { + logger.LogError(ex, "Failed to persist customer {Email} ({Id}) to repository. Attempting Keycloak compensation.", + maskedEmail, userResult.Value.Id); + } + + // Verifica se o usuário realmente não foi salvo no repositório antes da compensação + // Usamos CancellationToken.None para garantir que a compensação ocorra mesmo se o request original foi cancelado + var persistenceCheck = await userRepository.GetByIdNoTrackingAsync(userResult.Value.Id, CancellationToken.None); + if (persistenceCheck == null) + { + // Compensação: desativar o usuário criado no Keycloak para evitar usuário órfão "fantasma" que pode logar mas não tem dados locais + try + { + var compensationResult = await userDomainService.DeactivateUserInKeycloakAsync(userResult.Value.Id, CancellationToken.None); + if (compensationResult.IsFailure) + { + logger.LogError("Compensation failed for user {UserId}: {Error}", userResult.Value.Id, compensationResult.Error); + } + else + { + logger.LogInformation("Keycloak user {UserId} deactivated successfully as compensation.", userResult.Value.Id); + } + } + catch (Exception compensationEx) + { + logger.LogCritical(compensationEx, + "CRITICAL: Failed to compensate Keycloak user {UserId} after repository failure. Manual cleanup required.", + userResult.Value.Id); + } + } + else + { + logger.LogWarning("Repository write failure reported but user {UserId} was found in DB. Skipping Keycloak compensation.", userResult.Value.Id); + } + + if (ex is OperationCanceledException) + throw; + + return Result.Failure(Error.Internal("Falha ao salvar o cadastro. Tente novamente mais tarde.")); + } + + logger.LogInformation("Customer registered successfully: {Email} ({Id})", maskedEmail, userResult.Value.Id); + + return Result.Success(userResult.Value.ToDto()); + } +} diff --git a/src/Modules/Users/Application/Validators/RegisterCustomerCommandValidator.cs b/src/Modules/Users/Application/Validators/RegisterCustomerCommandValidator.cs new file mode 100644 index 000000000..515e47c63 --- /dev/null +++ b/src/Modules/Users/Application/Validators/RegisterCustomerCommandValidator.cs @@ -0,0 +1,41 @@ +using FluentValidation; +using MeAjudaAi.Modules.Users.Application.Commands; +using MeAjudaAi.Shared.Utilities.Constants; + +namespace MeAjudaAi.Modules.Users.Application.Validators; + +public class RegisterCustomerCommandValidator : AbstractValidator +{ + public RegisterCustomerCommandValidator() + { + RuleFor(x => x.Name) + .NotEmpty().WithMessage("Nome é obrigatório") + .Must(name => !string.IsNullOrWhiteSpace(name) && System.Text.RegularExpressions.Regex.IsMatch(name, @"\p{L}")) + .WithMessage("Nome deve conter pelo menos uma letra e não pode ser apenas espaços") + .MinimumLength(ValidationConstants.UserLimits.FirstNameMinLength + ValidationConstants.UserLimits.LastNameMinLength).WithMessage($"Nome deve ter pelo menos {ValidationConstants.UserLimits.FirstNameMinLength + ValidationConstants.UserLimits.LastNameMinLength} caracteres") + .MaximumLength(ValidationConstants.UserLimits.FirstNameMaxLength + ValidationConstants.UserLimits.LastNameMaxLength).WithMessage($"Nome deve ter no máximo {ValidationConstants.UserLimits.FirstNameMaxLength + ValidationConstants.UserLimits.LastNameMaxLength} caracteres") + .Matches(ValidationConstants.Patterns.Name).WithMessage("Nome deve conter apenas letras e espaços"); + + RuleFor(x => x.Email) + .NotEmpty().WithMessage("Email é obrigatório") + .EmailAddress().WithMessage("Email inválido") + .MaximumLength(ValidationConstants.UserLimits.EmailMaxLength).WithMessage($"Email deve ter no máximo {ValidationConstants.UserLimits.EmailMaxLength} caracteres"); + + RuleFor(x => x.PhoneNumber) + .MaximumLength(ValidationConstants.UserLimits.PhoneNumberMaxLength).WithMessage($"Telefone deve ter no máximo {ValidationConstants.UserLimits.PhoneNumberMaxLength} caracteres") + .When(x => !string.IsNullOrWhiteSpace(x.PhoneNumber)); + + RuleFor(x => x.Password) + .NotEmpty().WithMessage("Senha é obrigatória"); + + RuleFor(x => x.Password) + .Matches(ValidationConstants.Patterns.Password).WithMessage("Senha deve ter pelo menos 8 caracteres, uma letra maiúscula, uma minúscula e um número") + .When(x => !string.IsNullOrEmpty(x.Password)); + + RuleFor(x => x.TermsAccepted) + .Equal(true).WithMessage("Você deve aceitar os termos de uso"); + + RuleFor(x => x.AcceptedPrivacyPolicy) + .Equal(true).WithMessage("Você deve aceitar a política de privacidade"); + } +} diff --git a/src/Modules/Users/Domain/Entities/User.cs b/src/Modules/Users/Domain/Entities/User.cs index 7141b6e8b..7d4d42d9e 100644 --- a/src/Modules/Users/Domain/Entities/User.cs +++ b/src/Modules/Users/Domain/Entities/User.cs @@ -98,6 +98,29 @@ public sealed class User : AggregateRoot /// private User() { } + /// + /// Instancia um novo usuário já com o ID formatado. Usado internamente pelo Factory Method . + /// + private User(UserId id, Username username, Email email, string firstName, string lastName, string keycloakId, string? phoneNumber = null) + : base(id) + { + ArgumentNullException.ThrowIfNull(username); + ArgumentNullException.ThrowIfNull(email); + + Username = username; + Email = email; + FirstName = firstName; + LastName = lastName; + KeycloakId = keycloakId; + + if (!string.IsNullOrWhiteSpace(phoneNumber)) + { + PhoneNumber = new PhoneNumber(phoneNumber); + } + + AddDomainEvent(new UserRegisteredDomainEvent(Id.Value, 1, email.Value, username.Value, firstName, lastName)); + } + /// /// Helper interno de testes para definir o Id. Acessível apenas a partir de assemblies de teste. /// @@ -122,41 +145,46 @@ internal void SetUpdatedAtForTesting(DateTime? updatedAt) UpdatedAt = updatedAt; } + /// - /// Cria um novo usuário no sistema. + /// Cria um novo usuário no sistema validando as regras de negócio primeiro. /// /// Nome de usuário único /// Endereço de email único /// Primeiro nome /// Sobrenome - /// ID do usuário no Keycloak + /// ID do usuário no Keycloak (formato UUID) /// Número de telefone (opcional) + /// Result indicando sucesso com a entidade ou falha com erro de domínio. /// - /// Este construtor dispara automaticamente o evento UserRegisteredDomainEvent. + /// Este método valida o formato do Keycloak ID antes de criar a entidade base, prevenindo + /// exceções de formatação prematuras. /// - /// Thrown when business rules are violated - public User(Username username, Email email, string firstName, string lastName, string keycloakId, string? phoneNumber = null) - : base(UserId.New()) + public static MeAjudaAi.Contracts.Functional.Result Create(Username username, Email email, string firstName, string lastName, string keycloakId, string? phoneNumber = null) { - ArgumentNullException.ThrowIfNull(username); - ArgumentNullException.ThrowIfNull(email); - - // Validações de regras de negócio específicas para criação - ValidateUserCreation(keycloakId); + try + { + ValidateUserCreation(keycloakId); + } + catch (UserDomainException ex) + { + return MeAjudaAi.Contracts.Functional.Result.Failure(MeAjudaAi.Contracts.Functional.Error.BadRequest(ex.Message)); + } - Username = username; - Email = email; - FirstName = firstName; - LastName = lastName; - KeycloakId = keycloakId; - - // Define PhoneNumber se fornecido - if (!string.IsNullOrWhiteSpace(phoneNumber)) + if (!Guid.TryParse(keycloakId, out var parsedGuid)) { - PhoneNumber = new PhoneNumber(phoneNumber); + return MeAjudaAi.Contracts.Functional.Result.Failure(MeAjudaAi.Contracts.Functional.Error.BadRequest("O ID do Keycloak deve ser um GUID válido.")); } - AddDomainEvent(new UserRegisteredDomainEvent(Id.Value, 1, email.Value, username.Value, firstName, lastName)); + try + { + var user = new User(new UserId(parsedGuid), username, email, firstName, lastName, keycloakId, phoneNumber); + return MeAjudaAi.Contracts.Functional.Result.Success(user); + } + catch (Exception ex) + { + return MeAjudaAi.Contracts.Functional.Result.Failure(MeAjudaAi.Contracts.Functional.Error.BadRequest(ex.Message)); + } } /// diff --git a/src/Modules/Users/Domain/Repositories/IUserRepository.cs b/src/Modules/Users/Domain/Repositories/IUserRepository.cs index 110e4f316..5b4bd87bb 100644 --- a/src/Modules/Users/Domain/Repositories/IUserRepository.cs +++ b/src/Modules/Users/Domain/Repositories/IUserRepository.cs @@ -13,13 +13,15 @@ namespace MeAjudaAi.Modules.Users.Domain.Repositories; /// public interface IUserRepository { + Task GetByIdAsync(UserId id, CancellationToken cancellationToken = default); + /// - /// Busca um usuário pelo seu identificador único. + /// Busca um usuário pelo seu identificador único sem rastreamento do EF Core (AsNoTracking). /// /// Identificador único do usuário /// Token de cancelamento da operação /// O usuário encontrado ou null se não existir - Task GetByIdAsync(UserId id, CancellationToken cancellationToken = default); + Task GetByIdNoTrackingAsync(UserId id, CancellationToken cancellationToken = default); /// /// Busca um usuário pelo endereço de email. diff --git a/src/Modules/Users/Domain/Services/IUserDomainService.cs b/src/Modules/Users/Domain/Services/IUserDomainService.cs index ade2bfa6f..ce3c52ff6 100644 --- a/src/Modules/Users/Domain/Services/IUserDomainService.cs +++ b/src/Modules/Users/Domain/Services/IUserDomainService.cs @@ -34,4 +34,14 @@ Task> CreateUserAsync( Task SyncUserWithKeycloakAsync( UserId userId, CancellationToken cancellationToken = default); + + /// + /// Desativa o usuário no Keycloak (utilizado para compensação em caso de erro). + /// + /// ID do usuário no sistema local + /// Token de cancelamento + /// Resultado da operação + Task DeactivateUserInKeycloakAsync( + UserId userId, + CancellationToken cancellationToken = default); } diff --git a/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakOptions.cs b/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakOptions.cs index 0f2e53412..db6505e08 100644 --- a/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakOptions.cs +++ b/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakOptions.cs @@ -18,5 +18,9 @@ public class KeycloakOptions public string AuthorityUrl => $"{BaseUrl}/realms/{Realm}"; public string TokenUrl => $"{AuthorityUrl}/protocol/openid-connect/token"; + /// + /// URL do token para autenticação admin (sempre usa o realm master) + /// + public string AdminTokenUrl => $"{BaseUrl}/realms/master/protocol/openid-connect/token"; public string UsersUrl => $"{BaseUrl}/admin/realms/{Realm}/users"; } diff --git a/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs b/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs index d99f2f462..196715b23 100644 --- a/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs +++ b/src/Modules/Users/Infrastructure/Identity/Keycloak/KeycloakService.cs @@ -26,6 +26,7 @@ public class KeycloakService( private readonly KeycloakOptions _options = options; private string? _adminToken; private DateTime _adminTokenExpiry = DateTime.MinValue; + private readonly SemaphoreSlim _adminTokenSemaphore = new(1, 1); // Usando configurações padrão de serialização do projeto private static readonly JsonSerializerOptions JsonOptions = SerializationDefaults.Api; @@ -258,19 +259,23 @@ private async Task> GetAdminTokenAsync(CancellationToken cancella if (!string.IsNullOrEmpty(_adminToken) && _adminTokenExpiry > DateTime.UtcNow.AddMinutes(5)) return Result.Success(_adminToken); + await _adminTokenSemaphore.WaitAsync(cancellationToken); try { + // Verificar novamente + if (!string.IsNullOrEmpty(_adminToken) && _adminTokenExpiry > DateTime.UtcNow.AddMinutes(5)) + return Result.Success(_adminToken); + var tokenRequest = new List> { new("grant_type", "password"), - new("client_id", _options.ClientId), - new("client_secret", _options.ClientSecret), + new("client_id", "admin-cli"), new("username", _options.AdminUsername), new("password", _options.AdminPassword) }; using var content = new FormUrlEncodedContent(tokenRequest); - var response = await httpClient.PostAsync(_options.TokenUrl, content, cancellationToken); + var response = await httpClient.PostAsync(_options.AdminTokenUrl, content, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -295,6 +300,10 @@ private async Task> GetAdminTokenAsync(CancellationToken cancella logger.LogError(ex, "Exception occurred while getting admin token"); return Result.Failure($"Admin token request failed: {ex.Message}"); } + finally + { + _adminTokenSemaphore.Release(); + } } private async Task AssignRolesToUserAsync( diff --git a/src/Modules/Users/Infrastructure/Persistence/Repositories/UserRepository.cs b/src/Modules/Users/Infrastructure/Persistence/Repositories/UserRepository.cs index d5f16978b..d3cdfb8bd 100644 --- a/src/Modules/Users/Infrastructure/Persistence/Repositories/UserRepository.cs +++ b/src/Modules/Users/Infrastructure/Persistence/Repositories/UserRepository.cs @@ -16,6 +16,13 @@ internal sealed class UserRepository(UsersDbContext context, TimeProvider timePr .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); } + public async Task GetByIdNoTrackingAsync(UserId id, CancellationToken cancellationToken = default) + { + return await _context.Users + .AsNoTracking() + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + public async Task GetByEmailAsync(Email email, CancellationToken cancellationToken = default) { return await _context.Users diff --git a/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs b/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs index ad6ddbb78..0f76ff230 100644 --- a/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs +++ b/src/Modules/Users/Infrastructure/Services/KeycloakUserDomainService.cs @@ -3,6 +3,7 @@ using MeAjudaAi.Modules.Users.Domain.ValueObjects; using MeAjudaAi.Modules.Users.Infrastructure.Identity.Keycloak; using MeAjudaAi.Contracts.Functional; +using Microsoft.Extensions.Logging; namespace MeAjudaAi.Modules.Users.Infrastructure.Services; @@ -16,7 +17,10 @@ namespace MeAjudaAi.Modules.Users.Infrastructure.Services; /// entre o domínio local e o sistema de autenticação. /// /// Serviço de integração com Keycloak -internal class KeycloakUserDomainService(IKeycloakService keycloakService) : IUserDomainService +/// Logger para registro de operações e erros +internal class KeycloakUserDomainService( + IKeycloakService keycloakService, + ILogger logger) : IUserDomainService { /// /// Cria um novo usuário com sincronização automática no Keycloak. @@ -59,8 +63,19 @@ public async Task> CreateUserAsync( return Result.Failure(keycloakResult.Error); // Cria a entidade User local com o ID retornado pelo Keycloak - var user = new User(username, email, firstName, lastName, keycloakResult.Value, phoneNumber); - return Result.Success(user); + var userResult = User.Create(username, email, firstName, lastName, keycloakResult.Value, phoneNumber); + if (userResult.IsFailure) + { + var deactivationResult = await keycloakService.DeactivateUserAsync(keycloakResult.Value, CancellationToken.None); + if (deactivationResult.IsFailure) + { + logger.LogWarning("Failed to deactivate Keycloak user {KeycloakId} during compensation for local user creation failure. Error: {Error}", keycloakResult.Value, deactivationResult.Error.Message); + // Silenciar falhas de compensação para evitar mascarar o erro de validação original + } + return Result.Failure(userResult.Error); + } + + return Result.Success(userResult.Value); } /// @@ -83,8 +98,32 @@ public async Task SyncUserWithKeycloakAsync( CancellationToken cancellationToken = default) { // Implementação para sincronização de dados do usuário com Keycloak - // Pode incluir: desativação de usuário, atualização de papéis, etc. + // Por exemplo, garante que o usuário está habilitado await Task.CompletedTask; return Result.Success(); } + + /// + /// Desativa o usuário no Keycloak para compensar falha na persistência local. + /// + /// O identificador do usuário (ID local/Keycloak). + /// Token de cancelamento opcional. + /// Um Result indicando o sucesso ou falha da operação obtido do serviço Keycloak. + public async Task DeactivateUserInKeycloakAsync( + UserId userId, + CancellationToken cancellationToken = default) + { + // O ID do usuário local é o mesmo ID do Keycloak nesta implementação + var keycloakId = userId.Value.ToString(); + logger.LogWarning("Deactivating Keycloak user {UserId} due to local repository failure", keycloakId); + + var result = await keycloakService.DeactivateUserAsync(keycloakId, CancellationToken.None); + if (result.IsFailure) + { + logger.LogWarning("Failed to deactivate Keycloak user {UserId} due to local repository failure. Error: {Error}", keycloakId, result.Error.Message); + // Silenciar falhas de compensação para evitar mascarar o erro de validação original + } + + return result; + } } diff --git a/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs b/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs index 133b44384..7a4bfd926 100644 --- a/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs +++ b/src/Modules/Users/Infrastructure/Services/LocalDevelopment/LocalDevelopmentUserDomainService.cs @@ -29,8 +29,9 @@ public Task> CreateUserAsync( { // Para ambientes sem Keycloak, criar usuário mock com ID simulado // Using UuidGenerator.NewId() for better time-based ordering and performance - var user = new User(username, email, firstName, lastName, $"mock_keycloak_{UuidGenerator.NewId()}", phoneNumber); - return Task.FromResult(Result.Success(user)); + var userResult = User.Create(username, email, firstName, lastName, UuidGenerator.NewId().ToString(), phoneNumber); + if (userResult.IsFailure) return Task.FromResult(Result.Failure(userResult.Error)); + return Task.FromResult(Result.Success(userResult.Value)); } /// @@ -42,4 +43,17 @@ public Task SyncUserWithKeycloakAsync(UserId userId, CancellationToken c // Para ambientes sem Keycloak, simular sincronização bem-sucedida return Task.FromResult(Result.Success()); } + + /// + /// Simula a desativação de um usuário no Keycloak para ambiente de desenvolvimento local. + /// Sempre retorna sucesso. + /// + /// ID do usuário a ser desativado. + /// Token de cancelamento. + /// Resultado simulado (sempre sucesso). + public Task DeactivateUserInKeycloakAsync(UserId userId, CancellationToken cancellationToken = default) + { + // Para ambientes sem Keycloak, simular desativação bem-sucedida + return Task.FromResult(Result.Success()); + } } diff --git a/src/Modules/Users/Tests/Builders/UserBuilder.cs b/src/Modules/Users/Tests/Builders/UserBuilder.cs index d179ed8c1..48308cbbe 100644 --- a/src/Modules/Users/Tests/Builders/UserBuilder.cs +++ b/src/Modules/Users/Tests/Builders/UserBuilder.cs @@ -21,14 +21,21 @@ public UserBuilder() Faker = new Faker() .CustomInstantiator(f => { - var user = new User( + var userResult = User.Create( _username ?? new Username(f.Internet.UserName()), _email ?? new Email(f.Internet.Email()), _firstName ?? f.Name.FirstName(), _lastName ?? f.Name.LastName(), - _keycloakId ?? f.Random.Guid().ToString() + _keycloakId ?? Guid.NewGuid().ToString() ); + if (userResult.IsFailure) + { + throw new InvalidOperationException(userResult.Error.Message); + } + + var user = userResult.Value; + // Se um ID específico foi definido, usa helper interno if (_id.HasValue) { diff --git a/src/Modules/Users/Tests/Infrastructure/Mocks/MockKeycloakService.cs b/src/Modules/Users/Tests/Infrastructure/Mocks/MockKeycloakService.cs index 93450bbc2..234f27196 100644 --- a/src/Modules/Users/Tests/Infrastructure/Mocks/MockKeycloakService.cs +++ b/src/Modules/Users/Tests/Infrastructure/Mocks/MockKeycloakService.cs @@ -19,7 +19,7 @@ public Task> CreateUserAsync( CancellationToken cancellationToken = default) { // Para testes, simular criação bem-sucedida - var keycloakId = $"keycloak_{Guid.NewGuid()}"; + var keycloakId = Guid.NewGuid().ToString(); return Task.FromResult(Result.Success(keycloakId)); } diff --git a/src/Modules/Users/Tests/Infrastructure/Mocks/MockUserDomainService.cs b/src/Modules/Users/Tests/Infrastructure/Mocks/MockUserDomainService.cs index ee5558393..71c7ae70e 100644 --- a/src/Modules/Users/Tests/Infrastructure/Mocks/MockUserDomainService.cs +++ b/src/Modules/Users/Tests/Infrastructure/Mocks/MockUserDomainService.cs @@ -18,8 +18,9 @@ public Task> CreateUserAsync( CancellationToken cancellationToken = default) { // Para testes, criar usuário mock - var user = new User(username, email, firstName, lastName, $"keycloak_{Guid.NewGuid()}", phoneNumber); - return Task.FromResult(Result.Success(user)); + var userResult = User.Create(username, email, firstName, lastName, Guid.NewGuid().ToString(), phoneNumber); + if (userResult.IsFailure) return Task.FromResult(Result.Failure(userResult.Error)); + return Task.FromResult(Result.Success(userResult.Value)); } public Task SyncUserWithKeycloakAsync(UserId userId, CancellationToken cancellationToken = default) @@ -27,4 +28,10 @@ public Task SyncUserWithKeycloakAsync(UserId userId, CancellationToken c // Para testes, simular sincronização bem-sucedida return Task.FromResult(Result.Success()); } + + public Task DeactivateUserInKeycloakAsync(UserId userId, CancellationToken cancellationToken = default) + { + // Para testes, simular desativação bem-sucedida + return Task.FromResult(Result.Success()); + } } diff --git a/src/Modules/Users/Tests/Infrastructure/UsersIntegrationTestBase.cs b/src/Modules/Users/Tests/Infrastructure/UsersIntegrationTestBase.cs index cc8ac1ba0..904345499 100644 --- a/src/Modules/Users/Tests/Infrastructure/UsersIntegrationTestBase.cs +++ b/src/Modules/Users/Tests/Infrastructure/UsersIntegrationTestBase.cs @@ -69,9 +69,9 @@ protected async Task CreateUserAsync( { var usernameVO = new Username(username); var emailVO = new Email(email); - var keycloakId = $"keycloak_{UuidGenerator.NewId()}"; + var keycloakId = UuidGenerator.NewId().ToString(); - var user = new User(usernameVO, emailVO, firstName, lastName, keycloakId); + var user = User.Create(usernameVO, emailVO, firstName, lastName, keycloakId).Value; // Obter contexto var dbContext = GetService(); diff --git a/src/Modules/Users/Tests/Integration/LocalDevelopmentServicesIntegrationTests.cs b/src/Modules/Users/Tests/Integration/LocalDevelopmentServicesIntegrationTests.cs index 30571b412..e4df436c5 100644 --- a/src/Modules/Users/Tests/Integration/LocalDevelopmentServicesIntegrationTests.cs +++ b/src/Modules/Users/Tests/Integration/LocalDevelopmentServicesIntegrationTests.cs @@ -55,7 +55,7 @@ public async Task CreateUserAsync_ShouldCreateUserWithMockKeycloakId() result.Value.Email.Should().Be(email); result.Value.FirstName.Should().Be(firstName); result.Value.LastName.Should().Be(lastName); - result.Value.KeycloakId.Should().StartWith("mock_keycloak_"); + Guid.TryParse(result.Value.KeycloakId, out _).Should().BeTrue(); } [Fact] @@ -79,8 +79,8 @@ public async Task CreateUserAsync_WithDifferentCredentials_ShouldGenerateUniqueK result1.Value!.KeycloakId.Should().NotBe(result2.Value!.KeycloakId); // Verify both use UUID v7 format - var guid1 = Guid.Parse(result1.Value.KeycloakId.Replace("mock_keycloak_", "")); - var guid2 = Guid.Parse(result2.Value.KeycloakId.Replace("mock_keycloak_", "")); + var guid1 = Guid.Parse(result1.Value.KeycloakId); + var guid2 = Guid.Parse(result2.Value.KeycloakId); IsUuidVersion7(guid1).Should().BeTrue("First Keycloak ID should use UUID v7"); IsUuidVersion7(guid2).Should().BeTrue("Second Keycloak ID should use UUID v7"); } diff --git a/src/Modules/Users/Tests/Integration/UserRepositoryIntegrationTests.cs b/src/Modules/Users/Tests/Integration/UserRepositoryIntegrationTests.cs index cf6b596c0..c7dc665d3 100644 --- a/src/Modules/Users/Tests/Integration/UserRepositoryIntegrationTests.cs +++ b/src/Modules/Users/Tests/Integration/UserRepositoryIntegrationTests.cs @@ -33,12 +33,12 @@ private async Task InitializeInternalAsync() public async Task AddAsync_WithValidUser_ShouldPersistUser() { // Arrange + var keycloakId = Guid.NewGuid().ToString(); var user = new UserBuilder() - .WithUsername("testuser") .WithEmail("test@example.com") - .WithFirstName("John") - .WithLastName("Doe") - .WithKeycloakId("keycloak-123") + .WithUsername("testuser") + .WithFullName("John", "Doe") + .WithKeycloakId(keycloakId) .Build(); // Act @@ -51,7 +51,7 @@ public async Task AddAsync_WithValidUser_ShouldPersistUser() savedUser.Email.Value.Should().Be("test@example.com"); savedUser.FirstName.Should().Be("John"); savedUser.LastName.Should().Be("Doe"); - savedUser.KeycloakId.Should().Be("keycloak-123"); + savedUser.KeycloakId.Should().Be(keycloakId); } [Fact] @@ -152,7 +152,7 @@ public async Task GetByUsernameAsync_WithNonExistingUsername_ShouldReturnNull() public async Task GetByKeycloakIdAsync_WithExistingKeycloakId_ShouldReturnUser() { // Arrange - var keycloakId = "keycloak-123"; + var keycloakId = Guid.NewGuid().ToString(); var user = new UserBuilder() .WithKeycloakId(keycloakId) .Build(); diff --git a/src/Modules/Users/Tests/Unit/Application/Mappers/UserMappersTests.cs b/src/Modules/Users/Tests/Unit/Application/Mappers/UserMappersTests.cs index fe9b7c473..3ed31b5d4 100644 --- a/src/Modules/Users/Tests/Unit/Application/Mappers/UserMappersTests.cs +++ b/src/Modules/Users/Tests/Unit/Application/Mappers/UserMappersTests.cs @@ -16,7 +16,7 @@ public void ToDto_WithValidUser_ShouldMapAllProperties() .WithEmail("john.doe@example.com") .WithUsername("johndoe") .WithFullName("John", "Doe") - .WithKeycloakId("keycloak-123") + .WithKeycloakId(Guid.NewGuid().ToString()) .Build(); // Act @@ -43,7 +43,7 @@ public void ToDto_WithUserWithEmptyFirstName_ShouldMapCorrectly() .WithEmail("test@example.com") .WithUsername("testuser") .WithFullName("", "Doe") - .WithKeycloakId("keycloak-456") + .WithKeycloakId(Guid.NewGuid().ToString()) .Build(); // Act @@ -64,7 +64,7 @@ public void ToDto_WithUserWithEmptyLastName_ShouldMapCorrectly() .WithEmail("test@example.com") .WithUsername("testuser") .WithFullName("John", "") - .WithKeycloakId("keycloak-789") + .WithKeycloakId(Guid.NewGuid().ToString()) .Build(); // Act @@ -85,7 +85,7 @@ public void ToDto_WithSpecialCharactersInNames_ShouldMapCorrectly() .WithEmail("jose@example.com") // Email válido sem caracteres especiais .WithUsername("jose_silva") .WithFullName("José Carlos", "da Silva") - .WithKeycloakId("keycloak-special") + .WithKeycloakId(Guid.NewGuid().ToString()) .Build(); // Act @@ -111,7 +111,7 @@ public void ToDto_ShouldPreserveExactTimestamps() .WithEmail("timestamp@example.com") .WithUsername("timestampuser") .WithFullName("Time", "Stamp") - .WithKeycloakId("keycloak-time") + .WithKeycloakId(Guid.NewGuid().ToString()) .WithCreatedAt(createdAt) .WithUpdatedAt(updatedAt) .Build(); diff --git a/src/Modules/Users/Tests/Unit/Application/Queries/GetUserByIdQueryHandlerTests.cs b/src/Modules/Users/Tests/Unit/Application/Queries/GetUserByIdQueryHandlerTests.cs index 7f3f19844..1d74ced87 100644 --- a/src/Modules/Users/Tests/Unit/Application/Queries/GetUserByIdQueryHandlerTests.cs +++ b/src/Modules/Users/Tests/Unit/Application/Queries/GetUserByIdQueryHandlerTests.cs @@ -44,7 +44,7 @@ public async Task HandleAsync_ValidQuery_ShouldReturnUserSuccessfully() "Test", "User", "Test User", - "keycloak-id-123", + Guid.NewGuid().ToString(), DateTime.UtcNow, DateTime.UtcNow ); diff --git a/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs b/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs index 74111f7ae..ad5909d6a 100644 --- a/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs +++ b/src/Modules/Users/Tests/Unit/Application/Queries/GetUsersQueryHandlerTests.cs @@ -239,12 +239,12 @@ private static List CreateTestUsers(int count) private static User CreateTestUser(string username, string email, string firstName, string lastName) { - return new User( + return User.Create( username: new Username(username), email: new Email(email), firstName: firstName, lastName: lastName, keycloakId: Guid.NewGuid().ToString() - ); + ).Value; } } diff --git a/src/Modules/Users/Tests/Unit/Application/Validators/RegisterCustomerCommandValidatorTests.cs b/src/Modules/Users/Tests/Unit/Application/Validators/RegisterCustomerCommandValidatorTests.cs new file mode 100644 index 000000000..f129613c5 --- /dev/null +++ b/src/Modules/Users/Tests/Unit/Application/Validators/RegisterCustomerCommandValidatorTests.cs @@ -0,0 +1,91 @@ +using FluentValidation.TestHelper; +using MeAjudaAi.Modules.Users.Application.Commands; +using MeAjudaAi.Modules.Users.Application.Validators; +using MeAjudaAi.Shared.Utilities.Constants; +using Xunit; + +namespace MeAjudaAi.Modules.Users.Tests.Unit.Application.Validators; + +[Trait("Category", "Unit")] +[Trait("Module", "Users")] +[Trait("Layer", "Application")] +public class RegisterCustomerCommandValidatorTests +{ + private readonly RegisterCustomerCommandValidator _validator = new(); + + [Fact] + public void Should_Have_Error_When_Name_Is_Empty() + { + var command = new RegisterCustomerCommand("", "test@test.com", "Password123!", "123456789", true, true); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(x => x.Name); + } + + [Fact] + public void Should_Have_Error_When_Name_Is_Too_Short() + { + // MinLength é a soma de FirstNameMinLength (2) e LastNameMinLength (2) = 4 + var command = new RegisterCustomerCommand("abc", "test@test.com", "Password123!", "123456789", true, true); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(x => x.Name) + .WithErrorMessage($"Nome deve ter pelo menos {ValidationConstants.UserLimits.FirstNameMinLength + ValidationConstants.UserLimits.LastNameMinLength} caracteres"); + } + + [Fact] + public void Should_Have_Error_When_Name_Contains_Numbers() + { + var command = new RegisterCustomerCommand("Jean 123", "test@test.com", "Password123!", "123456789", true, true); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(x => x.Name) + .WithErrorMessage("Nome deve conter apenas letras e espaços"); + } + + [Fact] + public void Should_Not_Have_Error_When_Name_Is_Valid() + { + var command = new RegisterCustomerCommand("Jean Valjean", "test@test.com", "Password123!", "123456789", true, true); + var result = _validator.TestValidate(command); + result.ShouldNotHaveValidationErrorFor(x => x.Name); + } + + [Fact] + public void Should_Have_Error_When_Email_Is_Invalid() + { + var command = new RegisterCustomerCommand("Jean Valjean", "invalid-email", "Password123!", "123456789", true, true); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(x => x.Email); + } + + [Fact] + public void Should_Have_Error_When_Password_Missing_Uppercase() + { + var command = new RegisterCustomerCommand("Jean Valjean", "test@test.com", "password123!", "123456789", true, true); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(x => x.Password) + .WithErrorMessage("Senha deve ter pelo menos 8 caracteres, uma letra maiúscula, uma minúscula e um número"); + } + + [Fact] + public void Should_Have_Error_When_Password_Missing_Number() + { + var command = new RegisterCustomerCommand("Jean Valjean", "test@test.com", "Password!", "123456789", true, true); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(x => x.Password); + } + + [Fact] + public void Should_Have_Error_When_Terms_Not_Accepted() + { + var command = new RegisterCustomerCommand("Jean Valjean", "test@test.com", "Password123!", "123456789", false, true); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(x => x.TermsAccepted); + } + + [Fact] + public void Should_Have_Error_When_Privacy_Policy_Not_Accepted() + { + var command = new RegisterCustomerCommand("Jean Valjean", "test@test.com", "Password123!", "123456789", true, false); + var result = _validator.TestValidate(command); + result.ShouldHaveValidationErrorFor(x => x.AcceptedPrivacyPolicy); + } +} diff --git a/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs b/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs index bcfeae5b0..4883e68d1 100644 --- a/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs +++ b/src/Modules/Users/Tests/Unit/Domain/Entities/UserTests.cs @@ -25,10 +25,10 @@ public void Constructor_WithValidParameters_ShouldCreateUser() var email = new Email("test@example.com"); var firstName = "John"; var lastName = "Doe"; - var keycloakId = "keycloak-123"; + var keycloakId = Guid.NewGuid().ToString(); // Act - var user = new User(username, email, firstName, lastName, keycloakId); + var user = User.Create(username, email, firstName, lastName, keycloakId).Value; // Assert user.Id.Should().NotBeNull(); @@ -51,10 +51,10 @@ public void Constructor_ShouldRaiseUserRegisteredDomainEvent() var email = new Email("test@example.com"); var firstName = "John"; var lastName = "Doe"; - var keycloakId = "keycloak-123"; + var keycloakId = Guid.NewGuid().ToString(); // Act - var user = new User(username, email, firstName, lastName, keycloakId); + var user = User.Create(username, email, firstName, lastName, keycloakId).Value; // Assert user.DomainEvents.Should().HaveCount(1); @@ -403,12 +403,12 @@ public void CanChangeUsername_WhenSufficientTimeHasPassed_ShouldReturnTrue() // Cria um usuário de teste private static User CreateTestUser(string firstName = "John", string lastName = "Doe") { - return new User( + return User.Create( new Username("testuser"), new Email("test@example.com"), firstName, lastName, - "keycloak-123" - ); + Guid.NewGuid().ToString() + ).Value; } } diff --git a/src/Modules/Users/Tests/Unit/Infrastructure/Events/Handlers/UserRegisteredDomainEventHandlerTests.cs b/src/Modules/Users/Tests/Unit/Infrastructure/Events/Handlers/UserRegisteredDomainEventHandlerTests.cs index cf715fe13..4defe137c 100644 --- a/src/Modules/Users/Tests/Unit/Infrastructure/Events/Handlers/UserRegisteredDomainEventHandlerTests.cs +++ b/src/Modules/Users/Tests/Unit/Infrastructure/Events/Handlers/UserRegisteredDomainEventHandlerTests.cs @@ -180,9 +180,9 @@ await Assert.ThrowsAsync(() => [Fact] public async Task HandleAsync_WithUserWithKeycloakId_ShouldIncludeKeycloakId() { - // Arrange + var keycloakId = Guid.NewGuid().ToString(); var user = new UserBuilder() - .WithKeycloakId("keycloak-123") + .WithKeycloakId(keycloakId) .Build(); await _context.Users.AddAsync(user); @@ -204,7 +204,7 @@ public async Task HandleAsync_WithUserWithKeycloakId_ShouldIncludeKeycloakId() _messageBusMock.Verify( x => x.PublishAsync( It.Is(e => - e.KeycloakId == "keycloak-123" + e.KeycloakId == keycloakId ), It.IsAny(), It.IsAny() diff --git a/src/Modules/Users/Tests/Unit/Infrastructure/Persistence/UserRepositoryTests.cs b/src/Modules/Users/Tests/Unit/Infrastructure/Persistence/UserRepositoryTests.cs index b99078c14..a0c31c467 100644 --- a/src/Modules/Users/Tests/Unit/Infrastructure/Persistence/UserRepositoryTests.cs +++ b/src/Modules/Users/Tests/Unit/Infrastructure/Persistence/UserRepositoryTests.cs @@ -33,7 +33,7 @@ public async Task AddAsync_WithValidUser_ShouldCallRepositoryMethod() .WithEmail("test@example.com") .WithUsername("testuser") .WithFullName("John", "Doe") - .WithKeycloakId("keycloak123") + .WithKeycloakId(Guid.NewGuid().ToString()) .Build(); _mockUserRepository @@ -55,7 +55,7 @@ public async Task GetByIdAsync_WithExistingUser_ShouldReturnUser() .WithEmail("test@example.com") .WithUsername("testuser") .WithFullName("John", "Doe") - .WithKeycloakId("keycloak123") + .WithKeycloakId(Guid.NewGuid().ToString()) .Build(); _mockUserRepository @@ -72,7 +72,7 @@ public async Task GetByIdAsync_WithExistingUser_ShouldReturnUser() result.Username.Value.Should().Be("testuser"); result.FirstName.Should().Be("John"); result.LastName.Should().Be("Doe"); - result.KeycloakId.Should().Be("keycloak123"); + result!.KeycloakId.Should().Be(user.KeycloakId); } [Fact] @@ -101,7 +101,7 @@ public async Task GetByEmailAsync_WithExistingEmail_ShouldReturnUser() .WithEmail(email) .WithUsername("testuser") .WithFullName("John", "Doe") - .WithKeycloakId("keycloak123") + .WithKeycloakId(Guid.NewGuid().ToString()) .Build(); _mockUserRepository @@ -143,7 +143,7 @@ public async Task GetByUsernameAsync_WithExistingUsername_ShouldReturnUser() .WithEmail("test@example.com") .WithUsername(username) .WithFullName("John", "Doe") - .WithKeycloakId("keycloak123") + .WithKeycloakId(Guid.NewGuid().ToString()) .Build(); _mockUserRepository @@ -180,7 +180,7 @@ public async Task GetByUsernameAsync_WithNonExistentUsername_ShouldReturnNull() public async Task GetByKeycloakIdAsync_WithExistingKeycloakId_ShouldReturnUser() { // Arrange - var keycloakId = "keycloak123"; + var keycloakId = Guid.NewGuid().ToString(); var user = new UserBuilder() .WithEmail("test@example.com") .WithUsername("testuser") @@ -229,7 +229,7 @@ public async Task GetPagedAsync_WithValidParameters_ShouldReturnPagedResults() .WithEmail($"user{i}@example.com") .WithUsername($"user{i}") .WithFullName($"User{i}", "Test") - .WithKeycloakId($"keycloak{i}") + .WithKeycloakId(Guid.NewGuid().ToString()) .Build(); users.Add(user); } @@ -298,7 +298,7 @@ public async Task UpdateAsync_WithValidUser_ShouldCallRepositoryMethod() .WithEmail("test@example.com") .WithUsername("testuser") .WithFullName("John", "Doe") - .WithKeycloakId("keycloak123") + .WithKeycloakId(Guid.NewGuid().ToString()) .Build(); _mockUserRepository diff --git a/src/Modules/Users/Tests/Unit/Infrastructure/Services/KeycloakUserDomainServiceTests.cs b/src/Modules/Users/Tests/Unit/Infrastructure/Services/KeycloakUserDomainServiceTests.cs index f268fbc27..dd2969e11 100644 --- a/src/Modules/Users/Tests/Unit/Infrastructure/Services/KeycloakUserDomainServiceTests.cs +++ b/src/Modules/Users/Tests/Unit/Infrastructure/Services/KeycloakUserDomainServiceTests.cs @@ -1,7 +1,10 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; using MeAjudaAi.Modules.Users.Domain.ValueObjects; using MeAjudaAi.Modules.Users.Infrastructure.Identity.Keycloak; using MeAjudaAi.Modules.Users.Infrastructure.Services; using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Modules.Users.Domain.Entities; namespace MeAjudaAi.Modules.Users.Tests.Unit.Infrastructure.Services; @@ -16,9 +19,43 @@ public class KeycloakUserDomainServiceTests public KeycloakUserDomainServiceTests() { _keycloakServiceMock = new Mock(); - _service = new KeycloakUserDomainService(_keycloakServiceMock.Object); + _service = new KeycloakUserDomainService(_keycloakServiceMock.Object, NullLogger.Instance); } + [Fact] + public async Task DeactivateUserInKeycloakAsync_ShouldCallKeycloakServiceDeactivate() + { + // Arrange + var userId = new UserId(Guid.NewGuid()); + _keycloakServiceMock + .Setup(x => x.DeactivateUserAsync(userId.Value.ToString(), It.IsAny())) + .ReturnsAsync(Result.Success()); + + // Act + var result = await _service.DeactivateUserInKeycloakAsync(userId, CancellationToken.None); + + // Assert + Assert.True(result.IsSuccess); + _keycloakServiceMock.Verify(x => x.DeactivateUserAsync(userId.Value.ToString(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeactivateUserInKeycloakAsync_ShouldReturnFailure_WhenKeycloakServiceFails() + { + // Arrange + var userId = new UserId(Guid.NewGuid()); + var errorMessage = "Keycloak error"; + _keycloakServiceMock + .Setup(x => x.DeactivateUserAsync(userId.Value.ToString(), It.IsAny())) + .ReturnsAsync(Result.Failure(errorMessage)); + + // Act + var result = await _service.DeactivateUserInKeycloakAsync(userId, CancellationToken.None); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal(errorMessage, result.Error.Message); + } [Fact] public async Task CreateUserAsync_WhenKeycloakCreationSucceeds_ShouldReturnUserWithKeycloakId() { @@ -29,7 +66,7 @@ public async Task CreateUserAsync_WhenKeycloakCreationSucceeds_ShouldReturnUserW var lastName = "User"; var password = "SecurePassword123!"; var roles = new[] { "User" }; - var keycloakId = "keycloak-id-123"; + var keycloakId = Guid.NewGuid().ToString(); _keycloakServiceMock .Setup(x => x.CreateUserAsync( @@ -112,7 +149,7 @@ public async Task CreateUserAsync_WithValidParameters_ShouldCallKeycloakServiceW var lastName = "User"; var password = "SecurePassword123!"; var roles = new[] { "User", "Customer" }; - var keycloakId = "keycloak-id-123"; + var keycloakId = Guid.NewGuid().ToString(); _keycloakServiceMock .Setup(x => x.CreateUserAsync( @@ -186,7 +223,7 @@ public async Task CreateUserAsync_WithVariousPasswordFormats_ShouldPassToKeycloa var firstName = "Test"; var lastName = "User"; var roles = new[] { "User" }; - var keycloakId = "keycloak-id-123"; + var keycloakId = Guid.NewGuid().ToString(); _keycloakServiceMock .Setup(x => x.CreateUserAsync( @@ -234,7 +271,7 @@ public async Task CreateUserAsync_WithEmptyRoles_ShouldPassEmptyRolesToKeycloak( var lastName = "User"; var password = "SecurePassword123!"; var roles = Array.Empty(); - var keycloakId = "keycloak-id-123"; + var keycloakId = Guid.NewGuid().ToString(); _keycloakServiceMock .Setup(x => x.CreateUserAsync( @@ -282,7 +319,7 @@ public async Task CreateUserAsync_WithCancellationToken_ShouldPassTokenToKeycloa var lastName = "User"; var password = "SecurePassword123!"; var roles = new[] { "User" }; - var keycloakId = "keycloak-id-123"; + var keycloakId = Guid.NewGuid().ToString(); var cancellationToken = new CancellationToken(true); _keycloakServiceMock diff --git a/src/Modules/Users/Tests/Unit/Mocks/Services/MockKeycloakServiceTests.cs b/src/Modules/Users/Tests/Unit/Mocks/Services/MockKeycloakServiceTests.cs index e5633e1f6..0c64deb45 100644 --- a/src/Modules/Users/Tests/Unit/Mocks/Services/MockKeycloakServiceTests.cs +++ b/src/Modules/Users/Tests/Unit/Mocks/Services/MockKeycloakServiceTests.cs @@ -35,7 +35,7 @@ public async Task CreateUserAsync_ShouldReturnSuccessWithKeycloakId() // Assert result.IsSuccess.Should().BeTrue(); result.Value.Should().NotBeNullOrEmpty(); - result.Value.Should().StartWith("keycloak_"); + Guid.TryParse(result.Value, out _).Should().BeTrue(); } [Fact] diff --git a/src/Modules/Users/Tests/packages.lock.json b/src/Modules/Users/Tests/packages.lock.json index b6e987f1b..da31b4dca 100644 --- a/src/Modules/Users/Tests/packages.lock.json +++ b/src/Modules/Users/Tests/packages.lock.json @@ -1367,6 +1367,7 @@ "Scrutor": "[7.0.0, )", "Testcontainers.Azurite": "[4.10.0, )", "Testcontainers.PostgreSql": "[4.10.0, )", + "Testcontainers.RabbitMq": "[4.10.0, )", "xunit.v3": "[3.2.2, )" } }, @@ -2234,6 +2235,15 @@ "dependencies": { "Testcontainers": "4.10.0" } + }, + "Testcontainers.RabbitMq": { + "type": "CentralTransitive", + "requested": "[4.10.0, )", + "resolved": "4.10.0", + "contentHash": "psuDUrJbqaFeZ6T+kNL9rEiafyPVlQuk5xeHswuiFVMlBaihpobKTlUxWy0k9hLy6xR5Op8kGayLmsb32gDXkA==", + "dependencies": { + "Testcontainers": "4.10.0" + } } } } diff --git a/src/Shared/Authorization/Core/EPermission.cs b/src/Shared/Authorization/Core/EPermission.cs index bb272d70a..b2104b84c 100644 --- a/src/Shared/Authorization/Core/EPermission.cs +++ b/src/Shared/Authorization/Core/EPermission.cs @@ -121,6 +121,25 @@ public enum EPermission LocationsRead, [Display(Name = "locations:manage")] - LocationsManage -} + LocationsManage, + + // ===== SELF-REGISTRATION (PUBLIC) ===== + + /// Auto-registro de cliente — endpoint público, sem autenticação. + [Display(Name = "users:register")] + UsersRegister, + + /// Auto-registro de prestador — endpoint público, sem autenticação. + [Display(Name = "providers:register")] + ProvidersRegister, + // ===== PROVIDER SELF-SERVICE (AUTENTICADO) ===== + + /// Upload de documentos pelo próprio prestador. + [Display(Name = "providers:upload-documents")] + ProvidersUploadDocuments, + + /// Gestão de tier pelo sistema (Stripe webhook). + [Display(Name = "providers:manage-tier")] + ProvidersManageTier, +} diff --git a/src/Shared/Utilities/Constants/RateLimitPolicies.cs b/src/Shared/Utilities/Constants/RateLimitPolicies.cs index 2e10bff72..453293bcb 100644 --- a/src/Shared/Utilities/Constants/RateLimitPolicies.cs +++ b/src/Shared/Utilities/Constants/RateLimitPolicies.cs @@ -9,4 +9,14 @@ public static class RateLimitPolicies /// Política para endpoints públicos anonimizados /// public const string Public = "public"; + + /// + /// Política para registro de clientes + /// + public const string Registration = "registration"; + + /// + /// Política para registro de prestadores + /// + public const string ProviderRegistration = "provider-registration"; } diff --git a/src/Shared/Utilities/Constants/ValidationConstants.cs b/src/Shared/Utilities/Constants/ValidationConstants.cs index 591aebd88..a840c46c5 100644 --- a/src/Shared/Utilities/Constants/ValidationConstants.cs +++ b/src/Shared/Utilities/Constants/ValidationConstants.cs @@ -25,6 +25,9 @@ public static class UserLimits // Baseado em: .HasMaxLength(100) nas migrations public const int LastNameMaxLength = 100; + // Baseado em: .HasMaxLength(20) nas migrations (implicit/expected) + public const int PhoneNumberMaxLength = 20; + // Baseado em: .HasMaxLength(50) nas migrations public const int KeycloakIdMaxLength = 50; diff --git a/src/Shared/Utilities/UserRoles.cs b/src/Shared/Utilities/UserRoles.cs index 7663dbd73..18a38594f 100644 --- a/src/Shared/Utilities/UserRoles.cs +++ b/src/Shared/Utilities/UserRoles.cs @@ -5,6 +5,11 @@ namespace MeAjudaAi.Shared.Utilities; /// public static class UserRoles { + /// + /// Super Administrador - acesso irrestrito ao sistema inteiro + /// + public const string SuperAdmin = "super-admin"; + /// /// Administrador com permissões elevadas - acesso total ao Admin Portal /// @@ -40,18 +45,48 @@ public static class UserRoles /// public const string Customer = "customer"; + // ===== PROVIDER TIER ROLES ===== + // Gerenciados automaticamente via webhook Stripe (módulo de pagamentos futuro). + // Todo prestador começa como provider-standard (plano gratuito). + + /// + /// Prestador de serviços no plano gratuito (Standard). + /// Atribuído automaticamente no auto-registro. + /// + public const string ProviderStandard = "provider-standard"; + + /// + /// Prestador de serviços no plano Silver (pago via Stripe). + /// + public const string ProviderSilver = "provider-silver"; + + /// + /// Prestador de serviços no plano Gold (pago via Stripe). + /// + public const string ProviderGold = "provider-gold"; + + /// + /// Prestador de serviços no plano Platinum (pago via Stripe). + /// + public const string ProviderPlatinum = "provider-platinum"; + /// /// Obtém todos os papéis disponíveis no sistema /// public static readonly string[] AllRoles = [ + SuperAdmin, Admin, ProviderManager, DocumentReviewer, CatalogManager, Operator, Viewer, - Customer + Customer, + ProviderStandard, + ProviderSilver, + ProviderGold, + ProviderPlatinum ]; /// @@ -59,6 +94,7 @@ public static class UserRoles /// public static readonly string[] AdminRoles = [ + SuperAdmin, Admin, ProviderManager, DocumentReviewer, @@ -74,6 +110,17 @@ public static class UserRoles Customer ]; + /// + /// Obtém todos os papéis de prestador (qualquer tier) + /// + public static readonly string[] ProviderRoles = + [ + ProviderStandard, + ProviderSilver, + ProviderGold, + ProviderPlatinum + ]; + /// /// Valida se um papel é válido no sistema /// @@ -93,4 +140,12 @@ public static bool IsAdminRole(string role) { return AdminRoles.Contains(role, StringComparer.OrdinalIgnoreCase); } + + /// + /// Valida se um papel é de prestador de serviços (qualquer tier) + /// + public static bool IsProviderRole(string role) + { + return ProviderRoles.Contains(role, StringComparer.OrdinalIgnoreCase); + } } diff --git a/src/Web/MeAjudaAi.Web.Customer/app/(auth)/auth/signin/page.tsx b/src/Web/MeAjudaAi.Web.Customer/app/(auth)/auth/signin/page.tsx index 6722dd8ef..d4548a472 100644 --- a/src/Web/MeAjudaAi.Web.Customer/app/(auth)/auth/signin/page.tsx +++ b/src/Web/MeAjudaAi.Web.Customer/app/(auth)/auth/signin/page.tsx @@ -1,22 +1,32 @@ import { Suspense } from "react" -import { GalleryVerticalEnd } from "lucide-react" import { LoginForm } from "@/components/auth/login-form" -import Link from "next/link" +import { auth } from "@/auth" +import { redirect } from "next/navigation" export const dynamic = "force-dynamic"; -export default function LoginPage() { +export default async function LoginPage() { + const session = await auth() + if (session?.user) { + redirect("/") + } + return ( -
-
- - - Me Ajuda Aí - - Carregando...
}> - - +
+ {/* Header */} +
+

+ Entrar na Me Ajuda Aí +

+

+ Use sua conta para continuar +

+ + {/* Login Form */} + Carregando...
}> + +
) } diff --git a/src/Web/MeAjudaAi.Web.Customer/app/(auth)/cadastro/cliente/page.tsx b/src/Web/MeAjudaAi.Web.Customer/app/(auth)/cadastro/cliente/page.tsx new file mode 100644 index 000000000..7c6847e1e --- /dev/null +++ b/src/Web/MeAjudaAi.Web.Customer/app/(auth)/cadastro/cliente/page.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { CustomerRegisterForm } from "@/components/auth/customer-register-form"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { signIn } from "next-auth/react"; +import { ShieldCheck, Info } from "lucide-react"; +import { useState } from "react"; + +function GoogleIcon({ className }: { className?: string }) { + return ( + + ); +} + +function FacebookIcon({ className }: { className?: string }) { + return ( + + ); +} + +export default function CustomerRegisterPage() { + const [showPrivacyInfo, setShowPrivacyInfo] = useState(false); + + const handleSocialLogin = (provider: string) => { + signIn("keycloak", { callbackUrl: "/" }, { kc_idp_hint: provider }); + }; + + return ( +
+ {/* Header */} +
+

+ Crie sua conta grátis de forma rápida e segura +

+
+ + {/* Registration Form */} + + + {/* Privacy & Security Badge */} +
+ + {showPrivacyInfo && ( +
+
+ +
+

+ Seu número é verificado, mas permanece privado.{" "} + Nenhum outro usuário poderá ver seu telefone. +

+
+ +
+
+ )} +
+ + {/* Divider */} +
+
+ +
+
+ ou +
+
+ + {/* Social Login */} +
+ + +
+ + {/* Already have an account */} +
+ + Já tenho uma conta + +
+ + {/* Passive consent footer */} +

+ Ao criar sua conta, você concorda com nossos{" "} + Termos de Uso{" "} + e{" "} + Política de Privacidade. +

+
+ ); +} diff --git a/src/Web/MeAjudaAi.Web.Customer/app/(auth)/layout.tsx b/src/Web/MeAjudaAi.Web.Customer/app/(auth)/layout.tsx index 22a06a3f1..b58b021f0 100644 --- a/src/Web/MeAjudaAi.Web.Customer/app/(auth)/layout.tsx +++ b/src/Web/MeAjudaAi.Web.Customer/app/(auth)/layout.tsx @@ -1,11 +1,18 @@ +import { Header } from "@/components/layout/header"; +import { Footer } from "@/components/layout/footer"; + export default function AuthLayout({ children, }: { children: React.ReactNode; }) { return ( -
- {children} -
+ <> +
+
+ {children} +
+