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