diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000..d692a3943 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,368 @@ +# PLAN.md + +This document outlines the implementation plan for the next phase of the **MeAjudaAi** platform, focusing on core MVP features. It is a living document that will guide development efforts. + +## 1. Introduction + +The goal of this plan is to define the steps required to implement critical features for the platform's MVP. This includes multi-step provider registration with verification, geolocation-based search, service management, provider ratings, and a subscription-based monetization model. + +The implementation will adhere to the existing architectural principles outlined in `WARP.md`, including a Modular Monolith structure, DDD, CQRS, and schema-per-module isolation. + +## 2. High-Level Implementation Strategy + +The work will be broken down into three main phases to deliver value incrementally. + +- **Phase 1: Foundational Provider Enhancements.** Focus on enhancing the provider registration flow with document verification and establishing the core search and location capabilities. +- **Phase 2: Quality and Monetization.** Introduce the review and rating system, and implement the provider subscription model with Stripe integration. +- **Phase 3: User Experience and Engagement.** Develop the service booking/scheduling features and a robust communications module. + +--- + +## 3. Module Implementation Plan + +### 3.1. (Enhancement) Providers & Identity Module + +#### Purpose +To extend the existing `Providers` module to support a multi-step, verification-driven registration process. + +#### Implementation Steps +1. **Review Existing `Providers` Module**: Analyze the current registration flow to identify integration points for the new steps. +2. **Introduce Provider State**: Update the `Provider` aggregate to include a status property (e.g., `PendingBasicInfo`, `PendingDocumentVerification`, `Active`, `Suspended`). +3. **Develop Multi-Step API Endpoints**: Create or update API endpoints to handle partial registration data, allowing a provider to save their progress at each step. +4. **Integrate with Document Module**: Once the `Documents` module is available, the `Providers` module will trigger the verification process by calling its module API. +5. **Update Domain Events**: Publish new domain events like `ProviderAwaitingVerification` and `ProviderActivated`. + +--- + +### 3.2. (New) Media, Storage & Documents Module + +#### Purpose +To manage secure uploading, storage, and verification of provider documents (e.g., ID, proof of residence). This module will be critical for provider validation. + +#### Proposed Architecture +A standard layered architecture. Given the potential for resource-intensive operations like OCR, this module's background processing could be a candidate for future extraction into a separate microservice. + +#### Domain Design & Key Entities +- `Document`: Aggregate Root. Properties: `DocumentId`, `ProviderId`, `DocumentType` (enum: ID, CriminalRecord), `FileUrl`, `Status` (enum: `Uploaded`, `VerificationPassed`, `VerificationFailed`). +- `VerificationRequest`: Entity tracking a request to a third-party verification service. + +#### Proposed Interfaces (`Shared/Contracts/Modules/Documents/`) +```csharp +public interface IDocumentsModuleApi : IModuleApi +{ + Task> UploadDocumentAsync(UploadDocumentRequest request, CancellationToken ct = default); + Task> GetDocumentStatusAsync(Guid documentId, CancellationToken ct = default); +} +``` + +#### Implementation Steps +1. **Create Module Structure**: Follow the "Adding a New Module" guide in `WARP.md`. +2. **Setup Secure Storage**: Configure a private Azure Blob Storage container or S3 bucket for document storage. +3. **Implement Secure Upload Endpoint**: Create an API endpoint that generates a short-lived SAS token (Shared Access Signature) for the client to upload the file directly to blob storage. This avoids routing large files through the API server. +4. **Integrate OCR Service**: Create a service to process uploaded documents using an OCR tool (e.g., Azure AI Vision) to extract text for validation. +5. **Integrate Background Check API**: Implement a service to connect with a third-party API for criminal background checks. This should be an asynchronous process. +6. **Create Database Schema**: Define the `meajudaai_documents` schema and tables. +7. **Develop Background Worker**: Use a hosted service (IHostedService) to process documents in the background, calling OCR and other verification services. +8. **Develop Tests**: Create unit and integration tests for document upload, status checks, and background processing, ensuring compliance with the project's testing standards. + +--- + +### 3.3. (New) Search & Discovery Module + +#### Purpose +To enable users to find providers based on geolocation, service type, rating, and subscription tier. + +#### Proposed Architecture +**CQRS**. The read side will use a denormalized data model optimized for complex queries, stored either in a dedicated search engine like **Elasticsearch** or within PostgreSQL using its spatial extensions (**PostGIS**). Given the complexity of geo-queries and ranking, Elasticsearch is the recommended approach for scalability. + +#### Domain Design & Key Entities (Read Model) +- `SearchableProvider`: A flat, denormalized document. + - `ProviderId`, `Name`, `Location` (GeoPoint), `AverageRating`, `SubscriptionTier` (enum), `ServiceIds[]`. + +#### Proposed Interfaces (`Shared/Contracts/Modules/Search/`) +```csharp +public interface ISearchModuleApi : IModuleApi +{ + Task>> SearchProvidersAsync(ProviderSearchQuery query, CancellationToken ct = default); +} +``` + +#### Implementation Steps +1. **Create Module Structure**. +2. **Setup Search Index**: Configure an Elasticsearch index or a PostgreSQL table with PostGIS enabled. +3. **Create Indexing Worker**: Develop a background worker that subscribes to integration events from other modules (`ProviderUpdated`, `ReviewAdded`, `SubscriptionChanged`) and updates the `SearchableProvider` read model. +4. **Implement Search API**: Build the search endpoint that takes search parameters (latitude, longitude, radius, service type) and queries the read model. +5. **Implement Ranking Logic**: The search query must implement the specified ranking: + 1. Filter by radius. + 2. Sort by subscription tier (e.g., Platinum, Gold first). + 3. Sort by average rating (descending). +6. **Develop Tests**: Write unit tests for the ranking logic and integration tests for the indexing worker and search API endpoints. + +--- + +### 3.4. (New) Location Management Module + +#### Purpose +To abstract geolocation and address-related functionalities, including Brazilian CEP lookups. + +#### Proposed Architecture +A simple service-oriented module that acts as a wrapper or facade for external APIs. It will have minimal internal state. + +#### Proposed Interfaces (`Shared/Contracts/Modules/Location/`) +```csharp +public interface ILocationModuleApi : IModuleApi +{ + Task> GetAddressFromCepAsync(string cep, CancellationToken ct = default); + Task> GetCoordinatesFromAddressAsync(string address, CancellationToken ct = default); +} +``` + +#### Implementation Steps +1. **Create Module Structure**. +2. **Integrate CEP API**: Implement a client for a Brazilian CEP service like **ViaCEP** or **BrasilAPI**. +3. **Integrate Geocoding API**: Implement a client for a geocoding service (e.g., Google Maps, Nominatim) to convert addresses into latitude/longitude. +4. **Add Caching**: Use Redis to cache responses from these external APIs to reduce latency and cost. +5. **Develop Tests**: Create integration tests for the external API clients, using mocks to avoid actual HTTP calls in automated test runs. + +--- + +### 3.5. (New) Service Catalog Module + +#### Purpose +To manage the types of services that providers can offer. + +#### Proposed Architecture +Simple layered CRUD architecture. + +#### Domain Design & Key Entities +- `ServiceCategory`: Aggregate Root (e.g., "Cleaning", "Repairs"). +- `Service`: Aggregate Root (e.g., "Apartment Cleaning", "Faucet Repair"), linked to a `ServiceCategory`. +- `ProviderService`: Entity linking a `Provider` to a `Service`. + +#### Implementation Plan +I recommend a hybrid approach: +- An admin-managed catalog of `ServiceCategory` and `Service`. +- Providers select services they offer from this predefined catalog. +- (Future) Providers can suggest new services, which go into a moderation queue for admins to approve and add to the main catalog. + +#### Implementation Steps +1. **Create Module Structure**. +2. **Create Database Schema**: Define `meajudaai_services` schema with `ServiceCategories`, `Services`, and `ProviderServices` tables. +3. **Build Admin API**: Create endpoints for admins to manage categories and services. +4. **Update Provider API**: Extend the `Providers` module API to allow providers to add/remove services from their profile. +5. **Develop Tests**: Implement unit tests for the domain logic and integration tests for the admin and provider-facing APIs. + +--- + +### 3.6. (New) Reviews, Quality & Rating Module + +#### Purpose +To allow customers to rate and review providers, influencing their search ranking. + +#### Proposed Architecture +Simple layered architecture. + +#### Domain Design & Key Entities +- `Review`: Aggregate Root. Properties: `ReviewId`, `ProviderId`, `CustomerId`, `Rating` (1-5), `Comment` (optional), `CreatedAt`. +- `ProviderRating`: A separate aggregate (or part of the `Provider` read model) that stores the calculated `AverageRating` and `TotalReviews`. + +#### Proposed Interfaces (`Shared/Contracts/Modules/Reviews/`) +```csharp +public interface IReviewsModuleApi : IModuleApi +{ + Task SubmitReviewAsync(SubmitReviewRequest request, CancellationToken ct = default); + Task>> GetReviewsForProviderAsync(Guid providerId, int page, int pageSize, CancellationToken ct = default); +} +``` + +#### Implementation Steps +1. **Create Module Structure**. +2. **Create Database Schema**: Define `meajudaai_reviews` schema. +3. **Implement `SubmitReview` Endpoint**. +4. **Update Provider Rating**: When a new review is submitted, publish a `ReviewAddedIntegrationEvent`. The `Search` module will listen to this event to update the `AverageRating` in its `SearchableProvider` read model. This avoids a costly real-time calculation during searches. +5. **Develop Tests**: Write unit tests for the rating calculation logic and integration tests for the review submission endpoint. + +--- + +### 3.7. (New) Payments & Billing Module + +#### Purpose +To manage provider subscriptions using Stripe. + +#### Proposed Architecture +A dedicated module acting as an Anti-Corruption Layer (ACL) over the Stripe API. This isolates Stripe-specific logic and protects the domain from external changes. + +#### Domain Design & Key Entities +- `Subscription`: Aggregate Root. Properties: `SubscriptionId`, `ProviderId`, `StripeSubscriptionId`, `Plan` (enum: Free, Standard, Gold, Platinum), `Status` (enum: Active, Canceled, PastDue). +- `BillingAttempt`: Entity to log payment attempts. + +#### Proposed Interfaces (`Shared/Contracts/Modules/Billing/`) +```csharp +public interface IBillingModuleApi : IModuleApi +{ + Task> CreateCheckoutSessionAsync(CreateCheckoutRequest request, CancellationToken ct = default); + Task> GetSubscriptionForProviderAsync(Guid providerId, CancellationToken ct = default); +} +``` + +#### Implementation Steps +1. **Create Module Structure**. +2. **Configure Stripe**: Set up products and pricing plans in the Stripe dashboard. +3. **Implement Stripe Webhook Endpoint**: This is the most critical part. Create a public endpoint to receive events from Stripe (e.g., `checkout.session.completed`, `invoice.payment_succeeded`, `customer.subscription.deleted`). The handler for these events will update the `Subscription` status in the database. +4. **Implement Checkout Session Endpoint**: Create an API that generates a Stripe Checkout session and returns the URL to the client. +5. **Publish Integration Events**: On subscription status changes, publish events like `SubscriptionTierChangedIntegrationEvent`. The `Search` module will consume this to update its read model for ranking. +6. **Develop Tests**: Create integration tests for the Stripe webhook endpoint and checkout session creation, using mock events from Stripe's testing library. + +--- + +### 3.8. (Bonus) Communications Module + +#### Purpose +To centralize and orchestrate all outgoing communications (email, SMS, push notifications). + +#### Proposed Architecture +Orchestrator Pattern. A central `NotificationService` dispatches requests to specific channel handlers (e.g., `EmailHandler`, `SmsHandler`). + +#### Proposed Interfaces (`Shared/Contracts/Modules/Communications/`) +```csharp +public interface ICommunicationsModuleApi : IModuleApi +{ + Task SendEmailAsync(EmailRequest request, CancellationToken ct = default); + // Task SendSmsAsync(SmsRequest request, CancellationToken ct = default); +} +``` + +#### Implementation Steps +1. **Create Module Structure**. +2. **Integrate Email Service**: Implement an `IEmailService` using a provider like **SendGrid** or **Mailgun**. +3. **Create Notification Handlers**: Implement handlers for integration events from other modules (e.g., `UserRegisteredIntegrationEvent` -> send welcome email, `ProviderVerificationFailedIntegrationEvent` -> send notification). +4. **Develop Tests**: Write unit tests for the notification handlers and integration tests for the email service client. + +--- + +### 3.9. (New) Analytics & Reporting Module + +#### Purpose +To capture, process, and visualize key business and operational data. This module will provide insights into user behavior, platform growth, and financial performance, while also providing a comprehensive audit trail for security and compliance. + +#### Proposed Architecture +**CQRS and Event-Sourcing (for Audit)**. +- **Metrics (`IMetricsService`)**: This will be a thin facade over the existing .NET Aspire OpenTelemetry infrastructure. It will primarily be used for defining custom business metrics (e.g., "new_registrations", "subscriptions_created"). +- **Audit (`IAuditService`)**: The module will subscribe to integration events from all other modules. Each event will be stored immutably in an `audit_log` table, creating a complete event stream of system activities. This provides a powerful base for traceability. +- **Reporting (`IAnalyticsReportService`)**: For reporting, the module will process the same integration events to update several denormalized "read model" tables, optimized for fast querying. These tables will power the reports. + +#### Proposed Interfaces (`Shared/Contracts/Modules/Analytics/`) +```csharp +public interface IAnalyticsModuleApi : IModuleApi +{ + Task> GetReportAsync(ReportQuery query, CancellationToken ct = default); + Task>> GetAuditHistoryAsync(AuditLogQuery query, CancellationToken ct = default); +} + +// IMetricsService would likely be an internal service, not part of the public module API. +``` + +#### Implementation Steps +1. **Create Module Structure**. +2. **Create Database Schema**: Define the `meajudaai_analytics` schema. It will contain the `audit_log` table and various reporting tables (e.g., `monthly_revenue`, `provider_growth_summary`). +3. **Implement Event Handlers**: Create handlers for all relevant integration events (`UserRegistered`, `ProviderActivated`, `ReviewSubmitted`, `SubscriptionStarted`, etc.). These handlers will populate both the audit log and the reporting tables. +4. **Build Reporting API**: Develop endpoints to query the reporting tables. These should be highly optimized for read performance. +5. **Integrate with Aspire Dashboard**: Expose key business metrics via OpenTelemetry so they can be visualized in the Aspire Dashboard or other compatible tools like Grafana. +6. **Develop Tests**: Create integration tests for the event handlers to ensure data is correctly transformed and stored in the audit log and reporting tables. Write performance tests for the reporting API. + +#### Proposed Database Views +To simplify report generation and provide a stable data access layer, the following PostgreSQL views are recommended. These views would live in the `meajudaai_analytics` schema and query data from other modules' schemas. + +1. **`vw_provider_summary`**: + - **Purpose**: A holistic view of each provider. + - **Source Tables**: `meajudaai_providers.providers`, `meajudaai_reviews.reviews` (aggregated), `meajudaai_billing.subscriptions`. + - **Columns**: `ProviderId`, `Name`, `Status`, `JoinDate`, `SubscriptionTier`, `AverageRating`, `TotalReviews`. + +2. **`vw_financial_transactions`**: + - **Purpose**: Consolidate all financial events for revenue reporting. + - **Source Tables**: `meajudaai_billing.subscriptions`, `meajudaai_billing.billing_attempts`. + - **Columns**: `TransactionId`, `ProviderId`, `Amount`, `Plan`, `Status`, `TransactionDate`. + +3. **`vw_audit_log_enriched`**: + - **Purpose**: Make the raw audit log more human-readable. + - **Source Tables**: `meajudaai_analytics.audit_log`, `meajudaai_users.users`, `meajudaai_providers.providers`. + - **Columns**: `LogId`, `Timestamp`, `EventName`, `ActorId`, `ActorName`, `EntityId`, `DetailsJson`. + +--- + +## 4. Implementation Roadmap + +### Phase 1: Foundational Provider & Search (MVP Core) +1. **Task**: Enhance `Providers` module for multi-step registration. +2. **Task**: Build `Media, Storage & Documents` module for basic document upload. +3. **Task**: Build `Location Management` module for CEP lookup. +4. **Task**: Build `Search & Discovery` module with basic radius search (PostGIS initially, can migrate to Elasticsearch later). +5. **Task**: Build `Service Catalog` module. + +### Phase 2: Quality & Monetization +1. **Task**: Build `Reviews, Quality & Rating` module. +2. **Task**: Integrate rating into `Search & Discovery` ranking. +3. **Task**: Build `Payments & Billing` module with Stripe integration. +4. **Task**: Integrate subscription tier into `Search & Discovery` ranking. +5. **Task**: Implement document verification logic (OCR, background checks) in `Documents` module. + +### Phase 3: User Experience & Engagement (Post-MVP) +1. **Task**: Design and implement `Service Requests & Booking` module. +2. **Task**: Implement provider calendar/availability features. +3. **Task**: Build `Communications` module for email notifications. +4. **Task**: (Future) Consider an internal chat feature. + +--- + +## 5. Other Recommended Priority Features + +Beyond the core modules defined above, the following features are recommended for consideration in the MVP or as fast-follows to ensure a successful platform launch. + +### 5.1. Admin Portal +- **Why**: Platform operations are impossible without a back-office. Admins need a UI to manage the platform without direct database access. +- **Core Features**: + - **User & Provider Management**: View, suspend, or manually verify users and providers. + - **Service Catalog Management**: Approve/reject suggested services and manage categories. + - **Review Moderation**: Handle flagged or inappropriate reviews. + - **Dashboard**: A simple dashboard displaying key metrics from the `Analytics` module. +- **Implementation**: Could be a simple, separate web application (e.g., a Blazor or React app) that consumes the same API, but with admin-only endpoints. + +### 5.2. Customer (User) Profile Management +- **Why**: The current plan is heavily provider-focused. Customers also need a space to manage their information and activity. +- **Core Features**: + - Edit basic profile information (name, photo). + - View their history of contacted providers or service requests. + - Manage reviews they have written. +- **Implementation**: This would be an enhancement to the existing `Users` module and its API. + +### 5.3. Basic Dispute Resolution System +- **Why**: Even without in-app payments, disputes can arise (e.g., unfair reviews, provider misconduct). A basic flagging system is essential for trust and safety. +- **Core Features**: + - A "Report" button on provider profiles and reviews. + - A simple form to describe the issue. + - A queue in the Admin Portal for moderators to review and act on these reports. +- **Implementation**: A new small module or an extension of the `Reviews` module. + +--- + +## 6. Frontend Application Plan + +This section outlines the strategy for the client-facing web applications, including the public-facing site, the customer portal, the provider portal, and the admin portal. + +### 6.1. Technology Stack +- **Framework**: **React** with **TypeScript**. This provides a robust, type-safe foundation for a scalable application. The project will be initialized using Vite for a fast development experience. +- **UI & Styling**: **Material-UI (MUI)** will be used as the primary component library. Its comprehensive set of well-designed components will accelerate development and ensure a consistent, modern look and feel. +- **State Management**: **Zustand** will be used for global state management. Its simplicity and minimal boilerplate make it an excellent choice for managing state without the complexity of older solutions. +- **API Communication**: **Axios** will be used for making HTTP requests to the backend API. A wrapper will be created to handle authentication tokens, error handling, and response typing automatically. + +### 6.2. Project Structure +- A new top-level directory named `web/` will be created in the repository root. +- Initially, this will house a single React project for the **Admin Portal**. As the application grows, other portals (Customer, Provider) may be added as separate projects within the `web/` directory or as distinct sections within a single monorepo setup (e.g., using Nx or Turborepo). + +### 6.3. Authentication +- Authentication will be handled using the **OpenID Connect (OIDC)** protocol to communicate with the existing **Keycloak** instance. +- The `oidc-client-ts` library will be used to manage the OIDC flow, including token acquisition, refresh, and secure storage. This ensures a robust and secure authentication experience. + +### 6.4. Initial Implementation Focus +- The initial development effort will focus on building the **Admin Portal** (as defined in section 5.1) and the **Customer (User) Profile Management** features (section 5.2). +- This provides immediate value by enabling platform administration and giving users a space to manage their information, laying the groundwork for more complex features. diff --git a/WARP.md b/WARP.md new file mode 100644 index 000000000..d5ca3f35a --- /dev/null +++ b/WARP.md @@ -0,0 +1,548 @@ +# WARP.md + +This file provides guidance to WARP (warp.dev) when working with code in this repository. + +## Project Overview + +**MeAjudaAi** is a service marketplace platform built with .NET 9 and .NET Aspire, implementing a Modular Monolith architecture with Domain-Driven Design (DDD), CQRS, and Event-Driven patterns. The platform connects service providers with customers. + +### Key Technologies +- **.NET 9** with C# 12 +- **.NET Aspire** for orchestration and observability +- **PostgreSQL 15+** as primary database +- **Entity Framework Core 9** for data access +- **Keycloak** for authentication/authorization +- **Redis** for distributed caching +- **RabbitMQ/Azure Service Bus** for messaging +- **xUnit v3** for testing + +## Development Commands + +### Running the Application + +```powershell +# Run with Aspire (RECOMMENDED - includes all services) +cd src\Aspire\MeAjudaAi.AppHost +dotnet run + +# Run API only (without Aspire orchestration) +cd src\Bootstrapper\MeAjudaAi.ApiService +dotnet run + +# Access points after running: +# - Aspire Dashboard: https://localhost:17063 or http://localhost:15297 +# - API Service: https://localhost:7524 or http://localhost:5545 +``` + +### Building + +```powershell +# Build entire solution +dotnet build + +# Build specific configuration +dotnet build --configuration Release + +# Restore dependencies +dotnet restore +``` + +### Testing + +```powershell +# Run all tests +dotnet test + +# Run with code coverage +dotnet test --collect:"XPlat Code Coverage" + +# Run specific module tests +dotnet test src\Modules\Users\Tests\ +dotnet test src\Modules\Providers\Tests\ + +# Run specific test categories +dotnet test --filter "Category=Unit" +dotnet test --filter "Category=Integration" + +# Generate HTML coverage report (requires reportgenerator tool) +reportgenerator -reports:"coverage\**\coverage.opencover.xml" -targetdir:"coverage\html" -reporttypes:Html +``` + +### Database Migrations + +```powershell +# Apply all migrations (RECOMMENDED - cross-platform PowerShell script) +.\scripts\ef-migrate.ps1 + +# Apply migrations for specific module +.\scripts\ef-migrate.ps1 -Module Users +.\scripts\ef-migrate.ps1 -Module Providers + +# Check migration status +.\scripts\ef-migrate.ps1 -Command status + +# Add new migration +.\scripts\ef-migrate.ps1 -Command add -Module Users -MigrationName "AddNewField" + +# Environment variables needed: +# - DB_HOST (default: localhost) +# - DB_PORT (default: 5432) +# - DB_NAME (default: MeAjudaAi) +# - DB_USER (default: postgres) +# - DB_PASSWORD (required) +``` + +### Code Quality + +```powershell +# Format code according to .editorconfig +dotnet format + +# Run code analysis +dotnet build --verbosity normal + +# Clean build artifacts +dotnet clean +``` + +### API Documentation + +```powershell +# Generate OpenAPI spec for API clients (APIDog, Postman, Insomnia, Bruno) +.\scripts\export-openapi.ps1 + +# Specify custom output path +.\scripts\export-openapi.ps1 -OutputPath "docs\api-spec.json" + +# Access Swagger UI when running: +# https://localhost:7524/swagger +``` + +## Architecture Overview + +### Modular Monolith Structure + +The codebase follows a Modular Monolith pattern where each module is independently deployable but runs in the same process for development simplicity. Future extraction to microservices is possible. + +``` +src/ +├── Aspire/ # .NET Aspire orchestration +│ ├── MeAjudaAi.AppHost/ # Host application +│ └── MeAjudaAi.ServiceDefaults/ # Shared configurations +├── Bootstrapper/ +│ └── MeAjudaAi.ApiService/ # Main API entry point +├── Modules/ # Domain modules (bounded contexts) +│ ├── Users/ # User management module +│ │ ├── API/ # HTTP endpoints (Minimal APIs) +│ │ ├── Application/ # CQRS handlers (Commands/Queries) +│ │ ├── Domain/ # Domain entities, value objects, events +│ │ ├── Infrastructure/ # EF Core, repositories, external services +│ │ └── Tests/ # Unit and integration tests +│ └── Providers/ # Service provider module +│ ├── API/ +│ ├── Application/ +│ ├── Domain/ +│ ├── Infrastructure/ +│ └── Tests/ +└── Shared/ + └── MeAjudaAi.Shared/ # Cross-cutting concerns, abstractions +``` + +### Key Architectural Patterns + +#### 1. Clean Architecture Layers +- **Domain Layer**: Entities, Value Objects, Domain Events, Domain Services +- **Application Layer**: CQRS Commands/Queries, Handlers, Application Services +- **Infrastructure Layer**: EF Core DbContexts, Repositories, External Integrations +- **Presentation Layer**: Minimal API endpoints, DTOs + +#### 2. CQRS (Command Query Responsibility Segregation) +- **Commands**: Modify state (RegisterUserCommand, UpdateProviderCommand) +- **Queries**: Read data (GetUserByIdQuery, GetProvidersQuery) +- **Handlers**: Process commands/queries with MediatR +- **Validators**: FluentValidation for input validation + +#### 3. Domain-Driven Design +- **Aggregates**: User, Provider (with aggregate roots) +- **Value Objects**: Email, UserId, ProviderId, Address, ContactInfo +- **Domain Events**: UserRegisteredDomainEvent, ProviderVerificationStatusUpdatedDomainEvent +- **Bounded Contexts**: Users, Providers (future: Services, Bookings, Payments) + +#### 4. Event-Driven Architecture +- **Domain Events**: Internal module communication via MediatR +- **Integration Events**: Cross-module communication via message bus +- **Event Handlers**: React to events (e.g., send welcome email on user registration) + +#### 5. Module APIs Pattern +- Modules expose public APIs via interfaces (e.g., `IUsersModuleApi`, `IProvidersModuleApi`) +- In-process, type-safe communication between modules +- DTOs in `Shared/Contracts/Modules/{ModuleName}/DTOs/` +- Located in `{Module}/Application/Services/` + +### Database Per Module (Schema Isolation) +Each module has its own PostgreSQL schema: +- **Users**: `meajudaai_users` schema +- **Providers**: `meajudaai_providers` schema +- **Future modules**: Dedicated schemas for isolation + +### Important Design Decisions + +#### UUID v7 for IDs +The project uses .NET 9's UUID v7 (time-ordered) instead of UUID v4 for: +- Better database indexing performance +- Natural chronological ordering +- Compatibility with PostgreSQL 18+ +- Centralized generation via `UuidGenerator` in `MeAjudaAi.Shared.Time` + +#### Central Package Management +- All package versions defined in `Directory.Packages.props` +- xUnit v3 used for all tests +- Consistent versioning across solution + +#### Code Quality Standards +- EditorConfig enforced via `.editorconfig` +- SonarAnalyzer for static analysis +- Test authentication handler for integration tests +- No warnings as errors (by design for flexibility) + +## Development Guidelines + +### Adding a New Module + +Follow the established pattern from existing modules: + +1. **Create module structure**: + ``` + src/Modules/{ModuleName}/ + ├── API/ + ├── Application/ + │ ├── Commands/ + │ ├── Queries/ + │ └── Services/ + ├── Domain/ + │ ├── Entities/ + │ ├── ValueObjects/ + │ └── Events/ + ├── Infrastructure/ + │ ├── Persistence/ + │ └── Repositories/ + └── Tests/ + ``` + +2. **Register module in `Program.cs`**: + ```csharp + builder.Services.Add{ModuleName}Module(builder.Configuration); + ``` + +3. **Update CI/CD workflow** (`.github/workflows/pr-validation.yml`): + ```yaml + MODULES=( + "Users:src/Modules/Users/MeAjudaAi.Modules.Users.Tests/" + "Providers:src/Modules/Providers/MeAjudaAi.Modules.Providers.Tests/" + "{ModuleName}:src/Modules/{ModuleName}/MeAjudaAi.Modules.{ModuleName}.Tests/" + ) + ``` + +4. **Create database schema** in `infrastructure/database/schemas/` + +5. **Add Module API interface** in `Shared/Contracts/Modules/{ModuleName}/` + +### Naming Conventions + +```csharp +// Commands: [Verb][Entity]Command +public sealed record RegisterUserCommand(...); +public sealed record UpdateProviderCommand(...); + +// Queries: Get[Entity]By[Criteria]Query +public sealed record GetUserByIdQuery(UserId UserId); +public sealed record GetProvidersByStatusQuery(EVerificationStatus Status); + +// Handlers: [Command/Query]Handler +public sealed class RegisterUserCommandHandler : ICommandHandler; + +// Value Objects: PascalCase, sealed records +public sealed record UserId(Guid Value); +public sealed record Email(string Value); + +// Domain Events: [Entity][Action]DomainEvent +public sealed record UserRegisteredDomainEvent(...); +public sealed record ProviderDeletedDomainEvent(...); +``` + +### Testing Strategy + +#### Test Structure +```csharp +[Fact] +public async Task MethodName_Scenario_ExpectedResult() +{ + // Arrange - Set up test data and dependencies + var command = new RegisterUserCommand(...); + + // Act - Execute the operation + var result = await _handler.Handle(command, CancellationToken.None); + + // Assert - Verify the outcome + result.IsSuccess.Should().BeTrue(); +} +``` + +#### Test Categories +- **Unit Tests**: Domain logic, command/query handlers (in module Tests/ folder) +- **Integration Tests**: API endpoints, database operations (tests/MeAjudaAi.Integration.Tests/) +- **E2E Tests**: Full user scenarios (tests/MeAjudaAi.E2E.Tests/) +- **Architecture Tests**: Enforce architectural rules (tests/MeAjudaAi.Architecture.Tests/) + +#### Coverage Requirements +- Minimum: 70% (CI warning threshold) +- Recommended: 85% +- Excellent: 90%+ + +### Commit Message Format + +Use Conventional Commits: +``` +feat(users): add user registration endpoint +fix(providers): resolve null reference in verification service +refactor(shared): extract validation to separate class +test(providers): add tests for provider registration +docs(readme): update installation instructions +chore(deps): update EF Core to 9.0.9 +``` + +### Code Review Checklist + +Before submitting a PR: +- [ ] All tests pass locally +- [ ] Code follows existing naming conventions +- [ ] No warnings in build output +- [ ] Added/updated tests for new functionality +- [ ] Updated documentation if needed +- [ ] Added XML documentation for public APIs +- [ ] Used `Result` pattern for operations that can fail +- [ ] Domain events published for state changes +- [ ] Migrations added for database changes + +## Common Patterns + +### Result Pattern (Error Handling) + +```csharp +// Return Result instead of throwing exceptions +public async Task> RegisterUserAsync(RegisterUserCommand command) +{ + var validation = await _validator.ValidateAsync(command); + if (!validation.IsValid) + return Result.Failure(validation.Errors); + + var user = User.Create(...); + await _repository.AddAsync(user); + + return Result.Success(user); +} +``` + +### Domain Events + +```csharp +// In aggregate root +public class Provider : AggregateRoot +{ + public void UpdateVerificationStatus(EVerificationStatus newStatus) + { + var oldStatus = VerificationStatus; + VerificationStatus = newStatus; + + // Raise domain event + RaiseDomainEvent(new ProviderVerificationStatusUpdatedDomainEvent( + Id.Value, + Version, + oldStatus, + newStatus, + null + )); + } +} + +// Event handler +public class ProviderVerificationStatusUpdatedHandler + : INotificationHandler +{ + public async Task Handle(ProviderVerificationStatusUpdatedDomainEvent notification, + CancellationToken cancellationToken) + { + // React to event (e.g., send notification, update related entities) + } +} +``` + +### Module API Communication + +```csharp +// Define interface in Shared/Contracts/Modules/Users/ +public interface IUsersModuleApi : IModuleApi +{ + Task> GetUserByIdAsync(Guid userId, CancellationToken ct = default); + Task> UserExistsAsync(Guid userId, CancellationToken ct = default); +} + +// Implement in Users/Application/Services/ +[ModuleApi("Users", "1.0")] +public sealed class UsersModuleApi : IUsersModuleApi +{ + private readonly IMediator _mediator; + + public async Task> UserExistsAsync(Guid userId, CancellationToken ct = default) + { + var query = new CheckUserExistsQuery(userId); + var result = await _mediator.Send(query, ct); + return Result.Success(result); + } +} + +// Use in another module +public class CreateOrderHandler +{ + private readonly IUsersModuleApi _usersApi; + + public async Task Handle(CreateOrderCommand command, CancellationToken ct) + { + // Check if user exists via Module API + var userExists = await _usersApi.UserExistsAsync(command.UserId, ct); + if (!userExists.IsSuccess || !userExists.Value) + return Result.Failure("User not found"); + + // Continue with order creation... + } +} +``` + +### Entity Framework Configuration + +```csharp +// DbContext per module with dedicated schema +public class UsersDbContext : DbContext +{ + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Apply all configurations from assembly + modelBuilder.ApplyConfigurationsFromAssembly(typeof(UsersDbContext).Assembly); + + // Set default schema + modelBuilder.HasDefaultSchema("meajudaai_users"); + } +} + +// Entity configuration +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + + // Configure ID with conversion + builder.HasKey(u => u.Id); + builder.Property(u => u.Id) + .HasConversion( + id => id.Value, + value => UserId.From(value)); + + // Configure value objects + builder.OwnsOne(u => u.Email, email => + { + email.Property(e => e.Value) + .HasColumnName("email") + .IsRequired(); + }); + } +} +``` + +## Troubleshooting + +### Build Issues + +**Error**: Package version conflicts +```powershell +# Solution: Clean and restore +dotnet clean +dotnet restore --force +``` + +**Error**: Aspire not found +```powershell +# Solution: Install Aspire workload +dotnet workload update +dotnet workload install aspire +``` + +### Database Issues + +**Error**: Cannot connect to PostgreSQL +```powershell +# Check if Docker is running +docker ps + +# Check PostgreSQL container +docker logs [container-id] + +# Verify connection string environment variables +echo $env:DB_HOST +echo $env:DB_PORT +echo $env:DB_PASSWORD +``` + +**Error**: Migration already applied +```powershell +# Check migration status +.\scripts\ef-migrate.ps1 -Command status -Module Users + +# Rollback if needed (use carefully) +dotnet ef database update [PreviousMigrationName] --context UsersDbContext +``` + +### Test Issues + +**Error**: Tests fail with database errors +- Ensure PostgreSQL service is running +- Check that connection string environment variables are set +- Verify TestContainers has access to Docker socket + +**Error**: Coverage reports missing +```powershell +# Install report generator +dotnet tool install -g dotnet-reportgenerator-globaltool + +# Run tests with coverage collection +dotnet test --collect:"XPlat Code Coverage" +``` + +## Important Files + +- **`Directory.Build.props`**: Global MSBuild properties, warning suppressions +- **`Directory.Packages.props`**: Central package version management +- **`.editorconfig`**: Code style and formatting rules +- **`nuget.config`**: NuGet package sources +- **`README.md`**: User-facing documentation +- **`docs/architecture.md`**: Detailed architectural decisions and patterns +- **`docs/development.md`**: Comprehensive development guide +- **`scripts/README.md`**: All available development scripts + +## CI/CD Notes + +- **Pull Request Validation**: Runs on all PRs to `master` and `develop` +- **Required Secrets**: `POSTGRES_PASSWORD`, `POSTGRES_USER`, `POSTGRES_DB` +- **Optional Secrets**: `KEYCLOAK_ADMIN_PASSWORD` (for auth features) +- **Test Coverage**: Automatically collected and reported on PRs +- **Module Tests**: Each module has isolated test suite that runs in parallel + +## Additional Resources + +- [Architecture Documentation](docs/architecture.md) - Deep dive into patterns and decisions +- [Development Guide](docs/development.md) - Comprehensive development workflows +- [Infrastructure Guide](docs/infrastructure.md) - Deployment and infrastructure setup +- [CI/CD Setup](docs/ci_cd.md) - Pipeline configuration and deployment +- [Adding New Modules](docs/adding-new-modules.md) - Step-by-step module creation diff --git a/src/Modules/Providers/API/Endpoints/ProviderAdmin/RequireBasicInfoCorrectionEndpoint.cs b/src/Modules/Providers/API/Endpoints/ProviderAdmin/RequireBasicInfoCorrectionEndpoint.cs new file mode 100644 index 000000000..b873b5cd8 --- /dev/null +++ b/src/Modules/Providers/API/Endpoints/ProviderAdmin/RequireBasicInfoCorrectionEndpoint.cs @@ -0,0 +1,129 @@ +using MeAjudaAi.Modules.Providers.API.Mappers; +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Application.DTOs.Requests; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Endpoints; +using MeAjudaAi.Shared.Functional; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace MeAjudaAi.Modules.Providers.API.Endpoints.ProviderAdmin; + +/// +/// Endpoint responsável por solicitar correção de informações básicas de prestadores. +/// +/// +/// Implementa padrão de endpoint mínimo para retornar prestadores da etapa de verificação +/// de documentos para correção de informações básicas utilizando arquitetura CQRS. +/// Restrito a administradores e verificadores devido à criticidade da operação. +/// +public class RequireBasicInfoCorrectionEndpoint : BaseEndpoint, IEndpoint +{ + /// + /// Configura o mapeamento do endpoint de solicitação de correção. + /// + /// Builder de rotas do endpoint + /// + /// Configura endpoint POST em "/{id:guid}/require-basic-info-correction" com: + /// - Autorização AdminOnly (apenas administradores/verificadores podem solicitar correções) + /// - Validação automática de GUID para o parâmetro ID + /// - Documentação OpenAPI automática + /// - Códigos de resposta apropriados + /// - Nome único para referência + /// + public static void Map(IEndpointRouteBuilder app) + => app.MapPost("/{id:guid}/require-basic-info-correction", RequireBasicInfoCorrectionAsync) + .WithName("RequireBasicInfoCorrection") + .WithSummary("Solicitar correção de informações básicas") + .WithDescription(""" + Retorna um prestador de serviços para correção de informações básicas + durante o processo de verificação de documentos. + + **🔒 Acesso Restrito: Apenas Administradores/Verificadores** + + **Quando usar:** + - Informações básicas incorretas ou incompletas + - Inconsistências identificadas durante verificação de documentos + - Dados empresariais que precisam ser atualizados + - Informações de contato inválidas + + **Características:** + - 🔄 Retorna prestador para status PendingBasicInfo + - 📧 Notificação automática ao prestador (futuro) + - 📋 Auditoria completa da solicitação + - ⚖️ Motivo obrigatório para rastreabilidade + - 🔐 Identificação do solicitante extraída da autenticação + + **Fluxo após correção:** + 1. Prestador recebe notificação com motivo da correção + 2. Prestador atualiza informações básicas + 3. Prestador conclui informações básicas novamente + 4. Sistema retorna para verificação de documentos + + **Campos obrigatórios no request body:** + - Reason: Motivo detalhado da correção necessária + + **Campos derivados do servidor:** + - RequestedBy: Extraído automaticamente do contexto de autenticação (claims: name, sub ou email) + + **Validações aplicadas:** + - Prestador em status PendingDocumentVerification + - Motivo não pode ser vazio + - Prestador existente e ativo + - Autorização administrativa verificada + - Identidade do solicitante autenticada + """) + .RequireAuthorization("AdminOnly") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + /// + /// Processa requisição de solicitação de correção de forma assíncrona. + /// + /// ID único do prestador + /// Dados da solicitação de correção + /// Dispatcher para envio de comandos CQRS + /// Contexto HTTP para obter o usuário autenticado + /// Token de cancelamento da operação + /// + /// Resultado HTTP contendo: + /// - 200 OK: Correção solicitada com sucesso + /// - 400 Bad Request: Erro de validação ou solicitação + /// - 401 Unauthorized: Usuário não autenticado + /// - 404 Not Found: Prestador não encontrado + /// + /// + /// Fluxo de execução: + /// 1. Valida ID do prestador e autorização + /// 2. Extrai identidade do usuário autenticado do contexto HTTP + /// 3. Converte request em comando CQRS com identidade verificada + /// 4. Envia comando através do dispatcher + /// 5. Processa resultado e retorna confirmação + /// 6. Emite evento de domínio para notificação + /// + private static async Task RequireBasicInfoCorrectionAsync( + Guid id, + [FromBody] RequireBasicInfoCorrectionRequest request, + ICommandDispatcher commandDispatcher, + HttpContext httpContext, + CancellationToken cancellationToken) + { + if (request is null) + return Results.BadRequest("Request body is required"); + + // Extrai a identidade do usuário autenticado do contexto HTTP + var requestedBy = httpContext.User.Identity?.Name + ?? httpContext.User.FindFirst("sub")?.Value + ?? httpContext.User.FindFirst("email")?.Value + ?? "system"; + + var command = request.ToCommand(id, requestedBy); + var result = await commandDispatcher.SendAsync( + command, cancellationToken); + + return Handle(result); + } +} diff --git a/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs b/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs index 448fa6d24..a00d661cf 100644 --- a/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs +++ b/src/Modules/Providers/API/Endpoints/ProvidersModuleEndpoints.cs @@ -58,6 +58,7 @@ public static void MapProvidersEndpoints(this WebApplication app) .MapEndpoint() .MapEndpoint() .MapEndpoint() + .MapEndpoint() .MapEndpoint(); } } diff --git a/src/Modules/Providers/API/Mappers/RequestMapperExtensions.cs b/src/Modules/Providers/API/Mappers/RequestMapperExtensions.cs index f5a1a381a..d6db5b14b 100644 --- a/src/Modules/Providers/API/Mappers/RequestMapperExtensions.cs +++ b/src/Modules/Providers/API/Mappers/RequestMapperExtensions.cs @@ -74,6 +74,22 @@ public static UpdateVerificationStatusCommand ToCommand(this UpdateVerificationS ); } + /// + /// Mapeia RequireBasicInfoCorrectionRequest para RequireBasicInfoCorrectionCommand. + /// + /// Requisição de correção de informações básicas + /// ID do prestador + /// Identificador autenticado do administrador/verificador solicitando a correção + /// RequireBasicInfoCorrectionCommand com propriedades mapeadas + public static RequireBasicInfoCorrectionCommand ToCommand(this RequireBasicInfoCorrectionRequest request, Guid providerId, string requestedBy) + { + return new RequireBasicInfoCorrectionCommand( + providerId, + request.Reason, + requestedBy + ); + } + /// /// Mapeia o ID do prestador para GetProviderByIdQuery. /// diff --git a/src/Modules/Providers/Application/Commands/ActivateProviderCommand.cs b/src/Modules/Providers/Application/Commands/ActivateProviderCommand.cs new file mode 100644 index 000000000..7b782d9c7 --- /dev/null +++ b/src/Modules/Providers/Application/Commands/ActivateProviderCommand.cs @@ -0,0 +1,14 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Providers.Application.Commands; + +/// +/// Comando para ativar um prestador de serviços após verificação bem-sucedida de documentos. +/// +/// Identificador do prestador de serviços +/// Quem está executando a ativação +public sealed record ActivateProviderCommand( + Guid ProviderId, + string? ActivatedBy = null +) : Command; diff --git a/src/Modules/Providers/Application/Commands/CompleteBasicInfoCommand.cs b/src/Modules/Providers/Application/Commands/CompleteBasicInfoCommand.cs new file mode 100644 index 000000000..8ad154329 --- /dev/null +++ b/src/Modules/Providers/Application/Commands/CompleteBasicInfoCommand.cs @@ -0,0 +1,15 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Providers.Application.Commands; + +/// +/// Comando para completar o preenchimento de informações básicas e avançar +/// para a etapa de verificação de documentos. +/// +/// Identificador do prestador de serviços +/// Quem está executando a atualização +public sealed record CompleteBasicInfoCommand( + Guid ProviderId, + string? UpdatedBy = null +) : Command; diff --git a/src/Modules/Providers/Application/Commands/RejectProviderCommand.cs b/src/Modules/Providers/Application/Commands/RejectProviderCommand.cs new file mode 100644 index 000000000..ee5d26be9 --- /dev/null +++ b/src/Modules/Providers/Application/Commands/RejectProviderCommand.cs @@ -0,0 +1,16 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Providers.Application.Commands; + +/// +/// Comando para rejeitar o registro de um prestador de serviços. +/// +/// Identificador do prestador de serviços +/// Quem está executando a rejeição +/// Motivo da rejeição (obrigatório para auditoria) +public sealed record RejectProviderCommand( + Guid ProviderId, + string RejectedBy, + string Reason +) : Command; diff --git a/src/Modules/Providers/Application/Commands/RequireBasicInfoCorrectionCommand.cs b/src/Modules/Providers/Application/Commands/RequireBasicInfoCorrectionCommand.cs new file mode 100644 index 000000000..6f2613873 --- /dev/null +++ b/src/Modules/Providers/Application/Commands/RequireBasicInfoCorrectionCommand.cs @@ -0,0 +1,17 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Providers.Application.Commands; + +/// +/// Comando para retornar um prestador de serviços para correção de informações básicas +/// durante o processo de verificação de documentos. +/// +/// Identificador do prestador de serviços +/// Motivo da correção necessária (obrigatório para auditoria e notificação) +/// Quem está solicitando a correção (verificador/administrador) +public sealed record RequireBasicInfoCorrectionCommand( + Guid ProviderId, + string Reason, + string RequestedBy +) : Command; diff --git a/src/Modules/Providers/Application/Commands/SuspendProviderCommand.cs b/src/Modules/Providers/Application/Commands/SuspendProviderCommand.cs new file mode 100644 index 000000000..013ee8138 --- /dev/null +++ b/src/Modules/Providers/Application/Commands/SuspendProviderCommand.cs @@ -0,0 +1,16 @@ +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; + +namespace MeAjudaAi.Modules.Providers.Application.Commands; + +/// +/// Comando para suspender um prestador de serviços. +/// +/// Identificador do prestador de serviços +/// Quem está executando a suspensão +/// Motivo da suspensão (obrigatório para auditoria) +public sealed record SuspendProviderCommand( + Guid ProviderId, + string SuspendedBy, + string Reason +) : Command; diff --git a/src/Modules/Providers/Application/DTOs/ProviderDto.cs b/src/Modules/Providers/Application/DTOs/ProviderDto.cs index 8cbe1d718..26b27ab81 100644 --- a/src/Modules/Providers/Application/DTOs/ProviderDto.cs +++ b/src/Modules/Providers/Application/DTOs/ProviderDto.cs @@ -11,11 +11,14 @@ public sealed record ProviderDto( string Name, EProviderType Type, BusinessProfileDto BusinessProfile, + EProviderStatus Status, EVerificationStatus VerificationStatus, IReadOnlyList Documents, IReadOnlyList Qualifications, DateTime CreatedAt, DateTime? UpdatedAt, bool IsDeleted, - DateTime? DeletedAt + DateTime? DeletedAt, + string? SuspensionReason = null, + string? RejectionReason = null ); diff --git a/src/Modules/Providers/Application/DTOs/Requests/RequireBasicInfoCorrectionRequest.cs b/src/Modules/Providers/Application/DTOs/Requests/RequireBasicInfoCorrectionRequest.cs new file mode 100644 index 000000000..903f6a572 --- /dev/null +++ b/src/Modules/Providers/Application/DTOs/Requests/RequireBasicInfoCorrectionRequest.cs @@ -0,0 +1,18 @@ +using MeAjudaAi.Shared.Contracts; + +namespace MeAjudaAi.Modules.Providers.Application.DTOs.Requests; + +/// +/// Request para solicitar correção de informações básicas de um prestador de serviços. +/// +public record RequireBasicInfoCorrectionRequest : Request +{ + /// + /// Motivo detalhado da correção necessária (obrigatório). + /// + /// + /// Este campo será enviado ao prestador para que ele saiba quais informações + /// precisam ser corrigidas ou complementadas. + /// + public string Reason { get; init; } = string.Empty; +} diff --git a/src/Modules/Providers/Application/Handlers/Commands/ActivateProviderCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/ActivateProviderCommandHandler.cs new file mode 100644 index 000000000..fed708f17 --- /dev/null +++ b/src/Modules/Providers/Application/Handlers/Commands/ActivateProviderCommandHandler.cs @@ -0,0 +1,56 @@ +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Domain.Repositories; +using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; + +/// +/// Handler responsável por processar comandos de ativação de prestadores. +/// +/// +/// Este handler ativa um prestador após a verificação bem-sucedida dos documentos, +/// permitindo que ele comece a oferecer serviços na plataforma. +/// +/// Repositório para persistência de prestadores de serviços +/// Logger estruturado para auditoria e debugging +public sealed class ActivateProviderCommandHandler( + IProviderRepository providerRepository, + ILogger logger +) : ICommandHandler +{ + /// + /// Processa o comando de ativação de prestador. + /// + /// Comando de ativação + /// Token de cancelamento + /// Resultado da operação + public async Task HandleAsync(ActivateProviderCommand command, CancellationToken cancellationToken) + { + try + { + logger.LogInformation("Activating provider {ProviderId}", command.ProviderId); + + var provider = await providerRepository.GetByIdAsync(new ProviderId(command.ProviderId), cancellationToken); + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found", command.ProviderId); + return Result.Failure("Provider not found"); + } + + provider.Activate(command.ActivatedBy); + + await providerRepository.UpdateAsync(provider, cancellationToken); + + logger.LogInformation("Provider {ProviderId} activated successfully", command.ProviderId); + return Result.Success(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error activating provider {ProviderId}", command.ProviderId); + return Result.Failure("Failed to activate provider"); + } + } +} diff --git a/src/Modules/Providers/Application/Handlers/Commands/CompleteBasicInfoCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/CompleteBasicInfoCommandHandler.cs new file mode 100644 index 000000000..8156346dc --- /dev/null +++ b/src/Modules/Providers/Application/Handlers/Commands/CompleteBasicInfoCommandHandler.cs @@ -0,0 +1,56 @@ +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Domain.Repositories; +using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; + +/// +/// Handler responsável por processar comandos de conclusão de informações básicas. +/// +/// +/// Este handler move o prestador da etapa de PendingBasicInfo para PendingDocumentVerification, +/// indicando que as informações básicas foram preenchidas e o próximo passo é o envio de documentos. +/// +/// Repositório para persistência de prestadores de serviços +/// Logger estruturado para auditoria e debugging +public sealed class CompleteBasicInfoCommandHandler( + IProviderRepository providerRepository, + ILogger logger +) : ICommandHandler +{ + /// + /// Processa o comando de conclusão de informações básicas. + /// + /// Comando de conclusão + /// Token de cancelamento + /// Resultado da operação + public async Task HandleAsync(CompleteBasicInfoCommand command, CancellationToken cancellationToken) + { + try + { + logger.LogInformation("Completing basic info for provider {ProviderId}", command.ProviderId); + + var provider = await providerRepository.GetByIdAsync(new ProviderId(command.ProviderId), cancellationToken); + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found", command.ProviderId); + return Result.Failure("Provider not found"); + } + + provider.CompleteBasicInfo(command.UpdatedBy); + + await providerRepository.UpdateAsync(provider, cancellationToken); + + logger.LogInformation("Basic info completed for provider {ProviderId}", command.ProviderId); + return Result.Success(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error completing basic info for provider {ProviderId}", command.ProviderId); + return Result.Failure("Failed to complete provider basic info"); + } + } +} diff --git a/src/Modules/Providers/Application/Handlers/Commands/RejectProviderCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/RejectProviderCommandHandler.cs new file mode 100644 index 000000000..8994fb225 --- /dev/null +++ b/src/Modules/Providers/Application/Handlers/Commands/RejectProviderCommandHandler.cs @@ -0,0 +1,69 @@ +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Domain.Repositories; +using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; + +/// +/// Handler responsável por processar comandos de rejeição de prestadores. +/// +/// +/// Este handler rejeita o registro de um prestador após falha na verificação, +/// impedindo que ele seja ativado na plataforma. +/// +/// Repositório para persistência de prestadores de serviços +/// Logger estruturado para auditoria e debugging +public sealed class RejectProviderCommandHandler( + IProviderRepository providerRepository, + ILogger logger +) : ICommandHandler +{ + /// + /// Processa o comando de rejeição de prestador. + /// + /// Comando de rejeição + /// Token de cancelamento + /// Resultado da operação + public async Task HandleAsync(RejectProviderCommand command, CancellationToken cancellationToken) + { + try + { + logger.LogInformation("Rejecting provider {ProviderId}. Reason: {Reason}", + command.ProviderId, command.Reason); + + if (string.IsNullOrWhiteSpace(command.Reason)) + { + logger.LogWarning("Rejection reason is required but was not provided"); + return Result.Failure("Rejection reason is required"); + } + + if (string.IsNullOrWhiteSpace(command.RejectedBy)) + { + logger.LogWarning("RejectedBy is required but was not provided"); + return Result.Failure("RejectedBy is required"); + } + + var provider = await providerRepository.GetByIdAsync(new ProviderId(command.ProviderId), cancellationToken); + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found", command.ProviderId); + return Result.Failure("Provider not found"); + } + + provider.Reject(command.Reason, command.RejectedBy); + + await providerRepository.UpdateAsync(provider, cancellationToken); + + logger.LogInformation("Provider {ProviderId} rejected successfully", command.ProviderId); + return Result.Success(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error rejecting provider {ProviderId}", command.ProviderId); + return Result.Failure("Failed to reject provider"); + } + } +} diff --git a/src/Modules/Providers/Application/Handlers/Commands/RequireBasicInfoCorrectionCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/RequireBasicInfoCorrectionCommandHandler.cs new file mode 100644 index 000000000..2b2a18519 --- /dev/null +++ b/src/Modules/Providers/Application/Handlers/Commands/RequireBasicInfoCorrectionCommandHandler.cs @@ -0,0 +1,84 @@ +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Domain.Exceptions; +using MeAjudaAi.Modules.Providers.Domain.Repositories; +using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; + +/// +/// Handler responsável por processar comandos de correção de informações básicas. +/// +/// +/// Este handler retorna o prestador da etapa de PendingDocumentVerification para PendingBasicInfo, +/// permitindo que o prestador corrija informações identificadas como incorretas ou incompletas +/// durante o processo de verificação de documentos. +/// +/// Repositório para persistência de prestadores de serviços +/// Logger estruturado para auditoria e debugging +public sealed class RequireBasicInfoCorrectionCommandHandler( + IProviderRepository providerRepository, + ILogger logger +) : ICommandHandler +{ + /// + /// Processa o comando de solicitação de correção de informações básicas. + /// + /// Comando de solicitação de correção + /// Token de cancelamento + /// Resultado da operação + public async Task HandleAsync(RequireBasicInfoCorrectionCommand command, CancellationToken cancellationToken) + { + try + { + logger.LogInformation( + "Requiring basic info correction for provider {ProviderId}. Reason: {Reason}, Requested by: {RequestedBy}", + command.ProviderId, command.Reason, command.RequestedBy); + + if (string.IsNullOrWhiteSpace(command.Reason)) + { + logger.LogWarning("Correction reason is required but was not provided"); + return Result.Failure("Correction reason is required"); + } + + if (string.IsNullOrWhiteSpace(command.RequestedBy)) + { + logger.LogWarning("RequestedBy is required but was not provided"); + return Result.Failure("RequestedBy is required"); + } + + var provider = await providerRepository.GetByIdAsync(new ProviderId(command.ProviderId), cancellationToken); + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found", command.ProviderId); + return Result.Failure("Provider not found"); + } + + provider.RequireBasicInfoCorrection(command.Reason, command.RequestedBy); + + await providerRepository.UpdateAsync(provider, cancellationToken); + + logger.LogInformation( + "Basic info correction required for provider {ProviderId}. Provider returned to PendingBasicInfo status", + command.ProviderId); + + return Result.Success(); + } + catch (ProviderDomainException ex) + { + // Preserve domain validation messages for actionable feedback + logger.LogWarning(ex, + "Domain validation failed when requiring basic info correction for provider {ProviderId}: {Message}", + command.ProviderId, ex.Message); + return Result.Failure(ex.Message); + } + catch (Exception ex) + { + // Generic error for unexpected failures + logger.LogError(ex, "Error requiring basic info correction for provider {ProviderId}", command.ProviderId); + return Result.Failure("Failed to require basic info correction"); + } + } +} diff --git a/src/Modules/Providers/Application/Handlers/Commands/SuspendProviderCommandHandler.cs b/src/Modules/Providers/Application/Handlers/Commands/SuspendProviderCommandHandler.cs new file mode 100644 index 000000000..3607896e7 --- /dev/null +++ b/src/Modules/Providers/Application/Handlers/Commands/SuspendProviderCommandHandler.cs @@ -0,0 +1,69 @@ +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Domain.Repositories; +using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Shared.Commands; +using MeAjudaAi.Shared.Functional; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Application.Handlers.Commands; + +/// +/// Handler responsável por processar comandos de suspensão de prestadores. +/// +/// +/// Este handler suspende temporariamente um prestador, impedindo que ele ofereça serviços +/// até que a suspensão seja revertida. +/// +/// Repositório para persistência de prestadores de serviços +/// Logger estruturado para auditoria e debugging +public sealed class SuspendProviderCommandHandler( + IProviderRepository providerRepository, + ILogger logger +) : ICommandHandler +{ + /// + /// Processa o comando de suspensão de prestador. + /// + /// Comando de suspensão + /// Token de cancelamento + /// Resultado da operação + public async Task HandleAsync(SuspendProviderCommand command, CancellationToken cancellationToken) + { + try + { + logger.LogInformation("Suspending provider {ProviderId}. Reason: {Reason}", + command.ProviderId, command.Reason); + + if (string.IsNullOrWhiteSpace(command.Reason)) + { + logger.LogWarning("Suspension reason is required but was not provided"); + return Result.Failure("Suspension reason is required"); + } + + if (string.IsNullOrWhiteSpace(command.SuspendedBy)) + { + logger.LogWarning("SuspendedBy is required but was not provided"); + return Result.Failure("SuspendedBy is required"); + } + + var provider = await providerRepository.GetByIdAsync(new ProviderId(command.ProviderId), cancellationToken); + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found", command.ProviderId); + return Result.Failure("Provider not found"); + } + + provider.Suspend(command.Reason, command.SuspendedBy); + + await providerRepository.UpdateAsync(provider, cancellationToken); + + logger.LogInformation("Provider {ProviderId} suspended successfully", command.ProviderId); + return Result.Success(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error suspending provider {ProviderId}", command.ProviderId); + return Result.Failure("Failed to suspend provider"); + } + } +} diff --git a/src/Modules/Providers/Application/Mappers/ProviderMapper.cs b/src/Modules/Providers/Application/Mappers/ProviderMapper.cs index 6192f2846..8ee561549 100644 --- a/src/Modules/Providers/Application/Mappers/ProviderMapper.cs +++ b/src/Modules/Providers/Application/Mappers/ProviderMapper.cs @@ -20,13 +20,16 @@ public static ProviderDto ToDto(this Provider provider) provider.Name, provider.Type, provider.BusinessProfile.ToDto(), + provider.Status, provider.VerificationStatus, provider.Documents.Select(d => d.ToDto()).ToList(), provider.Qualifications.Select(q => q.ToDto()).ToList(), provider.CreatedAt, provider.UpdatedAt, provider.IsDeleted, - provider.DeletedAt + provider.DeletedAt, + provider.SuspensionReason, + provider.RejectionReason ); } diff --git a/src/Modules/Providers/Application/Validators/RejectProviderCommandValidator.cs b/src/Modules/Providers/Application/Validators/RejectProviderCommandValidator.cs new file mode 100644 index 000000000..aad9be350 --- /dev/null +++ b/src/Modules/Providers/Application/Validators/RejectProviderCommandValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; + +namespace MeAjudaAi.Modules.Providers.Application.Validators; + +/// +/// Validador para o comando de rejeição de prestador de serviços. +/// +public class RejectProviderCommandValidator : AbstractValidator +{ + public RejectProviderCommandValidator() + { + RuleFor(x => x.ProviderId) + .NotEmpty() + .WithMessage("Provider ID is required"); + + RuleFor(x => x.RejectedBy) + .NotEmpty() + .WithMessage("RejectedBy is required for audit purposes") + .MaximumLength(255) + .WithMessage("RejectedBy cannot exceed 255 characters"); + + RuleFor(x => x.Reason) + .NotEmpty() + .WithMessage("Reason is required for audit purposes") + .MinimumLength(10) + .WithMessage("Reason must be at least 10 characters") + .MaximumLength(1000) + .WithMessage("Reason cannot exceed 1000 characters"); + } +} diff --git a/src/Modules/Providers/Application/Validators/SuspendProviderCommandValidator.cs b/src/Modules/Providers/Application/Validators/SuspendProviderCommandValidator.cs new file mode 100644 index 000000000..acc45ca85 --- /dev/null +++ b/src/Modules/Providers/Application/Validators/SuspendProviderCommandValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; + +namespace MeAjudaAi.Modules.Providers.Application.Validators; + +/// +/// Validador para o comando de suspensão de prestador de serviços. +/// +public class SuspendProviderCommandValidator : AbstractValidator +{ + public SuspendProviderCommandValidator() + { + RuleFor(x => x.ProviderId) + .NotEmpty() + .WithMessage("Provider ID is required"); + + RuleFor(x => x.SuspendedBy) + .NotEmpty() + .WithMessage("SuspendedBy is required for audit purposes") + .MaximumLength(255) + .WithMessage("SuspendedBy cannot exceed 255 characters"); + + RuleFor(x => x.Reason) + .NotEmpty() + .WithMessage("Reason is required for audit purposes") + .MinimumLength(10) + .WithMessage("Reason must be at least 10 characters") + .MaximumLength(1000) + .WithMessage("Reason cannot exceed 1000 characters"); + } +} diff --git a/src/Modules/Providers/Domain/Entities/Provider.cs b/src/Modules/Providers/Domain/Entities/Provider.cs index 9ab0f62d5..ac1c658f2 100644 --- a/src/Modules/Providers/Domain/Entities/Provider.cs +++ b/src/Modules/Providers/Domain/Entities/Provider.cs @@ -47,6 +47,14 @@ public sealed class Provider : AggregateRoot /// public BusinessProfile BusinessProfile { get; private set; } = null!; + /// + /// Status do fluxo de registro do prestador de serviços. + /// + /// + /// Controla o progresso do prestador através do processo de registro multi-etapas. + /// + public EProviderStatus Status { get; private set; } + /// /// Status de verificação do prestador de serviços. /// @@ -74,6 +82,16 @@ public sealed class Provider : AggregateRoot /// public DateTime? DeletedAt { get; private set; } + /// + /// Motivo da suspensão do prestador (obrigatório quando Status = Suspended). + /// + public string? SuspensionReason { get; private set; } + + /// + /// Motivo da rejeição do prestador (obrigatório quando Status = Rejected). + /// + public string? RejectionReason { get; private set; } + /// /// Construtor privado para uso do Entity Framework. /// @@ -99,6 +117,7 @@ public Provider( Name = name.Trim(); Type = type; BusinessProfile = businessProfile; + Status = EProviderStatus.PendingBasicInfo; VerificationStatus = EVerificationStatus.Pending; // Não adiciona eventos de domínio para testes @@ -131,6 +150,7 @@ public Provider( Name = name.Trim(); Type = type; BusinessProfile = businessProfile; + Status = EProviderStatus.PendingBasicInfo; VerificationStatus = EVerificationStatus.Pending; AddDomainEvent(new ProviderRegisteredDomainEvent( @@ -338,14 +358,17 @@ public void RemoveQualification(string qualificationName) /// /// Novo status de verificação /// Quem está fazendo a atualização - public void UpdateVerificationStatus(EVerificationStatus status, string? updatedBy = null) + /// Se true, não chama MarkAsUpdated (útil quando chamado junto com UpdateStatus) + public void UpdateVerificationStatus(EVerificationStatus status, string? updatedBy = null, bool skipMarkAsUpdated = false) { if (IsDeleted) throw new ProviderDomainException("Cannot update verification status of deleted provider"); var previousStatus = VerificationStatus; VerificationStatus = status; - MarkAsUpdated(); + + if (!skipMarkAsUpdated) + MarkAsUpdated(); AddDomainEvent(new ProviderVerificationStatusUpdatedDomainEvent( Id.Value, @@ -355,6 +378,156 @@ public void UpdateVerificationStatus(EVerificationStatus status, string? updated updatedBy)); } + /// + /// Atualiza o status do fluxo de registro do prestador de serviços. + /// + /// Novo status de registro + /// Quem está fazendo a atualização + /// + /// Este método gerencia as transições entre diferentes etapas do processo de registro multi-etapas. + /// Valida que as transições de estado sejam válidas de acordo com as regras de negócio. + /// + public void UpdateStatus(EProviderStatus newStatus, string? updatedBy = null) + { + if (IsDeleted) + throw new ProviderDomainException("Cannot update status of deleted provider"); + + // Valida transições de estado permitidas + ValidateStatusTransition(Status, newStatus); + + // Valida que os motivos obrigatórios estejam preenchidos + ValidateRequiredReasons(newStatus); + + var previousStatus = Status; + Status = newStatus; + MarkAsUpdated(); + ClearReasonFieldsIfNeeded(newStatus); + + // Dispara eventos de domínio específicos baseado na transição + if (newStatus == EProviderStatus.PendingDocumentVerification && previousStatus == EProviderStatus.PendingBasicInfo) + { + AddDomainEvent(new ProviderAwaitingVerificationDomainEvent( + Id.Value, + 1, + UserId, + Name, + updatedBy)); + } + else if (newStatus == EProviderStatus.Active && previousStatus == EProviderStatus.PendingDocumentVerification) + { + AddDomainEvent(new ProviderActivatedDomainEvent( + Id.Value, + 1, + UserId, + Name, + updatedBy)); + } + } + + /// + /// Completa o preenchimento das informações básicas e avança para a etapa de verificação de documentos. + /// + /// Quem está fazendo a atualização + public void CompleteBasicInfo(string? updatedBy = null) + { + if (Status != EProviderStatus.PendingBasicInfo) + throw new ProviderDomainException("Cannot complete basic info when not in PendingBasicInfo status"); + + UpdateStatus(EProviderStatus.PendingDocumentVerification, updatedBy); + } + + /// + /// Retorna o prestador para correção de informações básicas durante a verificação de documentos. + /// + /// Motivo da correção necessária (obrigatório) + /// Quem está solicitando a correção + public void RequireBasicInfoCorrection(string reason, string? updatedBy = null) + { + if (Status != EProviderStatus.PendingDocumentVerification) + throw new ProviderDomainException("Can only require basic info correction during document verification"); + + if (string.IsNullOrWhiteSpace(reason)) + throw new ProviderDomainException("Correction reason is required"); + + UpdateStatus(EProviderStatus.PendingBasicInfo, updatedBy); + + AddDomainEvent(new ProviderBasicInfoCorrectionRequiredDomainEvent( + Id.Value, + 1, + UserId, + Name, + reason, + updatedBy)); + } + + /// + /// Ativa o prestador após verificação bem-sucedida dos documentos. + /// + /// Quem está fazendo a ativação + public void Activate(string? updatedBy = null) + { + if (Status != EProviderStatus.PendingDocumentVerification) + throw new ProviderDomainException("Can only activate providers in PendingDocumentVerification status"); + + UpdateStatus(EProviderStatus.Active, updatedBy); + UpdateVerificationStatus(EVerificationStatus.Verified, updatedBy, skipMarkAsUpdated: true); + } + + /// + /// Reativa um prestador previamente suspenso. + /// + /// Quem está fazendo a reativação + public void Reactivate(string? updatedBy = null) + { + if (Status != EProviderStatus.Suspended) + throw new ProviderDomainException("Can only reactivate providers in Suspended status"); + + UpdateStatus(EProviderStatus.Active, updatedBy); + UpdateVerificationStatus(EVerificationStatus.Verified, updatedBy, skipMarkAsUpdated: true); + } + + /// + /// Suspende o prestador de serviços. + /// + /// Motivo da suspensão (obrigatório) + /// Quem está fazendo a suspensão + public void Suspend(string reason, string? updatedBy = null) + { + if (string.IsNullOrWhiteSpace(reason)) + throw new ProviderDomainException("Suspension reason is required"); + + if (Status == EProviderStatus.Suspended) + return; + + if (IsDeleted) + throw new ProviderDomainException("Cannot suspend deleted provider"); + + SuspensionReason = reason; + UpdateStatus(EProviderStatus.Suspended, updatedBy); + UpdateVerificationStatus(EVerificationStatus.Suspended, updatedBy, skipMarkAsUpdated: true); + } + + /// + /// Rejeita o registro do prestador de serviços. + /// + /// Motivo da rejeição (obrigatório) + /// Quem está fazendo a rejeição + public void Reject(string reason, string? updatedBy = null) + { + if (string.IsNullOrWhiteSpace(reason)) + throw new ProviderDomainException("Rejection reason is required"); + + if (Status == EProviderStatus.Rejected) + return; + + if (IsDeleted) + throw new ProviderDomainException("Cannot reject deleted provider"); + + RejectionReason = reason; + UpdateStatus(EProviderStatus.Rejected, updatedBy); + UpdateVerificationStatus(EVerificationStatus.Rejected, updatedBy, skipMarkAsUpdated: true); + } + /// /// Exclui logicamente o prestador de serviços do sistema. /// @@ -381,6 +554,67 @@ public void Delete(IDateTimeProvider dateTimeProvider, string? deletedBy = null) deletedBy)); } + /// + /// Valida se uma transição de status é permitida pelas regras de negócio. + /// + private static void ValidateStatusTransition(EProviderStatus currentStatus, EProviderStatus newStatus) + { + // Permite manter o mesmo status + if (currentStatus == newStatus) + return; + + var allowedTransitions = new Dictionary + { + [EProviderStatus.PendingBasicInfo] = [EProviderStatus.PendingDocumentVerification, EProviderStatus.Rejected], + [EProviderStatus.PendingDocumentVerification] = [EProviderStatus.Active, EProviderStatus.Rejected, EProviderStatus.PendingBasicInfo], + [EProviderStatus.Active] = [EProviderStatus.Suspended], + [EProviderStatus.Suspended] = [EProviderStatus.Active, EProviderStatus.Rejected], + [EProviderStatus.Rejected] = [EProviderStatus.PendingBasicInfo] + }; + + if (!allowedTransitions.TryGetValue(currentStatus, out var allowed) || !allowed.Contains(newStatus)) + { + throw new ProviderDomainException( + $"Invalid status transition from {currentStatus} to {newStatus}"); + } + } + + /// + /// Limpa os campos de motivo (SuspensionReason e RejectionReason) quando o status não corresponde mais ao motivo armazenado. + /// + /// Novo status do prestador + /// + /// Este método garante a invariante de que os motivos de suspensão e rejeição + /// só existem enquanto o prestador está nos estados Suspended ou Rejected, respectivamente. + /// + private void ClearReasonFieldsIfNeeded(EProviderStatus newStatus) + { + // Limpa o motivo de suspensão se não estiver mais no estado Suspended + if (newStatus != EProviderStatus.Suspended) + SuspensionReason = null; + + // Limpa o motivo de rejeição se não estiver mais no estado Rejected + if (newStatus != EProviderStatus.Rejected) + RejectionReason = null; + } + + /// + /// Valida que os motivos obrigatórios estejam preenchidos ao transicionar para Suspended ou Rejected. + /// + /// Novo status do prestador + /// + /// Este método garante a invariante de auditoria: transições para Suspended requerem SuspensionReason + /// e transições para Rejected requerem RejectionReason. + /// + private void ValidateRequiredReasons(EProviderStatus newStatus) + { + if (newStatus == EProviderStatus.Suspended && string.IsNullOrWhiteSpace(SuspensionReason)) + throw new ProviderDomainException("SuspensionReason is required when transitioning to Suspended status"); + + if (newStatus == EProviderStatus.Rejected && string.IsNullOrWhiteSpace(RejectionReason)) + throw new ProviderDomainException("RejectionReason is required when transitioning to Rejected status"); + } + /// /// Valida as regras de negócio para criação de prestador de serviços. /// diff --git a/src/Modules/Providers/Domain/Enums/EProviderStatus.cs b/src/Modules/Providers/Domain/Enums/EProviderStatus.cs new file mode 100644 index 000000000..af1058a85 --- /dev/null +++ b/src/Modules/Providers/Domain/Enums/EProviderStatus.cs @@ -0,0 +1,47 @@ +namespace MeAjudaAi.Modules.Providers.Domain.Enums; + +/// +/// Status do fluxo de registro do prestador de serviços. +/// +/// +/// Este enum representa os diferentes estágios do processo de registro multi-etapas, +/// permitindo que os prestadores de serviços salvem seu progresso e completem o registro +/// de forma incremental. +/// +public enum EProviderStatus +{ + /// + /// Status não definido + /// + None = 0, + + /// + /// Aguardando preenchimento das informações básicas. + /// O prestador iniciou o registro mas ainda não completou as informações essenciais. + /// + PendingBasicInfo = 1, + + /// + /// Aguardando envio e verificação de documentos. + /// As informações básicas foram preenchidas, mas documentos ainda precisam ser enviados. + /// + PendingDocumentVerification = 2, + + /// + /// Prestador ativo e verificado. + /// Todas as etapas foram completadas e o prestador está apto a oferecer serviços. + /// + Active = 3, + + /// + /// Prestador suspenso. + /// A conta foi temporariamente desativada por violação de políticas ou outros motivos. + /// + Suspended = 4, + + /// + /// Prestador rejeitado. + /// O processo de verificação foi concluído, mas o prestador não atendeu aos requisitos. + /// + Rejected = 5 +} diff --git a/src/Modules/Providers/Domain/Events/ProviderActivatedDomainEvent.cs b/src/Modules/Providers/Domain/Events/ProviderActivatedDomainEvent.cs new file mode 100644 index 000000000..6f93b99ae --- /dev/null +++ b/src/Modules/Providers/Domain/Events/ProviderActivatedDomainEvent.cs @@ -0,0 +1,25 @@ +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Modules.Providers.Domain.Events; + +/// +/// Evento de domínio disparado quando um prestador de serviços é ativado após verificação bem-sucedida. +/// +/// +/// Este evento é publicado quando o prestador completa todas as etapas do processo de registro, +/// incluindo a verificação de documentos, e é ativado no sistema. Pode ser usado para notificar +/// o prestador que ele pode começar a oferecer serviços, atualizar índices de busca, ou iniciar +/// processos de onboarding. +/// +/// Identificador único do prestador de serviços +/// Versão do agregado no momento do evento +/// Identificador do usuário no Keycloak +/// Nome do prestador de serviços +/// Identificador de quem realizou a ativação (pode ser null para ativação automática) +public record ProviderActivatedDomainEvent( + Guid AggregateId, + int Version, + Guid UserId, + string Name, + string? ActivatedBy +) : DomainEvent(AggregateId, Version); diff --git a/src/Modules/Providers/Domain/Events/ProviderAwaitingVerificationDomainEvent.cs b/src/Modules/Providers/Domain/Events/ProviderAwaitingVerificationDomainEvent.cs new file mode 100644 index 000000000..47e86b7de --- /dev/null +++ b/src/Modules/Providers/Domain/Events/ProviderAwaitingVerificationDomainEvent.cs @@ -0,0 +1,25 @@ +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Modules.Providers.Domain.Events; + +/// +/// Evento de domínio disparado quando um prestador de serviços completa as informações básicas +/// e entra na etapa de verificação de documentos. +/// +/// +/// Este evento é publicado quando o prestador transita do status PendingBasicInfo para +/// PendingDocumentVerification. Pode ser usado para notificar o prestador sobre os próximos passos, +/// enviar instruções sobre upload de documentos, ou iniciar processos de verificação assíncronos. +/// +/// Identificador único do prestador de serviços +/// Versão do agregado no momento do evento +/// Identificador do usuário no Keycloak +/// Nome do prestador de serviços +/// Identificador de quem realizou a atualização (pode ser null para auto-atualização) +public record ProviderAwaitingVerificationDomainEvent( + Guid AggregateId, + int Version, + Guid UserId, + string Name, + string? UpdatedBy +) : DomainEvent(AggregateId, Version); diff --git a/src/Modules/Providers/Domain/Events/ProviderBasicInfoCorrectionRequiredDomainEvent.cs b/src/Modules/Providers/Domain/Events/ProviderBasicInfoCorrectionRequiredDomainEvent.cs new file mode 100644 index 000000000..092c697bb --- /dev/null +++ b/src/Modules/Providers/Domain/Events/ProviderBasicInfoCorrectionRequiredDomainEvent.cs @@ -0,0 +1,28 @@ +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Modules.Providers.Domain.Events; + +/// +/// Evento de domínio disparado quando um prestador de serviços precisa corrigir suas informações básicas +/// durante o processo de verificação de documentos. +/// +/// +/// Este evento é publicado quando o prestador é retornado do status PendingDocumentVerification para +/// PendingBasicInfo devido a inconsistências ou informações faltantes identificadas durante a verificação. +/// Pode ser usado para notificar o prestador sobre as correções necessárias, enviar emails de notificação, +/// ou registrar a solicitação de correção em sistemas de auditoria. +/// +/// Identificador único do prestador de serviços +/// Versão do agregado no momento do evento +/// Identificador do usuário no Keycloak +/// Nome do prestador de serviços +/// Motivo detalhado da correção necessária +/// Identificador de quem solicitou a correção (verificador/administrador) +public record ProviderBasicInfoCorrectionRequiredDomainEvent( + Guid AggregateId, + int Version, + Guid UserId, + string Name, + string Reason, + string? RequestedBy +) : DomainEvent(AggregateId, Version); diff --git a/src/Modules/Providers/Infrastructure/CONFIGURATION.md b/src/Modules/Providers/Infrastructure/CONFIGURATION.md new file mode 100644 index 000000000..e75ee331c --- /dev/null +++ b/src/Modules/Providers/Infrastructure/CONFIGURATION.md @@ -0,0 +1,42 @@ +# Configuration Guide + +## Database Connection String + +The database connection string should **never** be committed to source control. Use one of the following methods to configure it: + +### Local Development (Recommended) + +Use .NET User Secrets: + +```bash +# Navigate to the Infrastructure project +cd src/Modules/Providers/Infrastructure + +# Set the connection string +dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Host=localhost;Port=5432;Database=meajudaai;Username=postgres;Password=YOUR_PASSWORD" +``` + +### CI/CD and Production + +Set the connection string via environment variable: + +```bash +# Linux/Mac +export ConnectionStrings__DefaultConnection="Host=your-host;Port=5432;Database=meajudaai;Username=user;Password=password" + +# Windows PowerShell +$env:ConnectionStrings__DefaultConnection="Host=your-host;Port=5432;Database=meajudaai;Username=user;Password=password" +``` + +### Docker/Container Environments + +Use environment variables in your docker-compose.yml or Kubernetes manifests: + +```yaml +environment: + - ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=meajudaai;Username=user;Password=${DB_PASSWORD} +``` + +## Security Note + +⚠️ **IMPORTANT**: The original hardcoded credentials have been removed from appsettings.json. If you previously used these credentials in production, please rotate them immediately. diff --git a/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderActivatedDomainEventHandler.cs b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderActivatedDomainEventHandler.cs new file mode 100644 index 000000000..2bd170f02 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderActivatedDomainEventHandler.cs @@ -0,0 +1,46 @@ +using MeAjudaAi.Modules.Providers.Domain.Events; +using MeAjudaAi.Modules.Providers.Infrastructure.Events.Mappers; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Events.Handlers; + +/// +/// Manipula eventos de domínio ProviderActivatedDomainEvent e publica eventos de integração. +/// +/// +/// Responsável por converter eventos de domínio em eventos de integração para comunicação +/// entre módulos. Quando um prestador é ativado no sistema, este handler publica um evento +/// de integração para notificar outros sistemas, como o módulo de busca. +/// +public sealed class ProviderActivatedDomainEventHandler( + IMessageBus messageBus, + ILogger logger) : IEventHandler +{ + /// + /// Processa o evento de domínio de prestador ativado de forma assíncrona. + /// + /// Evento de domínio contendo dados do prestador + /// Token de cancelamento + /// Task representando a operação assíncrona + public async Task HandleAsync(ProviderActivatedDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + try + { + logger.LogInformation("Handling ProviderActivatedDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + + // Cria evento de integração para sistemas externos usando mapper + var integrationEvent = domainEvent.ToIntegrationEvent(); + + await messageBus.PublishAsync(integrationEvent, cancellationToken: cancellationToken); + + logger.LogInformation("Successfully published ProviderActivated integration event for provider {ProviderId}", domainEvent.AggregateId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling ProviderActivatedDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + throw; + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderAwaitingVerificationDomainEventHandler.cs b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderAwaitingVerificationDomainEventHandler.cs new file mode 100644 index 000000000..e6ddb9f3f --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Events/Handlers/ProviderAwaitingVerificationDomainEventHandler.cs @@ -0,0 +1,46 @@ +using MeAjudaAi.Modules.Providers.Domain.Events; +using MeAjudaAi.Modules.Providers.Infrastructure.Events.Mappers; +using MeAjudaAi.Shared.Events; +using MeAjudaAi.Shared.Messaging; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Events.Handlers; + +/// +/// Manipula eventos de domínio ProviderAwaitingVerificationDomainEvent e publica eventos de integração. +/// +/// +/// Responsável por converter eventos de domínio em eventos de integração para comunicação +/// entre módulos. Quando um prestador entra na fase de verificação de documentos, este handler +/// publica um evento de integração para notificar outros sistemas. +/// +public sealed class ProviderAwaitingVerificationDomainEventHandler( + IMessageBus messageBus, + ILogger logger) : IEventHandler +{ + /// + /// Processa o evento de domínio de prestador aguardando verificação de forma assíncrona. + /// + /// Evento de domínio contendo dados do prestador + /// Token de cancelamento + /// Task representando a operação assíncrona + public async Task HandleAsync(ProviderAwaitingVerificationDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + try + { + logger.LogInformation("Handling ProviderAwaitingVerificationDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + + // Cria evento de integração para sistemas externos usando mapper + var integrationEvent = domainEvent.ToIntegrationEvent(); + + await messageBus.PublishAsync(integrationEvent, cancellationToken: cancellationToken); + + logger.LogInformation("Successfully published ProviderAwaitingVerification integration event for provider {ProviderId}", domainEvent.AggregateId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling ProviderAwaitingVerificationDomainEvent for provider {ProviderId}", domainEvent.AggregateId); + throw; + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs b/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs index 72e29c2be..a806e0a3f 100644 --- a/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs +++ b/src/Modules/Providers/Infrastructure/Events/Mappers/ProviderEventMappers.cs @@ -73,4 +73,34 @@ public static ProviderProfileUpdatedIntegrationEvent ToIntegrationEvent(this Pro NewEmail: domainEvent.Email ); } + + /// + /// Converte ProviderAwaitingVerificationDomainEvent para ProviderAwaitingVerificationIntegrationEvent. + /// + public static ProviderAwaitingVerificationIntegrationEvent ToIntegrationEvent(this ProviderAwaitingVerificationDomainEvent domainEvent) + { + return new ProviderAwaitingVerificationIntegrationEvent( + Source: ModuleName, + ProviderId: domainEvent.AggregateId, + UserId: domainEvent.UserId, + Name: domainEvent.Name, + UpdatedBy: domainEvent.UpdatedBy, + TransitionedAt: DateTime.UtcNow + ); + } + + /// + /// Converte ProviderActivatedDomainEvent para ProviderActivatedIntegrationEvent. + /// + public static ProviderActivatedIntegrationEvent ToIntegrationEvent(this ProviderActivatedDomainEvent domainEvent) + { + return new ProviderActivatedIntegrationEvent( + Source: ModuleName, + ProviderId: domainEvent.AggregateId, + UserId: domainEvent.UserId, + Name: domainEvent.Name, + ActivatedBy: domainEvent.ActivatedBy, + ActivatedAt: DateTime.UtcNow + ); + } } \ No newline at end of file diff --git a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs index 7302dd38c..ed90d3439 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Configurations/ProviderConfiguration.cs @@ -34,6 +34,14 @@ public void Configure(EntityTypeBuilder builder) .IsRequired() .HasColumnName("type"); + builder.Property(p => p.Status) + .HasConversion( + status => status.ToString(), + value => Enum.Parse(value)) + .HasMaxLength(30) + .IsRequired() + .HasColumnName("status"); + builder.Property(p => p.VerificationStatus) .HasConversion( status => status.ToString(), @@ -49,6 +57,14 @@ public void Configure(EntityTypeBuilder builder) builder.Property(p => p.DeletedAt) .HasColumnName("deleted_at"); + builder.Property(p => p.SuspensionReason) + .HasMaxLength(1000) + .HasColumnName("suspension_reason"); + + builder.Property(p => p.RejectionReason) + .HasMaxLength(1000) + .HasColumnName("rejection_reason"); + // Configuração das propriedades de auditoria da BaseEntity builder.Property(p => p.CreatedAt) .IsRequired() @@ -207,6 +223,9 @@ public void Configure(EntityTypeBuilder builder) builder.HasIndex(p => p.Type) .HasDatabaseName("ix_providers_type"); + builder.HasIndex(p => p.Status) + .HasDatabaseName("ix_providers_status"); + builder.HasIndex(p => p.VerificationStatus) .HasDatabaseName("ix_providers_verification_status"); diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112144744_AddProviderStatusColumn.Designer.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112144744_AddProviderStatusColumn.Designer.cs new file mode 100644 index 000000000..3b1227ebf --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112144744_AddProviderStatusColumn.Designer.cs @@ -0,0 +1,331 @@ +// +using System; +using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ProvidersDbContext))] + [Migration("20251112144744_AddProviderStatusColumn")] + partial class AddProviderStatusColumn + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("providers") + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VerificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("verification_status"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("ix_providers_is_deleted"); + + b.HasIndex("Name") + .HasDatabaseName("ix_providers_name"); + + b.HasIndex("Status") + .HasDatabaseName("ix_providers_status"); + + b.HasIndex("Type") + .HasDatabaseName("ix_providers_type"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_providers_user_id"); + + b.HasIndex("VerificationStatus") + .HasDatabaseName("ix_providers_verification_status"); + + b.ToTable("providers", "providers"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.BusinessProfile", "BusinessProfile", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid"); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("FantasyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("fantasy_name"); + + b1.Property("LegalName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("legal_name"); + + b1.HasKey("ProviderId"); + + b1.ToTable("providers", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Address", "PrimaryAddress", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid"); + + b2.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("city"); + + b2.Property("Complement") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("complement"); + + b2.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("country"); + + b2.Property("Neighborhood") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("neighborhood"); + + b2.Property("Number") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("number"); + + b2.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("state"); + + b2.Property("Street") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("street"); + + b2.Property("ZipCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("zip_code"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId"); + }); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.ContactInfo", "ContactInfo", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid"); + + b2.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b2.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("phone_number"); + + b2.Property("Website") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("website"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId"); + }); + + b1.Navigation("ContactInfo") + .IsRequired(); + + b1.Navigation("PrimaryAddress") + .IsRequired(); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Document", "Documents", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("DocumentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("document_type"); + + b1.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_primary"); + + b1.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("number"); + + b1.HasKey("ProviderId", "Id"); + + b1.HasIndex("ProviderId", "DocumentType") + .IsUnique(); + + b1.ToTable("document", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Qualification", "Qualifications", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("DocumentNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("document_number"); + + b1.Property("ExpirationDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_date"); + + b1.Property("IssueDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("issue_date"); + + b1.Property("IssuingOrganization") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("issuing_organization"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b1.HasKey("ProviderId", "Id"); + + b1.ToTable("qualification", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + }); + + b.Navigation("BusinessProfile") + .IsRequired(); + + b.Navigation("Documents"); + + b.Navigation("Qualifications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112144744_AddProviderStatusColumn.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112144744_AddProviderStatusColumn.cs new file mode 100644 index 000000000..53d2e0d28 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112144744_AddProviderStatusColumn.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddProviderStatusColumn : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Add column with valid default value + migrationBuilder.AddColumn( + name: "status", + schema: "providers", + table: "providers", + type: "character varying(30)", + maxLength: 30, + nullable: false, + defaultValue: "PendingBasicInfo"); + + // Migrate existing data based on verification_status + migrationBuilder.Sql(@" + UPDATE providers.providers + SET status = CASE verification_status + WHEN 'Verified' THEN 'Active' + WHEN 'Suspended' THEN 'Suspended' + WHEN 'Rejected' THEN 'Rejected' + WHEN 'InProgress' THEN 'PendingDocumentVerification' + ELSE 'PendingBasicInfo' + END; + "); + + migrationBuilder.CreateIndex( + name: "ix_providers_status", + schema: "providers", + table: "providers", + column: "status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_providers_status", + schema: "providers", + table: "providers"); + + migrationBuilder.DropColumn( + name: "status", + schema: "providers", + table: "providers"); + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112181927_AddSuspensionAndRejectionReasonColumns.Designer.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112181927_AddSuspensionAndRejectionReasonColumns.Designer.cs new file mode 100644 index 000000000..fbff9d9e4 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112181927_AddSuspensionAndRejectionReasonColumns.Designer.cs @@ -0,0 +1,341 @@ +// +using System; +using MeAjudaAi.Modules.Providers.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ProvidersDbContext))] + [Migration("20251112181927_AddSuspensionAndRejectionReasonColumns")] + partial class AddSuspensionAndRejectionReasonColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("providers") + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasColumnName("is_deleted"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status"); + + b.Property("SuspensionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("suspension_reason"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VerificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("verification_status"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted") + .HasDatabaseName("ix_providers_is_deleted"); + + b.HasIndex("Name") + .HasDatabaseName("ix_providers_name"); + + b.HasIndex("Status") + .HasDatabaseName("ix_providers_status"); + + b.HasIndex("Type") + .HasDatabaseName("ix_providers_type"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_providers_user_id"); + + b.HasIndex("VerificationStatus") + .HasDatabaseName("ix_providers_verification_status"); + + b.ToTable("providers", "providers"); + }); + + modelBuilder.Entity("MeAjudaAi.Modules.Providers.Domain.Entities.Provider", b => + { + b.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.BusinessProfile", "BusinessProfile", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid"); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("FantasyName") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("fantasy_name"); + + b1.Property("LegalName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("legal_name"); + + b1.HasKey("ProviderId"); + + b1.ToTable("providers", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Address", "PrimaryAddress", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid"); + + b2.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("city"); + + b2.Property("Complement") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("complement"); + + b2.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("country"); + + b2.Property("Neighborhood") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("neighborhood"); + + b2.Property("Number") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("number"); + + b2.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("state"); + + b2.Property("Street") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("street"); + + b2.Property("ZipCode") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("zip_code"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId"); + }); + + b1.OwnsOne("MeAjudaAi.Modules.Providers.Domain.ValueObjects.ContactInfo", "ContactInfo", b2 => + { + b2.Property("BusinessProfileProviderId") + .HasColumnType("uuid"); + + b2.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b2.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("phone_number"); + + b2.Property("Website") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("website"); + + b2.HasKey("BusinessProfileProviderId"); + + b2.ToTable("providers", "providers"); + + b2.WithOwner() + .HasForeignKey("BusinessProfileProviderId"); + }); + + b1.Navigation("ContactInfo") + .IsRequired(); + + b1.Navigation("PrimaryAddress") + .IsRequired(); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Document", "Documents", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("DocumentType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("document_type"); + + b1.Property("IsPrimary") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_primary"); + + b1.Property("Number") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("number"); + + b1.HasKey("ProviderId", "Id"); + + b1.HasIndex("ProviderId", "DocumentType") + .IsUnique(); + + b1.ToTable("document", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + }); + + b.OwnsMany("MeAjudaAi.Modules.Providers.Domain.ValueObjects.Qualification", "Qualifications", b1 => + { + b1.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("description"); + + b1.Property("DocumentNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("document_number"); + + b1.Property("ExpirationDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_date"); + + b1.Property("IssueDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("issue_date"); + + b1.Property("IssuingOrganization") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("issuing_organization"); + + b1.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b1.HasKey("ProviderId", "Id"); + + b1.ToTable("qualification", "providers"); + + b1.WithOwner() + .HasForeignKey("ProviderId"); + }); + + b.Navigation("BusinessProfile") + .IsRequired(); + + b.Navigation("Documents"); + + b.Navigation("Qualifications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112181927_AddSuspensionAndRejectionReasonColumns.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112181927_AddSuspensionAndRejectionReasonColumns.cs new file mode 100644 index 000000000..c03841387 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/20251112181927_AddSuspensionAndRejectionReasonColumns.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MeAjudaAi.Modules.Providers.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddSuspensionAndRejectionReasonColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "rejection_reason", + schema: "providers", + table: "providers", + type: "character varying(1000)", + maxLength: 1000, + nullable: true); + + migrationBuilder.AddColumn( + name: "suspension_reason", + schema: "providers", + table: "providers", + type: "character varying(1000)", + maxLength: 1000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "rejection_reason", + schema: "providers", + table: "providers"); + + migrationBuilder.DropColumn( + name: "suspension_reason", + schema: "providers", + table: "providers"); + } + } +} diff --git a/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs b/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs index fbd9eb083..58e538157 100644 --- a/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs +++ b/src/Modules/Providers/Infrastructure/Persistence/Migrations/ProvidersDbContextModelSnapshot.cs @@ -47,6 +47,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(100)") .HasColumnName("name"); + b.Property("RejectionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("rejection_reason"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("status"); + + b.Property("SuspensionReason") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("suspension_reason"); + b.Property("Type") .IsRequired() .HasMaxLength(20) @@ -75,6 +91,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("Name") .HasDatabaseName("ix_providers_name"); + b.HasIndex("Status") + .HasDatabaseName("ix_providers_status"); + b.HasIndex("Type") .HasDatabaseName("ix_providers_type"); diff --git a/src/Modules/Providers/Infrastructure/appsettings.json b/src/Modules/Providers/Infrastructure/appsettings.json new file mode 100644 index 000000000..b29764211 --- /dev/null +++ b/src/Modules/Providers/Infrastructure/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "PLACEHOLDER - Use User Secrets or Environment Variables" + } +} diff --git a/src/Modules/Providers/Tests/Unit/Application/Commands/RequireBasicInfoCorrectionCommandHandlerTests.cs b/src/Modules/Providers/Tests/Unit/Application/Commands/RequireBasicInfoCorrectionCommandHandlerTests.cs new file mode 100644 index 000000000..901e30136 --- /dev/null +++ b/src/Modules/Providers/Tests/Unit/Application/Commands/RequireBasicInfoCorrectionCommandHandlerTests.cs @@ -0,0 +1,314 @@ +using MeAjudaAi.Modules.Providers.Application.Commands; +using MeAjudaAi.Modules.Providers.Application.Handlers.Commands; +using MeAjudaAi.Modules.Providers.Domain.Entities; +using MeAjudaAi.Modules.Providers.Domain.Enums; +using MeAjudaAi.Modules.Providers.Domain.Events; +using MeAjudaAi.Modules.Providers.Domain.Repositories; +using MeAjudaAi.Modules.Providers.Domain.ValueObjects; +using MeAjudaAi.Modules.Providers.Tests.Builders; +using Microsoft.Extensions.Logging; + +namespace MeAjudaAi.Modules.Providers.Tests.Unit.Application.Commands; + +[Trait("Category", "Unit")] +[Trait("Module", "Providers")] +[Trait("Layer", "Application")] +public class RequireBasicInfoCorrectionCommandHandlerTests +{ + private readonly Mock _providerRepositoryMock; + private readonly Mock> _loggerMock; + private readonly RequireBasicInfoCorrectionCommandHandler _handler; + + public RequireBasicInfoCorrectionCommandHandlerTests() + { + _providerRepositoryMock = new Mock(); + _loggerMock = new Mock>(); + _handler = new RequireBasicInfoCorrectionCommandHandler(_providerRepositoryMock.Object, _loggerMock.Object); + } + + [Fact] + public async Task HandleAsync_WithValidRequest_ShouldReturnSuccessResult() + { + // Arrange + var providerId = Guid.NewGuid(); + var provider = ProviderBuilder.Create() + .WithId(providerId) + .Build(); + + provider.CompleteBasicInfo(); // Transition to PendingDocumentVerification + + var command = new RequireBasicInfoCorrectionCommand( + ProviderId: providerId, + Reason: "Missing required information in business profile", + RequestedBy: "verifier@test.com" + ); + + _providerRepositoryMock + .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(provider); + + _providerRepositoryMock + .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + provider.Status.Should().Be(EProviderStatus.PendingBasicInfo); + + _providerRepositoryMock.Verify( + r => r.GetByIdAsync(It.IsAny(), It.IsAny()), + Times.Once); + + _providerRepositoryMock.Verify( + r => r.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenProviderNotFound_ShouldReturnFailureResult() + { + // Arrange + var providerId = Guid.NewGuid(); + var command = new RequireBasicInfoCorrectionCommand( + ProviderId: providerId, + Reason: "Missing information", + RequestedBy: "verifier@test.com" + ); + + _providerRepositoryMock + .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((Provider?)null); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.ToString().Should().Contain("Provider not found"); + + _providerRepositoryMock.Verify( + r => r.GetByIdAsync(It.IsAny(), It.IsAny()), + Times.Once); + + _providerRepositoryMock.Verify( + r => r.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task HandleAsync_WithInvalidReason_ShouldReturnFailureResult(string? invalidReason) + { + // Arrange + var providerId = Guid.NewGuid(); + var command = new RequireBasicInfoCorrectionCommand( + ProviderId: providerId, + Reason: invalidReason!, + RequestedBy: "verifier@test.com" + ); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.ToString().Should().Contain("Correction reason is required"); + + _providerRepositoryMock.Verify( + r => r.GetByIdAsync(It.IsAny(), It.IsAny()), + Times.Never); + + _providerRepositoryMock.Verify( + r => r.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task HandleAsync_WithInvalidRequestedBy_ShouldReturnFailureResult(string? invalidRequestedBy) + { + // Arrange + var providerId = Guid.NewGuid(); + var command = new RequireBasicInfoCorrectionCommand( + ProviderId: providerId, + Reason: "Missing information", + RequestedBy: invalidRequestedBy! + ); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.ToString().Should().Contain("RequestedBy is required"); + + _providerRepositoryMock.Verify( + r => r.GetByIdAsync(It.IsAny(), It.IsAny()), + Times.Never); + + _providerRepositoryMock.Verify( + r => r.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData(EProviderStatus.PendingBasicInfo)] + [InlineData(EProviderStatus.Active)] + [InlineData(EProviderStatus.Suspended)] + [InlineData(EProviderStatus.Rejected)] + public async Task HandleAsync_WhenProviderNotInPendingDocumentVerification_ShouldReturnDomainValidationMessage(EProviderStatus status) + { + // Arrange + var providerId = Guid.NewGuid(); + var provider = ProviderBuilder.Create() + .WithId(providerId) + .Build(); + + // Transition provider to the specified status + if (status == EProviderStatus.Active) + { + provider.CompleteBasicInfo(); + provider.Activate(); + } + else if (status == EProviderStatus.Suspended) + { + provider.CompleteBasicInfo(); + provider.Activate(); + provider.Suspend("Test reason", "admin"); + } + else if (status == EProviderStatus.Rejected) + { + provider.Reject("Test reason", "admin"); + } + // PendingBasicInfo is default, no action needed + + var command = new RequireBasicInfoCorrectionCommand( + ProviderId: providerId, + Reason: "Missing information", + RequestedBy: "verifier@test.com" + ); + + _providerRepositoryMock + .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(provider); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.ToString().Should().Contain("Can only require basic info correction during document verification"); + + _providerRepositoryMock.Verify( + r => r.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task HandleAsync_WhenRepositoryThrowsException_ShouldReturnFailureResult() + { + // Arrange + var providerId = Guid.NewGuid(); + var command = new RequireBasicInfoCorrectionCommand( + ProviderId: providerId, + Reason: "Missing information", + RequestedBy: "verifier@test.com" + ); + + _providerRepositoryMock + .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Database error")); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.ToString().Should().Contain("Failed to require basic info correction"); + + _providerRepositoryMock.Verify( + r => r.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task HandleAsync_ShouldTransitionProviderToPendingBasicInfo() + { + // Arrange + var providerId = Guid.NewGuid(); + var provider = ProviderBuilder.Create() + .WithId(providerId) + .Build(); + + provider.CompleteBasicInfo(); // Transition to PendingDocumentVerification + + var command = new RequireBasicInfoCorrectionCommand( + ProviderId: providerId, + Reason: "Address information incomplete", + RequestedBy: "admin@test.com" + ); + + _providerRepositoryMock + .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(provider); + + _providerRepositoryMock + .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + provider.Status.Should().Be(EProviderStatus.PendingBasicInfo); + provider.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task HandleAsync_ShouldEmitDomainEvent() + { + // Arrange + var providerId = Guid.NewGuid(); + var provider = ProviderBuilder.Create() + .WithId(providerId) + .Build(); + + provider.CompleteBasicInfo(); // Transition to PendingDocumentVerification + + var command = new RequireBasicInfoCorrectionCommand( + ProviderId: providerId, + Reason: "Contact information needs verification", + RequestedBy: "verifier@test.com" + ); + + _providerRepositoryMock + .Setup(r => r.GetByIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(provider); + + _providerRepositoryMock + .Setup(r => r.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + // Act + var result = await _handler.HandleAsync(command, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + provider.DomainEvents.Should().NotBeEmpty(); + + var correctionEvent = provider.DomainEvents + .OfType() + .Should().ContainSingle().Subject; + + correctionEvent.Reason.Should().Be("Contact information needs verification"); + correctionEvent.RequestedBy.Should().Be("verifier@test.com"); + } +} diff --git a/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs b/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs index 246510604..d54c9f82f 100644 --- a/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs +++ b/src/Modules/Providers/Tests/Unit/Application/Services/ProvidersModuleApiTests.cs @@ -230,6 +230,7 @@ private static ProviderDto CreateTestProviderDto(Guid id) Country: "Brasil" ) ), + Status: EProviderStatus.PendingBasicInfo, VerificationStatus: EVerificationStatus.Pending, Documents: new List { @@ -243,7 +244,9 @@ private static ProviderDto CreateTestProviderDto(Guid id) CreatedAt: DateTime.UtcNow, UpdatedAt: null, IsDeleted: false, - DeletedAt: null + DeletedAt: null, + SuspensionReason: null, + RejectionReason: null ); } diff --git a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs index 9c9c2cb74..9c54c098c 100644 --- a/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs +++ b/src/Modules/Providers/Tests/Unit/Domain/Entities/ProviderTests.cs @@ -397,4 +397,390 @@ private static Provider CreateValidProvider() return new Provider(userId, name, type, businessProfile); } + + #region Status Transition Tests + + [Fact] + public void Constructor_ShouldSetInitialStatusToPendingBasicInfo() + { + // Arrange & Act + var provider = CreateValidProvider(); + + // Assert + provider.Status.Should().Be(EProviderStatus.PendingBasicInfo); + } + + [Fact] + public void CompleteBasicInfo_WhenInPendingBasicInfo_ShouldTransitionToPendingDocumentVerification() + { + // Arrange + var provider = CreateValidProvider(); + provider.ClearDomainEvents(); // Clear registration event + + // Act + provider.CompleteBasicInfo("admin@test.com"); + + // Assert + provider.Status.Should().Be(EProviderStatus.PendingDocumentVerification); + provider.DomainEvents.Should().ContainSingle(e => e is ProviderAwaitingVerificationDomainEvent); + } + + [Fact] + public void CompleteBasicInfo_WhenNotInPendingBasicInfo_ShouldThrowException() + { + // Arrange + var provider = CreateValidProvider(); + provider.CompleteBasicInfo(); // Transition to PendingDocumentVerification + + // Act + var act = () => provider.CompleteBasicInfo(); + + // Assert + act.Should().Throw() + .WithMessage("Cannot complete basic info when not in PendingBasicInfo status"); + } + + [Fact] + public void Activate_WhenInPendingDocumentVerification_ShouldTransitionToActive() + { + // Arrange + var provider = CreateValidProvider(); + provider.CompleteBasicInfo(); + provider.ClearDomainEvents(); + + // Act + provider.Activate("admin@test.com"); + + // Assert + provider.Status.Should().Be(EProviderStatus.Active); + provider.VerificationStatus.Should().Be(EVerificationStatus.Verified); + provider.DomainEvents.Should().ContainSingle(e => e is ProviderActivatedDomainEvent); + } + + [Fact] + public void Activate_WhenNotInPendingDocumentVerification_ShouldThrowException() + { + // Arrange + var provider = CreateValidProvider(); + + // Act + var act = () => provider.Activate(); + + // Assert + act.Should().Throw() + .WithMessage("Can only activate providers in PendingDocumentVerification status"); + } + + [Fact] + public void Suspend_WhenActive_ShouldTransitionToSuspended() + { + // Arrange + var provider = CreateValidProvider(); + provider.CompleteBasicInfo(); + provider.Activate(); + + // Act + provider.Suspend("Violation of terms of service", "admin@test.com"); + + // Assert + provider.Status.Should().Be(EProviderStatus.Suspended); + provider.VerificationStatus.Should().Be(EVerificationStatus.Suspended); + provider.SuspensionReason.Should().Be("Violation of terms of service"); + } + + [Fact] + public void Suspend_WithoutReason_ShouldThrowException() + { + // Arrange + var provider = CreateValidProvider(); + provider.CompleteBasicInfo(); + provider.Activate(); + + // Act + var act = () => provider.Suspend("", "admin@test.com"); + + // Assert + act.Should().Throw() + .WithMessage("Suspension reason is required"); + } + + [Fact] + public void Suspend_WhenAlreadySuspended_ShouldNotChange() + { + // Arrange + var provider = CreateValidProvider(); + provider.CompleteBasicInfo(); + provider.Activate(); + provider.Suspend("Initial reason", "admin@test.com"); + var previousUpdateTime = provider.UpdatedAt; + + // Act + provider.Suspend("Another reason", "admin@test.com"); + + // Assert + provider.Status.Should().Be(EProviderStatus.Suspended); + provider.UpdatedAt.Should().Be(previousUpdateTime); + } + + [Fact] + public void Reject_WhenInPendingDocumentVerification_ShouldTransitionToRejected() + { + // Arrange + var provider = CreateValidProvider(); + provider.CompleteBasicInfo(); + + // Act + provider.Reject("Invalid documentation provided", "admin@test.com"); + + // Assert + provider.Status.Should().Be(EProviderStatus.Rejected); + provider.VerificationStatus.Should().Be(EVerificationStatus.Rejected); + provider.RejectionReason.Should().Be("Invalid documentation provided"); + } + + [Fact] + public void Reject_WithoutReason_ShouldThrowException() + { + // Arrange + var provider = CreateValidProvider(); + provider.CompleteBasicInfo(); + + // Act + var act = () => provider.Reject("", "admin@test.com"); + + // Assert + act.Should().Throw() + .WithMessage("Rejection reason is required"); + } + + [Fact] + public void UpdateStatus_WithInvalidTransition_ShouldThrowException() + { + // Arrange + var provider = CreateValidProvider(); + + // Act - Try to go directly from PendingBasicInfo to Active (not allowed) + var act = () => provider.UpdateStatus(EProviderStatus.Active); + + // Assert + act.Should().Throw() + .WithMessage("Invalid status transition from PendingBasicInfo to Active"); + } + + [Fact] + public void UpdateStatus_WhenProviderIsDeleted_ShouldThrowException() + { + // Arrange + var provider = CreateValidProvider(); + var dateTimeProvider = CreateMockDateTimeProvider(); + provider.Delete(dateTimeProvider); + + // Act + var act = () => provider.UpdateStatus(EProviderStatus.Active); + + // Assert + act.Should().Throw() + .WithMessage("Cannot update status of deleted provider"); + } + + [Theory] + [InlineData(EProviderStatus.PendingBasicInfo, EProviderStatus.PendingDocumentVerification, true)] + [InlineData(EProviderStatus.PendingBasicInfo, EProviderStatus.Rejected, true)] + [InlineData(EProviderStatus.PendingDocumentVerification, EProviderStatus.Active, true)] + [InlineData(EProviderStatus.PendingDocumentVerification, EProviderStatus.Rejected, true)] + [InlineData(EProviderStatus.PendingDocumentVerification, EProviderStatus.PendingBasicInfo, true)] + [InlineData(EProviderStatus.Active, EProviderStatus.Suspended, true)] + [InlineData(EProviderStatus.Suspended, EProviderStatus.Active, true)] + [InlineData(EProviderStatus.Suspended, EProviderStatus.Rejected, true)] + [InlineData(EProviderStatus.Rejected, EProviderStatus.PendingBasicInfo, true)] + [InlineData(EProviderStatus.PendingBasicInfo, EProviderStatus.Active, false)] + [InlineData(EProviderStatus.PendingBasicInfo, EProviderStatus.Suspended, false)] + [InlineData(EProviderStatus.Active, EProviderStatus.PendingBasicInfo, false)] + public void StatusTransitions_ShouldFollowDefinedRules(EProviderStatus from, EProviderStatus to, bool shouldSucceed) + { + // Arrange + var provider = CreateValidProvider(); + + // Set the provider to the "from" status + // This is a bit hacky but necessary for testing + if (from == EProviderStatus.PendingDocumentVerification) + { + provider.CompleteBasicInfo(); + } + else if (from == EProviderStatus.Active) + { + provider.CompleteBasicInfo(); + provider.Activate(); + } + else if (from == EProviderStatus.Suspended) + { + provider.CompleteBasicInfo(); + provider.Activate(); + provider.Suspend("Test suspension", "admin@test.com"); + } + else if (from == EProviderStatus.Rejected) + { + provider.CompleteBasicInfo(); + provider.Reject("Test rejection", "admin@test.com"); + } + + // Act & Assert + if (shouldSucceed) + { + // Use appropriate method for transitions that require reasons + if (to == EProviderStatus.Suspended) + { + var act = () => provider.Suspend("Test suspension reason", "admin@test.com"); + act.Should().NotThrow(); + } + else if (to == EProviderStatus.Rejected) + { + var act = () => provider.Reject("Test rejection reason", "admin@test.com"); + act.Should().NotThrow(); + } + else if (to == EProviderStatus.Active && from == EProviderStatus.Suspended) + { + var act = () => provider.Reactivate("admin@test.com"); + act.Should().NotThrow(); + } + else + { + var act = () => provider.UpdateStatus(to); + act.Should().NotThrow(); + } + + provider.Status.Should().Be(to); + } + else + { + var act = () => provider.UpdateStatus(to); + act.Should().Throw() + .WithMessage($"Invalid status transition from {from} to {to}"); + } + } + + [Fact] + public void Reactivate_WhenSuspended_ShouldTransitionToActive() + { + // Arrange + var provider = CreateValidProvider(); + provider.CompleteBasicInfo(); + provider.Activate(); + provider.Suspend("Policy violation", "admin@test.com"); + + // Act + provider.Reactivate("admin@test.com"); + + // Assert + provider.Status.Should().Be(EProviderStatus.Active); + provider.VerificationStatus.Should().Be(EVerificationStatus.Verified); + } + + [Theory] + [InlineData(EProviderStatus.PendingBasicInfo)] + [InlineData(EProviderStatus.PendingDocumentVerification)] + [InlineData(EProviderStatus.Active)] + [InlineData(EProviderStatus.Rejected)] + public void Reactivate_WhenNotInSuspendedStatus_ShouldThrowException(EProviderStatus currentStatus) + { + // Arrange + var provider = CreateValidProvider(); + + // Set provider to the specified status + if (currentStatus == EProviderStatus.PendingDocumentVerification) + { + provider.CompleteBasicInfo(); + } + else if (currentStatus == EProviderStatus.Active) + { + provider.CompleteBasicInfo(); + provider.Activate(); + } + else if (currentStatus == EProviderStatus.Rejected) + { + provider.CompleteBasicInfo(); + provider.Reject("Invalid documents", "admin@test.com"); + } + // PendingBasicInfo is the default status, no action needed + + // Act + var act = () => provider.Reactivate("admin@test.com"); + + // Assert + act.Should().Throw() + .WithMessage("Can only reactivate providers in Suspended status"); + } + + [Fact] + public void RequireBasicInfoCorrection_WhenPendingDocumentVerification_ShouldTransitionToPendingBasicInfo() + { + // Arrange + var provider = CreateValidProvider(); + provider.CompleteBasicInfo(); + var reason = "Missing required information in business profile"; + + // Act + provider.RequireBasicInfoCorrection(reason, "verifier@test.com"); + + // Assert + provider.Status.Should().Be(EProviderStatus.PendingBasicInfo); + provider.UpdatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); + } + + [Theory] + [InlineData(EProviderStatus.PendingBasicInfo)] + [InlineData(EProviderStatus.Active)] + [InlineData(EProviderStatus.Suspended)] + [InlineData(EProviderStatus.Rejected)] + public void RequireBasicInfoCorrection_WhenNotPendingDocumentVerification_ShouldThrowException(EProviderStatus currentStatus) + { + // Arrange + var provider = CreateValidProvider(); + + // Set provider to the specified status + if (currentStatus == EProviderStatus.Active) + { + provider.CompleteBasicInfo(); + provider.Activate(); + } + else if (currentStatus == EProviderStatus.Suspended) + { + provider.CompleteBasicInfo(); + provider.Activate(); + provider.Suspend("Policy violation", "admin@test.com"); + } + else if (currentStatus == EProviderStatus.Rejected) + { + provider.CompleteBasicInfo(); + provider.Reject("Invalid documents", "admin@test.com"); + } + // PendingBasicInfo is the default status, no action needed + + // Act + var act = () => provider.RequireBasicInfoCorrection("Some reason", "verifier@test.com"); + + // Assert + act.Should().Throw() + .WithMessage("Can only require basic info correction during document verification"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void RequireBasicInfoCorrection_WithInvalidReason_ShouldThrowException(string? invalidReason) + { + // Arrange + var provider = CreateValidProvider(); + provider.CompleteBasicInfo(); + + // Act + var act = () => provider.RequireBasicInfoCorrection(invalidReason!, "verifier@test.com"); + + // Assert + act.Should().Throw() + .WithMessage("Correction reason is required"); + } + + #endregion } diff --git a/src/Shared/Messaging/Messages/Providers/ProviderActivatedIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderActivatedIntegrationEvent.cs new file mode 100644 index 000000000..ca11e4504 --- /dev/null +++ b/src/Shared/Messaging/Messages/Providers/ProviderActivatedIntegrationEvent.cs @@ -0,0 +1,24 @@ +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Shared.Messaging.Messages.Providers; + +/// +/// Evento de integração disparado quando um prestador é ativado no sistema. +/// +/// +/// Este evento é publicado para comunicação entre módulos quando um prestador +/// completa todas as etapas de verificação e é ativado. +/// Outros módulos podem usar este evento para: +/// - Adicionar o prestador aos índices de busca +/// - Enviar notificações de ativação +/// - Habilitar recursos específicos para prestadores ativos +/// - Atualizar dashboards e métricas +/// +public sealed record ProviderActivatedIntegrationEvent( + string Source, + Guid ProviderId, + Guid UserId, + string Name, + string? ActivatedBy = null, + DateTime? ActivatedAt = null +) : IntegrationEvent(Source); diff --git a/src/Shared/Messaging/Messages/Providers/ProviderAwaitingVerificationIntegrationEvent.cs b/src/Shared/Messaging/Messages/Providers/ProviderAwaitingVerificationIntegrationEvent.cs new file mode 100644 index 000000000..9e5680bdc --- /dev/null +++ b/src/Shared/Messaging/Messages/Providers/ProviderAwaitingVerificationIntegrationEvent.cs @@ -0,0 +1,24 @@ +using MeAjudaAi.Shared.Events; + +namespace MeAjudaAi.Shared.Messaging.Messages.Providers; + +/// +/// Evento de integração disparado quando um prestador completa informações básicas +/// e entra na etapa de verificação de documentos. +/// +/// +/// Este evento é publicado para comunicação entre módulos quando um prestador +/// avança do status PendingBasicInfo para PendingDocumentVerification. +/// Outros módulos podem usar este evento para: +/// - Enviar notificações sobre próximos passos +/// - Preparar processos de verificação +/// - Atualizar dashboards e métricas +/// +public sealed record ProviderAwaitingVerificationIntegrationEvent( + string Source, + Guid ProviderId, + Guid UserId, + string Name, + string? UpdatedBy = null, + DateTime? TransitionedAt = null +) : IntegrationEvent(Source);