diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index 2129c6b83..2e9b799b5 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -124,7 +124,7 @@ jobs: - name: Wait for PostgreSQL shell: bash run: | - max_attempts=30 + max_attempts=60 attempt=0 until pg_isready -h localhost -p 5432 -U "$POSTGRES_USER"; do attempt=$((attempt + 1)) @@ -147,25 +147,8 @@ jobs: postgres-user: ${{ env.POSTGRES_USER }} postgres-password: ${{ env.POSTGRES_PASSWORD }} - - name: Wait for PostgreSQL - shell: bash - run: | - max_attempts=60 - attempt=0 - until pg_isready -h localhost -p 5432 -U "$POSTGRES_USER"; do - attempt=$((attempt + 1)) - if [ $attempt -ge $max_attempts ]; then - echo "ERROR: PostgreSQL did not become ready after $max_attempts attempts" - exit 1 - fi - echo "Waiting for PostgreSQL... (attempt $attempt/$max_attempts)" - sleep 2 - done - echo "PostgreSQL is ready" - - name: Run Unit Tests with Coverage id: unit-tests - continue-on-error: true env: ASPNETCORE_ENVIRONMENT: Testing MEAJUDAAI_DB_PASS: ${{ env.POSTGRES_PASSWORD }} @@ -188,6 +171,7 @@ jobs: "src/Modules/Documents/Tests/MeAjudaAi.Modules.Documents.Tests.csproj" "src/Modules/ServiceCatalogs/Tests/MeAjudaAi.Modules.ServiceCatalogs.Tests.csproj" "src/Modules/Locations/Tests/MeAjudaAi.Modules.Locations.Tests.csproj" + "src/Modules/Communications/Tests/MeAjudaAi.Modules.Communications.Tests.csproj" "src/Modules/SearchProviders/Tests/MeAjudaAi.Modules.SearchProviders.Tests.csproj" "tests/MeAjudaAi.Shared.Tests/MeAjudaAi.Shared.Tests.csproj" "tests/MeAjudaAi.ApiService.Tests/MeAjudaAi.ApiService.Tests.csproj" @@ -207,7 +191,8 @@ jobs: - name: Run Integration Tests id: integration-tests - continue-on-error: true + if: success() + timeout-minutes: 45 env: ASPNETCORE_ENVIRONMENT: Testing INTEGRATION_TESTS: true @@ -220,18 +205,33 @@ jobs: ConnectionStrings__meajudaai-db: ${{ steps.db.outputs.connection-string }} AZURE_STORAGE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://localhost:10000/devstoreaccount1;" run: | - echo "🚀 Running Integration Tests (expected duration ~40m)..." + echo "🚀 Running Integration Tests..." dotnet test tests/MeAjudaAi.Integration.Tests/MeAjudaAi.Integration.Tests.csproj \ --configuration Release --no-build \ --collect:"XPlat Code Coverage" \ --results-directory ./coverage/integration \ --settings ./coverlet.runsettings \ + --blame-hang-timeout 10min \ --verbosity normal + - name: Check test results + if: always() + run: | + if [[ "${{ steps.unit-tests.outcome }}" == "failure" ]]; then + echo "❌ Unit tests failed." + exit 1 + fi + if [[ "${{ steps.integration-tests.outcome }}" == "failure" ]]; then + echo "❌ Integration tests failed." + exit 1 + fi + echo "✅ All tests passed." + - name: Collect and aggregate coverage files + if: always() run: | mkdir -p ./coverage/aggregate - # Use mapfile for robust array handling (avoids subshell issue with pipe|while) + # Coleta de unitários, integração e retry mapfile -t coverage_files < <(find ./coverage -type f -name "coverage.cobertura.xml") counter=0 @@ -249,11 +249,13 @@ jobs: fi - name: Configure DOTNET_ROOT for ReportGenerator + if: always() run: | DOTNET_PATH="$(which dotnet)" echo "DOTNET_ROOT=$(dirname "$(readlink -f "$DOTNET_PATH")")" >> $GITHUB_ENV - name: Generate aggregated coverage report + if: always() uses: danielpalme/ReportGenerator-GitHub-Action@5 with: reports: "coverage/aggregate/**/*.cobertura.xml" @@ -272,6 +274,7 @@ jobs: - name: Code Coverage Summary uses: irongut/CodeCoverageSummary@v1.3.0 + if: always() with: filename: "coverage/final_report/Cobertura.xml" badge: true @@ -282,7 +285,7 @@ jobs: - name: Add Coverage PR Comment uses: marocchino/sticky-pull-request-comment@v3 - if: github.event_name == 'pull_request' + if: always() && github.event_name == 'pull_request' with: recreate: true header: coverage-report @@ -305,8 +308,6 @@ jobs: cat security-audit-report.txt - name: Verify No Critical Direct Vulnerabilities run: | - # Parse output to find if there are critical vulnerabilities in direct dependencies - # We check the top-level references specifically. A basic scan verifies if "Critical" shows up without being in a Transitive block, or simply checks top-level explicitly. dotnet list package --vulnerable > direct-security-audit.txt if grep -qi "Critical" direct-security-audit.txt; then echo "Critical direct vulnerabilities found!" diff --git a/MeAjudaAi.slnx b/MeAjudaAi.slnx index 1b35408c5..02881578d 100644 --- a/MeAjudaAi.slnx +++ b/MeAjudaAi.slnx @@ -20,6 +20,22 @@ + + + + + + + + + + + + + + + + diff --git a/docs/roadmap-current.md b/docs/roadmap-current.md index cfbcef439..eba93eb79 100644 --- a/docs/roadmap-current.md +++ b/docs/roadmap-current.md @@ -5,7 +5,7 @@ ### Objetivo Desenvolver aplicações frontend usando **React + Next.js** (Customer Web App, Admin Portal) + **React Native** (Mobile App). -> **📅 Status Atual**: Sprint 9 (Estabilização) em andamento (26 Mar 2026) +> **📅 Status Atual**: Sprint 9 (Estabilização) encerrada (11 Abr 2026) > **📝 Decisão Técnica**: Cobertura Global de 70% atingida e reforçada no CI/CD. > **🎉 MIGRAÇÃO CONCLUÍDA**: Admin Portal migrado de Blazor para React + Next.js na Sprint 8D @@ -16,18 +16,18 @@ Desenvolver aplicações frontend usando **React + Next.js** (Customer Web App, > **📝 Decisão Técnica** (5 Fevereiro 2026): > Stack de Customer App definida como **React 19 + Next.js 15 + Tailwind CSS v4**. > **Admin Portal**: Migrado de Blazor WASM para React + Next.js na Sprint 8D. -> **Razão**: SEO crítico para Customer App, performance inicial, ecosystem maduro, hiring facilitado. +> **Razão**: SEO crítico para Customer App, performance inicial, ecossistema maduro, contratação facilitada. **Decisão Estratégica**: Stack unificado em **React + Next.js** para todos os apps web **Justificativa**: - ✅ **SEO**: Customer App precisa aparecer no Google ("eletricista RJ") - Next.js SSR/SSG resolve - ✅ **Performance**: Initial load rápido crítico para conversão mobile - code splitting + lazy loading -- ✅ **Ecosystem**: Massivo - geolocation, maps, payments, qualquer problema já resolvido -- ✅ **Hiring**: Fácil escalar time - React devs abundantes +- ✅ **Ecossistema**: Massivo - geolocalização, mapas, pagamentos, qualquer problema já resolvido +- ✅ **Contratação**: Fácil escalar time - React devs abundantes - ✅ **Mobile**: React Native maduro e testado vs MAUI Hybrid ainda novo - ✅ **Modern Stack**: React 19 + Tailwind v4 é estado da arte (2026) -- ⚠️∩╕Å **Trade-off**: DTOs duplicados (C# backend, TS frontend) - mitigado com OpenAPI TypeScript Generator +- ⚠️ **Trade-off**: DTOs duplicados (C# backend, TS frontend) - mitigado com OpenAPI TypeScript Generator **Stack Completa**: @@ -66,42 +66,42 @@ Desenvolver aplicações frontend usando **React + Next.js** (Customer Web App, - Keycloak OIDC (autenticação unificada) - PostgreSQL (backend único) -**Code Sharing Strategy (C# Γåö TypeScript)**: +**Estratégia de Compartilhamento de Código (C# → TypeScript)**: -| Artifact | Backend Source | Frontend Output | Sync Method | +| Artefato | Fonte Backend | Saída Frontend | Método de Sincronização | |----------|----------------|-----------------|-------------| | **DTOs** | `Contracts/*.cs` | `types/api/*.ts` | OpenAPI Generator (auto) | -| **Enums** | `Shared.Contracts/Enums/` | `types/enums.ts` | OpenAPI Generator (auto) | -| **Validation** | FluentValidation | Zod schemas | Automated Generation (Sprint 8A) | -| **Constants** | `Shared.Contracts/Constants/` | `lib/constants.ts` | Automated Generation (Sprint 8A) | +| **Enums** | `MeAjudaAi.Contracts/Enums/` | `types/enums.ts` | OpenAPI Generator (auto) | +| **Validação** | FluentValidation | Zod schemas | Geração Automática (Sprint 8A) | +| **Constantes** | `MeAjudaAi.Contracts/Constants/` | `lib/constants.ts` | Geração Automática (Sprint 8A) | -**Generation Plan**: -1. Implementar ferramenta CLI para converter `Shared.Contracts` Enums e Constants em `types/enums.ts` e `lib/constants.ts`. +**Plano de Geração**: +1. Implementar ferramenta CLI para converter `MeAjudaAi.Contracts` Enums e Constants em `types/enums.ts` e `lib/constants.ts`. 2. Implementar conversor de metadados FluentValidation para Zod schemas em `types/api/validation.ts`. -3. Adicionar tickets no backlog para verificação em CI e versionamento sem├óntico dos artefatos gerados. +3. Adicionar tickets no backlog para verificação em CI e versionamento semântico dos artefatos gerados. -**Strategy Note**: We prioritize reusing `MeAjudaAi.Shared.Contracts` for enums and constants to keep the Frontend aligned with the Backend and avoid drift. +**Nota de Estratégia**: Priorizamos o reuso de `MeAjudaAi.Contracts` para enums e constantes para manter o Frontend alinhado com o Backend e evitar desvios. -**Generated Files Location**: +**Localização dos Arquivos Gerados**: ```text src/ ├── Contracts/ # Backend DTOs (C#) └── Web/ - ├── MeAjudaAi.Web.Admin/ # React + Next.js (migrated from Blazor in Sprint 8D) + ├── MeAjudaAi.Web.Admin/ # React + Next.js (migrado do Blazor na Sprint 8D) ├── MeAjudaAi.Web.Customer/ # Next.js - │ └── types/api/generated/ # ← OpenAPI generated types + │ └── types/api/generated/ # ← OpenAPI gerado types └── Mobile/ └── MeAjudaAi.Mobile.Customer/ # React Native - └── src/types/api/ # ← Same OpenAPI generated types + └── src/types/api/ # ← Mesmo OpenAPI gerado types ``` -**CI/CD Pipeline** (GitHub Actions): -1. Backend changes → Swagger JSON updated -2. OpenAPI diff check (breaking changes?) -3. If breaking → Require API version bump (`v1` → `v2`) -4. Generate TypeScript types -5. Commit to `types/api/generated/` (auto-commit bot) -6. Frontend tests run with new types +**Pipeline CI/CD** (GitHub Actions): +1. Mudanças no Backend → Swagger JSON atualizado +2. Verificação de OpenAPI diff (breaking changes?) +3. Se houver quebra → Requerer bump de versão da API (`v1` → `v2`) +4. Gerar tipos TypeScript +5. Commit para `types/api/generated/` (auto-commit bot) +6. Testes do Frontend rodam com novos tipos ### 🗂️ Estrutura de Projetos Atualizada ```text @@ -113,37 +113,37 @@ src/ │ └── MeAjudaAi.Mobile.Customer/ # 🚀 React Native + Expo (Sprint 8B) └── Shared/ ├── MeAjudaAi.Shared.DTOs/ # DTOs C# (backend) - └── MeAjudaAi.Shared.Contracts/ # OpenAPI spec → TypeScript types + └── MeAjudaAi.Contracts/ # OpenAPI spec → TypeScript types ``` ### 🔐 Autenticação Unificada -**Cross-Platform Authentication Consistency**: +**Consistência de Autenticação Cross-Platform**: -| Aspect | Admin (React) | Customer Web (Next.js) | Customer Mobile (RN) | +| Aspecto | Admin (React) | Customer Web (Next.js) | Customer Mobile (RN) | |--------|--------------|------------------------|----------------------| -| **Token Storage** | HTTP-only cookies | HTTP-only cookies | Secure Storage | -| **Token Lifetime** | 1h access + 24h refresh | 1h access + 7d refresh | 1h access + 30d refresh | -| **Refresh Strategy** | Automatic (NextAuth) | Middleware refresh | Background refresh | +| **Armazenamento de Token** | HTTP-only cookies | HTTP-only cookies | Secure Storage | +| **Vida útil do Token** | 1h acesso + 24h refresh | 1h acesso + 7d refresh | 1h acesso + 30d refresh | +| **Estratégia de Refresh** | Automática (NextAuth) | Middleware refresh | Background refresh | | **Role Claims** | `role` claim | `role` claim | `role` claim | -| **Logout** | `/api/auth/signout` | `/api/auth/signout` | Revoke + clear storage | +| **Sair (Logout)** | `/api/auth/signout` | `/api/auth/signout` | Revoke + clear storage | -**Keycloak Configuration**: +**Configuração Keycloak**: - **Realm**: `MeAjudaAi` -- **Clients**: `meajudaai-admin` (public), `meajudaai-customer` (public) +- **Clientes**: `meajudaai-admin` (público), `meajudaai-customer` (público) - **Roles**: `admin`, `customer`, `provider` -- **Token Format**: JWT (RS256) -- **Token Lifetime**: Access 1h, Refresh 30d (configurable per client: Admin=24h, Customer=7d, Mobile=30d) +- **Formato Token**: JWT (RS256) +- **Vida útil Token**: Acesso 1h, Refresh 30d (configurável por cliente: Admin=24h, Customer=7d, Mobile=30d) -**Implementation Details**: +**Detalhes de Implementação**: - **Protocolo**: OpenID Connect (OIDC) -- **Identity Provider**: Keycloak +- **Provedor de Identidade**: Keycloak - **Admin Portal**: NextAuth.js v5 (React + Next.js) - **Customer Web**: NextAuth.js v5 (Next.js) - **Customer Mobile**: React Native OIDC Client - **Refresh**: Automático via OIDC interceptor -**Migration Guide**: See `docs/authentication-migration.md` (to be created Sprint 8A) +**Guia de Migração**: Veja `docs/authentication-migration.md` (a ser criado na Sprint 8A) @@ -168,7 +168,7 @@ src/ - ❌ **DELETADO** `Shared/Configuration/` (pasta vazia após movimentações) - ❌ **DELETADO** `Shared/Middleware/` (pasta vazia, middleware único movido para ApiService) - **Justificativa**: - - GeographicRestriction é feature **exclusiva da API HTTP** (não será usada por Workers/Background Jobs) + - GeographicRestriction é funcionalidade **exclusiva da API HTTP** (não será usada por Workers/Background Jobs) - Options são lidas de appsettings que só existem em ApiService - FeatureFlags são constantes (similar a `AuthConstants.Claims.*`, `ValidationConstants.MaxLength.*`) - Middlewares genéricos já estão em pastas temáticas (Authorization/Middleware, Logging/, Monitoring/) @@ -182,10 +182,10 @@ src/ ↓ allowed_regions.is_active = true → Ativa cidade ESPECÍFICA ``` - - **MVP (Sprint 1)**: Feature toggle + appsettings (hardcoded cities) - - **Sprint 3**: Migration para database-backed + Admin Portal UI + - **MVP (Sprint 1)**: Feature toggle + appsettings (cidades hardcoded) + - **Sprint 3**: Migração para database-backed + Admin Portal UI -3. **Remoção de Redund├óncia** ✅ **J├ü REMOVIDO** +3. **Remoção de Redundância** ✅ **JÁ REMOVIDO** - ❌ **REMOVIDO**: Propriedade `GeographicRestrictionOptions.Enabled` (redundante com feature flag) - ❌ **REMOVIDO**: Verificação `|| !_options.Enabled` do middleware - ✅ **ÚNICA FONTE DE VERDADE**: `FeatureManagement:GeographicRestriction` (feature toggle) @@ -246,7 +246,7 @@ CREATE INDEX idx_allowed_regions_state ON geographic_restrictions.allowed_region CREATE INDEX idx_allowed_regions_active ON geographic_restrictions.allowed_regions(is_active); ``` -### 🚀 Fase 2 Follow-ups (Mar 2026+) +### 🚀 Fase 2 Seguimentos (Mar 2026+) **Funcionalidades Admin Portal**: @@ -254,10 +254,10 @@ CREATE INDEX idx_allowed_regions_active ON geographic_restrictions.allowed_regio - [ ] Tabela com cidades/estados permitidos - [ ] Filtros: Tipo (Cidade/Estado), Estado, Status (Ativo/Inativo) - [ ] Ordenação: Alfabética, Data de Adição - - [ ] Indicador visual: Badgets para "Cidade" vs "Estado" + - [ ] Indicador visual: Badges para "Cidade" vs "Estado" - [ ] **Adicionar Cidade/Estado** - - [ ] Form com campos: + - [ ] Formulário com campos: - Tipo: Dropdown (Cidade, Estado) - Estado: Dropdown preenchido via IBGE API (27 UFs) - Cidade: Autocomplete via IBGE API (se tipo=Cidade) @@ -270,7 +270,7 @@ CREATE INDEX idx_allowed_regions_active ON geographic_restrictions.allowed_regio - [ ] **Editar Região** - [ ] Apenas permitir editar "Notas" e "Status" - - [ ] Cidade/Estado são imutáveis (delete + re-add se necessário) + - [ ] Cidade/Estado são imutáveis (deletar + re-adicionar se necessário) - [ ] Confirmação antes de desativar região com prestadores ativos - [ ] **Ativar/Desativar Região** @@ -283,7 +283,7 @@ CREATE INDEX idx_allowed_regions_active ON geographic_restrictions.allowed_regio - [ ] Validação: Bloquear remoção se houver prestadores registrados nesta região - [ ] Mensagem: "Não é possível remover [Cidade]. Existem 15 prestadores registrados." -**Integração com Middleware** (Refactor Necessário): +**Integração com Middleware** (Refatoração Necessária): **Abordagem 1: Database-First (Recomendado)** ```csharp @@ -293,7 +293,7 @@ public class GeographicRestrictionOptions public bool Enabled { get; set; } public string BlockedMessage { get; set; } = "..."; - // DEPRECATED: Remover após migration para database + // DEPRECATED: Remover após migração para database [Obsolete("Use database-backed AllowedRegionsService instead")] public List AllowedCities { get; set; } = new(); [Obsolete("Use database-backed AllowedRegionsService instead")] @@ -327,69 +327,69 @@ public class GeographicRestrictionMiddleware } ``` -**Abordagem 2: Hybrid (Fallback para appsettings)** +**Abordagem 2: Híbrida (Fallback para appsettings)** - Se banco estiver vazio, usar `appsettings.json` - Migração gradual: Admin adiciona regiões no portal, depois remove de appsettings -**Cache Strategy**: +**Estratégia de Cache**: - Usar `HybridCache` (já implementado no `IbgeService`) -- TTL: 5 minutos (balanço entre performance e fresh data) -- Invalidação: Ao adicionar/remover/editar região no admin portal +- TTL: 5 minutos (balanço entre performance e dados frescos) +- Invalidação: Ao adicionar/remover/editar região no portal administrativo -**Migration Path**: +**Caminho de Migração**: 1. **Sprint 3 Semana 1**: Criar schema `geographic_restrictions` + tabela 2. **Sprint 3 Semana 1**: Implementar `AllowedRegionsService` com cache -3. **Sprint 3 Semana 1**: Refactor middleware para usar serviço (mantém fallback appsettings) -4. **Sprint 3 Semana 2**: Implementar CRUD endpoints no Admin API +3. **Sprint 3 Semana 1**: Refatoração do middleware para usar serviço (mantém fallback appsettings) +4. **Sprint 3 Semana 2**: Implementar endpoints CRUD na API administrativa 5. **Sprint 3 Semana 2**: Implementar UI no Admin Portal (React) 6. **Sprint 3 Pós-Deploy**: Popular banco com dados iniciais (Muriaé, Itaperuna, Linhares) 7. **Sprint 4**: Remover valores de appsettings.json (obsoleto) **Testes Necessários**: -- [ ] Unit tests: `AllowedRegionsService` (CRUD + cache invalidation) +- [ ] Unit tests: `AllowedRegionsService` (CRUD + invalidação de cache) - [ ] Integration tests: Middleware com banco populado vs vazio - [ ] E2E tests: Admin adiciona cidade → Middleware bloqueia outras cidades **Documentação**: -- [ ] Admin User Guide: Como adicionar/remover cidades piloto -- [ ] Technical Debt: Marcar `AllowedCities` e `AllowedStates` como obsoletos +- [ ] Guia do Usuário Admin: Como adicionar/remover cidades piloto +- [ ] Débito Técnico: Marcar `AllowedCities` e `AllowedStates` como obsoletos -**⚠️∩╕Å Breaking Changes**: -- ~~`GeographicRestrictionOptions.Enabled` será removido~~ ✅ **J├ü REMOVIDO** (Sprint 1 Dia 1) +**⚠️ Breaking Changes**: +- ~~`GeographicRestrictionOptions.Enabled` será removido~~ ✅ **JÁ REMOVIDO** (Sprint 1 Dia 1) - **Motivo**: Redundante com feature toggle - fonte de verdade única - **Migração**: Usar apenas `FeatureManagement:GeographicRestriction` em appsettings - `GeographicRestrictionOptions.AllowedCities/AllowedStates` será deprecado (Sprint 3) - **Migração**: Admin Portal populará tabela `allowed_regions` via UI **Estimativa**: -- **Backend (API + Service)**: 2 dias +- **Backend (API + Serviço)**: 2 dias - **Frontend (Admin Portal UI)**: 2 dias -- **Migration + Testes**: 1 dia -- **Total**: 5 dias (dentro do Sprint 3 de 2 semanas) +- **Migração + Testes**: 1 dia +- **Total**: 5 dias (dentro da Sprint 3 de 2 semanas) -#### 7. Moderação de Reviews (Preparação para Fase 3) -- [ ] **Listagem**: Reviews flagged/reportados +#### 7. Moderação de Avaliações (Preparação para Fase 3) +- [ ] **Listagem**: Avaliações sinalizadas/reportadas - [ ] **Ações**: Aprovar, Remover, Banir usuário -- [ ] Stub para módulo Reviews (a ser implementado na Fase 3) +- [ ] Stub para módulo de Avaliações (a ser implementado na Fase 3) **Tecnologias (Admin Portal React)**: - **Framework**: React 19 + TypeScript 5.7+ - **UI**: Tailwind CSS v4 + Base UI -- **State**: Zustand +- **Estado**: Zustand - **HTTP**: TanStack Query + React Hook Form -- **Charts**: Recharts +- **Gráficos**: Recharts **Resultado Esperado**: - ✅ Admin Portal funcional e responsivo (React) - ✅ Todas operações CRUD implementadas - ✅ Dashboard com métricas em tempo real -- ✅ Deploy em Azure Container Apps +- ✅ Deploy no Azure Container Apps --- ### 📅 Sprint 8A: Customer App & Nx Setup (2 semanas) ⏳ ATUALIZADO -**Status**: CONCLU├ìDA (5-13 Fev 2026) +**Status**: CONCLUÍDA (5-13 Fev 2026) **Dependências**: Sprint 7.16 concluído ✅ **Duração**: 2 semanas @@ -397,46 +397,46 @@ public class GeographicRestrictionMiddleware --- -#### 📱 Parte 1: Customer App Development (Focus) +#### 📱 Parte 1: Desenvolvimento do Customer App (Foco) **Home & Busca** (Semana 1): -- [ ] **Landing Page**: Hero section + busca rápida +- [ ] **Página de Destino**: Seção Hero + busca rápida - [ ] **Busca Geolocalizada**: Campo de endereço/CEP + raio + serviços - [ ] **Mapa Interativo**: Exibir prestadores no mapa (Leaflet.Blazor) -- [ ] **Listagem de Resultados**: Cards com foto, nome, rating, distância, tier badge -- [ ] **Filtros**: Rating mínimo, tier, disponibilidade -- [ ] **Ordenação**: Distância, Rating, Tier +- [ ] **Listagem de Resultados**: Cards com foto, nome, nota, distância, tier badge +- [ ] **Filtros**: Nota mínima, tier, disponibilidade +- [ ] **Ordenação**: Distância, Nota, Tier **Perfil de Prestador** (Semana 1-2): -- [ ] **Visualização**: Foto, nome, descrição, serviços, rating, reviews -- [ ] **Contato**: Botão WhatsApp, telefone, email (MVP: links externos) +- [ ] **Visualização**: Foto, nome, descrição, serviços, nota, avaliações +- [ ] **Contato**: Botão WhatsApp, telefone, e-mail (MVP: links externos) - [ ] **Galeria**: Fotos do trabalho (se disponível) -- [ ] **Reviews**: Listar avaliações de outros clientes (read-only, write em Fase 3) +- [ ] **Avaliações**: Listar avaliações de outros clientes (apenas leitura, escrita na Fase 3) - [ ] **Meu Perfil**: Editar informações básicas -#### 🛠️ Parte 2: Nx Monorepo Setup +#### 🛠️ Parte 2: Configuração do Nx Monorepo **Status**: 🔄 EM PROGRESSO (Março 2026) *Nota: Este é um contêiner ampliado que representa múltiplas sprints destinadas à reestruturação modular do front-end web. A "Sprint 8B.2" encapsula a fundação inicial concluída como parte intrínseca deste arco arquitetural.* -### ✅ Sprint 8B.2 - NX Scaffolding & Initial Migration (5 - 18 Mar 2026) +### ✅ Sprint 8B.2 - NX Scaffolding & Migração Inicial (5 - 18 Mar 2026) **Branch**: `feature/sprint-8b2-monorepo-cleanup` **Status**: 🔄 EM REVISÃO *Nota: A atualização final para "✅ CONCLUÍDA" deve ocorrer somente após o merge do PR ou confirmação explícita de finalização do trabalho na branch.* -**Objectives**: -1. 🔴 **MUST-HAVE**: **NX Monorepo Setup** (Effort: Large) - - Initialize workspace. - - **Migrate** existing `MeAjudaAi.Web.Customer` to `apps/customer-web`. - - **Scaffolding** (empty placeholders): `apps/provider-web` and `apps/admin-portal`. - - Extract shared libraries: `libs/ui`, `libs/auth`, `libs/api-client`. -2. 🔴 **MUST-HAVE**: **Messaging Unification** (Effort: Medium) - - Remove Azure Service Bus, unify on RabbitMQ only. -3. 🔴 **MUST-HAVE**: **Technical Excellence Pack** (Effort: Medium) - - [ ] [**TD**] **Keycloak Automation**: `setup-keycloak-clients.ps1` for local dev. - - [ ] [**TD**] **Analyzer Cleanup**: Fix SonarLint warnings in React apps & Contracts. - - [ ] [**TD**] **Refactor Extensions**: Extract `BusinessMetricsMiddlewareExtensions`. - - [ ] [**TD**] **Polly Logging**: Migrate resilience logging to ILogger (Issue #113). - - [ ] [**TD**] **Standardization**: Record syntax alignment in `Contracts`. +**Objetivos**: +1. 🔴 **MUST-HAVE**: **Configuração do NX Monorepo** (Esforço: Grande) + - Inicializar workspace. + - **Migrar** `MeAjudaAi.Web.Customer` existente para `apps/customer-web`. + - **Andaime (Scaffolding)** (placeholders vazios): `apps/provider-web` e `apps/admin-portal`. + - Extrair bibliotecas compartilhadas: `libs/ui`, `libs/auth`, `libs/api-client`. +2. 🔴 **MUST-HAVE**: **Unificação de Mensageria** (Esforço: Médio) + - Remover Azure Service Bus, unificar apenas no RabbitMQ. +3. 🔴 **MUST-HAVE**: **Pacote de Excelência Técnica** (Esforço: Médio) + - [ ] [**TD**] **Automação Keycloak**: `setup-keycloak-clients.ps1` para desenvolvimento local. + - [ ] [**TD**] **Limpeza de Analisadores**: Corrigir avisos SonarLint nos apps React e Contratos. + - [ ] [**TD**] **Refatoração de Extensões**: Extrair `BusinessMetricsMiddlewareExtensions`. + - [ ] [**TD**] **Logging Polly**: Migrar logs de resiliência para ILogger (Issue #113). + - [ ] [**TD**] **Padronização**: Alinhamento de sintaxe de Record em `Contracts`. *(TODO: Marcar os checkboxes acima como [x] após o merge do PR na branch feature/sprint-8b2-monorepo-cleanup)* --- @@ -462,9 +462,9 @@ public class GeographicRestrictionMiddleware Tarefas técnicas que devem ser aplicadas em todos os módulos para consistência e melhores práticas. -### Migration Control em Produção +### Controle de Migração em Produção -**Issue**: Implementar controle `APPLY_MIGRATIONS` nos módulos restantes +**Problema**: Implementar controle `APPLY_MIGRATIONS` nos módulos restantes **Contexto**: O módulo Documents já implementa controle via variável de ambiente `APPLY_MIGRATIONS` para desabilitar migrations automáticas em produção. @@ -473,7 +473,7 @@ Tarefas técnicas que devem ser aplicadas em todos os módulos para consistênci ```csharp private static void EnsureDatabaseMigrations(WebApplication app) { - // Read the environment variable (or from IConfiguration) + // Lê a variável de ambiente (ou de IConfiguration) var applyMigrations = app.Configuration["APPLY_MIGRATIONS"] ?? Environment.GetEnvironmentVariable("APPLY_MIGRATIONS"); @@ -507,20 +507,20 @@ private static void EnsureDatabaseMigrations(WebApplication app) --- -## 📋 Sprint 5.5: Package Lock Files & Dependency Updates (19 Dez 2025) +## 📋 Sprint 5.5: Arquivos de Lock de Pacote e Atualizações de Dependência (19 Dez 2025) **Status**: 🔄 EM ANDAMENTO - Aguardando CI/CD **Duração**: 1 dia -**Objetivo**: Resolver conflitos de package lock files e atualizar dependências +**Objetivo**: Resolver conflitos de arquivos de lock de pacote e atualizar dependências ### Contexto Durante o processo de atualização automática de dependências pelo Dependabot, foram identificados conflitos nos arquivos `packages.lock.json` causados por incompatibilidade de versões do pacote `Microsoft.OpenApi`. **Problema Raiz**: -- Lock files esperavam versão `[2.3.12, )` +- Arquivos de lock esperavam versão `[2.3.12, )` - Central Package Management especificava `[2.3.0, )` -- Isso causava erros NU1004 em todos os projetos, impedindo build e testes +- Isso causava erros NU1004 em todos os projetos, impedindo compilação e testes ### Ações Executadas @@ -534,29 +534,29 @@ Durante o processo de atualização automática de dependências pelo Dependabot 2. **Branch master** - ✅ Merge de feature/refactor-and-cleanup → master - ✅ Push para origin/master concluído - - ✅ Todos os lock files atualizados na branch principal + - ✅ Todos os arquivos de lock atualizados na branch principal -3. **PR #81 - Aspire 13.1.0 Update** +3. **PR #81 - Atualização do Aspire 13.1.0** - Branch: `dependabot/nuget/aspire-f7089cdef2` - - ✅ Lock files regenerados (37 arquivos) + - ✅ Arquivos de lock regenerados (37 arquivos) - ✅ Commit: "fix: regenerate package lock files after Aspire 13.1.0 update" - ✅ Force push concluído - - ⏳ Aguardando CI/CD (Code Quality Checks, Security Scan) + - ⏳ Aguardando CI/CD (Verificações de Qualidade de Código, Escaneamento de Segurança) -4. **PR #82 - FeatureManagement 4.4.0 Update** +4. **PR #82 - Atualização do FeatureManagement 4.4.0** - Branch: `dependabot/nuget/Microsoft.FeatureManagement.AspNetCore-4.4.0` - - ✅ Lock files regenerados (36 arquivos) + - ✅ Arquivos de lock regenerados (36 arquivos) - ✅ Commit: "fix: regenerate package lock files after FeatureManagement update" - ✅ Push concluído - - ⏳ Aguardando CI/CD (Code Quality Checks, Security Scan) + - ⏳ Aguardando CI/CD (Verificações de Qualidade de Código, Escaneamento de Segurança) ### Próximos Passos -1. ✅ **Merge PRs #81 e #82** - Concluído (19 Dez 2025) -2. ✅ **Atualizar feature branch** - Merge master → feature/refactor-and-cleanup +1. ✅ **Merge dos PRs #81 e #82** - Concluído (19 Dez 2025) +2. ✅ **Atualizar branch de funcionalidade** - Merge master → feature/refactor-and-cleanup 3. ✅ **Criar PR #83** - Branch feature/refactor-and-cleanup → master -4. ⏳ **Aguardar review e merge PR #83** -5. 📋 **Iniciar Sprint 6** - GitHub Pages Documentation (Q1 2026) +4. ⏳ **Aguardar revisão e merge do PR #83** +5. 📋 **Iniciar Sprint 6** - Documentação do GitHub Pages (Q1 2026) 6. 📋 **Planejar Sprint 7** - Blazor Admin Portal (Q1 2026) #### ✅ Atualizações de Documentação (19 Dez 2025) @@ -567,216 +567,465 @@ Durante o processo de atualização automática de dependências pelo Dependabot - ✅ Atualizados Sprints 3-5 com dependências e novas timelines - ✅ Atualizada última modificação para 19 de Dezembro de 2025 -**Limpeza de Templates**: -- ✅ Removido `.github/pull-request-template-coverage.md` (template específico de outro PR) +**Limpeza de Modelos**: +- ✅ Removido `.github/pull-request-template-coverage.md` (modelo específico de outro PR) - ✅ Removida pasta `.github/issue-template/` (issues obsoletas: EFCore.NamingConventions, Npgsql já resolvidas) -- ✅ Criado `.github/pull_request_template.md` (template genérico para futuros PRs) +- ✅ Criado `.github/pull_request_template.md` (modelo genérico para futuros PRs) - ✅ Commit: "chore: remove obsolete templates and create proper PR template" **Pull Request #83**: - ✅ PR criado: feature/refactor-and-cleanup → master - ✅ Título: "feat: refactoring and cleanup sprint 5.5" -- ✅ Descrição atualizada refletindo escopo real (documentação + merge PRs #81/#82 + limpeza templates) -- ⏳ Aguardando review e CI/CD validation +- ✅ Descrição atualizada refletindo escopo real (documentação + merge dos PRs #81/#82 + limpeza de modelos) +- ⏳ Aguardando revisão e validação do CI/CD ### Lições Aprendidas -- **Dependabot**: Regenerar lock files manualmente após updates de versões com conflicts -- **CI/CD**: Validação rigorosa de package locks previne deployments quebrados -- **Central Package Management**: Manter sincronização entre lock files e Directory.Packages.props -- **Template Management**: Manter apenas templates genéricos e reutilizáveis em `.github/` -- **Documentation-First**: Documentar ações executadas imediatamente no roadmap para rastreabilidade +- **Dependabot**: Regenerar arquivos de lock manualmente após atualizações de versões com conflitos +- **CI/CD**: Validação rigorosa dos locks de pacote previne implantações quebradas +- **Central Package Management**: Manter sincronização entre arquivos de lock e Directory.Packages.props +- **Gestão de Modelos**: Manter apenas modelos genéricos e reutilizáveis em `.github/` +- **Documentação Primeiro**: Documentar ações executadas imediatamente no roadmap para rastreabilidade --- ### ✅ Sprint 8C - Provider Web App (React + NX) (19 Mar - 21 Mar 2026) - ✅ **Nx Integration**: `MeAjudaAi.Web.Provider` integrado ao workspace Nx -- ✅ **Onboarding Integration**: +- ✅ **Integração de Onboarding**: - `/onboarding/basic-info` conectado à API (`apiMeGet`/`apiMePut`) - `/onboarding/documents` conectado à API (upload via SAS URL para Azure Blob Storage) -- ✅ **Dashboard Real Data**: Página principal (`/`) substituída por dados reais via `apiMeGet` -- ✅ **Provider Public Profile**: Nova rota `/provider/[slug]` para perfis públicos com slugs SEO-friendly -- ✅ **Provider Profile Management**: +- ✅ **Painel com Dados Reais**: Página principal (`/`) substituída por dados reais via `apiMeGet` +- ✅ **Perfil Público do Prestador**: Nova rota `/provider/[slug]` para perfis públicos com slugs amigáveis ao SEO +- ✅ **Gestão do Perfil do Prestador**: - `/alterar-dados` - Edição completa via `apiMePut` - - `/configuracoes` - Toggle de visibilidade + delete account com confirmação LGPD -- ✅ **Slug URLs**: Perfis públicos acessíveis via slugs (ex: `/provider/joao-silva-a1b2c3d4`) + - `/configuracoes` - Alternância de visibilidade + exclusão de conta com confirmação LGPD +- ✅ **URLs amigáveis (Slug)**: Perfis públicos acessíveis via slugs (ex: `/provider/joao-silva-a1b2c3d4`) -### ✅ Sprint 8D - Admin Portal Migration (2 - 24 Mar 2026) +### ✅ Sprint 8D - Migração do Portal Administrativo (2 - 24 Mar 2026) **Status**: ✅ CONCLUÍDA (24 Mar 2026) -**Foco**: Phased migration from Blazor WASM to React. +**Foco**: Migração em fases do Blazor WASM para React. **Entregáveis**: -- ✅ **Admin Portal React**: Functional `src/Web/MeAjudaAi.Web.Admin/` in React. -- ✅ **Providers CRUD**: Complete provider management. -- ✅ **Document Management**: Document upload and verification. -- ✅ **Service Catalogs**: Service catalog management. -- ✅ **Allowed Cities**: Geographic restrictions management. -- ✅ **Dashboard KPIs**: Admin dashboard with metrics. +- ✅ **Admin Portal React**: `src/Web/MeAjudaAi.Web.Admin/` funcional em React. +- ✅ **CRUD de Prestadores**: Gestão completa de prestadores. +- ✅ **Gestão de Documentos**: Envio e verificação de documentos. +- ✅ **Catálogo de Serviços**: Gestão do catálogo de serviços. +- ✅ **Cidades Permitidas**: Gestão de restrições geográficas. +- ✅ **KPIs do Painel**: Painel administrativo com métricas. -### ✅ Sprint 8E - E2E Tests & React Test Infrastructure (23 Mar - 25 Mar 2026) +### ✅ Sprint 8E - Testes E2E e Infraestrutura de Teste React (23 Mar - 25 Mar 2026) **Status**: ✅ CONCLUÍDA (25 Mar 2026) **Foco**: Testes E2E (Playwright) + infraestrutura de testes unitários (Vitest + RTL + MSW) + Governança de Cobertura Global. -**Scope — E2E (Playwright)** ✅: -1. ✅ **Playwright Config**: `playwright.config.ts` com 6 projetos (Chromium, Firefox, WebKit, Mobile, CI) -2. ✅ **Customer E2E** (5 specs): auth, onboarding, performance, profile, search -3. ✅ **Provider E2E** (5 specs): auth, dashboard, onboarding, performance, profile-mgmt -4. ✅ **Admin E2E** (5 specs): auth, configs, dashboard, mobile-responsiveness, providers -5. ✅ **Shared Fixtures**: `src/Web/libs/e2e-support/base.ts` (loginAsAdmin, loginAsProvider, loginAsCustomer, logout) -6. ✅ **CI Integration**: `master-ci-cd.yml` atualizado para gerar especificação OpenAPI e rodar E2E. - -**Scope — Testes Unitários (Vitest + RTL)** ✅: -7. ✅ **Infraestrutura**: `libs/test-support/` (test-utils.tsx, customRenderHook), thresholds individuais removidos em favor de Cobertura Global. -8. ✅ **Cobertura Global**: Script `src/Web/scripts/merge-coverage.mjs` consolida relatórios de todos os projetos com threshold de 70%. +**Escopo — E2E (Playwright)** ✅: +1. ✅ **Configuração Playwright**: `playwright.config.ts` com 6 projetos (Chromium, Firefox, WebKit, Mobile, CI) +2. ✅ **Customer E2E** (5 especificações): auth, onboarding, performance, profile, search +3. ✅ **Provider E2E** (5 especificações): auth, dashboard, onboarding, performance, profile-mgmt +4. ✅ **Admin E2E** (5 especificações): auth, configs, dashboard, mobile-responsiveness, providers +5. ✅ **Fixtures Compartilhadas**: `src/Web/libs/e2e-support/base.ts` (loginAsAdmin, loginAsProvider, loginAsCustomer, logout) +6. ✅ **Integração CI**: `master-ci-cd.yml` atualizado para gerar especificação OpenAPI e rodar E2E. + +**Escopo — Testes Unitários (Vitest + RTL)** ✅: +7. ✅ **Infraestrutura**: `libs/test-support/` (test-utils.tsx, customRenderHook), limites individuais removidos em favor de Cobertura Global. +8. ✅ **Cobertura Global**: Script `src/Web/scripts/merge-coverage.mjs` consolida relatórios de todos os projetos com limite de 70%. 9. ✅ **Hardening Admin**: Testes unitários para `Sidebar`, `Button`, `Dashboard`, `Providers` e `Users`. Autenticação centralizada em `auth.ts`. -10. ✅ **Hardening Customer**: `DashboardClient` (DTO compliance), `DocumentUpload` (API assertions) e `SearchFilters` (API category validation). +10. ✅ **Hardening Customer**: `DashboardClient` (conformidade com DTO), `DocumentUpload` (asserções da API) e `SearchFilters` (validação de categoria da API). **Cenários de Teste E2E**: - [x] Autenticação (login, logout, refresh token) -- [x] Fluxo de onboarding (Customer e Provider) -- [x] CRUD de providers e serviços (Admin) +- [x] Fluxo de onboarding (Cliente e Prestador) +- [x] CRUD de prestadores e serviços (Admin) - [x] Busca e filtros geolocalizados - [x] Responsividade mobile -- [x] Performance e Core Web Vitals (INP, LCP, CLS) +- [x] Desempenho e Core Web Vitals (INP, LCP, CLS) **Pendências para fechar Sprint**: - [x] Testes unitários Admin (hooks: providers, categories, dashboard, services, allowed-cities, users; components: sidebar, ui) -- [x] Testes unitários Provider (hooks; components: dashboard cards, profile) -- [x] Configurar MSW handlers para Admin e Provider +- [x] Testes unitários Prestador (hooks; components: dashboard cards, profile) +- [x] Configurar MSW handlers para Admin e Prestador -### ⏳ Sprint 9 - BUFFER & Risk Mitigation (25 Mar - 11 Mai 2026) +### ✅ Sprint 9 - BUFFER & Mitigação de Risco (25 Mar - 11 Abr 2026) -**Status**: ⏳ EM ANDAMENTO -**Duration**: 12 days buffer -- Polishing, Refactoring, and Fixing. -- Move Optional tasks from 8B.2 here if needed. -- Rate limiting and advanced security/monitoring. +**Status**: ✅ ENCERRADA (Snapshot: encerrada em 11 Abr 2026) +**Duração**: 12 dias de buffer (finalização do MVP) +- Polimento, Refatoração e Correção. +- Mover tarefas Opcionais da 8B.2 para cá se necessário. +- Limite de taxa (Rate limiting) e segurança/monitoramento avançado. -**Follow-ups Pendentes**: -- [ ] **OpenAPI Diff Gating**: Adicionar verificação de breaking changes em CI (falhar PR se API mudar sem version bump) +**Seguimentos Pendentes**: +- [ ] **Gating de Diff OpenAPI**: Adicionar verificação de mudanças de quebra em CI (falhar PR se API mudar sem bump de versão) +- [x] **Módulo de Comunicações**: ~~Implementar infraestrutura base (outbox pattern, modelos, handlers de evento)~~ ✅ Implementado (exceção BUFFER — infraestrutura entregue nesta sprint) ## 🎯 MVP Final Launch: 12 - 16 de Maio de 2026 🎯 -### ⚠️ Risk Assessment & Mitigation +### ⚠️ Avaliação e Mitigação de Risco -#### Risk Mitigation Strategy -- **Contingency Branching**: If major tasks (Admin Migration, NX Setup) slip, we prioritize essential Player flows (Customer/Provider) and fallback to existing Admin solutions. -- **Mobile Apps**: De-scoped from MVP to Phase 2 to ensure web platform stability. -- **Buffer**: Sprint 9 is strictly for stability, no new features. -- Documentação final para MVP +#### Estratégia de Mitigação de Risco +- **Contingência de Ramificação**: Se as tarefas principais (Migração Admin, Configuração NX) atrasarem, priorizaremos os fluxos essenciais do Jogador (Cliente/Prestador) e recairemos sobre as soluções Admin existentes. +- **Aplicativos Móveis**: Retirados do escopo do MVP para a Fase 2 para garantir a estabilidade da plataforma web. +- **Buffer**: A Sprint 9 é estritamente para estabilidade, sem novas funcionalidades (Exceção: infraestrutura do Módulo de Comunicações). +- Documentação final para o MVP ### Cenários de Risco Documentados -### Risk Scenario 1: Keycloak Integration Complexity +### Cenário de Risco 1: Complexidade de Integração do Keycloak -- **Problema Potencial**: OIDC flows em Blazor WASM com refresh tokens podem exigir configuração complexa -- **Impacto**: +2-3 dias além do planejado no Sprint 6 +- **Problema Potencial**: Fluxos OIDC em Blazor WASM com tokens de atualização podem exigir configuração complexa +- **Impacto**: +2-3 dias além do planejado na Sprint 6 - **Mitigação Sprint 9**: - - Usar Sprint 9 para refinar authentication flows - - Implementar proper token refresh handling - - Adicionar fallback mechanisms + - Usar a Sprint 9 para refinar os fluxos de autenticação + - Implementar o tratamento adequado de atualização de token + - Adicionar mecanismos de fallback -### Risk Scenario 3: React Performance Issues +### Cenário de Risco 2: Problemas de Desempenho do React -- **Problema Potencial**: App bundle size > 5MB, lazy loading não configurado corretamente -- **Impacto**: UX ruim, +2-3 dias de otimização +- **Problema Potencial**: Tamanho do pacote do aplicativo > 5MB, carregamento lento não configurado corretamente +- **Impacto**: Experiência do Usuário (UX) ruim, +2-3 dias de otimização - **Mitigação Sprint 9**: - - Code splitting with dynamic imports - - Tree shaking and bundle optimization - - SSR/SSG via Next.js to improve initial load - - Lazy load React components - - Optimize images using next/image and responsive formats + - Divisão de código com importações dinâmicas + - Tree shaking e otimização de pacotes + - SSR/SSG via Next.js para melhorar o carregamento inicial + - Carregamento lento de componentes React + - Otimizar imagens usando next/image e formatos responsivos -### Risk Scenario 4: MAUI Hybrid Platform-Specific Issues (DE-SCOPED FROM MVP) +### Cenário de Risco 3: Problemas Específicos da Plataforma MAUI Hybrid (REMOVIDO DO ESCOPO DO MVP) -> **⚠️ IMPORTANTE**: Este cenário de risco foi removido do escopo do MVP. Os Mobile Apps foram adiados para a Fase 2 conforme.nota acima. +> **⚠️ IMPORTANTE**: Este cenário de risco foi removido do escopo do MVP. Os Aplicativos Móveis foram adiados para a Fase 2 conforme nota acima. -- **Problema Potencial**: Diferenças de comportamento iOS vs Android (permissões, geolocation, file access) -- **Impacto**: +4-5 dias de debugging platform-specific +- **Problema Potencial**: Diferenças de comportamento iOS vs Android (permissões, geolocalização, acesso a arquivos) +- **Impacto**: +4-5 dias de depuração específica da plataforma - **Mitigação Sprint 9**: - - Criar abstractions para platform-specific APIs - - Implementar fallbacks para features não suportadas - - Testes em devices reais (não apenas emuladores) + - Criar abstrações para APIs específicas da plataforma + - Implementar fallbacks para funcionalidades não suportadas + - Testes em dispositivos reais (não apenas emuladores) -### Risk Scenario 5: API Integration Edge Cases +### Cenário de Risco 4: Casos de Borda da Integração de API -- **Problema Potencial**: Casos de erro não cobertos (timeouts, network failures, concurrent updates) -- **Impacto**: +2-3 dias de hardening +- **Problema Potencial**: Casos de erro não cobertos (tempos limite, falhas de rede, atualizações simultâneas) +- **Impacto**: +2-3 dias de endurecimento (hardening) - **Mitigação Sprint 9**: - - Implementar retry policies com Polly - - Adicionar optimistic concurrency handling - - Melhorar error messages e user feedback + - Implementar políticas de nova tentativa com Polly + - Adicionar tratamento de concorrência otimista + - Melhorar as mensagens de erro e o feedback do usuário ### Tarefas Sprint 9 (Executar conforme necessário) -#### 1. Work-in-Progress Completion -- [ ] Completar funcionalidades parciais de Sprints 6-8 -- [ ] Resolver todos os TODOs/FIXMEs adicionados durante implementação -- [ ] Fechar issues abertas durante desenvolvimento frontend +#### 1. Conclusão do Trabalho em Andamento +- [ ] Completar funcionalidades parciais das Sprints 6-8 +- [ ] Resolver todos os TODOs/FIXMEs adicionados durante a implementação +- [ ] Fechar issues abertas durante o desenvolvimento frontend -#### 1.1. ≡ƒº¬ SearchProviders E2E Tests (Movido da Sprint 7.16) -**Prioridade**: MÉDIA - Technical Debt da Sprint 7.16 -**Estimativa**: 1-2 dias - -**Objetivo**: Testar busca geolocalizada end-to-end. - -**Contexto**: Task 5 da Sprint 7.16 foi marcada como OPCIONAL e movida para Sprint 9 para permitir execução com qualidade sem pressão de deadline. Sprint 7.16 completou 4/4 tarefas obrigatórias. - -**Entregáveis**: -- [ ] Teste E2E: Buscar providers por serviço + raio (2km, 5km, 10km) -- [ ] Teste E2E: Validar ordenação por distância crescente -- [ ] Teste E2E: Validar restrição geográfica (AllowedCities) - providers fora da cidade não aparecem -- [ ] Teste E2E: Performance (<500ms para 1000 providers em raio de 10km) -- [ ] Teste E2E: Cenário sem resultados (nenhum provider no raio) -- [ ] Teste E2E: Validar paginação de resultados (10, 20, 50 items por página) +#### 1.1. ✅ 🧪 SearchProviders Testes E2E (Concluído) +- [x] Teste E2E: Buscar prestadores por serviço + raio (2km, 5km, 10km) +- [x] Teste E2E: Validar ordenação por distância crescente +- [x] Teste E2E: Validar restrição geográfica (AllowedCities) - prestadores fora da cidade não aparecem +- [x] Teste E2E: Desempenho (<1500ms em ambiente de teste) +- [x] Teste E2E: Cenário sem resultados (nenhum prestador no raio) +- [x] Teste E2E: Validar paginação de resultados (10, 20, 50 itens por página) **Infraestrutura**: - Usar `TestcontainersFixture` com PostGIS 16-3.4 -- Seed database com providers em localizações conhecidas (lat/lon) -- Usar `HttpClient` para chamar endpoint `/api/search-providers/search` -- Validar JSON response com FluentAssertions +- Semear banco de dados com prestadores em localizações conhecidas (lat/lon) +- Usar `HttpClient` para chamar o ponto de extremidade `/api/search-providers/search` +- Validar a resposta JSON com FluentAssertions **Critérios de Aceitação**: - ✅ 6 testes E2E passando com 100% de cobertura dos cenários -- ✅ Performance validada (95th percentile < 500ms) +- ✅ Desempenho validado - ✅ Documentação em `docs/testing/e2e-tests.md` - ✅ CI/CD executando testes E2E na pipeline -#### 2. UX/UI Improvements -- [ ] **Loading States**: Skeletons em todas cargas assíncronas -- [ ] **Error Handling**: Mensagens friendly para todos erros (não mostrar stack traces) -#### 3. Security & Performance Hardening -- [ ] **API Rate Limiting**: Aspire middleware (100 req/min por IP, 1000 req/min para authenticated users) +#### 1.2. 🛠️ Excelência Técnica e Débito (Sprint 9) +**Prioridade**: MÉDIA +**Estimativa**: 3-4 dias + +**Objetivo**: Resolver pendências técnicas e melhorar a resiliência do sistema. + +**Entregáveis**: +- [x] **Generalização do Outbox Pattern**: Mover estrutura base (entidade, repositório, worker base) para `MeAjudaAi.Shared` para reuso em futuros módulos (Payments, Bookings). +- [ ] **Handlers de Evento**: Implementar handlers para comunicação entre SearchProviders e ServiceCatalogs. + +- [ ] **Login Social**: Reintegrar login com Instagram via Keycloak OIDC (Issue #141). +- [ ] **Resiliência**: Aplicar `CancellationToken` nos Effects de `ServiceCatalogs`, `Documents` e `Locations`. +- [ ] **Localização (Backend)**: Migrar strings de erro da API para `.resx` e integrar FluentValidation para suporte a multi-idioma via cabeçalhos. +- [ ] **Localização (Frontend React)**: Implementar infraestrutura com `i18next` (JSON) nos apps Admin e Customer, localizando mensagens de validação do **Zod**. +- [ ] **Testes Arquiteturais**: Implementar testes com `NetArchTest` no Módulo de Comunicações para garantir isolamento (evitar referências circulares e acesso direto a DBs externos). +- [ ] **Identidade Visual da Interface**: Aplicar cores oficiais (Azul, Creme, Laranja) e padronizar o tema em todo o Portal Administrativo (React). +- [ ] **Testes Unitários (Débito)**: Testes para descarte de `LocalizationSubscription` e despejo LRU do `PerformanceHelper`. + +#### 2. Melhorias de UX/UI +- [ ] **Estados de Carregamento**: Skeletons em todas as cargas assíncronas +- [ ] **Tratamento de Erros**: Mensagens amigáveis para todos os erros (não mostrar stack traces) + +#### 3. Endurecimento de Segurança e Desempenho +- [ ] **Limite de Taxa da API**: Middleware Aspire (100 req/min por IP, 1000 req/min para usuários autenticados) - [ ] **CORS**: Configurar origens permitidas (apenas domínios de produção) -- [ ] **CSRF Protection**: Tokens anti-forgery em forms -- [ ] **Security Headers**: HSTS, X-Frame-Options, CSP -- [ ] **Bundle Optimization**: Lazy loading, AOT compilation, tree shaking -- [ ] **Cache Strategy**: Implementar cache HTTP para assets estáticos - -#### 4. Logging & Monitoring -- [ ] **Frontend Logging**: Integração com Application Insights (Blazor WASM) -- [ ] **Error Tracking**: Sentry ou similar para erros em produção -- [ ] **Analytics**: Google Analytics ou Plausible para usage tracking -- [ ] **Performance Monitoring**: Web Vitals tracking (LCP, FID, CLS) - -#### 5. Documentação Final MVP -- [ ] **API Documentation**: Swagger/OpenAPI atualizado com exemplos -- [ ] **User Guide**: Guia de uso para Admin Portal e Customer App -- [ ] **Developer Guide**: Como rodar localmente, como contribuir -- [ ] **Deployment Guide**: Deploy em Azure Container Apps (ARM templates ou Bicep) -- [ ] **Lessons Learned**: Documentar decisões de arquitetura e trade-offs +- [ ] **Proteção CSRF**: Tokens anti-falsificação em formulários +- [ ] **Cabeçalhos de Segurança**: HSTS, X-Frame-Options, CSP +- [ ] **Otimização do Pacote**: Carregamento lento (lazy loading), compilação AOT, tree shaking +- [ ] **Estratégia de Cache**: Implementar cache HTTP para ativos estáticos + +#### 4. Registro e Monitoramento +- [ ] **Registro do Frontend**: Integração com Application Insights (React + Next.js) +- [ ] **Rastreamento de Erros**: Sentry ou similar para erros em produção +- [ ] **Análises (Analytics)**: Google Analytics ou Plausible para rastreamento de uso +- [ ] **Monitoramento de Desempenho**: Rastreamento de Web Vitals (LCP, FID, CLS) + +#### 5. Documentação Final do MVP +- [ ] **Documentação da API**: Swagger/OpenAPI atualizado com exemplos +- [ ] **Guia do Usuário**: Guia de uso para o Portal Administrativo e o Aplicativo do Cliente +- [ ] **Guia do Desenvolvedor**: Como rodar localmente, como contribuir +- [ ] **Guia de Implantação**: Implantação nos Aplicativos de Contêiner do Azure (modelos ARM ou Bicep) +- [ ] **Lições Aprendidas**: Documentar decisões de arquitetura e compensações (trade-offs) + +#### 6. Módulo de Comunicações (NOVO - Sprint 9) + +**Prioridade**: MÉDIA - Infraestrutura base para funcionalidades pós-MVP +**Objetivo**: Criar módulo unificado de comunicações (e-mail, SMS, push) +**Contexto**: Outros módulos (Avaliações, Pagamentos, Reservas) dependem de infraestrutura de comunicações. Implementar agora evita refatoração depois. + +**Estratégia de Testes (Mandatória)**: + +| Tipo | Escopo | Ferramentas | +|------|--------|------------| +| **Unitários** | Lógica de modelos, cálculo de tentativas (retries), mapeamento de DTOs | ✅ xUnit + FluentAssertions | +| **Integrados** | Persistência do Outbox (PostgreSQL), Handlers de eventos | ✅ xUnit + Respawn + Docker | +| **E2E** | Fluxo completo: Evento → Outbox → Envio simulado → Registro | ✅ Playwright + MSW | +| **Arquiteturais** | Validar que módulos não acessam DB de outros, dependências de contratos | ✅ NetArchTest | + +--- + +**Decisão de Arquitetura** (diferente dos outros módulos): + +| Aspecto | Decisão | +|---------|---------| +| **Padrão** | Módulo Completo + Padrão Orquestrador | +| **Infraestrutura** | Padrão Outbox para garantia de entrega (Mensageria Confiável) | +| **Integração** | Baseada em eventos (consome IntegrationEvents) | +| **API Externa** | Abstração via interface (provedor configurável) | +| **Idempotência** | Garantida via CorrelationId/EventId nos Registros | + +--- + +**Arquitetura de Projetos**: +```text +src/ +├── Shared/ +│ └── MeAjudaAi.Contracts/Modules/Communications/ +│ ├── ICommunicationsModuleApi.cs +│ ├── DTOs/ +│ │ ├── EmailMessageDto.cs +│ │ ├── EmailTemplateDto.cs +│ │ ├── PushMessageDto.cs +│ │ ├── SmsMessageDto.cs +│ │ └── CommunicationLogDto.cs +│ ├── Channels/ +│ │ ├── IEmailChannel.cs +│ │ ├── IPushChannel.cs +│ │ └── ISmsChannel.cs +│ └── Queries/ +│ └── CommunicationLogQuery.cs +│ +├── Modules/ +│ └── Communications/ +│ ├── API/ +│ │ ├── MeAjudaAi.Modules.Communications.API.csproj +│ │ └── Endpoints/ +│ │ └── CommunicationsModuleEndpoints.cs +│ ├── Application/ +│ │ ├── MeAjudaAi.Modules.Communications.Application.csproj +│ │ ├── ModuleApi/ +│ │ │ └── CommunicationsModuleApi.cs +│ │ └── Services/ +│ │ └── Email/ +│ │ └── StubEmailService.cs # Provedor atual (Stubs) +│ ├── Domain/ +│ │ ├── MeAjudaAi.Modules.Communications.Domain.csproj +│ │ └── Entities/ +│ │ ├── OutboxMessage.cs (suporte a ScheduledAt) +│ │ ├── EmailTemplate.cs (flag IsSystemTemplate) +│ │ └── CommunicationLog.cs (suporte a CorrelationId) +│ └── Infrastructure/ +│ ├── MeAjudaAi.Modules.Communications.Infrastructure.csproj +│ └── Persistence/ +│ ├── Configurations/ +│ └── Migrations/ +│ +└── Shared/ + └── Communications/ + └── Templates/ + ├── WelcomeEmail.cshtml + ├── ProviderVerificationApproved.cshtml + └── ProviderVerificationRejected.cshtml +``` + +**Localização de Modelos**: +- **Fonte de Verdade**: Arquivos `.cshtml` em `Shared/Communications/Templates/` (compilados). +- **Sistema de Sobreposição (Override)**: A entidade `EmailTemplate` no banco permite sobrescrever o Assunto (`Subject`) ou trechos do Corpo (`Body`) via Portal Administrativo sem nova implantação. +- **IsSystemTemplate**: Flag para proteger modelos críticos de deleção (Garantido via validação em `EmailTemplateRepository.DeleteAsync`). + +--- + +**Mapeamento de Integração com Eventos**: + +| Evento existente | Ação de Comunicação | +|----------------|-------------------| +| `UserRegisteredIntegrationEvent` | ✅ Enviar e-mail de boas-vindas | +| `ProviderAwaitingVerificationIntegrationEvent` | ✅ Notificar administrador | +| `ProviderVerificationStatusUpdatedIntegrationEvent` | ✅ Notificar prestador | +| `DocumentVerifiedIntegrationEvent` | [ ] Notificar prestador | +| `DocumentRejectedIntegrationEvent` | [ ] Notificar prestador | + +--- + +**Interface ICommunicationsModuleApi (Atualizada)**: +> **Nota**: `ECommunicationPriority` é proveniente de `MeAjudaAi.Contracts.Shared` (não redeclare o enum — use o tipo compartilhado `ECommunicationPriority` diretamente). + +```csharp +public interface ICommunicationsModuleApi : IModuleApi +{ + // E-mail + Task> SendEmailAsync( + EmailMessageDto email, + ECommunicationPriority priority = ECommunicationPriority.Normal, + CancellationToken ct = default); + + Task>> GetTemplatesAsync(CancellationToken ct = default); + + // SMS + Task> SendSmsAsync( + SmsMessageDto sms, + ECommunicationPriority priority = ECommunicationPriority.Normal, + CancellationToken ct = default); + + // Push + Task> SendPushAsync( + PushMessageDto push, + ECommunicationPriority priority = ECommunicationPriority.Normal, + CancellationToken ct = default); + + // Registros (Verificação de idempotência via Identificador de Correlação) + Task>> GetLogsAsync( + CommunicationLogQuery query, + CancellationToken ct = default); +} +``` + +--- + +**Entidades de Domínio**: + +```csharp +// OutboxMessage: Garante entrega e permite agendamento +public class OutboxMessage +{ + public Guid Id { get; } + public string? CorrelationId { get; } // Idempotência + public ECommunicationChannel Channel { get; } + public string Payload { get; } // JSON serializado + public EOutboxMessageStatus Status { get; } // Pending, Processing, Sent, Failed + public int RetryCount { get; } + public DateTime CreatedAt { get; } + public DateTime? ScheduledAt { get; } // Agendamento futuro + public DateTime? SentAt { get; } + public string? ErrorMessage { get; } +} + +// EmailTemplate: Modelos com sistema de sobreposição +public class EmailTemplate +{ + public Guid Id { get; } + public string TemplateKey { get; } // "welcome", "verification-approved" + public string? OverrideKey { get; } // Contexto opcional + public string Subject { get; } + public string HtmlBody { get; } + public string TextBody { get; } + public string Language { get; } // pt-br, en-us + public bool IsSystemTemplate { get; } // Proteção contra exclusão + public DateTime CreatedAt { get; } +} + +// CommunicationLog: Trilha de auditoria + Idempotência +public class CommunicationLog +{ + public Guid Id { get; } + public string CorrelationId { get; } // Idempotência (identificador único) + public ECommunicationChannel Channel { get; } + public string Recipient { get; } + public string? TemplateKey { get; } + public bool IsSuccess { get; } + public DateTime CreatedAt { get; } + public string? ErrorMessage { get; } +} + +--- + +**Infraestrutura - Padrão Outbox**: + +Para garantir que as comunicações não sejam perdidas em caso de falha: + +1. **Processo com Outbox** (garantido): + ```text + Evento Ocorrido → Salvar na Tabela de Outbox (Mesma Transação) → Trabalhador de Background processa Outbox + ``` + +**Melhorias Implementadas**: +- ✅ **Nova tentativa automática**: Com recuo exponencial via Polly. +- ✅ **Priorização**: Mensagens de alta prioridade (ex: Redefinição de Senha) furam a fila. +- ✅ **Idempotência**: Verificação de `CorrelationId` no Registro antes do processamento. +- ✅ **Recuperação**: Mecanismo para resetar mensagens travadas no estado Processing. + +--- + +**Estimativa de Esforço**: + +| Tarefa | Esforço | Status | +|------|--------|-----------| +| 1. Criar estrutura de projetos | 2h | ✅ | +| 2. Interfaces ICommunicationsModuleApi | 2h | ✅ | +| 3. Implementar OutboxMessage (Agendamento) | 5h | ✅ | +| 4. Implementar EmailTemplate (Sistema de sobreposição) | 3h | ✅ | +| 5. Implementar CommunicationLog (CorrelationId) | 2h | ✅ | +| 6. Implementar ModuleApi + Orquestrador | 6h | ✅ | +| 7. Handlers de Canal de Stub (E-mail/Sms/Push) | 5h | ✅ | +| 8. Integração com Eventos Existentes (3/5) | 4h | ✅ | +| 9. Criar modelos básicos (.cshtml) | 3h | ✅ | +| 10. Configuração de DI + Políticas Polly | 3h | ✅ | +| **Total** | **~35h (~5 dias)** | ✅ | + +--- + +**Critérios de Aceitação**: +- ✅ Módulo registrado no ModuleApiRegistry +- ✅ Envio garantido via Padrão Outbox +- ✅ Suporte ao agendamento de mensagens +- ✅ Sistema de modelos híbrido funcional +- ✅ Registros com CorrelationId para evitar duplicidade +- ✅ Integração com mais de 3 IntegrationEvents +- ✅ Priorização de mensagens funcional + +--- + +**Seguimentos Pendentes**: +- [ ] Definir provedores reais (SendGrid, Twilio, Firebase) → **Veja Débito Técnico em docs/technical-debt.md** +- [ ] Interface administrativa para gestão dinâmica de modelos +- [ ] Suporte a anexos via URLs do Armazenamento de Blobs +- [ ] Painel de métricas de entrega e conversão + +--- **Resultado Esperado Sprint 9**: -- ✅ MVP production-ready e polished +- ✅ MVP pronto para produção e polido - ✅ Todos os cenários de risco mitigados ou resolvidos -- ✅ Segurança e performance hardened +- ✅ Segurança e desempenho endurecidos - ✅ Documentação completa para usuários e desenvolvedores -- ✅ Monitoring e observabilidade configurados -- 🎯 **PRONTO PARA LAUNCH EM 12-16 DE MAIO DE 2026** +- ✅ Monitoramento e observabilidade configurados +- ✅ Módulo de Comunicações implementado (infraestrutura base resiliente) +- 🎯 **PRONTO PARA LANÇAMENTO EM 12-16 DE MAIO 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. +> **⚠️ CRITICAL**: Se a Sprint 9 não for suficiente para completar todos os itens, considerar atraso no lançamento do MVP ou reduzir o escopo (mover recursos não críticos para o pós-MVP). A qualidade e a estabilidade do MVP são mais importantes que a data de lançamento. --- @@ -785,17 +1034,17 @@ Durante o processo de atualização automática de dependências pelo Dependabot ### Objetivo Introduzir sistema de avaliações para ranking, modelo de assinaturas premium via Stripe, e verificação automatizada de documentos. -### 3.1. Γ¡É Módulo Reviews & Ratings (Planejado) +### 3.1. 🌟 Módulo Reviews & Ratings (Planejado) -**Objetivo**: Permitir que clientes avaliem prestadores, influenciando ranking de busca. +**Objetivo**: Permitir que clientes avaliem prestadores, influenciando o ranking de busca. #### **Arquitetura Proposta** -- **Padrão**: Simple layered architecture -- **Agregação**: Cálculo de `AverageRating` via integration events (não real-time) +- **Padrão**: Arquitetura em camadas simples +- **Agregação**: Cálculo de `AverageRating` via eventos de integração (não em tempo real) #### **Entidades de Domínio** ```csharp -// Review: Aggregate Root +// Review: Raiz de Agregado public class Review { public Guid ReviewId { get; } @@ -807,7 +1056,7 @@ public class Review public bool IsFlagged { get; } // Para moderação } -// ProviderRating: Aggregate (ou parte do read model) +// ProviderRating: Agregado (ou parte do modelo de leitura) public class ProviderRating { public Guid ProviderId { get; } @@ -832,25 +1081,25 @@ public interface IReviewsModuleApi : IModuleApi ``` #### **Implementação** -1. **Schema**: Criar `meajudaai_reviews` com `reviews`, `provider_ratings` -2. **Submit Endpoint**: Validar que cliente pode avaliar (serviço contratado?) -3. **Rating Calculation**: Publicar `ReviewAddedIntegrationEvent` → Search module atualiza `AverageRating` -4. **Moderação**: Sistema de flag para reviews inapropriados -5. **Testes**: Unit tests para cálculo de média + integration tests para submission +1. **Esquema**: Criar `meajudaai_reviews` com `reviews`, `provider_ratings` +2. **Ponto de Extremidade de Envio**: Validar que o cliente pode avaliar (serviço contratado?) +3. **Cálculo de Classificação**: Publicar `ReviewAddedIntegrationEvent` → Módulo de busca atualiza `AverageRating` +4. **Moderação**: Sistema de sinalização para avaliações inapropriadas +5. **Testes**: Testes unitários para cálculo de média + testes de integração para envio --- -### 3.2. ≡ƒÆ│ Módulo Payments & Billing (Planejado) +### 3.2. 💳 Módulo Payments & Billing (Planejado) **Objetivo**: Gerenciar assinaturas de prestadores via Stripe (Free, Standard, Gold, Platinum). #### **Arquitetura Proposta** -- **Padrão**: Anti-Corruption Layer (ACL) sobre Stripe API -- **Isolamento**: Lógica de domínio protegida de mudanças na Stripe +- **Padrão**: Camada Anti-Corrupção (ACL) sobre a API do Stripe +- **Isolamento**: Lógica de domínio protegida de mudanças no Stripe #### **Entidades de Domínio** ```csharp -// Subscription: Aggregate Root +// Subscription: Raiz de Agregado public class Subscription { public Guid SubscriptionId { get; } @@ -862,7 +1111,7 @@ public class Subscription public DateTime? EndDate { get; } } -// BillingAttempt: Entity +// BillingAttempt: Entidade public class BillingAttempt { public Guid AttemptId { get; } @@ -887,50 +1136,50 @@ public interface IBillingModuleApi : IModuleApi ``` #### **Implementação** -1. **Stripe Setup**: Configurar produtos e pricing plans no dashboard -2. **Webhook Endpoint**: Receber eventos Stripe (`checkout.session.completed`, `invoice.payment_succeeded`, `customer.subscription.deleted`) -3. **Event Handlers**: Atualizar status de `Subscription` baseado em eventos -4. **Checkout Session**: Gerar URL de checkout para frontend -5. **Integration Events**: Publicar `SubscriptionTierChangedIntegrationEvent` → Search module atualiza ranking -6. **Testes**: Integration tests com mock events da Stripe testing library +1. **Configuração do Stripe**: Configurar produtos e planos de preços no painel +2. **Ponto de Extremidade de Webhook**: Receber eventos do Stripe (`checkout.session.completed`, `invoice.payment_succeeded`, `customer.subscription.deleted`) +3. **Handlers de Evento**: Atualizar o status da `Subscription` baseado em eventos +4. **Sessão de Checkout**: Gerar URL de checkout para o frontend +5. **Eventos de Integração**: Publicar `SubscriptionTierChangedIntegrationEvent` → Módulo de busca atualiza o ranking +6. **Testes**: Testes de integração com eventos simulados da biblioteca de testes do Stripe --- -### 3.3. ≡ƒñû Documents - Verificação Automatizada (Planejado - Fase 2) +### 3.3. 🤖 Documents - Verificação Automatizada (Planejado - Fase 2) -**Objetivo**: Automatizar verificação de documentos via OCR e APIs governamentais. +**Objetivo**: Automatizar a verificação de documentos via OCR e APIs governamentais. **Funcionalidades Planejadas**: - **OCR Inteligente**: Azure AI Vision para extrair texto de documentos -- **Validação de Dados**: Cross-check com dados fornecidos pelo prestador -- **Background Checks**: Integração com APIs de antecedentes criminais -- **Scoring Automático**: Sistema de pontuação baseado em qualidade de documentos +- **Validação de Dados**: Verificação cruzada com dados fornecidos pelo prestador +- **Verificações de Antecedentes**: Integração com APIs de antecedentes criminais +- **Pontuação Automática**: Sistema de pontuação baseado na qualidade dos documentos -**Background Jobs**: -1. **DocumentUploadedHandler**: Trigger OCR processing -2. **OcrCompletedHandler**: Validar campos extraídos -3. **VerificationScheduler**: Agendar verificações periódicas +**Trabalhos em Background**: +1. **DocumentUploadedHandler**: Aciona o processamento de OCR +2. **OcrCompletedHandler**: Valida os campos extraídos +3. **VerificationScheduler**: Agenda verificações periódicas -**Nota**: Infraestrutura básica já existe (campo OcrData, estados de verificação), falta implementar workers e integrações. +**Nota**: A infraestrutura básica já existe (campo OcrData, estados de verificação), falta implementar trabalhadores e integrações. --- -### 3.4. ≡ƒÅ╖∩╕Å Dynamic Service Tags (Planejado - Fase 3) +### 3.4. 🏷️ Dynamic Service Tags (Planejado - Fase 3) **Objetivo**: Exibir tags de serviços baseadas na popularidade real por região. **Funcionalidades**: -- **Endpoint**: `GET /services/top-region?city=SP` (ou lat/lon) +- **Ponto de extremidade**: `GET /services/top-region?city=SP` (ou lat/lon) - **Lógica**: Calcular serviços com maior volume de buscas/contratações na região do usuário. -- **Fallback**: Exibir "Top Globais" se dados regionais insuficientes. -- **Cache**: TTL curto (ex: 1h) para manter relev├óncia sem comprometer performance. +- **Recuo (Fallback)**: Exibir "Top Globais" se os dados regionais forem insuficientes. +- **Cache**: TTL curto (ex: 1h) para manter a relevância sem comprometer o desempenho. --- -## ≡ƒÜÇ Fase 4: Experiência e Engajamento (Post-MVP) +## 🚀 Fase 4: Experiência e Engajamento (Pós-MVP) ### Objetivo -Melhorar experiência do usuário com agendamentos, comunicações centralizadas e analytics avançado. +Melhorar a experiência do usuário com agendamentos, comunicações centralizadas e análises avançadas. ### 4.1. 📅 Módulo Service Requests & Booking (Planejado) @@ -938,53 +1187,21 @@ Melhorar experiência do usuário com agendamentos, comunicações centralizadas #### **Funcionalidades** - **Solicitação de Serviço**: Cliente descreve necessidade e localização -- **Matching**: Sistema sugere prestadores compatíveis -- **Agendamento**: Calendário integrado com disponibilidade de prestador -- **Notificações**: Lembretes automáticos via Communications module +- **Correspondência (Matching)**: O sistema sugere prestadores compatíveis +- **Agendamento**: Calendário integrado com disponibilidade do prestador +- **Notificações**: Lembretes automáticos via módulo de Comunicações --- -### 4.2. ≡ƒôº Módulo Communications (Planejado) - -**Objetivo**: Centralizar e orquestrar todas as comunicações da plataforma (email, SMS, push). - -#### **Arquitetura Proposta** -- **Padrão**: Orchestrator Pattern -- **Canais**: Email (SendGrid/Mailgun), SMS (Twilio), Push (Firebase) - -#### **API Pública (ICommunicationsModuleApi)** -```csharp -public interface ICommunicationsModuleApi : IModuleApi -{ - Task SendEmailAsync(EmailRequest request, CancellationToken ct = default); - Task SendSmsAsync(SmsRequest request, CancellationToken ct = default); - Task SendPushNotificationAsync(PushRequest request, CancellationToken ct = default); -} -``` - -#### **Event Handlers** -- `UserRegisteredIntegrationEvent` → Email de boas-vindas -- `ProviderVerificationFailedIntegrationEvent` → Notificação de rejeição -- `BookingConfirmedIntegrationEvent` → Lembrete de agendamento - -#### **Implementação** -1. **Channel Handlers**: Implementar `IEmailService`, `ISmsService`, `IPushService` -2. **Template Engine**: Sistema de templates para mensagens (Razor, Handlebars) -3. **Queue Processing**: Background worker para processar fila de mensagens -4. **Retry Logic**: Polly para retry com backoff exponencial -5. **Testes**: Unit tests para handlers + integration tests com mock services - ---- - -### 4.3. 📊 Módulo Analytics & Reporting (Planejado) +### 4.2. 📊 Módulo Analytics & Reporting (Planejado) **Objetivo**: Capturar, processar e visualizar dados de negócio e operacionais. #### **Arquitetura Proposta** -- **Padrão**: CQRS + Event Sourcing (para audit) -- **Metrics**: Façade sobre OpenTelemetry/Aspire -- **Audit**: Immutable event log de todas as atividades -- **Reporting**: Denormalized read models para queries rápidos +- **Padrão**: CQRS + Event Sourcing (para auditoria) +- **Métricas**: Fachada sobre OpenTelemetry/Aspire +- **Auditoria**: Log de eventos imutável de todas as atividades +- **Relatórios**: Modelos de leitura desnormalizados para consultas rápidas #### **API Pública (IAnalyticsModuleApi)** ```csharp @@ -997,7 +1214,7 @@ public interface IAnalyticsModuleApi : IModuleApi } ``` -#### **Database Views** +#### **Exibições de Banco de Dados** ```sql -- vw_provider_summary: Visão holística de cada prestador CREATE VIEW meajudaai_analytics.vw_provider_summary AS @@ -1025,7 +1242,7 @@ SELECT FROM meajudaai_billing.billing_attempts ba JOIN meajudaai_billing.subscriptions s ON ba.subscription_id = s.subscription_id; --- vw_audit_log_enriched: Audit log legível +-- vw_audit_log_enriched: Log de auditoria legível CREATE VIEW meajudaai_analytics.vw_audit_log_enriched AS SELECT al.log_id, @@ -1041,44 +1258,44 @@ LEFT JOIN providers.providers p ON al.actor_id = p.provider_id; ``` #### **Implementação** -1. **Schema**: Criar `meajudaai_analytics` com `audit_log`, reporting tables -2. **Event Handlers**: Consumir todos integration events relevantes -3. **Metrics Integration**: Expor métricas customizadas via OpenTelemetry -4. **Reporting API**: Endpoints otimizados para leitura de relatórios -5. **Dashboards**: Integração com Aspire Dashboard e Grafana -6. **Testes**: Integration tests para event handlers + performance tests para reporting +1. **Esquema**: Criar `meajudaai_analytics` com `audit_log`, tabelas de relatórios +2. **Handlers de Evento**: Consumir todos os eventos de integração relevantes +3. **Integração de Métricas**: Expor métricas personalizadas via OpenTelemetry +4. **API de Relatórios**: Pontos de extremidade otimizados para leitura de relatórios +5. **Painéis**: Integração com Aspire Dashboard e Grafana +6. **Testes**: Testes de integração para handlers de evento + testes de desempenho para relatórios --- ## 🎯 Funcionalidades Adicionais Recomendadas (Fase 4+) -### ≡ƒ¢í∩╕Å Admin Portal - Módulos Avançados +### 🛡️ Admin Portal - Módulos Avançados **Funcionalidades Adicionais (Pós-MVP)**: -- **Recent Activity Dashboard Widget**: Feed de atividades recentes (registros, uploads, verificações, mudanças de status) com atualizações em tempo real via SignalR -- **User & Provider Analytics**: Dashboards avançados com Grafana -- **Fraud Detection**: Sistema de scoring para detectar perfis suspeitos -- **Bulk Operations**: Ações em lote (ex: aprovar múltiplos documentos) -- **Audit Trail**: Histórico completo de todas ações administrativas +- **Widget de Painel de Atividades Recentes**: Feed cronológico de atividades (registros, envios, verificações, mudanças de status) com atualizações em tempo real via SignalR +- **Análises de Usuário e Prestador**: Painéis avançados com Grafana +- **Detecção de Fraude**: Sistema de pontuação para detectar perfis suspeitos +- **Operações em Lote**: Ações em lote (ex: aprovar múltiplos documentos) +- **Trilha de Auditoria**: Histórico completo de todas as ações administrativas -#### 📊 Recent Activity Widget (Prioridade: MÉDIA) +#### 📊 Widget de Atividade Recente (Prioridade: MÉDIA) -**Contexto**: Atualmente o Dashboard exibe apenas gráficos estáticos. Um feed de atividades recentes melhoraria a visibilidade operacional. +**Contexto**: Atualmente, o Painel exibe apenas gráficos estáticos. Um feed de atividades recentes melhoraria a visibilidade operacional. **Funcionalidades Core**: -- **Timeline de Eventos**: Feed cronológico de atividades do sistema +- **Linha do Tempo de Eventos**: Feed cronológico de atividades do sistema - **Tipos de Eventos**: - Novos registros de prestadores - - Uploads de documentos + - Envios de documentos - Mudanças de status de verificação - Ações administrativas (aprovações/rejeições) - Adições/remoções de serviços - **Filtros**: Por tipo de evento, módulo, data -- **Real-time Updates**: SignalR para atualização automática +- **Atualizações em Tempo Real**: SignalR para atualização automática - **Paginação**: Carregar mais atividades sob demanda **Implementação Técnica**: ```csharp -// Domain Events → Integration Events → SignalR Hub +// Eventos de Domínio → Eventos de Integração → SignalR Hub public record ProviderRegisteredEvent(Guid ProviderId, string Name, DateTime Timestamp); public record DocumentUploadedEvent(Guid DocumentId, string Type, DateTime Timestamp); public record VerificationStatusChangedEvent(Guid ProviderId, string OldStatus, string NewStatus); @@ -1091,64 +1308,74 @@ public class ActivityHub : Hub await Clients.All.SendAsync("ReceiveActivity", activity); } } +``` -// Frontend Component -@inject HubConnection HubConnection - - - @foreach (var activity in RecentActivities) - { - - @activity.Description - @activity.Timestamp.ToRelativeTime() - - } - - -@code { - protected override async Task OnInitializedAsync() - { - HubConnection.On("ReceiveActivity", activity => - { - RecentActivities.Insert(0, activity); - StateHasChanged(); - }); - await HubConnection.StartAsync(); - } +```typescript +// Componente Frontend (React) +import { useEffect, useState } from 'react'; +import * as signalR from '@microsoft/signalr'; + +export function ActivityTimeline() { + const [activities, setActivities] = useState([]); + + useEffect(() => { + const connection = new signalR.HubConnectionBuilder() + .withUrl("/hubs/activity") + .withAutomaticReconnect() + .build(); + + connection.on("ReceiveActivity", (activity: ActivityDto) => { + setActivities(prev => [activity, ...prev]); + }); + + connection.start(); + return () => { connection.stop(); }; + }, []); + + return ( + + {activities.map(activity => ( + + {activity.description} + {formatRelative(activity.timestamp)} + + ))} + + ); } ``` -**Estimativa**: 3-5 dias (1 dia backend events, 1 dia SignalR, 2-3 dias frontend) +**Estimativa**: 3-5 dias (1 dia para eventos de backend, 1 dia para SignalR, 2-3 dias para frontend) **Dependências**: - SignalR configurado no backend -- Event bus consumindo domain events -- ActivityDto contract definido +- Barramento de eventos consumindo eventos de domínio +- Contrato ActivityDto definido --- -### 👤 Customer Profile Management (Alta Prioridade) -**Por quê**: Plano atual é muito focado em prestadores; clientes também precisam de gestão de perfil. +### 👤 Gestão de Perfil do Cliente (Alta Prioridade) +**Por quê**: O plano atual é muito focado em prestadores; os clientes também precisam de gestão de perfil. **Funcionalidades Core**: - Editar informações básicas (nome, foto) - Ver histórico de prestadores contatados -- Gerenciar reviews escritos +- Gerenciar avaliações escritas - Preferências de notificações -**Implementação**: Enhancement ao módulo Users existente +**Implementação**: Melhoria (Enhancement) no módulo Users existente --- -### ⚖️∩╕Å Dispute Resolution System (Média Prioridade) -**Por quê**: Mesmo sem pagamentos in-app, disputas podem ocorrer (reviews injustos, má conduta). +### ⚖️ Sistema de Resolução de Disputas (Média Prioridade) +**Por quê**: Mesmo sem pagamentos no aplicativo, podem ocorrer disputas (avaliações injustas, má conduta). **Funcionalidades Core**: -- Botão "Reportar" em perfis de prestadores e reviews -- Formulário para descrever problema -- Fila no Admin Portal para moderadores +- Botão "Reportar" em perfis de prestadores e avaliações +- Formulário para descrever o problema +- Fila no Portal Administrativo para moderadores -**Implementação**: Novo módulo pequeno ou extensão do módulo Reviews +**Implementação**: Novo módulo pequeno ou extensão do módulo de Avaliações --- @@ -1158,29 +1385,29 @@ public class ActivityHub : Hub - **Crescimento de usuários**: 20% ao mês - **Retenção de prestadores**: 85% - **Satisfação média**: 4.5+ estrelas -- **Taxa de conversão (Free → Paid)**: 15% +- **Taxa de conversão (Grátis → Pago)**: 15% ### ⚡ Métricas Técnicas (SLOs) -#### **Tiered Performance Targets** +#### **Metas de Desempenho em Camadas** | Categoria | Tempo Alvo | Exemplo | |-----------|------------|---------| | **Consultas Simples** | <200ms | Busca por ID, dados em cache | | **Consultas Médias** | <500ms | Listagens com filtros básicos | -| **Consultas Complexas** | <1000ms | Busca cross-module, agregações | -| **Consultas Analíticas** | <3000ms | Relatórios, dashboards | +| **Consultas Complexas** | <1000ms | Busca entre módulos, agregações | +| **Consultas Analíticas** | <3000ms | Relatórios, painéis | -#### **Baseline de Desempenho** +#### **Linha de Base de Desempenho** - **Assumindo**: Cache distribuído configurado, índices otimizados - **Revisão Trimestral**: Ajustes baseados em métricas reais - - **Percentis monitorados**: P50, P95, P99 (latência de queries) + - **Percentis monitorados**: P50, P95, P99 (latência de consultas) - **Frequência**: Análise e ajuste a cada 3 meses - - **Processo**: Feedback loop → identificar outliers → otimizar queries lentas + - **Processo**: Ciclo de feedback → identificar outliers → otimizar consultas lentas - **Monitoramento**: OpenTelemetry + Aspire Dashboard + Application Insights #### **Outros SLOs** -- **Disponibilidade**: 99.9% uptime +- **Disponibilidade**: 99.9% de tempo de atividade (uptime) - **Segurança**: Zero vulnerabilidades críticas - **Cobertura de Testes**: >80% para código crítico @@ -1189,76 +1416,75 @@ public class ActivityHub : Hub ## 🔄 Processo de Gestão do Roadmap ### 📅 Revisão Trimestral -- Avaliação de progresso contra milestones +- Avaliação de progresso em relação aos marcos (milestones) - Ajuste de prioridades baseado em métricas - Análise de feedback de usuários e prestadores ### 💬 Feedback Contínuo -- **Input da comunidade**: Surveys, suporte, analytics +- **Contribuição da comunidade**: Pesquisas, suporte, análises - **Feedback de prestadores**: Portal dedicado para sugestões -- **Necessidades de negócio**: Alinhamento com stakeholders +- **Necessidades de negócio**: Alinhamento com as partes interessadas (stakeholders) ### 🎯 Critérios de Priorização -1. **Impacto no MVP**: Funcionalidade é crítica para lançamento? +1. **Impacto no MVP**: A funcionalidade é crítica para o lançamento? 2. **Esforço de Implementação**: Complexidade técnica e tempo estimado 3. **Dependências**: Quais módulos dependem desta funcionalidade? -4. **Valor para Usuário**: Feedback qualitativo e quantitativo +4. **Valor para o Usuário**: Feedback qualitativo e quantitativo --- ## 📋 Sumário Executivo de Prioridades ### ✅ **Concluído (Set-Dez 2025)** -1. ✅ Sprint 0: Migration .NET 10 + Aspire 13 (21 Nov 2025 - MERGED to master) -2. ✅ Sprint 1: Geographic Restriction + Module Integration (2 Dez 2025 - MERGED to master) -3. ✅ Sprint 2: Test Coverage 90.56% (10 Dez 2025) - Meta 35% SUPERADA em 55.56pp! -4. ✅ Sprint 5.5: Package Lock Files Fix (19 Dez 2025) - - Correção conflitos Microsoft.OpenApi (2.3.12 → 2.3.0) +1. ✅ Sprint 0: Migração .NET 10 + Aspire 13 (21 Nov 2025 - MERGE para master) +2. ✅ Sprint 1: Restrição Geográfica + Integração de Módulos (2 Dez 2025 - MERGE para master) +3. ✅ Sprint 2: Cobertura de Testes de 90.56% (10 Dez 2025) - Meta de 35% SUPERADA em 55.56pp! +4. ✅ Sprint 5.5: Correção de Arquivos de Lock de Pacote (19 Dez 2025) + - Correção de conflitos Microsoft.OpenApi (2.3.12 → 2.3.0) - 37 arquivos packages.lock.json regenerados - PRs #81 e #82 atualizados e aguardando merge 5. ✅ Módulo Users (Concluído) 6. ✅ Módulo Providers (Concluído) 7. ✅ Módulo Documents (Concluído) 8. ✅ Módulo Search & Discovery (Concluído) -9. ✅ Módulo Locations - CEP lookup e geocoding (Concluído) -10. ✅ Módulo ServiceCatalogs - Catálogo admin-managed (Concluído) -11. ✅ CI/CD - GitHub Actions workflows (.NET 10 + Aspire 13) -12. ✅ Feature/refactor-and-cleanup branch - Merged to master (19 Dez 2025) +9. ✅ Módulo Locations - Busca de CEP e geocodificação (Concluído) +10. ✅ Módulo ServiceCatalogs - Catálogo gerenciado por admin (Concluído) +11. ✅ CI/CD - Fluxos de trabalho do GitHub Actions (.NET 10 + Aspire 13) +12. ✅ Ramo (branch) feature/refactor-and-cleanup - Mesclado para master (19 Dez 2025) ### 📅 Alta Prioridade (Próximos 3 meses - Q1-Q2 2026) -1. ✅ **Sprint 8B.2: NX Monorepo & Technical Excellence** (Concluída) -2. ✅ **Sprint 8C: Provider Web App (React + NX)** (Concluída - 21 Mar 2026) -3. ✅ **Sprint 8D: Admin Portal Migration** (Concluída - 24 Mar 2026) -4. ✅ **Sprint 8E: E2E Tests React Apps (Playwright)** (Concluída - 25 Mar 2026) -5. ⏳ **Sprint 9: BUFFER & RISK MITIGATION** (Abril/Maio 2026) +1. ✅ **Sprint 8B.2: NX Monorepo e Excelência Técnica** (Concluída) +2. ✅ **Sprint 8C: App Web do Prestador (React + NX)** (Concluída - 21 Mar 2026) +3. ✅ **Sprint 8D: Migração do Portal Administrativo** (Concluída - 24 Mar 2026) +4. ✅ **Sprint 8E: Testes E2E Apps React (Playwright)** (Concluída - 25 Mar 2026) +5. ⏳ **Sprint 9: BUFFER E MITIGAÇÃO DE RISCO** (25 Mar - 11 Abr 2026) 6. 🎯 **MVP Final Launch: 12 - 16 de Maio de 2026** -7. 📋 API Collections - Bruno .bru files para todos os módulos +7. 📋 Coleções de API - Arquivos .bru do Bruno para todos os módulos ### 🎯 **Alta Prioridade - Pré-MVP** -1. 🎯 Communications - Email notifications +1. 🎯 Communications - Notificações por e-mail 2. 💳 Módulo Payments & Billing (Stripe) - Preparação para monetização ### 🎯 **Média Prioridade (6-12 meses - Fase 2)** 1. 🎉 Módulo Reviews & Ratings -2. 🌍 Documents - Verificação automatizada (OCR + Background checks) -3. 🔄 Search - Indexing worker para integration events (extensão do módulo SearchProviders) +2. 🌍 Documents - Verificação automatizada (OCR + Verificações de antecedentes) +3. 🔄 Search - Trabalhador de indexação para eventos de integração (extensão do módulo SearchProviders) 4. 📊 Analytics - Métricas básicas -5. 🏛️ Dispute Resolution System +5. 🏛️ Sistema de Resolução de Disputas 6. 🔥 Alinhamento de middleware entre UseSharedServices() e UseSharedServicesAsync() ### 🔬 **Testes E2E Frontend (Pós-MVP)** **Projeto**: `src/Web` (dividido por projeto) **Estrutura**: Uma pasta para cada projeto frontend -- `src/Web/MeAjudaAi.Web.Customer/e2e/` - Testes E2E para Customer Web App -- `src/Web/MeAjudaAi.Web.Provider/e2e/` - Testes E2E para Provider Web App -- `src/Web/MeAjudaAi.Web.Admin/e2e/` - Testes E2E para Admin Portal +- `src/Web/MeAjudaAi.Web.Customer/e2e/` - Testes E2E para App Web do Cliente +- `src/Web/MeAjudaAi.Web.Provider/e2e/` - Testes E2E para App Web do Prestador +- `src/Web/MeAjudaAi.Web.Admin/e2e/` - Testes E2E para Portal Administrativo **Framework**: Playwright **Cenários a cobrir**: - [ ] Autenticação (login, logout, refresh token) -- [ ] Fluxo de onboarding (Customer e Provider) -- [ ] CRUD de providers e serviços +- [ ] Fluxo de onboarding (Cliente e Prestador) +- [ ] CRUD de prestadores e serviços - [ ] Busca e filtros - [ ] Responsividade mobile -- [ ] Performance e Core Web Vitals - +- [ ] Desempenho e Core Web Vitals diff --git a/docs/roadmap.md b/docs/roadmap.md index 34f045a2e..0bf5c0bac 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,6 +9,7 @@ Este documento consolida o planejamento estratégico e tático da plataforma MeA Contém o status atual das sprints, o cronograma detalhado até o MVP e o plano de mitigação de riscos. - **Sprint Atual**: 9 (Buffer & Risk Mitigation) - **Sprint Concluída**: 8E (E2E Tests & React Unit Infrastructure) +- **Sprint em Andamento**: 9E (Buffer & Risk Mitigation + Módulo Comunicações) - **Meta MVP**: Maio 2026 (12-16) --- @@ -38,6 +39,8 @@ Contém os objetivos pós-MVP e ideias para o backlog de longo prazo. --- ## 🏗️ Decisões Arquiteturais Recentes + - **NX Monorepo**: Adotado para unificar o desenvolvimento frontend e compartilhamento de código. - **Dual-Stack Transition**: Transição de Blazor WASM para React 19 (Next.js) para unificação da stack. - **Testing Infrastructure**: Implementação de Vitest + MSW para unitários e Playwright para E2E, com agregação de cobertura global. +- **Módulo Comunicações** (Abril 2026): Implementado durante Sprint 9 com Outbox Pattern - provedor configurável pós-MVP. diff --git a/src/Aspire/MeAjudaAi.AppHost/packages.lock.json b/src/Aspire/MeAjudaAi.AppHost/packages.lock.json index 83579efbf..5b932ad8b 100644 --- a/src/Aspire/MeAjudaAi.AppHost/packages.lock.json +++ b/src/Aspire/MeAjudaAi.AppHost/packages.lock.json @@ -2,11 +2,11 @@ "version": 2, "dependencies": { "net10.0": { - "Aspire.Dashboard.Sdk.linux-x64": { + "Aspire.Dashboard.Sdk.win-x64": { "type": "Direct", "requested": "[13.2.1, )", "resolved": "13.2.1", - "contentHash": "rUlEhekc+EyDbOcyfWneGBikNvdLuV5UPtOww2KpUOAcO7oBVG70kTJiFzN/UYU3I/5Udc1xoDt2lWIoyEYADQ==" + "contentHash": "KLB9rXwY8kg2taWwxsJFoK0cAuupSZurcv1zTyYMqLyNuwvYYjs65Yz3g/cgh22QlUfOT3tOh+Jzk5MdJhy5+w==" }, "Aspire.Hosting.AppHost": { "type": "Direct", @@ -201,11 +201,11 @@ "System.IO.Hashing": "10.0.3" } }, - "Aspire.Hosting.Orchestration.linux-x64": { + "Aspire.Hosting.Orchestration.win-x64": { "type": "Direct", "requested": "[13.2.1, )", "resolved": "13.2.1", - "contentHash": "LcC21cYVVsTDSQe4B0i7X2q1U8u5Bl+X53wPfucfW4YlQhAGzG2FajVfa5PRDduGlp5mjtgjh2vDO4oEBfpSUg==" + "contentHash": "39lRUH4WuCsBaYB7fZH1/r81SSJIXrA8WphBlAdP1QT95+1sKQHzXJuXU4nzKpBLv4oZmjcWzvA+FDMGZbWmkw==" }, "Aspire.Hosting.PostgreSQL": { "type": "Direct", @@ -1433,6 +1433,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Aspire/MeAjudaAi.ServiceDefaults/Extensions.cs b/src/Aspire/MeAjudaAi.ServiceDefaults/Extensions.cs index 4d7d08051..ea8cb13c9 100644 --- a/src/Aspire/MeAjudaAi.ServiceDefaults/Extensions.cs +++ b/src/Aspire/MeAjudaAi.ServiceDefaults/Extensions.cs @@ -29,6 +29,8 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where builder.ConfigureHttpClients(); + builder.Services.AddLocalization(); + return builder; } @@ -121,6 +123,14 @@ private static TBuilder AddOpenTelemetryExporters(this TBuilder builde public static WebApplication MapDefaultEndpoints(this WebApplication app) { + var supportedCultures = new[] { "pt-BR", "en-US" }; + var localizationOptions = new RequestLocalizationOptions() + .SetDefaultCulture(supportedCultures[0]) + .AddSupportedCultures(supportedCultures) + .AddSupportedUICultures(supportedCultures); + + app.UseRequestLocalization(localizationOptions); + if (app.Environment.IsDevelopment() || IsTestingEnvironment()) { // Health endpoint excludes critical infrastructure (database) - only external services diff --git a/src/Aspire/MeAjudaAi.ServiceDefaults/packages.lock.json b/src/Aspire/MeAjudaAi.ServiceDefaults/packages.lock.json index 6b5b4dc36..31cc4050d 100644 --- a/src/Aspire/MeAjudaAi.ServiceDefaults/packages.lock.json +++ b/src/Aspire/MeAjudaAi.ServiceDefaults/packages.lock.json @@ -528,6 +528,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -607,6 +608,16 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs index 58be3e624..107dab070 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/SecurityExtensions.cs @@ -1,6 +1,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using MeAjudaAi.ApiService.Handlers; +using MeAjudaAi.ApiService.Middlewares; using MeAjudaAi.ApiService.Options; using MeAjudaAi.ApiService.Services.HostedServices; using MeAjudaAi.Modules.Users.Infrastructure.Identity.Keycloak; @@ -282,7 +283,13 @@ public static IServiceCollection AddCorsPolicy( } else { - policy.WithHeaders([.. corsOptions.AllowedHeaders]); + // Garante que X-XSRF-TOKEN seja incluído nos headers permitidos para o preflight + var headers = corsOptions.AllowedHeaders.ToList(); + if (!headers.Contains("X-XSRF-TOKEN", StringComparer.OrdinalIgnoreCase)) + { + headers.Add("X-XSRF-TOKEN"); + } + policy.WithHeaders([.. headers]); } // Configura credenciais (apenas se explicitamente habilitado) @@ -293,6 +300,9 @@ public static IServiceCollection AddCorsPolicy( // Define tempo máximo de cache do preflight policy.SetPreflightMaxAge(TimeSpan.FromSeconds(corsOptions.PreflightMaxAge)); + + // Expor header do token de antiforgery para clientes SPA + policy.WithExposedHeaders("X-XSRF-TOKEN"); }); }); @@ -552,6 +562,42 @@ public static IServiceCollection AddCustomRateLimiting(this IServiceCollection s })); options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + // Política Global Dinâmica (IP vs Usuário Autenticado) + options.GlobalLimiter = PartitionedRateLimiter.Create(context => + { + var isAuthenticated = context.User.Identity?.IsAuthenticated == true; + var key = isAuthenticated + ? context.User.FindFirst("sub")?.Value ?? context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? context.Connection.Id ?? "authenticated-anonymous" + : context.Connection.RemoteIpAddress?.ToString() ?? context.Connection.Id ?? "test-client"; + + var permitLimit = isAuthenticated + ? configuration.GetValue("AdvancedRateLimit:Authenticated:RequestsPerMinute", 120) + : configuration.GetValue("AdvancedRateLimit:Anonymous:RequestsPerMinute", 30); + + return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions + { + AutoReplenishment = true, + PermitLimit = permitLimit, + QueueLimit = 0, + Window = TimeSpan.FromMinutes(1) + }); + }); + }); + + return services; + } + + public static IServiceCollection AddCustomAntiforgery(this IServiceCollection services) + { + services.AddAntiforgery(options => + { + // O token é enviado via header (comum em APIs/SPAs) + options.HeaderName = "X-XSRF-TOKEN"; + options.Cookie.Name = "XSRF-TOKEN"; + options.Cookie.HttpOnly = false; // Deve ser acessível pelo JS para ler e enviar no header + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.Strict; }); return services; diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs index ded6230af..9242062eb 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Extensions/ServiceCollectionExtensions.cs @@ -56,6 +56,7 @@ public static IServiceCollection AddApiServices( services.AddDocumentation(); services.AddApiVersioning(); // Adiciona versionamento de API services.AddCorsPolicy(configuration, environment); + services.AddCustomAntiforgery(); services.AddMemoryCache(); // Configura ForwardedHeaders para suporte a proxy reverso (load balancers, nginx, etc.) @@ -123,13 +124,20 @@ public static WebApplication UseApiServices( // Exception handling DEVE estar no início do pipeline app.UseExceptionHandler(); - // Content Security Policy - adicionar no início para proteger todas as respostas - app.UseContentSecurityPolicy(); + if (!environment.IsDevelopment() && !environment.IsEnvironment("Testing")) + { + app.UseHsts(); + } // ForwardedHeaders deve ser o primeiro para popular corretamente RemoteIpAddress para rate limiting // Processa cabeçalhos X-Forwarded-* de proxies reversos (load balancers, nginx, etc.) app.UseForwardedHeaders(); + app.UseHttpsRedirection(); + + // Content Security Policy - adicionar no início para proteger todas as respostas + app.UseContentSecurityPolicy(); + // Logging Context Middleware - adiciona correlation ID aos logs e response headers app.UseLoggingContext(); @@ -163,6 +171,11 @@ public static WebApplication UseApiServices( app.UseCors("DefaultPolicy"); app.UseAuthentication(); + app.UseAuthorization(); + app.UseAntiforgery(); + + // Middleware para expor o cookie de antiforgery em requisições GET (Hardening) + app.UseMiddleware(); // Debug Middleware para diagnóstico de autorização (apenas em desenvolvimento) if (app.Environment.IsDevelopment()) @@ -174,7 +187,6 @@ public static WebApplication UseApiServices( app.UseMiddleware(); app.UsePermissionOptimization(); // Middleware de otimização após autenticação - app.UseAuthorization(); // Mapear endpoints de configuração (deve ser chamado após UseAuthorization) app.MapConfigurationEndpoints(); diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj b/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj index 891cd3220..c9b66dfdc 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj +++ b/src/Bootstrapper/MeAjudaAi.ApiService/MeAjudaAi.ApiService.csproj @@ -26,6 +26,7 @@ + diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/AntiforgeryCookieMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/AntiforgeryCookieMiddleware.cs new file mode 100644 index 000000000..95d274ec5 --- /dev/null +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/AntiforgeryCookieMiddleware.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; + +namespace MeAjudaAi.ApiService.Middlewares; + +/// +/// Middleware para garantir que o cookie de Antiforgery seja enviado em requisições GET. +/// Isso permite que SPAs leiam o cookie e enviem o token nos headers de requisições subsequentes (POST, PUT, DELETE). +/// +public sealed class AntiforgeryCookieMiddleware( + RequestDelegate next, + IAntiforgery antiforgery, + ILogger logger, + IWebHostEnvironment env) +{ + private readonly RequestDelegate _next = next ?? throw new ArgumentNullException(nameof(next)); + private readonly IAntiforgery _antiforgery = antiforgery ?? throw new ArgumentNullException(nameof(antiforgery)); + + public async Task InvokeAsync(HttpContext context) + { + // Se for uma requisição GET, gera e armazena os tokens (configurando o cookie) + if (HttpMethods.IsGet(context.Request.Method)) + { + try + { + var tokens = _antiforgery.GetAndStoreTokens(context); + + // Opcional: Adicionar o token também no header da resposta atual para facilitar a captura inicial + if (tokens.RequestToken != null) + { + context.Response.Headers.Append("X-XSRF-TOKEN", tokens.RequestToken); + } + } + catch (Exception ex) + { + // Em ambientes de teste ou bypass, falha silenciosa é aceitável para evitar ruído + if (!env.IsEnvironment("Testing") && !env.IsEnvironment("Test")) + { + logger.LogError(ex, "Error generating or storing antiforgery token for request {Path}", context.Request.Path); + + // Em produção ou outros ambientes, não queremos derrubar a requisição mas queremos que o erro seja visível + if (!env.IsDevelopment()) + { + throw; + } + } + } + } + + await _next(context); + } +} diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/CompressionSecurityMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/CompressionSecurityMiddleware.cs index fd1b98a13..cb9d72853 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/CompressionSecurityMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/CompressionSecurityMiddleware.cs @@ -1,3 +1,6 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + namespace MeAjudaAi.ApiService.Middlewares; /// @@ -10,19 +13,32 @@ namespace MeAjudaAi.ApiService.Middlewares; public class CompressionSecurityMiddleware { private readonly RequestDelegate _next; + private readonly ILogger _logger; - public CompressionSecurityMiddleware(RequestDelegate next) + public CompressionSecurityMiddleware(RequestDelegate next, ILogger logger) { _next = next; + _logger = logger; } public async Task InvokeAsync(HttpContext context) { + var isSafe = IsSafeForCompression(context); + // Verifica se é seguro comprimir antes de permitir que o middleware de compressão processe - if (!IsSafeForCompression(context)) + if (!isSafe) { - // Desabilita a compressão para esta requisição removendo o Accept-Encoding - context.Request.Headers.Remove("Accept-Encoding"); + _logger.LogWarning("Compression disabled for request {Path} due to security policy (BREACH/CRIME protection).", context.Request.Path); + + // Desabilita a compressão para esta requisição. + // Definir como "identity" é mais explícito do que remover o header para alguns proxies/middlewares. + context.Request.Headers["Accept-Encoding"] = "identity"; + + // Adiciona um marker no context para auditoria/testes + context.Items["CompressionDisabledBySecurity"] = true; + + // Adiciona o header de resposta ANTES de chamar o próximo middleware + context.Response.Headers["X-Compression-Disabled"] = "Security-Policy"; } await _next(context); @@ -48,7 +64,7 @@ private static bool IsSafeForCompression(HttpContext context) var sensitivePaths = new[] { "/auth", "/login", "/token", "/refresh", "/logout", - "/api/auth", "/api/login", "/api/token", "/api/refresh", + "/api/auth", "/api/login", "/api/token", "/api/refresh", "/api/logout", "/connect", "/oauth", "/openid", "/identity", "/users/profile", "/users/me", "/account" }; diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs index 09241510f..0f4d26843 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Middlewares/SecurityHeadersMiddleware.cs @@ -1,72 +1,63 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + namespace MeAjudaAi.ApiService.Middlewares; /// -/// Middleware para adicionar cabeçalhos de segurança com impacto mínimo na performance +/// Middleware para adicionar cabeçalhos de segurança (Hardening). +/// Atua como um fallback seguro, garantindo que cabeçalhos essenciais estejam presentes +/// sem sobrescrever configurações específicas de ambiente ou middlewares especializados (como CSP). /// -public class SecurityHeadersMiddleware(RequestDelegate next, IWebHostEnvironment environment) +public sealed class SecurityHeadersMiddleware( + RequestDelegate next, + ILogger? logger = null) { - private readonly RequestDelegate _next = next; - private readonly bool _isDevelopment = environment.IsDevelopment(); - - // Valores de cabeçalho pré-computados para evitar concatenação de strings a cada requisição - private static readonly KeyValuePair[] StaticHeaders = - [ - new("X-Content-Type-Options", "nosniff"), - new("X-Frame-Options", "DENY"), - new("X-XSS-Protection", "1; mode=block"), - new("Referrer-Policy", "strict-origin-when-cross-origin"), - new("Permissions-Policy", "geolocation=(), microphone=(), camera=()"), - new("Content-Security-Policy", - "default-src 'self'; " + - "script-src 'self' 'unsafe-inline'; " + - "style-src 'self' 'unsafe-inline'; " + - "img-src 'self' data: https:; " + - "font-src 'self'; " + - "connect-src 'self'; " + - "frame-ancestors 'none';") - ]; + private readonly RequestDelegate _next = next ?? throw new ArgumentNullException(nameof(next)); - private const string HstsHeaderName = "Strict-Transport-Security"; - private const string HstsHeader = "max-age=31536000; includeSubDomains"; + // Nomes dos Cabeçalhos + private const string XFrameOptions = "X-Frame-Options"; + private const string XContentTypeOptions = "X-Content-Type-Options"; + private const string ReferrerPolicy = "Referrer-Policy"; + private const string XPoweredBy = "X-Powered-By"; - // Cabeçalhos para remover - usando array para iteração mais rápida - private static readonly string[] HeadersToRemove = ["Server", "X-Powered-By", "X-AspNet-Version"]; + // Valores dos Cabeçalhos + private const string Deny = "DENY"; + private const string NoSniff = "nosniff"; + private const string StrictOriginWhenCrossOrigin = "strict-origin-when-cross-origin"; - public Task InvokeAsync(HttpContext context) + public async Task InvokeAsync(HttpContext context) { - ArgumentNullException.ThrowIfNull(context); - - context.Response.OnStarting(state => + context.Response.OnStarting((state) => { var ctx = (HttpContext)state; - var headers = ctx.Response.Headers; + logger?.LogTrace("Adding security headers to response via OnStarting."); - // Adiciona cabeçalhos de segurança estáticos eficientemente -#pragma warning disable S3267 // Loops should be simplified with "Where" LINQ method - avoiding LINQ allocations on hot path as requested - foreach (var header in StaticHeaders) + // Adiciona headers apenas se não existirem + + // Impede que o site seja emoldurado (clickjacking) + if (!ctx.Response.Headers.ContainsKey(XFrameOptions)) { - if (!headers.ContainsKey(header.Key)) - { - headers.Append(header.Key, header.Value); - } + ctx.Response.Headers.Append(XFrameOptions, Deny); } -#pragma warning restore S3267 - // HSTS apenas em produção e HTTPS - usando verificação de ambiente em cache - if (ctx.Request.IsHttps && !_isDevelopment && !headers.ContainsKey(HstsHeaderName)) + // Impede que o navegador tente adivinhar o tipo de conteúdo (MIME sniffing) + if (!ctx.Response.Headers.ContainsKey(XContentTypeOptions)) { - headers.Append(HstsHeaderName, HstsHeader); + ctx.Response.Headers.Append(XContentTypeOptions, NoSniff); } - // Remove cabeçalhos de exposição de informações eficientemente - foreach (var headerName in HeadersToRemove) + // Controla quanta informação de referência é enviada + if (!ctx.Response.Headers.ContainsKey(ReferrerPolicy)) { - headers.Remove(headerName); + ctx.Response.Headers.Append(ReferrerPolicy, StrictOriginWhenCrossOrigin); } + // Remove o cabeçalho que identifica a tecnologia do servidor + ctx.Response.Headers.Remove(XPoweredBy); + return Task.CompletedTask; }, context); - return _next(context); + await _next(context); } } diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/MigrationExtensions.cs b/src/Bootstrapper/MeAjudaAi.ApiService/MigrationExtensions.cs index 5627af7a3..17053037a 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/MigrationExtensions.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/MigrationExtensions.cs @@ -27,7 +27,8 @@ public static async Task ApplyModuleMigrationsAsync(this IHost app, Cancellation { "Locations", 3 }, { "Documents", 4 }, { "Providers", 5 }, - { "SearchProviders", 6 } + { "Communications", 6 }, + { "SearchProviders", 7 } }; dbContextTypes = dbContextTypes.OrderBy(t => diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs b/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs index 00fb47578..2669743d2 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs +++ b/src/Bootstrapper/MeAjudaAi.ApiService/Program.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using MeAjudaAi.ApiService.Endpoints; using MeAjudaAi.ApiService.Extensions; +using MeAjudaAi.Modules.Communications.API; using MeAjudaAi.Modules.Documents.API; using MeAjudaAi.Modules.Locations.API; using MeAjudaAi.Modules.Providers.API; @@ -24,6 +25,9 @@ protected Program() { } public static async Task Main(string[] args) { + // Correção para compatibilidade DateTime UTC com PostgreSQL timestamp + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + try { var builder = WebApplication.CreateBuilder(args); @@ -44,6 +48,7 @@ public static async Task Main(string[] args) builder.Services.AddSearchProvidersModule(builder.Configuration, builder.Environment); builder.Services.AddLocationsModule(builder.Configuration); builder.Services.AddServiceCatalogsModule(builder.Configuration); + builder.Services.AddCommunicationsModule(builder.Configuration); // Shared services por último (GlobalExceptionHandler atua como fallback) builder.Services.AddSharedServices(builder.Configuration); @@ -127,9 +132,11 @@ private static async Task ConfigureMiddlewareAsync(WebApplication app) app.UseSearchProvidersModule(); app.UseLocationsModule(); app.UseServiceCatalogsModule(); + app.UseCommunicationsModule(); // Endpoints de orquestração cross-módulo (ficam no ApiService) app.MapProviderRegistrationEndpoints(); + app.MapCommunicationsEndpoints(); } private static void LogStartupComplete(WebApplication app) diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/appsettings.json b/src/Bootstrapper/MeAjudaAi.ApiService/appsettings.json index 247ac7e02..0abd68654 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/appsettings.json +++ b/src/Bootstrapper/MeAjudaAi.ApiService/appsettings.json @@ -125,5 +125,8 @@ }, "FeatureManagement": { "GeographicRestriction": false + }, + "Communications": { + "EnableStubs": true } } \ No newline at end of file diff --git a/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json b/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json index 260a6b696..b6e0a33c1 100644 --- a/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json +++ b/src/Bootstrapper/MeAjudaAi.ApiService/packages.lock.json @@ -565,6 +565,38 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -794,6 +826,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Contracts/Contracts/Modules/Communications/Channels/IEmailChannel.cs b/src/Contracts/Contracts/Modules/Communications/Channels/IEmailChannel.cs new file mode 100644 index 000000000..315f4ee9a --- /dev/null +++ b/src/Contracts/Contracts/Modules/Communications/Channels/IEmailChannel.cs @@ -0,0 +1,23 @@ +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using MeAjudaAi.Contracts.Functional; + +namespace MeAjudaAi.Contracts.Modules.Communications.Channels; + +/// +/// Interface para um canal de e-mail (SendGrid, Mailgun, SMTP, etc.). +/// +public interface IEmailChannel +{ + /// + /// Nome descritivo do canal (ex: "SendGrid"). + /// + string Name { get; } + + /// + /// Envia uma mensagem de e-mail. + /// + /// DTO com dados do e-mail + /// Token de cancelamento + /// Resultado com o ID da mensagem no provedor + Task> SendAsync(EmailMessageDto message, CancellationToken cancellationToken = default); +} diff --git a/src/Contracts/Contracts/Modules/Communications/Channels/IPushChannel.cs b/src/Contracts/Contracts/Modules/Communications/Channels/IPushChannel.cs new file mode 100644 index 000000000..dea32573d --- /dev/null +++ b/src/Contracts/Contracts/Modules/Communications/Channels/IPushChannel.cs @@ -0,0 +1,23 @@ +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using MeAjudaAi.Contracts.Functional; + +namespace MeAjudaAi.Contracts.Modules.Communications.Channels; + +/// +/// Interface para um canal de push notification (Firebase, OneSignal, etc.). +/// +public interface IPushChannel +{ + /// + /// Nome descritivo do canal (ex: "Firebase"). + /// + string Name { get; } + + /// + /// Envia uma notificação push. + /// + /// DTO com dados do push + /// Token de cancelamento + /// Resultado com o ID da mensagem no provedor + Task> SendAsync(PushMessageDto message, CancellationToken cancellationToken = default); +} diff --git a/src/Contracts/Contracts/Modules/Communications/Channels/ISmsChannel.cs b/src/Contracts/Contracts/Modules/Communications/Channels/ISmsChannel.cs new file mode 100644 index 000000000..a30db2846 --- /dev/null +++ b/src/Contracts/Contracts/Modules/Communications/Channels/ISmsChannel.cs @@ -0,0 +1,23 @@ +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using MeAjudaAi.Contracts.Functional; + +namespace MeAjudaAi.Contracts.Modules.Communications.Channels; + +/// +/// Interface para um canal de SMS (Twilio, AWS Pinpoint, etc.). +/// +public interface ISmsChannel +{ + /// + /// Nome descritivo do canal (ex: "Twilio"). + /// + string Name { get; } + + /// + /// Envia uma mensagem SMS. + /// + /// DTO com dados do SMS + /// Token de cancelamento + /// Resultado com o ID da mensagem no provedor + Task> SendAsync(SmsMessageDto message, CancellationToken cancellationToken = default); +} diff --git a/src/Contracts/Contracts/Modules/Communications/DTOs/CommunicationLogDto.cs b/src/Contracts/Contracts/Modules/Communications/DTOs/CommunicationLogDto.cs new file mode 100644 index 000000000..c7a78183f --- /dev/null +++ b/src/Contracts/Contracts/Modules/Communications/DTOs/CommunicationLogDto.cs @@ -0,0 +1,16 @@ +namespace MeAjudaAi.Contracts.Modules.Communications.DTOs; + +/// +/// DTO de log de comunicação. +/// +public sealed record CommunicationLogDto( + Guid Id, + string CorrelationId, + string Channel, + string Recipient, + string? TemplateKey, + bool IsSuccess, + string? ErrorMessage, + int AttemptCount, + DateTime SentAt +); diff --git a/src/Contracts/Contracts/Modules/Communications/DTOs/EmailMessageDto.cs b/src/Contracts/Contracts/Modules/Communications/DTOs/EmailMessageDto.cs new file mode 100644 index 000000000..387f0d476 --- /dev/null +++ b/src/Contracts/Contracts/Modules/Communications/DTOs/EmailMessageDto.cs @@ -0,0 +1,14 @@ +namespace MeAjudaAi.Contracts.Modules.Communications.DTOs; + +/// +/// DTO para envio de mensagem de e-mail. +/// +public sealed record EmailMessageDto( + string To, + string Subject, + string Body, + bool IsHtml = true, + string? TemplateKey = null, + IDictionary? TemplateData = null, + IEnumerable? AttachmentUrls = null +); diff --git a/src/Contracts/Contracts/Modules/Communications/DTOs/EmailTemplateDto.cs b/src/Contracts/Contracts/Modules/Communications/DTOs/EmailTemplateDto.cs new file mode 100644 index 000000000..5502b278d --- /dev/null +++ b/src/Contracts/Contracts/Modules/Communications/DTOs/EmailTemplateDto.cs @@ -0,0 +1,14 @@ +namespace MeAjudaAi.Contracts.Modules.Communications.DTOs; + +/// +/// DTO de template de e-mail. +/// +public sealed record EmailTemplateDto( + Guid Id, + string Key, + string Subject, + string HtmlBody, + string TextBody, + bool IsSystemTemplate, + string Language +); diff --git a/src/Contracts/Contracts/Modules/Communications/DTOs/OutboxPayloads.cs b/src/Contracts/Contracts/Modules/Communications/DTOs/OutboxPayloads.cs new file mode 100644 index 000000000..ae2e28765 --- /dev/null +++ b/src/Contracts/Contracts/Modules/Communications/DTOs/OutboxPayloads.cs @@ -0,0 +1,24 @@ +namespace MeAjudaAi.Contracts.Modules.Communications.DTOs; + +/// +/// Payload para mensagens de e-mail no outbox +/// +public sealed record EmailOutboxPayload( + string To, + string Subject, + string? HtmlBody = null, + string? TextBody = null, + string? Body = null, + string? From = null, + string? TemplateKey = null, + IDictionary? TemplateData = null); + +/// +/// Payload para mensagens de SMS no outbox +/// +public sealed record SmsOutboxPayload(string PhoneNumber, string Body); + +/// +/// Payload para mensagens de Push no outbox +/// +public sealed record PushOutboxPayload(string DeviceToken, string Title, string Body, IDictionary? Data = null); diff --git a/src/Contracts/Contracts/Modules/Communications/DTOs/PushMessageDto.cs b/src/Contracts/Contracts/Modules/Communications/DTOs/PushMessageDto.cs new file mode 100644 index 000000000..66a5bef0b --- /dev/null +++ b/src/Contracts/Contracts/Modules/Communications/DTOs/PushMessageDto.cs @@ -0,0 +1,11 @@ +namespace MeAjudaAi.Contracts.Modules.Communications.DTOs; + +/// +/// DTO para envio de notificação push. +/// +public sealed record PushMessageDto( + string DeviceToken, + string Title, + string Body, + IDictionary? ExtraData = null +); diff --git a/src/Contracts/Contracts/Modules/Communications/DTOs/SmsMessageDto.cs b/src/Contracts/Contracts/Modules/Communications/DTOs/SmsMessageDto.cs new file mode 100644 index 000000000..4d5fba584 --- /dev/null +++ b/src/Contracts/Contracts/Modules/Communications/DTOs/SmsMessageDto.cs @@ -0,0 +1,9 @@ +namespace MeAjudaAi.Contracts.Modules.Communications.DTOs; + +/// +/// DTO para envio de mensagem SMS. +/// +public sealed record SmsMessageDto( + string PhoneNumber, + string Message +); diff --git a/src/Contracts/Contracts/Modules/Communications/ICommunicationsModuleApi.cs b/src/Contracts/Contracts/Modules/Communications/ICommunicationsModuleApi.cs new file mode 100644 index 000000000..efc114311 --- /dev/null +++ b/src/Contracts/Contracts/Modules/Communications/ICommunicationsModuleApi.cs @@ -0,0 +1,66 @@ +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using MeAjudaAi.Contracts.Modules.Communications.Queries; +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Models; +using MeAjudaAi.Contracts.Shared; + +namespace MeAjudaAi.Contracts.Modules.Communications; + +/// +/// API pública para o módulo de comunicações (E-mail, SMS, Push). +/// +public interface ICommunicationsModuleApi : IModuleApi +{ + /// + /// Envia uma mensagem de e-mail (enfileira no outbox). + /// + /// DTO com dados do e-mail + /// Prioridade de envio + /// Token de cancelamento + /// ID da mensagem no outbox + Task> SendEmailAsync( + EmailMessageDto email, + ECommunicationPriority priority = ECommunicationPriority.Normal, + CancellationToken ct = default); + + /// + /// Obtém todos os templates de e-mail disponíveis. + /// + /// Token de cancelamento + /// Lista de templates + Task>> GetTemplatesAsync(CancellationToken ct = default); + + /// + /// Envia uma mensagem SMS (enfileira no outbox). + /// + /// DTO com dados do SMS + /// Prioridade de envio + /// Token de cancelamento + /// ID da mensagem no outbox + Task> SendSmsAsync( + SmsMessageDto sms, + ECommunicationPriority priority = ECommunicationPriority.Normal, + CancellationToken ct = default); + + /// + /// Envia uma notificação push (enfileira no outbox). + /// + /// DTO com dados do push + /// Prioridade de envio + /// Token de cancelamento + /// ID da mensagem no outbox + Task> SendPushAsync( + PushMessageDto push, + ECommunicationPriority priority = ECommunicationPriority.Normal, + CancellationToken ct = default); + + /// + /// Obtém logs de comunicação paginados com verificação de idempotência via Identificador de Correlação (CorrelationId). + /// + /// Critérios de busca + /// Token de cancelamento + /// Resultado paginado de logs + Task>> GetLogsAsync( + CommunicationLogQuery query, + CancellationToken ct = default); +} diff --git a/src/Contracts/Contracts/Modules/Communications/Queries/CommunicationLogQuery.cs b/src/Contracts/Contracts/Modules/Communications/Queries/CommunicationLogQuery.cs new file mode 100644 index 000000000..eb4ce8762 --- /dev/null +++ b/src/Contracts/Contracts/Modules/Communications/Queries/CommunicationLogQuery.cs @@ -0,0 +1,13 @@ +namespace MeAjudaAi.Contracts.Modules.Communications.Queries; + +/// +/// Query para busca paginada de logs de comunicação. +/// +public sealed record CommunicationLogQuery( + string? CorrelationId = null, + string? Channel = null, + string? Recipient = null, + bool? IsSuccess = null, + int PageNumber = 1, + int PageSize = 20 +); diff --git a/src/Contracts/Contracts/Modules/SearchProviders/Enums/ESubscriptionTier.cs b/src/Contracts/Contracts/Modules/SearchProviders/Enums/ESubscriptionTier.cs index 48fcfd29f..4d450f481 100644 --- a/src/Contracts/Contracts/Modules/SearchProviders/Enums/ESubscriptionTier.cs +++ b/src/Contracts/Contracts/Modules/SearchProviders/Enums/ESubscriptionTier.cs @@ -1,9 +1,12 @@ +using System.Text.Json.Serialization; + namespace MeAjudaAi.Contracts.Modules.SearchProviders.Enums; /// /// Enumeração de níveis de assinatura para API do módulo. /// Os valores devem corresponder a MeAjudaAi.Modules.SearchProviders.Domain.Enums.ESubscriptionTier. /// +[JsonConverter(typeof(JsonStringEnumConverter))] public enum ESubscriptionTier { Free = 0, diff --git a/src/Contracts/Shared/CommunicationTemplateKeys.cs b/src/Contracts/Shared/CommunicationTemplateKeys.cs new file mode 100644 index 000000000..23828d6e9 --- /dev/null +++ b/src/Contracts/Shared/CommunicationTemplateKeys.cs @@ -0,0 +1,17 @@ +namespace MeAjudaAi.Contracts.Shared; + +/// +/// Chaves padrão para templates de comunicação. +/// +public static class CommunicationTemplateKeys +{ + /// + /// Template para documento verificado com sucesso. + /// + public const string DocumentVerified = "document-verified"; + + /// + /// Template para documento rejeitado. + /// + public const string DocumentRejected = "document-rejected"; +} diff --git a/src/Contracts/Shared/ECommunicationPriority.cs b/src/Contracts/Shared/ECommunicationPriority.cs new file mode 100644 index 000000000..276af6cd2 --- /dev/null +++ b/src/Contracts/Shared/ECommunicationPriority.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace MeAjudaAi.Contracts.Shared; + +/// +/// Prioridade de entrega de uma comunicação. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ECommunicationPriority +{ + Low = 0, + Normal = 1, + High = 2 +} diff --git a/src/Contracts/Shared/EOutboxMessageStatus.cs b/src/Contracts/Shared/EOutboxMessageStatus.cs new file mode 100644 index 000000000..dec0a35c8 --- /dev/null +++ b/src/Contracts/Shared/EOutboxMessageStatus.cs @@ -0,0 +1,12 @@ +namespace MeAjudaAi.Contracts.Shared; + +/// +/// Status de processamento de uma mensagem no Outbox. +/// +public enum EOutboxMessageStatus +{ + Pending = 0, + Processing = 1, + Sent = 2, + Failed = 3 +} diff --git a/src/Modules/Communications/API/Endpoints/CommunicationsModuleEndpoints.cs b/src/Modules/Communications/API/Endpoints/CommunicationsModuleEndpoints.cs new file mode 100644 index 000000000..c50a8416e --- /dev/null +++ b/src/Modules/Communications/API/Endpoints/CommunicationsModuleEndpoints.cs @@ -0,0 +1,37 @@ +using MeAjudaAi.Contracts.Modules.Communications; +using MeAjudaAi.Contracts.Modules.Communications.Queries; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.Communications.API.Endpoints; + +public static class CommunicationsModuleEndpoints +{ + public static IEndpointRouteBuilder MapEndpoints(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/v1/communications") + .WithTags("Communications") + .RequireAuthorization(); + + group.MapGet("/logs", async ( + [AsParameters] CommunicationLogQuery query, + ICommunicationsModuleApi api, + CancellationToken ct) => + { + var result = await api.GetLogsAsync(query, ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); + }).WithName("GetCommunicationLogs"); + + group.MapGet("/templates", async ( + ICommunicationsModuleApi api, + CancellationToken ct) => + { + var result = await api.GetTemplatesAsync(ct); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error); + }).WithName("GetEmailTemplates"); + + return endpoints; + } +} diff --git a/src/Modules/Communications/API/Extensions.cs b/src/Modules/Communications/API/Extensions.cs new file mode 100644 index 000000000..dc6ca2fba --- /dev/null +++ b/src/Modules/Communications/API/Extensions.cs @@ -0,0 +1,32 @@ +using MeAjudaAi.Modules.Communications.API.Endpoints; +using MeAjudaAi.Modules.Communications.Application; +using MeAjudaAi.Modules.Communications.Infrastructure; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace MeAjudaAi.Modules.Communications.API; + +public static class Extensions +{ + public static IServiceCollection AddCommunicationsModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddCommunicationsApplication(); + services.AddCommunicationsInfrastructure(configuration); + + return services; + } + + public static IApplicationBuilder UseCommunicationsModule(this IApplicationBuilder app) + { + // Registro de middlewares específicos do módulo, se houver + return app; + } + + public static IEndpointRouteBuilder MapCommunicationsEndpoints(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapEndpoints(); + } +} diff --git a/src/Modules/Communications/API/MeAjudaAi.Modules.Communications.API.csproj b/src/Modules/Communications/API/MeAjudaAi.Modules.Communications.API.csproj new file mode 100644 index 000000000..b9f8559df --- /dev/null +++ b/src/Modules/Communications/API/MeAjudaAi.Modules.Communications.API.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/src/Modules/Communications/API/packages.lock.json b/src/Modules/Communications/API/packages.lock.json new file mode 100644 index 000000000..7e2bcfb53 --- /dev/null +++ b/src/Modules/Communications/API/packages.lock.json @@ -0,0 +1,861 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.AspNetCore.Http.Abstractions": { + "type": "Direct", + "requested": "[2.3.9, )", + "resolved": "2.3.9", + "contentHash": "ULScB/0S9+qvf+yahjR+oQUp0GrvoDHJ9XS5gTqSjLjbjUDnHaJ1s8wo3RJMpaDfb1bawX4OgQM+YmvCUveR4Q==", + "dependencies": { + "Microsoft.AspNetCore.Http.Features": "2.3.0" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "uxmFjZEAB/KbsgWFSS4lLqkEHCfXxB2x0UcbiO4e5fCRpFFeTMSx/me6009nYJLu5IKlDwO1POh++P6RilFTDw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA==" + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.22.0.136894, )", + "resolved": "10.22.0.136894", + "contentHash": "6fI0XUWHvFIa/cvo1HuopV1Gh1hnKJq+XlTMJ2q71+6D3uVkl6Vxza3fFKQ9C4Bc7KFUFtukzRPmiH1be0JxOA==" + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "mpeNZyMdvrHztJwR1sXIUQ+3iioEU97YMBnFA9WLbsPOYhGwDJnqJMmEd8ny7kcmS9OjTHoEuX/bSXXY3brIFA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Dapper.AOT": { + "type": "Transitive", + "resolved": "1.0.48", + "contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA==" + }, + "Hangfire.NetCore": { + "type": "Transitive", + "resolved": "1.8.23", + "contentHash": "SmvUJF/u5MCP666R5Y1V+GntqBc4RCWJqn5ztMMN67d53Cx5cuaWR0YNLMrabjylwLarFYJ7EdR9RnGEZzp/dg==", + "dependencies": { + "Hangfire.Core": "[1.8.23]", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "3.0.0", + "Microsoft.Extensions.Logging.Abstractions": "3.0.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.AspNetCore.Http.Features": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "f10WUgcsKqrkmnz6gt8HeZ7kyKjYN30PO7cSic1lPtH7paPtnQqXPOveul/SIPI43PhRD4trttg4ywnrEmmJpA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Bcl.TimeProvider": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "C7kWHJnMRY7EvJev2S8+yJHZ1y7A4ZlLbA4NE+O23BDIAN5mHeqND1m+SKv1ChRS5YlCDW7yAMUe7lttRsJaAA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "32c58Rnm47Qvhimawf67KO9PytgPz3QoWye7Abapt0Yocw/JnzMiSNj/pRoIKyn8Jxypkv86zxKD4Q/zNTc0Ag==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "ipC4u1VojgEfoIZhtbS2Sx5IluJTP/Jf1hz3yGsxGBgSukYY/CquI6rAjxn5H58CZgVn36qcuPPtNMwZ0AUzMg==" + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "jUEXmkBUPdOS/MP9areK/sbKhdklq9+tEhvwfxGalZVnmyLUO5rrheNNutUBtvbZ7J8ECkG7/r2KXi/IFC06cA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "Transitive", + "resolved": "8.0.11", + "contentHash": "zLgN22Zp9pk8RHlwssRTexw4+a6wqOnKWN+VejdPn5Yhjql4XiBhkFo35Nu8mmqHIk/UEmmCnMGLWq75aFfkOw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "Transitive", + "resolved": "8.0.11", + "contentHash": "So3JUdRxozRjvQ3cxU6F3nI/i4emDnjane6yMYcJhvTTTu29ltlIdoXjkFGRceIWz8yKvuEpzXItZ0x5GvN2nQ==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g==" + }, + "Microsoft.FeatureManagement": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "qxvGAv9WJHYfOpixWywJTa1WNTPy5MbQiv+O+UlE6E/LVofiM1+YRR6m41zsHIbAGm1S0PQ0QFuAsOw9DkoKsg==", + "dependencies": { + "Microsoft.Bcl.TimeProvider": "8.0.1", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.2", + "Microsoft.Extensions.Logging": "8.0.1" + } + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "StackExchange.Redis": { + "type": "Transitive", + "resolved": "2.7.27", + "contentHash": "Uqc2OQHglqj9/FfGQ6RkKFkZfHySfZlfmbCl+hc+u2I/IqunfelQ7QJi7ZhvAJxUtu80pildVX6NPLdDaUffOw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "meajudaai.contracts": { + "type": "Project", + "dependencies": { + "FluentValidation": "[12.1.1, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.shared": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Mvc": "[8.1.1, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.1, )", + "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", + "AspNetCore.HealthChecks.Redis": "[9.0.0, )", + "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", + "FluentValidation": "[12.1.1, )", + "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", + "Hangfire.AspNetCore": "[1.8.23, )", + "Hangfire.Core": "[1.8.23, )", + "Hangfire.PostgreSql": "[1.21.1, )", + "MeAjudaAi.Contracts": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )", + "Microsoft.EntityFrameworkCore": "[10.0.5, )", + "Microsoft.EntityFrameworkCore.Design": "[10.0.5, )", + "Microsoft.Extensions.Caching.Hybrid": "[10.4.0, )", + "Microsoft.Extensions.Caching.StackExchangeRedis": "[10.0.5, )", + "Microsoft.FeatureManagement.AspNetCore": "[4.4.0, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )", + "RabbitMQ.Client": "[7.2.1, )", + "Rebus": "[8.9.0, )", + "Rebus.RabbitMq": "[10.1.1, )", + "Rebus.ServiceProvider": "[10.7.2, )", + "Scrutor": "[7.0.0, )", + "Serilog": "[4.3.1, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Enrichers.Environment": "[3.0.1, )", + "Serilog.Enrichers.Process": "[3.0.0, )", + "Serilog.Enrichers.Thread": "[4.0.0, )", + "Serilog.Settings.Configuration": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.Seq": "[9.0.0, )" + } + }, + "Asp.Versioning.Http": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "1D/Mzq1MSUSQe1eCY6GeEsu+tIlpzoWZxZkhlcw/uvtVoTrwO+BMl0fSj/XX8oZN1DutWTZqHDHgyDRq+IeSdQ==", + "dependencies": { + "Asp.Versioning.Abstractions": "8.1.0" + } + }, + "Asp.Versioning.Mvc": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "mkJv6eAdlbHbqTdrUcfEYFoZuGL6HoR7O+Lfsvivixp7N5BNhfCFPPOwsBzdIiH1qzdJXyJf+C+DZ08j27PMPg==", + "dependencies": { + "Asp.Versioning.Http": "8.1.1" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "u8PrP6CjmurgIh0EDfg8Gc/GdpDHgkbv8OLKdxQcWb5W4sp0i9BcdbQlLvRcATjNIJUyr7ZmqXjmdsFfqnuy0g==", + "dependencies": { + "Asp.Versioning.Mvc": "8.1.1" + } + }, + "AspNetCore.HealthChecks.NpgSql": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "Npgsql": "8.0.3" + } + }, + "AspNetCore.HealthChecks.Redis": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "yNH0h8GLRbAf+PU5HNVLZ5hNeyq9mDVmRKO9xuZsme/znUYoBJlQvI0gq45gaZNlLncCHkMhR4o90MuT+gxxPw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "StackExchange.Redis": "2.7.4" + } + }, + "Dapper": { + "type": "CentralTransitive", + "requested": "[2.1.72, )", + "resolved": "2.1.72", + "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "FluentValidation": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "dependencies": { + "FluentValidation": "12.1.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Hangfire.AspNetCore": { + "type": "CentralTransitive", + "requested": "[1.8.23, )", + "resolved": "1.8.23", + "contentHash": "TXpOl7kX4xXq5bLEqqWCpt9zh3TaouDwtb3GDtzGHX5uSC2RaAqZzn2swevivx3Uki16slXIigiPtgr4TPKpsg==", + "dependencies": { + "Hangfire.NetCore": "[1.8.23]" + } + }, + "Hangfire.Core": { + "type": "CentralTransitive", + "requested": "[1.8.23, )", + "resolved": "1.8.23", + "contentHash": "YCOTtF3NNOQI83PlfjeNDDBkofJDfdET2CwhfQsiVBwmsU6lP19QW9NVTIH9epl+MnOsyFC2G1RnlPSGV8F1FQ==", + "dependencies": { + "Newtonsoft.Json": "11.0.1" + } + }, + "Hangfire.PostgreSql": { + "type": "CentralTransitive", + "requested": "[1.21.1, )", + "resolved": "1.21.1", + "contentHash": "hFNZAxv+1p72/XCZdImnH6ovCzZ2DKAMTOI8CReT0P3yw/k0b0YJP2teA18agNH1ZYInPzhtxGk8hx5n2cxbbQ==", + "dependencies": { + "Dapper": "2.0.123", + "Dapper.AOT": "1.0.48", + "Hangfire.Core": "1.8.0", + "Npgsql": "6.0.11" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "vTcxIfOPyfFbYk1g8YcXJfkMnlEWVkSnnjxcZLy60zgwiHMRf2SnZR+9E4HlpwKxgE3yfKMOti8J6WfKuKsw6w==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.Build.Framework": { + "type": "CentralTransitive", + "requested": "[18.0.2, )", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9tNBmK3EpYVGRQLiqP+bqK2m+TD0Gv//4vCzR7ZOgl4FWzCFyOpYdIVka13M4kcBdPdSJcs3wbHr3rmzOqbIMA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.5", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "gm6f0cC2w/2tcd4GeZJqEMruTercpIJfO5sSAFLtqTqblDBHgAFk70xwshUIUVX4I6sZwdEUSd1YxoKFk1AL0w==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "k/QDdQ94/0Shi0KfU+e12m73jfQo+3JpErTtgpZfsCIqkvdEEO0XIx6R+iTbN55rNPaNhOqNY4/sB+jZ8XxVPw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Caching.Hybrid": { + "type": "CentralTransitive", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "4V+aMLQeU/p4VcIWIcvGro0L6HynmL2TrelL04Ce1iotP6T5+kjxuZQvl6P1ObSXIRPCbVXtQSt1NxK0fRIuag==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.4", + "Microsoft.Extensions.Caching.Memory": "10.0.4", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4" + } + }, + "Microsoft.Extensions.Caching.StackExchangeRedis": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "zXb143/TpEKOLQuWGw2CkJgb9F4XXh2XbevMvppzsIHr1/pjML0zjc+vzXcpCV8YUwpW5NIaScZhzFSm621B3Q==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "StackExchange.Redis": "2.7.27" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyModel": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "xA4kkL+QS6KCAOKz/O0oquHs44Ob8J7zpBCNt3wjkBWDg5aCqfwG8rWWLsg5V86AM0sB849g9JjPjIdksTCIKg==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.FeatureManagement.AspNetCore": { + "type": "CentralTransitive", + "requested": "[4.4.0, )", + "resolved": "4.4.0", + "contentHash": "jE5KxeEDUnUsx1w9uOFV5cOfM4E2mg7kf07328xO1x4JdoS0jwkD55nMjTlUPu90ynYX1oCBGC+FHK6ZoqRpxA==", + "dependencies": { + "Microsoft.FeatureManagement": "4.4.0" + } + }, + "Microsoft.OpenApi": { + "type": "CentralTransitive", + "requested": "[2.7.0, )", + "resolved": "2.7.0", + "contentHash": "b9xmpnmjq6p+HqF3uWG7u7/PlB38t/UB5UtXdi6xEAP9ZJGKHneYyjMGzBflB1rpLxYEcU6KRme+cz5wNPlxqA==" + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[10.0.2, )", + "resolved": "10.0.2", + "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "P6EwH0Q4xkaA264iNZDqCPhWt8pscfUGxXazDQg4noBfqjoOlk4hKWfvBjF9ZX3R/9JybRmmJfmxr2iBMj0EpA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.2" + } + }, + "RabbitMQ.Client": { + "type": "CentralTransitive", + "requested": "[7.2.1, )", + "resolved": "7.2.1", + "contentHash": "YKXEfg9fVQiTKgZlvIhAfPSFaamEgi8DsQmisCH0IAsU4FYLrtoguDrDj6JtJVGUt40QPnBLRH6fTQcAC4qsOg==", + "dependencies": { + "System.Threading.RateLimiting": "8.0.0" + } + }, + "Rebus": { + "type": "CentralTransitive", + "requested": "[8.9.0, )", + "resolved": "8.9.0", + "contentHash": "UaPGZuXIL4J5GUDA05JzEEzuPMEXY0CoF92nC6bsFBPvwoYPQ0uKyH2vKqdV80CW7cjbwBgDlEZ7R9hO9b59XA==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "Rebus.RabbitMq": { + "type": "CentralTransitive", + "requested": "[10.1.1, )", + "resolved": "10.1.1", + "contentHash": "66pUp4hfaYWfQEDOiVcuZQnPF4XFHyJ5KCfwCm18e3Dnr936Iog48KrN8Mp8QyRQ2tiNpzdjSATQLKEZpSk11A==", + "dependencies": { + "RabbitMq.Client": "7.1.2", + "rebus": "8.9.0" + } + }, + "Rebus.ServiceProvider": { + "type": "CentralTransitive", + "requested": "[10.7.2, )", + "resolved": "10.7.2", + "contentHash": "Qa8sKt1i9Fy/zCw5GwAUsfT+lt4BvkIgYh8sRJ6fvqJWoedS//pfcyiKUUb0wL3C5Wrpi3U+vRud5DCbMHaFIw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "[8.0.0, 11.0.0)", + "Microsoft.Extensions.Hosting.Abstractions": "[6.0.0, 11.0.0)", + "Microsoft.Extensions.Logging.Abstractions": "[6.0.0, 11.0.0)", + "Rebus": "8.9.0" + } + }, + "Scrutor": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.3.1, )", + "resolved": "4.3.1", + "contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ==" + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Enrichers.Environment": { + "type": "CentralTransitive", + "requested": "[3.0.1, )", + "resolved": "3.0.1", + "contentHash": "9BqCE4C9FF+/rJb/CsQwe7oVf44xqkOvMwX//CUxvUR25lFL4tSS6iuxE5eW07quby1BAyAEP+vM6TWsnT3iqw==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Enrichers.Process": { + "type": "CentralTransitive", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "/wPYz2PDCJGSHNI+Z0PAacZvrgZgrGduWqLXeC2wvW6pgGM/Bi45JrKy887MRcRPHIZVU0LAlkmJ7TkByC0boQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Enrichers.Thread": { + "type": "CentralTransitive", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "C7BK25a1rhUyr+Tp+1BYcVlBJq7M2VCHlIgnwoIUVJcicM9jYcvQK18+OeHiXw7uLPSjqWxJIp1EfaZ/RGmEwA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Seq": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "aNU8A0K322q7+voPNmp1/qNPH+9QK8xvM1p72sMmCG0wGlshFzmtDW9QnVSoSYCj0MgQKcMOlgooovtBhRlNHw==", + "dependencies": { + "Serilog": "4.2.0", + "Serilog.Sinks.File": "6.0.0" + } + } + } + } +} \ No newline at end of file diff --git a/src/Modules/Communications/Application/Extensions.cs b/src/Modules/Communications/Application/Extensions.cs new file mode 100644 index 000000000..20f57b07e --- /dev/null +++ b/src/Modules/Communications/Application/Extensions.cs @@ -0,0 +1,41 @@ +using MeAjudaAi.Contracts.Modules.Communications; +using MeAjudaAi.Modules.Communications.Application.Handlers; +using MeAjudaAi.Modules.Communications.Application.ModuleApi; +using MeAjudaAi.Modules.Communications.Application.Services; +using MeAjudaAi.Modules.Communications.Application.Services.Email; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging.Messages.Documents; +using MeAjudaAi.Shared.Messaging.Messages.Providers; +using MeAjudaAi.Shared.Messaging.Messages.Users; +using Microsoft.Extensions.DependencyInjection; + +namespace MeAjudaAi.Modules.Communications.Application; + +public static class Extensions +{ + /// + /// Registra os serviços da camada de Application do módulo Communications. + /// + public static IServiceCollection AddCommunicationsApplication(this IServiceCollection services) + { + // Public API + services.AddScoped(); + + // Serviços de aplicação + services.AddScoped(); + services.AddScoped(); + + // Integration Event Handlers + services.AddScoped, UserRegisteredIntegrationEventHandler>(); + services.AddScoped, ProviderActivatedIntegrationEventHandler>(); + services.AddScoped, ProviderAwaitingVerificationIntegrationEventHandler>(); + services.AddScoped, ProviderVerificationStatusUpdatedIntegrationEventHandler>(); + services.AddScoped, DocumentVerifiedIntegrationEventHandler>(); + services.AddScoped, DocumentRejectedIntegrationEventHandler>(); + + // Background Workers + services.AddHostedService(); + + return services; + } +} diff --git a/src/Modules/Communications/Application/Handlers/DocumentRejectedIntegrationEventHandler.cs b/src/Modules/Communications/Application/Handlers/DocumentRejectedIntegrationEventHandler.cs new file mode 100644 index 000000000..140d3a1fd --- /dev/null +++ b/src/Modules/Communications/Application/Handlers/DocumentRejectedIntegrationEventHandler.cs @@ -0,0 +1,94 @@ +using MeAjudaAi.Contracts.Modules.Providers; +using OutboxMessage = MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging.Messages.Documents; +using MeAjudaAi.Contracts.Shared; +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using MeAjudaAi.Shared.Utilities; +using MeAjudaAi.Shared.Database.Outbox; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace MeAjudaAi.Modules.Communications.Application.Handlers; + +/// +/// Handler para notificar o prestador quando um documento é rejeitado. +/// +public sealed class DocumentRejectedIntegrationEventHandler( + IOutboxMessageRepository outboxRepository, + IProvidersModuleApi providersModuleApi, + ILogger logger) + : IEventHandler +{ + public async Task HandleAsync(DocumentRejectedIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) + { + var providerResult = await providersModuleApi.GetProviderByIdAsync(integrationEvent.ProviderId, cancellationToken); + + if (!providerResult.IsSuccess) + { + throw new InvalidOperationException($"Failed to fetch provider {integrationEvent.ProviderId} for document rejected notification: {providerResult.Error.Message}"); + } + + if (providerResult.Value == null || string.IsNullOrWhiteSpace(providerResult.Value.Email)) + { + logger.LogWarning( + "Could not resolve email for provider {ProviderId}. Skipping document rejected notification for document {DocumentId}.", + integrationEvent.ProviderId, integrationEvent.DocumentId); + return; + } + + var recipientEmail = providerResult.Value.Email; + var templateKey = CommunicationTemplateKeys.DocumentRejected; + var correlationId = $"document_rejected:{integrationEvent.DocumentId}:{integrationEvent.ProviderId}"; + + var templateData = new Dictionary + { + ["ProviderName"] = providerResult.Value.Name, + ["DocumentType"] = integrationEvent.DocumentType, + ["Reason"] = integrationEvent.Reason + }; + + var emailPayload = new EmailOutboxPayload( + To: recipientEmail, + Subject: $"Documento rejeitado: {integrationEvent.DocumentType}", + Body: $"Olá {providerResult.Value.Name}, seu documento ({integrationEvent.DocumentType}) foi rejeitado. Motivo: {integrationEvent.Reason}", + TemplateKey: templateKey, + TemplateData: templateData + ); + + var message = OutboxMessage.Create( + channel: ECommunicationChannel.Email, + payload: JsonSerializer.Serialize(emailPayload), + priority: ECommunicationPriority.High, + correlationId: correlationId); + + try + { + await outboxRepository.AddAsync(message, cancellationToken); + await outboxRepository.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Document rejected notification enqueued for provider {ProviderId} (Email: {Email}, correlationId: {CorrelationId}).", + integrationEvent.ProviderId, PiiMaskingHelper.MaskEmail(recipientEmail), correlationId); + } + catch (Exception ex) + { + if (ex is Microsoft.EntityFrameworkCore.DbUpdateException dbEx) + { + var processedException = MeAjudaAi.Shared.Database.Exceptions.PostgreSqlExceptionProcessor.ProcessException(dbEx); + + if (processedException is MeAjudaAi.Shared.Database.Exceptions.UniqueConstraintException uniqueEx && + uniqueEx.ConstraintName == OutboxMessageConstraints.CorrelationIdIndexName) + { + logger.LogInformation( + "Skipping document rejected notification for document {DocumentId} — already enqueued (correlationId: {CorrelationId}).", + integrationEvent.DocumentId, correlationId); + return; + } + } + + throw; + } + } +} diff --git a/src/Modules/Communications/Application/Handlers/DocumentVerifiedIntegrationEventHandler.cs b/src/Modules/Communications/Application/Handlers/DocumentVerifiedIntegrationEventHandler.cs new file mode 100644 index 000000000..1a8a558f3 --- /dev/null +++ b/src/Modules/Communications/Application/Handlers/DocumentVerifiedIntegrationEventHandler.cs @@ -0,0 +1,93 @@ +using MeAjudaAi.Contracts.Modules.Providers; +using OutboxMessage = MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging.Messages.Documents; +using MeAjudaAi.Contracts.Shared; +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using MeAjudaAi.Shared.Utilities; +using MeAjudaAi.Shared.Database.Outbox; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace MeAjudaAi.Modules.Communications.Application.Handlers; + +/// +/// Handler para notificar o prestador quando um documento é verificado com sucesso. +/// +public sealed class DocumentVerifiedIntegrationEventHandler( + IOutboxMessageRepository outboxRepository, + IProvidersModuleApi providersModuleApi, + ILogger logger) + : IEventHandler +{ + public async Task HandleAsync(DocumentVerifiedIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) + { + var providerResult = await providersModuleApi.GetProviderByIdAsync(integrationEvent.ProviderId, cancellationToken); + + if (!providerResult.IsSuccess) + { + throw new InvalidOperationException($"Failed to fetch provider {integrationEvent.ProviderId} for document verified notification: {providerResult.Error.Message}"); + } + + if (providerResult.Value == null || string.IsNullOrWhiteSpace(providerResult.Value.Email)) + { + logger.LogWarning( + "Could not resolve email for provider {ProviderId}. Skipping document verified notification for document {DocumentId}.", + integrationEvent.ProviderId, integrationEvent.DocumentId); + return; + } + + var recipientEmail = providerResult.Value.Email; + var templateKey = CommunicationTemplateKeys.DocumentVerified; + var correlationId = $"document_verified:{integrationEvent.DocumentId}:{integrationEvent.ProviderId}"; + + var templateData = new Dictionary + { + ["ProviderName"] = providerResult.Value.Name, + ["DocumentType"] = integrationEvent.DocumentType + }; + + var emailPayload = new EmailOutboxPayload( + To: recipientEmail, + Subject: $"Documento verificado: {integrationEvent.DocumentType}", + Body: $"Olá {providerResult.Value.Name}, seu documento ({integrationEvent.DocumentType}) foi verificado com sucesso.", + TemplateKey: templateKey, + TemplateData: templateData + ); + + var message = OutboxMessage.Create( + channel: ECommunicationChannel.Email, + payload: JsonSerializer.Serialize(emailPayload), + priority: ECommunicationPriority.Normal, + correlationId: correlationId); + + try + { + await outboxRepository.AddAsync(message, cancellationToken); + await outboxRepository.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Document verified notification enqueued for provider {ProviderId} (Email: {Email}, correlationId: {CorrelationId}).", + integrationEvent.ProviderId, PiiMaskingHelper.MaskEmail(recipientEmail), correlationId); + } + catch (Exception ex) + { + if (ex is Microsoft.EntityFrameworkCore.DbUpdateException dbEx) + { + var processedException = MeAjudaAi.Shared.Database.Exceptions.PostgreSqlExceptionProcessor.ProcessException(dbEx); + + if (processedException is MeAjudaAi.Shared.Database.Exceptions.UniqueConstraintException uniqueEx && + uniqueEx.ConstraintName == OutboxMessageConstraints.CorrelationIdIndexName) + { + logger.LogInformation( + "Skipping document verified notification for document {DocumentId} — already enqueued (correlationId: {CorrelationId}).", + integrationEvent.DocumentId, correlationId); + return; + } + } + + throw; + } + } +} diff --git a/src/Modules/Communications/Application/Handlers/ProviderActivatedIntegrationEventHandler.cs b/src/Modules/Communications/Application/Handlers/ProviderActivatedIntegrationEventHandler.cs new file mode 100644 index 000000000..5f8c202cc --- /dev/null +++ b/src/Modules/Communications/Application/Handlers/ProviderActivatedIntegrationEventHandler.cs @@ -0,0 +1,95 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging.Messages.Providers; +using MeAjudaAi.Contracts.Shared; +using MeAjudaAi.Contracts.Modules.Users; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Encodings.Web; + +namespace MeAjudaAi.Modules.Communications.Application.Handlers; + +/// +/// Consome o ProviderActivatedIntegrationEvent e enfileira e-mail de aprovação do prestador. +/// +public sealed class ProviderActivatedIntegrationEventHandler( + IOutboxMessageRepository outboxRepository, + ICommunicationLogRepository logRepository, + IUsersModuleApi usersModuleApi, + ILogger logger) + : IEventHandler +{ + private const string TemplateKey = "provider_activated"; + + public async Task HandleAsync( + ProviderActivatedIntegrationEvent integrationEvent, + CancellationToken cancellationToken = default) + { + var correlationId = $"{TemplateKey}:{integrationEvent.ProviderId}"; + + if (await logRepository.ExistsByCorrelationIdAsync(correlationId, cancellationToken)) + { + logger.LogInformation( + "Skipping provider activation email for {ProviderId} — already sent (correlationId: {CorrelationId}).", + integrationEvent.ProviderId, correlationId); + return; + } + + var userResult = await usersModuleApi.GetUserByIdAsync(integrationEvent.UserId, cancellationToken); + if (!userResult.IsSuccess || userResult.Value == null || string.IsNullOrWhiteSpace(userResult.Value.Email)) + { + logger.LogWarning( + "Could not resolve email for user {UserId}. Skipping provider activation email for provider {ProviderId}.", + integrationEvent.UserId, integrationEvent.ProviderId); + return; + } + + var safeName = HtmlEncoder.Default.Encode(integrationEvent.Name); + var recipientEmail = userResult.Value.Email; + + var payload = JsonSerializer.Serialize(new + { + To = recipientEmail, + Subject = "Seu cadastro foi aprovado!", + HtmlBody = $"

Olá, {safeName}!

Seu cadastro foi aprovado. Você já pode receber solicitações de serviço.

", + TextBody = $"Olá, {integrationEvent.Name}!\nSeu cadastro foi aprovado. Você já pode receber solicitações de serviço.", + From = (string?)null, + CorrelationId = correlationId, + TemplateKey = TemplateKey + }); + + var message = OutboxMessage.Create( + channel: ECommunicationChannel.Email, + payload: payload, + priority: ECommunicationPriority.High, + correlationId: correlationId); + + try + { + await outboxRepository.AddAsync(message, cancellationToken); + await outboxRepository.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Provider activation email enqueued for provider {ProviderId} (UserId: {UserId}, correlationId: {CorrelationId}).", + integrationEvent.ProviderId, integrationEvent.UserId, correlationId); + } + catch (Exception ex) + { + if (ex is Microsoft.EntityFrameworkCore.DbUpdateException dbEx) + { + var processedException = MeAjudaAi.Shared.Database.Exceptions.PostgreSqlExceptionProcessor.ProcessException(dbEx); + + if (processedException is MeAjudaAi.Shared.Database.Exceptions.UniqueConstraintException) + { + logger.LogInformation( + "Skipping provider activation email for {ProviderId} — already enqueued or sent (correlationId: {CorrelationId}).", + integrationEvent.ProviderId, correlationId); + return; + } + } + + throw; + } + } +} diff --git a/src/Modules/Communications/Application/Handlers/ProviderAwaitingVerificationIntegrationEventHandler.cs b/src/Modules/Communications/Application/Handlers/ProviderAwaitingVerificationIntegrationEventHandler.cs new file mode 100644 index 000000000..9a2c60fd8 --- /dev/null +++ b/src/Modules/Communications/Application/Handlers/ProviderAwaitingVerificationIntegrationEventHandler.cs @@ -0,0 +1,71 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging.Messages.Providers; +using MeAjudaAi.Contracts.Shared; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace MeAjudaAi.Modules.Communications.Application.Handlers; + +/// +/// Handler para notificar administradores quando um prestador aguarda verificação. +/// +public sealed class ProviderAwaitingVerificationIntegrationEventHandler( + IOutboxMessageRepository outboxRepository, + IConfiguration configuration, + ILogger logger) + : IEventHandler +{ + public async Task HandleAsync(ProviderAwaitingVerificationIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) + { + var adminEmail = configuration["Communications:AdminEmail"]; + if (string.IsNullOrWhiteSpace(adminEmail)) + { + adminEmail = "suporte@meajudaai.com.br"; + } + + var correlationId = $"admin_verification_alert:{integrationEvent.ProviderId}"; + + var emailPayload = new + { + To = adminEmail, + Subject = "Novo prestador aguardando verificação", + Body = $"O prestador {integrationEvent.Name} (ID: {integrationEvent.ProviderId}) enviou documentos para análise.", + TemplateKey = "admin-provider-verification-alert", + CorrelationId = correlationId + }; + + var message = OutboxMessage.Create( + ECommunicationChannel.Email, + JsonSerializer.Serialize(emailPayload), + ECommunicationPriority.Normal, + correlationId: correlationId); + + try + { + await outboxRepository.AddAsync(message, cancellationToken); + await outboxRepository.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Admin notification enqueued for provider {ProviderId} (correlationId: {CorrelationId}).", + integrationEvent.ProviderId, correlationId); + } + catch (Exception ex) + { + var processedException = MeAjudaAi.Shared.Database.Exceptions.PostgreSqlExceptionProcessor.ProcessException( + ex as Microsoft.EntityFrameworkCore.DbUpdateException ?? new Microsoft.EntityFrameworkCore.DbUpdateException(ex.Message, ex)); + + if (processedException is MeAjudaAi.Shared.Database.Exceptions.UniqueConstraintException) + { + logger.LogInformation( + "Skipping admin notification for provider {ProviderId} — already enqueued (correlationId: {CorrelationId}).", + integrationEvent.ProviderId, correlationId); + return; + } + + throw; + } + } +} diff --git a/src/Modules/Communications/Application/Handlers/ProviderVerificationStatusUpdatedIntegrationEventHandler.cs b/src/Modules/Communications/Application/Handlers/ProviderVerificationStatusUpdatedIntegrationEventHandler.cs new file mode 100644 index 000000000..17e5af00b --- /dev/null +++ b/src/Modules/Communications/Application/Handlers/ProviderVerificationStatusUpdatedIntegrationEventHandler.cs @@ -0,0 +1,100 @@ +using MeAjudaAi.Contracts.Modules.Users; +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging.Messages.Providers; +using MeAjudaAi.Contracts.Shared; +using MeAjudaAi.Shared.Utilities; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace MeAjudaAi.Modules.Communications.Application.Handlers; + +/// +/// Handler para notificar o prestador quando seu status de verificação é atualizado. +/// +public sealed class ProviderVerificationStatusUpdatedIntegrationEventHandler( + IOutboxMessageRepository outboxRepository, + IUsersModuleApi usersModuleApi, + ILogger logger) + : IEventHandler +{ + public async Task HandleAsync(ProviderVerificationStatusUpdatedIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) + { + var userResult = await usersModuleApi.GetUserByIdAsync(integrationEvent.UserId, cancellationToken); + + if (!userResult.IsSuccess) + { + throw new InvalidOperationException($"Failed to fetch user {integrationEvent.UserId} for provider {integrationEvent.ProviderId} verification update: {userResult.Error.Message}"); + } + + if (userResult.Value == null || string.IsNullOrWhiteSpace(userResult.Value.Email)) + { + logger.LogWarning( + "Could not resolve email for user {UserId}. Skipping verification status notification for provider {ProviderId}.", + integrationEvent.UserId, integrationEvent.ProviderId); + return; + } + + var recipientEmail = userResult.Value.Email; + var normalizedStatus = integrationEvent.NewStatus.Trim().ToLowerInvariant(); + + var templateKey = normalizedStatus switch + { + "verified" or "approved" => "provider-verification-approved", + "rejected" or "denied" => "provider-verification-rejected", + "pending" or "awaiting" => "provider-verification-pending", + _ => "provider-verification-status-update" // default/fallback + }; + + var displayStatus = normalizedStatus switch + { + "verified" or "approved" => "aprovado", + "rejected" or "denied" => "rejeitado", + "pending" or "awaiting" => "pendente", + _ => normalizedStatus + }; + + var correlationId = $"verification_status_update:{integrationEvent.Id}:{integrationEvent.ProviderId}:{normalizedStatus}"; + + var emailPayload = new + { + To = recipientEmail, + Subject = $"Atualização no status de verificação: {displayStatus}", + Body = $"Olá {integrationEvent.Name}, seu status de verificação foi alterado para: {displayStatus}. {integrationEvent.Comments}", + TemplateKey = templateKey, + CorrelationId = correlationId + }; + + var message = OutboxMessage.Create( + channel: ECommunicationChannel.Email, + payload: JsonSerializer.Serialize(emailPayload), + priority: ECommunicationPriority.High, + correlationId: correlationId); + + try + { + await outboxRepository.AddAsync(message, cancellationToken); + await outboxRepository.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Verification status update notification enqueued for user {UserId} ({Email}, correlationId: {CorrelationId}).", + integrationEvent.UserId, PiiMaskingHelper.MaskEmail(recipientEmail), correlationId); + } + catch (Exception ex) + { + var processedException = MeAjudaAi.Shared.Database.Exceptions.PostgreSqlExceptionProcessor.ProcessException( + ex as Microsoft.EntityFrameworkCore.DbUpdateException ?? new Microsoft.EntityFrameworkCore.DbUpdateException(ex.Message, ex)); + + if (processedException is MeAjudaAi.Shared.Database.Exceptions.UniqueConstraintException) + { + logger.LogInformation( + "Skipping verification status update for provider {ProviderId} — already enqueued (correlationId: {CorrelationId}).", + integrationEvent.ProviderId, correlationId); + return; + } + + throw; + } + } +} diff --git a/src/Modules/Communications/Application/Handlers/UserRegisteredIntegrationEventHandler.cs b/src/Modules/Communications/Application/Handlers/UserRegisteredIntegrationEventHandler.cs new file mode 100644 index 000000000..739eb99b2 --- /dev/null +++ b/src/Modules/Communications/Application/Handlers/UserRegisteredIntegrationEventHandler.cs @@ -0,0 +1,80 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging.Messages.Users; +using MeAjudaAi.Contracts.Shared; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace MeAjudaAi.Modules.Communications.Application.Handlers; + +/// +/// Consome o evento UserRegisteredIntegrationEvent e enfileira e-mail de boas-vindas no Outbox. +/// +public sealed class UserRegisteredIntegrationEventHandler( + IOutboxMessageRepository outboxRepository, + ICommunicationLogRepository logRepository, + ILogger logger) + : IEventHandler +{ + private const string TemplateKey = "user_registered"; + + public async Task HandleAsync( + UserRegisteredIntegrationEvent integrationEvent, + CancellationToken cancellationToken = default) + { + var correlationId = $"{TemplateKey}:{integrationEvent.UserId}"; + + // Idempotência: evita re-enfileirar se já foi processado + if (await logRepository.ExistsByCorrelationIdAsync(correlationId, cancellationToken)) + { + logger.LogInformation( + "Skipping welcome email for user {UserId} — already sent (correlationId: {CorrelationId}).", + integrationEvent.UserId, correlationId); + return; + } + + var payload = JsonSerializer.Serialize(new + { + To = integrationEvent.Email, + Subject = "Bem-vindo ao MeAjudaAi!", + HtmlBody = $"

Olá, {integrationEvent.FirstName}!

Seja bem-vindo(a) ao MeAjudaAi.

", + TextBody = $"Olá, {integrationEvent.FirstName}!\nSeja bem-vindo(a) ao MeAjudaAi.", + From = (string?)null, + CorrelationId = correlationId, + TemplateKey = TemplateKey + }); + + var message = OutboxMessage.Create( + channel: ECommunicationChannel.Email, + payload: payload, + priority: ECommunicationPriority.Normal, + correlationId: correlationId); + + try + { + await outboxRepository.AddAsync(message, cancellationToken); + await outboxRepository.SaveChangesAsync(cancellationToken); + + logger.LogInformation( + "Welcome email enqueued for user {UserId} (outboxId: {OutboxId}, correlationId: {CorrelationId}).", + integrationEvent.UserId, message.Id, correlationId); + } + catch (Exception ex) + { + var processedException = MeAjudaAi.Shared.Database.Exceptions.PostgreSqlExceptionProcessor.ProcessException( + ex as Microsoft.EntityFrameworkCore.DbUpdateException ?? new Microsoft.EntityFrameworkCore.DbUpdateException(ex.Message, ex)); + + if (processedException is MeAjudaAi.Shared.Database.Exceptions.UniqueConstraintException) + { + logger.LogInformation( + "Skipping welcome email for user {UserId} — already enqueued or sent (correlationId: {CorrelationId}).", + integrationEvent.UserId, correlationId); + return; + } + + throw; + } + } +} diff --git a/src/Modules/Communications/Application/MeAjudaAi.Modules.Communications.Application.csproj b/src/Modules/Communications/Application/MeAjudaAi.Modules.Communications.Application.csproj new file mode 100644 index 000000000..3fb799ddd --- /dev/null +++ b/src/Modules/Communications/Application/MeAjudaAi.Modules.Communications.Application.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + + + + + <_Parameter1>MeAjudaAi.Modules.Communications.Tests + + + <_Parameter1>MeAjudaAi.Integration.Tests + + + + + + + + + + diff --git a/src/Modules/Communications/Application/ModuleApi/CommunicationsModuleApi.cs b/src/Modules/Communications/Application/ModuleApi/CommunicationsModuleApi.cs new file mode 100644 index 000000000..5173bafc4 --- /dev/null +++ b/src/Modules/Communications/Application/ModuleApi/CommunicationsModuleApi.cs @@ -0,0 +1,150 @@ +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Models; +using MeAjudaAi.Contracts.Modules.Communications; +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using MeAjudaAi.Contracts.Modules.Communications.Queries; +using MeAjudaAi.Contracts.Shared; +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Utilities.Constants; +using System.Text.Json; + +namespace MeAjudaAi.Modules.Communications.Application.ModuleApi; + +/// +/// Implementação da API pública do módulo de comunicações. +/// +[MeAjudaAi.Contracts.Modules.ModuleApi(ModuleNames.Communications)] +public sealed class CommunicationsModuleApi( + IOutboxMessageRepository outboxRepository, + IEmailTemplateRepository templateRepository, + ICommunicationLogRepository logRepository) + : ICommunicationsModuleApi +{ + private readonly IEmailTemplateRepository _templateRepository = templateRepository; + + public string ModuleName => ModuleNames.Communications; + public string ApiVersion => "1.0"; + + public Task IsAvailableAsync(CancellationToken cancellationToken = default) + => Task.FromResult(true); + + public async Task> SendEmailAsync( + EmailMessageDto email, + ECommunicationPriority priority = ECommunicationPriority.Normal, + CancellationToken ct = default) + { + if (email == null) return Result.Failure(Error.BadRequest("A mensagem de e-mail não pode ser nula.")); + if (string.IsNullOrWhiteSpace(email.To)) return Result.Failure(Error.BadRequest("O e-mail do destinatário é obrigatório.")); + if (string.IsNullOrWhiteSpace(email.Subject)) return Result.Failure(Error.BadRequest("O assunto do e-mail é obrigatório.")); + if (string.IsNullOrWhiteSpace(email.Body)) return Result.Failure(Error.BadRequest("O corpo do e-mail é obrigatório.")); + + if (!Enum.IsDefined(typeof(ECommunicationPriority), priority)) + return Result.Failure(Error.BadRequest("Prioridade de comunicação inválida.")); + + return await EnqueueOutboxAsync(ECommunicationChannel.Email, email, priority, ct); + } + + public async Task>> GetTemplatesAsync(CancellationToken ct = default) + { + var templates = await _templateRepository.GetAllAsync(ct); + + var dtos = templates.Select(x => new EmailTemplateDto( + x.Id, + x.TemplateKey, + x.Subject, + x.HtmlBody, + x.TextBody, + x.IsSystemTemplate, + x.Language)).ToList(); + + return Result>.Success(dtos); + } + + public async Task> SendSmsAsync( + SmsMessageDto sms, + ECommunicationPriority priority = ECommunicationPriority.Normal, + CancellationToken ct = default) + { + if (sms == null) return Result.Failure(Error.BadRequest("A mensagem SMS não pode ser nula.")); + if (string.IsNullOrWhiteSpace(sms.PhoneNumber)) return Result.Failure(Error.BadRequest("O número de telefone é obrigatório.")); + if (string.IsNullOrWhiteSpace(sms.Message)) return Result.Failure(Error.BadRequest("O corpo da mensagem SMS é obrigatório.")); + + if (!Enum.IsDefined(typeof(ECommunicationPriority), priority)) + return Result.Failure(Error.BadRequest("Prioridade de comunicação inválida.")); + + return await EnqueueOutboxAsync(ECommunicationChannel.Sms, sms, priority, ct); + } + + public async Task> SendPushAsync( + PushMessageDto push, + ECommunicationPriority priority = ECommunicationPriority.Normal, + CancellationToken ct = default) + { + if (push == null) return Result.Failure(Error.BadRequest("A notificação push não pode ser nula.")); + if (string.IsNullOrWhiteSpace(push.DeviceToken)) return Result.Failure(Error.BadRequest("O token do dispositivo é obrigatório.")); + if (string.IsNullOrWhiteSpace(push.Title)) return Result.Failure(Error.BadRequest("O título do push é obrigatório.")); + if (string.IsNullOrWhiteSpace(push.Body)) return Result.Failure(Error.BadRequest("O corpo do push é obrigatório.")); + + if (!Enum.IsDefined(typeof(ECommunicationPriority), priority)) + return Result.Failure(Error.BadRequest("Prioridade de comunicação inválida.")); + + return await EnqueueOutboxAsync(ECommunicationChannel.Push, push, priority, ct); + } + + public async Task>> GetLogsAsync( + CommunicationLogQuery query, + CancellationToken ct = default) + { + if (query == null) return Result>.Failure(Error.BadRequest("A consulta não pode ser nula.")); + if (query.PageNumber < 1) return Result>.Failure(Error.BadRequest("O número da página deve ser pelo menos 1.")); + if (query.PageSize < 1 || query.PageSize > 100) return Result>.Failure(Error.BadRequest("O tamanho da página deve estar entre 1 e 100.")); + + var (items, totalCount) = await logRepository.SearchAsync( + query.CorrelationId, + query.Channel, + query.Recipient, + query.IsSuccess, + query.PageNumber, + query.PageSize, + ct); + + var dtos = items.Select(x => new CommunicationLogDto( + x.Id, + x.CorrelationId, + x.Channel.ToString(), + x.Recipient, + x.TemplateKey, + x.IsSuccess, + x.ErrorMessage, + x.AttemptCount, + x.CreatedAt)).ToList(); + + return Result>.Success(new PagedResult + { + Items = dtos, + PageNumber = query.PageNumber, + PageSize = query.PageSize, + TotalItems = totalCount + }); + } + + private async Task> EnqueueOutboxAsync( + ECommunicationChannel channel, + TPayload payload, + ECommunicationPriority priority, + CancellationToken ct) + { + var serializedPayload = JsonSerializer.Serialize(payload); + var message = OutboxMessage.Create( + channel, + serializedPayload, + priority); + + await outboxRepository.AddAsync(message, ct); + await outboxRepository.SaveChangesAsync(ct); + + return Result.Success(message.Id); + } +} diff --git a/src/Modules/Communications/Application/Services/CommunicationsOutboxWorker.cs b/src/Modules/Communications/Application/Services/CommunicationsOutboxWorker.cs new file mode 100644 index 000000000..79ca395a3 --- /dev/null +++ b/src/Modules/Communications/Application/Services/CommunicationsOutboxWorker.cs @@ -0,0 +1,61 @@ +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Communications.Application.Services; + +/// +/// Worker de background para processamento periódico do Outbox de comunicações. +/// +internal sealed class CommunicationsOutboxWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(10); + private readonly TimeSpan _stuckTimeout = TimeSpan.FromMinutes(5); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Communications Outbox Worker started."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopeFactory.CreateScope(); + + // 1. Recupera mensagens travadas em processamento + var repository = scope.ServiceProvider.GetRequiredService(); + var resetCount = await repository.ResetStaleProcessingMessagesAsync(DateTime.UtcNow.Subtract(_stuckTimeout), stoppingToken); + + + if (resetCount > 0) + { + logger.LogWarning("Reset {Count} stuck outbox messages back to Pending.", resetCount); + } + + // 2. Processa mensagens pendentes + var processor = scope.ServiceProvider.GetRequiredService(); + int processedCount = await processor.ProcessPendingMessagesAsync(50, stoppingToken); + + if (processedCount > 0) + { + logger.LogInformation("Processed {Count} outbox messages.", processedCount); + } + } + catch (OperationCanceledException) + { + // Normal shutdown + } + catch (Exception ex) + { + logger.LogError(ex, "Error occurred while processing communications outbox."); + } + + await Task.Delay(_checkInterval, stoppingToken); + } + + logger.LogInformation("Communications Outbox Worker stopped."); + } +} diff --git a/src/Modules/Communications/Application/Services/Email/IEmailService.cs b/src/Modules/Communications/Application/Services/Email/IEmailService.cs new file mode 100644 index 000000000..7bba33e22 --- /dev/null +++ b/src/Modules/Communications/Application/Services/Email/IEmailService.cs @@ -0,0 +1,18 @@ +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Modules.Communications.DTOs; + +namespace MeAjudaAi.Modules.Communications.Application.Services.Email; + +/// +/// Serviço interno para envio de e-mails, abstraindo o canal real. +/// +public interface IEmailService +{ + /// + /// Envia uma mensagem de e-mail de forma imediata. + /// + /// DTO com dados do e-mail + /// Token de cancelamento + /// ID da mensagem no provedor + Task> SendAsync(EmailMessageDto message, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Communications/Application/Services/Email/StubEmailService.cs b/src/Modules/Communications/Application/Services/Email/StubEmailService.cs new file mode 100644 index 000000000..0328e8f37 --- /dev/null +++ b/src/Modules/Communications/Application/Services/Email/StubEmailService.cs @@ -0,0 +1,25 @@ +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Communications.Application.Services.Email; + +/// +/// Stub para envio de e-mails em desenvolvimento/MVP. +/// Apenas loga no console as informações do e-mail. +/// +public sealed class StubEmailService(ILogger logger) : IEmailService +{ + public async Task> SendAsync(EmailMessageDto message, CancellationToken cancellationToken = default) + { + // Simulação de delay de rede + await Task.Delay(100, cancellationToken); + + logger.LogInformation( + "[STUB EMAIL] Email dispatched (recipient and subject masked) | Body length: {BodyLength} bytes", + message.Body.Length); + + // Retorna um ID falso do provedor + return Result.Success($"stub_{Guid.NewGuid():N}"); + } +} diff --git a/src/Modules/Communications/Application/Services/OutboxProcessorService.cs b/src/Modules/Communications/Application/Services/OutboxProcessorService.cs new file mode 100644 index 000000000..2f6d7d1eb --- /dev/null +++ b/src/Modules/Communications/Application/Services/OutboxProcessorService.cs @@ -0,0 +1,206 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Modules.Communications.Domain.Services; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Shared.Database.Outbox; +using MeAjudaAi.Shared.Utilities; +using MeAjudaAi.Contracts.Shared; +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using OutboxMessage = MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage; + +namespace MeAjudaAi.Modules.Communications.Application.Services; + +/// +/// Serviço de processamento das mensagens do Outbox. +/// +public interface IOutboxProcessorService +{ + /// + /// Processa mensagens pendentes no Outbox. + /// + Task ProcessPendingMessagesAsync( + int batchSize = 50, + CancellationToken cancellationToken = default); +} + +/// +/// Implementação do processador de Outbox específica para comunicações. +/// Estende a base genérica para aproveitar lógica de polling e retries. +/// +public sealed class OutboxProcessorService( + IOutboxMessageRepository outboxRepository, + ICommunicationLogRepository logRepository, + IEmailSender emailSender, + ISmsSender smsSender, + IPushSender pushSender, + ILogger logger) + : OutboxProcessorBase(outboxRepository, logger), IOutboxProcessorService +{ + protected override async Task DispatchAsync(OutboxMessage message, CancellationToken cancellationToken) + { + try + { + var success = await DispatchInternalAsync(message, cancellationToken); + return success + ? DispatchResult.Success() + : DispatchResult.Failure("Dispatch service returned false."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return DispatchResult.Canceled(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error dispatching outbox message {Id} ({Channel}).", message.Id, message.Channel); + return DispatchResult.Failure(ex.Message); + } + } + + protected override async Task OnSuccessAsync(OutboxMessage message, CancellationToken cancellationToken) + { + var recipientRaw = ExtractRecipient(message); + var recipientMasked = MaskRecipientForChannel(recipientRaw, message.Channel); + + try + { + var log = CommunicationLog.CreateSuccess( + correlationId: message.CorrelationId ?? $"outbox:{message.Id}", + channel: message.Channel, + recipient: recipientRaw, + attemptCount: message.RetryCount + 1, + outboxMessageId: message.Id, + templateKey: ExtractTemplateKey(message)); + + await logRepository.AddAsync(log, cancellationToken); + // SaveChanges handled by OutboxProcessorBase + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create success log for outbox message {Id} (CorrelationId: {CorrelationId}).", + message.Id, message.CorrelationId); + } + + logger.LogInformation("Outbox message {Id} ({Channel}) sent to {Recipient}.", + message.Id, message.Channel, recipientMasked); + } + + protected override async Task OnFailureAsync(OutboxMessage message, string? error, CancellationToken cancellationToken) + { + var recipientRaw = ExtractRecipient(message); + var recipientMasked = MaskRecipientForChannel(recipientRaw, message.Channel); + + if (!message.HasRetriesLeft) + { + try + { + var log = CommunicationLog.CreateFailure( + correlationId: message.CorrelationId ?? $"outbox:{message.Id}", + channel: message.Channel, + recipient: recipientRaw, + errorMessage: error ?? "Max retries reached.", + attemptCount: message.RetryCount, + outboxMessageId: message.Id, + templateKey: ExtractTemplateKey(message)); + + await logRepository.AddAsync(log, cancellationToken); + // SaveChanges handled by OutboxProcessorBase + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create failure log for outbox message {Id} (CorrelationId: {CorrelationId}).", + message.Id, message.CorrelationId); + } + } + + logger.LogWarning("Outbox message {Id} dispatch to {Channel} for {Recipient} failed. Attempt {Retry}/{Max}.", + message.Id, message.Channel, recipientMasked, message.RetryCount, message.MaxRetries); + } + + private async Task DispatchInternalAsync(OutboxMessage message, CancellationToken cancellationToken) + { + return message.Channel switch + { + ECommunicationChannel.Email => await DispatchEmailAsync(message, cancellationToken), + ECommunicationChannel.Sms => await DispatchSmsAsync(message, cancellationToken), + ECommunicationChannel.Push => await DispatchPushAsync(message, cancellationToken), + _ => throw new InvalidOperationException($"Unknown channel: {message.Channel}") + }; + } + + private async Task DispatchEmailAsync(OutboxMessage message, CancellationToken cancellationToken) + { + var email = JsonSerializer.Deserialize(message.Payload) + ?? throw new InvalidOperationException("Invalid email payload."); + + var htmlBody = email.HtmlBody ?? (email.Body != null ? System.Net.WebUtility.HtmlEncode(email.Body) : string.Empty); + var textBody = email.TextBody ?? email.Body ?? string.Empty; + + return await emailSender.SendAsync( + new Domain.Services.EmailMessage(email.To, email.Subject, htmlBody, textBody, email.From), + cancellationToken); + } + + private async Task DispatchSmsAsync(OutboxMessage message, CancellationToken cancellationToken) + { + var sms = JsonSerializer.Deserialize(message.Payload) + ?? throw new InvalidOperationException("Invalid SMS payload."); + + return await smsSender.SendAsync( + new Domain.Services.SmsMessage(sms.PhoneNumber, sms.Body), + cancellationToken); + } + + private async Task DispatchPushAsync(OutboxMessage message, CancellationToken cancellationToken) + { + var push = JsonSerializer.Deserialize(message.Payload) + ?? throw new InvalidOperationException("Invalid push payload."); + + return await pushSender.SendAsync( + new Domain.Services.PushNotification(push.DeviceToken, push.Title, push.Body, push.Data), + cancellationToken); + } + + private string MaskRecipientForChannel(string recipient, ECommunicationChannel channel) + { + return channel switch + { + ECommunicationChannel.Email => PiiMaskingHelper.MaskEmail(recipient), + ECommunicationChannel.Sms => PiiMaskingHelper.MaskPhoneNumber(recipient), + ECommunicationChannel.Push => PiiMaskingHelper.MaskSensitiveData(recipient), + _ => PiiMaskingHelper.MaskSensitiveData(recipient) + }; + } + + private string? ExtractTemplateKey(OutboxMessage message) + { + if (message.Channel != ECommunicationChannel.Email) return null; + try + { + return JsonSerializer.Deserialize(message.Payload)?.TemplateKey; + } + catch + { + return null; + } + } + + private string ExtractRecipient(OutboxMessage message) + { + try + { + return message.Channel switch + { + ECommunicationChannel.Email => JsonSerializer.Deserialize(message.Payload)?.To ?? "unknown", + ECommunicationChannel.Sms => JsonSerializer.Deserialize(message.Payload)?.PhoneNumber ?? "unknown", + ECommunicationChannel.Push => JsonSerializer.Deserialize(message.Payload)?.DeviceToken ?? "unknown", + _ => "unknown" + }; + } + catch + { + return "error-extracting"; + } + } +} diff --git a/src/Modules/Communications/Application/packages.lock.json b/src/Modules/Communications/Application/packages.lock.json new file mode 100644 index 000000000..7c49998de --- /dev/null +++ b/src/Modules/Communications/Application/packages.lock.json @@ -0,0 +1,827 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.22.0.136894, )", + "resolved": "10.22.0.136894", + "contentHash": "6fI0XUWHvFIa/cvo1HuopV1Gh1hnKJq+XlTMJ2q71+6D3uVkl6Vxza3fFKQ9C4Bc7KFUFtukzRPmiH1be0JxOA==" + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "mpeNZyMdvrHztJwR1sXIUQ+3iioEU97YMBnFA9WLbsPOYhGwDJnqJMmEd8ny7kcmS9OjTHoEuX/bSXXY3brIFA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Dapper.AOT": { + "type": "Transitive", + "resolved": "1.0.48", + "contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA==" + }, + "Hangfire.NetCore": { + "type": "Transitive", + "resolved": "1.8.23", + "contentHash": "SmvUJF/u5MCP666R5Y1V+GntqBc4RCWJqn5ztMMN67d53Cx5cuaWR0YNLMrabjylwLarFYJ7EdR9RnGEZzp/dg==", + "dependencies": { + "Hangfire.Core": "[1.8.23]", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "3.0.0", + "Microsoft.Extensions.Logging.Abstractions": "3.0.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Bcl.TimeProvider": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "C7kWHJnMRY7EvJev2S8+yJHZ1y7A4ZlLbA4NE+O23BDIAN5mHeqND1m+SKv1ChRS5YlCDW7yAMUe7lttRsJaAA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "32c58Rnm47Qvhimawf67KO9PytgPz3QoWye7Abapt0Yocw/JnzMiSNj/pRoIKyn8Jxypkv86zxKD4Q/zNTc0Ag==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "ipC4u1VojgEfoIZhtbS2Sx5IluJTP/Jf1hz3yGsxGBgSukYY/CquI6rAjxn5H58CZgVn36qcuPPtNMwZ0AUzMg==" + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "jUEXmkBUPdOS/MP9areK/sbKhdklq9+tEhvwfxGalZVnmyLUO5rrheNNutUBtvbZ7J8ECkG7/r2KXi/IFC06cA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "Transitive", + "resolved": "8.0.11", + "contentHash": "zLgN22Zp9pk8RHlwssRTexw4+a6wqOnKWN+VejdPn5Yhjql4XiBhkFo35Nu8mmqHIk/UEmmCnMGLWq75aFfkOw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "Transitive", + "resolved": "8.0.11", + "contentHash": "So3JUdRxozRjvQ3cxU6F3nI/i4emDnjane6yMYcJhvTTTu29ltlIdoXjkFGRceIWz8yKvuEpzXItZ0x5GvN2nQ==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g==" + }, + "Microsoft.FeatureManagement": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "qxvGAv9WJHYfOpixWywJTa1WNTPy5MbQiv+O+UlE6E/LVofiM1+YRR6m41zsHIbAGm1S0PQ0QFuAsOw9DkoKsg==", + "dependencies": { + "Microsoft.Bcl.TimeProvider": "8.0.1", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.2", + "Microsoft.Extensions.Logging": "8.0.1" + } + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "StackExchange.Redis": { + "type": "Transitive", + "resolved": "2.7.27", + "contentHash": "Uqc2OQHglqj9/FfGQ6RkKFkZfHySfZlfmbCl+hc+u2I/IqunfelQ7QJi7ZhvAJxUtu80pildVX6NPLdDaUffOw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "meajudaai.contracts": { + "type": "Project", + "dependencies": { + "FluentValidation": "[12.1.1, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.shared": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Mvc": "[8.1.1, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.1, )", + "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", + "AspNetCore.HealthChecks.Redis": "[9.0.0, )", + "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", + "FluentValidation": "[12.1.1, )", + "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", + "Hangfire.AspNetCore": "[1.8.23, )", + "Hangfire.Core": "[1.8.23, )", + "Hangfire.PostgreSql": "[1.21.1, )", + "MeAjudaAi.Contracts": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )", + "Microsoft.EntityFrameworkCore": "[10.0.5, )", + "Microsoft.EntityFrameworkCore.Design": "[10.0.5, )", + "Microsoft.Extensions.Caching.Hybrid": "[10.4.0, )", + "Microsoft.Extensions.Caching.StackExchangeRedis": "[10.0.5, )", + "Microsoft.FeatureManagement.AspNetCore": "[4.4.0, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )", + "RabbitMQ.Client": "[7.2.1, )", + "Rebus": "[8.9.0, )", + "Rebus.RabbitMq": "[10.1.1, )", + "Rebus.ServiceProvider": "[10.7.2, )", + "Scrutor": "[7.0.0, )", + "Serilog": "[4.3.1, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Enrichers.Environment": "[3.0.1, )", + "Serilog.Enrichers.Process": "[3.0.0, )", + "Serilog.Enrichers.Thread": "[4.0.0, )", + "Serilog.Settings.Configuration": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.Seq": "[9.0.0, )" + } + }, + "Asp.Versioning.Http": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "1D/Mzq1MSUSQe1eCY6GeEsu+tIlpzoWZxZkhlcw/uvtVoTrwO+BMl0fSj/XX8oZN1DutWTZqHDHgyDRq+IeSdQ==", + "dependencies": { + "Asp.Versioning.Abstractions": "8.1.0" + } + }, + "Asp.Versioning.Mvc": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "mkJv6eAdlbHbqTdrUcfEYFoZuGL6HoR7O+Lfsvivixp7N5BNhfCFPPOwsBzdIiH1qzdJXyJf+C+DZ08j27PMPg==", + "dependencies": { + "Asp.Versioning.Http": "8.1.1" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "u8PrP6CjmurgIh0EDfg8Gc/GdpDHgkbv8OLKdxQcWb5W4sp0i9BcdbQlLvRcATjNIJUyr7ZmqXjmdsFfqnuy0g==", + "dependencies": { + "Asp.Versioning.Mvc": "8.1.1" + } + }, + "AspNetCore.HealthChecks.NpgSql": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "Npgsql": "8.0.3" + } + }, + "AspNetCore.HealthChecks.Redis": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "yNH0h8GLRbAf+PU5HNVLZ5hNeyq9mDVmRKO9xuZsme/znUYoBJlQvI0gq45gaZNlLncCHkMhR4o90MuT+gxxPw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "StackExchange.Redis": "2.7.4" + } + }, + "Dapper": { + "type": "CentralTransitive", + "requested": "[2.1.72, )", + "resolved": "2.1.72", + "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "FluentValidation": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "dependencies": { + "FluentValidation": "12.1.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Hangfire.AspNetCore": { + "type": "CentralTransitive", + "requested": "[1.8.23, )", + "resolved": "1.8.23", + "contentHash": "TXpOl7kX4xXq5bLEqqWCpt9zh3TaouDwtb3GDtzGHX5uSC2RaAqZzn2swevivx3Uki16slXIigiPtgr4TPKpsg==", + "dependencies": { + "Hangfire.NetCore": "[1.8.23]" + } + }, + "Hangfire.Core": { + "type": "CentralTransitive", + "requested": "[1.8.23, )", + "resolved": "1.8.23", + "contentHash": "YCOTtF3NNOQI83PlfjeNDDBkofJDfdET2CwhfQsiVBwmsU6lP19QW9NVTIH9epl+MnOsyFC2G1RnlPSGV8F1FQ==", + "dependencies": { + "Newtonsoft.Json": "11.0.1" + } + }, + "Hangfire.PostgreSql": { + "type": "CentralTransitive", + "requested": "[1.21.1, )", + "resolved": "1.21.1", + "contentHash": "hFNZAxv+1p72/XCZdImnH6ovCzZ2DKAMTOI8CReT0P3yw/k0b0YJP2teA18agNH1ZYInPzhtxGk8hx5n2cxbbQ==", + "dependencies": { + "Dapper": "2.0.123", + "Dapper.AOT": "1.0.48", + "Hangfire.Core": "1.8.0", + "Npgsql": "6.0.11" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "vTcxIfOPyfFbYk1g8YcXJfkMnlEWVkSnnjxcZLy60zgwiHMRf2SnZR+9E4HlpwKxgE3yfKMOti8J6WfKuKsw6w==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.Build.Framework": { + "type": "CentralTransitive", + "requested": "[18.0.2, )", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9tNBmK3EpYVGRQLiqP+bqK2m+TD0Gv//4vCzR7ZOgl4FWzCFyOpYdIVka13M4kcBdPdSJcs3wbHr3rmzOqbIMA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.5", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "gm6f0cC2w/2tcd4GeZJqEMruTercpIJfO5sSAFLtqTqblDBHgAFk70xwshUIUVX4I6sZwdEUSd1YxoKFk1AL0w==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "uxmFjZEAB/KbsgWFSS4lLqkEHCfXxB2x0UcbiO4e5fCRpFFeTMSx/me6009nYJLu5IKlDwO1POh++P6RilFTDw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "k/QDdQ94/0Shi0KfU+e12m73jfQo+3JpErTtgpZfsCIqkvdEEO0XIx6R+iTbN55rNPaNhOqNY4/sB+jZ8XxVPw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Caching.Hybrid": { + "type": "CentralTransitive", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "4V+aMLQeU/p4VcIWIcvGro0L6HynmL2TrelL04Ce1iotP6T5+kjxuZQvl6P1ObSXIRPCbVXtQSt1NxK0fRIuag==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.4", + "Microsoft.Extensions.Caching.Memory": "10.0.4", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4" + } + }, + "Microsoft.Extensions.Caching.StackExchangeRedis": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "zXb143/TpEKOLQuWGw2CkJgb9F4XXh2XbevMvppzsIHr1/pjML0zjc+vzXcpCV8YUwpW5NIaScZhzFSm621B3Q==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "StackExchange.Redis": "2.7.27" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "xA4kkL+QS6KCAOKz/O0oquHs44Ob8J7zpBCNt3wjkBWDg5aCqfwG8rWWLsg5V86AM0sB849g9JjPjIdksTCIKg==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.FeatureManagement.AspNetCore": { + "type": "CentralTransitive", + "requested": "[4.4.0, )", + "resolved": "4.4.0", + "contentHash": "jE5KxeEDUnUsx1w9uOFV5cOfM4E2mg7kf07328xO1x4JdoS0jwkD55nMjTlUPu90ynYX1oCBGC+FHK6ZoqRpxA==", + "dependencies": { + "Microsoft.FeatureManagement": "4.4.0" + } + }, + "Microsoft.OpenApi": { + "type": "CentralTransitive", + "requested": "[2.7.0, )", + "resolved": "2.7.0", + "contentHash": "b9xmpnmjq6p+HqF3uWG7u7/PlB38t/UB5UtXdi6xEAP9ZJGKHneYyjMGzBflB1rpLxYEcU6KRme+cz5wNPlxqA==" + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[10.0.2, )", + "resolved": "10.0.2", + "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "P6EwH0Q4xkaA264iNZDqCPhWt8pscfUGxXazDQg4noBfqjoOlk4hKWfvBjF9ZX3R/9JybRmmJfmxr2iBMj0EpA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.2" + } + }, + "RabbitMQ.Client": { + "type": "CentralTransitive", + "requested": "[7.2.1, )", + "resolved": "7.2.1", + "contentHash": "YKXEfg9fVQiTKgZlvIhAfPSFaamEgi8DsQmisCH0IAsU4FYLrtoguDrDj6JtJVGUt40QPnBLRH6fTQcAC4qsOg==", + "dependencies": { + "System.Threading.RateLimiting": "8.0.0" + } + }, + "Rebus": { + "type": "CentralTransitive", + "requested": "[8.9.0, )", + "resolved": "8.9.0", + "contentHash": "UaPGZuXIL4J5GUDA05JzEEzuPMEXY0CoF92nC6bsFBPvwoYPQ0uKyH2vKqdV80CW7cjbwBgDlEZ7R9hO9b59XA==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "Rebus.RabbitMq": { + "type": "CentralTransitive", + "requested": "[10.1.1, )", + "resolved": "10.1.1", + "contentHash": "66pUp4hfaYWfQEDOiVcuZQnPF4XFHyJ5KCfwCm18e3Dnr936Iog48KrN8Mp8QyRQ2tiNpzdjSATQLKEZpSk11A==", + "dependencies": { + "RabbitMq.Client": "7.1.2", + "rebus": "8.9.0" + } + }, + "Rebus.ServiceProvider": { + "type": "CentralTransitive", + "requested": "[10.7.2, )", + "resolved": "10.7.2", + "contentHash": "Qa8sKt1i9Fy/zCw5GwAUsfT+lt4BvkIgYh8sRJ6fvqJWoedS//pfcyiKUUb0wL3C5Wrpi3U+vRud5DCbMHaFIw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "[8.0.0, 11.0.0)", + "Microsoft.Extensions.Hosting.Abstractions": "[6.0.0, 11.0.0)", + "Microsoft.Extensions.Logging.Abstractions": "[6.0.0, 11.0.0)", + "Rebus": "8.9.0" + } + }, + "Scrutor": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.3.1, )", + "resolved": "4.3.1", + "contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ==" + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Enrichers.Environment": { + "type": "CentralTransitive", + "requested": "[3.0.1, )", + "resolved": "3.0.1", + "contentHash": "9BqCE4C9FF+/rJb/CsQwe7oVf44xqkOvMwX//CUxvUR25lFL4tSS6iuxE5eW07quby1BAyAEP+vM6TWsnT3iqw==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Enrichers.Process": { + "type": "CentralTransitive", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "/wPYz2PDCJGSHNI+Z0PAacZvrgZgrGduWqLXeC2wvW6pgGM/Bi45JrKy887MRcRPHIZVU0LAlkmJ7TkByC0boQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Enrichers.Thread": { + "type": "CentralTransitive", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "C7BK25a1rhUyr+Tp+1BYcVlBJq7M2VCHlIgnwoIUVJcicM9jYcvQK18+OeHiXw7uLPSjqWxJIp1EfaZ/RGmEwA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Seq": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "aNU8A0K322q7+voPNmp1/qNPH+9QK8xvM1p72sMmCG0wGlshFzmtDW9QnVSoSYCj0MgQKcMOlgooovtBhRlNHw==", + "dependencies": { + "Serilog": "4.2.0", + "Serilog.Sinks.File": "6.0.0" + } + } + } + } +} \ No newline at end of file diff --git a/src/Modules/Communications/Domain/Entities/CommunicationLog.cs b/src/Modules/Communications/Domain/Entities/CommunicationLog.cs new file mode 100644 index 000000000..b4bae52fe --- /dev/null +++ b/src/Modules/Communications/Domain/Entities/CommunicationLog.cs @@ -0,0 +1,114 @@ +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Shared.Domain; + +namespace MeAjudaAi.Modules.Communications.Domain.Entities; + +/// +/// Registro de auditoria de uma comunicação entregue (ou com falha definitiva). +/// +/// +/// Utilizado para rastrear o histórico de comunicações enviadas, com suporte +/// a idempotência via . Antes de criar um novo registro, +/// o sistema verifica se já existe um com o mesmo CorrelationId para evitar duplicatas. +/// +public sealed class CommunicationLog : BaseEntity +{ + private CommunicationLog() { } + + /// + /// ID de correlação para garantir idempotência. Ex: "user_registered:{userId}". + /// + public string CorrelationId { get; private set; } = string.Empty; + + /// + /// Canal pelo qual a comunicação foi enviada. + /// + public ECommunicationChannel Channel { get; private set; } + + /// + /// Destinatário da comunicação (e-mail, número de telefone, etc.). + /// + public string Recipient { get; private set; } = string.Empty; + + /// + /// Template key utilizado (quando aplicável). + /// + public string? TemplateKey { get; private set; } + + /// + /// Indica se foi entregue com sucesso. + /// + public bool IsSuccess { get; private set; } + + /// + /// Mensagem de erro (quando IsSuccess = false). + /// + public string? ErrorMessage { get; private set; } + + /// + /// Número de tentativas realizadas até a entrega (ou falha definitiva). + /// + public int AttemptCount { get; private set; } + + /// + /// ID da mensagem no Outbox (rastreabilidade). + /// + public Guid? OutboxMessageId { get; private set; } + + /// + /// Cria um log de comunicação bem-sucedida. + /// + public static CommunicationLog CreateSuccess( + string correlationId, + ECommunicationChannel channel, + string recipient, + int attemptCount, + Guid? outboxMessageId = null, + string? templateKey = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(correlationId); + ArgumentException.ThrowIfNullOrWhiteSpace(recipient); + ArgumentOutOfRangeException.ThrowIfNegative(attemptCount); + + return new CommunicationLog + { + CorrelationId = correlationId, + Channel = channel, + Recipient = recipient, + TemplateKey = templateKey, + IsSuccess = true, + AttemptCount = attemptCount, + OutboxMessageId = outboxMessageId + }; + } + + /// + /// Cria um log de comunicação com falha. + /// + public static CommunicationLog CreateFailure( + string correlationId, + ECommunicationChannel channel, + string recipient, + string errorMessage, + int attemptCount, + Guid? outboxMessageId = null, + string? templateKey = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(correlationId); + ArgumentException.ThrowIfNullOrWhiteSpace(recipient); + ArgumentException.ThrowIfNullOrWhiteSpace(errorMessage); + ArgumentOutOfRangeException.ThrowIfNegative(attemptCount); + + return new CommunicationLog + { + CorrelationId = correlationId, + Channel = channel, + Recipient = recipient, + TemplateKey = templateKey, + IsSuccess = false, + ErrorMessage = errorMessage, + AttemptCount = attemptCount, + OutboxMessageId = outboxMessageId + }; + } +} diff --git a/src/Modules/Communications/Domain/Entities/EmailTemplate.cs b/src/Modules/Communications/Domain/Entities/EmailTemplate.cs new file mode 100644 index 000000000..6b803bcc6 --- /dev/null +++ b/src/Modules/Communications/Domain/Entities/EmailTemplate.cs @@ -0,0 +1,138 @@ +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Shared.Domain; + +namespace MeAjudaAi.Modules.Communications.Domain.Entities; + +/// +/// Template de e-mail com suporte a override por contexto. +/// +/// +/// Permite que administradores substituam templates padrão por versões customizadas +/// sem alterar o código-fonte. O sistema verifica templates com +/// antes de aplicar o template padrão. +/// +public sealed class EmailTemplate : BaseEntity +{ + private EmailTemplate() { } + + /// + /// Identificador único do template (snake_case). Ex: "user_registered", "provider_approved". + /// + public string TemplateKey { get; private set; } = string.Empty; + + /// + /// Chave de override opcional. Permite substituição customizada sem alterar o padrão. + /// + public string? OverrideKey { get; private set; } + + /// + /// Assunto do e-mail. Suporta tokens como {{FirstName}}. + /// + public string Subject { get; private set; } = string.Empty; + + /// + /// Corpo HTML do e-mail. Suporta tokens como {{FirstName}}. + /// + public string HtmlBody { get; private set; } = string.Empty; + + /// + /// Corpo em texto puro (fallback para clientes sem suporte a HTML). + /// + public string TextBody { get; private set; } = string.Empty; + + /// + /// Indica se este template está ativo. + /// + public bool IsActive { get; private set; } + + /// + /// Indica se este é um template de sistema (protegido contra deleção). + /// + public bool IsSystemTemplate { get; private set; } + + /// + /// Idioma do template. Ex: "pt-BR", "en-US". + /// + public string Language { get; private set; } = "pt-BR"; + + /// + /// Versão do template (incrementada a cada update). + /// + public int Version { get; private set; } + + /// + /// Cria um novo template de e-mail. + /// + public static EmailTemplate Create( + string templateKey, + string subject, + string htmlBody, + string textBody, + string language = "pt-BR", + string? overrideKey = null, + bool isSystemTemplate = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(templateKey); + ArgumentException.ThrowIfNullOrWhiteSpace(subject); + ArgumentException.ThrowIfNullOrWhiteSpace(htmlBody); + ArgumentException.ThrowIfNullOrWhiteSpace(textBody); + ArgumentException.ThrowIfNullOrWhiteSpace(language); + + return new EmailTemplate + { + TemplateKey = templateKey.ToLowerInvariant().Trim(), + OverrideKey = string.IsNullOrWhiteSpace(overrideKey) ? null : overrideKey.ToLowerInvariant().Trim(), + Subject = subject, + HtmlBody = htmlBody, + TextBody = textBody, + Language = language.ToLowerInvariant().Trim(), + IsActive = true, + Version = 1, + IsSystemTemplate = isSystemTemplate + }; + } + + /// + /// Atualiza o conteúdo do template e incrementa a versão. + /// + public void UpdateContent(string subject, string htmlBody, string textBody) + { + if (IsSystemTemplate) + { + throw new InvalidOperationException("Não é possível alterar o conteúdo de um template do sistema."); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(subject); + ArgumentException.ThrowIfNullOrWhiteSpace(htmlBody); + ArgumentException.ThrowIfNullOrWhiteSpace(textBody); + + Subject = subject; + HtmlBody = htmlBody; + TextBody = textBody; + Version++; + MarkAsUpdated(); + } + + /// + /// Desativa o template. + /// + public void Deactivate() + { + if (IsSystemTemplate) + { + throw new InvalidOperationException("Não é possível desativar um template do sistema."); + } + + IsActive = false; + MarkAsUpdated(); + } + + /// + /// Ativa o template. + /// + public void Activate() + { + IsActive = true; + MarkAsUpdated(); + } +} diff --git a/src/Modules/Communications/Domain/Entities/OutboxMessage.cs b/src/Modules/Communications/Domain/Entities/OutboxMessage.cs new file mode 100644 index 000000000..4f5034a0b --- /dev/null +++ b/src/Modules/Communications/Domain/Entities/OutboxMessage.cs @@ -0,0 +1,49 @@ +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Shared.Database.Outbox; +using MeAjudaAi.Contracts.Shared; + +namespace MeAjudaAi.Modules.Communications.Domain.Entities; + +/// +/// Representa uma mensagem no Outbox específica do módulo de comunicações. +/// Herda da base genérica para aproveitar lógica de processamento e retries. +/// +public sealed class OutboxMessage : MeAjudaAi.Shared.Database.Outbox.OutboxMessage +{ + private OutboxMessage() { } + + /// + /// Canal de comunicação: Email, Sms ou Push. + /// No banco, este campo é específico deste módulo. + /// + public ECommunicationChannel Channel { get; private set; } + + /// + /// Cria uma nova mensagem no Outbox de comunicações. + /// + public static OutboxMessage Create( + ECommunicationChannel channel, + string payload, + ECommunicationPriority priority = ECommunicationPriority.Normal, + DateTime? scheduledAt = null, + int maxRetries = 3, + string? correlationId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(payload); + if (maxRetries < 1) + throw new ArgumentOutOfRangeException(nameof(maxRetries), "MaxRetries must be at least 1."); + + return new OutboxMessage + { + Channel = channel, + Type = channel.ToString(), // Mapeia o canal para o campo Type da base + Payload = payload, + Status = EOutboxMessageStatus.Pending, + Priority = priority, + RetryCount = 0, + MaxRetries = maxRetries, + ScheduledAt = scheduledAt, + CorrelationId = correlationId + }; + } +} diff --git a/src/Modules/Communications/Domain/Enums/ECommunicationChannel.cs b/src/Modules/Communications/Domain/Enums/ECommunicationChannel.cs new file mode 100644 index 000000000..94caafc7d --- /dev/null +++ b/src/Modules/Communications/Domain/Enums/ECommunicationChannel.cs @@ -0,0 +1,11 @@ +namespace MeAjudaAi.Modules.Communications.Domain.Enums; + +/// +/// Canal de comunicação. +/// +public enum ECommunicationChannel +{ + Email = 1, + Sms = 2, + Push = 3 +} diff --git a/src/Modules/Communications/Domain/MeAjudaAi.Modules.Communications.Domain.csproj b/src/Modules/Communications/Domain/MeAjudaAi.Modules.Communications.Domain.csproj new file mode 100644 index 000000000..4af60a596 --- /dev/null +++ b/src/Modules/Communications/Domain/MeAjudaAi.Modules.Communications.Domain.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + + + + + <_Parameter1>MeAjudaAi.Modules.Communications.Tests + + + <_Parameter1>MeAjudaAi.Integration.Tests + + + + + + + + diff --git a/src/Modules/Communications/Domain/Repositories/ICommunicationLogRepository.cs b/src/Modules/Communications/Domain/Repositories/ICommunicationLogRepository.cs new file mode 100644 index 000000000..31c8b7f73 --- /dev/null +++ b/src/Modules/Communications/Domain/Repositories/ICommunicationLogRepository.cs @@ -0,0 +1,45 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; + +namespace MeAjudaAi.Modules.Communications.Domain.Repositories; + +/// +/// Repositório de logs de comunicação. +/// +public interface ICommunicationLogRepository +{ + /// + /// Adiciona um novo log. + /// + Task AddAsync(CommunicationLog log, CancellationToken cancellationToken = default); + + /// + /// Verifica se já existe um log com o CorrelationId especificado. + /// Usado para garantir idempotência. + /// + Task ExistsByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default); + + /// + /// Retorna o histórico de logs de um determinado destinatário. + /// + Task> GetByRecipientAsync( + string recipient, + int maxResults = 50, + CancellationToken cancellationToken = default); + + /// + /// Busca logs de comunicação de forma paginada. + /// + Task<(IReadOnlyList Items, int TotalCount)> SearchAsync( + string? correlationId = null, + string? channel = null, + string? recipient = null, + bool? isSuccess = null, + int pageNumber = 1, + int pageSize = 20, + CancellationToken cancellationToken = default); + + /// + /// Persiste as alterações no banco de dados. + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Communications/Domain/Repositories/IEmailTemplateRepository.cs b/src/Modules/Communications/Domain/Repositories/IEmailTemplateRepository.cs new file mode 100644 index 000000000..48d204beb --- /dev/null +++ b/src/Modules/Communications/Domain/Repositories/IEmailTemplateRepository.cs @@ -0,0 +1,40 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; + +namespace MeAjudaAi.Modules.Communications.Domain.Repositories; + +/// +/// Repositório de templates de e-mail. +/// +public interface IEmailTemplateRepository +{ + /// + /// Adiciona um novo template. + /// + Task AddAsync(EmailTemplate template, CancellationToken cancellationToken = default); + + /// + /// Retorna um template ativo pelo TemplateKey e Language. + /// Prefere OverrideKey quando disponível. + /// + Task GetActiveByKeyAsync( + string templateKey, + string language = "pt-BR", + CancellationToken cancellationToken = default); + + /// + /// Retorna todos os templates de uma determinada chave. + /// + Task> GetAllByKeyAsync( + string templateKey, + CancellationToken cancellationToken = default); + + /// + /// Retorna todos os templates registrados. + /// + Task> GetAllAsync(CancellationToken cancellationToken = default); + + /// + /// Remove um template pelo ID (protegido se for template de sistema). + /// + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Communications/Domain/Repositories/IOutboxMessageRepository.cs b/src/Modules/Communications/Domain/Repositories/IOutboxMessageRepository.cs new file mode 100644 index 000000000..17ecf8c61 --- /dev/null +++ b/src/Modules/Communications/Domain/Repositories/IOutboxMessageRepository.cs @@ -0,0 +1,27 @@ +using MeAjudaAi.Shared.Database.Outbox; +using MeAjudaAi.Contracts.Shared; +using OutboxMessage = MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage; + +namespace MeAjudaAi.Modules.Communications.Domain.Repositories; + +/// +/// Repositório para gestão de mensagens Outbox de comunicação. +/// Herda da interface genérica para garantir consistência no processamento. +/// +public interface IOutboxMessageRepository : IOutboxRepository +{ + /// + /// Retorna o total de mensagens por status (usado para monitoramento/health checks). + /// + Task CountByStatusAsync(EOutboxMessageStatus status, CancellationToken cancellationToken = default); + + /// + /// Limpa mensagens muito antigas já enviadas para economizar espaço em disco. + /// + Task CleanupOldMessagesAsync(DateTime threshold, CancellationToken cancellationToken = default); + + /// + /// Reseta mensagens travadas no status 'Processing' por muito tempo. + /// + Task ResetStaleProcessingMessagesAsync(DateTime threshold, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Communications/Domain/Services/IEmailSender.cs b/src/Modules/Communications/Domain/Services/IEmailSender.cs new file mode 100644 index 000000000..cd51731c3 --- /dev/null +++ b/src/Modules/Communications/Domain/Services/IEmailSender.cs @@ -0,0 +1,31 @@ +namespace MeAjudaAi.Modules.Communications.Domain.Services; + +/// +/// DTO que representa uma mensagem de e-mail a ser enviada. +/// +/// Endereço de destino. +/// Assunto do e-mail. +/// Corpo HTML. +/// Corpo em texto puro. +/// Endereço de origem (null usa padrão configurado). +public sealed record EmailMessage( + string To, + string Subject, + string HtmlBody, + string TextBody, + string? From = null +); + +/// +/// Abstração do canal de envio de e-mail. +/// +public interface IEmailSender +{ + /// + /// Envia um e-mail. + /// + /// Dados do e-mail a enviar. + /// Token de cancelamento. + /// True se enviado com sucesso. + Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Communications/Domain/Services/IPushSender.cs b/src/Modules/Communications/Domain/Services/IPushSender.cs new file mode 100644 index 000000000..026404dbe --- /dev/null +++ b/src/Modules/Communications/Domain/Services/IPushSender.cs @@ -0,0 +1,29 @@ +namespace MeAjudaAi.Modules.Communications.Domain.Services; + +/// +/// DTO que representa uma notificação push a ser enviada. +/// +/// Token do dispositivo destino. +/// Título da notificação. +/// Corpo da notificação. +/// Dados adicionais opcionais (key-value pairs). +public sealed record PushNotification( + string DeviceToken, + string Title, + string Body, + IDictionary? Data = null +); + +/// +/// Abstração do canal de envio de notificações push. +/// +public interface IPushSender +{ + /// + /// Envia uma notificação push. + /// + /// Dados da notificação a enviar. + /// Token de cancelamento. + /// True se enviada com sucesso. + Task SendAsync(PushNotification notification, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Communications/Domain/Services/ISmsSender.cs b/src/Modules/Communications/Domain/Services/ISmsSender.cs new file mode 100644 index 000000000..d47963c5a --- /dev/null +++ b/src/Modules/Communications/Domain/Services/ISmsSender.cs @@ -0,0 +1,25 @@ +namespace MeAjudaAi.Modules.Communications.Domain.Services; + +/// +/// DTO que representa uma mensagem SMS a ser enviada. +/// +/// Número de telefone no formato E.164 (+5511999999999). +/// Texto da mensagem. +public sealed record SmsMessage( + string PhoneNumber, + string Body +); + +/// +/// Abstração do canal de envio de SMS. +/// +public interface ISmsSender +{ + /// + /// Envia uma mensagem SMS. + /// + /// Dados do SMS a enviar. + /// Token de cancelamento. + /// True se enviado com sucesso. + Task SendAsync(SmsMessage message, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/Communications/Domain/packages.lock.json b/src/Modules/Communications/Domain/packages.lock.json new file mode 100644 index 000000000..09f7dbed4 --- /dev/null +++ b/src/Modules/Communications/Domain/packages.lock.json @@ -0,0 +1,821 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.22.0.136894, )", + "resolved": "10.22.0.136894", + "contentHash": "6fI0XUWHvFIa/cvo1HuopV1Gh1hnKJq+XlTMJ2q71+6D3uVkl6Vxza3fFKQ9C4Bc7KFUFtukzRPmiH1be0JxOA==" + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "mpeNZyMdvrHztJwR1sXIUQ+3iioEU97YMBnFA9WLbsPOYhGwDJnqJMmEd8ny7kcmS9OjTHoEuX/bSXXY3brIFA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Dapper.AOT": { + "type": "Transitive", + "resolved": "1.0.48", + "contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA==" + }, + "Hangfire.NetCore": { + "type": "Transitive", + "resolved": "1.8.23", + "contentHash": "SmvUJF/u5MCP666R5Y1V+GntqBc4RCWJqn5ztMMN67d53Cx5cuaWR0YNLMrabjylwLarFYJ7EdR9RnGEZzp/dg==", + "dependencies": { + "Hangfire.Core": "[1.8.23]", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "3.0.0", + "Microsoft.Extensions.Logging.Abstractions": "3.0.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Bcl.TimeProvider": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "C7kWHJnMRY7EvJev2S8+yJHZ1y7A4ZlLbA4NE+O23BDIAN5mHeqND1m+SKv1ChRS5YlCDW7yAMUe7lttRsJaAA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "32c58Rnm47Qvhimawf67KO9PytgPz3QoWye7Abapt0Yocw/JnzMiSNj/pRoIKyn8Jxypkv86zxKD4Q/zNTc0Ag==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "ipC4u1VojgEfoIZhtbS2Sx5IluJTP/Jf1hz3yGsxGBgSukYY/CquI6rAjxn5H58CZgVn36qcuPPtNMwZ0AUzMg==" + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "jUEXmkBUPdOS/MP9areK/sbKhdklq9+tEhvwfxGalZVnmyLUO5rrheNNutUBtvbZ7J8ECkG7/r2KXi/IFC06cA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "Transitive", + "resolved": "8.0.11", + "contentHash": "zLgN22Zp9pk8RHlwssRTexw4+a6wqOnKWN+VejdPn5Yhjql4XiBhkFo35Nu8mmqHIk/UEmmCnMGLWq75aFfkOw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "Transitive", + "resolved": "8.0.11", + "contentHash": "So3JUdRxozRjvQ3cxU6F3nI/i4emDnjane6yMYcJhvTTTu29ltlIdoXjkFGRceIWz8yKvuEpzXItZ0x5GvN2nQ==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g==" + }, + "Microsoft.FeatureManagement": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "qxvGAv9WJHYfOpixWywJTa1WNTPy5MbQiv+O+UlE6E/LVofiM1+YRR6m41zsHIbAGm1S0PQ0QFuAsOw9DkoKsg==", + "dependencies": { + "Microsoft.Bcl.TimeProvider": "8.0.1", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.2", + "Microsoft.Extensions.Logging": "8.0.1" + } + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "StackExchange.Redis": { + "type": "Transitive", + "resolved": "2.7.27", + "contentHash": "Uqc2OQHglqj9/FfGQ6RkKFkZfHySfZlfmbCl+hc+u2I/IqunfelQ7QJi7ZhvAJxUtu80pildVX6NPLdDaUffOw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "meajudaai.contracts": { + "type": "Project", + "dependencies": { + "FluentValidation": "[12.1.1, )" + } + }, + "meajudaai.shared": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Mvc": "[8.1.1, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.1, )", + "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", + "AspNetCore.HealthChecks.Redis": "[9.0.0, )", + "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", + "FluentValidation": "[12.1.1, )", + "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", + "Hangfire.AspNetCore": "[1.8.23, )", + "Hangfire.Core": "[1.8.23, )", + "Hangfire.PostgreSql": "[1.21.1, )", + "MeAjudaAi.Contracts": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )", + "Microsoft.EntityFrameworkCore": "[10.0.5, )", + "Microsoft.EntityFrameworkCore.Design": "[10.0.5, )", + "Microsoft.Extensions.Caching.Hybrid": "[10.4.0, )", + "Microsoft.Extensions.Caching.StackExchangeRedis": "[10.0.5, )", + "Microsoft.FeatureManagement.AspNetCore": "[4.4.0, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )", + "RabbitMQ.Client": "[7.2.1, )", + "Rebus": "[8.9.0, )", + "Rebus.RabbitMq": "[10.1.1, )", + "Rebus.ServiceProvider": "[10.7.2, )", + "Scrutor": "[7.0.0, )", + "Serilog": "[4.3.1, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Enrichers.Environment": "[3.0.1, )", + "Serilog.Enrichers.Process": "[3.0.0, )", + "Serilog.Enrichers.Thread": "[4.0.0, )", + "Serilog.Settings.Configuration": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.Seq": "[9.0.0, )" + } + }, + "Asp.Versioning.Http": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "1D/Mzq1MSUSQe1eCY6GeEsu+tIlpzoWZxZkhlcw/uvtVoTrwO+BMl0fSj/XX8oZN1DutWTZqHDHgyDRq+IeSdQ==", + "dependencies": { + "Asp.Versioning.Abstractions": "8.1.0" + } + }, + "Asp.Versioning.Mvc": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "mkJv6eAdlbHbqTdrUcfEYFoZuGL6HoR7O+Lfsvivixp7N5BNhfCFPPOwsBzdIiH1qzdJXyJf+C+DZ08j27PMPg==", + "dependencies": { + "Asp.Versioning.Http": "8.1.1" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "u8PrP6CjmurgIh0EDfg8Gc/GdpDHgkbv8OLKdxQcWb5W4sp0i9BcdbQlLvRcATjNIJUyr7ZmqXjmdsFfqnuy0g==", + "dependencies": { + "Asp.Versioning.Mvc": "8.1.1" + } + }, + "AspNetCore.HealthChecks.NpgSql": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "Npgsql": "8.0.3" + } + }, + "AspNetCore.HealthChecks.Redis": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "yNH0h8GLRbAf+PU5HNVLZ5hNeyq9mDVmRKO9xuZsme/znUYoBJlQvI0gq45gaZNlLncCHkMhR4o90MuT+gxxPw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "StackExchange.Redis": "2.7.4" + } + }, + "Dapper": { + "type": "CentralTransitive", + "requested": "[2.1.72, )", + "resolved": "2.1.72", + "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "FluentValidation": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "dependencies": { + "FluentValidation": "12.1.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Hangfire.AspNetCore": { + "type": "CentralTransitive", + "requested": "[1.8.23, )", + "resolved": "1.8.23", + "contentHash": "TXpOl7kX4xXq5bLEqqWCpt9zh3TaouDwtb3GDtzGHX5uSC2RaAqZzn2swevivx3Uki16slXIigiPtgr4TPKpsg==", + "dependencies": { + "Hangfire.NetCore": "[1.8.23]" + } + }, + "Hangfire.Core": { + "type": "CentralTransitive", + "requested": "[1.8.23, )", + "resolved": "1.8.23", + "contentHash": "YCOTtF3NNOQI83PlfjeNDDBkofJDfdET2CwhfQsiVBwmsU6lP19QW9NVTIH9epl+MnOsyFC2G1RnlPSGV8F1FQ==", + "dependencies": { + "Newtonsoft.Json": "11.0.1" + } + }, + "Hangfire.PostgreSql": { + "type": "CentralTransitive", + "requested": "[1.21.1, )", + "resolved": "1.21.1", + "contentHash": "hFNZAxv+1p72/XCZdImnH6ovCzZ2DKAMTOI8CReT0P3yw/k0b0YJP2teA18agNH1ZYInPzhtxGk8hx5n2cxbbQ==", + "dependencies": { + "Dapper": "2.0.123", + "Dapper.AOT": "1.0.48", + "Hangfire.Core": "1.8.0", + "Npgsql": "6.0.11" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "vTcxIfOPyfFbYk1g8YcXJfkMnlEWVkSnnjxcZLy60zgwiHMRf2SnZR+9E4HlpwKxgE3yfKMOti8J6WfKuKsw6w==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.Build.Framework": { + "type": "CentralTransitive", + "requested": "[18.0.2, )", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9tNBmK3EpYVGRQLiqP+bqK2m+TD0Gv//4vCzR7ZOgl4FWzCFyOpYdIVka13M4kcBdPdSJcs3wbHr3rmzOqbIMA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.5", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "gm6f0cC2w/2tcd4GeZJqEMruTercpIJfO5sSAFLtqTqblDBHgAFk70xwshUIUVX4I6sZwdEUSd1YxoKFk1AL0w==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "uxmFjZEAB/KbsgWFSS4lLqkEHCfXxB2x0UcbiO4e5fCRpFFeTMSx/me6009nYJLu5IKlDwO1POh++P6RilFTDw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "k/QDdQ94/0Shi0KfU+e12m73jfQo+3JpErTtgpZfsCIqkvdEEO0XIx6R+iTbN55rNPaNhOqNY4/sB+jZ8XxVPw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Caching.Hybrid": { + "type": "CentralTransitive", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "4V+aMLQeU/p4VcIWIcvGro0L6HynmL2TrelL04Ce1iotP6T5+kjxuZQvl6P1ObSXIRPCbVXtQSt1NxK0fRIuag==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.4", + "Microsoft.Extensions.Caching.Memory": "10.0.4", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4" + } + }, + "Microsoft.Extensions.Caching.StackExchangeRedis": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "zXb143/TpEKOLQuWGw2CkJgb9F4XXh2XbevMvppzsIHr1/pjML0zjc+vzXcpCV8YUwpW5NIaScZhzFSm621B3Q==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "StackExchange.Redis": "2.7.27" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "xA4kkL+QS6KCAOKz/O0oquHs44Ob8J7zpBCNt3wjkBWDg5aCqfwG8rWWLsg5V86AM0sB849g9JjPjIdksTCIKg==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.FeatureManagement.AspNetCore": { + "type": "CentralTransitive", + "requested": "[4.4.0, )", + "resolved": "4.4.0", + "contentHash": "jE5KxeEDUnUsx1w9uOFV5cOfM4E2mg7kf07328xO1x4JdoS0jwkD55nMjTlUPu90ynYX1oCBGC+FHK6ZoqRpxA==", + "dependencies": { + "Microsoft.FeatureManagement": "4.4.0" + } + }, + "Microsoft.OpenApi": { + "type": "CentralTransitive", + "requested": "[2.7.0, )", + "resolved": "2.7.0", + "contentHash": "b9xmpnmjq6p+HqF3uWG7u7/PlB38t/UB5UtXdi6xEAP9ZJGKHneYyjMGzBflB1rpLxYEcU6KRme+cz5wNPlxqA==" + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[10.0.2, )", + "resolved": "10.0.2", + "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "P6EwH0Q4xkaA264iNZDqCPhWt8pscfUGxXazDQg4noBfqjoOlk4hKWfvBjF9ZX3R/9JybRmmJfmxr2iBMj0EpA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.2" + } + }, + "RabbitMQ.Client": { + "type": "CentralTransitive", + "requested": "[7.2.1, )", + "resolved": "7.2.1", + "contentHash": "YKXEfg9fVQiTKgZlvIhAfPSFaamEgi8DsQmisCH0IAsU4FYLrtoguDrDj6JtJVGUt40QPnBLRH6fTQcAC4qsOg==", + "dependencies": { + "System.Threading.RateLimiting": "8.0.0" + } + }, + "Rebus": { + "type": "CentralTransitive", + "requested": "[8.9.0, )", + "resolved": "8.9.0", + "contentHash": "UaPGZuXIL4J5GUDA05JzEEzuPMEXY0CoF92nC6bsFBPvwoYPQ0uKyH2vKqdV80CW7cjbwBgDlEZ7R9hO9b59XA==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "Rebus.RabbitMq": { + "type": "CentralTransitive", + "requested": "[10.1.1, )", + "resolved": "10.1.1", + "contentHash": "66pUp4hfaYWfQEDOiVcuZQnPF4XFHyJ5KCfwCm18e3Dnr936Iog48KrN8Mp8QyRQ2tiNpzdjSATQLKEZpSk11A==", + "dependencies": { + "RabbitMq.Client": "7.1.2", + "rebus": "8.9.0" + } + }, + "Rebus.ServiceProvider": { + "type": "CentralTransitive", + "requested": "[10.7.2, )", + "resolved": "10.7.2", + "contentHash": "Qa8sKt1i9Fy/zCw5GwAUsfT+lt4BvkIgYh8sRJ6fvqJWoedS//pfcyiKUUb0wL3C5Wrpi3U+vRud5DCbMHaFIw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "[8.0.0, 11.0.0)", + "Microsoft.Extensions.Hosting.Abstractions": "[6.0.0, 11.0.0)", + "Microsoft.Extensions.Logging.Abstractions": "[6.0.0, 11.0.0)", + "Rebus": "8.9.0" + } + }, + "Scrutor": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.3.1, )", + "resolved": "4.3.1", + "contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ==" + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Enrichers.Environment": { + "type": "CentralTransitive", + "requested": "[3.0.1, )", + "resolved": "3.0.1", + "contentHash": "9BqCE4C9FF+/rJb/CsQwe7oVf44xqkOvMwX//CUxvUR25lFL4tSS6iuxE5eW07quby1BAyAEP+vM6TWsnT3iqw==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Enrichers.Process": { + "type": "CentralTransitive", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "/wPYz2PDCJGSHNI+Z0PAacZvrgZgrGduWqLXeC2wvW6pgGM/Bi45JrKy887MRcRPHIZVU0LAlkmJ7TkByC0boQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Enrichers.Thread": { + "type": "CentralTransitive", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "C7BK25a1rhUyr+Tp+1BYcVlBJq7M2VCHlIgnwoIUVJcicM9jYcvQK18+OeHiXw7uLPSjqWxJIp1EfaZ/RGmEwA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Seq": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "aNU8A0K322q7+voPNmp1/qNPH+9QK8xvM1p72sMmCG0wGlshFzmtDW9QnVSoSYCj0MgQKcMOlgooovtBhRlNHw==", + "dependencies": { + "Serilog": "4.2.0", + "Serilog.Sinks.File": "6.0.0" + } + } + } + } +} \ No newline at end of file diff --git a/src/Modules/Communications/Infrastructure/Extensions.cs b/src/Modules/Communications/Infrastructure/Extensions.cs new file mode 100644 index 000000000..dd687ca15 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Extensions.cs @@ -0,0 +1,40 @@ +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Modules.Communications.Domain.Services; +using MeAjudaAi.Modules.Communications.Infrastructure.Persistence; +using MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Repositories; +using MeAjudaAi.Modules.Communications.Infrastructure.Services; +using MeAjudaAi.Shared.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace MeAjudaAi.Modules.Communications.Infrastructure; + +public static class Extensions +{ + public static IServiceCollection AddCommunicationsInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + // Persistência + services.AddPostgresContext(builder => + { + builder.UseSnakeCaseNamingConvention(); + }); + + // Repositórios + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // Stubs de remetentes (ativados via feature flag para dev/testes) + // Por padrão, ativa se não houver configuração para evitar crash na injeção de dependência do OutboxProcessorService + if (configuration.GetValue("Communications:EnableStubs", true)) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + } + + return services; + } +} diff --git a/src/Modules/Communications/Infrastructure/MeAjudaAi.Modules.Communications.Infrastructure.csproj b/src/Modules/Communications/Infrastructure/MeAjudaAi.Modules.Communications.Infrastructure.csproj new file mode 100644 index 000000000..b49e9996a --- /dev/null +++ b/src/Modules/Communications/Infrastructure/MeAjudaAi.Modules.Communications.Infrastructure.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + + + + + <_Parameter1>MeAjudaAi.Modules.Communications.Tests + + + <_Parameter1>MeAjudaAi.Integration.Tests + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/src/Modules/Communications/Infrastructure/Persistence/CommunicationsDbContext.cs b/src/Modules/Communications/Infrastructure/Persistence/CommunicationsDbContext.cs new file mode 100644 index 000000000..18c100bf2 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/CommunicationsDbContext.cs @@ -0,0 +1,55 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Shared.Database; +using MeAjudaAi.Shared.Domain; +using MeAjudaAi.Shared.Events; +using Microsoft.EntityFrameworkCore; + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence; + +/// +/// DbContext para o módulo de comunicações. +/// +public sealed class CommunicationsDbContext : BaseDbContext +{ + public DbSet OutboxMessages => Set(); + public DbSet EmailTemplates => Set(); + public DbSet CommunicationLogs => Set(); + + public CommunicationsDbContext(DbContextOptions options) : base(options) + { + } + + public CommunicationsDbContext(DbContextOptions options, IDomainEventProcessor domainEventProcessor) + : base(options, domainEventProcessor) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("communications"); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(CommunicationsDbContext).Assembly); + base.OnModelCreating(modelBuilder); + } + + protected override Task> GetDomainEventsAsync(CancellationToken cancellationToken = default) + { + var domainEvents = ChangeTracker.Entries() + .Select(x => x.Entity) + .SelectMany(x => x.DomainEvents) + .ToList(); + + return Task.FromResult(domainEvents); + } + + protected override void ClearDomainEvents() + { + var entities = ChangeTracker.Entries() + .Select(x => x.Entity) + .ToList(); + + foreach (var entity in entities) + { + entity.ClearDomainEvents(); + } + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/CommunicationsDbContextFactory.cs b/src/Modules/Communications/Infrastructure/Persistence/CommunicationsDbContextFactory.cs new file mode 100644 index 000000000..12a4a47af --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/CommunicationsDbContextFactory.cs @@ -0,0 +1,15 @@ +using MeAjudaAi.Shared.Database; +using Microsoft.EntityFrameworkCore; + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence; + +/// +/// Factory para criação do DbContext em tempo de design (usado por dotnet ef migrations). +/// +public class CommunicationsDbContextFactory : BaseDesignTimeDbContextFactory +{ + protected override CommunicationsDbContext CreateDbContextInstance(DbContextOptions options) + { + return new CommunicationsDbContext(options); + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Configurations/CommunicationLogConfiguration.cs b/src/Modules/Communications/Infrastructure/Persistence/Configurations/CommunicationLogConfiguration.cs new file mode 100644 index 000000000..e2e7cbbcb --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Configurations/CommunicationLogConfiguration.cs @@ -0,0 +1,38 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Configurations; + +internal sealed class CommunicationLogConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("communication_logs"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.CorrelationId) + .HasMaxLength(200) + .IsRequired(); + + builder.Property(x => x.Channel) + .HasConversion() + .HasMaxLength(20) + .IsRequired(); + + builder.Property(x => x.Recipient) + .HasMaxLength(255) + .IsRequired(); + + builder.Property(x => x.TemplateKey) + .HasMaxLength(100); + + builder.Property(x => x.ErrorMessage) + .HasMaxLength(2000); + + builder.HasIndex(x => x.CorrelationId).IsUnique(); + builder.HasIndex(x => x.Recipient); + builder.HasIndex(x => x.CreatedAt); + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Configurations/EmailTemplateConfiguration.cs b/src/Modules/Communications/Infrastructure/Persistence/Configurations/EmailTemplateConfiguration.cs new file mode 100644 index 000000000..83c72a1bf --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Configurations/EmailTemplateConfiguration.cs @@ -0,0 +1,41 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Configurations; + +internal sealed class EmailTemplateConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("email_templates"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.TemplateKey) + .HasMaxLength(100) + .IsRequired(); + + builder.Property(x => x.OverrideKey) + .HasMaxLength(100); + + builder.Property(x => x.Subject) + .HasMaxLength(255) + .IsRequired(); + + builder.Property(x => x.HtmlBody) + .IsRequired(); + + builder.Property(x => x.TextBody) + .IsRequired(); + + builder.Property(x => x.Language) + .HasMaxLength(10) + .IsRequired() + .HasDefaultValue("pt-BR"); + + builder.HasIndex(x => new { x.TemplateKey, x.Language, x.OverrideKey }) + .IsUnique() + .AreNullsDistinct(false); + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs b/src/Modules/Communications/Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs new file mode 100644 index 000000000..7482a1123 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Configurations/OutboxMessageConfiguration.cs @@ -0,0 +1,52 @@ +using OutboxMessage = MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage; +using MeAjudaAi.Shared.Database.Outbox; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Configurations; + +internal sealed class OutboxMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("outbox_messages"); + + builder.HasKey(x => x.Id); + + builder.Property(x => x.Channel) + .HasConversion() + .HasMaxLength(20) + .IsRequired(); + + builder.Property(x => x.Type) + .HasMaxLength(100) + .IsRequired(); + + builder.Property(x => x.CorrelationId) + .HasMaxLength(200); + + builder.Property(x => x.Payload) + .HasColumnType("jsonb") + .IsRequired(); + + builder.Property(x => x.Status) + .HasConversion() + .HasMaxLength(20) + .IsRequired(); + + builder.Property(x => x.Priority) + .HasConversion() + .HasMaxLength(20) + .IsRequired(); + + builder.Property(x => x.ErrorMessage) + .HasMaxLength(2000); + + builder.HasIndex(x => x.CorrelationId) + .HasDatabaseName(OutboxMessageConstraints.CorrelationIdIndexName) + .IsUnique() + .HasFilter("\"correlation_id\" IS NOT Null"); + + builder.HasIndex(x => new { x.Status, x.ScheduledAt, x.Priority, x.CreatedAt }); + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409000512_InitialCommunications.Designer.cs b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409000512_InitialCommunications.Designer.cs new file mode 100644 index 000000000..5a1e00ccd --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409000512_InitialCommunications.Designer.cs @@ -0,0 +1,201 @@ +// +using System; +using MeAjudaAi.Modules.Communications.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CommunicationsDbContext))] + [Migration("20260409000512_InitialCommunications")] + partial class InitialCommunications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("communications") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.CommunicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsSuccess") + .HasColumnType("boolean"); + + b.Property("OutboxMessageId") + .HasColumnType("uuid"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TemplateKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Recipient"); + + b.ToTable("communication_logs", "communications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSystemTemplate") + .HasColumnType("boolean"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("pt-BR"); + + b.Property("OverrideKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TemplateKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TextBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TemplateKey", "Language", "OverrideKey") + .IsUnique(); + + b.ToTable("email_templates", "communications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("MaxRetries") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp without time zone"); + + b.Property("SentAt") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("ScheduledAt"); + + b.HasIndex("Status", "Priority", "CreatedAt"); + + b.ToTable("outbox_messages", "communications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409000512_InitialCommunications.cs b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409000512_InitialCommunications.cs new file mode 100644 index 000000000..f29c046c7 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409000512_InitialCommunications.cs @@ -0,0 +1,140 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialCommunications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "communications"); + + migrationBuilder.CreateTable( + name: "communication_logs", + schema: "communications", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CorrelationId = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Channel = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Recipient = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + TemplateKey = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + IsSuccess = table.Column(type: "boolean", nullable: false), + ErrorMessage = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + AttemptCount = table.Column(type: "integer", nullable: false), + OutboxMessageId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp without time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp without time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_communication_logs", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "email_templates", + schema: "communications", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TemplateKey = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + OverrideKey = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Subject = table.Column(type: "character varying(255)", maxLength: 255, nullable: false), + HtmlBody = table.Column(type: "text", nullable: false), + TextBody = table.Column(type: "text", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + IsSystemTemplate = table.Column(type: "boolean", nullable: false), + Language = table.Column(type: "character varying(10)", maxLength: 10, nullable: false, defaultValue: "pt-BR"), + Version = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp without time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp without time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_email_templates", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "outbox_messages", + schema: "communications", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Channel = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Payload = table.Column(type: "jsonb", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Priority = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + RetryCount = table.Column(type: "integer", nullable: false), + MaxRetries = table.Column(type: "integer", nullable: false), + ScheduledAt = table.Column(type: "timestamp without time zone", nullable: true), + SentAt = table.Column(type: "timestamp without time zone", nullable: true), + ErrorMessage = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + CreatedAt = table.Column(type: "timestamp without time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp without time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_outbox_messages", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_communication_logs_CorrelationId", + schema: "communications", + table: "communication_logs", + column: "CorrelationId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_communication_logs_CreatedAt", + schema: "communications", + table: "communication_logs", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_communication_logs_Recipient", + schema: "communications", + table: "communication_logs", + column: "Recipient"); + + migrationBuilder.CreateIndex( + name: "IX_email_templates_TemplateKey_Language_OverrideKey", + schema: "communications", + table: "email_templates", + columns: new[] { "TemplateKey", "Language", "OverrideKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_outbox_messages_ScheduledAt", + schema: "communications", + table: "outbox_messages", + column: "ScheduledAt"); + + migrationBuilder.CreateIndex( + name: "IX_outbox_messages_Status_Priority_CreatedAt", + schema: "communications", + table: "outbox_messages", + columns: new[] { "Status", "Priority", "CreatedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "communication_logs", + schema: "communications"); + + migrationBuilder.DropTable( + name: "email_templates", + schema: "communications"); + + migrationBuilder.DropTable( + name: "outbox_messages", + schema: "communications"); + } + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409004631_AddCorrelationIdToOutbox.Designer.cs b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409004631_AddCorrelationIdToOutbox.Designer.cs new file mode 100644 index 000000000..f6e6eefca --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409004631_AddCorrelationIdToOutbox.Designer.cs @@ -0,0 +1,209 @@ +// +using System; +using MeAjudaAi.Modules.Communications.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CommunicationsDbContext))] + [Migration("20260409004631_AddCorrelationIdToOutbox")] + partial class AddCorrelationIdToOutbox + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("communications") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.CommunicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IsSuccess") + .HasColumnType("boolean"); + + b.Property("OutboxMessageId") + .HasColumnType("uuid"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TemplateKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId") + .IsUnique(); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Recipient"); + + b.ToTable("communication_logs", "communications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSystemTemplate") + .HasColumnType("boolean"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("pt-BR"); + + b.Property("OverrideKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TemplateKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TextBody") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TemplateKey", "Language", "OverrideKey") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TemplateKey", "Language", "OverrideKey"), false); + + b.ToTable("email_templates", "communications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CorrelationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("MaxRetries") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId") + .IsUnique() + .HasFilter("\"CorrelationId\" IS NOT Null"); + + b.HasIndex("Status", "ScheduledAt", "Priority", "CreatedAt"); + + b.ToTable("outbox_messages", "communications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409004631_AddCorrelationIdToOutbox.cs b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409004631_AddCorrelationIdToOutbox.cs new file mode 100644 index 000000000..2d68f7fe5 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260409004631_AddCorrelationIdToOutbox.cs @@ -0,0 +1,257 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddCorrelationIdToOutbox : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_outbox_messages_ScheduledAt", + schema: "communications", + table: "outbox_messages"); + + migrationBuilder.DropIndex( + name: "IX_outbox_messages_Status_Priority_CreatedAt", + schema: "communications", + table: "outbox_messages"); + + migrationBuilder.DropIndex( + name: "IX_email_templates_TemplateKey_Language_OverrideKey", + schema: "communications", + table: "email_templates"); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + schema: "communications", + table: "outbox_messages", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SentAt", + schema: "communications", + table: "outbox_messages", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ScheduledAt", + schema: "communications", + table: "outbox_messages", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + schema: "communications", + table: "outbox_messages", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AddColumn( + name: "CorrelationId", + schema: "communications", + table: "outbox_messages", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + schema: "communications", + table: "email_templates", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + schema: "communications", + table: "email_templates", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + schema: "communications", + table: "communication_logs", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + schema: "communications", + table: "communication_logs", + type: "timestamp with time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp without time zone"); + + migrationBuilder.CreateIndex( + name: "IX_outbox_messages_CorrelationId", + schema: "communications", + table: "outbox_messages", + column: "CorrelationId", + unique: true, + filter: "\"CorrelationId\" IS NOT Null"); + + migrationBuilder.CreateIndex( + name: "IX_outbox_messages_Status_ScheduledAt_Priority_CreatedAt", + schema: "communications", + table: "outbox_messages", + columns: new[] { "Status", "ScheduledAt", "Priority", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_email_templates_TemplateKey_Language_OverrideKey", + schema: "communications", + table: "email_templates", + columns: new[] { "TemplateKey", "Language", "OverrideKey" }, + unique: true) + .Annotation("Npgsql:NullsDistinct", false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_outbox_messages_CorrelationId", + schema: "communications", + table: "outbox_messages"); + + migrationBuilder.DropIndex( + name: "IX_outbox_messages_Status_ScheduledAt_Priority_CreatedAt", + schema: "communications", + table: "outbox_messages"); + + migrationBuilder.DropIndex( + name: "IX_email_templates_TemplateKey_Language_OverrideKey", + schema: "communications", + table: "email_templates"); + + migrationBuilder.DropColumn( + name: "CorrelationId", + schema: "communications", + table: "outbox_messages"); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + schema: "communications", + table: "outbox_messages", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "SentAt", + schema: "communications", + table: "outbox_messages", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ScheduledAt", + schema: "communications", + table: "outbox_messages", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + schema: "communications", + table: "outbox_messages", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + schema: "communications", + table: "email_templates", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + schema: "communications", + table: "email_templates", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.AlterColumn( + name: "UpdatedAt", + schema: "communications", + table: "communication_logs", + type: "timestamp without time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "CreatedAt", + schema: "communications", + table: "communication_logs", + type: "timestamp without time zone", + nullable: false, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + + migrationBuilder.CreateIndex( + name: "IX_outbox_messages_ScheduledAt", + schema: "communications", + table: "outbox_messages", + column: "ScheduledAt"); + + migrationBuilder.CreateIndex( + name: "IX_outbox_messages_Status_Priority_CreatedAt", + schema: "communications", + table: "outbox_messages", + columns: new[] { "Status", "Priority", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_email_templates_TemplateKey_Language_OverrideKey", + schema: "communications", + table: "email_templates", + columns: new[] { "TemplateKey", "Language", "OverrideKey" }, + unique: true); + } + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260410183507_EnableSnakeCaseNaming.Designer.cs b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260410183507_EnableSnakeCaseNaming.Designer.cs new file mode 100644 index 000000000..f4f281ad8 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260410183507_EnableSnakeCaseNaming.Designer.cs @@ -0,0 +1,254 @@ +// +using System; +using MeAjudaAi.Modules.Communications.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CommunicationsDbContext))] + [Migration("20260410183507_EnableSnakeCaseNaming")] + partial class EnableSnakeCaseNaming + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("communications") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.CommunicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("integer") + .HasColumnName("attempt_count"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("channel"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("error_message"); + + b.Property("IsSuccess") + .HasColumnType("boolean") + .HasColumnName("is_success"); + + b.Property("OutboxMessageId") + .HasColumnType("uuid") + .HasColumnName("outbox_message_id"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("recipient"); + + b.Property("TemplateKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("template_key"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_communication_logs"); + + b.HasIndex("CorrelationId") + .IsUnique() + .HasDatabaseName("ix_communication_logs_correlation_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_communication_logs_created_at"); + + b.HasIndex("Recipient") + .HasDatabaseName("ix_communication_logs_recipient"); + + b.ToTable("communication_logs", "communications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html_body"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSystemTemplate") + .HasColumnType("boolean") + .HasColumnName("is_system_template"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("pt-BR") + .HasColumnName("language"); + + b.Property("OverrideKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("override_key"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("subject"); + + b.Property("TemplateKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("template_key"); + + b.Property("TextBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text_body"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_email_templates"); + + b.HasIndex("TemplateKey", "Language", "OverrideKey") + .IsUnique() + .HasDatabaseName("ix_email_templates_template_key_language_override_key"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TemplateKey", "Language", "OverrideKey"), false); + + b.ToTable("email_templates", "communications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("channel"); + + b.Property("CorrelationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("error_message"); + + b.Property("MaxRetries") + .HasColumnType("integer") + .HasColumnName("max_retries"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("retry_count"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("scheduled_at"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.HasIndex("CorrelationId") + .IsUnique() + .HasDatabaseName("ix_outbox_messages_correlation_id") + .HasFilter("\"correlation_id\" IS NOT Null"); + + b.HasIndex("Status", "ScheduledAt", "Priority", "CreatedAt") + .HasDatabaseName("ix_outbox_messages_status_scheduled_at_priority_created_at"); + + b.ToTable("outbox_messages", "communications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260410183507_EnableSnakeCaseNaming.cs b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260410183507_EnableSnakeCaseNaming.cs new file mode 100644 index 000000000..c21003f76 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260410183507_EnableSnakeCaseNaming.cs @@ -0,0 +1,602 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Migrations +{ + /// + public partial class EnableSnakeCaseNaming : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_outbox_messages", + schema: "communications", + table: "outbox_messages"); + + migrationBuilder.DropIndex( + name: "IX_outbox_messages_CorrelationId", + schema: "communications", + table: "outbox_messages"); + + migrationBuilder.DropPrimaryKey( + name: "PK_email_templates", + schema: "communications", + table: "email_templates"); + + migrationBuilder.DropPrimaryKey( + name: "PK_communication_logs", + schema: "communications", + table: "communication_logs"); + + migrationBuilder.RenameColumn( + name: "Status", + schema: "communications", + table: "outbox_messages", + newName: "status"); + + migrationBuilder.RenameColumn( + name: "Priority", + schema: "communications", + table: "outbox_messages", + newName: "priority"); + + migrationBuilder.RenameColumn( + name: "Payload", + schema: "communications", + table: "outbox_messages", + newName: "payload"); + + migrationBuilder.RenameColumn( + name: "Channel", + schema: "communications", + table: "outbox_messages", + newName: "channel"); + + migrationBuilder.RenameColumn( + name: "Id", + schema: "communications", + table: "outbox_messages", + newName: "id"); + + migrationBuilder.RenameColumn( + name: "UpdatedAt", + schema: "communications", + table: "outbox_messages", + newName: "updated_at"); + + migrationBuilder.RenameColumn( + name: "SentAt", + schema: "communications", + table: "outbox_messages", + newName: "sent_at"); + + migrationBuilder.RenameColumn( + name: "ScheduledAt", + schema: "communications", + table: "outbox_messages", + newName: "scheduled_at"); + + migrationBuilder.RenameColumn( + name: "RetryCount", + schema: "communications", + table: "outbox_messages", + newName: "retry_count"); + + migrationBuilder.RenameColumn( + name: "MaxRetries", + schema: "communications", + table: "outbox_messages", + newName: "max_retries"); + + migrationBuilder.RenameColumn( + name: "ErrorMessage", + schema: "communications", + table: "outbox_messages", + newName: "error_message"); + + migrationBuilder.RenameColumn( + name: "CreatedAt", + schema: "communications", + table: "outbox_messages", + newName: "created_at"); + + migrationBuilder.RenameColumn( + name: "CorrelationId", + schema: "communications", + table: "outbox_messages", + newName: "correlation_id"); + + migrationBuilder.RenameIndex( + name: "IX_outbox_messages_Status_ScheduledAt_Priority_CreatedAt", + schema: "communications", + table: "outbox_messages", + newName: "ix_outbox_messages_status_scheduled_at_priority_created_at"); + + migrationBuilder.RenameColumn( + name: "Version", + schema: "communications", + table: "email_templates", + newName: "version"); + + migrationBuilder.RenameColumn( + name: "Subject", + schema: "communications", + table: "email_templates", + newName: "subject"); + + migrationBuilder.RenameColumn( + name: "Language", + schema: "communications", + table: "email_templates", + newName: "language"); + + migrationBuilder.RenameColumn( + name: "Id", + schema: "communications", + table: "email_templates", + newName: "id"); + + migrationBuilder.RenameColumn( + name: "UpdatedAt", + schema: "communications", + table: "email_templates", + newName: "updated_at"); + + migrationBuilder.RenameColumn( + name: "TextBody", + schema: "communications", + table: "email_templates", + newName: "text_body"); + + migrationBuilder.RenameColumn( + name: "TemplateKey", + schema: "communications", + table: "email_templates", + newName: "template_key"); + + migrationBuilder.RenameColumn( + name: "OverrideKey", + schema: "communications", + table: "email_templates", + newName: "override_key"); + + migrationBuilder.RenameColumn( + name: "IsSystemTemplate", + schema: "communications", + table: "email_templates", + newName: "is_system_template"); + + migrationBuilder.RenameColumn( + name: "IsActive", + schema: "communications", + table: "email_templates", + newName: "is_active"); + + migrationBuilder.RenameColumn( + name: "HtmlBody", + schema: "communications", + table: "email_templates", + newName: "html_body"); + + migrationBuilder.RenameColumn( + name: "CreatedAt", + schema: "communications", + table: "email_templates", + newName: "created_at"); + + migrationBuilder.RenameIndex( + name: "IX_email_templates_TemplateKey_Language_OverrideKey", + schema: "communications", + table: "email_templates", + newName: "ix_email_templates_template_key_language_override_key"); + + migrationBuilder.RenameColumn( + name: "Recipient", + schema: "communications", + table: "communication_logs", + newName: "recipient"); + + migrationBuilder.RenameColumn( + name: "Channel", + schema: "communications", + table: "communication_logs", + newName: "channel"); + + migrationBuilder.RenameColumn( + name: "Id", + schema: "communications", + table: "communication_logs", + newName: "id"); + + migrationBuilder.RenameColumn( + name: "UpdatedAt", + schema: "communications", + table: "communication_logs", + newName: "updated_at"); + + migrationBuilder.RenameColumn( + name: "TemplateKey", + schema: "communications", + table: "communication_logs", + newName: "template_key"); + + migrationBuilder.RenameColumn( + name: "OutboxMessageId", + schema: "communications", + table: "communication_logs", + newName: "outbox_message_id"); + + migrationBuilder.RenameColumn( + name: "IsSuccess", + schema: "communications", + table: "communication_logs", + newName: "is_success"); + + migrationBuilder.RenameColumn( + name: "ErrorMessage", + schema: "communications", + table: "communication_logs", + newName: "error_message"); + + migrationBuilder.RenameColumn( + name: "CreatedAt", + schema: "communications", + table: "communication_logs", + newName: "created_at"); + + migrationBuilder.RenameColumn( + name: "CorrelationId", + schema: "communications", + table: "communication_logs", + newName: "correlation_id"); + + migrationBuilder.RenameColumn( + name: "AttemptCount", + schema: "communications", + table: "communication_logs", + newName: "attempt_count"); + + migrationBuilder.RenameIndex( + name: "IX_communication_logs_Recipient", + schema: "communications", + table: "communication_logs", + newName: "ix_communication_logs_recipient"); + + migrationBuilder.RenameIndex( + name: "IX_communication_logs_CreatedAt", + schema: "communications", + table: "communication_logs", + newName: "ix_communication_logs_created_at"); + + migrationBuilder.RenameIndex( + name: "IX_communication_logs_CorrelationId", + schema: "communications", + table: "communication_logs", + newName: "ix_communication_logs_correlation_id"); + + migrationBuilder.AddPrimaryKey( + name: "pk_outbox_messages", + schema: "communications", + table: "outbox_messages", + column: "id"); + + migrationBuilder.AddPrimaryKey( + name: "pk_email_templates", + schema: "communications", + table: "email_templates", + column: "id"); + + migrationBuilder.AddPrimaryKey( + name: "pk_communication_logs", + schema: "communications", + table: "communication_logs", + column: "id"); + + migrationBuilder.CreateIndex( + name: "ix_outbox_messages_correlation_id", + schema: "communications", + table: "outbox_messages", + column: "correlation_id", + unique: true, + filter: "\"correlation_id\" IS NOT Null"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "pk_outbox_messages", + schema: "communications", + table: "outbox_messages"); + + migrationBuilder.DropIndex( + name: "ix_outbox_messages_correlation_id", + schema: "communications", + table: "outbox_messages"); + + migrationBuilder.DropPrimaryKey( + name: "pk_email_templates", + schema: "communications", + table: "email_templates"); + + migrationBuilder.DropPrimaryKey( + name: "pk_communication_logs", + schema: "communications", + table: "communication_logs"); + + migrationBuilder.RenameColumn( + name: "status", + schema: "communications", + table: "outbox_messages", + newName: "Status"); + + migrationBuilder.RenameColumn( + name: "priority", + schema: "communications", + table: "outbox_messages", + newName: "Priority"); + + migrationBuilder.RenameColumn( + name: "payload", + schema: "communications", + table: "outbox_messages", + newName: "Payload"); + + migrationBuilder.RenameColumn( + name: "channel", + schema: "communications", + table: "outbox_messages", + newName: "Channel"); + + migrationBuilder.RenameColumn( + name: "id", + schema: "communications", + table: "outbox_messages", + newName: "Id"); + + migrationBuilder.RenameColumn( + name: "updated_at", + schema: "communications", + table: "outbox_messages", + newName: "UpdatedAt"); + + migrationBuilder.RenameColumn( + name: "sent_at", + schema: "communications", + table: "outbox_messages", + newName: "SentAt"); + + migrationBuilder.RenameColumn( + name: "scheduled_at", + schema: "communications", + table: "outbox_messages", + newName: "ScheduledAt"); + + migrationBuilder.RenameColumn( + name: "retry_count", + schema: "communications", + table: "outbox_messages", + newName: "RetryCount"); + + migrationBuilder.RenameColumn( + name: "max_retries", + schema: "communications", + table: "outbox_messages", + newName: "MaxRetries"); + + migrationBuilder.RenameColumn( + name: "error_message", + schema: "communications", + table: "outbox_messages", + newName: "ErrorMessage"); + + migrationBuilder.RenameColumn( + name: "created_at", + schema: "communications", + table: "outbox_messages", + newName: "CreatedAt"); + + migrationBuilder.RenameColumn( + name: "correlation_id", + schema: "communications", + table: "outbox_messages", + newName: "CorrelationId"); + + migrationBuilder.RenameIndex( + name: "ix_outbox_messages_status_scheduled_at_priority_created_at", + schema: "communications", + table: "outbox_messages", + newName: "IX_outbox_messages_Status_ScheduledAt_Priority_CreatedAt"); + + migrationBuilder.RenameColumn( + name: "version", + schema: "communications", + table: "email_templates", + newName: "Version"); + + migrationBuilder.RenameColumn( + name: "subject", + schema: "communications", + table: "email_templates", + newName: "Subject"); + + migrationBuilder.RenameColumn( + name: "language", + schema: "communications", + table: "email_templates", + newName: "Language"); + + migrationBuilder.RenameColumn( + name: "id", + schema: "communications", + table: "email_templates", + newName: "Id"); + + migrationBuilder.RenameColumn( + name: "updated_at", + schema: "communications", + table: "email_templates", + newName: "UpdatedAt"); + + migrationBuilder.RenameColumn( + name: "text_body", + schema: "communications", + table: "email_templates", + newName: "TextBody"); + + migrationBuilder.RenameColumn( + name: "template_key", + schema: "communications", + table: "email_templates", + newName: "TemplateKey"); + + migrationBuilder.RenameColumn( + name: "override_key", + schema: "communications", + table: "email_templates", + newName: "OverrideKey"); + + migrationBuilder.RenameColumn( + name: "is_system_template", + schema: "communications", + table: "email_templates", + newName: "IsSystemTemplate"); + + migrationBuilder.RenameColumn( + name: "is_active", + schema: "communications", + table: "email_templates", + newName: "IsActive"); + + migrationBuilder.RenameColumn( + name: "html_body", + schema: "communications", + table: "email_templates", + newName: "HtmlBody"); + + migrationBuilder.RenameColumn( + name: "created_at", + schema: "communications", + table: "email_templates", + newName: "CreatedAt"); + + migrationBuilder.RenameIndex( + name: "ix_email_templates_template_key_language_override_key", + schema: "communications", + table: "email_templates", + newName: "IX_email_templates_TemplateKey_Language_OverrideKey"); + + migrationBuilder.RenameColumn( + name: "recipient", + schema: "communications", + table: "communication_logs", + newName: "Recipient"); + + migrationBuilder.RenameColumn( + name: "channel", + schema: "communications", + table: "communication_logs", + newName: "Channel"); + + migrationBuilder.RenameColumn( + name: "id", + schema: "communications", + table: "communication_logs", + newName: "Id"); + + migrationBuilder.RenameColumn( + name: "updated_at", + schema: "communications", + table: "communication_logs", + newName: "UpdatedAt"); + + migrationBuilder.RenameColumn( + name: "template_key", + schema: "communications", + table: "communication_logs", + newName: "TemplateKey"); + + migrationBuilder.RenameColumn( + name: "outbox_message_id", + schema: "communications", + table: "communication_logs", + newName: "OutboxMessageId"); + + migrationBuilder.RenameColumn( + name: "is_success", + schema: "communications", + table: "communication_logs", + newName: "IsSuccess"); + + migrationBuilder.RenameColumn( + name: "error_message", + schema: "communications", + table: "communication_logs", + newName: "ErrorMessage"); + + migrationBuilder.RenameColumn( + name: "created_at", + schema: "communications", + table: "communication_logs", + newName: "CreatedAt"); + + migrationBuilder.RenameColumn( + name: "correlation_id", + schema: "communications", + table: "communication_logs", + newName: "CorrelationId"); + + migrationBuilder.RenameColumn( + name: "attempt_count", + schema: "communications", + table: "communication_logs", + newName: "AttemptCount"); + + migrationBuilder.RenameIndex( + name: "ix_communication_logs_recipient", + schema: "communications", + table: "communication_logs", + newName: "IX_communication_logs_Recipient"); + + migrationBuilder.RenameIndex( + name: "ix_communication_logs_created_at", + schema: "communications", + table: "communication_logs", + newName: "IX_communication_logs_CreatedAt"); + + migrationBuilder.RenameIndex( + name: "ix_communication_logs_correlation_id", + schema: "communications", + table: "communication_logs", + newName: "IX_communication_logs_CorrelationId"); + + migrationBuilder.AddPrimaryKey( + name: "PK_outbox_messages", + schema: "communications", + table: "outbox_messages", + column: "Id"); + + migrationBuilder.AddPrimaryKey( + name: "PK_email_templates", + schema: "communications", + table: "email_templates", + column: "Id"); + + migrationBuilder.AddPrimaryKey( + name: "PK_communication_logs", + schema: "communications", + table: "communication_logs", + column: "Id"); + + migrationBuilder.CreateIndex( + name: "IX_outbox_messages_CorrelationId", + schema: "communications", + table: "outbox_messages", + column: "CorrelationId", + unique: true, + filter: "\"CorrelationId\" IS NOT Null"); + } + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260412150640_AddTypeToOutbox.Designer.cs b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260412150640_AddTypeToOutbox.Designer.cs new file mode 100644 index 000000000..a221ab491 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260412150640_AddTypeToOutbox.Designer.cs @@ -0,0 +1,260 @@ +// +using System; +using MeAjudaAi.Modules.Communications.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CommunicationsDbContext))] + [Migration("20260412150640_AddTypeToOutbox")] + partial class AddTypeToOutbox + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("communications") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.CommunicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("integer") + .HasColumnName("attempt_count"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("channel"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("error_message"); + + b.Property("IsSuccess") + .HasColumnType("boolean") + .HasColumnName("is_success"); + + b.Property("OutboxMessageId") + .HasColumnType("uuid") + .HasColumnName("outbox_message_id"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("recipient"); + + b.Property("TemplateKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("template_key"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_communication_logs"); + + b.HasIndex("CorrelationId") + .IsUnique() + .HasDatabaseName("ix_communication_logs_correlation_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_communication_logs_created_at"); + + b.HasIndex("Recipient") + .HasDatabaseName("ix_communication_logs_recipient"); + + b.ToTable("communication_logs", "communications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html_body"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSystemTemplate") + .HasColumnType("boolean") + .HasColumnName("is_system_template"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("pt-BR") + .HasColumnName("language"); + + b.Property("OverrideKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("override_key"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("subject"); + + b.Property("TemplateKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("template_key"); + + b.Property("TextBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text_body"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_email_templates"); + + b.HasIndex("TemplateKey", "Language", "OverrideKey") + .IsUnique() + .HasDatabaseName("ix_email_templates_template_key_language_override_key"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TemplateKey", "Language", "OverrideKey"), false); + + b.ToTable("email_templates", "communications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("channel"); + + b.Property("CorrelationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("error_message"); + + b.Property("MaxRetries") + .HasColumnType("integer") + .HasColumnName("max_retries"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("retry_count"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("scheduled_at"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.HasIndex("CorrelationId") + .IsUnique() + .HasDatabaseName("ix_outbox_messages_correlation_id") + .HasFilter("\"correlation_id\" IS NOT Null"); + + b.HasIndex("Status", "ScheduledAt", "Priority", "CreatedAt") + .HasDatabaseName("ix_outbox_messages_status_scheduled_at_priority_created_at"); + + b.ToTable("outbox_messages", "communications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260412150640_AddTypeToOutbox.cs b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260412150640_AddTypeToOutbox.cs new file mode 100644 index 000000000..1f1202075 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Migrations/20260412150640_AddTypeToOutbox.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddTypeToOutbox : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "type", + schema: "communications", + table: "outbox_messages", + type: "character varying(100)", + maxLength: 100, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "type", + schema: "communications", + table: "outbox_messages"); + } + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Migrations/CommunicationsDbContextModelSnapshot.cs b/src/Modules/Communications/Infrastructure/Persistence/Migrations/CommunicationsDbContextModelSnapshot.cs new file mode 100644 index 000000000..bfc24c4f4 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Migrations/CommunicationsDbContextModelSnapshot.cs @@ -0,0 +1,257 @@ +// +using System; +using MeAjudaAi.Modules.Communications.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CommunicationsDbContext))] + partial class CommunicationsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("communications") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.CommunicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AttemptCount") + .HasColumnType("integer") + .HasColumnName("attempt_count"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("channel"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("error_message"); + + b.Property("IsSuccess") + .HasColumnType("boolean") + .HasColumnName("is_success"); + + b.Property("OutboxMessageId") + .HasColumnType("uuid") + .HasColumnName("outbox_message_id"); + + b.Property("Recipient") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("recipient"); + + b.Property("TemplateKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("template_key"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_communication_logs"); + + b.HasIndex("CorrelationId") + .IsUnique() + .HasDatabaseName("ix_communication_logs_correlation_id"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("ix_communication_logs_created_at"); + + b.HasIndex("Recipient") + .HasDatabaseName("ix_communication_logs_recipient"); + + b.ToTable("communication_logs", "communications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html_body"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("IsSystemTemplate") + .HasColumnType("boolean") + .HasColumnName("is_system_template"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("pt-BR") + .HasColumnName("language"); + + b.Property("OverrideKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("override_key"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("subject"); + + b.Property("TemplateKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("template_key"); + + b.Property("TextBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text_body"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_email_templates"); + + b.HasIndex("TemplateKey", "Language", "OverrideKey") + .IsUnique() + .HasDatabaseName("ix_email_templates_template_key_language_override_key"); + + NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("TemplateKey", "Language", "OverrideKey"), false); + + b.ToTable("email_templates", "communications"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("channel"); + + b.Property("CorrelationId") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("correlation_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("error_message"); + + b.Property("MaxRetries") + .HasColumnType("integer") + .HasColumnName("max_retries"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Priority") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("priority"); + + b.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("retry_count"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("scheduled_at"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_outbox_messages"); + + b.HasIndex("CorrelationId") + .IsUnique() + .HasDatabaseName("ix_outbox_messages_correlation_id") + .HasFilter("\"correlation_id\" IS NOT Null"); + + b.HasIndex("Status", "ScheduledAt", "Priority", "CreatedAt") + .HasDatabaseName("ix_outbox_messages_status_scheduled_at_priority_created_at"); + + b.ToTable("outbox_messages", "communications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Repositories/CommunicationLogRepository.cs b/src/Modules/Communications/Infrastructure/Persistence/Repositories/CommunicationLogRepository.cs new file mode 100644 index 000000000..7cc0b9618 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Repositories/CommunicationLogRepository.cs @@ -0,0 +1,80 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Repositories; + +internal sealed class CommunicationLogRepository(CommunicationsDbContext context) : ICommunicationLogRepository +{ + public async Task AddAsync(CommunicationLog log, CancellationToken cancellationToken = default) + { + await context.CommunicationLogs.AddAsync(log, cancellationToken); + } + + public async Task ExistsByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) + { + return await context.CommunicationLogs.AnyAsync(x => x.CorrelationId == correlationId, cancellationToken); + } + + public async Task> GetByRecipientAsync( + string recipient, + int maxResults = 50, + CancellationToken cancellationToken = default) + { + return await context.CommunicationLogs + .Where(x => x.Recipient == recipient) + .OrderByDescending(x => x.CreatedAt) + .Take(maxResults) + .ToListAsync(cancellationToken); + } + + public async Task<(IReadOnlyList Items, int TotalCount)> SearchAsync( + string? correlationId = null, + string? channel = null, + string? recipient = null, + bool? isSuccess = null, + int pageNumber = 1, + int pageSize = 20, + CancellationToken cancellationToken = default) + { + var query = context.CommunicationLogs.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(correlationId)) + query = query.Where(x => x.CorrelationId.Contains(correlationId)); + + if (!string.IsNullOrWhiteSpace(channel)) + { + if (Enum.TryParse(channel, true, out var ch)) + { + query = query.Where(x => x.Channel == ch); + } + else + { + // Se o canal informado é inválido, forçamos retorno vazio + query = query.Where(x => false); + } + } + + if (!string.IsNullOrWhiteSpace(recipient)) + query = query.Where(x => x.Recipient.Contains(recipient)); + + if (isSuccess.HasValue) + query = query.Where(x => x.IsSuccess == isSuccess.Value); + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(x => x.CreatedAt) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, totalCount); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + await context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Repositories/EmailTemplateRepository.cs b/src/Modules/Communications/Infrastructure/Persistence/Repositories/EmailTemplateRepository.cs new file mode 100644 index 000000000..6aabed75b --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Repositories/EmailTemplateRepository.cs @@ -0,0 +1,71 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Repositories; + +internal sealed class EmailTemplateRepository(CommunicationsDbContext context) : IEmailTemplateRepository +{ + public async Task AddAsync(EmailTemplate template, CancellationToken cancellationToken = default) + { + await context.EmailTemplates.AddAsync(template, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task GetActiveByKeyAsync( + string templateKey, + string language = "pt-BR", + CancellationToken cancellationToken = default) + { + var templateKeyLower = templateKey.ToLowerInvariant(); + var languageLower = (language ?? "pt-BR").ToLowerInvariant(); + + // 1. Tenta buscar primeiro por um override exato (OverrideKey coincide com o solicitado) + var overrideTemplate = await context.EmailTemplates + .FirstOrDefaultAsync(x => x.OverrideKey == templateKeyLower + && x.Language == languageLower + && x.IsActive, cancellationToken); + + if (overrideTemplate != null) return overrideTemplate; + + // 2. Se não houver override, busca pelo template base (TemplateKey coincide e OverrideKey é nulo) + return await context.EmailTemplates + .FirstOrDefaultAsync(x => x.TemplateKey == templateKeyLower + && x.OverrideKey == null + && x.Language == languageLower + && x.IsActive, cancellationToken); + } + + public async Task> GetAllByKeyAsync( + string templateKey, + CancellationToken cancellationToken = default) + { + return await context.EmailTemplates + .Where(x => x.TemplateKey == templateKey.ToLowerInvariant()) + .OrderBy(x => x.Language) + .ThenByDescending(x => x.Version) + .ToListAsync(cancellationToken); + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + return await context.EmailTemplates + .OrderBy(x => x.TemplateKey) + .ThenBy(x => x.Language) + .ToListAsync(cancellationToken); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) + { + var template = await context.EmailTemplates.FindAsync(new object[] { id }, cancellationToken); + if (template == null) return; + + if (template.IsSystemTemplate) + { + throw new InvalidOperationException($"Cannot delete system template with ID {id}."); + } + + context.EmailTemplates.Remove(template); + await context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Modules/Communications/Infrastructure/Persistence/Repositories/OutboxMessageRepository.cs b/src/Modules/Communications/Infrastructure/Persistence/Repositories/OutboxMessageRepository.cs new file mode 100644 index 000000000..434d76d61 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Persistence/Repositories/OutboxMessageRepository.cs @@ -0,0 +1,97 @@ +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Database.Outbox; +using MeAjudaAi.Contracts.Shared; +using Microsoft.EntityFrameworkCore; +using OutboxMessage = MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage; + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Persistence.Repositories; + +internal sealed class OutboxMessageRepository(CommunicationsDbContext context) : IOutboxMessageRepository +{ + public async Task AddAsync(OutboxMessage message, CancellationToken cancellationToken = default) + { + await context.OutboxMessages.AddAsync(message, cancellationToken); + } + + public async Task> GetPendingAsync( + int batchSize = 20, + DateTime? utcNow = null, + CancellationToken cancellationToken = default) + { + var now = utcNow ?? DateTime.UtcNow; + var strategy = context.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + + try + { + var messages = await context.OutboxMessages + .Where(x => x.Status == EOutboxMessageStatus.Pending && (x.ScheduledAt == null || x.ScheduledAt <= now)) + .OrderByDescending(x => x.Priority) + .ThenBy(x => x.CreatedAt) + .Take(batchSize) + .ToListAsync(cancellationToken); + + if (messages.Count != 0) + { + foreach (var message in messages) + { + message.MarkAsProcessing(); + } + + await context.SaveChangesAsync(cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + return messages; + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + }); + } + + public async Task CountByStatusAsync(EOutboxMessageStatus status, CancellationToken cancellationToken = default) + { + return await context.OutboxMessages.CountAsync(x => x.Status == status, cancellationToken); + } + + public async Task CleanupOldMessagesAsync(DateTime threshold, CancellationToken cancellationToken = default) + { + var oldMessages = await context.OutboxMessages + .Where(x => x.Status == EOutboxMessageStatus.Sent && x.CreatedAt < threshold) + .ToListAsync(cancellationToken); + + if (oldMessages.Count == 0) return 0; + + context.OutboxMessages.RemoveRange(oldMessages); + await context.SaveChangesAsync(cancellationToken); + return oldMessages.Count; + } + + public async Task ResetStaleProcessingMessagesAsync(DateTime threshold, CancellationToken cancellationToken = default) + { + var staleMessages = await context.OutboxMessages + .Where(x => x.Status == EOutboxMessageStatus.Processing && (x.UpdatedAt ?? x.CreatedAt) < threshold) + .ToListAsync(cancellationToken); + + if (staleMessages.Count == 0) return 0; + + foreach (var message in staleMessages) + { + message.ResetToPending(); + } + + await context.SaveChangesAsync(cancellationToken); + return staleMessages.Count; + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + await context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Modules/Communications/Infrastructure/Services/EmailSenderStub.cs b/src/Modules/Communications/Infrastructure/Services/EmailSenderStub.cs new file mode 100644 index 000000000..82cee4b13 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Services/EmailSenderStub.cs @@ -0,0 +1,17 @@ +using MeAjudaAi.Modules.Communications.Domain.Services; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Services; + +/// +/// Stub para envio de e-mails na infraestrutura. +/// +internal sealed class EmailSenderStub(ILogger logger) : IEmailSender +{ + public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + logger.LogInformation("[STUB EMAIL SENDER] Email sent successfully (recipient and subject masked)."); + return true; + } +} diff --git a/src/Modules/Communications/Infrastructure/Services/PushSenderStub.cs b/src/Modules/Communications/Infrastructure/Services/PushSenderStub.cs new file mode 100644 index 000000000..818d6950a --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Services/PushSenderStub.cs @@ -0,0 +1,17 @@ +using MeAjudaAi.Modules.Communications.Domain.Services; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Services; + +/// +/// Stub para envio de notificações push na infraestrutura. +/// +internal sealed class PushSenderStub(ILogger logger) : IPushSender +{ + public async Task SendAsync(PushNotification notification, CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + logger.LogInformation("[STUB PUSH SENDER] Push notification sent (token masked) | Title: {Title}", notification.Title); + return true; + } +} diff --git a/src/Modules/Communications/Infrastructure/Services/SmsSenderStub.cs b/src/Modules/Communications/Infrastructure/Services/SmsSenderStub.cs new file mode 100644 index 000000000..98fe35355 --- /dev/null +++ b/src/Modules/Communications/Infrastructure/Services/SmsSenderStub.cs @@ -0,0 +1,17 @@ +using MeAjudaAi.Modules.Communications.Domain.Services; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Communications.Infrastructure.Services; + +/// +/// Stub para envio de SMS na infraestrutura. +/// +internal sealed class SmsSenderStub(ILogger logger) : ISmsSender +{ + public async Task SendAsync(SmsMessage message, CancellationToken cancellationToken = default) + { + await Task.Delay(100, cancellationToken); + logger.LogInformation("[STUB SMS SENDER] SMS sent successfully (number and message redacted)."); + return true; + } +} diff --git a/src/Modules/Communications/Infrastructure/packages.lock.json b/src/Modules/Communications/Infrastructure/packages.lock.json new file mode 100644 index 000000000..07034f08a --- /dev/null +++ b/src/Modules/Communications/Infrastructure/packages.lock.json @@ -0,0 +1,835 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "EFCore.NamingConventions": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "gm6f0cC2w/2tcd4GeZJqEMruTercpIJfO5sSAFLtqTqblDBHgAFk70xwshUIUVX4I6sZwdEUSd1YxoKFk1AL0w==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "SonarAnalyzer.CSharp": { + "type": "Direct", + "requested": "[10.22.0.136894, )", + "resolved": "10.22.0.136894", + "contentHash": "6fI0XUWHvFIa/cvo1HuopV1Gh1hnKJq+XlTMJ2q71+6D3uVkl6Vxza3fFKQ9C4Bc7KFUFtukzRPmiH1be0JxOA==" + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "mpeNZyMdvrHztJwR1sXIUQ+3iioEU97YMBnFA9WLbsPOYhGwDJnqJMmEd8ny7kcmS9OjTHoEuX/bSXXY3brIFA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Dapper.AOT": { + "type": "Transitive", + "resolved": "1.0.48", + "contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA==" + }, + "Hangfire.NetCore": { + "type": "Transitive", + "resolved": "1.8.23", + "contentHash": "SmvUJF/u5MCP666R5Y1V+GntqBc4RCWJqn5ztMMN67d53Cx5cuaWR0YNLMrabjylwLarFYJ7EdR9RnGEZzp/dg==", + "dependencies": { + "Hangfire.Core": "[1.8.23]", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "3.0.0", + "Microsoft.Extensions.Logging.Abstractions": "3.0.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.Bcl.TimeProvider": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "C7kWHJnMRY7EvJev2S8+yJHZ1y7A4ZlLbA4NE+O23BDIAN5mHeqND1m+SKv1ChRS5YlCDW7yAMUe7lttRsJaAA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "32c58Rnm47Qvhimawf67KO9PytgPz3QoWye7Abapt0Yocw/JnzMiSNj/pRoIKyn8Jxypkv86zxKD4Q/zNTc0Ag==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "ipC4u1VojgEfoIZhtbS2Sx5IluJTP/Jf1hz3yGsxGBgSukYY/CquI6rAjxn5H58CZgVn36qcuPPtNMwZ0AUzMg==" + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "jUEXmkBUPdOS/MP9areK/sbKhdklq9+tEhvwfxGalZVnmyLUO5rrheNNutUBtvbZ7J8ECkG7/r2KXi/IFC06cA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "Transitive", + "resolved": "8.0.11", + "contentHash": "zLgN22Zp9pk8RHlwssRTexw4+a6wqOnKWN+VejdPn5Yhjql4XiBhkFo35Nu8mmqHIk/UEmmCnMGLWq75aFfkOw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "8.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "Transitive", + "resolved": "8.0.11", + "contentHash": "So3JUdRxozRjvQ3cxU6F3nI/i4emDnjane6yMYcJhvTTTu29ltlIdoXjkFGRceIWz8yKvuEpzXItZ0x5GvN2nQ==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g==" + }, + "Microsoft.FeatureManagement": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "qxvGAv9WJHYfOpixWywJTa1WNTPy5MbQiv+O+UlE6E/LVofiM1+YRR6m41zsHIbAGm1S0PQ0QFuAsOw9DkoKsg==", + "dependencies": { + "Microsoft.Bcl.TimeProvider": "8.0.1", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.2", + "Microsoft.Extensions.Logging": "8.0.1" + } + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==" + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "StackExchange.Redis": { + "type": "Transitive", + "resolved": "2.7.27", + "contentHash": "Uqc2OQHglqj9/FfGQ6RkKFkZfHySfZlfmbCl+hc+u2I/IqunfelQ7QJi7ZhvAJxUtu80pildVX6NPLdDaUffOw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "meajudaai.contracts": { + "type": "Project", + "dependencies": { + "FluentValidation": "[12.1.1, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.shared": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Mvc": "[8.1.1, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.1, )", + "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", + "AspNetCore.HealthChecks.Redis": "[9.0.0, )", + "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", + "FluentValidation": "[12.1.1, )", + "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", + "Hangfire.AspNetCore": "[1.8.23, )", + "Hangfire.Core": "[1.8.23, )", + "Hangfire.PostgreSql": "[1.21.1, )", + "MeAjudaAi.Contracts": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )", + "Microsoft.EntityFrameworkCore": "[10.0.5, )", + "Microsoft.EntityFrameworkCore.Design": "[10.0.5, )", + "Microsoft.Extensions.Caching.Hybrid": "[10.4.0, )", + "Microsoft.Extensions.Caching.StackExchangeRedis": "[10.0.5, )", + "Microsoft.FeatureManagement.AspNetCore": "[4.4.0, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )", + "RabbitMQ.Client": "[7.2.1, )", + "Rebus": "[8.9.0, )", + "Rebus.RabbitMq": "[10.1.1, )", + "Rebus.ServiceProvider": "[10.7.2, )", + "Scrutor": "[7.0.0, )", + "Serilog": "[4.3.1, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Enrichers.Environment": "[3.0.1, )", + "Serilog.Enrichers.Process": "[3.0.0, )", + "Serilog.Enrichers.Thread": "[4.0.0, )", + "Serilog.Settings.Configuration": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.Seq": "[9.0.0, )" + } + }, + "Asp.Versioning.Http": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "1D/Mzq1MSUSQe1eCY6GeEsu+tIlpzoWZxZkhlcw/uvtVoTrwO+BMl0fSj/XX8oZN1DutWTZqHDHgyDRq+IeSdQ==", + "dependencies": { + "Asp.Versioning.Abstractions": "8.1.0" + } + }, + "Asp.Versioning.Mvc": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "mkJv6eAdlbHbqTdrUcfEYFoZuGL6HoR7O+Lfsvivixp7N5BNhfCFPPOwsBzdIiH1qzdJXyJf+C+DZ08j27PMPg==", + "dependencies": { + "Asp.Versioning.Http": "8.1.1" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "u8PrP6CjmurgIh0EDfg8Gc/GdpDHgkbv8OLKdxQcWb5W4sp0i9BcdbQlLvRcATjNIJUyr7ZmqXjmdsFfqnuy0g==", + "dependencies": { + "Asp.Versioning.Mvc": "8.1.1" + } + }, + "AspNetCore.HealthChecks.NpgSql": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "Npgsql": "8.0.3" + } + }, + "AspNetCore.HealthChecks.Redis": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "yNH0h8GLRbAf+PU5HNVLZ5hNeyq9mDVmRKO9xuZsme/znUYoBJlQvI0gq45gaZNlLncCHkMhR4o90MuT+gxxPw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "StackExchange.Redis": "2.7.4" + } + }, + "Dapper": { + "type": "CentralTransitive", + "requested": "[2.1.72, )", + "resolved": "2.1.72", + "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" + }, + "FluentValidation": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "dependencies": { + "FluentValidation": "12.1.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Hangfire.AspNetCore": { + "type": "CentralTransitive", + "requested": "[1.8.23, )", + "resolved": "1.8.23", + "contentHash": "TXpOl7kX4xXq5bLEqqWCpt9zh3TaouDwtb3GDtzGHX5uSC2RaAqZzn2swevivx3Uki16slXIigiPtgr4TPKpsg==", + "dependencies": { + "Hangfire.NetCore": "[1.8.23]" + } + }, + "Hangfire.Core": { + "type": "CentralTransitive", + "requested": "[1.8.23, )", + "resolved": "1.8.23", + "contentHash": "YCOTtF3NNOQI83PlfjeNDDBkofJDfdET2CwhfQsiVBwmsU6lP19QW9NVTIH9epl+MnOsyFC2G1RnlPSGV8F1FQ==", + "dependencies": { + "Newtonsoft.Json": "11.0.1" + } + }, + "Hangfire.PostgreSql": { + "type": "CentralTransitive", + "requested": "[1.21.1, )", + "resolved": "1.21.1", + "contentHash": "hFNZAxv+1p72/XCZdImnH6ovCzZ2DKAMTOI8CReT0P3yw/k0b0YJP2teA18agNH1ZYInPzhtxGk8hx5n2cxbbQ==", + "dependencies": { + "Dapper": "2.0.123", + "Dapper.AOT": "1.0.48", + "Hangfire.Core": "1.8.0", + "Npgsql": "6.0.11" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "vTcxIfOPyfFbYk1g8YcXJfkMnlEWVkSnnjxcZLy60zgwiHMRf2SnZR+9E4HlpwKxgE3yfKMOti8J6WfKuKsw6w==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.Build.Framework": { + "type": "CentralTransitive", + "requested": "[18.0.2, )", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9tNBmK3EpYVGRQLiqP+bqK2m+TD0Gv//4vCzR7ZOgl4FWzCFyOpYdIVka13M4kcBdPdSJcs3wbHr3rmzOqbIMA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.5", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "uxmFjZEAB/KbsgWFSS4lLqkEHCfXxB2x0UcbiO4e5fCRpFFeTMSx/me6009nYJLu5IKlDwO1POh++P6RilFTDw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "k/QDdQ94/0Shi0KfU+e12m73jfQo+3JpErTtgpZfsCIqkvdEEO0XIx6R+iTbN55rNPaNhOqNY4/sB+jZ8XxVPw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Caching.Hybrid": { + "type": "CentralTransitive", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "4V+aMLQeU/p4VcIWIcvGro0L6HynmL2TrelL04Ce1iotP6T5+kjxuZQvl6P1ObSXIRPCbVXtQSt1NxK0fRIuag==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.4", + "Microsoft.Extensions.Caching.Memory": "10.0.4", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4" + } + }, + "Microsoft.Extensions.Caching.StackExchangeRedis": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "zXb143/TpEKOLQuWGw2CkJgb9F4XXh2XbevMvppzsIHr1/pjML0zjc+vzXcpCV8YUwpW5NIaScZhzFSm621B3Q==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "StackExchange.Redis": "2.7.27" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "xA4kkL+QS6KCAOKz/O0oquHs44Ob8J7zpBCNt3wjkBWDg5aCqfwG8rWWLsg5V86AM0sB849g9JjPjIdksTCIKg==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.FeatureManagement.AspNetCore": { + "type": "CentralTransitive", + "requested": "[4.4.0, )", + "resolved": "4.4.0", + "contentHash": "jE5KxeEDUnUsx1w9uOFV5cOfM4E2mg7kf07328xO1x4JdoS0jwkD55nMjTlUPu90ynYX1oCBGC+FHK6ZoqRpxA==", + "dependencies": { + "Microsoft.FeatureManagement": "4.4.0" + } + }, + "Microsoft.OpenApi": { + "type": "CentralTransitive", + "requested": "[2.7.0, )", + "resolved": "2.7.0", + "contentHash": "b9xmpnmjq6p+HqF3uWG7u7/PlB38t/UB5UtXdi6xEAP9ZJGKHneYyjMGzBflB1rpLxYEcU6KRme+cz5wNPlxqA==" + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[10.0.2, )", + "resolved": "10.0.2", + "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "P6EwH0Q4xkaA264iNZDqCPhWt8pscfUGxXazDQg4noBfqjoOlk4hKWfvBjF9ZX3R/9JybRmmJfmxr2iBMj0EpA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.2" + } + }, + "RabbitMQ.Client": { + "type": "CentralTransitive", + "requested": "[7.2.1, )", + "resolved": "7.2.1", + "contentHash": "YKXEfg9fVQiTKgZlvIhAfPSFaamEgi8DsQmisCH0IAsU4FYLrtoguDrDj6JtJVGUt40QPnBLRH6fTQcAC4qsOg==", + "dependencies": { + "System.Threading.RateLimiting": "8.0.0" + } + }, + "Rebus": { + "type": "CentralTransitive", + "requested": "[8.9.0, )", + "resolved": "8.9.0", + "contentHash": "UaPGZuXIL4J5GUDA05JzEEzuPMEXY0CoF92nC6bsFBPvwoYPQ0uKyH2vKqdV80CW7cjbwBgDlEZ7R9hO9b59XA==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "Rebus.RabbitMq": { + "type": "CentralTransitive", + "requested": "[10.1.1, )", + "resolved": "10.1.1", + "contentHash": "66pUp4hfaYWfQEDOiVcuZQnPF4XFHyJ5KCfwCm18e3Dnr936Iog48KrN8Mp8QyRQ2tiNpzdjSATQLKEZpSk11A==", + "dependencies": { + "RabbitMq.Client": "7.1.2", + "rebus": "8.9.0" + } + }, + "Rebus.ServiceProvider": { + "type": "CentralTransitive", + "requested": "[10.7.2, )", + "resolved": "10.7.2", + "contentHash": "Qa8sKt1i9Fy/zCw5GwAUsfT+lt4BvkIgYh8sRJ6fvqJWoedS//pfcyiKUUb0wL3C5Wrpi3U+vRud5DCbMHaFIw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "[8.0.0, 11.0.0)", + "Microsoft.Extensions.Hosting.Abstractions": "[6.0.0, 11.0.0)", + "Microsoft.Extensions.Logging.Abstractions": "[6.0.0, 11.0.0)", + "Rebus": "8.9.0" + } + }, + "Scrutor": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.3.1, )", + "resolved": "4.3.1", + "contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ==" + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Enrichers.Environment": { + "type": "CentralTransitive", + "requested": "[3.0.1, )", + "resolved": "3.0.1", + "contentHash": "9BqCE4C9FF+/rJb/CsQwe7oVf44xqkOvMwX//CUxvUR25lFL4tSS6iuxE5eW07quby1BAyAEP+vM6TWsnT3iqw==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Enrichers.Process": { + "type": "CentralTransitive", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "/wPYz2PDCJGSHNI+Z0PAacZvrgZgrGduWqLXeC2wvW6pgGM/Bi45JrKy887MRcRPHIZVU0LAlkmJ7TkByC0boQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Enrichers.Thread": { + "type": "CentralTransitive", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "C7BK25a1rhUyr+Tp+1BYcVlBJq7M2VCHlIgnwoIUVJcicM9jYcvQK18+OeHiXw7uLPSjqWxJIp1EfaZ/RGmEwA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Seq": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "aNU8A0K322q7+voPNmp1/qNPH+9QK8xvM1p72sMmCG0wGlshFzmtDW9QnVSoSYCj0MgQKcMOlgooovtBhRlNHw==", + "dependencies": { + "Serilog": "4.2.0", + "Serilog.Sinks.File": "6.0.0" + } + } + } + } +} \ No newline at end of file diff --git a/src/Modules/Communications/Tests/Architecture/CommunicationsArchitectureTests.cs b/src/Modules/Communications/Tests/Architecture/CommunicationsArchitectureTests.cs new file mode 100644 index 000000000..98d2fc1a7 --- /dev/null +++ b/src/Modules/Communications/Tests/Architecture/CommunicationsArchitectureTests.cs @@ -0,0 +1,79 @@ +using MeAjudaAi.Modules.Communications.Application.Handlers; +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Infrastructure.Persistence; +using NetArchTest.Rules; +using FluentAssertions; +using Xunit; + +namespace MeAjudaAi.Modules.Communications.Tests.Architecture; + +public class CommunicationsArchitectureTests +{ + private static readonly System.Reflection.Assembly ApplicationAssembly = typeof(UserRegisteredIntegrationEventHandler).Assembly; + private static readonly System.Reflection.Assembly DomainAssembly = typeof(EmailTemplate).Assembly; + private static readonly System.Reflection.Assembly InfrastructureAssembly = typeof(CommunicationsDbContext).Assembly; + + [Fact] + public void Domain_Should_Not_Have_Dependency_On_Other_Layers() + { + Types.InAssembly(DomainAssembly) + .ShouldNot() + .HaveDependencyOn("MeAjudaAi.Modules.Communications.Application") + .GetResult() + .IsSuccessful.Should().BeTrue("Domain should not depend on Application"); + + Types.InAssembly(DomainAssembly) + .ShouldNot() + .HaveDependencyOn("MeAjudaAi.Modules.Communications.Infrastructure") + .GetResult() + .IsSuccessful.Should().BeTrue("Domain should not depend on Infrastructure"); + } + + [Fact] + public void Application_Should_Not_Have_Dependency_On_Infrastructure() + { + var result = Types.InAssembly(ApplicationAssembly) + .ShouldNot() + .HaveDependencyOn("MeAjudaAi.Modules.Communications.Infrastructure") + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Infrastructure_Should_Not_Have_Dependency_On_Application() + { + var result = Types.InAssembly(InfrastructureAssembly) + .ShouldNot() + .HaveDependencyOn("MeAjudaAi.Modules.Communications.Application") + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Handlers_Should_Be_Sealed() + { + var result = Types.InAssembly(ApplicationAssembly) + .That() + .HaveNameEndingWith("Handler") + .Should() + .BeSealed() + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } + + [Fact] + public void Repositories_Should_Be_Internal() + { + var result = Types.InAssembly(InfrastructureAssembly) + .That() + .HaveNameEndingWith("Repository") + .Should() + .NotBePublic() + .GetResult(); + + result.IsSuccessful.Should().BeTrue(); + } +} diff --git a/src/Modules/Communications/Tests/MeAjudaAi.Modules.Communications.Tests.csproj b/src/Modules/Communications/Tests/MeAjudaAi.Modules.Communications.Tests.csproj new file mode 100644 index 000000000..13f876d3a --- /dev/null +++ b/src/Modules/Communications/Tests/MeAjudaAi.Modules.Communications.Tests.csproj @@ -0,0 +1,51 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Modules/Communications/Tests/Unit/Application/Handlers/DocumentRejectedIntegrationEventHandlerTests.cs b/src/Modules/Communications/Tests/Unit/Application/Handlers/DocumentRejectedIntegrationEventHandlerTests.cs new file mode 100644 index 000000000..5e23b33e7 --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Application/Handlers/DocumentRejectedIntegrationEventHandlerTests.cs @@ -0,0 +1,171 @@ +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Modules.Providers; +using MeAjudaAi.Contracts.Modules.Providers.DTOs; +using MeAjudaAi.Modules.Communications.Application.Handlers; +using OutboxMessage = MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Messaging.Messages.Documents; +using MeAjudaAi.Shared.Database.Exceptions; +using MeAjudaAi.Shared.Database.Outbox; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using FluentAssertions; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Application.Handlers; + +public class DocumentRejectedIntegrationEventHandlerTests +{ + private readonly Mock _outboxRepositoryMock; + private readonly Mock _providersModuleApiMock; + private readonly Mock> _loggerMock; + private readonly DocumentRejectedIntegrationEventHandler _handler; + + public DocumentRejectedIntegrationEventHandlerTests() + { + _outboxRepositoryMock = new Mock(); + _providersModuleApiMock = new Mock(); + _loggerMock = new Mock>(); + + _handler = new DocumentRejectedIntegrationEventHandler( + _outboxRepositoryMock.Object, + _providersModuleApiMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_WhenValidEvent_ShouldEnqueueOutboxMessage() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new DocumentRejectedIntegrationEvent( + "Documents", Guid.NewGuid(), providerId, "Identity", "Invalid photo", DateTime.UtcNow); + + var providerDto = new ModuleProviderDto( + providerId, + "Test Provider", + "test-provider", + "provider@test.com", + "123456789", + "Individual", + "Verified", + DateTime.UtcNow, + DateTime.UtcNow, + true); + + _providersModuleApiMock.Setup(x => x.GetProviderByIdAsync(providerId, It.IsAny())) + .ReturnsAsync(Result.Success(providerDto)); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _outboxRepositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenProviderNotFound_ShouldSkip() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new DocumentRejectedIntegrationEvent( + "Documents", Guid.NewGuid(), providerId, "Identity", "Invalid photo", DateTime.UtcNow); + + _providersModuleApiMock.Setup(x => x.GetProviderByIdAsync(providerId, It.IsAny())) + .ReturnsAsync(Result.Success(null)); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + _outboxRepositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_WhenProviderFetchFails_ShouldThrowException() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new DocumentRejectedIntegrationEvent( + "Documents", Guid.NewGuid(), providerId, "Identity", "Invalid photo", DateTime.UtcNow); + + _providersModuleApiMock.Setup(x => x.GetProviderByIdAsync(providerId, It.IsAny())) + .ReturnsAsync(Result.Failure(new Error("Failed to fetch provider", 500))); + + // Act + var act = () => _handler.HandleAsync(integrationEvent); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*Failed to fetch provider*"); + + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_WhenProviderHasNoEmail_ShouldSkipEnqueue() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new DocumentRejectedIntegrationEvent( + "Documents", Guid.NewGuid(), providerId, "Identity", "Invalid photo", DateTime.UtcNow); + + var providerDto = new ModuleProviderDto( + providerId, + "Test Provider", + "test-provider", + null!, // No email + "123456789", + "Individual", + "Verified", + DateTime.UtcNow, + DateTime.UtcNow, + true); + + _providersModuleApiMock.Setup(x => x.GetProviderByIdAsync(providerId, It.IsAny())) + .ReturnsAsync(Result.Success(providerDto)); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_WhenOutboxDuplicateExists_ShouldNotCreateDuplicate() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new DocumentRejectedIntegrationEvent( + "Documents", Guid.NewGuid(), providerId, "Identity", "Invalid photo", DateTime.UtcNow); + + var providerDto = new ModuleProviderDto( + providerId, + "Test Provider", + "test-provider", + "provider@test.com", + "123456789", + "Individual", + "Verified", + DateTime.UtcNow, + DateTime.UtcNow, + true); + + _providersModuleApiMock.Setup(x => x.GetProviderByIdAsync(providerId, It.IsAny())) + .ReturnsAsync(Result.Success(providerDto)); + + // Simular exceção de duplicidade + _outboxRepositoryMock.Setup(x => x.SaveChangesAsync(It.IsAny())) + .ThrowsAsync(new UniqueConstraintException(OutboxMessageConstraints.CorrelationIdIndexName, "correlation_id", new Exception())); + + // Act + var act = () => _handler.HandleAsync(integrationEvent); + + // Assert + await act.Should().NotThrowAsync(); + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Communications/Tests/Unit/Application/Handlers/DocumentVerifiedIntegrationEventHandlerTests.cs b/src/Modules/Communications/Tests/Unit/Application/Handlers/DocumentVerifiedIntegrationEventHandlerTests.cs new file mode 100644 index 000000000..793b23edb --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Application/Handlers/DocumentVerifiedIntegrationEventHandlerTests.cs @@ -0,0 +1,153 @@ +using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Contracts.Modules.Providers; +using MeAjudaAi.Contracts.Modules.Providers.DTOs; +using MeAjudaAi.Modules.Communications.Application.Handlers; +using OutboxMessage = MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Messaging.Messages.Documents; +using MeAjudaAi.Shared.Database.Exceptions; +using MeAjudaAi.Shared.Database.Outbox; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using FluentAssertions; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Application.Handlers; + +public class DocumentVerifiedIntegrationEventHandlerTests +{ + private readonly Mock _outboxRepositoryMock; + private readonly Mock _providersModuleApiMock; + private readonly Mock> _loggerMock; + private readonly DocumentVerifiedIntegrationEventHandler _handler; + + public DocumentVerifiedIntegrationEventHandlerTests() + { + _outboxRepositoryMock = new Mock(); + _providersModuleApiMock = new Mock(); + _loggerMock = new Mock>(); + + _handler = new DocumentVerifiedIntegrationEventHandler( + _outboxRepositoryMock.Object, + _providersModuleApiMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_WhenValidEvent_ShouldEnqueueOutboxMessage() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new DocumentVerifiedIntegrationEvent( + "Documents", Guid.NewGuid(), providerId, "Identity", true, DateTime.UtcNow); + + var providerDto = new ModuleProviderDto( + providerId, + "Test Provider", + "test-provider", + "provider@test.com", + "123456789", + "Individual", + "Verified", + DateTime.UtcNow, + DateTime.UtcNow, + true); + + _providersModuleApiMock.Setup(x => x.GetProviderByIdAsync(providerId, It.IsAny())) + .ReturnsAsync(Result.Success(providerDto)); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _outboxRepositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenProviderNotFound_ShouldSkip() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new DocumentVerifiedIntegrationEvent( + "Documents", Guid.NewGuid(), providerId, "Identity", true, DateTime.UtcNow); + + _providersModuleApiMock.Setup(x => x.GetProviderByIdAsync(providerId, It.IsAny())) + .ReturnsAsync(Result.Success(null)); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + _outboxRepositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_WhenProviderApiFails_ShouldThrowException() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new DocumentVerifiedIntegrationEvent( + "Documents", Guid.NewGuid(), providerId, "Identity", true, DateTime.UtcNow); + + _providersModuleApiMock.Setup(x => x.GetProviderByIdAsync(providerId, It.IsAny())) + .ReturnsAsync(Result.Failure(new MeAjudaAi.Contracts.Functional.Error("Failed to fetch provider", 500))); + + // Act + var act = () => _handler.HandleAsync(integrationEvent); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*Failed to fetch provider*"); + } + + [Fact] + public async Task HandleAsync_WhenDuplicateEvent_ShouldHandleUniqueConstraintException() + { + // Arrange + var providerId = Guid.NewGuid(); + var integrationEvent = new DocumentVerifiedIntegrationEvent( + "Documents", Guid.NewGuid(), providerId, "Identity", true, DateTime.UtcNow); + + var providerDto = new ModuleProviderDto( + providerId, + "Test Provider", + "test-provider", + "provider@test.com", + "123456789", + "Individual", + "Verified", + DateTime.UtcNow, + DateTime.UtcNow, + true); + + _providersModuleApiMock.Setup(x => x.GetProviderByIdAsync(providerId, It.IsAny())) + .ReturnsAsync(Result.Success(providerDto)); + + // Como o PostgreSqlExceptionProcessor é estático e o PostgresException do Npgsql + // é difícil de instanciar manualmente com detalhes via reflexão, + // vamos simular que o repositório lance diretamente a exceção processada + // ou que o handler a receba após o processamento (se o repositório já usasse o processador). + + // No entanto, o handler chama o processador explicitamente. + // Para este teste unitário passar e validar a lógica do catch, + // vamos lançar uma UniqueConstraintException customizada que herda de DbUpdateException. + + var uniqueException = new UniqueConstraintException( + "ix_outbox_messages_correlation_id", + "correlation_id", + new Exception("Simulated inner")); + + _outboxRepositoryMock.Setup(x => x.SaveChangesAsync(It.IsAny())) + .ThrowsAsync(uniqueException); + + // Act + var act = () => _handler.HandleAsync(integrationEvent); + + // Assert + await act.Should().NotThrowAsync(); + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Communications/Tests/Unit/Application/Handlers/ProviderActivatedIntegrationEventHandlerTests.cs b/src/Modules/Communications/Tests/Unit/Application/Handlers/ProviderActivatedIntegrationEventHandlerTests.cs new file mode 100644 index 000000000..bb29e16b2 --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Application/Handlers/ProviderActivatedIntegrationEventHandlerTests.cs @@ -0,0 +1,85 @@ +using MeAjudaAi.Modules.Communications.Application.Handlers; +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Messaging.Messages.Providers; +using MeAjudaAi.Contracts.Modules.Users; +using MeAjudaAi.Contracts.Modules.Users.DTOs; +using MeAjudaAi.Contracts.Functional; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Application.Handlers; + +public class ProviderActivatedIntegrationEventHandlerTests +{ + private readonly Mock _outboxRepositoryMock; + private readonly Mock _logRepositoryMock; + private readonly Mock _usersModuleApiMock; + private readonly Mock> _loggerMock; + private readonly ProviderActivatedIntegrationEventHandler _handler; + + public ProviderActivatedIntegrationEventHandlerTests() + { + _outboxRepositoryMock = new Mock(); + _logRepositoryMock = new Mock(); + _usersModuleApiMock = new Mock(); + _loggerMock = new Mock>(); + _handler = new ProviderActivatedIntegrationEventHandler( + _outboxRepositoryMock.Object, + _logRepositoryMock.Object, + _usersModuleApiMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_WhenNewActivation_ShouldEnqueueOutboxMessage() + { + // Arrange + var userId = Guid.NewGuid(); + var integrationEvent = new ProviderActivatedIntegrationEvent( + "Providers", + Guid.NewGuid(), + userId, + "Test Provider", + "Admin", + DateTime.UtcNow); + + _logRepositoryMock.Setup(x => x.ExistsByCorrelationIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var userDto = new ModuleUserDto(userId, "testuser", "test@test.com", "First", "Last", "Full Name"); + _usersModuleApiMock.Setup(x => x.GetUserByIdAsync(userId, It.IsAny())) + .ReturnsAsync(Result.Success(userDto)); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _outboxRepositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenAlreadyProcessed_ShouldSkip() + { + // Arrange + var integrationEvent = new ProviderActivatedIntegrationEvent( + "Providers", + Guid.NewGuid(), + Guid.NewGuid(), + "Test Provider", + "Admin", + DateTime.UtcNow); + + _logRepositoryMock.Setup(x => x.ExistsByCorrelationIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + _outboxRepositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); + } +} diff --git a/src/Modules/Communications/Tests/Unit/Application/Handlers/ProviderAwaitingVerificationIntegrationEventHandlerTests.cs b/src/Modules/Communications/Tests/Unit/Application/Handlers/ProviderAwaitingVerificationIntegrationEventHandlerTests.cs new file mode 100644 index 000000000..6e25b1023 --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Application/Handlers/ProviderAwaitingVerificationIntegrationEventHandlerTests.cs @@ -0,0 +1,47 @@ +using MeAjudaAi.Modules.Communications.Application.Handlers; +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Messaging.Messages.Providers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Application.Handlers; + +public class ProviderAwaitingVerificationIntegrationEventHandlerTests +{ + private readonly Mock _outboxRepositoryMock; + private readonly Mock _configurationMock; + private readonly Mock> _loggerMock; + private readonly ProviderAwaitingVerificationIntegrationEventHandler _handler; + + public ProviderAwaitingVerificationIntegrationEventHandlerTests() + { + _outboxRepositoryMock = new Mock(); + _configurationMock = new Mock(); + _loggerMock = new Mock>(); + _handler = new ProviderAwaitingVerificationIntegrationEventHandler( + _outboxRepositoryMock.Object, + _configurationMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_WhenProviderAwaiting_ShouldEnqueueAdminNotification() + { + // Arrange + var integrationEvent = new ProviderAwaitingVerificationIntegrationEvent( + "Providers", + Guid.NewGuid(), + Guid.NewGuid(), + "New Provider"); + + _configurationMock.Setup(x => x["Communications:AdminEmail"]).Returns("admin@test.com"); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _outboxRepositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Communications/Tests/Unit/Application/Handlers/ProviderVerificationStatusUpdatedIntegrationEventHandlerTests.cs b/src/Modules/Communications/Tests/Unit/Application/Handlers/ProviderVerificationStatusUpdatedIntegrationEventHandlerTests.cs new file mode 100644 index 000000000..f3a1d740c --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Application/Handlers/ProviderVerificationStatusUpdatedIntegrationEventHandlerTests.cs @@ -0,0 +1,104 @@ +using MeAjudaAi.Modules.Communications.Application.Handlers; +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Messaging.Messages.Providers; +using MeAjudaAi.Contracts.Modules.Users; +using MeAjudaAi.Contracts.Modules.Users.DTOs; +using MeAjudaAi.Contracts.Functional; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Application.Handlers; + +public class ProviderVerificationStatusUpdatedIntegrationEventHandlerTests +{ + private readonly Mock _outboxRepositoryMock; + private readonly Mock _usersModuleApiMock; + private readonly Mock> _loggerMock; + private readonly ProviderVerificationStatusUpdatedIntegrationEventHandler _handler; + + public ProviderVerificationStatusUpdatedIntegrationEventHandlerTests() + { + _outboxRepositoryMock = new Mock(); + _usersModuleApiMock = new Mock(); + _loggerMock = new Mock>(); + _handler = new ProviderVerificationStatusUpdatedIntegrationEventHandler( + _outboxRepositoryMock.Object, + _usersModuleApiMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_WhenStatusUpdated_ShouldEnqueueNotification() + { + // Arrange + var userId = Guid.NewGuid(); + var integrationEvent = new ProviderVerificationStatusUpdatedIntegrationEvent( + "Providers", + Guid.NewGuid(), + userId, + "Test Provider", + "Pending", + "Verified", + "Admin", + "Looks good"); + + var userDto = new ModuleUserDto(userId, "testuser", "test@test.com", "John", "Doe", "John Doe"); + _usersModuleApiMock.Setup(x => x.GetUserByIdAsync(userId, It.IsAny())) + .ReturnsAsync(Result.Success(userDto)); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _outboxRepositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenUserNotFound_ShouldSkip() + { + // Arrange + var userId = Guid.NewGuid(); + var integrationEvent = new ProviderVerificationStatusUpdatedIntegrationEvent( + "Providers", + Guid.NewGuid(), + userId, + "Test Provider", + "Pending", + "Verified"); + + // Success result with null value means not found + _usersModuleApiMock.Setup(x => x.GetUserByIdAsync(userId, It.IsAny())) + .ReturnsAsync(Result.Success(null)); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_WhenApiFails_ShouldThrow() + { + // Arrange + var userId = Guid.NewGuid(); + var integrationEvent = new ProviderVerificationStatusUpdatedIntegrationEvent( + "Providers", + Guid.NewGuid(), + userId, + "Test Provider", + "Pending", + "Verified"); + + _usersModuleApiMock.Setup(x => x.GetUserByIdAsync(userId, It.IsAny())) + .ReturnsAsync(Result.Failure("API Error")); + + // Act + var act = () => _handler.HandleAsync(integrationEvent); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*API Error*"); + } +} diff --git a/src/Modules/Communications/Tests/Unit/Application/Handlers/UserRegisteredIntegrationEventHandlerTests.cs b/src/Modules/Communications/Tests/Unit/Application/Handlers/UserRegisteredIntegrationEventHandlerTests.cs new file mode 100644 index 000000000..2a31dded2 --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Application/Handlers/UserRegisteredIntegrationEventHandlerTests.cs @@ -0,0 +1,110 @@ +using MeAjudaAi.Modules.Communications.Application.Handlers; +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Shared.Messaging.Messages.Users; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Application.Handlers; + +public class UserRegisteredIntegrationEventHandlerTests +{ + private readonly Mock _outboxRepositoryMock; + private readonly Mock _logRepositoryMock; + private readonly Mock> _loggerMock; + private readonly UserRegisteredIntegrationEventHandler _handler; + + public UserRegisteredIntegrationEventHandlerTests() + { + _outboxRepositoryMock = new Mock(); + _logRepositoryMock = new Mock(); + _loggerMock = new Mock>(); + _handler = new UserRegisteredIntegrationEventHandler( + _outboxRepositoryMock.Object, + _logRepositoryMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_WhenNewUser_ShouldEnqueueOutboxMessage() + { + // Arrange + var integrationEvent = new UserRegisteredIntegrationEvent( + "Users", + Guid.NewGuid(), + "test@test.com", + "testuser", + "John", + "Doe", + "kc-123", + new[] { "User" }, + DateTime.UtcNow); + + _logRepositoryMock.Setup(x => x.ExistsByCorrelationIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + _outboxRepositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenAlreadyProcessed_ShouldSkip() + { + // Arrange + var integrationEvent = new UserRegisteredIntegrationEvent( + "Users", + Guid.NewGuid(), + "test@test.com", + "testuser", + "John", + "Doe", + "kc-123", + new[] { "User" }, + DateTime.UtcNow); + + _logRepositoryMock.Setup(x => x.ExistsByCorrelationIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_WhenUniqueConstraintViolationOccurs_ShouldHandleGracefully() + { + // Arrange + var integrationEvent = new UserRegisteredIntegrationEvent( + "Users", + Guid.NewGuid(), + "test@test.com", + "testuser", + "John", + "Doe", + "kc-123", + new[] { "User" }, + DateTime.UtcNow); + + _logRepositoryMock.Setup(x => x.ExistsByCorrelationIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + // Throw UniqueConstraintException directly + _outboxRepositoryMock.Setup(x => x.SaveChangesAsync(It.IsAny())) + .ThrowsAsync(new MeAjudaAi.Shared.Database.Exceptions.UniqueConstraintException("Already exists")); + + // Act + var act = () => _handler.HandleAsync(integrationEvent); + + // Assert + // Should not throw because handler catches it + await act.Should().NotThrowAsync(); + + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Communications/Tests/Unit/Application/ModuleApi/CommunicationsModuleApiTests.cs b/src/Modules/Communications/Tests/Unit/Application/ModuleApi/CommunicationsModuleApiTests.cs new file mode 100644 index 000000000..9c5bf2dd0 --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Application/ModuleApi/CommunicationsModuleApiTests.cs @@ -0,0 +1,190 @@ +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using MeAjudaAi.Contracts.Modules.Communications.Queries; +using MeAjudaAi.Modules.Communications.Application.ModuleApi; +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using Moq; +using MeAjudaAi.Contracts.Shared; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Application.ModuleApi; + +public class CommunicationsModuleApiTests +{ + private readonly Mock _outboxRepositoryMock; + private readonly Mock _templateRepositoryMock; + private readonly Mock _logRepositoryMock; + private readonly CommunicationsModuleApi _api; + + public CommunicationsModuleApiTests() + { + _outboxRepositoryMock = new Mock(); + _templateRepositoryMock = new Mock(); + _logRepositoryMock = new Mock(); + + _api = new CommunicationsModuleApi( + _outboxRepositoryMock.Object, + _templateRepositoryMock.Object, + _logRepositoryMock.Object); + } + + [Fact] + public async Task SendEmailAsync_WithValidData_ShouldEnqueueMessage() + { + // Arrange + var dto = new EmailMessageDto("test@test.com", "Subject", "Body"); + + // Act + var result = await _api.SendEmailAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + _outboxRepositoryMock.Verify(x => x.AddAsync(It.Is(m => m.Channel == ECommunicationChannel.Email), It.IsAny()), Times.Once); + _outboxRepositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } + + [Theory] + [InlineData(null, "Subject", "Body")] + [InlineData("test@test.com", null, "Body")] + [InlineData("test@test.com", "Subject", null)] + public async Task SendEmailAsync_WithInvalidData_ShouldReturnFailure(string? to, string? subject, string? body) + { + // Arrange + var dto = new EmailMessageDto(to!, subject!, body!); + + // Act + var result = await _api.SendEmailAsync(dto); + + // Assert + result.IsSuccess.Should().BeFalse(); + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SendSmsAsync_WithValidData_ShouldEnqueueMessage() + { + // Arrange + var dto = new SmsMessageDto("123456", "Body"); + + // Act + var result = await _api.SendSmsAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + _outboxRepositoryMock.Verify(x => x.AddAsync(It.Is(m => m.Channel == ECommunicationChannel.Sms), It.IsAny()), Times.Once); + } + + [Fact] + public async Task SendPushAsync_WithValidData_ShouldEnqueueMessage() + { + // Arrange + var dto = new PushMessageDto("token", "Title", "Body"); + + // Act + var result = await _api.SendPushAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + _outboxRepositoryMock.Verify(x => x.AddAsync(It.Is(m => m.Channel == ECommunicationChannel.Push), It.IsAny()), Times.Once); + } + + [Fact] + public async Task SendEmailAsync_WithNullDto_ShouldReturnFailure() + { + // Act + var result = await _api.SendEmailAsync(null!); + + // Assert + result.IsSuccess.Should().BeFalse(); + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SendEmailAsync_WithInvalidPriority_ShouldReturnFailure() + { + // Arrange + var dto = new EmailMessageDto("test@test.com", "Subject", "Body"); + + // Act + var result = await _api.SendEmailAsync(dto, (ECommunicationPriority)999); + + // Assert + result.IsSuccess.Should().BeFalse(); + _outboxRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SendSmsAsync_WithNullDto_ShouldReturnFailure() + { + // Act + var result = await _api.SendSmsAsync(null!); + + // Assert + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public async Task SendPushAsync_WithNullDto_ShouldReturnFailure() + { + // Act + var result = await _api.SendPushAsync(null!); + + // Assert + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public async Task GetLogsAsync_WithInvalidPagination_ShouldReturnFailure() + { + // Arrange + var query = new CommunicationLogQuery { PageNumber = 0, PageSize = 10 }; + + // Act + var result = await _api.GetLogsAsync(query); + + // Assert + result.IsSuccess.Should().BeFalse(); + _logRepositoryMock.Verify(x => x.SearchAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetTemplatesAsync_ShouldReturnDtos() + { + // Arrange + var templates = new List + { + EmailTemplate.Create("key1", "Sub1", "Html1", "Text1") + }; + _templateRepositoryMock.Setup(x => x.GetAllAsync(It.IsAny())) + .ReturnsAsync(templates); + + // Act + var result = await _api.GetTemplatesAsync(); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(1); + result.Value[0].Key.Should().Be("key1"); + } + + [Fact] + public async Task GetLogsAsync_WithValidQuery_ShouldReturnPagedResult() + { + // Arrange + var query = new CommunicationLogQuery { PageNumber = 1, PageSize = 10 }; + var logs = new List + { + CommunicationLog.CreateSuccess("corr1", ECommunicationChannel.Email, "rec1", 1) + }; + _logRepositoryMock.Setup(x => x.SearchAsync(null, null, null, null, 1, 10, It.IsAny())) + .ReturnsAsync((logs, 1)); + + // Act + var result = await _api.GetLogsAsync(query); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Items.Should().HaveCount(1); + result.Value.TotalItems.Should().Be(1); + } +} diff --git a/src/Modules/Communications/Tests/Unit/Application/Services/OutboxProcessorServiceTests.cs b/src/Modules/Communications/Tests/Unit/Application/Services/OutboxProcessorServiceTests.cs new file mode 100644 index 000000000..df06b370a --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Application/Services/OutboxProcessorServiceTests.cs @@ -0,0 +1,192 @@ +using MeAjudaAi.Modules.Communications.Application.Services; +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Modules.Communications.Domain.Repositories; +using MeAjudaAi.Modules.Communications.Domain.Services; +using Microsoft.Extensions.Logging; +using Moq; +using System.Text.Json; +using MeAjudaAi.Contracts.Shared; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Application.Services; + +public class OutboxProcessorServiceTests +{ + private readonly Mock _outboxRepositoryMock; + private readonly Mock _logRepositoryMock; + private readonly Mock _emailSenderMock; + private readonly Mock _smsSenderMock; + private readonly Mock _pushSenderMock; + private readonly Mock> _loggerMock; + private readonly OutboxProcessorService _service; + + public OutboxProcessorServiceTests() + { + _outboxRepositoryMock = new Mock(); + _logRepositoryMock = new Mock(); + _emailSenderMock = new Mock(); + _smsSenderMock = new Mock(); + _pushSenderMock = new Mock(); + _loggerMock = new Mock>(); + + _service = new OutboxProcessorService( + _outboxRepositoryMock.Object, + _logRepositoryMock.Object, + _emailSenderMock.Object, + _smsSenderMock.Object, + _pushSenderMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task ProcessPendingMessagesAsync_WhenNoMessages_ShouldReturnZero() + { + // Arrange + _outboxRepositoryMock.Setup(x => x.GetPendingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + // Act + var result = await _service.ProcessPendingMessagesAsync(); + + // Assert + result.Should().Be(0); + _outboxRepositoryMock.Verify(x => x.GetPendingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessPendingMessagesAsync_WhenEmailSuccess_ShouldMarkAsSentAndLog() + { + // Arrange + var payload = JsonSerializer.Serialize(new { To = "test@test.com", Subject = "Hi", Body = "Hello" }); + var message = OutboxMessage.Create(ECommunicationChannel.Email, payload); + + _outboxRepositoryMock.Setup(x => x.GetPendingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { message }); + + _emailSenderMock.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.ProcessPendingMessagesAsync(); + + // Assert + result.Should().Be(1); + message.Status.Should().Be(EOutboxMessageStatus.Sent); + _emailSenderMock.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Once); + _logRepositoryMock.Verify(x => x.AddAsync(It.Is(l => l.IsSuccess), It.IsAny()), Times.Once); + _outboxRepositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.AtLeastOnce); + } + + [Fact] + public async Task ProcessPendingMessagesAsync_WhenSmsSuccess_ShouldMarkAsSent() + { + // Arrange + var payload = JsonSerializer.Serialize(new { PhoneNumber = "123456", Body = "Hello" }); + var message = OutboxMessage.Create(ECommunicationChannel.Sms, payload); + + _outboxRepositoryMock.Setup(x => x.GetPendingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { message }); + + _smsSenderMock.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.ProcessPendingMessagesAsync(); + + // Assert + result.Should().Be(1); + message.Status.Should().Be(EOutboxMessageStatus.Sent); + _smsSenderMock.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessPendingMessagesAsync_WhenPushSuccess_ShouldMarkAsSent() + { + // Arrange + var payload = JsonSerializer.Serialize(new { DeviceToken = "token", Title = "Title", Body = "Body" }); + var message = OutboxMessage.Create(ECommunicationChannel.Push, payload); + + _outboxRepositoryMock.Setup(x => x.GetPendingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { message }); + + _pushSenderMock.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + var result = await _service.ProcessPendingMessagesAsync(); + + // Assert + result.Should().Be(1); + message.Status.Should().Be(EOutboxMessageStatus.Sent); + _pushSenderMock.Verify(x => x.SendAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ProcessPendingMessagesAsync_WhenDispatchFails_ShouldMarkAsFailedAndRetryLater() + { + // Arrange + var payload = JsonSerializer.Serialize(new { To = "test@test.com", Subject = "Hi", Body = "Hello" }); + var message = OutboxMessage.Create(ECommunicationChannel.Email, payload, maxRetries: 3); + + _outboxRepositoryMock.Setup(x => x.GetPendingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { message }); + + _emailSenderMock.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + // Act + var result = await _service.ProcessPendingMessagesAsync(); + + // Assert + result.Should().Be(0); + message.Status.Should().Be(EOutboxMessageStatus.Pending); + message.RetryCount.Should().Be(1); + _logRepositoryMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessPendingMessagesAsync_WhenDispatchThrows_ShouldHandleExceptionAndRetry() + { + // Arrange + var payload = JsonSerializer.Serialize(new { To = "test@test.com", Subject = "Hi", Body = "Hello" }); + var message = OutboxMessage.Create(ECommunicationChannel.Email, payload, maxRetries: 3); + + _outboxRepositoryMock.Setup(x => x.GetPendingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { message }); + + _emailSenderMock.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Network Error")); + + // Act + var result = await _service.ProcessPendingMessagesAsync(); + + // Assert + result.Should().Be(0); + message.Status.Should().Be(EOutboxMessageStatus.Pending); + message.RetryCount.Should().Be(1); + message.ErrorMessage.Should().Be("Network Error"); + } + + [Fact] + public async Task ProcessPendingMessagesAsync_WhenMaxRetriesReached_ShouldMarkAsFailedAndLog() + { + // Arrange + var payload = JsonSerializer.Serialize(new { To = "test@test.com", Subject = "Hi", Body = "Hello" }); + var message = OutboxMessage.Create(ECommunicationChannel.Email, payload, maxRetries: 1); + + _outboxRepositoryMock.Setup(x => x.GetPendingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { message }); + + _emailSenderMock.Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + // Act + var result = await _service.ProcessPendingMessagesAsync(); + + // Assert + result.Should().Be(0); + message.Status.Should().Be(EOutboxMessageStatus.Failed); + message.RetryCount.Should().Be(1); + _logRepositoryMock.Verify(x => x.AddAsync(It.Is(l => !l.IsSuccess), It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/Communications/Tests/Unit/Application/Services/StubEmailServiceTests.cs b/src/Modules/Communications/Tests/Unit/Application/Services/StubEmailServiceTests.cs new file mode 100644 index 000000000..6eb3bc52d --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Application/Services/StubEmailServiceTests.cs @@ -0,0 +1,32 @@ +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using MeAjudaAi.Modules.Communications.Application.Services.Email; +using Microsoft.Extensions.Logging; +using Moq; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Application.Services; + +public class StubEmailServiceTests +{ + private readonly Mock> _loggerMock; + private readonly StubEmailService _service; + + public StubEmailServiceTests() + { + _loggerMock = new Mock>(); + _service = new StubEmailService(_loggerMock.Object); + } + + [Fact] + public async Task SendAsync_ShouldReturnSuccess() + { + // Arrange + var dto = new EmailMessageDto("test@test.com", "Subject", "Body"); + + // Act + var result = await _service.SendAsync(dto); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().StartWith("stub_"); + } +} diff --git a/src/Modules/Communications/Tests/Unit/Domain/Entities/CommunicationLogTests.cs b/src/Modules/Communications/Tests/Unit/Domain/Entities/CommunicationLogTests.cs new file mode 100644 index 000000000..4df415be2 --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Domain/Entities/CommunicationLogTests.cs @@ -0,0 +1,100 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Enums; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Domain.Entities; + +public class CommunicationLogTests +{ + [Fact] + public void CreateSuccess_WithValidData_ShouldCreateLog() + { + // Arrange + var correlationId = "corr-123"; + var channel = ECommunicationChannel.Email; + var recipient = "test@test.com"; + var attemptCount = 1; + var outboxId = Guid.NewGuid(); + var templateKey = "template-1"; + + // Act + var log = CommunicationLog.CreateSuccess(correlationId, channel, recipient, attemptCount, outboxId, templateKey); + + // Assert + log.CorrelationId.Should().Be(correlationId); + log.Channel.Should().Be(channel); + log.Recipient.Should().Be(recipient); + log.AttemptCount.Should().Be(attemptCount); + log.OutboxMessageId.Should().Be(outboxId); + log.TemplateKey.Should().Be(templateKey); + log.IsSuccess.Should().BeTrue(); + log.ErrorMessage.Should().BeNull(); + } + + [Fact] + public void CreateFailure_WithValidData_ShouldCreateLog() + { + // Arrange + var correlationId = "corr-456"; + var channel = ECommunicationChannel.Sms; + var recipient = "+5511999999999"; + var errorMessage = "Provider Down"; + var attemptCount = 3; + + // Act + var log = CommunicationLog.CreateFailure(correlationId, channel, recipient, errorMessage, attemptCount); + + // Assert + log.CorrelationId.Should().Be(correlationId); + log.Channel.Should().Be(channel); + log.Recipient.Should().Be(recipient); + log.ErrorMessage.Should().Be(errorMessage); + log.AttemptCount.Should().Be(attemptCount); + log.IsSuccess.Should().BeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void CreateSuccess_WithInvalidCorrelationId_ShouldThrowArgumentException(string? invalidId) + { + // Act + var act = () => CommunicationLog.CreateSuccess(invalidId!, ECommunicationChannel.Email, "test@test.com", 1); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void CreateSuccess_WithNegativeAttemptCount_ShouldThrowArgumentOutOfRangeException() + { + // Act + var act = () => CommunicationLog.CreateSuccess("id", ECommunicationChannel.Email, "test@test.com", -1); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData(null, ECommunicationChannel.Email, "test@test.com", "Error")] + [InlineData("id", ECommunicationChannel.Email, null, "Error")] + [InlineData("id", ECommunicationChannel.Email, "test@test.com", null)] + public void CreateFailure_WithInvalidData_ShouldThrowArgumentException(string? corrId, ECommunicationChannel channel, string? recipient, string? error) + { + // Act + var act = () => CommunicationLog.CreateFailure(corrId!, channel, recipient!, error!, 1); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void CreateFailure_WithNegativeAttemptCount_ShouldThrowArgumentOutOfRangeException() + { + // Act + var act = () => CommunicationLog.CreateFailure("id", ECommunicationChannel.Email, "test@test.com", "Error", -1); + + // Assert + act.Should().Throw(); + } +} diff --git a/src/Modules/Communications/Tests/Unit/Domain/Entities/EmailTemplateTests.cs b/src/Modules/Communications/Tests/Unit/Domain/Entities/EmailTemplateTests.cs new file mode 100644 index 000000000..e2d19bb70 --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Domain/Entities/EmailTemplateTests.cs @@ -0,0 +1,132 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Domain.Entities; + +public class EmailTemplateTests +{ + [Fact] + public void Create_WithValidData_ShouldCreateTemplate() + { + // Act + var template = EmailTemplate.Create( + "user_registered", + "Welcome", + "

Hello

", + "Hello", + "en-US", + "custom-key", + true); + + // Assert + template.TemplateKey.Should().Be("user_registered"); + template.Subject.Should().Be("Welcome"); + template.HtmlBody.Should().Be("

Hello

"); + template.TextBody.Should().Be("Hello"); + template.Language.Should().Be("en-us"); + template.OverrideKey.Should().Be("custom-key"); + template.IsSystemTemplate.Should().BeTrue(); + template.IsActive.Should().BeTrue(); + template.Version.Should().Be(1); + } + + [Fact] + public void UpdateContent_WhenNotSystemTemplate_ShouldUpdate() + { + // Arrange + var template = EmailTemplate.Create("test", "S", "H", "T", isSystemTemplate: false); + + // Act + template.UpdateContent("New S", "New H", "New T"); + + // Assert + template.Subject.Should().Be("New S"); + template.HtmlBody.Should().Be("New H"); + template.TextBody.Should().Be("New T"); + template.Version.Should().Be(2); + } + + [Fact] + public void UpdateContent_WhenSystemTemplate_ShouldThrowInvalidOperationException() + { + // Arrange + var template = EmailTemplate.Create("test", "S", "H", "T", isSystemTemplate: true); + + // Act + var act = () => template.UpdateContent("New S", "New H", "New T"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Deactivate_WhenNotSystemTemplate_ShouldDeactivate() + { + // Arrange + var template = EmailTemplate.Create("test", "S", "H", "T", isSystemTemplate: false); + + // Act + template.Deactivate(); + + // Assert + template.IsActive.Should().BeFalse(); + } + + [Fact] + public void Deactivate_WhenSystemTemplate_ShouldThrowInvalidOperationException() + { + // Arrange + var template = EmailTemplate.Create("test", "S", "H", "T", isSystemTemplate: true); + + // Act + var act = () => template.Deactivate(); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Activate_ShouldActivate() + { + // Arrange + var template = EmailTemplate.Create("test", "S", "H", "T", isSystemTemplate: false); + template.Deactivate(); + + // Act + template.Activate(); + + // Assert + template.IsActive.Should().BeTrue(); + } + + [Theory] + [InlineData(null, "S", "H", "T")] + [InlineData("", "S", "H", "T")] + [InlineData(" ", "S", "H", "T")] + [InlineData("K", null, "H", "T")] + [InlineData("K", "S", null, "T")] + [InlineData("K", "S", "H", null)] + public void Create_WithInvalidData_ShouldThrowArgumentException(string? key, string? sub, string? html, string? text) + { + // Act + var act = () => EmailTemplate.Create(key!, sub!, html!, text!); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData(null, "H", "T")] + [InlineData("S", null, "T")] + [InlineData("S", "H", null)] + public void UpdateContent_WithInvalidData_ShouldThrowArgumentException(string? sub, string? html, string? text) + { + // Arrange + var template = EmailTemplate.Create("test", "S", "H", "T", isSystemTemplate: false); + + // Act + var act = () => template.UpdateContent(sub!, html!, text!); + + // Assert + act.Should().Throw(); + } +} diff --git a/src/Modules/Communications/Tests/Unit/Domain/Entities/OutboxMessageTests.cs b/src/Modules/Communications/Tests/Unit/Domain/Entities/OutboxMessageTests.cs new file mode 100644 index 000000000..40d49aa8f --- /dev/null +++ b/src/Modules/Communications/Tests/Unit/Domain/Entities/OutboxMessageTests.cs @@ -0,0 +1,156 @@ +using MeAjudaAi.Modules.Communications.Domain.Entities; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Contracts.Shared; + +namespace MeAjudaAi.Modules.Communications.Tests.Unit.Domain.Entities; + +public class OutboxMessageTests +{ + [Fact] + public void Create_WithValidData_ShouldCreateMessageInPendingStatus() + { + // Arrange + var channel = ECommunicationChannel.Email; + var payload = "{\"To\":\"test@example.com\"}"; + var priority = ECommunicationPriority.High; + var scheduledAt = DateTime.UtcNow.AddHours(1); + var correlationId = "test-correlation"; + + // Act + var message = OutboxMessage.Create(channel, payload, priority, scheduledAt, 5, correlationId); + + // Assert + message.Channel.Should().Be(channel); + message.Payload.Should().Be(payload); + message.Priority.Should().Be(priority); + message.ScheduledAt.Should().Be(scheduledAt); + message.MaxRetries.Should().Be(5); + message.CorrelationId.Should().Be(correlationId); + message.Status.Should().Be(EOutboxMessageStatus.Pending); + message.RetryCount.Should().Be(0); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_WithInvalidPayload_ShouldThrowArgumentException(string? invalidPayload) + { + // Act + var act = () => OutboxMessage.Create(ECommunicationChannel.Email, invalidPayload!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Create_WithInvalidMaxRetries_ShouldThrowArgumentOutOfRangeException() + { + // Act + var act = () => OutboxMessage.Create(ECommunicationChannel.Email, "payload", maxRetries: 0); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void IsReadyToProcess_WhenPendingAndScheduledAtIsPast_ShouldReturnTrue() + { + // Arrange + var message = OutboxMessage.Create(ECommunicationChannel.Email, "payload", scheduledAt: DateTime.UtcNow.AddMinutes(-1)); + + // Act + var result = message.IsReadyToProcess(DateTime.UtcNow); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsReadyToProcess_WhenPendingAndScheduledAtIsFuture_ShouldReturnFalse() + { + // Arrange + var message = OutboxMessage.Create(ECommunicationChannel.Email, "payload", scheduledAt: DateTime.UtcNow.AddMinutes(10)); + + // Act + var result = message.IsReadyToProcess(DateTime.UtcNow); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void MarkAsProcessing_WhenPending_ShouldUpdateStatus() + { + // Arrange + var message = OutboxMessage.Create(ECommunicationChannel.Email, "payload"); + + // Act + message.MarkAsProcessing(); + + // Assert + message.Status.Should().Be(EOutboxMessageStatus.Processing); + } + + [Fact] + public void ResetToPending_WhenProcessing_ShouldUpdateStatus() + { + // Arrange + var message = OutboxMessage.Create(ECommunicationChannel.Email, "payload"); + message.MarkAsProcessing(); + + // Act + message.ResetToPending(); + + // Assert + message.Status.Should().Be(EOutboxMessageStatus.Pending); + } + + [Fact] + public void MarkAsSent_WhenProcessing_ShouldUpdateStatusAndSentAt() + { + // Arrange + var message = OutboxMessage.Create(ECommunicationChannel.Email, "payload"); + message.MarkAsProcessing(); + var sentAt = DateTime.UtcNow; + + // Act + message.MarkAsSent(sentAt); + + // Assert + message.Status.Should().Be(EOutboxMessageStatus.Sent); + message.SentAt.Should().Be(sentAt); + } + + [Fact] + public void MarkAsFailed_WhenBelowMaxRetries_ShouldIncrementRetryCountAndStayPending() + { + // Arrange + var message = OutboxMessage.Create(ECommunicationChannel.Email, "payload", maxRetries: 3); + message.MarkAsProcessing(); + + // Act + message.MarkAsFailed("Error 1"); + + // Assert + message.RetryCount.Should().Be(1); + message.ErrorMessage.Should().Be("Error 1"); + message.Status.Should().Be(EOutboxMessageStatus.Pending); + } + + [Fact] + public void MarkAsFailed_WhenReachesMaxRetries_ShouldUpdateStatusToFailed() + { + // Arrange + var message = OutboxMessage.Create(ECommunicationChannel.Email, "payload", maxRetries: 1); + message.MarkAsProcessing(); + + // Act + message.MarkAsFailed("Fatal Error"); + + // Assert + message.RetryCount.Should().Be(1); + message.Status.Should().Be(EOutboxMessageStatus.Failed); + message.HasRetriesLeft.Should().BeFalse(); + } +} diff --git a/src/Modules/Communications/Tests/packages.lock.json b/src/Modules/Communications/Tests/packages.lock.json new file mode 100644 index 000000000..047f3de5f --- /dev/null +++ b/src/Modules/Communications/Tests/packages.lock.json @@ -0,0 +1,2231 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "AutoFixture": { + "type": "Direct", + "requested": "[4.18.1, )", + "resolved": "4.18.1", + "contentHash": "BmWZDY4fkrYOyd5/CTBOeXbzsNwV8kI4kDi/Ty1Y5F+WDHBVKxzfWlBE4RSicvZ+EOi2XDaN5uwdrHsItLW6Kw==", + "dependencies": { + "Fare": "[2.1.1, 3.0.0)" + } + }, + "AutoFixture.AutoMoq": { + "type": "Direct", + "requested": "[4.18.1, )", + "resolved": "4.18.1", + "contentHash": "5mG4BdhamHBJGDKNdH5p0o1GIqbNCDqq+4Ny4csnYpzZPYjkfT5xOXLyhkvpF8EgK3GN5o4HMclEe2rhQVr1jQ==", + "dependencies": { + "AutoFixture": "4.18.1", + "Moq": "[4.7.0, 5.0.0)" + } + }, + "Bogus": { + "type": "Direct", + "requested": "[35.6.5, )", + "resolved": "35.6.5", + "contentHash": "2FGZn+aAVHjmCgClgmGkTDBVZk0zkLvAKGaxEf5JL6b3i9JbHTE4wnuY4vHCuzlCmJdU6VZjgDfHwmYkQF8VAA==" + }, + "coverlet.collector": { + "type": "Direct", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "heVQl5tKYnnIDYlR1QMVGueYH6iriZTcZB6AjDczQNwZzxkjDIt9C84Pt4cCiZYrbo7jkZOYGWbs6Lo9wAtVLg==" + }, + "FluentAssertions": { + "type": "Direct", + "requested": "[8.9.0, )", + "resolved": "8.9.0", + "contentHash": "Y5RDjxaVlxWX2yy0X/ay1tJjSKMOtjepSb83mmfngFS63hm3LsoZNj6nhmImzm1ifRmpF9ouvmHjx9nNwnkpDg==" + }, + "Microsoft.EntityFrameworkCore.InMemory": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "zz4THzlDOrJ7fKU2YUOzQFs2LJ9DgOSr5xFXcDdoD59el73MTQLtQQOAJxQ94F4XDegyL9+2sePSQGdcU25ZxQ==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" + } + }, + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[18.4.0, )", + "resolved": "18.4.0", + "contentHash": "w49iZdL4HL6V25l41NVQLXWQ+e71GvSkKVteMrOL02gP/PUkcnO/1yEb2s9FntU4wGmJWfKnyrRAhcMHd9ZZNA==", + "dependencies": { + "Microsoft.CodeCoverage": "18.4.0", + "Microsoft.TestPlatform.TestHost": "18.4.0" + } + }, + "Moq": { + "type": "Direct", + "requested": "[4.20.72, )", + "resolved": "4.20.72", + "contentHash": "EA55cjyNn8eTNWrgrdZJH5QLFp2L43oxl1tlkoYUKIE9pRwL784OWiTXeCV5ApS+AMYEAlt7Fo03A2XfouvHmQ==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "NetArchTest.Rules": { + "type": "Direct", + "requested": "[1.3.2, )", + "resolved": "1.3.2", + "contentHash": "puPyNXkwJq8/UwXhHV8NrzNzkQl4IxEbcP+3PU0xLRiOedsVpaSdpwHhvOZfI0VwTcRvawCNxYQcSRbD4RUg4w==", + "dependencies": { + "Mono.Cecil": "0.11.3" + } + }, + "xunit.runner.visualstudio": { + "type": "Direct", + "requested": "[3.1.5, )", + "resolved": "3.1.5", + "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Asp.Versioning.Abstractions": { + "type": "Transitive", + "resolved": "8.1.0", + "contentHash": "mpeNZyMdvrHztJwR1sXIUQ+3iioEU97YMBnFA9WLbsPOYhGwDJnqJMmEd8ny7kcmS9OjTHoEuX/bSXXY3brIFA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "AspNetCore.HealthChecks.UI.Core": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "TVriy4hgYnhfqz6NAzv8qe62Q8wf82iKUL6WV9selqeFZTq1ILi39Sic6sFQegRysvAVcnxKP/vY8z9Fk8x6XQ==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11" + } + }, + "Azure.Core": { + "type": "Transitive", + "resolved": "1.50.0", + "contentHash": "GBNKZEhdIbTXxedvD3R7I/yDVFX9jJJEz02kCziFSJxspSQ5RMHc3GktulJ1s7+ffXaXD7kMgrtdQTaggyInLw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "8.0.0", + "System.ClientModel": "1.8.0", + "System.Memory.Data": "8.0.1" + } + }, + "Azure.Monitor.OpenTelemetry.Exporter": { + "type": "Transitive", + "resolved": "1.5.0", + "contentHash": "7YgW82V13PwhjrlaN2Nbu9UIvYMzZxjgV9TYqK34PK+81IWsDwPO3vBhyeHYpDBwKWm7wqHp1c3VVX5DN4G2WA==", + "dependencies": { + "Azure.Core": "1.50.0", + "OpenTelemetry": "1.14.0", + "OpenTelemetry.Extensions.Hosting": "1.14.0", + "OpenTelemetry.PersistentStorage.FileSystem": "1.0.2" + } + }, + "BouncyCastle.Cryptography": { + "type": "Transitive", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "Dapper.AOT": { + "type": "Transitive", + "resolved": "1.0.48", + "contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA==" + }, + "Docker.DotNet.Enhanced": { + "type": "Transitive", + "resolved": "3.131.1", + "contentHash": "hGLHCNUsQbT2Ab/HUznRnNqYZQs40zInXa3eLwYjeNyfUYbw1pqqDGqcOLl5uGepS8IuigEYakEdAcVT/2ezYg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Docker.DotNet.Enhanced.X509": { + "type": "Transitive", + "resolved": "3.131.1", + "contentHash": "8FU7zmttFQzp0xb0EPupxQ0nGtC2cTpukgh3jMxMT8luj5TSDyzIKTnroDpXCjpg9P2fV+6JIvC+IetsMEfyBA==", + "dependencies": { + "Docker.DotNet.Enhanced": "3.131.1" + } + }, + "Fare": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "HaI8puqA66YU7/9cK4Sgbs1taUTP1Ssa4QT2PIzqJ7GvAbN1QgkjbRsjH+FSbMh1MJdvS0CIwQNLtFT+KF6KpA==", + "dependencies": { + "NETStandard.Library": "1.6.1" + } + }, + "Hangfire.NetCore": { + "type": "Transitive", + "resolved": "1.8.23", + "contentHash": "SmvUJF/u5MCP666R5Y1V+GntqBc4RCWJqn5ztMMN67d53Cx5cuaWR0YNLMrabjylwLarFYJ7EdR9RnGEZzp/dg==", + "dependencies": { + "Hangfire.Core": "[1.8.23]", + "Microsoft.Extensions.DependencyInjection.Abstractions": "3.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "3.0.0", + "Microsoft.Extensions.Logging.Abstractions": "3.0.0" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.AspNetCore.Http.Features": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "f10WUgcsKqrkmnz6gt8HeZ7kyKjYN30PO7cSic1lPtH7paPtnQqXPOveul/SIPI43PhRD4trttg4ywnrEmmJpA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" + }, + "Microsoft.Bcl.TimeProvider": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "C7kWHJnMRY7EvJev2S8+yJHZ1y7A4ZlLbA4NE+O23BDIAN5mHeqND1m+SKv1ChRS5YlCDW7yAMUe7lttRsJaAA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZXRAdvH6GiDeHRyd3q/km8Z44RoM6FBWHd+gen/la81mVnAdHTEsEkO5J0TCNXBymAcx5UYKt5TvgKBhaLJEow==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "5DSyJ9bk+ATuDy7fp2Zt0mJStDVKbBoiz1DyfAwSa+k4H4IwykAUcV3URelw5b8/iVbfSaOwkwmPUZH6opZKCw==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "Al/Q8B+yO8odSqGVpSvrShMFDvlQdIBU//F3E6Rb0YdiLSALE9wh/pvozPNnfmh5HDnvU+mkmSjpz4hQO++jaA==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.CSharp": "[5.0.0]", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "ZbUmIvT6lqTNKiv06Jl5wf0MTMi1vQ1oH7ou4CLcs2C/no/L7EhP3T8y3XXvn9VbqMcJaJnEsNA1jwYUMgc5jg==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[5.0.0]", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeAnalysis.Workspaces.MSBuild": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "/G+LVoAGMz6Ae8nm+PGLxSw+F5RjYx/J7irbTO5uKAPw1bxHyQJLc/YOnpDxt+EpPtYxvC9wvBsg/kETZp1F9Q==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "17.11.31", + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Workspaces.Common": "[5.0.0]", + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0", + "Microsoft.VisualStudio.SolutionPersistence": "1.0.52", + "Newtonsoft.Json": "13.0.3", + "System.Composition": "9.0.0" + } + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "9O0BtCfzCWrkAmK187ugKdq72HHOXoOUjuWFDVc2LsZZ0pOnA9bTt+Sg9q4cF+MoAaUU+MuWtvBuFsnduviJow==" + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "32c58Rnm47Qvhimawf67KO9PytgPz3QoWye7Abapt0Yocw/JnzMiSNj/pRoIKyn8Jxypkv86zxKD4Q/zNTc0Ag==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "ipC4u1VojgEfoIZhtbS2Sx5IluJTP/Jf1hz3yGsxGBgSukYY/CquI6rAjxn5H58CZgVn36qcuPPtNMwZ0AUzMg==" + }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.4.0", + "contentHash": "bovnONzrr/JIc+w343i857rJEb7cQH9UzEjbV5n67agWBEYICGQb8xiqYz5+GoFXp6mKEKLwYCQGttMU1p5yXQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.4", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.4", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.4" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "jUEXmkBUPdOS/MP9areK/sbKhdklq9+tEhvwfxGalZVnmyLUO5rrheNNutUBtvbZ7J8ECkG7/r2KXi/IFC06cA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.4.0", + "contentHash": "4WkknDbVrHNf+S6fwSt1OAXlGJ/G/QrtJlqx4aNzOLmeT3GRyxpGLZn+Q3UV+RMRAF6FfsijEZBg2ZAW8bTAkg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4", + "Microsoft.Extensions.ObjectPool": "10.0.4" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "or9fOLopMUTJOQVJ3bou4aD6PwvsiKf4kZC4EE5sRRKSkmh+wfk/LekJXRjAX88X+1JA9zHjDo+5fiQ7z3MY/A==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "OhTr0O79dP49734lLTqVveivVX9sDXxbI/8vjELAZTHXqoN90mdpgTAgwicJED42iaHMCcZcK6Bj+8wNyBikaw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Physical": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "fhdG6UV9lIp70QhNkVyaHciUVq25IPFkczheVJL9bIFvmnJ+Zghaie6dWkDbbVmxZlHl9gj3zTDxMxJs5zNhIA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Json": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Physical": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.4.0", + "contentHash": "ksmUG2SFTcXzYdyoLOdeSM/qYLRGN6qbbSzYVkwMK9xsctfR1hYkUayeOpFCMd7L+QSlYX72mK9wxwdgQxyS4g==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.4" + } + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "vAJHd4yOpmKoK+jBuYV7a3y+Ab9U4ARCc29b6qvMy276RgJFw9LFs0DdsPqOL3ahwzyrX7tM+i4cCxU/RX0qAg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5" + } + }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.4.0", + "contentHash": "1/hQmONMWxRTKXuN0pQShQN9QsqIRTS1G4fdmKW0O9phuVZjyzIROQD9Fbfwyn2t+yvP8SzjatGAPX4jDRfgHg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.4" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "REdt95QXHscGdtw/UUgyCW2lF9DJcAOJxmebKW2IkgUjuCAdMODIi2HNOWg5utW98nm8ekgV0Gjqs/sljwwqMw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.5", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "NrIMTy7dpqxAvA6kHAYH8cXID/YgeNOy0OqFKpLtkPu5X4WS/basX91UszANzVrMNRAICJ2GOnGiRxJtsRyEQw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "nCBmCx0Xemlu65ZiWMcXbvfvtznKxf4/YYKF9R28QkqdI9lTikedGqzJ28/xmdGGsxUnsP5/3TQGpiPwVjK0dA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "dMu5kUPSfol1Rqhmr6nWPSmbFjDe9w6bkoKithG17bWTZA0UyKirTatM5mqYUN3mGpNA0MorlusIoVTh6J7o5g==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "mOE3ARusNQR0a5x8YOcnUbfyyXGqoAWQtEc7qFOfNJgruDWQLo39Re+3/Lzj5pLPFuFYj8hN4dgKzaSQDKiOCw==" + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.4.0", + "contentHash": "ybx2QcCWROCnUCbSj/IyHXn1c58brjjHzTTbueKgBl/qHsWk69mu25mjQ3oaMsO1I0+EcS6AhVuhIopL2q3IDw==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.4", + "Microsoft.Extensions.Telemetry": "10.4.0" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "/VacEkBQ02A8PBXSa6YpbIXCuisYy6JJr62/+ANJDZE+RMBfZMcXJXLfr/LpyLE6pgdp17Wxlt7e7R9zvkwZ3Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "0ezhWYJS4/6KrqQel9JL+Tr4n+4EX2TF5EYiaysBWNNEM2c3Gtj1moD39esfgk8OHblSX+UFjtZ3z0c4i9tRvw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "System.Diagnostics.EventLog": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "vN+aq1hBFXyYvY5Ow9WyeR66drKQxRZmas4lAjh6QWfryPkjTn1uLtX5AFIxyDaZj78v5TG2sELUyvrXpAPQQw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.4", + "contentHash": "2pufIFOgNl/yWTOoIC9XgBnO9VxgfAjdRCnVwpE2+ICfcroGnjuEAGzJ5lTdZeAe0HvA31vMBWXtcmGB7TOq3g==" + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "BB9uUW3+6Rxu1R97OB1H/13lUF8P2+H1+eDhpZlK30kDh/6E4EKHBUqTp+ilXQmZLzsRErxON8aBSR6WpUKJdg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Binder": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "/HUHJ0tw/LQvD0DZrz50eQy/3z7PfX7WWEaXnjKTV9/TNdcgFlNTZGo49QhS7PTmhDqMyHRMqAXSBxLh0vso4g==" + }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.4.0", + "contentHash": "41CCbJJPsDWU6NsmKfANHkfT/+KCBlZZqQ1eBoQhhW0xqGCiWmUlMdi2BoaM/GcwKHX5WiQL/IESROmgk0Owfw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.4", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.4.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.4", + "Microsoft.Extensions.Telemetry.Abstractions": "10.4.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.4.0", + "contentHash": "AbHleTzdpGPjA6RpOjKVHEYx7SoBRnJ2bwAbbPa3aGB7HiVwBmeTJhBGhtIBiuIW0VpKDS8x+bV5iWqpBRIf4w==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.4.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.4.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.4", + "Microsoft.Extensions.ObjectPool": "10.0.4", + "Microsoft.Extensions.Telemetry.Abstractions": "10.4.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.4.0", + "contentHash": "3b2uVa4voJfLLg39BPCKQS0ZgnpEZFkKf7YmnMVlM5FQJYBPOuePIQdnEK1/Oxd+w3GscxGYuE7IMOXDwixZtQ==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.4.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.ObjectPool": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4" + } + }, + "Microsoft.FeatureManagement": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "qxvGAv9WJHYfOpixWywJTa1WNTPy5MbQiv+O+UlE6E/LVofiM1+YRR6m41zsHIbAGm1S0PQ0QFuAsOw9DkoKsg==", + "dependencies": { + "Microsoft.Bcl.TimeProvider": "8.0.1", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.2", + "Microsoft.Extensions.Logging": "8.0.1" + } + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.17.0", + "contentHash": "6NrxQGcZg6IunkN8K2F0UVMavNpfCjbjjjON7PYcL8FwI8aULKUreiHsRX/yaA8j3XsTJnQKUYpoQk5gBjULZw==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.17.0", + "contentHash": "JbFZ3OVwtvqcqgBL0cIkhRYbIP7u9GIUYLOgbNqLWtBtZY8tGDpdGyXMzUVX0gVHq1ovuHsKZrkVv+ziHEnBHw==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.17.0" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.17.0", + "contentHash": "w1vjfri0BWqW7RkSZY3ZsqekNfIJJg5BQSFs2j+a+pCXOVrkezmJcn74pT3djwjXJh71577C6wJQgNc2UPz30w==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "8.17.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "8.17.0", + "contentHash": "teaW35URIV2x78Tzk+dVJiC4M62/9mQoSEoDjDGoEZmcQa3H2rE+XQpm9Tmdo9KK1Lcrnve4zoyLavl69kCFGg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.IdentityModel.Logging": "8.17.0" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "4L6m2kS2pY5uJ9cpeRxzW22opr6ttScIRqsOpMDQpgENp/ZwxkkQCcmc6LRSURo2dFaaSW5KVflQZvroiJ7Wzg==" + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "18.4.0", + "contentHash": "gZsCHI+zOmZCcKZieIL4Jg14qKD2OGZOmX5DehuIk1EA9BN6Crm0+taXQNEuajOH1G9CCyBxw8VWR4t5tumcng==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "18.4.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.VisualStudio.SolutionPersistence": { + "type": "Transitive", + "resolved": "1.0.52", + "contentHash": "oNv2JtYXhpdJrX63nibx1JT3uCESOBQ1LAk7Dtz/sr0+laW0KRM6eKp4CZ3MHDR2siIkKsY8MmUkeP5DKkQQ5w==" + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Mono.Cecil": { + "type": "Transitive", + "resolved": "0.11.3", + "contentHash": "DNYE+io5XfEE8+E+5padThTPHJARJHbz1mhbhMPNrrWGKVKKqj/KEeLvbawAmbIcT73NuxLV7itHZaYCZcVWGg==" + }, + "Mono.TextTemplating": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "YqueG52R/Xej4VVbKuRIodjiAhV0HR/XVbLbNrJhCZnzjnSjgMJ/dCdV0akQQxavX6hp/LC6rqLGLcXeQYU7XA==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "NETStandard.Library": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "NetTopologySuite": { + "type": "Transitive", + "resolved": "2.6.0", + "contentHash": "1B1OTacTd4QtFyBeuIOcThwSSLUdRZU3bSFIwM8vk36XiZlBMi3K36u74e4OqwwHRHUuJC1PhbDx4hyI266X1Q==" + }, + "NetTopologySuite.IO.PostGis": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "3W8XTFz8iP6GQ5jDXK1/LANHiU+988k1kmmuPWNKcJLpmSg6CvFpbTpz+s4+LBzkAp64wHGOldSlkSuzYfrIKA==", + "dependencies": { + "NetTopologySuite": "[2.0.0, 3.0.0-A)" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "Npgsql.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "YHFa4vD27sNIfv6s5q8Zi1fLvKfmK1xcpMv0PUvXOxDFbRmuMRSHwpZTbPvsAlj97q1/o7DfyynLqfqrCm1VnA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Npgsql": "10.0.1" + } + }, + "Npgsql.NetTopologySuite": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "WNM5ZHATFj0dSMJ/4vlbgMWBgP0ElMwsGiHR0PwzwflNS+IXbX8xxNZyLp4veCLcr5tDCWwMh9Lw7nwUxWkzvg==", + "dependencies": { + "NetTopologySuite": "2.6.0", + "NetTopologySuite.IO.PostGIS": "2.1.0", + "Npgsql": "10.0.2" + } + }, + "Npgsql.OpenTelemetry": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "G9fEIBaHggZXWfDSDnKLc0XwKcbuU6i2eXp7zDqpgYxbhCmIN9fRgaSOGyyMNHSo/yY1IB4G4CjW5VO/SKRR0g==", + "dependencies": { + "Npgsql": "10.0.1", + "OpenTelemetry.API": "1.14.0" + } + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.1", + "contentHash": "oJCqFTS/9S70TGPoamdGJRvw5hLOn6I/XC4X0npDErl2sHDlAg030ArJkIHIuLFCTO5GWqj1uDhsZNjO36xMxg==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.1" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.1", + "contentHash": "+LJP0YBrysh4kPCRZhEyTUTcd+FFP0NPDvV4AzULBmiInGt6fp+RgBieRhUzVX/yyVEyshg3s82RWFYZJIkeGQ==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.1", + "contentHash": "aZedpOfXtHmVSWlebxJBeJg2DCdzds86mMowBTS6l+sjwV9LvQuZa0JDU9+S7FQvta4hnauxlCEYplbiDiYGeg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.1" + } + }, + "OpenTelemetry.PersistentStorage.Abstractions": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "QuBc6e7M4Skvbc+eTQGSmrcoho7lSkHLT5ngoSsVeeT8OXLpSUETNcuRPW8F5drTPTzzTKQ98C5AhKO/pjpTJg==" + }, + "OpenTelemetry.PersistentStorage.FileSystem": { + "type": "Transitive", + "resolved": "1.0.2", + "contentHash": "ys0l9vL0/wOV9p/iuyDeemjX+d8iH4yjaYA1IcmyQUw0xsxx0I3hQm7tN3FnuRPsmPtrohiLtp31hO1BcrhQ+A==", + "dependencies": { + "OpenTelemetry.PersistentStorage.Abstractions": "1.0.2" + } + }, + "Pipelines.Sockets.Unofficial": { + "type": "Transitive", + "resolved": "2.2.8", + "contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==" + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "BpE2I6HBYYA5tF0Vn4eoQOGYTYIK1BlF5EXVgkWGn3mqUUjbXAr13J6fZVbp7Q3epRR8yshacBMlsHMhpOiV3g==" + }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, + "Serilog.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "E7juuIc+gzoGxgzFooFgAV8g9BfiSXNKsUok9NmEpyAXg2odkcPsMa/Yo4axkJRlh0se7mkYQ1GXDaBemR+b6w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Serilog": "4.3.0", + "Serilog.Extensions.Logging": "10.0.0" + } + }, + "Serilog.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "vx0kABKl2dWbBhhqAfTOk53/i8aV/5VaT3a6il9gn72Wqs2pM7EK2OB6No6xdqK2IaY6Zf9gdjLuK9BVa2rT+Q==", + "dependencies": { + "Microsoft.Extensions.Logging": "10.0.0", + "Serilog": "4.2.0" + } + }, + "Serilog.Formatting.Compact": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Debug": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.File": { + "type": "Transitive", + "resolved": "7.0.0", + "contentHash": "fKL7mXv7qaiNBUC71ssvn/dU0k9t0o45+qm2XgKAlSt19xF+ijjxyA3R6HmCgfKEKwfcfkwWjayuQtRueZFkYw==", + "dependencies": { + "Serilog": "4.2.0" + } + }, + "SharpZipLib": { + "type": "Transitive", + "resolved": "1.4.2", + "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==" + }, + "SSH.NET": { + "type": "Transitive", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "StackExchange.Redis": { + "type": "Transitive", + "resolved": "2.7.27", + "contentHash": "Uqc2OQHglqj9/FfGQ6RkKFkZfHySfZlfmbCl+hc+u2I/IqunfelQ7QJi7ZhvAJxUtu80pildVX6NPLdDaUffOw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "6.0.0", + "Pipelines.Sockets.Unofficial": "2.2.8" + } + }, + "Swashbuckle.AspNetCore.Swagger": { + "type": "Transitive", + "resolved": "10.1.7", + "contentHash": "EjLibt/d/QuRv170GoihTbcPUpgzSFm2WKHhnGJFZQ03JYzfuitsM79azaAR8NBwRunU7yScSX6HRE5JUlrEMQ==", + "dependencies": { + "Microsoft.OpenApi": "2.4.1" + } + }, + "Swashbuckle.AspNetCore.SwaggerUI": { + "type": "Transitive", + "resolved": "10.1.7", + "contentHash": "iJo3ODyUb/M8Vm8AH1r9y9iAba0w95xsCn3zFVl96ISRHbTDWxi+l7oFVCZqUEdjd97B8VMDPnMliWAdomR8uw==" + }, + "System.ClientModel": { + "type": "Transitive", + "resolved": "1.8.0", + "contentHash": "AqRzhn0v29GGGLj/Z6gKq4lGNtvPHT4nHdG5PDJh9IfVjv/nYUVmX11hwwws1vDFeIAzrvmn0dPu8IjLtu6fAw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "System.Memory.Data": "8.0.1" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Composition": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "3Djj70fFTraOarSKmRnmRy/zm4YurICm+kiCtI0dYRqGJnLX6nJ+G3WYuFJ173cAPax/gh96REcbNiVqcrypFQ==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Convention": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0", + "System.Composition.TypedParts": "9.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "iri00l/zIX9g4lHMY+Nz0qV1n40+jFYAmgsaiNn16xvt2RDwlqByNG4wgblagnDYxm3YSQQ0jLlC/7Xlk9CzyA==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "+vuqVP6xpi582XIjJi6OCsIxuoTZfR0M7WWufk3uGDeCl3wGW6KnpylUJ3iiXdPByPE0vR5TjJgR6hDLez4FQg==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "OFqSeFeJYr7kHxDfaViGM1ymk7d4JxK//VSoNF9Ux0gpqkLsauDZpu89kTHHNdCWfSljbFcvAafGyBoY094btQ==", + "dependencies": { + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "w1HOlQY1zsOWYussjFGZCEYF2UZXgvoYnS94NIu2CBnAGMbXFAX8PY8c92KwUItPmowal68jnVLBCzdrWLeEKA==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "aRZlojCCGEHDKqh43jaDgaVpYETsgd7Nx4g1zwLKMtv4iTo0627715ajEFNpEEBTgLmvZuv8K0EVxc3sM4NWJA==", + "dependencies": { + "System.Composition.AttributedModel": "9.0.0", + "System.Composition.Hosting": "9.0.0", + "System.Composition.Runtime": "9.0.0" + } + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.5", + "contentHash": "wugvy+pBVzjQEnRs9wMTWwoaeNFX3hsaHeVHFDIvJSWXp7wfmNWu3mxAwBIE6pyW+g6+rHa1Of5fTzb0QVqUTA==" + }, + "System.Memory.Data": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BVYuec3jV23EMRDeR7Dr1/qhx7369dZzJ9IWy2xylvb4YfXsrUxspWc4UWYid/tj4zZK58uGZqn2WQiaDMhmAg==" + }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, + "Testcontainers": { + "type": "Transitive", + "resolved": "4.11.0", + "contentHash": "9pBNaK9Ra3GVnr5h6gaDJOBH0txA5G3Juho5WANPuyu38l5xyr2lCvf11oA5/uSd+Lh4Wtng34nKp3nMiea02g==", + "dependencies": { + "Docker.DotNet.Enhanced": "3.131.1", + "Docker.DotNet.Enhanced.X509": "3.131.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "SSH.NET": "2025.1.0", + "SharpZipLib": "1.4.2" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "meajudaai.apiservice": { + "type": "Project", + "dependencies": { + "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", + "AspNetCore.HealthChecks.Redis": "[9.0.0, )", + "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", + "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", + "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", + "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", + "MeAjudaAi.Modules.SearchProviders.API": "[1.0.0, )", + "MeAjudaAi.Modules.ServiceCatalogs.API": "[1.0.0, )", + "MeAjudaAi.Modules.Users.API": "[1.0.0, )", + "MeAjudaAi.ServiceDefaults": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.5, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Sinks.Seq": "[9.0.0, )", + "Swashbuckle.AspNetCore": "[10.1.7, )", + "Swashbuckle.AspNetCore.Annotations": "[10.1.7, )" + } + }, + "meajudaai.contracts": { + "type": "Project", + "dependencies": { + "FluentValidation": "[12.1.1, )" + } + }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.documents.api": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Http": "[8.1.1, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.1, )", + "MeAjudaAi.Modules.Documents.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Documents.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )" + } + }, + "meajudaai.modules.documents.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Documents.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.documents.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.documents.infrastructure": { + "type": "Project", + "dependencies": { + "Azure.AI.DocumentIntelligence": "[1.0.0, )", + "Azure.Storage.Blobs": "[12.27.0, )", + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Documents.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Documents.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.5, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )" + } + }, + "meajudaai.modules.locations.api": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Http": "[8.1.1, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.1, )", + "MeAjudaAi.Modules.Locations.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Locations.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )" + } + }, + "meajudaai.modules.locations.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Locations.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.locations.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.locations.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Locations.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Locations.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.providers.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Providers.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Providers.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.providers.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Providers.Domain": "[1.0.0, )", + "MeAjudaAi.Modules.ServiceCatalogs.Application": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.providers.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.providers.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Providers.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Providers.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.searchproviders.api": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Http": "[8.1.1, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.1, )", + "MeAjudaAi.Modules.SearchProviders.Application": "[1.0.0, )", + "MeAjudaAi.Modules.SearchProviders.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )" + } + }, + "meajudaai.modules.searchproviders.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.SearchProviders.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.searchproviders.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.searchproviders.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.SearchProviders.Application": "[1.0.0, )", + "MeAjudaAi.Modules.SearchProviders.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.5, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )", + "Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite": "[10.0.1, )" + } + }, + "meajudaai.modules.servicecatalogs.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.ServiceCatalogs.Application": "[1.0.0, )", + "MeAjudaAi.Modules.ServiceCatalogs.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.servicecatalogs.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.ServiceCatalogs.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.servicecatalogs.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.servicecatalogs.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.ServiceCatalogs.Application": "[1.0.0, )", + "MeAjudaAi.Modules.ServiceCatalogs.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.users.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Users.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Users.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.users.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Users.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.users.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.users.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Users.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Users.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.EntityFrameworkCore": "[10.0.5, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )", + "System.IdentityModel.Tokens.Jwt": "[8.17.0, )" + } + }, + "meajudaai.servicedefaults": { + "type": "Project", + "dependencies": { + "Aspire.Npgsql": "[13.2.1, )", + "Azure.Monitor.OpenTelemetry.AspNetCore": "[1.4.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.Extensions.Http.Resilience": "[10.4.0, )", + "Microsoft.FeatureManagement.AspNetCore": "[4.4.0, )", + "OpenTelemetry.Exporter.Console": "[1.15.1, )", + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.1, )", + "OpenTelemetry.Extensions.Hosting": "[1.15.1, )", + "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.1, )", + "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.14.0-beta.2, )", + "OpenTelemetry.Instrumentation.Http": "[1.15.0, )", + "OpenTelemetry.Instrumentation.Runtime": "[1.15.0, )" + } + }, + "meajudaai.shared": { + "type": "Project", + "dependencies": { + "Asp.Versioning.Mvc": "[8.1.1, )", + "Asp.Versioning.Mvc.ApiExplorer": "[8.1.1, )", + "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", + "AspNetCore.HealthChecks.Redis": "[9.0.0, )", + "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", + "FluentValidation": "[12.1.1, )", + "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", + "Hangfire.AspNetCore": "[1.8.23, )", + "Hangfire.Core": "[1.8.23, )", + "Hangfire.PostgreSql": "[1.21.1, )", + "MeAjudaAi.Contracts": "[1.0.0, )", + "Microsoft.AspNetCore.OpenApi": "[10.0.5, )", + "Microsoft.EntityFrameworkCore": "[10.0.5, )", + "Microsoft.EntityFrameworkCore.Design": "[10.0.5, )", + "Microsoft.Extensions.Caching.Hybrid": "[10.4.0, )", + "Microsoft.Extensions.Caching.StackExchangeRedis": "[10.0.5, )", + "Microsoft.FeatureManagement.AspNetCore": "[4.4.0, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )", + "RabbitMQ.Client": "[7.2.1, )", + "Rebus": "[8.9.0, )", + "Rebus.RabbitMq": "[10.1.1, )", + "Rebus.ServiceProvider": "[10.7.2, )", + "Scrutor": "[7.0.0, )", + "Serilog": "[4.3.1, )", + "Serilog.AspNetCore": "[10.0.0, )", + "Serilog.Enrichers.Environment": "[3.0.1, )", + "Serilog.Enrichers.Process": "[3.0.0, )", + "Serilog.Enrichers.Thread": "[4.0.0, )", + "Serilog.Settings.Configuration": "[10.0.0, )", + "Serilog.Sinks.Console": "[6.1.1, )", + "Serilog.Sinks.Seq": "[9.0.0, )" + } + }, + "meajudaai.shared.tests": { + "type": "Project", + "dependencies": { + "AutoFixture": "[4.18.1, )", + "AutoFixture.AutoMoq": "[4.18.1, )", + "Bogus": "[35.6.5, )", + "Dapper": "[2.1.72, )", + "FluentAssertions": "[8.9.0, )", + "Hangfire.InMemory": "[1.0.0, )", + "MeAjudaAi.ApiService": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Mvc.Testing": "[10.0.5, )", + "Microsoft.Extensions.Hosting": "[10.0.5, )", + "Microsoft.Extensions.Logging.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.TimeProvider.Testing": "[10.4.0, )", + "Microsoft.NET.Test.Sdk": "[18.4.0, )", + "Moq": "[4.20.72, )", + "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.1, )", + "Respawn": "[7.0.0, )", + "Scrutor": "[7.0.0, )", + "Testcontainers.Azurite": "[4.11.0, )", + "Testcontainers.PostgreSql": "[4.11.0, )", + "Testcontainers.RabbitMq": "[4.11.0, )", + "xunit.v3": "[3.2.2, )" + } + }, + "Asp.Versioning.Http": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "1D/Mzq1MSUSQe1eCY6GeEsu+tIlpzoWZxZkhlcw/uvtVoTrwO+BMl0fSj/XX8oZN1DutWTZqHDHgyDRq+IeSdQ==", + "dependencies": { + "Asp.Versioning.Abstractions": "8.1.0" + } + }, + "Asp.Versioning.Mvc": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "mkJv6eAdlbHbqTdrUcfEYFoZuGL6HoR7O+Lfsvivixp7N5BNhfCFPPOwsBzdIiH1qzdJXyJf+C+DZ08j27PMPg==", + "dependencies": { + "Asp.Versioning.Http": "8.1.1" + } + }, + "Asp.Versioning.Mvc.ApiExplorer": { + "type": "CentralTransitive", + "requested": "[8.1.1, )", + "resolved": "8.1.1", + "contentHash": "u8PrP6CjmurgIh0EDfg8Gc/GdpDHgkbv8OLKdxQcWb5W4sp0i9BcdbQlLvRcATjNIJUyr7ZmqXjmdsFfqnuy0g==", + "dependencies": { + "Asp.Versioning.Mvc": "8.1.1" + } + }, + "Aspire.Npgsql": { + "type": "CentralTransitive", + "requested": "[13.2.1, )", + "resolved": "13.2.1", + "contentHash": "k1JIdi9QoMPl0KNDlqMKe+158z3jE46Si6BNe12vreG7DjR3eDilXx2B9M7tL8ExH/DhP+DPGxSu43nS7+xOdg==", + "dependencies": { + "AspNetCore.HealthChecks.NpgSql": "9.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Binder": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics.HealthChecks": "10.0.5", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5", + "Npgsql.DependencyInjection": "10.0.1", + "Npgsql.OpenTelemetry": "10.0.1", + "OpenTelemetry.Extensions.Hosting": "1.15.0" + } + }, + "AspNetCore.HealthChecks.NpgSql": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "npc58/AD5zuVxERdhCl2Kb7WnL37mwX42SJcXIwvmEig0/dugOLg3SIwtfvvh3TnvTwR/sk5LYNkkPaBdks61A==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "Npgsql": "8.0.3" + } + }, + "AspNetCore.HealthChecks.Redis": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "yNH0h8GLRbAf+PU5HNVLZ5hNeyq9mDVmRKO9xuZsme/znUYoBJlQvI0gq45gaZNlLncCHkMhR4o90MuT+gxxPw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.HealthChecks": "8.0.11", + "StackExchange.Redis": "2.7.4" + } + }, + "AspNetCore.HealthChecks.UI.Client": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "1Ub3Wvvbz7CMuFNWgLEc9qqQibiMoovDML/WHrwr5J83RPgtI20giCR92s/ipLgu7IIuqw+W/y7WpIeHqAICxg==", + "dependencies": { + "AspNetCore.HealthChecks.UI.Core": "9.0.0" + } + }, + "Azure.AI.DocumentIntelligence": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "RSpMmlRY5vvGy2TrAk4djJTqOsdHUunvhcSoSN+FJtexqZh6RFn+a2ylehIA/N+HV2IK0i+XK4VG3rDa8h2tsA==", + "dependencies": { + "Azure.Core": "1.44.1", + "System.ClientModel": "1.2.1" + } + }, + "Azure.Monitor.OpenTelemetry.AspNetCore": { + "type": "CentralTransitive", + "requested": "[1.4.0, )", + "resolved": "1.4.0", + "contentHash": "Zs9wBCBLkm/8Fz97GfRtbuhgd4yPlM8RKxaL6owlW2KcmO8kMqjNK/2riR5DUF5ck8KloFsUg+cuGTDmIHlqww==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Monitor.OpenTelemetry.Exporter": "1.5.0", + "OpenTelemetry.Extensions.Hosting": "1.14.0", + "OpenTelemetry.Instrumentation.AspNetCore": "1.14.0", + "OpenTelemetry.Instrumentation.Http": "1.14.0" + } + }, + "Azure.Storage.Blobs": { + "type": "CentralTransitive", + "requested": "[12.27.0, )", + "resolved": "12.27.0", + "contentHash": "zI5rg1tTtnA8T2g2/21l+1iIUdDjpEQQ0FI1BabJVEQJ1JUyTQKrc41eNabAHs0SBHprl6pu/6OqIMK9Ve+4tQ==", + "dependencies": { + "Azure.Core": "1.50.0", + "Azure.Storage.Common": "12.26.0" + } + }, + "Azure.Storage.Common": { + "type": "CentralTransitive", + "requested": "[12.26.0, )", + "resolved": "12.26.0", + "contentHash": "XaT6CDcSshZb7KaCTwc6m4EouZbLBg7ciOEpsJSdJCvkNsZJQCvPKw7V5TtXno19AA1NpwtsZriYque8mzbQVg==", + "dependencies": { + "Azure.Core": "1.50.0", + "System.IO.Hashing": "10.0.1" + } + }, + "Dapper": { + "type": "CentralTransitive", + "requested": "[2.1.72, )", + "resolved": "2.1.72", + "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" + }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, + "FluentValidation": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw==" + }, + "FluentValidation.DependencyInjectionExtensions": { + "type": "CentralTransitive", + "requested": "[12.1.1, )", + "resolved": "12.1.1", + "contentHash": "D0VXh4dtjjX2aQizuaa0g6R8X3U1JaVqJPfGCvLwZX9t/O2h7tkpbitbadQMfwcgSPdDbI2vDxuwRMv/Uf9dHA==", + "dependencies": { + "FluentValidation": "12.1.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.0" + } + }, + "Hangfire.AspNetCore": { + "type": "CentralTransitive", + "requested": "[1.8.23, )", + "resolved": "1.8.23", + "contentHash": "TXpOl7kX4xXq5bLEqqWCpt9zh3TaouDwtb3GDtzGHX5uSC2RaAqZzn2swevivx3Uki16slXIigiPtgr4TPKpsg==", + "dependencies": { + "Hangfire.NetCore": "[1.8.23]" + } + }, + "Hangfire.Core": { + "type": "CentralTransitive", + "requested": "[1.8.23, )", + "resolved": "1.8.23", + "contentHash": "YCOTtF3NNOQI83PlfjeNDDBkofJDfdET2CwhfQsiVBwmsU6lP19QW9NVTIH9epl+MnOsyFC2G1RnlPSGV8F1FQ==", + "dependencies": { + "Newtonsoft.Json": "11.0.1" + } + }, + "Hangfire.InMemory": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "56H71lfcqn5sN/8Bjj9hOLGTG5HIERLRuMsRJTFpw0Tsq5ck5OUkNvtUw92s7bwD3PRKOo4PkDGqNs9KugaqoQ==", + "dependencies": { + "Hangfire.Core": "1.8.0" + } + }, + "Hangfire.PostgreSql": { + "type": "CentralTransitive", + "requested": "[1.21.1, )", + "resolved": "1.21.1", + "contentHash": "hFNZAxv+1p72/XCZdImnH6ovCzZ2DKAMTOI8CReT0P3yw/k0b0YJP2teA18agNH1ZYInPzhtxGk8hx5n2cxbbQ==", + "dependencies": { + "Dapper": "2.0.123", + "Dapper.AOT": "1.0.48", + "Hangfire.Core": "1.8.0", + "Npgsql": "6.0.11" + } + }, + "Microsoft.AspNetCore.Authentication.JwtBearer": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "fZzXogChrwQ/SfifQJgeW7AtR8hUv5+LH9oLWjm5OqfnVt3N8MwcMHHMdawvqqdjP79lIZgetnSpj77BLsSI1g==", + "dependencies": { + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.0.1" + } + }, + "Microsoft.AspNetCore.Http.Abstractions": { + "type": "CentralTransitive", + "requested": "[2.3.9, )", + "resolved": "2.3.9", + "contentHash": "ULScB/0S9+qvf+yahjR+oQUp0GrvoDHJ9XS5gTqSjLjbjUDnHaJ1s8wo3RJMpaDfb1bawX4OgQM+YmvCUveR4Q==", + "dependencies": { + "Microsoft.AspNetCore.Http.Features": "2.3.0" + } + }, + "Microsoft.AspNetCore.Mvc.Testing": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "MfacYQ7jNzj6073YobyoFfXpNmGqrV1UCywTM339DOcYpfalcM4K4heFjV5k3dDkKkWOGWO/DV3hdmVRqFkIxA==", + "dependencies": { + "Microsoft.AspNetCore.TestHost": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5", + "Microsoft.Extensions.Hosting": "10.0.5" + } + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "vTcxIfOPyfFbYk1g8YcXJfkMnlEWVkSnnjxcZLy60zgwiHMRf2SnZR+9E4HlpwKxgE3yfKMOti8J6WfKuKsw6w==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.AspNetCore.TestHost": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "PJEdrZnnhvxIEXzDdvdZ38GvpdaiUfKkZ99kudS8riJwhowFb/Qh26Wjk9smrCWcYdMFQmpN5epGiL4o1s8LYA==" + }, + "Microsoft.Build.Framework": { + "type": "CentralTransitive", + "requested": "[18.0.2, )", + "resolved": "18.0.2", + "contentHash": "sOSb+0J4G/jCBW/YqmRuL0eOMXgfw1KQLdC9TkbvfA5xs7uNm+PBQXJCOzSJGXtZcZrtXozcwxPmUiRUbmd7FA==" + }, + "Microsoft.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9tNBmK3EpYVGRQLiqP+bqK2m+TD0Gv//4vCzR7ZOgl4FWzCFyOpYdIVka13M4kcBdPdSJcs3wbHr3rmzOqbIMA==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.5", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" + } + }, + "Microsoft.EntityFrameworkCore.Design": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "gm6f0cC2w/2tcd4GeZJqEMruTercpIJfO5sSAFLtqTqblDBHgAFk70xwshUIUVX4I6sZwdEUSd1YxoKFk1AL0w==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Build.Framework": "18.0.2", + "Microsoft.CodeAnalysis.CSharp": "5.0.0", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "5.0.0", + "Microsoft.CodeAnalysis.Workspaces.MSBuild": "5.0.0", + "Microsoft.EntityFrameworkCore.Relational": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyModel": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Mono.TextTemplating": "3.0.0", + "Newtonsoft.Json": "13.0.3" + } + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "uxmFjZEAB/KbsgWFSS4lLqkEHCfXxB2x0UcbiO4e5fCRpFFeTMSx/me6009nYJLu5IKlDwO1POh++P6RilFTDw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "10.0.5", + "Microsoft.Extensions.Caching.Memory": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5" + } + }, + "Microsoft.Extensions.ApiDescription.Server": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "Lx8zu/RWUAuPuDfM4S3AeXGNu50G/oDMw2MIWRsZPm+giT0iChOGln6ezM5CEuI7SS2IKOPmAp+ry4pMBv/r/w==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "k/QDdQ94/0Shi0KfU+e12m73jfQo+3JpErTtgpZfsCIqkvdEEO0XIx6R+iTbN55rNPaNhOqNY4/sB+jZ8XxVPw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Caching.Hybrid": { + "type": "CentralTransitive", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "4V+aMLQeU/p4VcIWIcvGro0L6HynmL2TrelL04Ce1iotP6T5+kjxuZQvl6P1ObSXIRPCbVXtQSt1NxK0fRIuag==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.4", + "Microsoft.Extensions.Caching.Memory": "10.0.4", + "Microsoft.Extensions.Logging.Abstractions": "10.0.4", + "Microsoft.Extensions.Options": "10.0.4" + } + }, + "Microsoft.Extensions.Caching.StackExchangeRedis": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "zXb143/TpEKOLQuWGw2CkJgb9F4XXh2XbevMvppzsIHr1/pjML0zjc+vzXcpCV8YUwpW5NIaScZhzFSm621B3Q==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "StackExchange.Redis": "2.7.27" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "8Rx5sqg04FttxrumyG6bmoRuFRgYzK6IVwF1i0/o0cXfKBdDeVpJejKHtJCMjyg9E/DNMVqpqOGe/tCT5gYvVA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "P09QpTHjqHmCLQOTC+WyLkoRNxek4NIvfWt+TnU0etoDUSRxcltyd6+j/ouRbMdLR0j44GqGO+lhI2M4fAHG4g==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "99Z4rjyXopb1MIazDSPcvwYCUdYNO01Cf1GUs2WUjIFAbkGmwzj2vPa2k+3pheJRV+YgNd2QqRKHAri0oBAU4Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "tchMGQ+zVTO40np/Zzg2Li/TIR8bksQgg4UVXZa0OzeFCKWnIYtxE2FVs+eSmjPGCjMS2voZbwN/mUcYfpSTuA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "brBM/WP0YAUYh2+QqSYVdK8eQHYQTtTEUJXJ+84Zkdo2buGLja9VSrMIhgoeBUU7JBmcskAib8Lb/N83bvxgYQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "v1SVsowG6YE1YnHVGmLWz57YTRCQRx9pH5ebIESXfm5isI9gA3QaMyg/oMTzPpXYZwSAVDzYItGJKfmV+pqXkQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "iVMtq9eRvzyhx8949EGT0OCYJfXi737SbRVzWXE5GrOgGj5AaZ9eUuxA/BSUfmOMALKn/g8KfFaNQw0eiB3lyA==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "xA4kkL+QS6KCAOKz/O0oquHs44Ob8J7zpBCNt3wjkBWDg5aCqfwG8rWWLsg5V86AM0sB849g9JjPjIdksTCIKg==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "/nYGrpa9/0BZofrVpBbbj+Ns8ZesiPE0V/KxsuHgDgHQopIzN54nRaQGSuvPw16/kI9sW1Zox5yyAPqvf0Jz6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "8i7e5IBdiKLNqt/+ciWrS8U95Rv5DClaaj7ulkZbimnCi4uREWd+lXzkp3joofFuIPOlAzV4AckxLTIELv2jdg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Binder": "10.0.5", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.5", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.5", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.5", + "Microsoft.Extensions.Configuration.Json": "10.0.5", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.5", + "Microsoft.Extensions.DependencyInjection": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Physical": "10.0.5", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Configuration": "10.0.5", + "Microsoft.Extensions.Logging.Console": "10.0.5", + "Microsoft.Extensions.Logging.Debug": "10.0.5", + "Microsoft.Extensions.Logging.EventLog": "10.0.5", + "Microsoft.Extensions.Logging.EventSource": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+Wb7KAMVZTomwJkQrjuPTe5KBzGod7N8XeG+ScxRlkPOB4sZLG4ccVwjV4Phk5BCJt7uIMnGHVoN6ZMVploX+g==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.5", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Http": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "AiFvHYM8nP0wPC7bGPI3NHQlSYSLqjjT7DMJUuuxhd+7pz3O89iu2gdQfgACy5DxsXENiok5i1bMacJL7KR8jA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Diagnostics": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "HbkUsPUC7vLy2TaDbdA9aooW64n9yX4sUppRuiJ1cOzzU1FUW+MVEotm6kYVq6AuUI9xwFSBhRFzA03blmk3VA==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.4.0", + "Microsoft.Extensions.ObjectPool": "10.0.4", + "Microsoft.Extensions.Resilience": "10.4.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "+XTMKQyDWg4ODoNHU/BN3BaI1jhGO7VCS+BnzT/4IauiG6y2iPAte7MyD7rHKS+hNP0TkFkjrae8DFjDUxtcxg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "9HOdqlDtPptVcmKAjsQ/Nr5Rxfq6FMYLdhvZh1lVmeKR738qeYecQD7+ldooXf+u2KzzR1kafSphWngIM3C6ug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "cSgxsDgfP0+gmVRPVoNHI/KIDavIZxh+CxE6tSLPlYTogqccDnjBFI9CgEsiNuMP6+fiuXUwhhlTz36uUEpwbQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.5", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.5", + "Microsoft.Extensions.Configuration.Binder": "10.0.5", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.5" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "PMs2gha2v24hvH5o5KQem5aNK4mN0BhhCWlMqsg9tzifWKzjeQi2tyPOP/RaWMVvalOhVLcrmoMYPqbnia/epg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging": "10.0.5", + "Microsoft.Extensions.Logging.Abstractions": "10.0.5", + "Microsoft.Extensions.Logging.Configuration": "10.0.5", + "Microsoft.Extensions.Options": "10.0.5" + } + }, + "Microsoft.Extensions.Options": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "MDaQMdUplw0AIRhWWmbLA7yQEXaLIHb+9CTroTiNS8OlI0LMXS4LCxtopqauiqGCWlRgJ+xyraVD8t6veRAFbw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.5", + "Microsoft.Extensions.Primitives": "10.0.5" + } + }, + "Microsoft.Extensions.TimeProvider.Testing": { + "type": "CentralTransitive", + "requested": "[10.4.0, )", + "resolved": "10.4.0", + "contentHash": "uJ8n9WUEzux9I2CjZh7imGBgZadfwhAKlxuBq7GsNGL8FJF81aHXAYaRMnwW+9EvRFQNytu7xo1ffeuuTncAzg==" + }, + "Microsoft.FeatureManagement.AspNetCore": { + "type": "CentralTransitive", + "requested": "[4.4.0, )", + "resolved": "4.4.0", + "contentHash": "jE5KxeEDUnUsx1w9uOFV5cOfM4E2mg7kf07328xO1x4JdoS0jwkD55nMjTlUPu90ynYX1oCBGC+FHK6ZoqRpxA==", + "dependencies": { + "Microsoft.FeatureManagement": "4.4.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "UFrU7d46UTsPQTa2HIEIpB9H1uJe1BW9FLw5uhEJ2ZuKdur8bcUA/bO5caq5dlBt5gNJeRIB3QQXYNs5fCQCZA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.16.0" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "CentralTransitive", + "requested": "[8.16.0, )", + "resolved": "8.16.0", + "contentHash": "h4yVXyJsEBBX5lg2G5ftMsi5JzcNEGAzrNphA6DQ6eOd8P0s+cDCOyPwVTYLePZvJL5unbPvYIvzrbTXzFjXnQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "8.16.0", + "System.IdentityModel.Tokens.Jwt": "8.16.0" + } + }, + "Microsoft.OpenApi": { + "type": "CentralTransitive", + "requested": "[2.7.0, )", + "resolved": "2.7.0", + "contentHash": "b9xmpnmjq6p+HqF3uWG7u7/PlB38t/UB5UtXdi6xEAP9ZJGKHneYyjMGzBflB1rpLxYEcU6KRme+cz5wNPlxqA==" + }, + "Npgsql": { + "type": "CentralTransitive", + "requested": "[10.0.2, )", + "resolved": "10.0.2", + "contentHash": "q5RfBI+wywJSFUNDE1L4ZbHEHCFTblo8Uf6A6oe4feOUFYiUQXyAf9GBh5qEZpvJaHiEbpBPkQumjEhXCJxdrg==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "P6EwH0Q4xkaA264iNZDqCPhWt8pscfUGxXazDQg4noBfqjoOlk4hKWfvBjF9ZX3R/9JybRmmJfmxr2iBMj0EpA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.4, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.4, 11.0.0)", + "Npgsql": "10.0.2" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Fcx8k5cSF7cGeXMeguLwyV+wTcKbmhojCse2WBXfHlEQNXILpI1My7H6rvPT8n3fI4TxZiYq/LWO2vVGBD3FHA==", + "dependencies": { + "Npgsql.EntityFrameworkCore.PostgreSQL": "10.0.1", + "Npgsql.NetTopologySuite": "10.0.2" + } + }, + "OpenTelemetry.Exporter.Console": { + "type": "CentralTransitive", + "requested": "[1.15.1, )", + "resolved": "1.15.1", + "contentHash": "/K+pKIsoS2wMihbHocCZG68TuIgKQqpr6qKJigJ/iZiucdZlULF7sZEmxSkc3JGoQikecTIpVITOWjQ80GDIlw==", + "dependencies": { + "OpenTelemetry": "1.15.1" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "CentralTransitive", + "requested": "[1.15.1, )", + "resolved": "1.15.1", + "contentHash": "400L64MwDd1s2bj4fFJblo3Hf5rXE3bhJUlOSBcLF6QP1Ln116Eqnwnesxhg2siDxOgHYLjcfCC8ByJTDEpNFQ==", + "dependencies": { + "OpenTelemetry": "1.15.1" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[1.15.1, )", + "resolved": "1.15.1", + "contentHash": "/mN9I16P8miDSHogFC0OFyPzUvYXibk/rLFLXW3Io50IN+XEQx7E6dSyUdMRdY+NKmOCo/oS5ICXkjdoFrwq2A==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.1" + } + }, + "OpenTelemetry.Instrumentation.AspNetCore": { + "type": "CentralTransitive", + "requested": "[1.15.1, )", + "resolved": "1.15.1", + "contentHash": "wXaZTu6LHY8xcbRd6ClcrtjHqGVoGYCcArXEZA3iUjUcYSVYwDGyPU0PdkwTfylxv8JeCCVDQhVb0fT7xBJjGA==", + "dependencies": { + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.0, 2.0.0)" + } + }, + "OpenTelemetry.Instrumentation.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[1.14.0-beta.2, )", + "resolved": "1.14.0-beta.2", + "contentHash": "XsxsKgMuwi84TWkPN98H8FLOO/yW8vWIo/lxXQ8kWXastTI58+A4nmlFderFPmpLc+tvyhOGjHDlTK/AXWWOpQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.14.0, 2.0.0)" + } + }, + "OpenTelemetry.Instrumentation.Http": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "uToc7bUp8IEdb0ny9mKsL6FrrYelINPzxxiSShJgOf4XmQc4Azww6S5RjRj24YhsOn2a1MABOrxfVTZXtDk4Eg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.0, 2.0.0)" + } + }, + "OpenTelemetry.Instrumentation.Runtime": { + "type": "CentralTransitive", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "OOvpqR/j2Pb6+tWhHNODIbSJ53Or/MDtTiXEyrsWI02K2lLAgvBFcxUOrHggS/8015cYR3AdSaXv6NZrkz5yQA==", + "dependencies": { + "OpenTelemetry.Api": "[1.15.0, 2.0.0)" + } + }, + "RabbitMQ.Client": { + "type": "CentralTransitive", + "requested": "[7.2.1, )", + "resolved": "7.2.1", + "contentHash": "YKXEfg9fVQiTKgZlvIhAfPSFaamEgi8DsQmisCH0IAsU4FYLrtoguDrDj6JtJVGUt40QPnBLRH6fTQcAC4qsOg==", + "dependencies": { + "System.Threading.RateLimiting": "8.0.0" + } + }, + "Rebus": { + "type": "CentralTransitive", + "requested": "[8.9.0, )", + "resolved": "8.9.0", + "contentHash": "UaPGZuXIL4J5GUDA05JzEEzuPMEXY0CoF92nC6bsFBPvwoYPQ0uKyH2vKqdV80CW7cjbwBgDlEZ7R9hO9b59XA==", + "dependencies": { + "Newtonsoft.Json": "13.0.4" + } + }, + "Rebus.RabbitMq": { + "type": "CentralTransitive", + "requested": "[10.1.1, )", + "resolved": "10.1.1", + "contentHash": "66pUp4hfaYWfQEDOiVcuZQnPF4XFHyJ5KCfwCm18e3Dnr936Iog48KrN8Mp8QyRQ2tiNpzdjSATQLKEZpSk11A==", + "dependencies": { + "RabbitMq.Client": "7.1.2", + "rebus": "8.9.0" + } + }, + "Rebus.ServiceProvider": { + "type": "CentralTransitive", + "requested": "[10.7.2, )", + "resolved": "10.7.2", + "contentHash": "Qa8sKt1i9Fy/zCw5GwAUsfT+lt4BvkIgYh8sRJ6fvqJWoedS//pfcyiKUUb0wL3C5Wrpi3U+vRud5DCbMHaFIw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "[8.0.0, 11.0.0)", + "Microsoft.Extensions.Hosting.Abstractions": "[6.0.0, 11.0.0)", + "Microsoft.Extensions.Logging.Abstractions": "[6.0.0, 11.0.0)", + "Rebus": "8.9.0" + } + }, + "Respawn": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "AyWlducZGOnmlEVz4PaL3Qv8wcY3ClPZS9CrlDnSVahGd/E9tTEgSFiC8yoV/F6o6P6IYm8xnHFa/vmWT8tfcw==" + }, + "Scrutor": { + "type": "CentralTransitive", + "requested": "[7.0.0, )", + "resolved": "7.0.0", + "contentHash": "wHWaroody48jnlLoq/REwUltIFLxplXyHTP+sttrc8P7+jkiVqf38afDidJNv4qgD/6zz2NKOYg06xLZ1bG7wQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.3.1, )", + "resolved": "4.3.1", + "contentHash": "savYe7h5yRlkqBVOwP8cIRDOdqKiPmYCU4W87JH38sBmcKD5EBoXvQIw6bNEvZ/pTe1gsiye3VFCzBsoppGkXQ==" + }, + "Serilog.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "a/cNa1mY4On1oJlfGG1wAvxjp5g7OEzk/Jf/nm7NF9cWoE7KlZw1GldrifUBWm9oKibHkR7Lg/l5jy3y7ACR8w==", + "dependencies": { + "Serilog": "4.3.0", + "Serilog.Extensions.Hosting": "10.0.0", + "Serilog.Formatting.Compact": "3.0.0", + "Serilog.Settings.Configuration": "10.0.0", + "Serilog.Sinks.Console": "6.1.1", + "Serilog.Sinks.Debug": "3.0.0", + "Serilog.Sinks.File": "7.0.0" + } + }, + "Serilog.Enrichers.Environment": { + "type": "CentralTransitive", + "requested": "[3.0.1, )", + "resolved": "3.0.1", + "contentHash": "9BqCE4C9FF+/rJb/CsQwe7oVf44xqkOvMwX//CUxvUR25lFL4tSS6iuxE5eW07quby1BAyAEP+vM6TWsnT3iqw==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Enrichers.Process": { + "type": "CentralTransitive", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "/wPYz2PDCJGSHNI+Z0PAacZvrgZgrGduWqLXeC2wvW6pgGM/Bi45JrKy887MRcRPHIZVU0LAlkmJ7TkByC0boQ==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Enrichers.Thread": { + "type": "CentralTransitive", + "requested": "[4.0.0, )", + "resolved": "4.0.0", + "contentHash": "C7BK25a1rhUyr+Tp+1BYcVlBJq7M2VCHlIgnwoIUVJcicM9jYcvQK18+OeHiXw7uLPSjqWxJIp1EfaZ/RGmEwA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Settings.Configuration": { + "type": "CentralTransitive", + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "LNq+ibS1sbhTqPV1FIE69/9AJJbfaOhnaqkzcjFy95o+4U+STsta9mi97f1smgXsWYKICDeGUf8xUGzd/52/uA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyModel": "10.0.0", + "Serilog": "4.3.0" + } + }, + "Serilog.Sinks.Console": { + "type": "CentralTransitive", + "requested": "[6.1.1, )", + "resolved": "6.1.1", + "contentHash": "8jbqgjUyZlfCuSTaJk6lOca465OndqOz3KZP6Cryt/IqZYybyBu7GP0fE/AXBzrrQB3EBmQntBFAvMVz1COvAA==", + "dependencies": { + "Serilog": "4.0.0" + } + }, + "Serilog.Sinks.Seq": { + "type": "CentralTransitive", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "aNU8A0K322q7+voPNmp1/qNPH+9QK8xvM1p72sMmCG0wGlshFzmtDW9QnVSoSYCj0MgQKcMOlgooovtBhRlNHw==", + "dependencies": { + "Serilog": "4.2.0", + "Serilog.Sinks.File": "6.0.0" + } + }, + "Swashbuckle.AspNetCore": { + "type": "CentralTransitive", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "vgef8DPT411JU5JjHiDbr0WOxsIVuAvegPGtqmm4Na4JRl/264dfBJcGkiPHsAr5P+Vda+qN1rZKRtBl1rF9aA==", + "dependencies": { + "Microsoft.Extensions.ApiDescription.Server": "10.0.0", + "Swashbuckle.AspNetCore.Swagger": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7", + "Swashbuckle.AspNetCore.SwaggerUI": "10.1.7" + } + }, + "Swashbuckle.AspNetCore.Annotations": { + "type": "CentralTransitive", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "qHNQghKAzrjDEECIlyKEanmWxXDzrh6PyjGWVdql2qZfap0Ionuxam5OrUozibjbgQbVjRmgSXxWC8giIuCbGA==", + "dependencies": { + "Swashbuckle.AspNetCore.SwaggerGen": "10.1.7" + } + }, + "Swashbuckle.AspNetCore.SwaggerGen": { + "type": "CentralTransitive", + "requested": "[10.1.7, )", + "resolved": "10.1.7", + "contentHash": "PuubO9BjvNn6U3D9kLpuWKY1JtziWw7SsGBq0age1E50uQjQ8Fzl8s0EwzrLfANqYJNgDnJi9l7N1QxcGVB2Zw==", + "dependencies": { + "Swashbuckle.AspNetCore.Swagger": "10.1.7" + } + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "CentralTransitive", + "requested": "[8.17.0, )", + "resolved": "8.17.0", + "contentHash": "nKikRYheDeSaXA3wGr2otwaiRFygBa25m+hc7MEomZVIEWZvKVqd8wgP9yn+8QpLRGgw//dUs4LErGx9gtVmAA==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "8.17.0", + "Microsoft.IdentityModel.Tokens": "8.17.0" + } + }, + "System.IO.Hashing": { + "type": "CentralTransitive", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "8IBJWcCT9+e4Bmevm4T7+fQEiAh133KGiz4oiVTgJckd3Q76OFdR1falgn9lpz7+C4HJvogCDJeAa2QmvbeVtg==" + }, + "Testcontainers.Azurite": { + "type": "CentralTransitive", + "requested": "[4.11.0, )", + "resolved": "4.11.0", + "contentHash": "fDML8KiS9alTzmXD8gZziRZWNQDeElejBWr+Wg5vpg6IsK2PhM94coQDNmPyabTpuDQ8XopqxJIw2hyv7YdGqw==", + "dependencies": { + "Testcontainers": "4.11.0" + } + }, + "Testcontainers.PostgreSql": { + "type": "CentralTransitive", + "requested": "[4.11.0, )", + "resolved": "4.11.0", + "contentHash": "OWGi0Og+qFpr2OPDignA74aJSfUd0nvZOaXNGWXwMJR1BvpdzhCNHQB2h7yLSTb0a8JVmmQQ4mUpHAC9q27NLw==", + "dependencies": { + "Testcontainers": "4.11.0" + } + }, + "Testcontainers.RabbitMq": { + "type": "CentralTransitive", + "requested": "[4.11.0, )", + "resolved": "4.11.0", + "contentHash": "+4rDOXdjwwDx11vUpSAAK/gDOPtZ/LC+qj02RiulHtIMEAzC4KjyP5hEactvwSuGfcomFnmtJqYn1Pwyh+pPDw==", + "dependencies": { + "Testcontainers": "4.11.0" + } + } + } + } +} \ No newline at end of file diff --git a/src/Modules/Documents/API/packages.lock.json b/src/Modules/Documents/API/packages.lock.json index 7e191f0f8..fe6505422 100644 --- a/src/Modules/Documents/API/packages.lock.json +++ b/src/Modules/Documents/API/packages.lock.json @@ -334,6 +334,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/Documents/Application/packages.lock.json b/src/Modules/Documents/Application/packages.lock.json index 1c7a9e5a3..8b87e9d8b 100644 --- a/src/Modules/Documents/Application/packages.lock.json +++ b/src/Modules/Documents/Application/packages.lock.json @@ -325,6 +325,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -406,6 +407,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/Documents/Domain/packages.lock.json b/src/Modules/Documents/Domain/packages.lock.json index 28ce8e906..09f7dbed4 100644 --- a/src/Modules/Documents/Domain/packages.lock.json +++ b/src/Modules/Documents/Domain/packages.lock.json @@ -319,6 +319,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -400,6 +401,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/Documents/Infrastructure/packages.lock.json b/src/Modules/Documents/Infrastructure/packages.lock.json index 93beb755c..5c114d7a3 100644 --- a/src/Modules/Documents/Infrastructure/packages.lock.json +++ b/src/Modules/Documents/Infrastructure/packages.lock.json @@ -435,6 +435,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/Documents/Tests/packages.lock.json b/src/Modules/Documents/Tests/packages.lock.json index e1f402916..df3e7c536 100644 --- a/src/Modules/Documents/Tests/packages.lock.json +++ b/src/Modules/Documents/Tests/packages.lock.json @@ -1010,6 +1010,7 @@ "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", @@ -1031,6 +1032,41 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -1269,6 +1305,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/Locations/API/packages.lock.json b/src/Modules/Locations/API/packages.lock.json index 70c6e6a8e..56403fe6a 100644 --- a/src/Modules/Locations/API/packages.lock.json +++ b/src/Modules/Locations/API/packages.lock.json @@ -302,6 +302,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/Locations/Application/packages.lock.json b/src/Modules/Locations/Application/packages.lock.json index 001733277..7e74bdd86 100644 --- a/src/Modules/Locations/Application/packages.lock.json +++ b/src/Modules/Locations/Application/packages.lock.json @@ -325,6 +325,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -406,6 +407,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/Locations/Domain/ValueObjects/Address.cs b/src/Modules/Locations/Domain/ValueObjects/Address.cs index cfbfb5536..26d4ac2a5 100644 --- a/src/Modules/Locations/Domain/ValueObjects/Address.cs +++ b/src/Modules/Locations/Domain/ValueObjects/Address.cs @@ -55,27 +55,14 @@ private Address( return null; } - if (string.IsNullOrWhiteSpace(street)) + if (string.IsNullOrWhiteSpace(street) || + string.IsNullOrWhiteSpace(neighborhood) || + string.IsNullOrWhiteSpace(city) || + string.IsNullOrWhiteSpace(state)) { return null; } - if (string.IsNullOrWhiteSpace(neighborhood)) - { - return null; - } - - if (string.IsNullOrWhiteSpace(city)) - { - return null; - } - - if (string.IsNullOrWhiteSpace(state)) - { - return null; - } - - // Validar UF (2 letras e código válido) var upperState = state.ToUpperInvariant(); if (!ValidUfs.Contains(upperState)) { diff --git a/src/Modules/Locations/Domain/packages.lock.json b/src/Modules/Locations/Domain/packages.lock.json index 28ce8e906..09f7dbed4 100644 --- a/src/Modules/Locations/Domain/packages.lock.json +++ b/src/Modules/Locations/Domain/packages.lock.json @@ -319,6 +319,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -400,6 +401,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/Locations/Infrastructure/Extensions.cs b/src/Modules/Locations/Infrastructure/Extensions.cs index 76f1343ea..c4ed0ae5a 100644 --- a/src/Modules/Locations/Infrastructure/Extensions.cs +++ b/src/Modules/Locations/Infrastructure/Extensions.cs @@ -88,21 +88,27 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddHttpClient(client => { var baseUrl = configuration["Locations:ExternalApis:ViaCep:BaseUrl"] - ?? "https://viacep.com.br"; // Fallback para testes + ?? "https://viacep.com.br/"; // Fallback para testes + + if (!baseUrl.EndsWith('/')) baseUrl += "/"; client.BaseAddress = new Uri(baseUrl); }); services.AddHttpClient(client => { var baseUrl = configuration["Locations:ExternalApis:BrasilApi:BaseUrl"] - ?? "https://brasilapi.com.br"; // Fallback para testes + ?? "https://brasilapi.com.br/"; // Fallback para testes + + if (!baseUrl.EndsWith('/')) baseUrl += "/"; client.BaseAddress = new Uri(baseUrl); }); services.AddHttpClient(client => { var baseUrl = configuration["Locations:ExternalApis:OpenCep:BaseUrl"] - ?? "https://opencep.com"; // Fallback para testes + ?? "https://opencep.com/"; // Fallback para testes + + if (!baseUrl.EndsWith('/')) baseUrl += "/"; client.BaseAddress = new Uri(baseUrl); }); diff --git a/src/Modules/Locations/Infrastructure/ExternalApis/Clients/BrasilApiCepClient.cs b/src/Modules/Locations/Infrastructure/ExternalApis/Clients/BrasilApiCepClient.cs index 0203c2f3e..494cc9eb4 100644 --- a/src/Modules/Locations/Infrastructure/ExternalApis/Clients/BrasilApiCepClient.cs +++ b/src/Modules/Locations/Infrastructure/ExternalApis/Clients/BrasilApiCepClient.cs @@ -20,25 +20,36 @@ public sealed class BrasilApiCepClient(HttpClient httpClient, ILogger(content, SerializationDefaults.Default); + logger.LogDebug("BrasilAPI response for {Cep}: {Content}", cep.Value, content); + var brasilApiResponse = JsonSerializer.Deserialize(content, SerializationDefaults.Api); if (brasilApiResponse is null) { - logger.LogInformation("CEP {Cep} not found in BrasilAPI", cep.Value); + logger.LogInformation("CEP {Cep} not found in BrasilAPI (null deserialization)", cep.Value); return null; } - return Address.Create( + var address = Address.Create( cep, brasilApiResponse.Street, brasilApiResponse.Neighborhood, brasilApiResponse.City, brasilApiResponse.State); + + if (address is null) + { + logger.LogWarning("BrasilAPI returned data for {Cep}, but Address.Create failed. Data: {@Response}", + cep.Value, brasilApiResponse); + } + + return address; } catch (Exception ex) { diff --git a/src/Modules/Locations/Infrastructure/ExternalApis/Clients/OpenCepClient.cs b/src/Modules/Locations/Infrastructure/ExternalApis/Clients/OpenCepClient.cs index 020df4dc1..2c8c08108 100644 --- a/src/Modules/Locations/Infrastructure/ExternalApis/Clients/OpenCepClient.cs +++ b/src/Modules/Locations/Infrastructure/ExternalApis/Clients/OpenCepClient.cs @@ -21,12 +21,15 @@ public sealed class OpenCepClient(HttpClient httpClient, ILogger if (!response.IsSuccessStatusCode) { - logger.LogWarning("OpenCEP returned status {StatusCode} for CEP {Cep}", response.StatusCode, cep.Value); + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogWarning("OpenCEP returned status {StatusCode} for CEP {Cep}. Content: {Content}", + response.StatusCode, cep.Value, errorContent); return null; } var content = await response.Content.ReadAsStringAsync(cancellationToken); - var openCepResponse = JsonSerializer.Deserialize(content, SerializationDefaults.Default); + logger.LogDebug("OpenCEP response for {Cep}: {Content}", cep.Value, content); + var openCepResponse = JsonSerializer.Deserialize(content, SerializationDefaults.Api); if (openCepResponse is null) { diff --git a/src/Modules/Locations/Infrastructure/ExternalApis/Clients/ViaCepClient.cs b/src/Modules/Locations/Infrastructure/ExternalApis/Clients/ViaCepClient.cs index 492950cab..20fee21b7 100644 --- a/src/Modules/Locations/Infrastructure/ExternalApis/Clients/ViaCepClient.cs +++ b/src/Modules/Locations/Infrastructure/ExternalApis/Clients/ViaCepClient.cs @@ -21,26 +21,43 @@ public sealed class ViaCepClient(HttpClient httpClient, ILogger lo if (!response.IsSuccessStatusCode) { - logger.LogWarning("ViaCEP returned status {StatusCode} for CEP {Cep}", response.StatusCode, cep.Value); + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + logger.LogWarning("ViaCEP returned status {StatusCode} for CEP {Cep}. Content: {Content}", + response.StatusCode, cep.Value, errorContent); return null; } var content = await response.Content.ReadAsStringAsync(cancellationToken); - var viaCepResponse = JsonSerializer.Deserialize(content, SerializationDefaults.Default); + logger.LogDebug("ViaCEP response for {Cep}: {Content}", cep.Value, content); + var viaCepResponse = JsonSerializer.Deserialize(content, SerializationDefaults.Api); - if (viaCepResponse is null || viaCepResponse.Erro) + if (viaCepResponse is null) { - logger.LogInformation("CEP {Cep} not found in ViaCEP", cep.Value); + logger.LogInformation("CEP {Cep} not found in ViaCEP (null response)", cep.Value); return null; } - return Address.Create( + if (viaCepResponse.Erro) + { + logger.LogInformation("CEP {Cep} not found in ViaCEP (erro=true)", cep.Value); + return null; + } + + var address = Address.Create( cep, viaCepResponse.Logradouro, viaCepResponse.Bairro, viaCepResponse.Localidade, viaCepResponse.Uf, viaCepResponse.Complemento); + + if (address is null) + { + logger.LogWarning("ViaCEP returned data for CEP {Cep}, but Address.Create failed. Data: {@Response}", + cep.Value, viaCepResponse); + } + + return address; } catch (Exception ex) { diff --git a/src/Modules/Locations/Infrastructure/ExternalApis/Responses/BrasilApiCepResponse.cs b/src/Modules/Locations/Infrastructure/ExternalApis/Responses/BrasilApiCepResponse.cs index 0c81719ea..021e7ed33 100644 --- a/src/Modules/Locations/Infrastructure/ExternalApis/Responses/BrasilApiCepResponse.cs +++ b/src/Modules/Locations/Infrastructure/ExternalApis/Responses/BrasilApiCepResponse.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace MeAjudaAi.Modules.Locations.Infrastructure.ExternalApis.Responses; /// @@ -6,9 +8,18 @@ namespace MeAjudaAi.Modules.Locations.Infrastructure.ExternalApis.Responses; /// public sealed class BrasilApiCepResponse { + [JsonPropertyName("cep")] public string? Cep { get; set; } + + [JsonPropertyName("street")] public string? Street { get; set; } + + [JsonPropertyName("neighborhood")] public string? Neighborhood { get; set; } + + [JsonPropertyName("city")] public string? City { get; set; } + + [JsonPropertyName("state")] public string? State { get; set; } } diff --git a/src/Modules/Locations/Infrastructure/ExternalApis/Responses/OpenCepResponse.cs b/src/Modules/Locations/Infrastructure/ExternalApis/Responses/OpenCepResponse.cs index 45454c35e..a2e17cf57 100644 --- a/src/Modules/Locations/Infrastructure/ExternalApis/Responses/OpenCepResponse.cs +++ b/src/Modules/Locations/Infrastructure/ExternalApis/Responses/OpenCepResponse.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace MeAjudaAi.Modules.Locations.Infrastructure.ExternalApis.Responses; /// @@ -6,10 +8,21 @@ namespace MeAjudaAi.Modules.Locations.Infrastructure.ExternalApis.Responses; /// public sealed class OpenCepResponse { + [JsonPropertyName("cep")] public string? Cep { get; set; } + + [JsonPropertyName("logradouro")] public string? Logradouro { get; set; } + + [JsonPropertyName("complemento")] public string? Complemento { get; set; } + + [JsonPropertyName("bairro")] public string? Bairro { get; set; } + + [JsonPropertyName("localidade")] public string? Localidade { get; set; } + + [JsonPropertyName("uf")] public string? Uf { get; set; } } diff --git a/src/Modules/Locations/Infrastructure/ExternalApis/Responses/ViaCepResponse.cs b/src/Modules/Locations/Infrastructure/ExternalApis/Responses/ViaCepResponse.cs index 792ef6a4a..95b39142a 100644 --- a/src/Modules/Locations/Infrastructure/ExternalApis/Responses/ViaCepResponse.cs +++ b/src/Modules/Locations/Infrastructure/ExternalApis/Responses/ViaCepResponse.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace MeAjudaAi.Modules.Locations.Infrastructure.ExternalApis.Responses; /// @@ -6,15 +8,27 @@ namespace MeAjudaAi.Modules.Locations.Infrastructure.ExternalApis.Responses; /// public sealed class ViaCepResponse { + [JsonPropertyName("cep")] public string? Cep { get; set; } + + [JsonPropertyName("logradouro")] public string? Logradouro { get; set; } + + [JsonPropertyName("complemento")] public string? Complemento { get; set; } + + [JsonPropertyName("bairro")] public string? Bairro { get; set; } + + [JsonPropertyName("localidade")] public string? Localidade { get; set; } + + [JsonPropertyName("uf")] public string? Uf { get; set; } /// /// API retorna {"erro": true} quando CEP não existe /// + [JsonPropertyName("erro")] public bool Erro { get; set; } } diff --git a/src/Modules/Locations/Infrastructure/Services/CepLookupService.cs b/src/Modules/Locations/Infrastructure/Services/CepLookupService.cs index 1c2030a2a..fad571c78 100644 --- a/src/Modules/Locations/Infrastructure/Services/CepLookupService.cs +++ b/src/Modules/Locations/Infrastructure/Services/CepLookupService.cs @@ -37,7 +37,7 @@ public sealed class CepLookupService( cacheKey, async ct => { - logger.LogInformation("Cache miss para CEP {Cep}, consultando APIs", cep.Value); + logger.LogInformation("Cache miss for CEP {Cep}, querying APIs", cep.Value); return await LookupFromProvidersAsync(cep, ct); }, expiration: TimeSpan.FromHours(24), @@ -54,33 +54,45 @@ public sealed class CepLookupService( private async Task LookupFromProvidersAsync(Cep cep, CancellationToken cancellationToken) { - logger.LogInformation("Starting CEP lookup {Cep}", cep.Value); + logger.LogInformation("Starting CEP lookup for {Cep}", cep.Value); foreach (var provider in DefaultProviderOrder) { + logger.LogInformation("Trying provider {Provider} for CEP {Cep}", provider, cep.Value); var address = await TryProviderAsync(provider, cep, cancellationToken); + if (address is not null) { - logger.LogInformation("CEP {Cep} found in provider {Provider}", cep.Value, provider); + logger.LogInformation("CEP {Cep} found in provider {Provider}. City: {City}, State: {State}", + cep.Value, provider, address.City, address.State); return address; } - logger.LogWarning("Provider {Provider} failed for CEP {Cep}, trying next", provider, cep.Value); + logger.LogWarning("Provider {Provider} returned no result for CEP {Cep}", provider, cep.Value); } - logger.LogError("CEP {Cep} not found in any provider", cep.Value); + logger.LogError("CEP {Cep} not found in any of the configured providers", cep.Value); return null; } private async Task TryProviderAsync(ECepProvider provider, Cep cep, CancellationToken cancellationToken) { - return provider switch + try { - ECepProvider.ViaCep => await viaCepClient.GetAddressAsync(cep, cancellationToken), - ECepProvider.BrasilApi => await brasilApiClient.GetAddressAsync(cep, cancellationToken), - ECepProvider.OpenCep => await openCepClient.GetAddressAsync(cep, cancellationToken), - _ => null - }; + return provider switch + { + ECepProvider.ViaCep => await viaCepClient.GetAddressAsync(cep, cancellationToken), + ECepProvider.BrasilApi => await brasilApiClient.GetAddressAsync(cep, cancellationToken), + ECepProvider.OpenCep => await openCepClient.GetAddressAsync(cep, cancellationToken), + _ => null + }; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Provider {Provider} encountered an error for CEP {Cep}. Error: {Message}", + provider, cep.Value, ex.Message); + return null; + } } private static string GetCacheKey(Cep cep) diff --git a/src/Modules/Locations/Infrastructure/packages.lock.json b/src/Modules/Locations/Infrastructure/packages.lock.json index 333cc209b..77befed1c 100644 --- a/src/Modules/Locations/Infrastructure/packages.lock.json +++ b/src/Modules/Locations/Infrastructure/packages.lock.json @@ -363,6 +363,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/Locations/Tests/packages.lock.json b/src/Modules/Locations/Tests/packages.lock.json index 2f8a51175..b5c189827 100644 --- a/src/Modules/Locations/Tests/packages.lock.json +++ b/src/Modules/Locations/Tests/packages.lock.json @@ -988,6 +988,7 @@ "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", @@ -1009,6 +1010,41 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -1247,6 +1283,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/Providers/API/packages.lock.json b/src/Modules/Providers/API/packages.lock.json index 5e7eabeae..c57191ed1 100644 --- a/src/Modules/Providers/API/packages.lock.json +++ b/src/Modules/Providers/API/packages.lock.json @@ -399,6 +399,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/Providers/Application/packages.lock.json b/src/Modules/Providers/Application/packages.lock.json index b55019591..64ca5d6a6 100644 --- a/src/Modules/Providers/Application/packages.lock.json +++ b/src/Modules/Providers/Application/packages.lock.json @@ -338,6 +338,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -419,6 +420,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/Providers/Domain/packages.lock.json b/src/Modules/Providers/Domain/packages.lock.json index 28ce8e906..09f7dbed4 100644 --- a/src/Modules/Providers/Domain/packages.lock.json +++ b/src/Modules/Providers/Domain/packages.lock.json @@ -319,6 +319,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -400,6 +401,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/Providers/Infrastructure/packages.lock.json b/src/Modules/Providers/Infrastructure/packages.lock.json index 1a680f69d..ac5e0840e 100644 --- a/src/Modules/Providers/Infrastructure/packages.lock.json +++ b/src/Modules/Providers/Infrastructure/packages.lock.json @@ -377,6 +377,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/Providers/Tests/packages.lock.json b/src/Modules/Providers/Tests/packages.lock.json index 2266b22da..f55c86df6 100644 --- a/src/Modules/Providers/Tests/packages.lock.json +++ b/src/Modules/Providers/Tests/packages.lock.json @@ -1019,6 +1019,7 @@ "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", @@ -1040,6 +1041,41 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -1278,6 +1314,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/SearchProviders/API/packages.lock.json b/src/Modules/SearchProviders/API/packages.lock.json index c7f0ff055..4e2c8d81e 100644 --- a/src/Modules/SearchProviders/API/packages.lock.json +++ b/src/Modules/SearchProviders/API/packages.lock.json @@ -328,6 +328,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/SearchProviders/Application/packages.lock.json b/src/Modules/SearchProviders/Application/packages.lock.json index 01b9afb11..8248b016d 100644 --- a/src/Modules/SearchProviders/Application/packages.lock.json +++ b/src/Modules/SearchProviders/Application/packages.lock.json @@ -325,6 +325,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -406,6 +407,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/SearchProviders/Domain/Enums/ESubscriptionTier.cs b/src/Modules/SearchProviders/Domain/Enums/ESubscriptionTier.cs index 85f60822b..ec5f648b4 100644 --- a/src/Modules/SearchProviders/Domain/Enums/ESubscriptionTier.cs +++ b/src/Modules/SearchProviders/Domain/Enums/ESubscriptionTier.cs @@ -1,3 +1,4 @@ + namespace MeAjudaAi.Modules.SearchProviders.Domain.Enums; /// diff --git a/src/Modules/SearchProviders/Domain/Repositories/ISearchableProviderRepository.cs b/src/Modules/SearchProviders/Domain/Repositories/ISearchableProviderRepository.cs index 859c8546f..8db1d5c1a 100644 --- a/src/Modules/SearchProviders/Domain/Repositories/ISearchableProviderRepository.cs +++ b/src/Modules/SearchProviders/Domain/Repositories/ISearchableProviderRepository.cs @@ -46,6 +46,11 @@ Task SearchAsync( int take = 20, CancellationToken cancellationToken = default); + /// + /// Recupera todos os provedores que oferecem um serviço específico. + /// + Task> GetByServiceIdAsync(Guid serviceId, CancellationToken cancellationToken = default); + /// /// Adiciona um novo provedor pesquisável ao repositório. /// diff --git a/src/Modules/SearchProviders/Domain/packages.lock.json b/src/Modules/SearchProviders/Domain/packages.lock.json index 28ce8e906..09f7dbed4 100644 --- a/src/Modules/SearchProviders/Domain/packages.lock.json +++ b/src/Modules/SearchProviders/Domain/packages.lock.json @@ -319,6 +319,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -400,6 +401,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/SearchProviders/Infrastructure/Events/Handlers/ServiceDeactivatedIntegrationEventHandler.cs b/src/Modules/SearchProviders/Infrastructure/Events/Handlers/ServiceDeactivatedIntegrationEventHandler.cs new file mode 100644 index 000000000..fcf947238 --- /dev/null +++ b/src/Modules/SearchProviders/Infrastructure/Events/Handlers/ServiceDeactivatedIntegrationEventHandler.cs @@ -0,0 +1,38 @@ +using MeAjudaAi.Modules.SearchProviders.Domain.Repositories; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging.Messages.ServiceCatalogs; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.SearchProviders.Infrastructure.Events.Handlers; + +/// +/// Handler para remover um serviço de todos os prestadores pesquisáveis quando ele é desativado no catálogo. +/// +public sealed class ServiceDeactivatedIntegrationEventHandler( + ISearchableProviderRepository repository, + ILogger logger) : IEventHandler +{ + public async Task HandleAsync(ServiceDeactivatedIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) + { + logger.LogInformation("Handling ServiceDeactivatedIntegrationEvent for service {ServiceId}", integrationEvent.ServiceId); + + var providers = await repository.GetByServiceIdAsync(integrationEvent.ServiceId, cancellationToken); + + if (!providers.Any()) + { + return; + } + + foreach (var provider in providers) + { + var updatedServices = provider.ServiceIds.Where(id => id != integrationEvent.ServiceId).ToArray(); + provider.UpdateServices(updatedServices); + await repository.UpdateAsync(provider, cancellationToken); + } + + await repository.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Successfully removed service {ServiceId} from {Count} searchable providers.", + integrationEvent.ServiceId, providers.Count); + } +} diff --git a/src/Modules/SearchProviders/Infrastructure/Extensions.cs b/src/Modules/SearchProviders/Infrastructure/Extensions.cs index 18f1d80a3..e744dc5e4 100644 --- a/src/Modules/SearchProviders/Infrastructure/Extensions.cs +++ b/src/Modules/SearchProviders/Infrastructure/Extensions.cs @@ -10,6 +10,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using MeAjudaAi.Shared.Messaging.Messages.ServiceCatalogs; + namespace MeAjudaAi.Modules.SearchProviders.Infrastructure; /// @@ -108,6 +110,8 @@ private static IServiceCollection AddEventHandlers(this IServiceCollection servi { // Integration Event Handlers services.AddScoped, ProviderActivatedIntegrationEventHandler>(); + services.AddScoped, ProviderServicesUpdatedIntegrationEventHandler>(); + services.AddScoped, ServiceDeactivatedIntegrationEventHandler>(); return services; } diff --git a/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs b/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs index 5f8247378..f9a0a5b72 100644 --- a/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs +++ b/src/Modules/SearchProviders/Infrastructure/Persistence/Repositories/SearchableProviderRepository.cs @@ -239,6 +239,13 @@ private static SearchableProvider MapToEntity(ProviderSearchResultDto dto) state: dto.State); } + public async Task> GetByServiceIdAsync(Guid serviceId, CancellationToken cancellationToken = default) + { + return await context.SearchableProviders + .Where(p => p.ServiceIds.Contains(serviceId)) + .ToListAsync(cancellationToken); + } + public async Task AddAsync(SearchableProvider provider, CancellationToken cancellationToken = default) { await context.SearchableProviders.AddAsync(provider, cancellationToken); diff --git a/src/Modules/SearchProviders/Infrastructure/packages.lock.json b/src/Modules/SearchProviders/Infrastructure/packages.lock.json index e48f8eac8..233a4122d 100644 --- a/src/Modules/SearchProviders/Infrastructure/packages.lock.json +++ b/src/Modules/SearchProviders/Infrastructure/packages.lock.json @@ -419,6 +419,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Events/Handlers/ServiceDeactivatedIntegrationEventHandlerTests.cs b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Events/Handlers/ServiceDeactivatedIntegrationEventHandlerTests.cs new file mode 100644 index 000000000..43e62b918 --- /dev/null +++ b/src/Modules/SearchProviders/Tests/Unit/Infrastructure/Events/Handlers/ServiceDeactivatedIntegrationEventHandlerTests.cs @@ -0,0 +1,49 @@ +using MeAjudaAi.Modules.SearchProviders.Domain.Entities; +using MeAjudaAi.Modules.SearchProviders.Domain.Repositories; +using MeAjudaAi.Modules.SearchProviders.Domain.ValueObjects; +using MeAjudaAi.Modules.SearchProviders.Infrastructure.Events.Handlers; +using MeAjudaAi.Shared.Geolocation; +using MeAjudaAi.Shared.Messaging.Messages.ServiceCatalogs; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using FluentAssertions; + +namespace MeAjudaAi.Modules.SearchProviders.Tests.Unit.Infrastructure.Events.Handlers; + +public class ServiceDeactivatedIntegrationEventHandlerTests +{ + private readonly Mock _repositoryMock; + private readonly Mock> _loggerMock; + private readonly ServiceDeactivatedIntegrationEventHandler _handler; + + public ServiceDeactivatedIntegrationEventHandlerTests() + { + _repositoryMock = new Mock(); + _loggerMock = new Mock>(); + _handler = new ServiceDeactivatedIntegrationEventHandler(_repositoryMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_WhenServiceDeactivated_ShouldRemoveFromProviders() + { + // Arrange + var serviceId = Guid.NewGuid(); + var integrationEvent = new ServiceDeactivatedIntegrationEvent("ServiceCatalogs", serviceId); + + var provider = SearchableProvider.Create( + Guid.NewGuid(), "Provider 1", "p1", new GeoPoint(0, 0)); + provider.UpdateServices([serviceId, Guid.NewGuid()]); + + _repositoryMock.Setup(x => x.GetByServiceIdAsync(serviceId, It.IsAny())) + .ReturnsAsync(new List { provider }); + + // Act + await _handler.HandleAsync(integrationEvent); + + // Assert + provider.ServiceIds.Should().NotContain(serviceId); + _repositoryMock.Verify(x => x.UpdateAsync(provider, It.IsAny()), Times.Once); + _repositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); + } +} diff --git a/src/Modules/SearchProviders/Tests/packages.lock.json b/src/Modules/SearchProviders/Tests/packages.lock.json index e1f402916..df3e7c536 100644 --- a/src/Modules/SearchProviders/Tests/packages.lock.json +++ b/src/Modules/SearchProviders/Tests/packages.lock.json @@ -1010,6 +1010,7 @@ "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", @@ -1031,6 +1032,41 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -1269,6 +1305,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/ServiceCatalogs/API/packages.lock.json b/src/Modules/ServiceCatalogs/API/packages.lock.json index 2c5259faa..3d241456d 100644 --- a/src/Modules/ServiceCatalogs/API/packages.lock.json +++ b/src/Modules/ServiceCatalogs/API/packages.lock.json @@ -385,6 +385,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/ServiceCatalogs/Application/packages.lock.json b/src/Modules/ServiceCatalogs/Application/packages.lock.json index d2ba65de2..250016149 100644 --- a/src/Modules/ServiceCatalogs/Application/packages.lock.json +++ b/src/Modules/ServiceCatalogs/Application/packages.lock.json @@ -325,6 +325,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -406,6 +407,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/ServiceCatalogs/Domain/packages.lock.json b/src/Modules/ServiceCatalogs/Domain/packages.lock.json index 28ce8e906..09f7dbed4 100644 --- a/src/Modules/ServiceCatalogs/Domain/packages.lock.json +++ b/src/Modules/ServiceCatalogs/Domain/packages.lock.json @@ -319,6 +319,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -400,6 +401,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/ServiceCatalogs/Infrastructure/Events/Handlers/ServiceActivatedDomainEventHandler.cs b/src/Modules/ServiceCatalogs/Infrastructure/Events/Handlers/ServiceActivatedDomainEventHandler.cs new file mode 100644 index 000000000..72acec3e4 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Infrastructure/Events/Handlers/ServiceActivatedDomainEventHandler.cs @@ -0,0 +1,41 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Events.Service; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging; +using MeAjudaAi.Shared.Messaging.Messages.ServiceCatalogs; +using MeAjudaAi.Shared.Utilities.Constants; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Infrastructure.Events.Handlers; + +public sealed class ServiceActivatedDomainEventHandler( + IServiceRepository serviceRepository, + IMessageBus messageBus, + ILogger logger) : IEventHandler +{ + public async Task HandleAsync(ServiceActivatedDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + try + { + var service = await serviceRepository.GetByIdAsync(domainEvent.ServiceId, cancellationToken); + if (service == null) + { + throw new InvalidOperationException($"Service {domainEvent.ServiceId} not found when handling activation event."); + } + + var integrationEvent = new ServiceActivatedIntegrationEvent( + ModuleNames.ServiceCatalogs, + service.Id.Value, + service.Name); + + await messageBus.PublishAsync(integrationEvent, cancellationToken: cancellationToken); + + logger.LogInformation("Published ServiceActivatedIntegrationEvent for service {ServiceId}", service.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling ServiceActivatedDomainEvent for service {ServiceId}", domainEvent.ServiceId); + throw; + } + } +} diff --git a/src/Modules/ServiceCatalogs/Infrastructure/Events/Handlers/ServiceDeactivatedDomainEventHandler.cs b/src/Modules/ServiceCatalogs/Infrastructure/Events/Handlers/ServiceDeactivatedDomainEventHandler.cs new file mode 100644 index 000000000..71388f3d3 --- /dev/null +++ b/src/Modules/ServiceCatalogs/Infrastructure/Events/Handlers/ServiceDeactivatedDomainEventHandler.cs @@ -0,0 +1,32 @@ +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Events.Service; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging; +using MeAjudaAi.Shared.Messaging.Messages.ServiceCatalogs; +using MeAjudaAi.Shared.Utilities.Constants; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.ServiceCatalogs.Infrastructure.Events.Handlers; + +public sealed class ServiceDeactivatedDomainEventHandler( + IMessageBus messageBus, + ILogger logger) : IEventHandler +{ + public async Task HandleAsync(ServiceDeactivatedDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + try + { + var integrationEvent = new ServiceDeactivatedIntegrationEvent( + ModuleNames.ServiceCatalogs, + domainEvent.ServiceId.Value); + + await messageBus.PublishAsync(integrationEvent, cancellationToken: cancellationToken); + + logger.LogInformation("Published ServiceDeactivatedIntegrationEvent for service {ServiceId}", domainEvent.ServiceId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling ServiceDeactivatedDomainEvent for service {ServiceId}", domainEvent.ServiceId); + throw; + } + } +} diff --git a/src/Modules/ServiceCatalogs/Infrastructure/Extensions.cs b/src/Modules/ServiceCatalogs/Infrastructure/Extensions.cs index 743a3382d..a99205c7b 100644 --- a/src/Modules/ServiceCatalogs/Infrastructure/Extensions.cs +++ b/src/Modules/ServiceCatalogs/Infrastructure/Extensions.cs @@ -7,12 +7,15 @@ using MeAjudaAi.Modules.ServiceCatalogs.Application.Handlers.Queries.ServiceCategory; using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.Service; using MeAjudaAi.Modules.ServiceCatalogs.Application.Queries.ServiceCategory; +using MeAjudaAi.Modules.ServiceCatalogs.Domain.Events.Service; using MeAjudaAi.Modules.ServiceCatalogs.Domain.Repositories; +using MeAjudaAi.Modules.ServiceCatalogs.Infrastructure.Events.Handlers; using MeAjudaAi.Modules.ServiceCatalogs.Infrastructure.Persistence; using MeAjudaAi.Modules.ServiceCatalogs.Infrastructure.Persistence.Repositories; using MeAjudaAi.Shared.Commands; using MeAjudaAi.Shared.Database; using MeAjudaAi.Contracts.Functional; +using MeAjudaAi.Shared.Events; using MeAjudaAi.Shared.Queries; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -30,7 +33,8 @@ public static IServiceCollection AddServiceCatalogsInfrastructure( this IServiceCollection services, IConfiguration configuration) { - // Configura DbContext + // ... rest of DB configuration ... + services.AddDbContext((serviceProvider, options) => { var environment = serviceProvider.GetService(); @@ -101,6 +105,10 @@ public static IServiceCollection AddServiceCatalogsInfrastructure( services.AddScoped>, GetServiceByIdQueryHandler>(); services.AddScoped>>, GetServicesByCategoryQueryHandler>(); + // Registra domain event handlers + services.AddScoped, ServiceActivatedDomainEventHandler>(); + services.AddScoped, ServiceDeactivatedDomainEventHandler>(); + return services; } } diff --git a/src/Modules/ServiceCatalogs/Infrastructure/packages.lock.json b/src/Modules/ServiceCatalogs/Infrastructure/packages.lock.json index abb30c785..5776d4526 100644 --- a/src/Modules/ServiceCatalogs/Infrastructure/packages.lock.json +++ b/src/Modules/ServiceCatalogs/Infrastructure/packages.lock.json @@ -363,6 +363,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/ServiceCatalogs/Tests/packages.lock.json b/src/Modules/ServiceCatalogs/Tests/packages.lock.json index e1f402916..df3e7c536 100644 --- a/src/Modules/ServiceCatalogs/Tests/packages.lock.json +++ b/src/Modules/ServiceCatalogs/Tests/packages.lock.json @@ -1010,6 +1010,7 @@ "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", @@ -1031,6 +1032,41 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -1269,6 +1305,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/Users/API/packages.lock.json b/src/Modules/Users/API/packages.lock.json index 603ea8fa5..113dfbb18 100644 --- a/src/Modules/Users/API/packages.lock.json +++ b/src/Modules/Users/API/packages.lock.json @@ -419,6 +419,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/Users/Application/packages.lock.json b/src/Modules/Users/Application/packages.lock.json index d2d5884b8..9d6e15b93 100644 --- a/src/Modules/Users/Application/packages.lock.json +++ b/src/Modules/Users/Application/packages.lock.json @@ -325,6 +325,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -406,6 +407,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/Users/Domain/packages.lock.json b/src/Modules/Users/Domain/packages.lock.json index 28ce8e906..09f7dbed4 100644 --- a/src/Modules/Users/Domain/packages.lock.json +++ b/src/Modules/Users/Domain/packages.lock.json @@ -319,6 +319,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", @@ -400,6 +401,17 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "CentralTransitive", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[12.1.1, )", diff --git a/src/Modules/Users/Infrastructure/Persistence/Migrations/UsersDbContextModelSnapshot.cs b/src/Modules/Users/Infrastructure/Persistence/Migrations/UsersDbContextModelSnapshot.cs index 2fe95941d..96391549c 100644 --- a/src/Modules/Users/Infrastructure/Persistence/Migrations/UsersDbContextModelSnapshot.cs +++ b/src/Modules/Users/Infrastructure/Persistence/Migrations/UsersDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using MeAjudaAi.Modules.Users.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("users") - .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("ProductVersion", "10.0.5") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -87,7 +87,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(30)") .HasColumnName("username"); - b.HasKey("Id"); + b.HasKey("Id") + .HasName("pk_users"); b.HasIndex("CreatedAt") .HasDatabaseName("ix_users_created_at"); @@ -124,7 +125,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.OwnsOne("MeAjudaAi.Modules.Users.Domain.ValueObjects.PhoneNumber", "PhoneNumber", b1 => { b1.Property("UserId") - .HasColumnType("uuid"); + .HasColumnType("uuid") + .HasColumnName("id"); b1.Property("CountryCode") .ValueGeneratedOnAdd() @@ -143,7 +145,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.ToTable("users", "users"); b1.WithOwner() - .HasForeignKey("UserId"); + .HasForeignKey("UserId") + .HasConstraintName("fk_users_users_id"); }); b.Navigation("PhoneNumber"); diff --git a/src/Modules/Users/Infrastructure/packages.lock.json b/src/Modules/Users/Infrastructure/packages.lock.json index 8bebc0a30..27c9f8fe0 100644 --- a/src/Modules/Users/Infrastructure/packages.lock.json +++ b/src/Modules/Users/Infrastructure/packages.lock.json @@ -438,6 +438,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Modules/Users/Tests/Integration/UserRepositoryIntegrationTests.cs b/src/Modules/Users/Tests/Integration/UserRepositoryIntegrationTests.cs index c7dc665d3..afcd8554d 100644 --- a/src/Modules/Users/Tests/Integration/UserRepositoryIntegrationTests.cs +++ b/src/Modules/Users/Tests/Integration/UserRepositoryIntegrationTests.cs @@ -18,9 +18,7 @@ private async Task InitializeInternalAsync() { await base.InitializeAsync(); - var options = new DbContextOptionsBuilder() - .UseNpgsql(ConnectionString) - .Options; + var options = CreateDbContextOptions(); _context = new UsersDbContext(options); await _context.Database.MigrateAsync(); diff --git a/src/Modules/Users/Tests/packages.lock.json b/src/Modules/Users/Tests/packages.lock.json index e1f402916..df3e7c536 100644 --- a/src/Modules/Users/Tests/packages.lock.json +++ b/src/Modules/Users/Tests/packages.lock.json @@ -1010,6 +1010,7 @@ "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", @@ -1031,6 +1032,41 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -1269,6 +1305,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/src/Shared/Communications/Templates/WelcomeEmail.cshtml b/src/Shared/Communications/Templates/WelcomeEmail.cshtml new file mode 100644 index 000000000..47f7afe1b --- /dev/null +++ b/src/Shared/Communications/Templates/WelcomeEmail.cshtml @@ -0,0 +1,31 @@ +@model MeAjudaAi.Shared.Communications.Templates.Models.WelcomeEmailModel + + + + + Bem-vindo ao MeAjudaAi! + + + +
+
+

MeAjudaAi

+
+
+

Olá, @Model.FirstName!

+

Seja muito bem-vindo(a) ao MeAjudaAi, a plataforma que conecta você aos melhores prestadores de serviço.

+

Sua conta foi criada com sucesso. Agora você já pode explorar nossos serviços e encontrar o que precisa.

+

Acessar minha conta

+
+ +
+ + diff --git a/src/Shared/Database/BaseDesignTimeDbContextFactory.cs b/src/Shared/Database/BaseDesignTimeDbContextFactory.cs index 5fcffbbb9..1d5e039b3 100644 --- a/src/Shared/Database/BaseDesignTimeDbContextFactory.cs +++ b/src/Shared/Database/BaseDesignTimeDbContextFactory.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using EFCore.NamingConventions; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; @@ -141,6 +142,8 @@ public TContext CreateDbContext(string[] args) // Permite que classes derivadas configurem opções adicionais ConfigureAdditionalOptions(optionsBuilder); + optionsBuilder.UseSnakeCaseNamingConvention(); + return CreateDbContextInstance(optionsBuilder.Options); } diff --git a/src/Shared/Database/DatabaseExtensions.cs b/src/Shared/Database/DatabaseExtensions.cs index d98d8c720..1bf7c3533 100644 --- a/src/Shared/Database/DatabaseExtensions.cs +++ b/src/Shared/Database/DatabaseExtensions.cs @@ -67,12 +67,18 @@ public static IServiceCollection AddPostgres( } /// - /// Adiciona DbContext configurado com PostgreSQL + /// Adiciona DbContext configurado com PostgreSQL e permite configuração adicional. /// - public static IServiceCollection AddPostgresContext(this IServiceCollection services) + public static IServiceCollection AddPostgresContext( + this IServiceCollection services, + Action? optionsAction = null) where TContext : DbContext { - services.AddDbContext(ConfigureWithPostgresOptions); + services.AddDbContext((serviceProvider, options) => + { + ConfigureWithPostgresOptions(serviceProvider, options); + optionsAction?.Invoke(options); + }); return services; } diff --git a/src/Shared/Database/Outbox/IOutboxRepository.cs b/src/Shared/Database/Outbox/IOutboxRepository.cs new file mode 100644 index 000000000..d8f85dae0 --- /dev/null +++ b/src/Shared/Database/Outbox/IOutboxRepository.cs @@ -0,0 +1,26 @@ +namespace MeAjudaAi.Shared.Database.Outbox; + +/// +/// Interface base para repositórios de Outbox. +/// +/// Tipo da entidade de mensagem do outbox. +public interface IOutboxRepository where TMessage : OutboxMessage +{ + /// + /// Adiciona uma nova mensagem ao outbox. + /// + Task AddAsync(TMessage message, CancellationToken cancellationToken = default); + + /// + /// Recupera mensagens pendentes para processamento. + /// + Task> GetPendingAsync( + int batchSize = 20, + DateTime? utcNow = null, + CancellationToken cancellationToken = default); + + /// + /// Persiste as alterações no banco de dados. + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Shared/Database/Outbox/OutboxMessage.cs b/src/Shared/Database/Outbox/OutboxMessage.cs new file mode 100644 index 000000000..76f1dfab9 --- /dev/null +++ b/src/Shared/Database/Outbox/OutboxMessage.cs @@ -0,0 +1,145 @@ +using MeAjudaAi.Shared.Domain; +using MeAjudaAi.Contracts.Shared; + +namespace MeAjudaAi.Shared.Database.Outbox; + +/// +/// Representa uma mensagem genérica no Outbox para processamento assíncrono. +/// +public class OutboxMessage : BaseEntity +{ + protected OutboxMessage() { } + + /// + /// Identificador único opcional para evitar duplicidade (Idempotência). + /// + public string? CorrelationId { get; protected set; } + + /// + /// Tipo da mensagem (ex: nome do evento de integração, canal de comunicação). + /// + public string Type { get; protected set; } = string.Empty; + + /// + /// Payload JSON serializado da mensagem. + /// + public string Payload { get; protected set; } = string.Empty; + + /// + /// Status atual do processamento. + /// + public EOutboxMessageStatus Status { get; protected set; } + + /// + /// Prioridade de entrega. + /// + public ECommunicationPriority Priority { get; protected set; } + + /// + /// Contador de tentativas realizadas. + /// + public int RetryCount { get; protected set; } + + /// + /// Número máximo de tentativas antes de marcar como falha definitiva. + /// + public int MaxRetries { get; protected set; } + + /// + /// Momento agendado para processamento (null = processa imediatamente). + /// + public DateTime? ScheduledAt { get; protected set; } + + /// + /// Momento em que foi enviada com sucesso. + /// + public DateTime? SentAt { get; protected set; } + + /// + /// Mensagem de erro da última tentativa falhada. + /// + public string? ErrorMessage { get; protected set; } + + public static OutboxMessage Create( + string type, + string payload, + ECommunicationPriority priority = ECommunicationPriority.Normal, + DateTime? scheduledAt = null, + int maxRetries = 3, + string? correlationId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(type); + ArgumentException.ThrowIfNullOrWhiteSpace(payload); + if (maxRetries < 1) + throw new ArgumentOutOfRangeException(nameof(maxRetries), "MaxRetries must be at least 1."); + + return new OutboxMessage + { + Type = type, + Payload = payload, + Status = EOutboxMessageStatus.Pending, + Priority = priority, + RetryCount = 0, + MaxRetries = maxRetries, + ScheduledAt = scheduledAt, + CorrelationId = correlationId + }; + } + + public bool IsReadyToProcess(DateTime utcNow) + => Status == EOutboxMessageStatus.Pending + && (ScheduledAt == null || ScheduledAt <= utcNow); + + public void MarkAsProcessing() + { + if (Status != EOutboxMessageStatus.Pending) + return; + + Status = EOutboxMessageStatus.Processing; + MarkAsUpdated(); + } + + public void ResetToPending() + { + if (Status != EOutboxMessageStatus.Processing) + return; + + Status = EOutboxMessageStatus.Pending; + MarkAsUpdated(); + } + + public void MarkAsSent(DateTime utcNow) + { + if (Status is EOutboxMessageStatus.Sent or EOutboxMessageStatus.Failed) + return; + + Status = EOutboxMessageStatus.Sent; + SentAt = utcNow; + ErrorMessage = null; + MarkAsUpdated(); + } + + public void MarkAsFailed(string errorMessage) + { + if (Status is EOutboxMessageStatus.Sent or EOutboxMessageStatus.Failed) + return; + + ArgumentException.ThrowIfNullOrWhiteSpace(errorMessage); + + RetryCount++; + ErrorMessage = errorMessage; + + if (RetryCount >= MaxRetries) + { + Status = EOutboxMessageStatus.Failed; + } + else + { + Status = EOutboxMessageStatus.Pending; + } + + MarkAsUpdated(); + } + + public bool HasRetriesLeft => RetryCount < MaxRetries; +} diff --git a/src/Shared/Database/Outbox/OutboxMessageConstraints.cs b/src/Shared/Database/Outbox/OutboxMessageConstraints.cs new file mode 100644 index 000000000..0029272dc --- /dev/null +++ b/src/Shared/Database/Outbox/OutboxMessageConstraints.cs @@ -0,0 +1,12 @@ +namespace MeAjudaAi.Shared.Database.Outbox; + +/// +/// Constantes para restrições de banco de dados do Outbox +/// +public static class OutboxMessageConstraints +{ + /// + /// Nome do índice de unicidade para correlation_id na tabela de outbox + /// + public const string CorrelationIdIndexName = "ix_outbox_messages_correlation_id"; +} diff --git a/src/Shared/Database/Outbox/OutboxProcessorBase.cs b/src/Shared/Database/Outbox/OutboxProcessorBase.cs new file mode 100644 index 000000000..8a522ea73 --- /dev/null +++ b/src/Shared/Database/Outbox/OutboxProcessorBase.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Shared.Database.Outbox; + +/// +/// Classe base para processadores de Outbox. +/// Centraliza lógica de polling, retries e atualização de estado. +/// +/// Tipo da entidade de mensagem. +public abstract class OutboxProcessorBase( + IOutboxRepository outboxRepository, + ILogger logger) + where TMessage : OutboxMessage +{ + /// + /// Processa um lote de mensagens pendentes. + /// + /// Tamanho do lote. + /// Token de cancelamento. + /// Número de mensagens processadas com sucesso. + public virtual async Task ProcessPendingMessagesAsync( + int batchSize = 20, + CancellationToken cancellationToken = default) + { + var messages = await outboxRepository.GetPendingAsync(batchSize, DateTime.UtcNow, cancellationToken); + if (messages.Count == 0) return 0; + + logger.LogInformation("Processing {Count} pending outbox messages...", messages.Count); + + var processed = 0; + var processedCount = 0; + + try + { + for (int i = 0; i < messages.Count; i++) + { + if (cancellationToken.IsCancellationRequested) break; + + var message = messages[i]; + processedCount++; + + // Marcar explicitamente como processando e persistir para garantir bloqueio/visibilidade + message.MarkAsProcessing(); + await outboxRepository.SaveChangesAsync(cancellationToken); + + try + { + var result = await DispatchAsync(message, cancellationToken); + + if (result.IsCanceled) + { + message.ResetToPending(); + await outboxRepository.SaveChangesAsync(cancellationToken); + break; + } + + if (result.IsSuccess) + { + message.MarkAsSent(DateTime.UtcNow); + await OnSuccessAsync(message, cancellationToken); + processed++; + } + else + { + message.MarkAsFailed(result.ErrorMessage ?? "Dispatch failed without error message."); + await OnFailureAsync(message, result.ErrorMessage, cancellationToken); + } + } + catch (OperationCanceledException) + { + message.ResetToPending(); + await outboxRepository.SaveChangesAsync(CancellationToken.None); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing outbox message {Id}: {Message}", message.Id, ex.Message); + message.MarkAsFailed(ex.Message); + await OnFailureAsync(message, ex.Message, cancellationToken); + } + + await outboxRepository.SaveChangesAsync(cancellationToken); + } + } + finally + { + // Resetar mensagens que foram buscadas como 'Processing' mas não foram processadas devido a cancelamento/erro + var remainingMessages = messages.Skip(processedCount).ToList(); + if (remainingMessages.Count != 0) + { + logger.LogInformation("Resetting {Count} unprocessed outbox messages back to Pending due to shutdown/cancellation.", remainingMessages.Count); + foreach (var remaining in remainingMessages) + { + remaining.ResetToPending(); + } + await outboxRepository.SaveChangesAsync(CancellationToken.None); + } + } + + return processed; + } + + /// + /// Implementação do despacho real da mensagem (deve ser sobrescrito pelo módulo). + /// + protected abstract Task DispatchAsync(TMessage message, CancellationToken cancellationToken); + + /// + /// Gancho para execução após sucesso (ex: logging de auditoria). + /// + protected virtual Task OnSuccessAsync(TMessage message, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Gancho para execução após falha (ex: logging de erro). + /// + protected virtual Task OnFailureAsync(TMessage message, string? error, CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Resultado de um despacho de mensagem. + /// + public record DispatchResult(bool IsSuccess, string? ErrorMessage = null, bool IsCanceled = false) + { + public static DispatchResult Success() => new(true); + public static DispatchResult Failure(string errorMessage) => new(false, errorMessage); + public static DispatchResult Canceled() => new(false, null, true); + } +} diff --git a/src/Shared/MeAjudaAi.Shared.csproj b/src/Shared/MeAjudaAi.Shared.csproj index b850576f9..95a57053f 100644 --- a/src/Shared/MeAjudaAi.Shared.csproj +++ b/src/Shared/MeAjudaAi.Shared.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Shared/Messaging/Messages/Documents/DocumentRejectedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Documents/DocumentRejectedIntegrationEvent.cs new file mode 100644 index 000000000..f0b9e260e --- /dev/null +++ b/src/Shared/Messaging/Messages/Documents/DocumentRejectedIntegrationEvent.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Shared.Messaging.Messages.Documents; + +/// +/// Evento de integração disparado quando um documento é rejeitado. +/// +[ExcludeFromCodeCoverage] +public sealed record DocumentRejectedIntegrationEvent( + string Source, + Guid DocumentId, + Guid ProviderId, + string DocumentType, + string Reason, + DateTime RejectedAt +) : IntegrationEvent(Source); diff --git a/src/Shared/Messaging/Messages/ServiceCatalogs/ServiceActivatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/ServiceCatalogs/ServiceActivatedIntegrationEvent.cs new file mode 100644 index 000000000..eaaaa3743 --- /dev/null +++ b/src/Shared/Messaging/Messages/ServiceCatalogs/ServiceActivatedIntegrationEvent.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Shared.Messaging.Messages.ServiceCatalogs; + +/// +/// Evento de integração disparado quando um serviço é ativado no catálogo global. +/// +[ExcludeFromCodeCoverage] +public sealed record ServiceActivatedIntegrationEvent( + string Source, + Guid ServiceId, + string Name +) : IntegrationEvent(Source); diff --git a/src/Shared/Messaging/Messages/ServiceCatalogs/ServiceDeactivatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/ServiceCatalogs/ServiceDeactivatedIntegrationEvent.cs new file mode 100644 index 000000000..ac74066e7 --- /dev/null +++ b/src/Shared/Messaging/Messages/ServiceCatalogs/ServiceDeactivatedIntegrationEvent.cs @@ -0,0 +1,13 @@ +using System.Diagnostics.CodeAnalysis; +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Shared.Messaging.Messages.ServiceCatalogs; + +/// +/// Evento de integração disparado quando um serviço é desativado no catálogo global. +/// +[ExcludeFromCodeCoverage] +public sealed record ServiceDeactivatedIntegrationEvent( + string Source, + Guid ServiceId +) : IntegrationEvent(Source); diff --git a/src/Shared/Monitoring/BusinessMetrics.cs b/src/Shared/Monitoring/BusinessMetrics.cs index 76dc7d2fa..020fac795 100644 --- a/src/Shared/Monitoring/BusinessMetrics.cs +++ b/src/Shared/Monitoring/BusinessMetrics.cs @@ -5,7 +5,7 @@ namespace MeAjudaAi.Shared.Monitoring; /// -/// Métricas customizadas de negócio para MeAjudaAi +/// Custom business metrics for MeAjudaAi /// [ExcludeFromCodeCoverage] public class BusinessMetrics : IDisposable @@ -21,9 +21,11 @@ public class BusinessMetrics : IDisposable private readonly Gauge _activeUsers; private readonly Gauge _pendingHelpRequests; - public BusinessMetrics() + public const string DefaultMeterName = "MeAjudaAi.Business"; + + public BusinessMetrics(string meterName = DefaultMeterName) { - _meter = new Meter("MeAjudaAi.Business", "1.0.0"); + _meter = new Meter(meterName, "1.0.0"); // User metrics _userRegistrations = _meter.CreateCounter( diff --git a/src/Shared/Resources/Strings.cs b/src/Shared/Resources/Strings.cs new file mode 100644 index 000000000..3a464c1b0 --- /dev/null +++ b/src/Shared/Resources/Strings.cs @@ -0,0 +1,8 @@ +namespace MeAjudaAi.Shared.Resources; + +/// +/// Anchor class para localização de recursos de string. +/// +public class Strings +{ +} diff --git a/src/Shared/Resources/Strings.en.resx b/src/Shared/Resources/Strings.en.resx new file mode 100644 index 000000000..d54308bf2 --- /dev/null +++ b/src/Shared/Resources/Strings.en.resx @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + An unexpected error occurred. Please try again later. + + + The requested resource was not found. + + + You do not have permission to perform this operation. + + diff --git a/src/Shared/Resources/Strings.resx b/src/Shared/Resources/Strings.resx new file mode 100644 index 000000000..2b4291ebb --- /dev/null +++ b/src/Shared/Resources/Strings.resx @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ocorreu um erro inesperado. Por favor, tente novamente mais tarde. + + + O recurso solicitado não foi encontrado. + + + Você não tem permissão para realizar esta operação. + + diff --git a/src/Shared/Utilities/Constants/ModuleNames.cs b/src/Shared/Utilities/Constants/ModuleNames.cs index 711882cb3..d774fe36a 100644 --- a/src/Shared/Utilities/Constants/ModuleNames.cs +++ b/src/Shared/Utilities/Constants/ModuleNames.cs @@ -44,9 +44,9 @@ public static class ModuleNames public const string Bookings = "Bookings"; /// - /// Módulo de notificações - sistema de notificações e comunicação (futuro) + /// Módulo de comunicações - email, SMS, push (implementado na Sprint 9) /// - public const string Notifications = "Notifications"; + public const string Communications = "Communications"; /// /// Módulo de pagamentos - processamento de pagamentos (futuro) @@ -88,7 +88,7 @@ public static class ModuleNames SearchProviders, Locations, Bookings, - Notifications, + Communications, Payments, Reports, Reviews diff --git a/src/Shared/Utilities/PiiMaskingHelper.cs b/src/Shared/Utilities/PiiMaskingHelper.cs index 847a3d936..e71e27718 100644 --- a/src/Shared/Utilities/PiiMaskingHelper.cs +++ b/src/Shared/Utilities/PiiMaskingHelper.cs @@ -23,4 +23,47 @@ public static string MaskUserId(string? userId) return $"{userId[..3]}***{userId[^3..]}"; } + + /// + /// Mascara um endereço de e-mail (ex: jo**@domain.com). + /// + public static string MaskEmail(string? email) + { + if (string.IsNullOrWhiteSpace(email)) return "[EMPTY]"; + var parts = email.Split('@'); + if (parts.Length != 2) return "***@***"; + + var name = parts[0]; + if (name.Length <= 2) return $"*@{parts[1]}"; + + return $"{name[..2]}**@{parts[1]}"; + } + + /// + /// Mascara um número de telefone (ex: +55119****1234). + /// + public static string MaskPhoneNumber(string? phoneNumber) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) return "[EMPTY]"; + + var length = phoneNumber.Length; + if (length <= 4) return "****"; + + if (length <= 8) + { + // Para números curtos, mostra apenas os últimos 2 dígitos + return new string('*', length - 2) + phoneNumber[^2..]; + } + + return $"{phoneNumber[..5]}****{phoneNumber[^4..]}"; + } + + /// + /// Retorna "[REDACTED]" se o dado não for nulo ou vazio, caso contrário retorna "[EMPTY]". + /// + public static string MaskSensitiveData(string? data) + { + if (string.IsNullOrWhiteSpace(data)) return "[EMPTY]"; + return "[REDACTED]"; + } } diff --git a/src/Shared/packages.lock.json b/src/Shared/packages.lock.json index b9ea43444..3526fe313 100644 --- a/src/Shared/packages.lock.json +++ b/src/Shared/packages.lock.json @@ -44,6 +44,16 @@ "resolved": "2.1.72", "contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw==" }, + "EFCore.NamingConventions": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)" + } + }, "FluentValidation": { "type": "Direct", "requested": "[12.1.1, )", diff --git a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/CompressionSecurityMiddlewareTests.cs b/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/CompressionSecurityMiddlewareTests.cs index bfe648fed..ca51808ae 100644 --- a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/CompressionSecurityMiddlewareTests.cs +++ b/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/CompressionSecurityMiddlewareTests.cs @@ -1,6 +1,8 @@ using FluentAssertions; using MeAjudaAi.ApiService.Middlewares; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; using Xunit; using System.IO; @@ -9,10 +11,12 @@ namespace MeAjudaAi.ApiService.Tests.Unit.Middlewares; public class CompressionSecurityMiddlewareTests { private bool _nextCalled; + private readonly Mock> _loggerMock; public CompressionSecurityMiddlewareTests() { _nextCalled = false; + _loggerMock = new Mock>(); } [Fact] @@ -21,225 +25,243 @@ public async Task InvokeAsync_WithoutAuthHeaders_ShouldAllowCompression() // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext(); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("gzip"); + context.Response.Headers.ContainsKey("X-Compression-Disabled").Should().BeFalse(); } [Fact] - public async Task InvokeAsync_WithAuthorizationHeader_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_WithAuthorizationHeader_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext(); context.Request.Headers.Append("Authorization", "Bearer token123"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); + context.Response.Headers["X-Compression-Disabled"].Should().BeEquivalentTo("Security-Policy"); } [Fact] - public async Task InvokeAsync_WithApiKeyHeader_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_WithApiKeyHeader_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext(); context.Request.Headers.Append("X-API-Key", "my-api-key"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); + context.Response.Headers["X-Compression-Disabled"].Should().BeEquivalentTo("Security-Policy"); } [Fact] - public async Task InvokeAsync_PathStartingWithAuth_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithAuth_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/auth/login"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] - public async Task InvokeAsync_PathStartingWithLogin_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithLogin_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/api/login"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] - public async Task InvokeAsync_PathStartingWithToken_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithToken_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/api/token"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] - public async Task InvokeAsync_PathStartingWithRefresh_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithRefresh_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/api/refresh"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] - public async Task InvokeAsync_PathStartingWithLogout_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithLogout_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/api/logout"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] - public async Task InvokeAsync_PathStartingWithConnect_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithConnect_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/connect/token"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] - public async Task InvokeAsync_PathStartingWithOAuth_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithOAuth_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/oauth/authorize"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] - public async Task InvokeAsync_PathStartingWithOpenId_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithOpenId_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/openid/userinfo"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] - public async Task InvokeAsync_PathStartingWithIdentity_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithIdentity_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/identity/userinfo"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] - public async Task InvokeAsync_PathStartingWithUsersProfile_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithUsersProfile_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/users/profile"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] - public async Task InvokeAsync_PathStartingWithUsersMe_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithUsersMe_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/users/me"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] - public async Task InvokeAsync_PathStartingWithAccount_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_PathStartingWithAccount_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/account/settings"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] @@ -248,43 +270,30 @@ public async Task InvokeAsync_NonSensitivePath_ShouldAllowCompression() // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/api/providers"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("gzip"); } [Fact] - public async Task InvokeAsync_NonSensitivePath_ShouldAllowCompression2() - { - // Arrange - var middleware = CreateMiddleware(); - var context = CreateHttpContext("/api/documents"); - - // Act - await middleware.InvokeAsync(context); - - // Assert - _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); - } - - [Fact] - public async Task InvokeAsync_CaseInsensitivePathMatching_ShouldRemoveAcceptEncoding() + public async Task InvokeAsync_CaseInsensitivePathMatching_ShouldSetIdentityEncoding() { // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/AUTH/login"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); - context.Request.Headers.ContainsKey("Accept-Encoding").Should().BeFalse(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("identity"); } [Fact] @@ -293,12 +302,14 @@ public async Task InvokeAsync_NullPath_ShouldAllowCompression() // Arrange var middleware = CreateMiddleware(); var context = CreateHttpContext("/"); + context.Request.Headers.Append("Accept-Encoding", "gzip"); // Act await middleware.InvokeAsync(context); // Assert _nextCalled.Should().BeTrue(); + context.Request.Headers["Accept-Encoding"].Should().BeEquivalentTo("gzip"); } private CompressionSecurityMiddleware CreateMiddleware() @@ -308,7 +319,8 @@ private CompressionSecurityMiddleware CreateMiddleware() { _nextCalled = true; return Task.CompletedTask; - } + }, + logger: _loggerMock.Object ); } diff --git a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/SecurityHeadersMiddlewareTests.cs b/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/SecurityHeadersMiddlewareTests.cs index 4075cba76..ab0e684f6 100644 --- a/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/SecurityHeadersMiddlewareTests.cs +++ b/tests/MeAjudaAi.ApiService.Tests/Unit/Middlewares/SecurityHeadersMiddlewareTests.cs @@ -1,7 +1,7 @@ using FluentAssertions; using MeAjudaAi.ApiService.Middlewares; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Moq; namespace MeAjudaAi.ApiService.Tests.Unit.Middlewares; @@ -10,15 +10,23 @@ namespace MeAjudaAi.ApiService.Tests.Unit.Middlewares; [Trait("Layer", "ApiService")] public class SecurityHeadersMiddlewareTests { + private readonly Mock _mockNext; + private readonly Mock> _mockLogger; + private readonly SecurityHeadersMiddleware _middleware; + + public SecurityHeadersMiddlewareTests() + { + _mockNext = new Mock(); + _mockNext.Setup(n => n(It.IsAny())).Returns(Task.CompletedTask); + _mockLogger = new Mock>(); + _middleware = new SecurityHeadersMiddleware(_mockNext.Object, _mockLogger.Object); + } + [Fact] public void SecurityHeadersMiddleware_ShouldHaveCorrectConstructor() { - // Arrange - var mockNext = new Mock(); - var mockEnvironment = new Mock(); - // Act - var middleware = new SecurityHeadersMiddleware(mockNext.Object, mockEnvironment.Object); + var middleware = new SecurityHeadersMiddleware(_mockNext.Object, _mockLogger.Object); // Assert middleware.Should().NotBeNull(); @@ -29,22 +37,9 @@ public void SecurityHeadersMiddleware_WithNullNext_ShouldThrowArgumentNullExcept { // Arrange RequestDelegate? next = null; - var mockEnvironment = new Mock(); - - // Act & Assert - The primary constructor may not throw, so we check if middleware works correctly - var middleware = new SecurityHeadersMiddleware(next!, mockEnvironment.Object); - middleware.Should().NotBeNull(); - } - - [Fact] - public void SecurityHeadersMiddleware_WithNullEnvironment_ShouldThrowArgumentNullException() - { - // Arrange - var mockNext = new Mock(); - IWebHostEnvironment? environment = null; // Act & Assert - Assert.Throws(() => new SecurityHeadersMiddleware(mockNext.Object, environment!)); + Assert.Throws(() => new SecurityHeadersMiddleware(next!, _mockLogger.Object)); } [Fact] @@ -58,4 +53,103 @@ public void SecurityHeadersMiddleware_ShouldImplementCorrectInterface() middlewareType.IsClass.Should().BeTrue(); middlewareType.IsPublic.Should().BeTrue(); } -} + + [Fact] + public async Task InvokeAsync_ShouldRegisterOnStartingCallback() + { + // Arrange + var setup = SetupMiddlewareContext(); + + // Act + await _middleware.InvokeAsync(setup.MockContext.Object); + + // Assert + setup.MockResponse.Verify(r => r.OnStarting(It.IsAny>(), It.IsAny()), Times.Once); + _mockNext.Verify(n => n(setup.MockContext.Object), Times.Once); + } + + [Fact] + public async Task OnStartingCallback_ShouldAddMissingSecurityHeaders() + { + // Arrange + var setup = SetupMiddlewareContext(); + + // Act + await _middleware.InvokeAsync(setup.MockContext.Object); + + // Simula o início da resposta + setup.OnStartingCallback.Should().NotBeNull(); + await setup.OnStartingCallback!(setup.CapturedState!); + + // Assert + setup.Headers.Should().ContainKey("X-Frame-Options").WhoseValue.Should().ContainSingle("DENY"); + setup.Headers.Should().ContainKey("X-Content-Type-Options").WhoseValue.Should().ContainSingle("nosniff"); + setup.Headers.Should().ContainKey("Referrer-Policy").WhoseValue.Should().ContainSingle("strict-origin-when-cross-origin"); + } + + [Fact] + public async Task OnStartingCallback_ShouldRemoveXPoweredByHeader() + { + // Arrange + var setup = SetupMiddlewareContext(); + setup.Headers.Append("X-Powered-By", "ASP.NET"); + + // Act + await _middleware.InvokeAsync(setup.MockContext.Object); + setup.OnStartingCallback.Should().NotBeNull(); + await setup.OnStartingCallback!(setup.CapturedState!); + + // Assert + setup.Headers.Should().NotContainKey("X-Powered-By"); + } + + [Fact] + public async Task OnStartingCallback_ShouldNotOverwriteExistingHeaders() + { + // Arrange + var setup = SetupMiddlewareContext(); + setup.Headers.Append("X-Frame-Options", "SAMEORIGIN"); + + // Act + await _middleware.InvokeAsync(setup.MockContext.Object); + setup.OnStartingCallback.Should().NotBeNull(); + await setup.OnStartingCallback!(setup.CapturedState!); + + // Assert + setup.Headers.Should().ContainKey("X-Frame-Options").WhoseValue.Should().ContainSingle("SAMEORIGIN"); + } + + private MiddlewareTestSetup SetupMiddlewareContext() + { + var context = new DefaultHttpContext(); + var mockResponse = new Mock(); + var mockContext = new Mock(); + + var headers = new HeaderDictionary(); + mockResponse.SetupGet(r => r.Headers).Returns(headers); + mockContext.SetupGet(c => c.Response).Returns(mockResponse.Object); + + var setup = new MiddlewareTestSetup(mockContext, mockResponse, headers); + + mockResponse.Setup(r => r.OnStarting(It.IsAny>(), It.IsAny())) + .Callback, object>((cb, state) => + { + setup.OnStartingCallback = cb; + setup.CapturedState = state; + }); + + return setup; + } + + private class MiddlewareTestSetup( + Mock mockContext, + Mock mockResponse, + IHeaderDictionary headers) + { + public Mock MockContext { get; } = mockContext; + public Mock MockResponse { get; } = mockResponse; + public IHeaderDictionary Headers { get; } = headers; + public Func? OnStartingCallback { get; set; } + public object? CapturedState { get; set; } + } +} \ No newline at end of file diff --git a/tests/MeAjudaAi.ApiService.Tests/packages.lock.json b/tests/MeAjudaAi.ApiService.Tests/packages.lock.json index c9edaac45..b28192de8 100644 --- a/tests/MeAjudaAi.ApiService.Tests/packages.lock.json +++ b/tests/MeAjudaAi.ApiService.Tests/packages.lock.json @@ -906,6 +906,7 @@ "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", @@ -927,6 +928,41 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -1165,6 +1201,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/tests/MeAjudaAi.Architecture.Tests/packages.lock.json b/tests/MeAjudaAi.Architecture.Tests/packages.lock.json index 8dece8b6b..f6bdef201 100644 --- a/tests/MeAjudaAi.Architecture.Tests/packages.lock.json +++ b/tests/MeAjudaAi.Architecture.Tests/packages.lock.json @@ -801,6 +801,7 @@ "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", @@ -822,6 +823,41 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -1060,6 +1096,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/tests/MeAjudaAi.E2E.Tests/packages.lock.json b/tests/MeAjudaAi.E2E.Tests/packages.lock.json index 2e39fdae7..5b82b34d6 100644 --- a/tests/MeAjudaAi.E2E.Tests/packages.lock.json +++ b/tests/MeAjudaAi.E2E.Tests/packages.lock.json @@ -145,10 +145,10 @@ "Microsoft.Extensions.Primitives": "8.0.0" } }, - "Aspire.Dashboard.Sdk.linux-x64": { + "Aspire.Dashboard.Sdk.win-x64": { "type": "Transitive", "resolved": "13.2.1", - "contentHash": "rUlEhekc+EyDbOcyfWneGBikNvdLuV5UPtOww2KpUOAcO7oBVG70kTJiFzN/UYU3I/5Udc1xoDt2lWIoyEYADQ==" + "contentHash": "KLB9rXwY8kg2taWwxsJFoK0cAuupSZurcv1zTyYMqLyNuwvYYjs65Yz3g/cgh22QlUfOT3tOh+Jzk5MdJhy5+w==" }, "Aspire.Hosting": { "type": "Transitive", @@ -374,10 +374,10 @@ "System.IO.Hashing": "10.0.3" } }, - "Aspire.Hosting.Orchestration.linux-x64": { + "Aspire.Hosting.Orchestration.win-x64": { "type": "Transitive", "resolved": "13.2.1", - "contentHash": "LcC21cYVVsTDSQe4B0i7X2q1U8u5Bl+X53wPfucfW4YlQhAGzG2FajVfa5PRDduGlp5mjtgjh2vDO4oEBfpSUg==" + "contentHash": "39lRUH4WuCsBaYB7fZH1/r81SSJIXrA8WphBlAdP1QT95+1sKQHzXJuXU4nzKpBLv4oZmjcWzvA+FDMGZbWmkw==" }, "AspNetCore.HealthChecks.Rabbitmq": { "type": "Transitive", @@ -1607,6 +1607,7 @@ "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", @@ -1625,13 +1626,13 @@ "meajudaai.apphost": { "type": "Project", "dependencies": { - "Aspire.Dashboard.Sdk.linux-x64": "[13.2.1, )", + "Aspire.Dashboard.Sdk.win-x64": "[13.2.1, )", "Aspire.Hosting.AppHost": "[13.2.1, )", "Aspire.Hosting.Azure.AppContainers": "[13.2.1, )", "Aspire.Hosting.Azure.PostgreSQL": "[13.2.1, )", "Aspire.Hosting.JavaScript": "[13.2.1, )", "Aspire.Hosting.Keycloak": "[13.2.1-preview.1.26180.6, )", - "Aspire.Hosting.Orchestration.linux-x64": "[13.2.1, )", + "Aspire.Hosting.Orchestration.win-x64": "[13.2.1, )", "Aspire.Hosting.PostgreSQL": "[13.2.1, )", "Aspire.Hosting.RabbitMQ": "[13.2.1, )", "Aspire.Hosting.Redis": "[13.2.1, )", @@ -1653,6 +1654,41 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -1933,6 +1969,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/tests/MeAjudaAi.Integration.Tests/Base/BaseApiTest.cs b/tests/MeAjudaAi.Integration.Tests/Base/BaseApiTest.cs index 005b36401..a024c427d 100644 --- a/tests/MeAjudaAi.Integration.Tests/Base/BaseApiTest.cs +++ b/tests/MeAjudaAi.Integration.Tests/Base/BaseApiTest.cs @@ -2,20 +2,26 @@ using MeAjudaAi.ApiService; using MeAjudaAi.Integration.Tests.Infrastructure; using MeAjudaAi.Integration.Tests.Mocks; +using MeAjudaAi.Modules.Communications.Infrastructure.Persistence; using MeAjudaAi.Modules.Documents.Infrastructure.Persistence; +using MeAjudaAi.Modules.SearchProviders.Domain.Entities; +using MeAjudaAi.Modules.SearchProviders.Domain.Enums; +using MeAjudaAi.Modules.SearchProviders.Domain.ValueObjects; +using MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence; +using MeAjudaAi.Shared.Geolocation; using MeAjudaAi.Modules.Documents.Tests; using MeAjudaAi.Modules.Locations.Infrastructure.ExternalApis.Clients; using MeAjudaAi.Modules.Locations.Infrastructure.Persistence; using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; -using MeAjudaAi.Modules.SearchProviders.Infrastructure.Persistence; using MeAjudaAi.Modules.ServiceCatalogs.Infrastructure.Persistence; using MeAjudaAi.Modules.Users.Infrastructure.Persistence; -using MeAjudaAi.Shared.Geolocation; using MeAjudaAi.Shared.Jobs; using MeAjudaAi.Shared.Serialization; -using MeAjudaAi.Shared.Tests.TestInfrastructure.Handlers; using MeAjudaAi.Shared.Tests.Extensions; using MeAjudaAi.Shared.Tests.TestInfrastructure.Mocks; +using MeAjudaAi.Shared.Tests.TestInfrastructure.Handlers; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Modules.Locations.Infrastructure.ExternalApis.Clients.Interfaces; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -24,9 +30,10 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Xunit; +using System.Runtime.CompilerServices; -// Disable parallel execution to prevent race conditions when using shared database containers -[assembly: CollectionBehavior(DisableTestParallelization = true)] +// Enable parallel execution by isolating databases per test class +// [assembly: CollectionBehavior(DisableTestParallelization = true)] namespace MeAjudaAi.Integration.Tests.Base; @@ -43,731 +50,438 @@ public enum TestModule ServiceCatalogs = 1 << 3, Locations = 1 << 4, SearchProviders = 1 << 5, - All = Users | Providers | Documents | ServiceCatalogs | Locations | SearchProviders + Communications = 1 << 6, + All = Users | Providers | Documents | ServiceCatalogs | Locations | SearchProviders | Communications } /// /// Classe base unificada para testes de integração com suporte a autenticação baseada em instância. -/// Elimina condições de corrida e instabilidade causadas por estado estático. -/// Cria containers individuais para máxima compatibilidade com CI. -/// Aplica migrations apenas dos módulos necessários (especificados via RequiredModules). /// public abstract class BaseApiTest : IAsyncLifetime { - // Semáforo estático para sincronizar aplicação de migrations entre testes paralelos + [ModuleInitializer] + public static void InitializeNpgsql() + { + // ⚠️ CRÍTICO: Configura Npgsql ANTES de qualquer operação de banco no processo de teste + // Correção para compatibilidade DateTime UTC com PostgreSQL timestamp + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + } + private static readonly SemaphoreSlim MigrationLock = new(1, 1); private SimpleDatabaseFixture? _databaseFixture; private WireMockFixture? _wireMockFixture; - private WebApplicationFactory? _factory; + protected WebApplicationFactory? _factory; + private string? _databaseName; protected HttpClient Client { get; private set; } = null!; protected IServiceProvider Services => _factory!.Services; protected ITestAuthenticationConfiguration AuthConfig { get; private set; } = null!; protected WireMockFixture WireMock => _wireMockFixture ?? throw new InvalidOperationException("WireMock not initialized"); + protected string DatabaseName => _databaseName ??= $"test_{GetType().Name.ToLowerInvariant()}_{Guid.NewGuid().ToString("n")[..8]}"; - /// - /// Especifica quais módulos este teste precisa (migrations serão aplicadas apenas para estes). - /// Override em classes derivadas para otimizar tempo de inicialização. - /// Default: All (comportamento legado para compatibilidade). - /// protected virtual TestModule RequiredModules => TestModule.All; + protected virtual bool UseMockGeographicValidation => true; - /// - /// HTTP header name for user location (format: "City|State") - /// protected const string UserLocationHeader = "X-User-Location"; - - /// - /// API endpoint for providers listing - /// protected const string ProvidersEndpoint = "/api/v1/providers"; - - /// - /// Controls whether to use mock geographic validation service. - /// Set to false in IBGE-focused tests to use real service with WireMock. - /// - protected virtual bool UseMockGeographicValidation => true; + public static readonly Guid TestServiceId = Guid.Parse("d3b07384-d9a6-4475-bd61-1c3906d4e135"); public async ValueTask InitializeAsync() { - // Define variáveis de ambiente para testes + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing"); Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing"); Environment.SetEnvironmentVariable("INTEGRATION_TESTS", "true"); - // Inicializa WireMock antes da aplicação para que as URLs mockadas estejam disponíveis _wireMockFixture = new WireMockFixture(); await _wireMockFixture.StartAsync(); - // Configure environment variables with dynamic WireMock URLs - var wireMockUrl = _wireMockFixture.BaseUrl; - Environment.SetEnvironmentVariable("Locations__ExternalApis__ViaCep__BaseUrl", wireMockUrl); - Environment.SetEnvironmentVariable("Locations__ExternalApis__BrasilApi__BaseUrl", wireMockUrl); - Environment.SetEnvironmentVariable("Locations__ExternalApis__OpenCep__BaseUrl", wireMockUrl); - Environment.SetEnvironmentVariable("Locations__ExternalApis__Nominatim__BaseUrl", wireMockUrl); - Environment.SetEnvironmentVariable("Locations__ExternalApis__IBGE__BaseUrl", $"{wireMockUrl}/api/v1/localidades"); - _databaseFixture = new SimpleDatabaseFixture(); await _databaseFixture.InitializeAsync(); -#pragma warning disable CA2000 // Dispose é gerenciado por IAsyncLifetime.DisposeAsync + // Criar banco de dados isolado para esta classe de teste + await _databaseFixture.CreateDatabaseAsync(DatabaseName); + var connectionString = _databaseFixture.GetConnectionString(DatabaseName); + + #pragma warning disable CA2000 _factory = new WebApplicationFactory() -#pragma warning restore CA2000 + #pragma warning restore CA2000 .WithWebHostBuilder(builder => { - // Resolve ApiService content root using robust path resolution var apiServicePath = ResolveApiServicePath(); - if (!string.IsNullOrEmpty(apiServicePath)) - { - builder.UseContentRoot(apiServicePath); - } - else - { - Console.Error.WriteLine("WARNING: Could not resolve ApiService content root path. Configuration files may not load correctly."); - } + if (!string.IsNullOrEmpty(apiServicePath)) builder.UseContentRoot(apiServicePath); builder.UseEnvironment("Testing"); + builder.UseSetting("https_port", "443"); + + var wireMockUrl = _wireMockFixture!.BaseUrl; + + // Configurar URLs do WireMock nos provedores de CEP específicos para esta instância + builder.UseSetting("Locations:ExternalApis:ViaCep:BaseUrl", wireMockUrl); + builder.UseSetting("Locations:ExternalApis:BrasilApi:BaseUrl", wireMockUrl); + builder.UseSetting("Locations:ExternalApis:OpenCep:BaseUrl", wireMockUrl); + builder.UseSetting("Locations:ExternalApis:Nominatim:BaseUrl", wireMockUrl); + builder.UseSetting("Locations:ExternalApis:IBGE:BaseUrl", $"{wireMockUrl}/api/v1/localidades"); - // Inject test configuration directly to ensure consistent behavior across environments builder.ConfigureAppConfiguration((context, config) => { config.AddInMemoryCollection(new Dictionary { ["Logging:LogLevel:Default"] = "Warning", ["Logging:LogLevel:Microsoft.AspNetCore"] = "Warning", - ["Logging:LogLevel:Microsoft.EntityFrameworkCore"] = "Warning", - ["RateLimit:DefaultRequestsPerMinute"] = "10000", - ["RateLimit:AuthRequestsPerMinute"] = "10000", - ["RateLimit:ProviderRequestsPerMinute"] = "10000", - ["RateLimit:SearchRequestsPerMinute"] = "10000", - ["RateLimit:WindowInSeconds"] = "60", - ["AdvancedRateLimit:General:Enabled"] = "false", - ["Cache:Enabled"] = "false", - ["Caching:Enabled"] = "false", - ["Postgres:ConnectionString"] = _databaseFixture.ConnectionString, - ["ConnectionStrings:DefaultConnection"] = _databaseFixture.ConnectionString, + ["Logging:LogLevel:Microsoft.EntityFrameworkCore"] = "Information", + ["Postgres:ConnectionString"] = connectionString, + ["ConnectionStrings:DefaultConnection"] = connectionString, ["RabbitMQ:Enabled"] = "false", ["Messaging:Enabled"] = "false", ["Messaging:Provider"] = "Mock", ["Keycloak:Enabled"] = "false", - ["Keycloak:ClientSecret"] = "test-secret", - ["Keycloak:AdminUsername"] = "test-admin", - ["Keycloak:AdminPassword"] = "test-password", ["FeatureManagement:GeographicRestriction"] = "true", - ["FeatureManagement:PushNotifications"] = "false", - ["FeatureManagement:StripePayments"] = "false", - ["FeatureManagement:MaintenanceMode"] = "false", - // Geographic restriction: Cities with states in "City|State" format - // This ensures proper validation when both city and state headers are provided + ["Locations:ExternalApis:ViaCep:BaseUrl"] = wireMockUrl, + ["Locations:ExternalApis:BrasilApi:BaseUrl"] = wireMockUrl, + ["Locations:ExternalApis:OpenCep:BaseUrl"] = wireMockUrl, + ["Locations:ExternalApis:Nominatim:BaseUrl"] = wireMockUrl, + ["Locations:ExternalApis:IBGE:BaseUrl"] = $"{wireMockUrl}/api/v1/localidades", + ["GeographicRestriction:AllowedCities:0"] = "Muriaé", + ["GeographicRestriction:AllowedCities:1"] = "Itaperuna", + ["GeographicRestriction:AllowedCities:2"] = "Linhares", ["GeographicRestriction:AllowedStates:0"] = "MG", - ["GeographicRestriction:AllowedStates:1"] = "ES", - ["GeographicRestriction:AllowedStates:2"] = "RJ", - ["GeographicRestriction:AllowedCities:0"] = "Muriaé|MG", - ["GeographicRestriction:AllowedCities:1"] = "Itaperuna|RJ", - ["GeographicRestriction:AllowedCities:2"] = "Linhares|ES", - ["GeographicRestriction:BlockedMessage"] = "Serviço indisponível na sua região. Disponível apenas em: {allowedRegions}" + ["GeographicRestriction:AllowedStates:1"] = "RJ", + ["GeographicRestriction:AllowedStates:2"] = "ES", + ["Cache:Enabled"] = "false", + ["RateLimit:Enabled"] = "false", + ["AdvancedRateLimit:General:Enabled"] = "false" }); }); - builder.ConfigureServices(services => { - // Substitui banco de dados por container de teste - Remove todos os serviços relacionados ao DbContext + // Forçar o uso de cache em memória para IDistributedCache + services.AddDistributedMemoryCache(); + RemoveDbContextRegistrations(services); RemoveDbContextRegistrations(services); RemoveDbContextRegistrations(services); RemoveDbContextRegistrations(services); RemoveDbContextRegistrations(services); RemoveDbContextRegistrations(services); + RemoveDbContextRegistrations(services); - // Reconfigure CEP provider HttpClients to use WireMock - ReconfigureCepProviderClients(services); + AddTestDbContext(services, "users", "MeAjudaAi.Modules.Users.Infrastructure"); + AddTestDbContext(services, "providers", "MeAjudaAi.Modules.Providers.Infrastructure"); + AddTestDbContext(services, "documents", "MeAjudaAi.Modules.Documents.Infrastructure"); + AddTestDbContext(services, "service_catalogs", "MeAjudaAi.Modules.ServiceCatalogs.Infrastructure"); + AddTestDbContext(services, "locations", "MeAjudaAi.Modules.Locations.Infrastructure"); + AddTestDbContext(services, "search_providers", "MeAjudaAi.Modules.SearchProviders.Infrastructure"); + AddTestDbContext(services, "communications", "MeAjudaAi.Modules.Communications.Infrastructure"); - // Adiciona contextos de banco de dados para testes - services.AddDbContext(options => - { - options.UseNpgsql(_databaseFixture.ConnectionString, npgsqlOptions => - { - npgsqlOptions.MigrationsAssembly("MeAjudaAi.Modules.Users.Infrastructure"); - npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "users"); - }); - options.EnableSensitiveDataLogging(); - options.ConfigureWarnings(warnings => - warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); - }); - - services.AddDbContext(options => - { - options.UseNpgsql(_databaseFixture.ConnectionString, npgsqlOptions => - { - npgsqlOptions.MigrationsAssembly("MeAjudaAi.Modules.Providers.Infrastructure"); - npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "providers"); - }); - options.EnableSensitiveDataLogging(); - options.ConfigureWarnings(warnings => - warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); - }); - - services.AddDbContext(options => - { - options.UseNpgsql(_databaseFixture.ConnectionString, npgsqlOptions => - { - npgsqlOptions.MigrationsAssembly("MeAjudaAi.Modules.Documents.Infrastructure"); - npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "documents"); - }); - options.EnableSensitiveDataLogging(); - options.ConfigureWarnings(warnings => - warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); - }); - - services.AddDbContext(options => - { - options.UseNpgsql(_databaseFixture.ConnectionString, npgsqlOptions => - { - npgsqlOptions.MigrationsAssembly("MeAjudaAi.Modules.ServiceCatalogs.Infrastructure"); - npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "service_catalogs"); - }); - options.UseSnakeCaseNamingConvention(); - options.EnableSensitiveDataLogging(); - options.ConfigureWarnings(warnings => - warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); - }); - - services.AddDbContext(options => - { - options.UseNpgsql(_databaseFixture.ConnectionString, npgsqlOptions => - { - npgsqlOptions.MigrationsAssembly("MeAjudaAi.Modules.Locations.Infrastructure"); - npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "locations"); - }); - options.EnableSensitiveDataLogging(); - options.ConfigureWarnings(warnings => - warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); - }); - - services.AddDbContext(options => - { - options.UseNpgsql(_databaseFixture.ConnectionString, npgsqlOptions => - { - npgsqlOptions.MigrationsAssembly("MeAjudaAi.Modules.SearchProviders.Infrastructure"); - npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "search_providers"); - }); - options.EnableSensitiveDataLogging(); - options.ConfigureWarnings(warnings => - warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); - }); - - // Adiciona mocks de serviços para testes - // TODO: Investigar problema de SAS token do Azurite e migrar de Mock para emulador Azurite - // Atualmente usando Mock porque Azurite retorna erros 500 em testes de upload (problema CanGenerateSasUri). - // Ver issue de rastreamento: https://github.com/frigini/MeAjudaAi/issues/1 - // Passos de investigação: verificar logs Azurite, testar criação de container manualmente, validar compatibilidade Azurite 3.33.0 com SAS tokens services.AddDocumentsTestServices(useAzurite: false); - - // Mock do BackgroundJobService para evitar execução de jobs em testes services.AddSingleton(); - - // Adiciona HttpContextAccessor necessário para alguns handlers services.AddHttpContextAccessor(); - // Conditionally replace geographic validation with mock - // IBGE-focused tests can override UseMockGeographicValidation to use real service with WireMock if (UseMockGeographicValidation) { - // Remove ALL instances of IGeographicValidationService - var geoValidationDescriptors = services - .Where(d => d.ServiceType == typeof(IGeographicValidationService)) - .ToList(); - - foreach (var descriptor in geoValidationDescriptors) - { - services.Remove(descriptor); - } - - // Registra mock com cidades piloto padrão (Scoped para isolamento entre testes) + var geoValidationDescriptors = services.Where(d => d.ServiceType == typeof(IGeographicValidationService)).ToList(); + foreach (var descriptor in geoValidationDescriptors) services.Remove(descriptor); services.AddScoped(); } - // Adiciona autenticação de teste baseada em instância para evitar estado estático services.RemoveRealAuthentication(); services.AddInstanceTestAuthentication(); - - // Remove ClaimsTransformation que causa travamentos nos testes - var claimsTransformationDescriptor = services.FirstOrDefault(d => - d.ServiceType == typeof(IClaimsTransformation)); - if (claimsTransformationDescriptor != null) - services.Remove(claimsTransformationDescriptor); - }); - - // Habilita logging detalhado para debug - builder.ConfigureLogging(logging => - { - logging.ClearProviders(); - logging.AddConsole(); - logging.SetMinimumLevel(LogLevel.Debug); - logging.AddFilter("Microsoft.AspNetCore", LogLevel.Debug); - logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Debug); - logging.AddFilter("MeAjudaAi", LogLevel.Debug); }); }); - Client = _factory.CreateClient(); - - // Obtém a configuração de autenticação da instância do container DI + var options = new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = true, + BaseAddress = new Uri("https://localhost") + }; + Client = _factory.CreateClient(options); AuthConfig = _factory.Services.GetRequiredService(); - // Aplica migrações apenas dos módulos necessários (otimização de performance) using var scope = _factory.Services.CreateScope(); var logger = scope.ServiceProvider.GetService>(); - await ApplyRequiredModuleMigrationsAsync(scope.ServiceProvider, logger); } - private static async Task SeedTestDataAsync(LocationsDbContext locationsContext, ILogger? logger) + private void AddTestDbContext(IServiceCollection services, string schema, string assembly) where TContext : DbContext { - // Seed allowed cities for GeographicRestriction tests - // These match the cities configured in test configuration (lines 122-124) - var testCities = new[] + services.AddDbContext(options => { - new { IbgeCode = 3143906, CityName = "Muriaé", State = "MG" }, - new { IbgeCode = 3302504, CityName = "Itaperuna", State = "RJ" }, - new { IbgeCode = 3203205, CityName = "Linhares", State = "ES" } - }; - - var citiesToAdd = new List(); - - foreach (var city in testCities) - { - // Check if city already exists to avoid duplicate key errors - var exists = await locationsContext.AllowedCities - .AnyAsync(c => c.CityName == city.CityName && c.StateSigla == city.State); - - if (!exists) + var connectionString = _databaseFixture!.GetConnectionString(DatabaseName); + options.UseNpgsql(connectionString, npgsqlOptions => { - // Use EF Core entity instead of raw SQL to avoid case sensitivity issues - var allowedCity = new MeAjudaAi.Modules.Locations.Domain.Entities.AllowedCity( - city.CityName, - city.State, - "system", - city.IbgeCode); - - citiesToAdd.Add(allowedCity); - } - else + npgsqlOptions.UseNetTopologySuite(); + npgsqlOptions.MigrationsAssembly(assembly); + npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", schema); + }); + + options.UseSnakeCaseNamingConvention(); + + // Suprime aviso de mudanças pendentes no modelo durante testes de integração. + // Útil para ignorar drifts menores de convenção de nomes (ex: PK_ casing) sem forçar novas migrations em dev. + options.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); + + if (Environment.GetEnvironmentVariable("ENABLE_SENSITIVE_LOGGING") == "true") { - logger?.LogDebug("City {City}/{State} already exists, skipping", city.CityName, city.State); + options.EnableSensitiveDataLogging(); } - } - - if (citiesToAdd.Count > 0) - { - locationsContext.AllowedCities.AddRange(citiesToAdd); - await locationsContext.SaveChangesAsync(); - logger?.LogInformation("✅ Seeded {Count} test cities", citiesToAdd.Count); - } - - var totalCount = await locationsContext.AllowedCities.CountAsync(); - logger?.LogInformation("Total cities in database: {Count}", totalCount); + }); } - /// - /// Aplica migrations apenas dos módulos especificados em RequiredModules. - /// Otimiza tempo de inicialização e evita race conditions ao aplicar apenas o necessário. - /// private async Task ApplyRequiredModuleMigrationsAsync(IServiceProvider serviceProvider, ILogger? logger) { var modules = RequiredModules; - - // Se nenhum módulo especificado, retorna sem fazer nada - if (modules == TestModule.None) - { - logger?.LogInformation("ℹ️ No modules required - skipping migrations"); - return; - } + if (modules == TestModule.None) return; - // Implicit dependencies: satisfying cross-module database requirements - // 1. Providers module depends on ServiceCatalogs during migration (AddProviderProfileEnhancements) - if (modules.HasFlag(TestModule.Providers) && !modules.HasFlag(TestModule.ServiceCatalogs)) + // Dependências implícitas + if (modules.HasFlag(TestModule.SearchProviders)) { - logger?.LogInformation("🔄 Adding implicit ServiceCatalogs dependency for Providers module"); - modules |= TestModule.ServiceCatalogs; + if (!modules.HasFlag(TestModule.Providers)) modules |= TestModule.Providers; + if (!modules.HasFlag(TestModule.ServiceCatalogs)) modules |= TestModule.ServiceCatalogs; + if (!modules.HasFlag(TestModule.Locations)) modules |= TestModule.Locations; } - // 2. SearchProviders depends on Providers and ServiceCatalogs for its read model - if (modules.HasFlag(TestModule.SearchProviders)) + if (modules.HasFlag(TestModule.Providers)) { - if (!modules.HasFlag(TestModule.Providers)) - { - logger?.LogInformation("🔄 Adding implicit Providers dependency for SearchProviders module"); - modules |= TestModule.Providers; - } - if (!modules.HasFlag(TestModule.ServiceCatalogs)) - { - logger?.LogInformation("🔄 Adding implicit ServiceCatalogs dependency for SearchProviders module"); - modules |= TestModule.ServiceCatalogs; - } + if (!modules.HasFlag(TestModule.ServiceCatalogs)) modules |= TestModule.ServiceCatalogs; } - logger?.LogInformation("🔄 Applying migrations for modules: {Modules}", modules); - - // Usa semáforo para garantir que apenas um teste aplique migrations por vez - // Evita race conditions e erro "57P01: terminating connection due to administrator command" + // Lock para evitar que múltiplas migrações ocorram simultaneamente no MESMO banco, + // mas como os bancos agora são isolados, o lock serve apenas como precaução + // caso algo tente acessar o banco master simultaneamente. await MigrationLock.WaitAsync(); try { - // Garante estado limpo do banco de dados apenas uma vez - DbContext anyContext; - if (modules.HasFlag(TestModule.Users)) - anyContext = serviceProvider.GetRequiredService(); - else if (modules.HasFlag(TestModule.Documents)) - anyContext = serviceProvider.GetRequiredService(); - else if (modules.HasFlag(TestModule.Providers)) - anyContext = serviceProvider.GetRequiredService(); - else if (modules.HasFlag(TestModule.ServiceCatalogs)) - anyContext = serviceProvider.GetRequiredService(); - else if (modules.HasFlag(TestModule.Locations)) - anyContext = serviceProvider.GetRequiredService(); - else - anyContext = serviceProvider.GetRequiredService(); - - await EnsureCleanDatabaseAsync(anyContext, logger); - - // Aplica migrations dos módulos necessários - if (modules.HasFlag(TestModule.Users)) - { - var context = serviceProvider.GetRequiredService(); - await ApplyMigrationForContextAsync(context, "Users", logger, "UsersDbContext"); - await context.Database.CloseConnectionAsync(); - } - - if (modules.HasFlag(TestModule.ServiceCatalogs)) + // Apply migrations in production priority order: Users -> ServiceCatalogs -> Locations -> Documents -> Providers -> Communications + if (modules.HasFlag(TestModule.Users)) await ApplyMigrationForContextAsync(serviceProvider.GetRequiredService(), "Users", logger); + if (modules.HasFlag(TestModule.ServiceCatalogs)) await ApplyMigrationForContextAsync(serviceProvider.GetRequiredService(), "ServiceCatalogs", logger); + if (modules.HasFlag(TestModule.Locations)) await ApplyMigrationForContextAsync(serviceProvider.GetRequiredService(), "Locations", logger); + if (modules.HasFlag(TestModule.Documents)) await ApplyMigrationForContextAsync(serviceProvider.GetRequiredService(), "Documents", logger); + if (modules.HasFlag(TestModule.Providers)) await ApplyMigrationForContextAsync(serviceProvider.GetRequiredService(), "Providers", logger); + if (modules.HasFlag(TestModule.Communications)) await ApplyMigrationForContextAsync(serviceProvider.GetRequiredService(), "Communications", logger); + + if (modules.HasFlag(TestModule.SearchProviders)) { - var context = serviceProvider.GetRequiredService(); - await ApplyMigrationForContextAsync(context, "ServiceCatalogs", logger, "ServiceCatalogsDbContext"); - await context.Database.CloseConnectionAsync(); + var context = serviceProvider.GetRequiredService(); + await context.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS postgis;"); + await ApplyMigrationForContextAsync(context, "SearchProviders", logger); } - if (modules.HasFlag(TestModule.Providers)) - { - var context = serviceProvider.GetRequiredService(); - await ApplyMigrationForContextAsync(context, "Providers", logger, "ProvidersDbContext"); - await context.Database.CloseConnectionAsync(); - } + await SeedTestDataAsync(serviceProvider, logger); + logger?.LogInformation("✅ Migrations and Seeding applied successfully to {DbName}", DatabaseName); + } + finally { MigrationLock.Release(); } + } - if (modules.HasFlag(TestModule.Documents)) - { - var context = serviceProvider.GetRequiredService(); - await ApplyMigrationForContextAsync(context, "Documents", logger, "DocumentsDbContext"); - await context.Database.CloseConnectionAsync(); - } + private async Task SeedTestDataAsync(IServiceProvider serviceProvider, ILogger? logger) + { + var modules = RequiredModules; - if (modules.HasFlag(TestModule.Locations)) + if (modules.HasFlag(TestModule.Locations) || modules.HasFlag(TestModule.SearchProviders)) + { + var locationsContext = serviceProvider.GetRequiredService(); + var testCities = new[] { - var context = serviceProvider.GetRequiredService(); - await ApplyMigrationForContextAsync(context, "Locations", logger, "LocationsDbContext"); - - // Seed test data for allowed cities (required for GeographicRestriction tests) - // Must be called BEFORE CloseConnectionAsync to use the already-open connection - await SeedTestDataAsync(context, logger); - - await context.Database.CloseConnectionAsync(); - } + new { IbgeCode = 3143906, CityName = "Muriaé", State = "MG" }, + new { IbgeCode = 3302504, CityName = "Itaperuna", State = "RJ" }, + new { IbgeCode = 3203205, CityName = "Linhares", State = "ES" } + }; - if (modules.HasFlag(TestModule.SearchProviders)) + foreach (var city in testCities) { - var context = serviceProvider.GetRequiredService(); - - // Garante que a extensão PostGIS exista (necessária para tipos geométricos) - // Isso é necessário porque o EnsureDeletedAsync apaga o banco de dados e a extensão - // E precisamos dela antes das migrations do SearchProviders se elas usarem tipos geométricos - try - { - await context.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS postgis;"); - } - catch (Npgsql.PostgresException ex) + if (!await locationsContext.AllowedCities.AnyAsync(c => c.CityName == city.CityName && c.StateSigla == city.State)) { - logger?.LogWarning(ex, "⚠️ Failed to explicitly create PostGIS extension. Migrations might fail if not included."); + locationsContext.AllowedCities.Add(new MeAjudaAi.Modules.Locations.Domain.Entities.AllowedCity(city.CityName, city.State, "system", city.IbgeCode)); } - - await ApplyMigrationForContextAsync(context, "SearchProviders", logger, "SearchProvidersDbContext"); - await context.Database.CloseConnectionAsync(); } - - logger?.LogInformation("✅ Migrations applied for required modules"); + await locationsContext.SaveChangesAsync(); } - finally + + if (modules.HasFlag(TestModule.SearchProviders)) { - MigrationLock.Release(); + var searchContext = serviceProvider.GetRequiredService(); + if (!await searchContext.SearchableProviders.AnyAsync()) + { + var closeProvider = MeAjudaAi.Modules.SearchProviders.Domain.Entities.SearchableProvider.Create(Guid.NewGuid(), "SP Close Provider", "sp-close", new GeoPoint(-23.5501, -46.6330), ESubscriptionTier.Gold); + closeProvider.UpdateServices(new[] { TestServiceId }); + + var providers = new List + { + closeProvider, // ~50m from center (-23.5505, -46.6333) with TestServiceId + MeAjudaAi.Modules.SearchProviders.Domain.Entities.SearchableProvider.Create(Guid.NewGuid(), "SP Nearby Provider", "sp-nearby", new GeoPoint(-23.5550, -46.6400), ESubscriptionTier.Standard), // ~850m from center + MeAjudaAi.Modules.SearchProviders.Domain.Entities.SearchableProvider.Create(Guid.NewGuid(), "SP Far Provider", "sp-far", new GeoPoint(-23.6000, -46.7000), ESubscriptionTier.Free) // ~8km from center + }; + searchContext.SearchableProviders.AddRange(providers); + await searchContext.SaveChangesAsync(); + } } } - /// - /// Garante que o banco de dados está limpo antes de aplicar migrations. - /// private static async Task EnsureCleanDatabaseAsync(DbContext context, ILogger? logger) { const int maxRetries = 10; - var baseDelay = TimeSpan.FromSeconds(1); - for (int attempt = 1; attempt <= maxRetries; attempt++) { - try - { - await context.Database.EnsureDeletedAsync(); - logger?.LogInformation("🧹 Database cleaned (attempt {Attempt})", attempt); - break; - } - catch (Npgsql.PostgresException ex) when (ex.SqlState == "57P03") // database starting up - { - if (attempt == maxRetries) + try + { + // Clear all connection pools to prevent "database in use" errors + if (attempt == 1) { - logger?.LogError(ex, "❌ PostgreSQL still initializing after {MaxRetries} attempts", maxRetries); - throw new InvalidOperationException($"PostgreSQL not ready after {maxRetries} attempts", ex); + Npgsql.NpgsqlConnection.ClearAllPools(); } - var delay = baseDelay * attempt; - logger?.LogWarning("⚠️ PostgreSQL initializing... Attempt {Attempt}/{MaxRetries}. Waiting {Delay}s", - attempt, maxRetries, delay.TotalSeconds); - await Task.Delay(delay); + await context.Database.EnsureDeletedAsync(); + break; + } + catch (Exception ex) when (IsTransientException(ex)) + { + if (attempt == maxRetries) throw; + + var sqlState = (ex as Npgsql.PostgresException)?.SqlState ?? + (ex.InnerException as Npgsql.PostgresException)?.SqlState ?? "Unknown"; + + logger?.LogWarning("⚠️ Database cleanup attempt {Attempt}/{Max} due to transient error {SqlState}. Message: {Message}", + attempt, maxRetries, sqlState, ex.Message); + + await Task.Delay(1000 * attempt); } catch (Exception ex) { - logger?.LogError(ex, "❌ Failed to clean database: {Message}", ex.Message); - throw new InvalidOperationException("Failed to clean database before tests", ex); + logger?.LogError(ex, "❌ Deterministic error during database cleanup on attempt {Attempt}", attempt); + throw; } } } - public async ValueTask DisposeAsync() + private static bool IsTransientException(Exception ex) { - Client?.Dispose(); - _factory?.Dispose(); - if (_databaseFixture != null) - await _databaseFixture.DisposeAsync(); - if (_wireMockFixture != null) - await _wireMockFixture.DisposeAsync(); - } + // Direct PostgresException + if (ex is Npgsql.PostgresException pgEx && (pgEx.SqlState == "57P03" || pgEx.SqlState == "57P01" || pgEx.SqlState == "55006" || pgEx.SqlState == "53300")) + return true; - /// - /// Remove DbContextOptions e DbContext registrations do DI container. - /// - private static void RemoveDbContextRegistrations(IServiceCollection services) where TContext : DbContext - { - var optionsDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); - if (optionsDescriptor != null) - services.Remove(optionsDescriptor); + // EF Core often wraps transient errors into InvalidOperationException + if (ex is InvalidOperationException && ex.Message.Contains("transient failure") && ex.InnerException != null) + return IsTransientException(ex.InnerException); - var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(TContext)); - if (contextDescriptor != null) - services.Remove(contextDescriptor); - } - - /// - /// Reconfigura os HttpClients dos provedores de CEP para usar o WireMock ao invés das APIs reais. - /// - private void ReconfigureCepProviderClients(IServiceCollection services) - { - // Configure HttpClients to point to WireMock - // AddHttpClient will replace existing registrations if called again - services.AddHttpClient(client => - { - client.BaseAddress = new Uri(_wireMockFixture!.BaseUrl); - }); - - services.AddHttpClient(client => - { - client.BaseAddress = new Uri(_wireMockFixture!.BaseUrl); - }); - - services.AddHttpClient(client => - { - client.BaseAddress = new Uri(_wireMockFixture!.BaseUrl); - }); - - services.AddHttpClient(client => - { - client.BaseAddress = new Uri(_wireMockFixture!.BaseUrl + "/api/v1/localidades/"); - }); + // DbUpdateException often wraps Npgsql exceptions + if (ex is Microsoft.EntityFrameworkCore.DbUpdateException && ex.InnerException != null) + return IsTransientException(ex.InnerException); - services.AddHttpClient(client => - { - client.BaseAddress = new Uri(_wireMockFixture!.BaseUrl); - }); + return false; } - /// - /// Aplica migrações para um DbContext específico com tratamento de erros padronizado. - /// Inclui retry logic para erro "57P01" que ocorre quando PostgreSQL termina conexão - /// após instalar extensões (ex: PostGIS no SearchProviders). - /// - private static async Task ApplyMigrationForContextAsync( - TContext context, - string moduleName, - ILogger? logger, - string? description = null) where TContext : DbContext + private static async Task ApplyMigrationForContextAsync(TContext context, string moduleName, ILogger? logger) where TContext : DbContext { - const int maxRetries = 3; - const int delayMs = 1000; - - for (int attempt = 1; attempt <= maxRetries; attempt++) + Exception? lastException = null; + for (int attempt = 1; attempt <= 3; attempt++) { - try - { - var message = description != null - ? $"🔄 Applying {moduleName} module migrations ({description})... (attempt {attempt}/{maxRetries})" - : $"🔄 Applying {moduleName} module migrations... (attempt {attempt}/{maxRetries})"; - logger?.LogInformation(message); - - await context.Database.MigrateAsync(); - logger?.LogInformation("✅ {Module} database migrations completed successfully", moduleName); - return; // Success - } - catch (Npgsql.PostgresException ex) when (ex.SqlState == "57P01" && attempt < maxRetries) - { - // 57P01 = "terminating connection due to administrator command" - // Ocorre quando Postgres reinicia após instalar extensões (ex: PostGIS) - logger?.LogWarning( - "⚠️ PostgreSQL connection terminated (57P01 - extension install). Retrying {Module} migrations... Attempt {Attempt}/{MaxRetries}", - moduleName, attempt, maxRetries); - - // Aguarda antes de tentar novamente - await Task.Delay(delayMs * attempt); // Backoff progressivo + try + { + context.Database.SetCommandTimeout(TimeSpan.FromMinutes(2)); + await context.Database.MigrateAsync(); + return; } catch (Exception ex) { - logger?.LogError(ex, "❌ Failed to apply {Module} migrations: {Message}", moduleName, ex.Message); - throw new InvalidOperationException($"Failed to apply {moduleName} database migrations", ex); + lastException = ex; + bool isTransient = ex is TimeoutException || + (ex is Npgsql.PostgresException pgEx && (pgEx.SqlState == "57P01" || pgEx.SqlState == "53300" || pgEx.SqlState == "08006")); + + if (!isTransient || attempt == 3) break; + await Task.Delay(1000 * attempt); } } + throw new InvalidOperationException($"Failed to apply {moduleName} migrations.", lastException); + } + + public async ValueTask DisposeAsync() + { + Client?.Dispose(); + _factory?.Dispose(); + if (_databaseFixture != null && _databaseName != null) await _databaseFixture.DropDatabaseAsync(_databaseName); + if (_wireMockFixture != null) await _wireMockFixture.DisposeAsync(); + } + + private static void RemoveDbContextRegistrations(IServiceCollection services) where TContext : DbContext + { + var optionsDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); + if (optionsDescriptor != null) services.Remove(optionsDescriptor); + var contextDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(TContext)); + if (contextDescriptor != null) services.Remove(contextDescriptor); } - /// - /// Deserializa resposta JSON usando as opções de serialização compartilhadas (com suporte a enums). - /// Detecta automaticamente se a resposta está envolvida em um Result<T> e a desembrulha se necessário. - /// protected async Task ReadJsonAsync(HttpContent content) { - // Lê tudo como string para evitar problemas de seek em streams não-bufferizados var jsonString = await content.ReadAsStringAsync(); - - // Tenta deserializar como JsonElement primeiro para inspecionar a estrutura try { var json = JsonSerializer.Deserialize(jsonString, SerializationDefaults.Api); - // Verifica se tem as propriedades de um Result - if (json.ValueKind == JsonValueKind.Object && - json.TryGetProperty("isSuccess", out var isSuccessProp) && - json.TryGetProperty("value", out var valueProp)) - { - // É um Result wrapper - verifica se foi sucesso - if (isSuccessProp.ValueKind == JsonValueKind.True) - { - // Se sucesso, desserializa o campo 'value' - return JsonSerializer.Deserialize(valueProp.GetRawText(), SerializationDefaults.Api); - } - - // Se falha e T for JsonElement, retorna o objeto completo para inspeção - if (typeof(T) == typeof(JsonElement)) - { - return (T)(object)json; - } + // Só tentamos desembrulhar se: + // 1. O JSON for um objeto com a estrutura do Result { items: ..., isSuccess: true, value: ... } + // 2. O tipo solicitado T NÃO for do tipo Result ou Response (para evitar erro de dupla desserialização) + + var type = typeof(T); + bool isResultType = false; - return default; + if (type.IsGenericType) + { + var genericTypeDefinition = type.GetGenericTypeDefinition(); + isResultType = genericTypeDefinition.Name == "Result`1" || + genericTypeDefinition.Name == "Response`1"; } + else + { + // Tratar tipos de wrapper de resposta não genéricos (ex: UploadDocumentResponse) + isResultType = type.Name.EndsWith("Response") || + (type.Namespace?.Contains("Contracts.Functional") ?? false); + } + + if (!isResultType && json.ValueKind == JsonValueKind.Object && json.TryGetProperty("isSuccess", out var s) && s.ValueKind == JsonValueKind.True && json.TryGetProperty("value", out var v)) + return JsonSerializer.Deserialize(v.GetRawText(), SerializationDefaults.Api); - // Não é wrapper, deserializa direto return JsonSerializer.Deserialize(jsonString, SerializationDefaults.Api); } - catch (JsonException) + catch (JsonException ex) { - // Fallback para deserialização direta se a inspeção falhar (ex: string vazia ou inválida) - return JsonSerializer.Deserialize(jsonString, SerializationDefaults.Api); + var preview = BuildSafeResponsePreview(jsonString); + throw new InvalidOperationException($"Failed to deserialize JSON response to type {typeof(T).Name}. Content Preview: {preview}", ex); + } + catch (Exception ex) + { + var preview = BuildSafeResponsePreview(jsonString); + throw new InvalidOperationException($"An unexpected error occurred while processing the API response. Preview: {preview}", ex); } } - /// - /// Helper para extrair dados da resposta, suportando tanto formato legado (data wrapper) quanto novo (Result with value) - /// - protected static JsonElement GetResponseData(JsonElement response) + private static string BuildSafeResponsePreview(string content, int maxLength = 1000) { - // Handle array responses directly - if (response.ValueKind == JsonValueKind.Array) - { - return response; - } + if (string.IsNullOrEmpty(content)) return "[Empty Content]"; + return content.Length <= maxLength ? content : content[..maxLength] + "... [TRUNCATED]"; + } - // Only try to get properties if it's an object + protected static JsonElement GetResponseData(JsonElement response) + { + if (response.ValueKind == JsonValueKind.Array) return response; if (response.ValueKind == JsonValueKind.Object) { - // Se a resposta tem uma propriedade 'value', retorna ela (mesmo que seja null) - if (response.TryGetProperty("value", out var valueElement)) - { - return valueElement; - } - - // Fallback para 'data' (legado) - if (response.TryGetProperty("data", out var dataElement)) - { - return dataElement; - } + if (response.TryGetProperty("value", out var v)) return v; + if (response.TryGetProperty("data", out var d)) return d; } - - // Return original response if nothing matched return response; } - /// - /// Resolves the ApiService project path using multiple strategies: - /// 1. Environment variable MEAJUDAAI_API_SERVICE_PATH (for CI override) - /// 2. Assembly location relative path resolution - /// 3. Search for .csproj file up the directory tree - /// private static string? ResolveApiServicePath() { - // Strategy 1: Check environment variable (CI override) var envPath = Environment.GetEnvironmentVariable("MEAJUDAAI_API_SERVICE_PATH"); - if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath)) - { - Console.WriteLine($"Using ApiService path from environment variable: {envPath}"); - return envPath; - } - - // Strategy 2: Use base directory to compute relative path + if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath)) return envPath; var assemblyDir = AppContext.BaseDirectory; - if (!string.IsNullOrEmpty(assemblyDir)) { - // From: tests/MeAjudaAi.Integration.Tests/bin/Debug/net10.0/ - // To: src/Bootstrapper/MeAjudaAi.ApiService/ var candidatePath = Path.GetFullPath(Path.Combine(assemblyDir, "..", "..", "..", "..", "..", "src", "Bootstrapper", "MeAjudaAi.ApiService")); - - if (Directory.Exists(candidatePath)) - { - Console.WriteLine($"Resolved ApiService path from assembly location: {candidatePath}"); - return candidatePath; - } + if (Directory.Exists(candidatePath)) return candidatePath; } - - // Strategy 3: Search for .csproj file up the directory tree (fallback) - var currentDir = assemblyDir; - while (!string.IsNullOrEmpty(currentDir)) - { - var projectFile = Path.Combine(currentDir, "src", "Bootstrapper", "MeAjudaAi.ApiService", "MeAjudaAi.ApiService.csproj"); - if (File.Exists(projectFile)) - { - var resolvedPath = Path.GetDirectoryName(projectFile); - Console.WriteLine($"Found ApiService path via directory search: {resolvedPath}"); - return resolvedPath; - } - - currentDir = Directory.GetParent(currentDir)?.FullName; - } - - Console.Error.WriteLine("ERROR: Could not resolve ApiService path using any strategy."); - Console.Error.WriteLine($"Base directory: {assemblyDir}"); - Console.Error.WriteLine($"Environment variable MEAJUDAAI_API_SERVICE_PATH: {envPath ?? "(not set)"}"); - return null; } } diff --git a/tests/MeAjudaAi.Integration.Tests/Fixtures/Database/SimpleDatabaseFixture.cs b/tests/MeAjudaAi.Integration.Tests/Fixtures/Database/SimpleDatabaseFixture.cs index 3e65659f5..436c12fda 100644 --- a/tests/MeAjudaAi.Integration.Tests/Fixtures/Database/SimpleDatabaseFixture.cs +++ b/tests/MeAjudaAi.Integration.Tests/Fixtures/Database/SimpleDatabaseFixture.cs @@ -23,12 +23,84 @@ public sealed class SimpleDatabaseFixture : IAsyncLifetime /// /// Connection string com detalhes de erro habilitados para diagnóstico em CI /// - public string? ConnectionString => _postgresContainer != null - ? $"{_postgresContainer.GetConnectionString()};Include Error Detail=true" - : null; - + public string GetConnectionString(string databaseName) + { + if (_postgresContainer == null) throw new InvalidOperationException("Postgres container not initialized"); + + var builder = new NpgsqlConnectionStringBuilder(_postgresContainer.GetConnectionString()) + { + Database = databaseName, + IncludeErrorDetail = true + }; + + return builder.ConnectionString; + } + + public string? ConnectionString => _postgresContainer?.GetConnectionString(); + public string? AzuriteConnectionString => _azuriteContainer?.GetConnectionString(); + public async Task CreateDatabaseAsync(string databaseName) + { + if (_postgresContainer == null) throw new InvalidOperationException("Postgres container not initialized"); + + // Sanitização: apenas letras, números e underscores + if (string.IsNullOrWhiteSpace(databaseName) || !System.Text.RegularExpressions.Regex.IsMatch(databaseName, @"^[A-Za-z0-9_]+$")) + throw new ArgumentException("Invalid database name format. Only letters, numbers and underscores allowed.", nameof(databaseName)); + + var masterConnectionString = _postgresContainer.GetConnectionString(); // Default to postgres DB + await using var conn = new NpgsqlConnection(masterConnectionString); + await conn.OpenAsync(); + + // Verifica se banco já existe - usando parâmetros para o SELECT + await using var checkCmd = new NpgsqlCommand("SELECT 1 FROM pg_database WHERE datname = @dbName", conn); + checkCmd.Parameters.AddWithValue("dbName", databaseName); + var exists = await checkCmd.ExecuteScalarAsync(); + + if (exists == null) + { + // CREATE DATABASE não suporta parâmetros, mas o nome já foi validado pela regex acima + await using var cmd = new NpgsqlCommand($"CREATE DATABASE {databaseName}", conn); + await cmd.ExecuteNonQueryAsync(); + Console.WriteLine($"[DB-FIXTURE] Database {databaseName} created"); + } + } + + public async Task DropDatabaseAsync(string databaseName) + { + if (_postgresContainer == null) return; + + // Sanitização idêntica ao Create + if (string.IsNullOrWhiteSpace(databaseName) || !System.Text.RegularExpressions.Regex.IsMatch(databaseName, @"^[A-Za-z0-9_]+$")) + throw new ArgumentException("Invalid database name format. Only letters, numbers and underscores allowed.", nameof(databaseName)); + + try + { + var masterConnectionString = _postgresContainer.GetConnectionString(); + await using var conn = new NpgsqlConnection(masterConnectionString); + await conn.OpenAsync(); + + // Forçar encerramento de conexões + await using var terminateCmd = new NpgsqlCommand($""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = '{databaseName}' + AND pid <> pg_backend_pid(); + """, conn); + await terminateCmd.ExecuteNonQueryAsync(); + + // DROP DATABASE não suporta parâmetros, mas o nome já foi validado pela regex acima + await using var cmd = new NpgsqlCommand($"DROP DATABASE IF EXISTS {databaseName}", conn); + await cmd.ExecuteNonQueryAsync(); + Console.WriteLine($"[DB-FIXTURE] Database {databaseName} dropped"); + } + catch (Exception ex) + { + Console.WriteLine($"[DB-FIXTURE] ERROR: Could not drop database {databaseName}: {ex.Message}"); + throw; + } + } + public async ValueTask InitializeAsync() { // Se já foi inicializado, retorna imediatamente diff --git a/tests/MeAjudaAi.Integration.Tests/Fixtures/External/WireMockFixture.cs b/tests/MeAjudaAi.Integration.Tests/Fixtures/External/WireMockFixture.cs index 584f885a4..89afbf2fa 100644 --- a/tests/MeAjudaAi.Integration.Tests/Fixtures/External/WireMockFixture.cs +++ b/tests/MeAjudaAi.Integration.Tests/Fixtures/External/WireMockFixture.cs @@ -7,7 +7,7 @@ namespace MeAjudaAi.Integration.Tests.Infrastructure; /// /// Fixture para servidor HTTP WireMock usado para simular APIs externas em testes de integração. -/// Fornece stubs para APIs ViaCep, BrasilApi, OpenCep, Nominatim e IBGE. +/// Fornece stubs para as APIs ViaCep, BrasilApi, OpenCep, Nominatim e IBGE. /// public class WireMockFixture : IAsyncDisposable { @@ -30,14 +30,14 @@ public Task StartAsync() { _server = WireMockServer.Start(new WireMockServerSettings { - Port = 0, // Use dynamic port to avoid conflicts in parallel test execution + Port = 0, // Usa porta dinâmica para evitar conflitos na execução paralela de testes StartAdminInterface = true, ReadStaticMappings = false, WatchStaticMappings = false, Logger = new WireMockConsoleLogger() }); - // Configure all API stubs + // Configura todos os stubs de API ConfigureIbgeStubs(); ConfigureViaCepStubs(); ConfigureBrasilApiStubs(); @@ -48,15 +48,16 @@ public Task StartAsync() } /// - /// Configures IBGE Localidades API stubs. + /// Configura os stubs da API IBGE Localidades. /// private void ConfigureIbgeStubs() { - // Search by city name: Muriaé/MG - IBGE code: 3143906 + // Busca por nome de cidade: Muriaé/MG - Código IBGE: 3143906 + // Usando Regex para o parâmetro 'nome' para lidar com variações de codificação de URL (ex: %C3%A9 para é) Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/municipios") - .WithParam("nome", "muriaé") + .WithParam("nome", new WireMock.Matchers.RegexMatcher("(?i)^muria(%C3%A9|\u00E9|e)$", true)) .UsingGet()) .RespondWith(WireMock.ResponseBuilders.Response.Create() .WithStatusCode(200) @@ -82,11 +83,11 @@ private void ConfigureIbgeStubs() }] """)); - // Search by city name: Itaperuna/RJ - IBGE code: 3302205 + // Busca por nome de cidade: Itaperuna/RJ Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/municipios") - .WithParam("nome", "itaperuna") + .WithParam("nome", new WireMock.Matchers.RegexMatcher("(?i)^itaperuna$", true)) .UsingGet()) .RespondWith(WireMock.ResponseBuilders.Response.Create() .WithStatusCode(200) @@ -112,7 +113,37 @@ private void ConfigureIbgeStubs() }] """)); - // Get city by ID: Muriaé/MG + // Busca por nome de cidade: Linhares/ES + Server + .Given(WireMock.RequestBuilders.Request.Create() + .WithPath("/api/v1/localidades/municipios") + .WithParam("nome", new WireMock.Matchers.RegexMatcher("(?i)^linhares$", true)) + .UsingGet()) + .RespondWith(WireMock.ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json; charset=utf-8") + .WithBody(""" + [{ + "id": 3201506, + "nome": "Linhares", + "microrregiao": { + "id": 32004, + "nome": "Linhares", + "mesorregiao": { + "id": 3202, + "nome": "Litoral Norte Espírito-Santense", + "UF": { + "id": 32, + "sigla": "ES", + "nome": "Espírito Santo", + "regiao": { "id": 3, "sigla": "SE", "nome": "Sudeste" } + } + } + } + }] + """)); + + // Busca cidade por ID: Muriaé/MG Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/municipios/3143906") @@ -141,7 +172,7 @@ private void ConfigureIbgeStubs() } """)); - // Get all states (UFs) + // Busca todas as UFs Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/estados") @@ -172,7 +203,7 @@ private void ConfigureIbgeStubs() ] """)); - // Get state by ID: MG + // Busca estado por ID: MG Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/estados/31") @@ -189,7 +220,7 @@ private void ConfigureIbgeStubs() } """)); - // Get state by UF: MG + // Busca estado por UF: MG Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/estados/MG") @@ -206,7 +237,7 @@ private void ConfigureIbgeStubs() } """)); - // Search by state: SP + // Busca por estado: SP Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/estados/SP/municipios") @@ -235,7 +266,7 @@ private void ConfigureIbgeStubs() }] """)); - // Invalid city ID - 404 + // ID de cidade inválido - 404 Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/municipios/9999999") @@ -245,7 +276,7 @@ private void ConfigureIbgeStubs() .WithHeader("Content-Type", "application/json; charset=utf-8") .WithBody("[]")); - // Invalid state ID - 404 + // ID de estado inválido - 404 Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/estados/999") @@ -255,7 +286,7 @@ private void ConfigureIbgeStubs() .WithHeader("Content-Type", "application/json; charset=utf-8") .WithBody("[]")); - // Special characters handling: São Paulo + // Tratamento de caracteres especiais: São Paulo Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/municipios") @@ -285,20 +316,20 @@ private void ConfigureIbgeStubs() }] """)); - // Catch-all for unknown cities - return empty array (200 status, not 404) - // This allows IbgeClient to return null instead of throwing exception + // Catch-all para cidades desconhecidas - retorna array vazio (status 200, não 404) + // Isso permite que o IbgeClient retorne null em vez de lançar exceção Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/municipios") - .WithParam("nome") // Match any nome parameter + .WithParam("nome") // Corresponde a qualquer parâmetro nome .UsingGet()) - .AtPriority(100) // Lower priority so specific stubs match first + .AtPriority(100) // Prioridade menor para que stubs específicos coincidam primeiro .RespondWith(WireMock.ResponseBuilders.Response.Create() .WithStatusCode(200) .WithHeader("Content-Type", "application/json; charset=utf-8") .WithBody("[]")); - // Service unavailability simulation - 500 error + // Simulação de indisponibilidade de serviço - erro 500 Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/municipios/unavailable") @@ -308,7 +339,7 @@ private void ConfigureIbgeStubs() .WithHeader("Content-Type", "text/plain") .WithBody("Internal Server Error")); - // Timeout simulation - 5 second delay (well within the 30 second HTTP client timeout configured for tests) + // Simulação de timeout - delay de 5 segundos (dentro do timeout de 30s do HttpClient configurado nos testes) Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/municipios/timeout") @@ -319,7 +350,7 @@ private void ConfigureIbgeStubs() .WithBody("[]") .WithDelay(TimeSpan.FromSeconds(5))); - // Malformed response simulation - invalid JSON + // Simulação de resposta malformada - JSON inválido Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/api/v1/localidades/municipios/malformed") @@ -331,7 +362,7 @@ private void ConfigureIbgeStubs() } /// - /// Configures ViaCep API stubs. + /// Configura os stubs da API ViaCep. /// private void ConfigureViaCepStubs() { @@ -371,7 +402,7 @@ private void ConfigureViaCepStubs() } /// - /// Configures BrasilApi CEP stubs. + /// Configura os stubs da API BrasilApi CEP. /// private void ConfigureBrasilApiStubs() { @@ -396,7 +427,7 @@ private void ConfigureBrasilApiStubs() } /// - /// Configures OpenCep API stubs. + /// Configura os stubs da API OpenCep. /// private void ConfigureOpenCepStubs() { @@ -422,11 +453,11 @@ private void ConfigureOpenCepStubs() } /// - /// Configures Nominatim geocoding API stubs. + /// Configura os stubs da API Nominatim (geocoding). /// private void ConfigureNominatimStubs() { - // São Paulo search + // Busca por São Paulo Server .Given(WireMock.RequestBuilders.Request.Create() .WithPath("/search") @@ -450,7 +481,7 @@ private void ConfigureNominatimStubs() } /// - /// Resets all configured stubs and request logs. + /// Reseta todos os stubs configurados e logs de requisição. /// public void Reset() { @@ -463,7 +494,7 @@ public void Reset() } /// - /// Disposes the WireMock server. + /// Descarta o servidor WireMock. /// public ValueTask DisposeAsync() { @@ -476,13 +507,13 @@ public ValueTask DisposeAsync() } /// -/// Console logger for WireMock server. +/// Logger de console para o servidor WireMock. /// internal class WireMockConsoleLogger : IWireMockLogger { public void Debug(string formatString, params object[] args) { - // Suppress debug logs to reduce noise + // Suprime logs de debug para reduzir ruído } public void Info(string formatString, params object[] args) @@ -507,6 +538,6 @@ public void Error(string formatString, Exception exception) public void DebugRequestResponse(LogEntryModel logEntryModel, bool isAdminRequest) { - // Suppress request/response logs to reduce noise + // Suprime logs de requisição/resposta para reduzir ruído } } diff --git a/tests/MeAjudaAi.Integration.Tests/Middleware/CompressionSecurityMiddlewareTests.cs b/tests/MeAjudaAi.Integration.Tests/Middleware/CompressionSecurityMiddlewareTests.cs index 4afcf7704..311ad1141 100644 --- a/tests/MeAjudaAi.Integration.Tests/Middleware/CompressionSecurityMiddlewareTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Middleware/CompressionSecurityMiddlewareTests.cs @@ -7,7 +7,7 @@ namespace MeAjudaAi.Integration.Tests.Middleware; /// -/// Testes de integração para CompressionSecurityMiddleware +/// Integration tests for CompressionSecurityMiddleware /// public sealed class CompressionSecurityMiddlewareTests : BaseApiTest { @@ -16,27 +16,29 @@ public sealed class CompressionSecurityMiddlewareTests : BaseApiTest [Fact] public async Task CompressionSecurity_AuthenticatedUser_ShouldDisableCompression() { - // Arrange - Configurar usuário autenticado - AuthConfig.ConfigureRegularUser(); - - // Simula requisição autenticada adicionando header Authorization - // O middleware CompressionSecurity verifica este header antes de UseAuthentication() executar + // Arrange + // CompressionSecurity middleware checks this header before UseAuthentication() executes + // We don't need a real user in DB, just the header presence HttpClient.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); HttpClient.DefaultRequestHeaders.Add("Accept-Encoding", "gzip, deflate, br"); // Act - using var response = await HttpClient.GetAsync("/api/v1/providers"); + using var response = await HttpClient.GetAsync("/health"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK, - "Requisição autenticada ao endpoint /api/v1/users deve ser bem-sucedida"); - - // CompressionSecurityMiddleware deve desabilitar compressão para usuários autenticados - // Isso previne ataques BREACH/CRIME que exploram compressão + "Health endpoint should be accessible"); + + // Verify if security middleware acted + response.Headers.Should().ContainKey("X-Compression-Disabled", + "Security middleware should add debug header when disabling compression"); + + // CompressionSecurityMiddleware should disable compression for authenticated users + // This prevents BREACH/CRIME attacks that exploit compression response.Content.Headers.ContentEncoding.Should().NotContain("gzip", - "Compressão deve ser desabilitada para usuários autenticados (proteção BREACH)"); + "Compression must be disabled for authenticated users (BREACH protection)"); response.Content.Headers.ContentEncoding.Should().NotContain("br", - "Brotli deve ser desabilitado para usuários autenticados (proteção BREACH)"); + "Brotli must be disabled for authenticated users (BREACH protection)"); HttpClient.DefaultRequestHeaders.Remove("Authorization"); HttpClient.DefaultRequestHeaders.Remove("Accept-Encoding"); @@ -55,10 +57,14 @@ public async Task CompressionSecurity_AnonymousUser_ShouldAllowCompression() // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); - // Para usuários anônimos, compressão pode estar habilitada - // (conteúdo público não é vulnerável a BREACH) - // Nota: se o servidor decidir comprimir ou não depende de outros fatores - // Este teste valida que middleware NÃO bloqueia compressão para anônimos + // Verify if security middleware DID NOT act (debug header absent) + response.Headers.Contains("X-Compression-Disabled").Should().BeFalse( + "Security middleware should not disable compression for anonymous users"); + + // For anonymous users, compression may be enabled + // (public content is not vulnerable to BREACH) + // Note: whether the server decides to compress or not depends on other factors + // This test validates that middleware does NOT block compression for anonymous HttpClient.DefaultRequestHeaders.Remove("Accept-Encoding"); } @@ -66,42 +72,34 @@ public async Task CompressionSecurity_AnonymousUser_ShouldAllowCompression() [Fact] public async Task CompressionSecurity_AuthenticatedRequest_WithoutAcceptEncoding_ShouldSucceed() { - // Arrange - Configurar usuário autenticado - AuthConfig.ConfigureRegularUser(); + // Arrange + HttpClient.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); HttpClient.DefaultRequestHeaders.Remove("Accept-Encoding"); // Act - using var response = await HttpClient.GetAsync("/api/v1/providers"); + using var response = await HttpClient.GetAsync("/health"); // Assert - response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Unauthorized); - - if (response.StatusCode == HttpStatusCode.OK) - { - response.Content.Headers.ContentEncoding.Should().BeEmpty( - "Sem Accept-Encoding, não deve haver compressão"); - } + response.StatusCode.Should().Be(HttpStatusCode.OK); - HttpClient.DefaultRequestHeaders.Authorization = null; + HttpClient.DefaultRequestHeaders.Remove("Authorization"); } [Fact] public async Task CompressionSecurity_MultipleAuthenticatedRequests_ShouldConsistentlyDisableCompression() { - // Arrange - Configurar usuário autenticado - AuthConfig.ConfigureRegularUser(); + // Arrange HttpClient.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); HttpClient.DefaultRequestHeaders.Add("Accept-Encoding", "gzip, deflate, br"); // Act & Assert - Dispose responses immediately after use for (int i = 0; i < 5; i++) { - using var response = await HttpClient.GetAsync("/api/v1/providers"); - if (response.StatusCode == HttpStatusCode.OK) - { - response.Content.Headers.ContentEncoding.Should().NotContain("gzip", - "Todas as requisições autenticadas devem ter compressão desabilitada"); - } + using var response = await HttpClient.GetAsync("/health"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + response.Content.Headers.ContentEncoding.Should().NotContain("gzip", + "All authenticated requests must have compression disabled"); } HttpClient.DefaultRequestHeaders.Remove("Authorization"); @@ -111,12 +109,11 @@ public async Task CompressionSecurity_MultipleAuthenticatedRequests_ShouldConsis [Fact] public async Task CompressionSecurity_DifferentEndpoints_ShouldApplyRulesConsistently() { - // Arrange - Configurar usuário autenticado - AuthConfig.ConfigureRegularUser(); + // Arrange HttpClient.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); HttpClient.DefaultRequestHeaders.Add("Accept-Encoding", "gzip, deflate, br"); - var endpoints = new[] { "/api/v1/providers", "/api/v1/service-categories" }; + var endpoints = new[] { "/health", "/api/v1/configuration/features" }; // Act & Assert - Dispose responses immediately after use foreach (var endpoint in endpoints) @@ -125,7 +122,7 @@ public async Task CompressionSecurity_DifferentEndpoints_ShouldApplyRulesConsist if (response.StatusCode == HttpStatusCode.OK) { response.Content.Headers.ContentEncoding.Should().NotContain("gzip", - $"Endpoint {endpoint} não deve comprimir para usuários autenticados"); + $"Endpoint {endpoint} should not compress for authenticated users"); } } diff --git a/tests/MeAjudaAi.Integration.Tests/Middleware/GeographicRestrictionIntegrationTests.cs b/tests/MeAjudaAi.Integration.Tests/Middleware/GeographicRestrictionIntegrationTests.cs index 92a38f128..c986dbb7e 100644 --- a/tests/MeAjudaAi.Integration.Tests/Middleware/GeographicRestrictionIntegrationTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Middleware/GeographicRestrictionIntegrationTests.cs @@ -10,7 +10,6 @@ namespace MeAjudaAi.Integration.Tests.Middleware; /// Testes para GeographicRestrictionMiddleware. /// Usa validação baseada em configuração (não mock IBGE) para consistência. /// -[Collection("Integration")] public class GeographicRestrictionIntegrationTests : BaseApiTest { /// diff --git a/tests/MeAjudaAi.Integration.Tests/Middleware/SecurityHeadersMiddlewareTests.cs b/tests/MeAjudaAi.Integration.Tests/Middleware/SecurityHeadersMiddlewareTests.cs index 5127b4d79..b057e92ae 100644 --- a/tests/MeAjudaAi.Integration.Tests/Middleware/SecurityHeadersMiddlewareTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Middleware/SecurityHeadersMiddlewareTests.cs @@ -177,4 +177,38 @@ public async Task SecurityHeaders_ErrorResponse_ShouldStillHaveHeaders() response.Headers.Should().Contain(h => h.Key == "X-Content-Type-Options", "Respostas de erro também devem ter headers de segurança"); } + + [Fact] + public async Task SecurityHardening_AntiforgeryCookie_ShouldBePresentInGetRequests() + { + // Act + using var response = await HttpClient.GetAsync("/health"); + + // Assert + // O ASP.NET Core gera o cookie de antiforgery em requisições GET para que o SPA possa lê-lo + response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue("O header Set-Cookie deve estar presente"); + cookies.Should().Contain(c => c.Contains("XSRF-TOKEN"), "O cookie de antiforgery deve ser enviado para o cliente"); + } + + [Fact] + public async Task SecurityHardening_HttpsRedirection_ShouldBeActive() + { + // Arrange + // Criar um cliente que NÃO segue redirecionamentos automaticamente para podermos ver o 307/308 + var options = new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + BaseAddress = new Uri("http://localhost") + }; + using var noRedirectClient = _factory!.CreateClient(options); + + // Act + using var response = await noRedirectClient.GetAsync("/health"); + + // Assert + // Esperamos 307 (Temporary Redirect) ou 308 (Permanent Redirect) + response.StatusCode.Should().BeOneOf([HttpStatusCode.TemporaryRedirect, HttpStatusCode.PermanentRedirect], + "Requisições HTTP devem ser redirecionadas para HTTPS"); + response.Headers.Location!.Scheme.Should().Be("https"); + } } diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Communications/CommunicationsModuleApiTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Communications/CommunicationsModuleApiTests.cs new file mode 100644 index 000000000..f00bb1a33 --- /dev/null +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Communications/CommunicationsModuleApiTests.cs @@ -0,0 +1,64 @@ +using MeAjudaAi.Integration.Tests.Base; +using FluentAssertions; +using System.Net; +using System.Net.Http.Json; +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using MeAjudaAi.Contracts.Models; + +namespace MeAjudaAi.Integration.Tests.Modules.Communications; + +public class CommunicationsModuleApiTests : BaseApiTest +{ + protected override TestModule RequiredModules => TestModule.Communications; + + [Fact] + public async Task GetLogs_WithAuthentication_ShouldReturnOk() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + // Act + var response = await Client.GetAsync("/api/v1/communications/logs?pageNumber=1&pageSize=10"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync>(); + result.Should().NotBeNull(); + result!.Items.Should().BeEmpty(); // Inicialmente vazio + } + + [Fact] + public async Task GetTemplates_WithAuthentication_ShouldReturnOk() + { + // Arrange + AuthConfig.ConfigureAdmin(); + + // Act + var response = await Client.GetAsync("/api/v1/communications/templates"); + + // Assert + var result = await ReadJsonAsync>(response.Content); + response.StatusCode.Should().Be(HttpStatusCode.OK); + result.Should().NotBeNull(); + } + + [Fact] + public async Task GetLogs_WithoutAuthentication_ShouldReturnUnauthorized() + { + // Act + var response = await Client.GetAsync("/api/v1/communications/logs"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetTemplates_WithoutAuthentication_ShouldReturnUnauthorized() + { + // Act + var response = await Client.GetAsync("/api/v1/communications/templates"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Communications/OutboxPatternTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Communications/OutboxPatternTests.cs new file mode 100644 index 000000000..a7ac446d5 --- /dev/null +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Communications/OutboxPatternTests.cs @@ -0,0 +1,102 @@ +using MeAjudaAi.Contracts.Modules.Communications; +using MeAjudaAi.Contracts.Modules.Communications.DTOs; +using MeAjudaAi.Contracts.Shared; +using MeAjudaAi.Integration.Tests.Base; +using MeAjudaAi.Modules.Communications.Application.Services; +using MeAjudaAi.Modules.Communications.Domain.Enums; +using MeAjudaAi.Modules.Communications.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using FluentAssertions; +using System.Text.Json; + +namespace MeAjudaAi.Integration.Tests.Modules.Communications; + +public class OutboxPatternTests : BaseApiTest +{ + protected override TestModule RequiredModules => TestModule.Communications; + + [Fact] + public async Task SendEmail_ShouldEnqueueInOutbox_AndProcessorShouldSendIt() + { + // Arrange + var api = Services.GetRequiredService(); + var processor = Services.GetRequiredService(); + var dbContext = Services.GetRequiredService(); + + var emailDto = new EmailMessageDto( + To: "test@example.com", + Subject: "Test Outbox", + Body: "Hello from integration test" + ); + + // Act - 1. Enviar via API (deve enfileirar) + var result = await api.SendEmailAsync(emailDto); + result.IsSuccess.Should().BeTrue(); + var outboxId = result.Value; + + // Verificar se está no banco como Pending + var messageBefore = await dbContext.OutboxMessages.FindAsync(outboxId); + messageBefore.Should().NotBeNull(); + messageBefore!.Status.Should().Be(EOutboxMessageStatus.Pending); + + // Act - 2. Rodar processador + var processedCount = await processor.ProcessPendingMessagesAsync(); + processedCount.Should().BeGreaterThanOrEqualTo(1); + + // Assert - 3. Verificar se foi marcado como Sent + // Limpar cache do EF para pegar dados frescos do banco + dbContext.Entry(messageBefore).State = EntityState.Detached; + var messageAfter = await dbContext.OutboxMessages.FindAsync(outboxId); + + messageAfter.Should().NotBeNull(); + messageAfter!.Status.Should().Be(EOutboxMessageStatus.Sent); + messageAfter.SentAt.Should().NotBeNull(); + + // Verificar log de comunicação + var log = await dbContext.CommunicationLogs + .FirstOrDefaultAsync(x => x.OutboxMessageId == outboxId); + + log.Should().NotBeNull(); + log!.IsSuccess.Should().BeTrue(); + log.Recipient.Should().Be(emailDto.To); + } + + [Fact] + public async Task ScheduledMessage_ShouldNotBeProcessedBeforeTime() + { + // Arrange + var api = Services.GetRequiredService(); + var processor = Services.GetRequiredService(); + var dbContext = Services.GetRequiredService(); + + var emailDto = new EmailMessageDto( + To: "future@example.com", + Subject: "Future Email", + Body: "I am from the future" + ); + + // Enfileirar com agendamento para daqui a 1 hora + var scheduledAt = DateTime.UtcNow.AddHours(1); + + // Como a API atual não aceita ScheduledAt diretamente (DTO simplificado), + // vamos criar a entidade manualmente para testar o comportamento do repositório/processor + var message = MeAjudaAi.Modules.Communications.Domain.Entities.OutboxMessage.Create( + ECommunicationChannel.Email, + System.Text.Json.JsonSerializer.Serialize(emailDto), + ECommunicationPriority.Normal, + scheduledAt: scheduledAt); + + await dbContext.OutboxMessages.AddAsync(message); + await dbContext.SaveChangesAsync(); + + // Act + var processedCount = await processor.ProcessPendingMessagesAsync(); + + // Assert + processedCount.Should().Be(0); // Não deve processar a mensagem agendada + + var dbMessage = await dbContext.OutboxMessages.FindAsync(message.Id); + dbMessage!.Status.Should().Be(EOutboxMessageStatus.Pending); + } +} diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Documents/DocumentsEndpointsTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Documents/DocumentsEndpointsTests.cs index 68c7c809d..184e6480e 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Documents/DocumentsEndpointsTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Documents/DocumentsEndpointsTests.cs @@ -6,7 +6,7 @@ namespace MeAjudaAi.Integration.Tests.Modules.Documents; -public class DocumentsEndpointsTests(ITestOutputHelper testOutput) : BaseApiTest +public class DocumentsEndpointsTests : BaseApiTest { protected override TestModule RequiredModules => TestModule.Documents | TestModule.Providers; diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/BrasilApiConfigTest.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/BrasilApiConfigTest.cs new file mode 100644 index 000000000..825ef189f --- /dev/null +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/BrasilApiConfigTest.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using MeAjudaAi.Integration.Tests.Base; +using MeAjudaAi.Modules.Locations.Infrastructure.ExternalApis.Clients; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using MeAjudaAi.Modules.Locations.Domain.ValueObjects; + +namespace MeAjudaAi.Integration.Tests.Modules.Locations; + +public sealed class BrasilApiConfigTest : BaseApiTest +{ + protected override TestModule RequiredModules => TestModule.Locations; + + [Fact] + public async Task BrasilApi_ShouldUseWireMockUrl() + { + // Arrange + var client = Services.GetRequiredService(); + + var uniqueCep = "36880000"; + // Stub no WireMock (acessível via propriedade protegida na base se estiver correta, ou via WireMockServer) + WireMock.Server + .Given(global::WireMock.RequestBuilders.Request.Create() + .WithPath($"/api/cep/v2/{uniqueCep}") + .UsingGet()) + .RespondWith(global::WireMock.ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"cep\":\"36880000\",\"street\":\"Test\",\"neighborhood\":\"Test\",\"city\":\"Test\",\"state\":\"MG\"}")); + + // Act + var cepVo = Cep.Create(uniqueCep); + Assert.NotNull(cepVo); + + var address = await client.GetAddressAsync(cepVo, default); + + // Assert + address.Should().NotBeNull(because: "O cliente deveria ter atingido o WireMock e retornado o endereço mockado"); + address!.Street.Should().Be("Test"); + } +} diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/CepProvidersUnavailabilityTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/CepProvidersUnavailabilityTests.cs index a67aba3e8..2b40ed4f0 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/CepProvidersUnavailabilityTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/CepProvidersUnavailabilityTests.cs @@ -1,262 +1,191 @@ using FluentAssertions; -using MeAjudaAi.Integration.Tests.Base; -using MeAjudaAi.Integration.Tests.Infrastructure; using MeAjudaAi.Contracts.Modules.Locations; +using MeAjudaAi.Integration.Tests.Base; using Microsoft.Extensions.DependencyInjection; using Xunit; +using MeAjudaAi.Modules.Locations.Domain.ValueObjects; +using MeAjudaAi.Contracts.Functional; +using System.Net; namespace MeAjudaAi.Integration.Tests.Modules.Locations; /// -/// Integration tests for CEP provider unavailability scenarios. -/// Validates the fallback chain behavior when external CEP APIs fail: -/// ViaCEP → BrasilAPI → OpenCEP +/// Testes de integração para cenários de indisponibilidade de provedores de CEP. +/// Valida o mecanismo de fallback e resiliência entre ViaCEP, BrasilAPI e OpenCEP. /// -[Collection("Integration")] -public sealed class CepProvidersUnavailabilityTests : BaseApiTest +public sealed class CepProvidersUnavailabilityTests(ITestOutputHelper output) : BaseApiTest { + protected override bool UseMockGeographicValidation => false; protected override TestModule RequiredModules => TestModule.Locations; - [Fact] - public async Task LookupCep_WhenViaCepReturns500_ShouldFallbackToBrasilApi() + private void LogWireMockEntries() { - // Arrange - Use unique CEP to avoid conflicts with default stubs - var uniqueCep = "23456789"; - - // ViaCEP fails with 500 - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath($"/ws/{uniqueCep}/json/") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(500) - .WithBody("Internal Server Error")); - - // BrasilAPI succeeds - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath($"/api/cep/v2/{uniqueCep}") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody($$""" - { - "cep": "{{uniqueCep}}", - "state": "SP", - "city": "São Paulo", - "neighborhood": "Bela Vista", - "street": "Avenida Paulista" - } - """)); - - var locationApi = Services.GetRequiredService(); - - // Act - var result = await locationApi.GetAddressFromCepAsync(uniqueCep); - - // Assert - Should succeed via BrasilAPI fallback (ViaCEP fails, BrasilAPI succeeds) - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBeNull(); - result.Value!.City.Should().Be("São Paulo"); - result.Value.State.Should().Be("SP"); - - // NOTE: Provider hit count assertions skipped due to WireMock shared state in parallel CI execution. - // WireMock server is shared across test collections, making baseline counts unreliable even with unique CEPs. - // The functional behavior (successful fallback) is validated above. + output.WriteLine("--- WireMock Log Entries ---"); + foreach (var entry in WireMock.Server.LogEntries) + { + output.WriteLine($"Request: {entry.RequestMessage.Method} {entry.RequestMessage.Url}"); + output.WriteLine($"Response: {entry.ResponseMessage?.StatusCode}"); + output.WriteLine("----------------------------"); + } } [Fact] - public async Task LookupCep_WhenViaCepAndBrasilApiReturnInvalidJson_ShouldFallbackToOpenCep() + public async Task LookupCep_WhenViaCepFails_ShouldFallbackToBrasilApi() { - // Arrange - Use unique CEP to avoid conflicts with default stubs - var uniqueCep = "34567890"; - - // ViaCEP returns invalid/empty JSON (missing required fields triggers deserialization failure) - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath($"/ws/{uniqueCep}/json/") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(200) - .WithBody("{}")); // Empty JSON lacks required fields, causing validation to fail - - // BrasilAPI also returns invalid/empty JSON - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath($"/api/cep/v2/{uniqueCep}") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(200) - .WithBody("{}")); // Empty JSON lacks required fields, causing validation to fail - - // OpenCEP succeeds - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath($"/v1/{uniqueCep}") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody($$""" - { - "cep": "{{uniqueCep}}", - "logradouro": "Avenida Paulista", - "bairro": "Bela Vista", - "localidade": "São Paulo", - "uf": "SP", - "ibge": "3550308" - } - """)); - - var locationApi = Services.GetRequiredService(); - - // Act - var result = await locationApi.GetAddressFromCepAsync(uniqueCep); - - // Assert - Should succeed via OpenCEP fallback - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBeNull(); - result.Value!.City.Should().Be("São Paulo"); - result.Value.State.Should().Be("SP"); - - // NOTE: Provider hit count assertions skipped due to WireMock shared state in parallel CI execution. - // WireMock server is shared across test collections, making baseline counts unreliable even with unique CEPs. - // The functional behavior (successful fallback to OpenCEP) is validated above. + try + { + // Arrange + var uniqueCep = "11110001"; + AuthConfig.ConfigureAdmin(); + + // 1. ViaCEP retorna erro 500 + WireMock.Server + .Given(global::WireMock.RequestBuilders.Request.Create() + .WithPath($"/ws/{uniqueCep}/json/") + .UsingGet()) + .RespondWith(global::WireMock.ResponseBuilders.Response.Create() + .WithStatusCode(500)); + + // 2. BrasilAPI retorna sucesso + WireMock.Server + .Given(global::WireMock.RequestBuilders.Request.Create() + .WithPath($"/api/cep/v2/{uniqueCep}") + .UsingGet()) + .RespondWith(global::WireMock.ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody($$""" + { + "cep": "{{uniqueCep}}", + "street": "Rua Fallback BrasilAPI", + "neighborhood": "Centro", + "city": "Muriaé", + "state": "MG" + } + """)); + + var locationApi = Services.GetRequiredService(); + + // Limpar cache + var cache = Services.GetRequiredService(); + await cache.RemoveAsync($"cep:{uniqueCep}"); + + // Act + var result = await locationApi.GetAddressFromCepAsync(uniqueCep); + + // Assert + result.IsSuccess.Should().BeTrue(because: $"Deveria ter caído no fallback do BrasilAPI. Erro: {result.Error}"); + result.Value!.Street.Should().Be("Rua Fallback BrasilAPI"); + } + catch + { + LogWireMockEntries(); + throw; + } } [Fact] - public async Task LookupCep_WhenAllProvidersReturn500_ShouldReturnFailure() - { - // Arrange - All providers fail for a unique CEP to avoid cache hits - var uniqueCep = "88888888"; // CEP not used in other tests - - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath($"/ws/{uniqueCep}/json/") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(500)); - - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath($"/api/cep/v2/{uniqueCep}") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(500)); - - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath($"/v1/{uniqueCep}") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(500)); - - var locationApi = Services.GetRequiredService(); - - // Act - var result = await locationApi.GetAddressFromCepAsync(uniqueCep); - - // Assert - Should return failure when all providers down - result.IsSuccess.Should().BeFalse(); - result.Error.Should().NotBeNull(); - } - - [Fact(Skip = "WireMock infrastructure not properly configured in CI")] - public async Task LookupCep_WhenViaCepReturnsMalformedJson_ShouldFallbackToBrasilApi() + public async Task LookupCep_WhenViaCepAndBrasilApiFail_ShouldFallbackToOpenCep() { - // Arrange - Use unique CEP to avoid conflicts with default stubs - var uniqueCep = "12345678"; - - // ViaCEP returns malformed JSON - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath($"/ws/{uniqueCep}/json/") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody("{invalid json}")); - - // BrasilAPI succeeds - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath($"/api/cep/v2/{uniqueCep}") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody($$""" - { - "cep": "{{uniqueCep}}", - "state": "SP", - "city": "São Paulo", - "neighborhood": "Bela Vista", - "street": "Avenida Paulista" - } - """)); - - var locationApi = Services.GetRequiredService(); - - // Act - var result = await locationApi.GetAddressFromCepAsync(uniqueCep); - - // Assert - Should fallback to BrasilAPI - result.IsSuccess.Should().BeTrue(); - result.Value.Should().NotBeNull(); - result.Value!.City.Should().Be("São Paulo"); - result.Value.State.Should().Be("SP"); + try + { + // Arrange + var uniqueCep = "22220002"; + AuthConfig.ConfigureAdmin(); + + // 1. ViaCEP e BrasilAPI retornam erro + WireMock.Server + .Given(global::WireMock.RequestBuilders.Request.Create().WithPath($"/ws/{uniqueCep}/json/").UsingGet()) + .RespondWith(global::WireMock.ResponseBuilders.Response.Create().WithStatusCode(500)); + + WireMock.Server + .Given(global::WireMock.RequestBuilders.Request.Create().WithPath($"/api/cep/v2/{uniqueCep}").UsingGet()) + .RespondWith(global::WireMock.ResponseBuilders.Response.Create().WithStatusCode(500)); + + // 2. OpenCEP retorna sucesso + WireMock.Server + .Given(global::WireMock.RequestBuilders.Request.Create() + .WithPath($"/v1/{uniqueCep}") + .UsingGet()) + .RespondWith(global::WireMock.ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody($$""" + { + "cep": "{{uniqueCep}}", + "logradouro": "Rua Fallback OpenCEP", + "bairro": "Centro", + "localidade": "Muriaé", + "uf": "MG" + } + """)); + + var locationApi = Services.GetRequiredService(); + var cache = Services.GetRequiredService(); + await cache.RemoveAsync($"cep:{uniqueCep}"); + + // Act + var result = await locationApi.GetAddressFromCepAsync(uniqueCep); + + // Assert + result.IsSuccess.Should().BeTrue(because: "Deveria ter caído no último fallback (OpenCEP)"); + result.Value!.Street.Should().Be("Rua Fallback OpenCEP"); + } + catch + { + LogWireMockEntries(); + throw; + } } [Fact] - public async Task LookupCep_WhenViaCepReturnsErrorTrueAndOthersFail_ShouldReturnFailure() + public async Task LookupCep_WhenViaCepReturnsMalformedJson_ShouldFallbackToBrasilApi() { - // Arrange - ViaCEP returns "erro: true" for invalid CEP - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath("/ws/00000000/json/") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(200) - .WithHeader("Content-Type", "application/json") - .WithBody("""{"erro": true}""")); - - // BrasilAPI also fails (404 for invalid CEP - v2 behavior) - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath("/api/cep/v2/00000000") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(404) - .WithBody("CEP não encontrado")); - - // OpenCEP also fails - WireMock.Server - .Given(global::WireMock.RequestBuilders.Request.Create() - .WithPath("/v1/00000000") - .UsingGet()) - .AtPriority(1) // Higher priority than default stubs - .RespondWith(global::WireMock.ResponseBuilders.Response.Create() - .WithStatusCode(404)); - - var locationApi = Services.GetRequiredService(); - - // Act - var result = await locationApi.GetAddressFromCepAsync("00000000"); - - // Assert - Should return failure for truly invalid CEP - result.IsSuccess.Should().BeFalse(); + try + { + // Arrange + var uniqueCep = "33330003"; + AuthConfig.ConfigureAdmin(); + + // 1. ViaCEP retorna JSON inválido + WireMock.Server + .Given(global::WireMock.RequestBuilders.Request.Create().WithPath($"/ws/{uniqueCep}/json/").UsingGet()) + .RespondWith(global::WireMock.ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody("INVALID JSON { ...")); + + // 2. BrasilAPI retorna sucesso + WireMock.Server + .Given(global::WireMock.RequestBuilders.Request.Create().WithPath($"/api/cep/v2/{uniqueCep}").UsingGet()) + .RespondWith(global::WireMock.ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody($$""" + { + "cep": "{{uniqueCep}}", + "street": "Rua Apos ViaCep Invalido", + "neighborhood": "Centro", + "city": "Muriaé", + "state": "MG" + } + """)); + + var locationApi = Services.GetRequiredService(); + var cache = Services.GetRequiredService(); + await cache.RemoveAsync($"cep:{uniqueCep}"); + + // Act + var result = await locationApi.GetAddressFromCepAsync(uniqueCep); + + // Assert + result.IsSuccess.Should().BeTrue(because: "Deveria ter ignorado o JSON malformado do ViaCEP e usado o BrasilAPI"); + result.Value!.Street.Should().Be("Rua Apos ViaCep Invalido"); + } + catch + { + LogWireMockEntries(); + throw; + } } } + diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/FullModuleCepTest.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/FullModuleCepTest.cs new file mode 100644 index 000000000..29e95d51e --- /dev/null +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/FullModuleCepTest.cs @@ -0,0 +1,54 @@ +using FluentAssertions; +using MeAjudaAi.Integration.Tests.Base; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using MeAjudaAi.Contracts.Modules.Locations; +using MeAjudaAi.Modules.Locations.Infrastructure.ExternalApis.Clients; +using System.Net.Http; +using System.Reflection; + +namespace MeAjudaAi.Integration.Tests.Modules.Locations; + +public sealed class FullModuleCepTest : BaseApiTest +{ + protected override TestModule RequiredModules => TestModule.Locations; + + [Fact] + public async Task GetAddressFromCepAsync_ShouldWorkWithWireMock() + { + // Arrange + var locationApi = Services.GetRequiredService(); + var uniqueCep = "01310000"; // CEP Real da Avenida Paulista para testar se está batendo na internet + + WireMock.Server + .Given(global::WireMock.RequestBuilders.Request.Create() + .WithPath($"/ws/{uniqueCep}/json/") + .UsingGet()) + .RespondWith(global::WireMock.ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"cep\":\"01310000\",\"logradouro\":\"WIRE_MOCK_STUB\",\"complemento\":\"\",\"bairro\":\"Bela Vista\",\"localidade\":\"São Paulo\",\"uf\":\"SP\",\"erro\":false}")); + + // Act + var result = await locationApi.GetAddressFromCepAsync(uniqueCep); + + // Assert + if (!result.IsSuccess) + { + var debugInfo = $"[ERROR] Result failed: {result.Error}\n"; + var logs = WireMock.Server.LogEntries; + debugInfo += $"[DEBUG] WireMock Logs count: {logs.Count()}\n"; + foreach (var log in logs) + { + debugInfo += $"[WireMock Log] Request: {log.RequestMessage.Method} {log.RequestMessage.Url}\n"; + debugInfo += $"[WireMock Log] Response: {log.ResponseMessage.StatusCode}\n"; + } + + var logPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "debug_logs.txt"); + await System.IO.File.WriteAllTextAsync(logPath, debugInfo); + } + + result.IsSuccess.Should().BeTrue(because: $"Deveria ter funcionado. Erro: {result.Error}"); + result.Value!.Street.Should().Be("WIRE_MOCK_STUB"); + } +} diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/GeographicRestrictionConfigTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/GeographicRestrictionConfigTests.cs index 778c0e9b3..ab5db90f0 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/GeographicRestrictionConfigTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/GeographicRestrictionConfigTests.cs @@ -9,7 +9,6 @@ namespace MeAjudaAi.Integration.Tests.Modules.Locations; /// These tests validate that the middleware is properly configured and blocking requests. /// If these tests fail, it indicates a middleware registration or configuration issue. /// -[Collection("Integration")] public sealed class GeographicRestrictionConfigTests : BaseApiTest { protected override TestModule RequiredModules => TestModule.None; diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/GeographicRestrictionTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/GeographicRestrictionTests.cs index 30b090a7d..002b061fa 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/GeographicRestrictionTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/GeographicRestrictionTests.cs @@ -4,7 +4,7 @@ namespace MeAjudaAi.Integration.Tests.Modules.Locations; -public class GeographicRestrictionTests(ITestOutputHelper testOutput) : BaseApiTest +public class GeographicRestrictionTests : BaseApiTest { protected override TestModule RequiredModules => TestModule.Locations | TestModule.Providers; diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/IbgeUnavailabilityTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/IbgeUnavailabilityTests.cs index 4c379f6ef..381da29e7 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Locations/IbgeUnavailabilityTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Locations/IbgeUnavailabilityTests.cs @@ -9,9 +9,8 @@ namespace MeAjudaAi.Integration.Tests.Modules.Locations; /// Testes de integração para cenários de indisponibilidade do serviço IBGE. /// Valida que o middleware de restrição geográfica trata corretamente falhas do IBGE /// fazendo fallback para validação simples (correspondência de nome de cidade/estado). -/// Usa IGeographicValidationService real com stubs WireMock para a API do IBGE. +/// Utiliza o IGeographicValidationService real com stubs WireMock para a API do IBGE. /// -[Collection("Integration")] public sealed class IbgeUnavailabilityTests : BaseApiTest { protected override TestModule RequiredModules => TestModule.Locations | TestModule.Providers; diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/SearchProviders/SearchProvidersApiTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/SearchProviders/SearchProvidersApiTests.cs index cfeab16bc..911b57c9b 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/SearchProviders/SearchProvidersApiTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/SearchProviders/SearchProvidersApiTests.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Net; using FluentAssertions; +using MeAjudaAi.Contracts.Modules.SearchProviders.Enums; using MeAjudaAi.Integration.Tests.Base; namespace MeAjudaAi.Integration.Tests.Modules.SearchProviders; @@ -127,7 +128,7 @@ public async Task Search_WithSubscriptionTierFilter_ShouldAcceptParameter() var latitude = -23.5505; var longitude = -46.6333; var radiusInKm = 10.0; - var subscriptionTier = 2; // Gold + var subscriptionTier = nameof(ESubscriptionTier.Gold); // Act var response = await Client.GetAsync( @@ -146,7 +147,7 @@ public async Task Search_WithMultipleFilters_ShouldAcceptParameters() var longitude = -46.6333; var radiusInKm = 10.0; var minRating = 3.5; - var subscriptionTier = 1; // Standard + var subscriptionTier = (int)ESubscriptionTier.Standard; // ESubscriptionTier.Standard = 1 // Act var response = await Client.GetAsync( diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/SearchProviders/SearchProvidersE2ETests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/SearchProviders/SearchProvidersE2ETests.cs new file mode 100644 index 000000000..f5001bc6d --- /dev/null +++ b/tests/MeAjudaAi.Integration.Tests/Modules/SearchProviders/SearchProvidersE2ETests.cs @@ -0,0 +1,171 @@ +using MeAjudaAi.Integration.Tests.Base; +using FluentAssertions; +using System.Net; +using System.Net.Http.Json; +using System.Globalization; +using System.Text.Json; +using System.Linq; +using MeAjudaAi.Contracts.Models; +using MeAjudaAi.Shared.Serialization; +using MeAjudaAi.Modules.SearchProviders.Application.DTOs; + +namespace MeAjudaAi.Integration.Tests.Modules.SearchProviders; + +public class SearchProvidersE2ETests : BaseApiTest +{ + protected override TestModule RequiredModules => TestModule.SearchProviders; + + [Fact] + public async Task Search_ByServiceAndRadius_ShouldReturnNearbyProviders() + { + // 1. Arrange: Coordenadas do centro de São Paulo e o ID do serviço de teste + string lat = (-23.5505).ToString(CultureInfo.InvariantCulture); + string lon = (-46.6333).ToString(CultureInfo.InvariantCulture); + string radius = (10.0).ToString(CultureInfo.InvariantCulture); + var serviceId = BaseApiTest.TestServiceId; + + // 2. Act: Busca com filtro de serviço + var response = await Client.GetAsync($"/api/v1/search/providers?latitude={lat}&longitude={lon}&radiusInKm={radius}&serviceIds={serviceId}"); + + // 3. Assert + var responseBody = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.Should().BeTrue($"Search request failed with status {response.StatusCode}. Body: {responseBody}"); + + var result = JsonSerializer.Deserialize>(responseBody, SerializationDefaults.Api); + result.Should().NotBeNull(); + result!.Items.Should().NotBeEmpty("At least one provider should match the service and radius filter"); + + // Validar filtro de serviço e raio + result.Items.Should().OnlyContain(x => x.ServiceIds.Contains(serviceId), "All returned providers must offer the requested service"); + result.Items.Should().OnlyContain(x => x.DistanceInKm.HasValue && x.DistanceInKm <= 10.0, "All returned providers must be within the 10km radius"); + } + + [Fact] + public async Task Search_WithSmallRadius_ShouldFilterOutDistantProviders() + { + // Arrange + string lat = (-23.5505).ToString(CultureInfo.InvariantCulture); + string lon = (-46.6333).ToString(CultureInfo.InvariantCulture); + double tinyRadiusVal = 0.1; + string tinyRadius = tinyRadiusVal.ToString(CultureInfo.InvariantCulture); // 100 metros + + // Act + var response = await Client.GetAsync($"/api/v1/search/providers?latitude={lat}&longitude={lon}&radiusInKm={tinyRadius}"); + + // Assert + var responseBody = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.Should().BeTrue($"Search request failed with status {response.StatusCode}. Body: {responseBody}"); + + var result = JsonSerializer.Deserialize>(responseBody, SerializationDefaults.Api); + result.Should().NotBeNull(); + result!.Items.Should().NotBeEmpty("At least one provider should be within the tiny radius for this test to be valid"); + + foreach (var provider in result.Items) + { + provider.DistanceInKm.HasValue.Should().BeTrue("DistanceInKm should be calculated"); + if (provider.DistanceInKm.HasValue) + { + provider.DistanceInKm.Value.Should().BeLessThanOrEqualTo(tinyRadiusVal, $"Provider {provider.Name} should be within {tinyRadiusVal}km"); + } + } + } + + [Fact] + public async Task Search_ShouldBeOrderedByDistanceAscending() + { + // Arrange + string lat = (-23.5505).ToString(CultureInfo.InvariantCulture); + string lon = (-46.6333).ToString(CultureInfo.InvariantCulture); + string radius = (50.0).ToString(CultureInfo.InvariantCulture); + + // Act + var response = await Client.GetAsync($"/api/v1/search/providers?latitude={lat}&longitude={lon}&radiusInKm={radius}"); + + // Assert + var responseBody = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.Should().BeTrue($"Search request failed with status {response.StatusCode}. Body: {responseBody}"); + + var result = JsonSerializer.Deserialize>(responseBody, SerializationDefaults.Api); + result.Should().NotBeNull(); + result!.Items.Should().NotBeEmpty(); + result.Items.Should().OnlyContain(x => x.DistanceInKm.HasValue); + + // Verificar ordenação composta: SubscriptionTier (DESC), AverageRating (DESC), DistanceInKm (ASC) + var orderedItems = result.Items + .OrderByDescending(x => x.SubscriptionTier) + .ThenByDescending(x => x.AverageRating) + .ThenBy(x => x.DistanceInKm) + .ToList(); + + result.Items.Should().Equal(orderedItems, "A lista deve estar ordenada por Tier, Rating e Distância"); + } + + [Fact] + public async Task Search_WithNoResults_ShouldReturnEmptyPage() + { + // Arrange: Coordenadas da Antártida (nenhum prestador esperado) + string lat = (-90.0).ToString(CultureInfo.InvariantCulture); + string lon = (0.0).ToString(CultureInfo.InvariantCulture); + string radius = (1.0).ToString(CultureInfo.InvariantCulture); + + // Act + var response = await Client.GetAsync($"/api/v1/search/providers?latitude={lat}&longitude={lon}&radiusInKm={radius}"); + + // Assert + var responseBody = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.Should().BeTrue($"Search request failed with status {response.StatusCode}. Body: {responseBody}"); + + var result = JsonSerializer.Deserialize>(responseBody, SerializationDefaults.Api); + result.Should().NotBeNull(); + result!.Items.Should().BeEmpty(); + result.TotalItems.Should().Be(0); + } + + [Fact] + public async Task Search_Pagination_ShouldWork() + { + // Arrange + string lat = (-23.5505).ToString(CultureInfo.InvariantCulture); + string lon = (-46.6333).ToString(CultureInfo.InvariantCulture); + string radius = (100.0).ToString(CultureInfo.InvariantCulture); + int pageSize = 2; + + // Act + var response = await Client.GetAsync($"/api/v1/search/providers?latitude={lat}&longitude={lon}&radiusInKm={radius}&page=1&pageSize={pageSize}"); + + // Assert + var responseBody = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.Should().BeTrue($"Search request failed with status {response.StatusCode}. Body: {responseBody}"); + + var result = JsonSerializer.Deserialize>(responseBody, SerializationDefaults.Api); + result.Should().NotBeNull(); + result!.Items.Count.Should().BeLessThanOrEqualTo(pageSize); + result.PageSize.Should().Be(pageSize); + } + + [Fact] + [Trait("Category", "Performance")] + public async Task Search_Performance_ShouldBeWithinLimit() + { + // Arrange + string lat = (-23.5505).ToString(CultureInfo.InvariantCulture); + string lon = (-46.6333).ToString(CultureInfo.InvariantCulture); + string radius = (20.0).ToString(CultureInfo.InvariantCulture); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + // Act + var response = await Client.GetAsync($"/api/v1/search/providers?latitude={lat}&longitude={lon}&radiusInKm={radius}"); + + // Assert + stopwatch.Stop(); + var responseBody = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.Should().BeTrue($"Search request failed with status {response.StatusCode}. Body: {responseBody}"); + + var result = JsonSerializer.Deserialize>(responseBody, SerializationDefaults.Api); + result.Should().NotBeNull(); + result!.Items.Should().NotBeNullOrEmpty(); + + // Threshold aumentado para 10s para evitar falhas intermitentes em ambientes de CI lentos + stopwatch.ElapsedMilliseconds.Should().BeLessThanOrEqualTo(10000, $"A busca deve ser rápida (< 10s). Tempo: {stopwatch.ElapsedMilliseconds}ms"); + } + } \ No newline at end of file diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/ServiceCatalogs/ServiceCatalogsEndpointsTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/ServiceCatalogs/ServiceCatalogsEndpointsTests.cs index 14982a17b..0ebfc9bbd 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/ServiceCatalogs/ServiceCatalogsEndpointsTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/ServiceCatalogs/ServiceCatalogsEndpointsTests.cs @@ -6,7 +6,7 @@ namespace MeAjudaAi.Integration.Tests.Modules.ServiceCatalogs; -public class ServiceCatalogsEndpointsTests(ITestOutputHelper testOutput) : BaseApiTest +public class ServiceCatalogsEndpointsTests : BaseApiTest { protected override TestModule RequiredModules => TestModule.ServiceCatalogs; diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Users/UserRepositoryIntegrationTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Users/UserRepositoryIntegrationTests.cs index 77f2aebdd..5324db85f 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Users/UserRepositoryIntegrationTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Users/UserRepositoryIntegrationTests.cs @@ -158,8 +158,9 @@ public async Task UpdateAsync_WithModifiedUser_ShouldPersistChanges() private User CreateValidUser() { - var username = new Username(_faker.Internet.UserName()); - var email = new Email(_faker.Internet.Email()); + var suffix = Guid.NewGuid().ToString("n")[..6]; + var username = new Username(_faker.Internet.UserName() + "_" + suffix); + var email = new Email(suffix + "_" + _faker.Internet.Email()); var firstName = _faker.Name.FirstName(); var lastName = _faker.Name.LastName(); var keycloakId = UuidGenerator.NewId().ToString(); diff --git a/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersEndpointsTests.cs b/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersEndpointsTests.cs index b8fc2a3b7..fa293eaac 100644 --- a/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersEndpointsTests.cs +++ b/tests/MeAjudaAi.Integration.Tests/Modules/Users/UsersEndpointsTests.cs @@ -6,7 +6,7 @@ namespace MeAjudaAi.Integration.Tests.Modules.Users; -public class UsersEndpointsTests(ITestOutputHelper testOutput) : BaseApiTest +public class UsersEndpointsTests : BaseApiTest { protected override TestModule RequiredModules => TestModule.Users; diff --git a/tests/MeAjudaAi.Integration.Tests/appsettings.Testing.json b/tests/MeAjudaAi.Integration.Tests/appsettings.Testing.json index c4c941029..7c112a87d 100644 --- a/tests/MeAjudaAi.Integration.Tests/appsettings.Testing.json +++ b/tests/MeAjudaAi.Integration.Tests/appsettings.Testing.json @@ -50,4 +50,4 @@ "Linhares" ] } -} \ No newline at end of file +} diff --git a/tests/MeAjudaAi.Integration.Tests/packages.lock.json b/tests/MeAjudaAi.Integration.Tests/packages.lock.json index c64f15bd1..5714996f3 100644 --- a/tests/MeAjudaAi.Integration.Tests/packages.lock.json +++ b/tests/MeAjudaAi.Integration.Tests/packages.lock.json @@ -201,10 +201,10 @@ "Microsoft.Extensions.Primitives": "8.0.0" } }, - "Aspire.Dashboard.Sdk.linux-x64": { + "Aspire.Dashboard.Sdk.win-x64": { "type": "Transitive", "resolved": "13.2.1", - "contentHash": "rUlEhekc+EyDbOcyfWneGBikNvdLuV5UPtOww2KpUOAcO7oBVG70kTJiFzN/UYU3I/5Udc1xoDt2lWIoyEYADQ==" + "contentHash": "KLB9rXwY8kg2taWwxsJFoK0cAuupSZurcv1zTyYMqLyNuwvYYjs65Yz3g/cgh22QlUfOT3tOh+Jzk5MdJhy5+w==" }, "Aspire.Hosting": { "type": "Transitive", @@ -430,10 +430,10 @@ "System.IO.Hashing": "10.0.3" } }, - "Aspire.Hosting.Orchestration.linux-x64": { + "Aspire.Hosting.Orchestration.win-x64": { "type": "Transitive", "resolved": "13.2.1", - "contentHash": "LcC21cYVVsTDSQe4B0i7X2q1U8u5Bl+X53wPfucfW4YlQhAGzG2FajVfa5PRDduGlp5mjtgjh2vDO4oEBfpSUg==" + "contentHash": "39lRUH4WuCsBaYB7fZH1/r81SSJIXrA8WphBlAdP1QT95+1sKQHzXJuXU4nzKpBLv4oZmjcWzvA+FDMGZbWmkw==" }, "AspNetCore.HealthChecks.Rabbitmq": { "type": "Transitive", @@ -2526,6 +2526,7 @@ "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", @@ -2544,13 +2545,13 @@ "meajudaai.apphost": { "type": "Project", "dependencies": { - "Aspire.Dashboard.Sdk.linux-x64": "[13.2.1, )", + "Aspire.Dashboard.Sdk.win-x64": "[13.2.1, )", "Aspire.Hosting.AppHost": "[13.2.1, )", "Aspire.Hosting.Azure.AppContainers": "[13.2.1, )", "Aspire.Hosting.Azure.PostgreSQL": "[13.2.1, )", "Aspire.Hosting.JavaScript": "[13.2.1, )", "Aspire.Hosting.Keycloak": "[13.2.1-preview.1.26180.6, )", - "Aspire.Hosting.Orchestration.linux-x64": "[13.2.1, )", + "Aspire.Hosting.Orchestration.win-x64": "[13.2.1, )", "Aspire.Hosting.PostgreSQL": "[13.2.1, )", "Aspire.Hosting.RabbitMQ": "[13.2.1, )", "Aspire.Hosting.Redis": "[13.2.1, )", @@ -2572,6 +2573,41 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -2852,6 +2888,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )", diff --git a/tests/MeAjudaAi.Shared.Tests/TestInfrastructure/Base/BaseDatabaseTest.cs b/tests/MeAjudaAi.Shared.Tests/TestInfrastructure/Base/BaseDatabaseTest.cs index 8408db194..5cb90d0e6 100644 --- a/tests/MeAjudaAi.Shared.Tests/TestInfrastructure/Base/BaseDatabaseTest.cs +++ b/tests/MeAjudaAi.Shared.Tests/TestInfrastructure/Base/BaseDatabaseTest.cs @@ -53,6 +53,7 @@ protected DbContextOptions CreateDbContextOptions() where TC { return new DbContextOptionsBuilder() .UseNpgsql(ConnectionString) + .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)) .EnableSensitiveDataLogging() .EnableDetailedErrors() .Options; diff --git a/tests/MeAjudaAi.Shared.Tests/Unit/Constants/ModuleNamesTests.cs b/tests/MeAjudaAi.Shared.Tests/Unit/Constants/ModuleNamesTests.cs index cbdcc825a..dc25e38e5 100644 --- a/tests/MeAjudaAi.Shared.Tests/Unit/Constants/ModuleNamesTests.cs +++ b/tests/MeAjudaAi.Shared.Tests/Unit/Constants/ModuleNamesTests.cs @@ -18,7 +18,7 @@ public class ModuleNamesTests [InlineData(ModuleNames.SearchProviders, true)] [InlineData(ModuleNames.Locations, true)] [InlineData(ModuleNames.Bookings, true)] - [InlineData(ModuleNames.Notifications, true)] + [InlineData(ModuleNames.Communications, true)] [InlineData(ModuleNames.Payments, true)] [InlineData(ModuleNames.Reports, true)] [InlineData(ModuleNames.Reviews, true)] @@ -90,7 +90,7 @@ public void IsImplemented_WithImplementedModule_ShouldReturnTrue(string moduleNa [Theory] [InlineData(ModuleNames.Bookings)] - [InlineData(ModuleNames.Notifications)] + [InlineData(ModuleNames.Communications)] [InlineData(ModuleNames.Payments)] [InlineData(ModuleNames.Reports)] [InlineData(ModuleNames.Reviews)] @@ -182,7 +182,7 @@ public void AllModules_ShouldContainPlannedModules() var plannedModules = new[] { ModuleNames.Bookings, - ModuleNames.Notifications, + ModuleNames.Communications, ModuleNames.Payments, ModuleNames.Reports, ModuleNames.Reviews @@ -230,7 +230,7 @@ public void ModuleNames_AllConstantsShouldBeNotNullOrEmpty() ModuleNames.SearchProviders, ModuleNames.Locations, ModuleNames.Bookings, - ModuleNames.Notifications, + ModuleNames.Communications, ModuleNames.Payments, ModuleNames.Reports, ModuleNames.Reviews @@ -304,7 +304,7 @@ public void PlannedModules_ShouldBeValidButNotImplemented() var plannedModules = new[] { ModuleNames.Bookings, - ModuleNames.Notifications, + ModuleNames.Communications, ModuleNames.Payments, ModuleNames.Reports, ModuleNames.Reviews diff --git a/tests/MeAjudaAi.Shared.Tests/Unit/Database/Outbox/OutboxMessageTests.cs b/tests/MeAjudaAi.Shared.Tests/Unit/Database/Outbox/OutboxMessageTests.cs new file mode 100644 index 000000000..9d31b87f3 --- /dev/null +++ b/tests/MeAjudaAi.Shared.Tests/Unit/Database/Outbox/OutboxMessageTests.cs @@ -0,0 +1,100 @@ +using MeAjudaAi.Shared.Database.Outbox; +using MeAjudaAi.Contracts.Shared; +using FluentAssertions; +using Xunit; + +namespace MeAjudaAi.Shared.Tests.Unit.Database.Outbox; + +public class OutboxMessageTests +{ + [Fact] + public void Create_WithValidData_ShouldCreateInPendingStatus() + { + // Act + var message = OutboxMessage.Create("Type", "Payload", ECommunicationPriority.High); + + // Assert + message.Type.Should().Be("Type"); + message.Payload.Should().Be("Payload"); + message.Status.Should().Be(EOutboxMessageStatus.Pending); + message.Priority.Should().Be(ECommunicationPriority.High); + message.RetryCount.Should().Be(0); + } + + [Fact] + public void IsReadyToProcess_WhenPendingAndNoScheduledDate_ShouldBeTrue() + { + // Arrange + var message = OutboxMessage.Create("T", "P"); + + // Act & Assert + message.IsReadyToProcess(DateTime.UtcNow).Should().BeTrue(); + } + + [Fact] + public void IsReadyToProcess_WhenFutureScheduledDate_ShouldBeFalse() + { + // Arrange + var message = OutboxMessage.Create("T", "P", scheduledAt: DateTime.UtcNow.AddMinutes(5)); + + // Act & Assert + message.IsReadyToProcess(DateTime.UtcNow).Should().BeFalse(); + } + + [Fact] + public void MarkAsProcessing_ShouldUpdateStatus() + { + // Arrange + var message = OutboxMessage.Create("T", "P"); + + // Act + message.MarkAsProcessing(); + + // Assert + message.Status.Should().Be(EOutboxMessageStatus.Processing); + } + + [Fact] + public void MarkAsSent_ShouldUpdateStatusAndSentAt() + { + // Arrange + var message = OutboxMessage.Create("T", "P"); + var sentAt = DateTime.UtcNow; + + // Act + message.MarkAsSent(sentAt); + + // Assert + message.Status.Should().Be(EOutboxMessageStatus.Sent); + message.SentAt.Should().Be(sentAt); + } + + [Fact] + public void MarkAsFailed_WhenBelowMaxRetries_ShouldStayPendingAndIncrementCount() + { + // Arrange + var message = OutboxMessage.Create("T", "P", maxRetries: 3); + + // Act + message.MarkAsFailed("Error"); + + // Assert + message.Status.Should().Be(EOutboxMessageStatus.Pending); + message.RetryCount.Should().Be(1); + message.ErrorMessage.Should().Be("Error"); + } + + [Fact] + public void MarkAsFailed_WhenMaxRetriesReached_ShouldSetStatusToFailed() + { + // Arrange + var message = OutboxMessage.Create("T", "P", maxRetries: 1); + + // Act + message.MarkAsFailed("Fatal"); + + // Assert + message.Status.Should().Be(EOutboxMessageStatus.Failed); + message.HasRetriesLeft.Should().BeFalse(); + } +} diff --git a/tests/MeAjudaAi.Shared.Tests/Unit/Database/Outbox/OutboxProcessorBaseTests.cs b/tests/MeAjudaAi.Shared.Tests/Unit/Database/Outbox/OutboxProcessorBaseTests.cs new file mode 100644 index 000000000..c99899acd --- /dev/null +++ b/tests/MeAjudaAi.Shared.Tests/Unit/Database/Outbox/OutboxProcessorBaseTests.cs @@ -0,0 +1,107 @@ +using MeAjudaAi.Shared.Database.Outbox; +using MeAjudaAi.Contracts.Shared; +using Microsoft.Extensions.Logging; +using Moq; +using FluentAssertions; +using Xunit; + +namespace MeAjudaAi.Shared.Tests.Unit.Database.Outbox; + +public class OutboxProcessorBaseTests +{ + private readonly Mock> _repositoryMock; + private readonly Mock _loggerMock; + private readonly TestOutboxProcessor _processor; + + public OutboxProcessorBaseTests() + { + _repositoryMock = new Mock>(); + _loggerMock = new Mock(); + _processor = new TestOutboxProcessor(_repositoryMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task ProcessPendingMessagesAsync_WhenSuccessfulDispatch_ShouldMarkAsSent() + { + // Arrange + var message = OutboxMessage.Create("T", "P"); + _repositoryMock.Setup(x => x.GetPendingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { message }); + + _processor.DispatchResultToReturn = TestOutboxProcessor.DispatchResult.Success(); + + // Act + var result = await _processor.ProcessPendingMessagesAsync(); + + // Assert + result.Should().Be(1); + message.Status.Should().Be(EOutboxMessageStatus.Sent); + _processor.OnSuccessCalled.Should().BeTrue(); + _repositoryMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.AtLeast(2)); // Mark processing + Mark Sent + } + + [Fact] + public async Task ProcessPendingMessagesAsync_WhenDispatchFails_ShouldMarkAsFailed() + { + // Arrange + var message = OutboxMessage.Create("T", "P", maxRetries: 1); + _repositoryMock.Setup(x => x.GetPendingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { message }); + + _processor.DispatchResultToReturn = TestOutboxProcessor.DispatchResult.Failure("Error"); + + // Act + await _processor.ProcessPendingMessagesAsync(); + + // Assert + message.Status.Should().Be(EOutboxMessageStatus.Failed); + _processor.OnFailureCalled.Should().BeTrue(); + } + + [Fact] + public async Task ProcessPendingMessagesAsync_WhenExceptionOccurs_ShouldHandleAndMarkAsFailed() + { + // Arrange + var message = OutboxMessage.Create("T", "P", maxRetries: 3); + _repositoryMock.Setup(x => x.GetPendingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { message }); + + _processor.ShouldThrowException = true; + + // Act + await _processor.ProcessPendingMessagesAsync(); + + // Assert + message.Status.Should().Be(EOutboxMessageStatus.Pending); // Stay pending because retry count < max + message.RetryCount.Should().Be(1); + _processor.OnFailureCalled.Should().BeTrue(); + } + + // Concrete implementation for testing the abstract base + private class TestOutboxProcessor(IOutboxRepository repository, ILogger logger) + : OutboxProcessorBase(repository, logger) + { + public DispatchResult DispatchResultToReturn { get; set; } = DispatchResult.Success(); + public bool ShouldThrowException { get; set; } + public bool OnSuccessCalled { get; private set; } + public bool OnFailureCalled { get; private set; } + + protected override Task DispatchAsync(OutboxMessage message, CancellationToken cancellationToken) + { + if (ShouldThrowException) throw new Exception("Unexpected"); + return Task.FromResult(DispatchResultToReturn); + } + + protected override Task OnSuccessAsync(OutboxMessage message, CancellationToken cancellationToken) + { + OnSuccessCalled = true; + return Task.CompletedTask; + } + + protected override Task OnFailureAsync(OutboxMessage message, string? error, CancellationToken cancellationToken) + { + OnFailureCalled = true; + return Task.CompletedTask; + } + } +} diff --git a/tests/MeAjudaAi.Shared.Tests/Unit/Monitoring/BusinessMetricsTests.cs b/tests/MeAjudaAi.Shared.Tests/Unit/Monitoring/BusinessMetricsTests.cs index 54e297485..8f10266c6 100644 --- a/tests/MeAjudaAi.Shared.Tests/Unit/Monitoring/BusinessMetricsTests.cs +++ b/tests/MeAjudaAi.Shared.Tests/Unit/Monitoring/BusinessMetricsTests.cs @@ -11,9 +11,12 @@ public sealed class BusinessMetricsTests : IDisposable private readonly BusinessMetrics _sut; private readonly List> _longMeasurements; private readonly List> _doubleMeasurements; + private readonly string _meterName; + private readonly object _lock = new(); public BusinessMetricsTests() { + _meterName = $"{BusinessMetrics.DefaultMeterName}.Test.{Guid.NewGuid()}"; _longMeasurements = new List>(); _doubleMeasurements = new List>(); @@ -21,7 +24,7 @@ public BusinessMetricsTests() { InstrumentPublished = (instrument, listener) => { - if (instrument.Meter.Name == "MeAjudaAi.Business") + if (instrument.Meter.Name == _meterName) { listener.EnableMeasurementEvents(instrument); } @@ -30,17 +33,23 @@ public BusinessMetricsTests() _meterListener.SetMeasurementEventCallback((_, measurement, tags, _) => { - _longMeasurements.Add(new Measurement(measurement, tags)); + lock (_lock) + { + _longMeasurements.Add(new Measurement(measurement, tags)); + } }); _meterListener.SetMeasurementEventCallback((_, measurement, tags, _) => { - _doubleMeasurements.Add(new Measurement(measurement, tags)); + lock (_lock) + { + _doubleMeasurements.Add(new Measurement(measurement, tags)); + } }); _meterListener.Start(); - _sut = new BusinessMetrics(); + _sut = new BusinessMetrics(_meterName); } [Fact] @@ -50,7 +59,7 @@ public void RecordUserRegistration_ShouldIncrementCounterWithSourceTag() _sut.RecordUserRegistration("mobile"); // Assert - var metric = _longMeasurements.Should().ContainSingle().Subject; + var metric = GetSingleLongMeasurement(); metric.Value.Should().Be(1); metric.Tags.ToArray().Should().ContainEquivalentOf(new KeyValuePair("source", "mobile")); } @@ -62,7 +71,7 @@ public void RecordUserLogin_ShouldIncrementCounterWithUserAndMethodTags() _sut.RecordUserLogin("user-123", "oauth"); // Assert - var metric = _longMeasurements.Should().ContainSingle().Subject; + var metric = GetSingleLongMeasurement(); metric.Value.Should().Be(1); var tags = metric.Tags.ToArray(); @@ -77,7 +86,7 @@ public void UpdateActiveUsers_ShouldRecordGaugeValue() _sut.UpdateActiveUsers(42); // Assert - var metric = _longMeasurements.Should().ContainSingle().Subject; + var metric = GetSingleLongMeasurement(); metric.Value.Should().Be(42); } @@ -88,7 +97,7 @@ public void RecordHelpRequestCreated_ShouldIncrementCounterWithCategoryAndUrgenc _sut.RecordHelpRequestCreated("medical", "high"); // Assert - var metric = _longMeasurements.Should().ContainSingle().Subject; + var metric = GetSingleLongMeasurement(); metric.Value.Should().Be(1); var tags = metric.Tags.ToArray(); @@ -103,7 +112,7 @@ public void RecordHelpRequestCompleted_ShouldIncrementCounterWithCategory() _sut.RecordHelpRequestCompleted("medical", TimeSpan.FromMinutes(30)); // Assert - var metric = _longMeasurements.Should().ContainSingle().Subject; + var metric = GetSingleLongMeasurement(); metric.Value.Should().Be(1); metric.Tags.ToArray().Should().ContainEquivalentOf(new KeyValuePair("category", "medical")); } @@ -115,7 +124,7 @@ public void RecordHelpRequestDuration_ShouldRecordHistogramValueInSeconds() _sut.RecordHelpRequestDuration(TimeSpan.FromSeconds(120), "plumbing"); // Assert - var metric = _doubleMeasurements.Should().ContainSingle().Subject; + var metric = GetSingleDoubleMeasurement(); metric.Value.Should().Be(120); metric.Tags.ToArray().Should().ContainEquivalentOf(new KeyValuePair("category", "plumbing")); } @@ -127,7 +136,7 @@ public void UpdatePendingHelpRequests_ShouldRecordGaugeValue() _sut.UpdatePendingHelpRequests(15); // Assert - var metric = _longMeasurements.Should().ContainSingle().Subject; + var metric = GetSingleLongMeasurement(); metric.Value.Should().Be(15); } @@ -138,7 +147,7 @@ public void RecordApiCall_ShouldIncrementCounterWithEndpointMethodAndStatus() _sut.RecordApiCall("/api/users", "GET", 200); // Assert - var metric = _longMeasurements.Should().ContainSingle().Subject; + var metric = GetSingleLongMeasurement(); metric.Value.Should().Be(1); var tags = metric.Tags.ToArray(); @@ -154,14 +163,33 @@ public void RecordDatabaseQuery_ShouldRecordHistogramValueInSeconds() _sut.RecordDatabaseQuery(TimeSpan.FromMilliseconds(500), "SELECT"); // Assert - var metric = _doubleMeasurements.Should().ContainSingle().Subject; + var metric = GetSingleDoubleMeasurement(); metric.Value.Should().Be(0.5); metric.Tags.ToArray().Should().ContainEquivalentOf(new KeyValuePair("operation", "SELECT")); } + private Measurement GetSingleLongMeasurement() + { + lock (_lock) + { + return _longMeasurements.Should().ContainSingle().Subject; + } + } + + private Measurement GetSingleDoubleMeasurement() + { + lock (_lock) + { + return _doubleMeasurements.Should().ContainSingle().Subject; + } + } + public void Dispose() { - _meterListener.Dispose(); - _sut.Dispose(); + lock (_lock) + { + _meterListener.Dispose(); + _sut.Dispose(); + } } } diff --git a/tests/MeAjudaAi.Shared.Tests/packages.lock.json b/tests/MeAjudaAi.Shared.Tests/packages.lock.json index f6250e398..68669b127 100644 --- a/tests/MeAjudaAi.Shared.Tests/packages.lock.json +++ b/tests/MeAjudaAi.Shared.Tests/packages.lock.json @@ -1098,6 +1098,7 @@ "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "AspNetCore.HealthChecks.UI.Client": "[9.0.0, )", "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.API": "[1.0.0, )", "MeAjudaAi.Modules.Documents.API": "[1.0.0, )", "MeAjudaAi.Modules.Locations.API": "[1.0.0, )", "MeAjudaAi.Modules.Providers.API": "[1.0.0, )", @@ -1119,6 +1120,41 @@ "FluentValidation": "[12.1.1, )" } }, + "meajudaai.modules.communications.api": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Infrastructure": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )", + "Microsoft.AspNetCore.Http.Abstractions": "[2.3.9, )", + "Microsoft.EntityFrameworkCore.Relational": "[10.0.5, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.5, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.5, )" + } + }, + "meajudaai.modules.communications.application": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Contracts": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.domain": { + "type": "Project", + "dependencies": { + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, + "meajudaai.modules.communications.infrastructure": { + "type": "Project", + "dependencies": { + "EFCore.NamingConventions": "[10.0.1, )", + "MeAjudaAi.Modules.Communications.Application": "[1.0.0, )", + "MeAjudaAi.Modules.Communications.Domain": "[1.0.0, )", + "MeAjudaAi.Shared": "[1.0.0, )" + } + }, "meajudaai.modules.documents.api": { "type": "Project", "dependencies": { @@ -1357,6 +1393,7 @@ "AspNetCore.HealthChecks.Npgsql": "[9.0.0, )", "AspNetCore.HealthChecks.Redis": "[9.0.0, )", "Dapper": "[2.1.72, )", + "EFCore.NamingConventions": "[10.0.1, )", "FluentValidation": "[12.1.1, )", "FluentValidation.DependencyInjectionExtensions": "[12.1.1, )", "Hangfire.AspNetCore": "[1.8.23, )",