diff --git a/services/ads-analytics-service-net/SERVICE_DOCS.md b/services/ads-analytics-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..37e574be --- /dev/null +++ b/services/ads-analytics-service-net/SERVICE_DOCS.md @@ -0,0 +1,385 @@ +# AdsAnalyticsService - Service Documentation + +## 1. Overview + +**Purpose**: Ads Analytics microservice for the GoodGo Platform. Provides campaign performance metrics aggregation, custom report generation, breakdown analytics (by age/gender/device/placement), audience insights, performance recommendations, and admin-level platform-wide metrics. + +**Service Port**: `5015` (Development, via launchSettings.json) / `8080` (Docker/Production) + +**Database**: PostgreSQL (`ads_analytics_service` on Neon cloud) +- Connection string configured via `ConnectionStrings:DefaultConnection` or `DATABASE_URL` env var +- Current default points to Neon: `ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech` + +**Framework**: .NET 10.0 / C# 14 + +**Architecture**: Clean Architecture + CQRS (MediatR) + +**Solution Structure**: +``` +src/ + AdsAnalyticsService.API/ # Web API + Application layer (CQRS handlers) + AdsAnalyticsService.Domain/ # Domain entities, SeedWork + AdsAnalyticsService.Infrastructure/ # EF Core DbContext, configs, migrations +tests/ + AdsAnalyticsService.FunctionalTests/ # Integration tests (WebApplicationFactory) + AdsAnalyticsService.UnitTests/ # Unit tests (empty project, no test files yet) +``` + +--- + +## 2. API Endpoints + +### MetricsController +**Base Route**: `api/v{version}/ads-analytics` + +| Method | Route | Description | Status | +|--------|-------|-------------|--------| +| `GET` | `campaigns/{id}/metrics?startDate&endDate` | Get aggregated campaign metrics for a date range. Defaults to last 30 days. Returns `CampaignMetricsDto` with impressions, clicks, conversions, spend, revenue, CTR, CPC, CPA, ROAS. | **Implemented** | +| `GET` | `adsets/{id}/metrics` | Get ad set metrics. | **Placeholder** (returns 501) | +| `GET` | `ads/{id}/metrics` | Get ad metrics. | **Placeholder** (returns 501) | + +### ReportsController +**Base Route**: `api/v{version}/ads-analytics/reports` + +| Method | Route | Description | Status | +|--------|-------|-------------|--------| +| `GET` | `/` | List reports for an advertiser. Params: `advertiserId` (required), `skip`, `take`. Returns `List`. | **Implemented** | +| `POST` | `/` | Create a new custom report. Body: `CreateReportRequest`. Query: `advertiserId`. Returns `Guid` (report ID). | **Implemented** | +| `GET` | `/{id}` | Get report by ID. | **Placeholder** (returns 501) | +| `POST` | `/schedule` | Schedule a recurring report. Body: `ScheduleReportRequest`. | **Placeholder** (returns 501) | +| `GET` | `/{id}/export?format=csv` | Export report in given format. | **Placeholder** (returns 501) | + +### BreakdownController +**Base Route**: `api/v{version}/ads-analytics/campaigns` + +| Method | Route | Description | Status | +|--------|-------|-------------|--------| +| `GET` | `/{id}/breakdown?by&startDate&endDate` | Get campaign breakdown by dimension. Valid `by` values: `age`, `gender`, `device`, `placement`. | **Mock data** (returns hardcoded breakdown) | + +### InsightsController +**Base Route**: `api/v{version}/ads-analytics/insights` + +| Method | Route | Description | Status | +|--------|-------|-------------|--------| +| `GET` | `/audience?campaignId&startDate&endDate` | Get audience insights (age, gender, location, user count, engagement rate). | **Mock data** | +| `GET` | `/performance?advertiserId` | Get performance insights and recommendations (Low CTR, High CPA, Budget Underspend). | **Mock data** | + +### AdminMetricsController +**Base Route**: `api/v{version}/admin/ads-analytics/metrics` + +| Method | Route | Description | Status | +|--------|-------|-------------|--------| +| `GET` | `/overview?startDate&endDate` | Platform-wide metrics overview (total impressions, clicks, spend, revenue, active campaigns/advertisers, avg CTR, avg ROAS). Defaults to last 30 days. | **Implemented** (queries real DB; active advertisers count is mocked) | +| `GET` | `/top-campaigns?metric&limit` | Top performing campaigns. Valid `metric` values: `spend`, `impressions`, `clicks`, `revenue`, `roas`. Default limit: 10. | **Implemented** (queries real DB) | +| `GET` | `/anomalies` | Anomaly detection. | **Placeholder** (returns 501, planned for ML integration) | + +### AdminReportsController +**Base Route**: `api/v{version}/admin/ads-analytics/reports` + +| Method | Route | Description | Status | +|--------|-------|-------------|--------| +| `GET` | `/` | List all reports across all advertisers. Params: `skip`, `take` (default 50). | **Implemented** | +| `DELETE` | `/{id}` | Delete a report by ID (admin only). Returns 204 or 404. | **Implemented** | + +### Health Endpoints + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/health` | Full health check (includes PostgreSQL) | +| `GET` | `/health/live` | Liveness probe (app is running, no dependency checks) | +| `GET` | `/health/ready` | Readiness probe (includes PostgreSQL check) | + +--- + +## 3. Commands + +### CreateReportCommand +- **File**: `src/AdsAnalyticsService.API/Application/Commands/CreateReportCommand.cs` +- **Handler**: `CreateReportCommandHandler.cs` +- **Parameters**: + - `AdvertiserId` (Guid) - The advertiser who owns the report + - `Name` (string) - Report name + - `ReportType` (ReportType enum) - Campaign, AdSet, Ad, or Audience + - `StartDate` (DateTime) - Report period start + - `EndDate` (DateTime) - Report period end +- **Returns**: `Guid` (the newly created report's ID) +- **Behavior**: Creates a new `Report` aggregate, persists it via `AdsAnalyticsServiceContext`, and logs the creation. The report is created with status `Pending`. +- **Note**: Handler injects `AdsAnalyticsServiceContext` directly (no repository pattern used for this command). + +--- + +## 4. Queries + +### GetCampaignMetricsQuery +- **File**: `src/AdsAnalyticsService.API/Application/Queries/GetCampaignMetricsQuery.cs` +- **Handler**: `GetCampaignMetricsQueryHandler.cs` +- **Parameters**: + - `CampaignId` (Guid) + - `StartDate` (DateTime) + - `EndDate` (DateTime) +- **Returns**: `CampaignMetricsDto?` (null if no metrics found) + - `CampaignId`, `StartDate`, `EndDate` + - `TotalImpressions` (long), `TotalClicks` (long), `TotalConversions` (long) + - `TotalSpend` (decimal), `TotalRevenue` (decimal) + - `CTR` (decimal, percentage), `CPC` (decimal), `CPA` (decimal), `ROAS` (decimal) +- **Behavior**: Queries `CampaignMetrics` table filtered by campaign ID and date range, aggregates totals and calculates KPIs (CTR, CPC, CPA, ROAS). + +### GetReportsQuery +- **File**: `src/AdsAnalyticsService.API/Application/Queries/GetReportsQuery.cs` +- **Handler**: Inline in same file (`GetReportsQueryHandler`) +- **Parameters**: + - `AdvertiserId` (Guid) + - `Skip` (int, default 0) + - `Take` (int, default 20) +- **Returns**: `List` ordered by `CreatedAt` descending + - Each item: `Id`, `Name`, `ReportType`, `StartDate`, `EndDate`, `Status`, `CreatedAt` + +--- + +## 5. Domain Model + +### Aggregates + +#### CampaignMetrics (Aggregate Root) +- **File**: `src/AdsAnalyticsService.Domain/AggregatesModel/MetricsAggregate/CampaignMetrics.cs` +- **Extends**: `Entity`, implements `IAggregateRoot` +- **Private Fields**: `_campaignId`, `_date`, `_impressions`, `_clicks`, `_conversions`, `_spend`, `_revenue` +- **Public Getters**: `CampaignId`, `Date`, `Impressions`, `Clicks`, `Conversions`, `Spend`, `Revenue` +- **Calculated Properties** (not persisted): + - `CTR` = clicks / impressions * 100 + - `CPC` = spend / clicks + - `CPA` = spend / conversions + - `ROAS` = revenue / spend +- **Behavior Methods**: + - `RecordImpression()` - Increments impressions counter + - `RecordClick()` - Increments clicks counter + - `RecordConversion()` - Increments conversions counter + - `AddSpend(decimal amount)` - Adds to spend total + - `AddRevenue(decimal amount)` - Adds to revenue total +- **Constructor**: `CampaignMetrics(Guid campaignId, DateTime date)` - Creates new metrics record, normalizes date to start of day + +#### Report (Aggregate Root) +- **File**: `src/AdsAnalyticsService.Domain/AggregatesModel/ReportAggregate/Report.cs` +- **Extends**: `Entity`, implements `IAggregateRoot` +- **Private Fields**: `_advertiserId`, `_name`, `_reportType`, `_startDate`, `_endDate`, `_status`, `_dataJson` +- **Public Getters**: `AdvertiserId`, `Name`, `ReportType`, `StartDate`, `EndDate`, `Status`, `DataJson`, `CreatedAt` +- **Behavior Methods**: + - `MarkAsProcessing()` - Sets status to Processing + - `Complete(string dataJson)` - Sets data and status to Completed + - `Fail()` - Sets status to Failed +- **Constructor**: `Report(Guid advertiserId, string name, ReportType reportType, DateTime startDate, DateTime endDate)` - Creates report with status `Pending` and `CreatedAt = DateTime.UtcNow` + +### Enumerations + +#### ReportType (enum) +- `Campaign = 1` +- `AdSet = 2` +- `Ad = 3` +- `Audience = 4` + +#### ReportStatus (enum) +- `Pending = 1` +- `Processing = 2` +- `Completed = 3` +- `Failed = 4` + +### SeedWork (Base Classes) +- **Entity**: Base class with `Id` (Guid), `DomainEvents` (IReadOnlyCollection), equality by ID +- **IAggregateRoot**: Marker interface +- **IRepository**: Generic repository interface with `UnitOfWork` property +- **IUnitOfWork**: `SaveChangesAsync()`, `SaveEntitiesAsync()` (with domain event dispatch) +- **ValueObject**: Immutable value comparison base class +- **Enumeration**: Type-safe enum pattern (not used by current aggregates) + +### Exceptions +- **DomainException**: Base domain exception class +- **AdsAnalyticsDomainException**: Service-specific domain exception (extends Exception, not DomainException) + +--- + +## 6. Database Schema + +**Database**: `ads_analytics_service` (PostgreSQL) +**Migration**: `20260117181438_InitialCreate` + +### Table: `campaign_metrics` + +| Column | Type | Nullable | Default | Notes | +|--------|------|----------|---------|-------| +| `id` | uuid | NOT NULL | - | Primary key | +| `campaign_id` | uuid | NOT NULL | - | Reference to campaign (no FK constraint) | +| `date` | date | NOT NULL | - | Daily aggregation date | +| `impressions` | bigint | NOT NULL | 0 | Number of ad impressions | +| `clicks` | bigint | NOT NULL | 0 | Number of ad clicks | +| `conversions` | bigint | NOT NULL | 0 | Number of conversions | +| `spend` | decimal(18,2) | NOT NULL | 0 | Ad spend amount | +| `revenue` | decimal(18,2) | NOT NULL | 0 | Revenue generated | + +**Indexes**: +- `PK_campaign_metrics` - Primary key on `id` +- `idx_campaign_metrics_campaign_id` - Index on `campaign_id` +- `idx_campaign_metrics_campaign_date` - **Unique** composite index on (`campaign_id`, `date`) - enforces one metrics record per campaign per day + +### Table: `reports` + +| Column | Type | Nullable | Default | Notes | +|--------|------|----------|---------|-------| +| `id` | uuid | NOT NULL | - | Primary key | +| `advertiser_id` | uuid | NOT NULL | - | Reference to advertiser (no FK constraint) | +| `name` | varchar(200) | NOT NULL | - | Report name | +| `report_type` | integer | NOT NULL | - | Enum: 1=Campaign, 2=AdSet, 3=Ad, 4=Audience | +| `start_date` | date | NOT NULL | - | Report period start | +| `end_date` | date | NOT NULL | - | Report period end | +| `status` | integer | NOT NULL | - | Enum: 1=Pending, 2=Processing, 3=Completed, 4=Failed | +| `data_json` | jsonb | NULL | - | Report data stored as JSON | +| `created_at` | timestamp with time zone | NOT NULL | - | Creation timestamp | + +**Indexes**: +- `PK_reports` - Primary key on `id` +- `idx_reports_advertiser_id` - Index on `advertiser_id` +- `idx_reports_status` - Index on `status` +- `idx_reports_created_at` - Index on `created_at` + +### Idempotency: `ClientRequest` (entity exists but no EF configuration/table migration) +- `Id` (Guid), `Name` (string), `Time` (DateTime) +- Used via `IRequestManager` / `RequestManager` but **no corresponding table is created in the migration** -- this will fail at runtime if invoked. + +--- + +## 7. Integration Events + +**No integration events are defined.** The service does not publish or consume any cross-service events via RabbitMQ or any other message broker. + +**No domain events are raised** by either aggregate. The `CampaignMetrics` and `Report` entities have domain event support inherited from the `Entity` base class, but neither constructor nor behavior method calls `AddDomainEvent()`. + +--- + +## 8. Dependencies + +### NuGet Packages + +**API Layer** (`AdsAnalyticsService.API`): +| Package | Version | +|---------|---------| +| MediatR | 12.4.1 | +| FluentValidation | 11.11.0 | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | +| Microsoft.EntityFrameworkCore.Design | 10.0.2 | +| Swashbuckle.AspNetCore | 7.2.0 | +| Asp.Versioning.Mvc | 8.1.0 | +| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | +| AspNetCore.HealthChecks.Redis | 8.0.1 | +| Hellang.Middleware.ProblemDetails | 6.5.1 | +| Serilog.AspNetCore | 8.0.3 | +| Serilog.Sinks.Console | 6.0.0 | +| Serilog.Sinks.Seq | 8.0.0 | + +**Domain Layer** (`AdsAnalyticsService.Domain`): +| Package | Version | +|---------|---------| +| MediatR.Contracts | 2.0.1 | + +**Infrastructure Layer** (`AdsAnalyticsService.Infrastructure`): +| Package | Version | +|---------|---------| +| Microsoft.EntityFrameworkCore | 10.0.0 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.0 | +| MediatR | 12.4.1 | +| Dapper | 2.1.35 | +| Microsoft.Extensions.Http.Polly | 9.0.0 | +| Polly | 8.5.0 | +| StackExchange.Redis | 2.8.16 | + +**Test Projects**: +| Package | Version | Project | +|---------|---------|---------| +| xunit | 2.9.2 | Both | +| FluentAssertions | 6.12.2 | Both | +| Moq | 4.20.72 | UnitTests only | +| Microsoft.AspNetCore.Mvc.Testing | 10.0.0 | FunctionalTests | +| Microsoft.EntityFrameworkCore.InMemory | 10.0.0 | FunctionalTests | +| Testcontainers.PostgreSql | 4.1.0 | FunctionalTests | +| coverlet.collector | 6.0.2 | Both | + +**Global Build Properties** (`Directory.Build.props`): +- `net10.0`, `LangVersion 14.0`, `Nullable enable`, `TreatWarningsAsErrors true` +- `Microsoft.SourceLink.GitHub 8.0.0` + +### External Service Dependencies +- **PostgreSQL** (Neon cloud) - Primary data store +- **Redis** - Configured in appsettings (`localhost:6379`) but **not used in any code** (StackExchange.Redis is a NuGet dependency only) +- **No other service dependencies** - This service does not call any other microservices + +--- + +## 9. Configuration + +### appsettings.json +```json +{ + "ConnectionStrings": { + "DefaultConnection": "postgresql://...@neon.tech/ads_analytics_service?sslmode=require" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + } +} +``` + +### Environment Variables (from .env.example) +| Variable | Description | Default | +|----------|-------------|---------| +| `ASPNETCORE_ENVIRONMENT` | Runtime environment | Development | +| `DATABASE_URL` | PostgreSQL connection string (fallback) | localhost:5432 | +| `REDIS_URL` | Redis connection string | localhost:6379 | +| `REDIS_PASSWORD` | Redis password | (empty) | +| `JWT_SECRET` | JWT signing key | - | +| `JWT_ISSUER` | JWT issuer | goodgo-platform | +| `JWT_AUDIENCE` | JWT audience | goodgo-services | +| `API_PORT` | API port | 5000 | +| `LOG_LEVEL` | Serilog minimum level | Information | +| `SEQ_URL` | Seq logging endpoint | http://localhost:5341 | + +### Connection String Resolution Order +1. `ConnectionStrings:DefaultConnection` from appsettings +2. `DATABASE_URL` environment variable (fallback) + +### MediatR Pipeline (order) +1. `LoggingBehavior` - Logs request name, elapsed time, errors (with Stopwatch) +2. `ValidatorBehavior` - Runs FluentValidation validators (throws `ValidationException` on failure) +3. `TransactionBehavior` - Wraps Commands in DB transaction (skips Queries by name suffix check); uses `ExecutionStrategy` for retry + +### Docker Configuration +- Multi-stage build: `sdk:10.0` (build) -> `aspnet:10.0` (runtime) +- Non-root user: `dotnetuser` (UID/GID 1001) +- Exposed port: 8080 +- Health check: `curl -f http://localhost:8080/health/live` (30s interval, 3 retries) + +### Startup Behavior +- Auto-applies EF Core migrations on startup (catches and logs errors without crashing) +- Swagger UI available at `/swagger` in Development environment +- CORS: Allow any origin/method/header (open policy) + +--- + +## 10. Notable Gaps & Observations + +1. **No authentication/authorization**: No `[Authorize]` attributes on any controller. JWT config exists in appsettings but is not wired up in `Program.cs`. +2. **No FluentValidation validators defined**: `ValidatorBehavior` is registered but no `AbstractValidator` implementations exist for any command. +3. **No repository interfaces/implementations**: Both `CreateReportCommandHandler` and query handlers inject `AdsAnalyticsServiceContext` directly instead of using the repository pattern defined in SeedWork. +4. **Mock data endpoints**: BreakdownController and InsightsController return hardcoded mock data, not from the database. +5. **ClientRequest table missing**: `IRequestManager`/`RequestManager` are registered in DI but the `ClientRequest` entity has no EF configuration and no migration creates its table. +6. **Redis not wired**: Redis NuGet package is included and connection string is configured, but no Redis usage exists in the codebase. +7. **Dapper not used**: Dapper NuGet package is included but never referenced in any query handler. +8. **No unit tests**: The `AdsAnalyticsService.UnitTests` project exists but contains no test files. +9. **Domain events unused**: Neither aggregate raises domain events despite inheriting the capability. +10. **No API response wrapper**: Controllers return raw DTOs or anonymous objects, not the standard `{ success: bool, data: T }` format documented in CLAUDE.md. diff --git a/services/ads-billing-service-net/SERVICE_DOCS.md b/services/ads-billing-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..383f9740 --- /dev/null +++ b/services/ads-billing-service-net/SERVICE_DOCS.md @@ -0,0 +1,526 @@ +# AdsBillingService - Service Documentation + +## 1. Overview + +The **AdsBillingService** is a .NET 10.0 microservice responsible for managing advertising billing within the GoodGo platform. It handles billing accounts for advertisers, processes ad charges (impressions/clicks), generates invoices, manages credit lines, and provides admin analytics. + +- **Port**: 5013 (Development), 8080 (Docker/Production) +- **Database**: PostgreSQL - `ads_billing_service` (Neon cloud in appsettings, local Docker available) +- **Connection String Key**: `ConnectionStrings:DefaultConnection` or `DATABASE_URL` env var +- **Base Route**: `/api/v1/ads-billing/` (public), `/api/v1/admin/ads-billing/` (admin) +- **Health Checks**: `/health`, `/health/live`, `/health/ready` +- **Swagger**: `/swagger` (Development only) +- **SDK**: .NET 10.0 / C# 14 +- **Migration**: `20260117181417_InitialCreate` (auto-applied on startup) + +--- + +## 2. API Endpoints + +### 2.1 BillingAccountsController + +Route prefix: `api/v1/ads-billing/accounts` + +| Method | Route | Action | Description | +|--------|-------|--------|-------------| +| POST | `/` | `CreateBillingAccount` | Create a new billing account for an advertiser. Body: `CreateBillingAccountCommand`. Returns 201 with account GUID. | +| GET | `/{id}` | `GetBillingAccount` | Get billing account details by ID. Returns `BillingAccountDto` or 404. | +| POST | `/{id}/add-funds` | `AddFunds` | Add funds to a billing account. Body: `AddFundsRequest { Amount }`. Validates amount > 0. | +| GET | `/{id}/balance` | `GetBalance` | Get the current balance of a billing account. Returns `{ accountId, balance }` or 404. | + +### 2.2 InvoicesController + +Route prefix: `api/v1/ads-billing/invoices` + +| Method | Route | Action | Description | +|--------|-------|--------|-------------| +| GET | `/` | `GetInvoices` | List invoices with optional filters. Query params: `billingAccountId`, `status`, `pageNumber` (default 1), `pageSize` (default 20). | +| GET | `/{id}` | `GetInvoiceById` | Get invoice details by ID, including line items. Returns `InvoiceDto` or 404. | +| GET | `/{id}/download` | `DownloadInvoice` | Download invoice summary as a `.txt` file. Returns `text/plain` file content. | + +### 2.3 CreditLinesController + +Route prefix: `api/v1/ads-billing/credit-lines` + +| Method | Route | Action | Description | +|--------|-------|--------|-------------| +| GET | `/{advertiserId}` | `GetCreditLine` | Get credit line info for an advertiser (creditLimit, balance, availableCredit, paymentMethod, status). | +| POST | `/request` | `RequestCreditIncrease` | Request credit limit increase. Body: `CreditIncreaseRequest { AdvertiserId, RequestedAmount, Reason? }`. Immediately applies the increase. Returns 202. | + +### 2.4 AdminBillingAccountsController + +Route prefix: `api/v1/admin/ads-billing/accounts` + +| Method | Route | Action | Description | +|--------|-------|--------|-------------| +| GET | `/` | `SearchAccounts` | Search billing accounts with filters. Query params: `status`, `paymentMethod`, `pageNumber`, `pageSize`. | +| GET | `/stats` | `GetStatistics` | Get aggregate statistics: totalAccounts, activeAccounts, suspendedAccounts, totalBalance, totalCreditLimit. | +| POST | `/{id}/suspend` | `SuspendAccount` | Suspend a billing account. Body: `SuspendAccountRequest { Reason }`. | +| POST | `/{id}/reactivate` | `ReactivateAccount` | Reactivate a suspended billing account. | +| PUT | `/{id}/credit-limit` | `UpdateCreditLimit` | Update credit limit for an account. Body: `UpdateCreditLimitRequest { NewCreditLimit }`. Validates >= 0. | + +### 2.5 AdminChargesController + +Route prefix: `api/v1/admin/ads-billing/charges` + +| Method | Route | Action | Description | +|--------|-------|--------|-------------| +| GET | `/` | `GetCharges` | Search charges with filters. Query params: `advertiserId`, `campaignId`, `chargeType`, `processed`, `fromDate`, `toDate`, `pageNumber`, `pageSize` (default 50). | +| GET | `/stats` | `GetStatistics` | Charge statistics: total/processed/unprocessed counts and amounts, breakdown by chargeType, recent 10 charges. | +| GET | `/analytics/by-advertiser` | `GetAdvertiserAnalytics` | Top advertisers by charge amount. Query param: `top` (default 10). | + +### 2.6 AdminInvoicesController (file: `Admin ChargesController.cs`) + +Route prefix: `api/v1/admin/ads-billing/invoices` + +**Note**: This controller is defined in the file named `Admin ChargesController.cs` (with a space) but the class is `AdminInvoicesController`. + +| Method | Route | Action | Description | +|--------|-------|--------|-------------| +| GET | `/` | `SearchInvoices` | Search invoices with filters. Query params: `status`, `fromDate`, `toDate`, `pageNumber`, `pageSize`. | +| GET | `/stats` | `GetStatistics` | Invoice statistics: total/paid/overdue/pending counts, totalAmount, paidAmount, outstandingAmount. | +| POST | `/{id}/mark-paid` | `MarkInvoiceAsPaid` | Mark an invoice as paid. Calls `invoice.MarkAsPaid()`. | +| POST | `/regenerate` | `RegenerateInvoice` | Regenerate invoice from charge data for a billing period. Body: `RegenerateInvoiceRequest { BillingAccountId, StartDate, EndDate }`. Groups charges by campaign + chargeType into line items. | + +--- + +## 3. Commands + +### 3.1 CreateBillingAccountCommand +- **File**: `Application/Commands/CreateBillingAccountCommand.cs` +- **Returns**: `Guid` (account ID) +- **Parameters**: + - `AdvertiserId` (Guid) - required + - `WalletId` (Guid?) - optional + - `PaymentMethod` (string) - default "prepaid"; accepts "prepaid", "postpaid", "creditcard" +- **Behavior**: Checks if account already exists for the advertiser (returns existing ID if so). Creates new `BillingAccount` entity with the specified payment method. Persists via `SaveEntitiesAsync`. +- **Handler**: `CreateBillingAccountCommandHandler` - injects `AdsBillingServiceContext` directly (no repository pattern). + +### 3.2 ChargeAdvertiserCommand +- **File**: `Application/Commands/ChargeAdvertiserCommand.cs` +- **Returns**: `bool` (success/failure) +- **Parameters**: + - `AdvertiserId` (Guid) - required + - `CampaignId` (Guid) - required + - `AdId` (Guid) - required + - `ChargeType` (string) - "impression" or "click" + - `Amount` (decimal) - charge amount +- **Behavior**: Creates `AdCharge` entity, looks up billing account by advertiser ID, calls `billingAccount.ApplyCharge(amount)` (deducts for prepaid, accrues for postpaid/credit). Marks charge as processed. Returns false if account not found or charge fails (e.g., insufficient balance). +- **Handler**: `ChargeAdvertiserCommandHandler` + +### 3.3 AddFundsCommand +- **File**: `Application/Commands/AddFundsCommand.cs` +- **Returns**: `bool` (success/failure) +- **Parameters**: + - `AccountId` (Guid) - billing account ID + - `Amount` (decimal) - amount to add +- **Behavior**: Finds account by ID, calls `account.AddBalance(amount)`. Returns false if account not found. +- **Handler**: `AddFundsCommandHandler` (defined in same file) + +--- + +## 4. Queries + +### 4.1 GetBillingAccountQuery +- **File**: `Application/Queries/GetBillingAccountQuery.cs` +- **Parameters**: `AccountId` (Guid) +- **Returns**: `BillingAccountDto?` - includes Id, AdvertiserId, WalletId, PaymentMethod, Status, Balance, CreditLimit, Threshold (Amount, AutoCharge), CreatedAt, UpdatedAt +- **Behavior**: Projects from `BillingAccounts` DbSet with `AsNoTracking()`. + +### 4.2 GetBillingAccountBalanceQuery +- **File**: `Application/Queries/GetBillingAccountBalanceQuery.cs` +- **Parameters**: `AccountId` (Guid) +- **Returns**: `decimal?` - the balance value, or null if account not found +- **Behavior**: Simple projection of balance from `BillingAccounts` with `AsNoTracking()`. + +### 4.3 GetInvoicesQuery +- **File**: `Application/Queries/GetInvoicesQuery.cs` +- **Parameters**: `BillingAccountId` (Guid?), `Status` (string?), `PageNumber` (int, default 1), `PageSize` (int, default 20) +- **Returns**: `List` - ordered by IssueDate descending. Note: LineItems are returned as empty list (not loaded in list query). +- **Behavior**: Applies optional filters on BillingAccountId and Status. + +### 4.4 GetInvoiceByIdQuery +- **File**: `Application/Queries/GetInvoiceByIdQuery.cs` +- **Parameters**: `InvoiceId` (Guid) +- **Returns**: `InvoiceDto?` - includes full LineItems (CampaignId, Description, Quantity, UnitPrice, TotalAmount). +- **Behavior**: Projects from `Invoices` with nested `LineItems` projection. + +--- + +## 5. Domain Model + +### 5.1 Aggregates + +#### BillingAccount (Aggregate Root) +- **File**: `Domain/AggregatesModel/BillingAccountAggregate/BillingAccount.cs` +- **Extends**: `Entity`, implements `IAggregateRoot` +- **Fields** (private with public getters): + - `AdvertiserId` (Guid) + - `WalletId` (Guid?) + - `PaymentMethod` (PaymentMethodType enum) + - `Threshold` (BillingThreshold? value object) + - `Status` (AccountStatus enum) + - `Balance` (decimal) + - `CreditLimit` (decimal) + - `CreatedAt` (DateTime) + - `UpdatedAt` (DateTime?) +- **Behavior Methods**: + - `SetThreshold(decimal amount, bool autoCharge)` - sets auto-charge threshold + - `DeductBalance(decimal amount)` - deducts from prepaid accounts only; throws if insufficient + - `AddBalance(decimal amount)` - adds funds; throws if amount <= 0 + - `Suspend()` - sets status to Suspended + - `Reactivate()` - sets status to Active; throws if account is Closed + - `SetCreditLimit(decimal creditLimit)` - updates credit limit; throws if negative + - `ApplyCharge(decimal amount)` - core billing logic: + - Prepaid: deducts from balance (throws if insufficient) + - Postpaid/CreditCard: accrues to balance (throws if credit limit exceeded) + - Throws if account not active or amount <= 0 + +#### AdCharge (Aggregate Root) +- **File**: `Domain/AggregatesModel/ChargeAggregate/AdCharge.cs` +- **Extends**: `Entity`, implements `IAggregateRoot` +- **Fields**: + - `AdvertiserId` (Guid) + - `CampaignId` (Guid) + - `AdId` (Guid) + - `ChargeType` (ChargeType enum) + - `Amount` (decimal) + - `Currency` (string, default "VND") + - `ChargedAt` (DateTime) + - `Processed` (bool) +- **Behavior Methods**: + - `MarkAsProcessed()` - flags charge as processed +- **Factory Methods**: + - `ForImpression(advertiserId, campaignId, adId, cpm)` - creates impression charge (amount = cpm / 1000) + - `ForClick(advertiserId, campaignId, adId, cpc)` - creates click charge (amount = cpc) + +#### Invoice (Aggregate Root) +- **File**: `Domain/AggregatesModel/InvoiceAggregate/Invoice.cs` +- **Extends**: `Entity`, implements `IAggregateRoot` +- **Fields**: + - `BillingAccountId` (Guid) + - `InvoiceNumber` (string, auto-generated: `INV-yyyyMMdd-XXXXXXXX`) + - `Status` (InvoiceStatus enum) + - `IssueDate` (DateTime) + - `DueDate` (DateTime) + - `TotalAmount` (decimal, auto-calculated from line items) + - `LineItems` (IReadOnlyCollection) +- **Behavior Methods**: + - `AddLineItem(campaignId, description, quantity, unitPrice)` - adds line item and recalculates total + - `Issue()` - transitions from Draft to Issued; throws if not Draft + - `MarkAsPaid()` - transitions from Issued to Paid; throws if not Issued + +### 5.2 Child Entities + +#### InvoiceLineItem +- **File**: `Domain/AggregatesModel/InvoiceAggregate/Invoice.cs` (same file as Invoice) +- **Extends**: `Entity` (not an aggregate root) +- **Properties**: + - `CampaignId` (Guid) + - `Description` (string) + - `Quantity` (int) + - `UnitPrice` (decimal) + - `TotalAmount` (decimal, computed: Quantity * UnitPrice) + +### 5.3 Value Objects + +#### BillingThreshold +- **File**: `Domain/AggregatesModel/BillingAccountAggregate/BillingAccount.cs` +- **Extends**: `ValueObject` +- **Properties**: `Amount` (decimal), `AutoCharge` (bool) +- **Validation**: Amount must be positive + +### 5.4 Enumerations (C# enums) + +#### PaymentMethodType +- `Prepaid = 1` (pay upfront via Wallet) +- `Postpaid = 2` (monthly invoice) +- `CreditCard = 3` (auto-charge credit card) + +#### AccountStatus +- `Active = 1` +- `Suspended = 2` +- `Closed = 3` + +#### ChargeType +- `Impression = 1` +- `Click = 2` +- `Conversion = 3` + +#### InvoiceStatus +- `Draft = 1` +- `Issued = 2` +- `Paid = 3` +- `Overdue = 4` +- `Cancelled = 5` + +### 5.5 Exceptions + +- **AdsBillingDomainException** (`Domain/Exceptions/AdsBillingDomainException.cs`) - used for all business rule violations in this service +- **DomainException** (`Domain/Exceptions/DomainException.cs`) - generic base exception (not currently used by entities) + +### 5.6 SeedWork + +Standard DDD building blocks in `Domain/SeedWork/`: +- **Entity** - base class with Id (Guid), DomainEvents (List), equality by Id +- **IAggregateRoot** - marker interface +- **IRepository** - generic repo interface with `IUnitOfWork UnitOfWork` +- **IUnitOfWork** - `SaveChangesAsync()` and `SaveEntitiesAsync()` (dispatches domain events) +- **ValueObject** - base class with equality by components +- **Enumeration** - type-safe enum pattern (not used by current enums, which are plain C# enums) + +--- + +## 6. Database Schema + +Database: `ads_billing_service` (PostgreSQL) +Migration: `20260117181417_InitialCreate` + +### Table: `billing_accounts` + +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| `id` | uuid | NOT NULL | PK | +| `advertiser_id` | uuid | NOT NULL | Indexed | +| `wallet_id` | uuid | NULL | | +| `payment_method` | integer | NOT NULL | Enum: 1=Prepaid, 2=Postpaid, 3=CreditCard | +| `status` | integer | NOT NULL | Enum: 1=Active, 2=Suspended, 3=Closed | +| `balance` | numeric(18,2) | NOT NULL | | +| `credit_limit` | numeric(18,2) | NOT NULL | | +| `threshold_amount` | numeric(18,2) | NULL | Owned entity (BillingThreshold) | +| `threshold_auto_charge` | boolean | NULL | Owned entity (BillingThreshold) | +| `created_at` | timestamp with time zone | NOT NULL | Indexed | +| `updated_at` | timestamp with time zone | NULL | | + +**Indexes**: +- `ix_billing_accounts_advertiser_id` on `advertiser_id` +- `ix_billing_accounts_created_at` on `created_at` + +### Table: `ad_charges` + +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| `id` | uuid | NOT NULL | PK | +| `advertiser_id` | uuid | NOT NULL | Indexed | +| `campaign_id` | uuid | NOT NULL | Indexed | +| `ad_id` | uuid | NOT NULL | | +| `charge_type` | integer | NOT NULL | Enum: 1=Impression, 2=Click, 3=Conversion. Indexed | +| `amount` | numeric(18,6) | NOT NULL | 6 decimal places for micro-charges | +| `currency` | varchar(10) | NOT NULL | Default "VND" | +| `charged_at` | timestamp with time zone | NOT NULL | Indexed | +| `processed` | boolean | NOT NULL | Indexed | + +**Indexes**: +- `ix_ad_charges_advertiser_id` on `advertiser_id` +- `ix_ad_charges_campaign_id` on `campaign_id` +- `ix_ad_charges_charge_type` on `charge_type` +- `ix_ad_charges_charged_at` on `charged_at` +- `ix_ad_charges_processed` on `processed` +- `ix_ad_charges_advertiser_processed_charged` composite on (`advertiser_id`, `processed`, `charged_at`) + +### Table: `invoices` + +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| `id` | uuid | NOT NULL | PK | +| `billing_account_id` | uuid | NOT NULL | Indexed | +| `invoice_number` | varchar(50) | NOT NULL | Unique index. Format: `INV-yyyyMMdd-XXXXXXXX` | +| `status` | integer | NOT NULL | Enum: 1=Draft, 2=Issued, 3=Paid, 4=Overdue, 5=Cancelled. Indexed | +| `issue_date` | timestamp with time zone | NOT NULL | | +| `due_date` | timestamp with time zone | NOT NULL | Indexed | +| `total_amount` | numeric(18,2) | NOT NULL | | + +**Indexes**: +- `ix_invoices_billing_account_id` on `billing_account_id` +- `ix_invoices_invoice_number` (unique) on `invoice_number` +- `ix_invoices_status` on `status` +- `ix_invoices_due_date` on `due_date` + +### Table: `invoice_line_items` + +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| `id` | uuid | NOT NULL | PK | +| `invoice_id` | uuid | NOT NULL | FK -> invoices.id (CASCADE). Indexed | +| `campaign_id` | uuid | NOT NULL | | +| `description` | varchar(500) | NOT NULL | | +| `quantity` | integer | NOT NULL | | +| `unit_price` | numeric(18,2) | NOT NULL | | +| `InvoiceId1` | uuid | NULL | Spurious FK column from migration (likely EF config issue) | + +**Indexes**: +- `ix_invoice_line_items_invoice_id` on `invoice_id` +- `IX_invoice_line_items_InvoiceId1` on `InvoiceId1` + +**Note**: The `TotalAmount` property on `InvoiceLineItem` is computed (Quantity * UnitPrice) and ignored by EF (`builder.Ignore(li => li.TotalAmount)`). + +### Table: `client_requests` + +| Column | Type | Nullable | Notes | +|--------|------|----------|-------| +| `id` | uuid | NOT NULL | PK | +| `name` | varchar(200) | NOT NULL | Unique index. Stores command type name. | +| `time` | timestamp with time zone | NOT NULL | | + +**Indexes**: +- `ix_client_requests_name` (unique) on `name` + +--- + +## 7. Integration Events + +**None implemented.** The service does not currently publish or consume any cross-service integration events via RabbitMQ or any other message broker. Domain events are supported via MediatR (in-process `INotification`) but no domain events are currently raised by any entity (no calls to `AddDomainEvent` in entity code). The `DispatchDomainEventsAsync` method exists in the DbContext but would be a no-op with the current entities. + +--- + +## 8. Dependencies + +### NuGet Packages + +#### API Layer +| Package | Version | +|---------|---------| +| MediatR | 12.4.1 | +| FluentValidation | 11.11.0 | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | +| Microsoft.EntityFrameworkCore.Design | 10.0.2 | +| Swashbuckle.AspNetCore | 7.2.0 | +| Asp.Versioning.Mvc | 8.1.0 | +| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | +| AspNetCore.HealthChecks.Redis | 8.0.1 | +| Hellang.Middleware.ProblemDetails | 6.5.1 | +| Serilog.AspNetCore | 8.0.3 | +| Serilog.Sinks.Console | 6.0.0 | +| Serilog.Sinks.Seq | 8.0.0 | + +#### Domain Layer +| Package | Version | +|---------|---------| +| MediatR.Contracts | 2.0.1 | + +#### Infrastructure Layer +| Package | Version | +|---------|---------| +| Microsoft.EntityFrameworkCore | 10.0.0 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.0 | +| MediatR | 12.4.1 | +| Dapper | 2.1.35 | +| Microsoft.Extensions.Http.Polly | 9.0.0 | +| Polly | 8.5.0 | +| StackExchange.Redis | 2.8.16 | + +#### Test Projects +| Package | Version | Project | +|---------|---------|---------| +| xunit | 2.9.2 | Both | +| xunit.runner.visualstudio | 2.8.2 | Both | +| Microsoft.NET.Test.Sdk | 17.12.0 | Both | +| FluentAssertions | 6.12.2 | Both | +| coverlet.collector | 6.0.2 | Both | +| Moq | 4.20.72 | UnitTests | +| Microsoft.AspNetCore.Mvc.Testing | 10.0.0 | FunctionalTests | +| Microsoft.EntityFrameworkCore.InMemory | 10.0.0 | FunctionalTests | +| Testcontainers.PostgreSql | 4.1.0 | FunctionalTests | + +### External Service Dependencies + +- **PostgreSQL** - primary data store (Neon cloud or local Docker) +- **Redis** - configured in appsettings but not actively used in code (health check registered, StackExchange.Redis in Infrastructure deps) +- **Dapper** - referenced in Infrastructure but not actively used in current code +- **Polly** - referenced in Infrastructure but no resilience policies implemented yet + +### Inter-Service Dependencies + +No direct calls to other microservices. The service references `AdvertiserId`, `CampaignId`, `AdId`, and `WalletId` as foreign keys by convention (Guid) but does not call ads-manager-service, ads-serving-service, or wallet-service APIs. + +--- + +## 9. Configuration + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "Serilog": { "..." }, + "Logging": { "..." } +} +``` + +**Note**: JWT settings are defined in appsettings but JWT authentication middleware is NOT configured in `Program.cs`. No `[Authorize]` attributes exist on any controller. All endpoints are currently unauthenticated. + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASPNETCORE_ENVIRONMENT` | Runtime environment | `Development` (local), `Production` (Docker) | +| `ASPNETCORE_URLS` | Listen URL | `http://+:8080` (Docker) | +| `DATABASE_URL` | Fallback connection string | - | +| `ConnectionStrings__DefaultConnection` | Primary connection string | Neon PostgreSQL URL | + +### .env.example Variables + +Additional env vars from `.env.example` (template, not all used in code): +- `REDIS_URL`, `REDIS_PASSWORD` +- `JWT_SECRET`, `JWT_ISSUER`, `JWT_AUDIENCE`, `JWT_ACCESS_TOKEN_EXPIRY_MINUTES`, `JWT_REFRESH_TOKEN_EXPIRY_DAYS` +- `API_PORT`, `API_BASE_PATH` +- `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_SERVICE_NAME` +- `LOG_LEVEL`, `SEQ_URL` +- `RATE_LIMIT_PERMITS_PER_MINUTE`, `RATE_LIMIT_QUEUE_LIMIT` +- `HEALTHCHECK_TIMEOUT_SECONDS` + +### MediatR Pipeline (configured in Program.cs) + +1. `LoggingBehavior<,>` - logs request name and elapsed time (Stopwatch) +2. `ValidatorBehavior<,>` - runs FluentValidation validators (throws `ValidationException` on failure) +3. `TransactionBehavior<,>` - wraps commands in DB transaction (skips queries, uses `ExecutionStrategy`) + +### Infrastructure DI (DependencyInjection.cs) + +- `AdsBillingServiceContext` registered with Npgsql (retry on failure: 5 retries, 30s max delay) +- `IRequestManager` -> `RequestManager` (scoped, for idempotency) +- Repository registration commented out (`IBillingAccountRepository` not implemented) +- Sensitive data logging enabled in Development + +### Docker + +- Multi-stage build: `sdk:10.0` -> `aspnet:10.0` +- Non-root user: `dotnetuser` (UID/GID 1001) +- Port: 8080 +- Healthcheck: `curl -f http://localhost:8080/health/live` (30s interval, 3 retries) + +### Tests + +- **FunctionalTests**: 4 tests using `CustomWebApplicationFactory` with InMemory database + - `BillingAccountFlow_ShouldCreateAccount_AddFunds_AndReturnBalance` + - `CreditLineRequest_ShouldIncreaseCreditLimit` + - `RegenerateInvoice_WithoutCharges_ShouldReturnBadRequest` + - `HealthCheck_ShouldReturnHealthy` +- **UnitTests**: Project exists but contains no test classes + +--- + +## 10. Known Issues + +1. **No authentication**: JWT settings are in config but `UseAuthentication`/`UseAuthorization` middleware and `[Authorize]` attributes are not implemented. All endpoints are publicly accessible. +2. **No repository pattern**: Handlers inject `AdsBillingServiceContext` directly instead of using repository interfaces (commented out in DI). Only `IRequestManager` is registered. +3. **No FluentValidation validators**: While `ValidatorBehavior` is in the pipeline, no `AbstractValidator` implementations exist for any command. +4. **No domain events raised**: Entities support domain events via `AddDomainEvent()` but none of the aggregates actually raise any events. +5. **Spurious FK column**: The `invoice_line_items` table has a duplicate FK column `InvoiceId1` (likely EF navigation config issue). +6. **Duplicate controller file**: There is a file `Admin ChargesController.cs` (with space in name) containing `AdminInvoicesController` class, alongside the properly named `AdminChargesController.cs`. +7. **Empty unit tests project**: `AdsBillingService.UnitTests` has the .csproj but no test files. +8. **Redis/Dapper unused**: Both are in NuGet dependencies but have no active usage in the codebase. diff --git a/services/ads-manager-service-net/SERVICE_DOCS.md b/services/ads-manager-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..5d2df031 --- /dev/null +++ b/services/ads-manager-service-net/SERVICE_DOCS.md @@ -0,0 +1,583 @@ +# AdsManagerService - Service Documentation + +## 1. Overview + +**Purpose**: Manages advertising campaigns, ad sets, and individual ads for the GoodGo platform. Follows a 3-tier ads structure: Campaign > Ad Set > Ad, similar to Meta/Facebook Ads architecture. Includes admin endpoints for ad review/moderation and reporting. + +**Port**: `5011` (Development, via launchSettings.json), `8080` (Docker/Production) + +**Database**: PostgreSQL - `ads_manager_service` (Neon cloud in appsettings.json) + +**Connection String Key**: `ConnectionStrings:DefaultConnection` or `DATABASE_URL` environment variable + +**Framework**: .NET 10.0, C# 14 + +**Architecture**: Clean Architecture (API / Domain / Infrastructure) + CQRS via MediatR + +**Health Endpoints**: +- `GET /health` - Full health check (includes PostgreSQL) +- `GET /health/live` - Liveness probe (app is running) +- `GET /health/ready` - Readiness probe (dependencies ready) + +**Auto-migration**: EF Core migrations are applied automatically on startup. + +--- + +## 2. API Endpoints + +### 2.1 Campaigns Controller + +**Route prefix**: `api/v1/ads-manager/campaigns` + +| Method | Route | Action | Description | Response | +|--------|-------|--------|-------------|----------| +| POST | `/` | CreateCampaign | Create a new campaign | 201 Created (Guid) | +| GET | `/` | ListCampaigns | List campaigns with filtering and pagination | 200 OK (ListCampaignsResult) | +| GET | `/{id}` | GetCampaignById | Get campaign by ID | 200 OK (CampaignDto) / 404 | +| PUT | `/{id}` | UpdateCampaign | Update campaign name and description | 204 No Content / 404 | +| POST | `/{id}/activate` | ActivateCampaign | Activate a draft/paused campaign | 204 No Content / 404 | +| POST | `/{id}/pause` | PauseCampaign | Pause an active campaign | 204 No Content / 404 | +| DELETE | `/{id}` | DeleteCampaign | Archive a campaign (soft delete) | 204 No Content / 404 | + +**ListCampaigns Query Parameters**: +- `advertiserId` (Guid?) - Filter by advertiser +- `status` (string?) - Filter by status (draft, active, paused, completed, archived) +- `objective` (string?) - Filter by objective (awareness, traffic, conversion, etc.) +- `searchTerm` (string?) - Search by campaign name +- `page` (int, default: 1) - Page number +- `pageSize` (int, default: 20) - Page size + +### 2.2 Ad Sets Controller + +**Route prefix**: `api/v1/ads-manager/adsets` + +| Method | Route | Action | Description | Response | +|--------|-------|--------|-------------|----------| +| POST | `/` | CreateAdSet | Create a new ad set | 201 Created (Guid) | +| GET | `/{id}` | GetAdSetById | Get ad set by ID | 200 OK (AdSetDto) / 404 | + +### 2.3 Ads Controller + +**Route prefix**: `api/v1/ads-manager/ads` + +| Method | Route | Action | Description | Response | +|--------|-------|--------|-------------|----------| +| POST | `/` | CreateAd | Create a new ad | 201 Created (Guid) | +| GET | `/{id}` | GetAdById | Get ad by ID | 200 OK (AdDto) / 404 | +| POST | `/{id}/submit` | SubmitAdForReview | Submit ad for review | 204 No Content / 404 | + +### 2.4 Audiences Controller + +**Route prefix**: `api/v1/ads-manager/audiences` + +| Method | Route | Action | Description | Response | +|--------|-------|--------|-------------|----------| +| GET | `/` | ListAudiences | List audiences for an advertiser | 200 OK (List\) | +| GET | `/{id}` | GetAudienceById | Get audience by ID | 200 OK (AudienceDto) / 404 | + +**ListAudiences Query Parameters**: +- `advertiserId` (Guid) - Required. Filter by advertiser. + +**Note**: The Audiences controller defines its own queries and DTOs inline (ListAudiencesQuery, GetAudienceByIdQuery, AudienceDto) but there are NO handler implementations for these queries in the codebase. These endpoints will fail at runtime. + +### 2.5 Admin Ads Controller + +**Route prefix**: `api/v1/admin/ads-manager/ads` + +| Method | Route | Action | Description | Response | +|--------|-------|--------|-------------|----------| +| GET | `/pending` | ListPendingAds | List ads pending review | 200 OK (List\) | +| POST | `/{id}/approve` | ApproveAd | Approve an ad | 204 No Content / 404 | +| POST | `/{id}/reject` | RejectAd | Reject an ad with reason | 204 No Content / 404 | + +**ListPendingAds Query Parameters**: +- `page` (int, default: 1) +- `pageSize` (int, default: 20) + +**RejectAd Request Body**: `{ "reason": "string" }` + +### 2.6 Admin Campaigns Controller + +**Route prefix**: `api/v1/admin/ads-manager/campaigns` + +| Method | Route | Action | Description | Response | +|--------|-------|--------|-------------|----------| +| GET | `/` | ListAllCampaigns | List all campaigns across all advertisers | 200 OK (ListCampaignsResult) | +| GET | `/stats` | GetCampaignStats | Get aggregate campaign statistics | 200 OK (CampaignStatsDto) | + +**ListAllCampaigns Query Parameters**: +- `status` (string?) +- `page` (int, default: 1) +- `pageSize` (int, default: 50) + +### 2.7 Admin Reports Controller + +**Route prefix**: `api/v1/admin/ads-manager/reports` + +| Method | Route | Action | Description | Response | +|--------|-------|--------|-------------|----------| +| GET | `/top-advertisers` | GetTopAdvertisers | Get top advertisers by spend | 200 OK (List\) | +| GET | `/revenue` | GetRevenueAnalytics | Get revenue analytics | 200 OK (RevenueAnalyticsDto) | + +**GetTopAdvertisers Query Parameters**: +- `limit` (int, default: 10) + +**GetRevenueAnalytics Query Parameters**: +- `startDate` (DateTime?) +- `endDate` (DateTime?) + +--- + +## 3. Commands + +### CreateCampaignCommand +- **Returns**: `Guid` (new campaign ID) +- **Parameters**: `AdvertiserId` (Guid), `Name` (string), `Description` (string?), `Objective` (string: "awareness"/"traffic"/"conversion"/"app_installs"/"video_views"/"lead_generation"), `BudgetType` (string: "daily"/"lifetime"), `BudgetAmount` (decimal), `Currency` (string, default "VND"), `StartDate` (DateTime?), `EndDate` (DateTime?) +- **Behavior**: Creates a Campaign aggregate with Draft status, sets schedule if dates provided, raises CampaignCreatedDomainEvent. + +### UpdateCampaignCommand +- **Returns**: `bool` +- **Parameters**: `CampaignId` (Guid), `Name` (string), `Description` (string?) +- **Behavior**: Updates campaign name and description. Rejects if campaign is archived. + +### ActivateCampaignCommand +- **Returns**: `bool` +- **Parameters**: `CampaignId` (Guid) +- **Behavior**: Transitions campaign from Draft/Paused to Active. Raises CampaignActivatedDomainEvent. + +### PauseCampaignCommand +- **Returns**: `bool` +- **Parameters**: `CampaignId` (Guid) +- **Behavior**: Transitions campaign from Active to Paused. Raises CampaignStatusChangedDomainEvent. Uses injected IUnitOfWork (not repository UnitOfWork). + +### DeleteCampaignCommand +- **Returns**: `bool` +- **Parameters**: `CampaignId` (Guid) +- **Behavior**: Soft-deletes by archiving the campaign. Cannot archive active campaigns (must pause first). Raises CampaignStatusChangedDomainEvent. + +### CreateAdSetCommand +- **Returns**: `Guid` (new ad set ID) +- **Parameters**: `CampaignId` (Guid), `Name` (string), `DailyBudget` (decimal), `BidType` (string: "cpc"/"cpm"/"ocpm"/"automatic", default "cpc"), `BidAmount` (decimal?), `MinAge` (int?), `MaxAge` (int?), `Genders` (string?), `Locations` (string?), `Interests` (string?) +- **Behavior**: Creates an AdSet with targeting and bid strategy, in Draft status. + +### CreateAdCommand +- **Returns**: `Guid` (new ad ID) +- **Parameters**: `AdSetId` (Guid), `Name` (string), `Format` (string: "single_image"/"single_video"/"carousel"/"collection"/"stories", default "single_image"), `Headline` (string?), `PrimaryText` (string?), `CallToAction` (string?), `DestinationUrl` (string?), `CreativeUrl` (string?) +- **Behavior**: Creates an Ad in Draft status with ReviewStatus=NotSubmitted. + +### SubmitAdForReviewCommand +- **Returns**: `bool` +- **Parameters**: `AdId` (Guid) +- **Behavior**: Transitions ad review status from NotSubmitted/Rejected to PendingReview. + +### ApproveAdCommand (defined in AdminAdsController) +- **Returns**: `bool` +- **Parameters**: `AdId` (Guid) +- **Behavior**: Transitions ad review status from PendingReview to Approved. + +### RejectAdCommand (defined in AdminAdsController) +- **Returns**: `bool` +- **Parameters**: `AdId` (Guid), `Reason` (string) +- **Behavior**: Transitions ad review status from PendingReview to Rejected. + +--- + +## 4. Queries + +### GetCampaignByIdQuery +- **Parameters**: `CampaignId` (Guid) +- **Returns**: `CampaignDto?` (Id, AdvertiserId, Name, Description, Status, Objective, BudgetType, BudgetAmount, Currency, TotalSpend, StartDate, EndDate, CreatedAt, UpdatedAt) +- **Handler**: Fetches from ICampaignRepository. + +### ListCampaignsQuery +- **Parameters**: `AdvertiserId` (Guid?), `Status` (string?), `Objective` (string?), `SearchTerm` (string?), `Page` (int), `PageSize` (int) +- **Returns**: `ListCampaignsResult` (Items: List\, TotalCount, Page, PageSize, TotalPages) +- **Handler**: Queries AdsManagerServiceContext directly with filters, pagination, ordered by CreatedAt DESC. + +### GetAdByIdQuery +- **Parameters**: `AdId` (Guid) +- **Returns**: `AdDto?` (Id, AdSetId, Name, Format, Status, ReviewStatus, Headline, PrimaryText, Description, CallToAction, DestinationUrl, CreativeUrl, CreatedAt, UpdatedAt) +- **Handler**: Queries AdsManagerServiceContext directly. + +### GetAdSetByIdQuery +- **Parameters**: `AdSetId` (Guid) +- **Returns**: `AdSetDto?` (Id, CampaignId, Name, Status, DailyBudget, BidType, BidAmount, StartDate, EndDate, Targeting: TargetingDto, CreatedAt, UpdatedAt) +- **Handler**: Queries AdsManagerServiceContext directly. Splits targeting comma-separated strings into lists. + +### GetCampaignStatsQuery +- **Parameters**: None +- **Returns**: `CampaignStatsDto` (TotalCampaigns, ActiveCampaigns, PausedCampaigns, DraftCampaigns, CompletedCampaigns, TotalSpend, TotalBudget) +- **Handler**: Loads ALL campaigns into memory and counts in-memory. Warning: potential performance issue with large datasets. + +### ListPendingAdsQuery (defined in AdminAdsController) +- **Parameters**: `Page` (int), `PageSize` (int) +- **Returns**: `List` +- **Handler**: Filters ads where ReviewStatus.Name == "Pending", ordered by CreatedAt ASC. + +### GetTopAdvertisersQuery (defined in AdminReportsController) +- **Parameters**: `Limit` (int, default: 10) +- **Returns**: `List` (AdvertiserId, TotalCampaigns, TotalSpend, ActiveCampaigns) +- **Handler**: Groups campaigns by AdvertiserId, ordered by TotalSpend DESC. + +### GetRevenueAnalyticsQuery (defined in AdminReportsController) +- **Parameters**: `StartDate` (DateTime?), `EndDate` (DateTime?) +- **Returns**: `RevenueAnalyticsDto` (TotalRevenue, AverageRevenuePerCampaign, TotalCampaigns, RevenueByObjective: Dictionary) +- **Handler**: Loads campaigns filtered by date range, groups revenue by objective. + +### ListAudiencesQuery (defined in AudiencesController) +- **Parameters**: `AdvertiserId` (Guid) +- **Returns**: `List` +- **Handler**: NOT IMPLEMENTED - no handler exists in the codebase. + +### GetAudienceByIdQuery (defined in AudiencesController) +- **Parameters**: `AudienceId` (Guid) +- **Returns**: `AudienceDto?` +- **Handler**: NOT IMPLEMENTED - no handler exists in the codebase. + +--- + +## 5. Domain Model + +### Aggregates + +#### Campaign Aggregate (CampaignAggregate/) +- **Campaign** (Entity, IAggregateRoot) + - Fields: `_name`, `_description`, `_advertiserId`, `_status` (CampaignStatus), `_objective` (CampaignObjective), `_budget` (CampaignBudget), `_startDate`, `_endDate`, `_createdAt`, `_updatedAt`, `TotalSpend` + - Public IDs: `StatusId`, `ObjectiveId` + - Behavior methods: `Update()`, `SetSchedule()`, `SetBudget()`, `Activate()`, `Pause()`, `Complete()`, `Archive()`, `RecordSpend()` + - State machine: Draft -> Active <-> Paused -> Completed; Draft/Paused/Completed -> Archived (Active cannot be directly archived) + - Domain events: CampaignCreatedDomainEvent (on create), CampaignActivatedDomainEvent (on activate), CampaignStatusChangedDomainEvent (on pause/complete/archive) + +- **CampaignStatus** (Enumeration) + - Values: Draft (1), Active (2), Paused (3), Completed (4), Archived (5) + - Names stored as lowercase: "draft", "active", "paused", "completed", "archived" + +- **CampaignObjective** (Enumeration) + - Values: Awareness (1, "awareness"), Traffic (2, "traffic"), Conversion (3, "conversion"), AppInstalls (4, "app_installs"), VideoViews (5, "video_views"), LeadGeneration (6, "lead_generation") + +- **CampaignBudget** (ValueObject) + - Properties: `Type` (BudgetType enum), `Amount` (decimal), `Currency` (string, default "VND") + - Factory methods: `Daily()`, `Lifetime()` + +- **BudgetType** (enum): Daily (1), Lifetime (2) + +- **ICampaignRepository**: `Add()`, `Update()`, `GetByIdAsync()`, `GetByAdvertiserIdAsync()`, `GetActiveAsync()` + +#### AdSet Aggregate (AdSetAggregate/) +- **AdSet** (Entity, IAggregateRoot) + - Fields: `_name`, `_campaignId`, `_status` (AdSetStatus), `_targeting` (Targeting), `_bidStrategy` (BidStrategy), `_dailyBudget`, `_startDate`, `_endDate`, `_createdAt`, `_updatedAt` + - Public ID: `StatusId` + - Behavior methods: `Update()`, `SetTargeting()`, `SetBidStrategy()`, `Activate()`, `Pause()` + - State machine: Draft -> Active <-> Paused + +- **AdSetStatus** (Enumeration): Draft (1), Active (2), Paused (3), Archived (4) + +- **Targeting** (ValueObject) + - Properties: `MinAge` (int?), `MaxAge` (int?), `Genders` (string?), `Locations` (string?), `Interests` (string?), `CustomAudienceIds` (string?), `LookalikeAudienceIds` (string?) + - All list-type fields stored as comma-separated strings. + - Factory methods: `Broad()`, `Demographic()` + +- **BidStrategy** (ValueObject) + - Properties: `Type` (BidType enum), `BidAmount` (decimal?), `TargetCost` (decimal?) + - Factory methods: `CPC()`, `CPM()`, `OCPM()`, `Automatic()` + +- **BidType** (enum): CPC (1), CPM (2), OCPM (3), TargetCost (4), Automatic (5) + +- **IAdSetRepository**: `Add()`, `Update()`, `GetByIdAsync()`, `GetByCampaignIdAsync()` + +#### Ad Aggregate (AdAggregate/) +- **Ad** (Entity, IAggregateRoot) + - Fields: `_name`, `_adSetId`, `_format` (AdFormat), `_status` (AdStatus), `_reviewStatus` (AdReviewStatus), `_headline`, `_primaryText`, `_description`, `_callToAction`, `_destinationUrl`, `_creativeUrl`, `_createdAt`, `_updatedAt` + - Public IDs: `FormatId`, `StatusId`, `ReviewStatusId` + - Behavior methods: `Update()`, `SetCreative()`, `SubmitForReview()`, `Approve()`, `Reject()`, `Activate()`, `Pause()` + - Review state machine: NotSubmitted -> PendingReview -> Approved/Rejected; Approved resets to PendingReview if content updated + - Status state machine: Draft -> Active <-> Paused (activation requires Approved review status) + +- **AdFormat** (Enumeration): SingleImage (1, "single_image"), SingleVideo (2, "single_video"), Carousel (3, "carousel"), Collection (4, "collection"), Stories (5, "stories") + +- **AdStatus** (Enumeration): Draft (1), Active (2), Paused (3), Archived (4) + +- **AdReviewStatus** (Enumeration): NotSubmitted (1, "not_submitted"), PendingReview (2, "pending_review"), Approved (3, "approved"), Rejected (4, "rejected") + +- **IAdRepository**: `Add()`, `Update()`, `GetByIdAsync()` + +#### Audience Aggregate (AudienceAggregate/) +- **CustomAudience** (Entity, IAggregateRoot) + - Fields: `_name`, `_advertiserId`, `_source` (AudienceSource), `_size`, `_createdAt`, `_updatedAt` + - Public ID: `SourceId` + - Behavior methods: `UpdateSize()` + +- **LookalikeAudience** (Entity, IAggregateRoot) + - Fields: `_name`, `_advertiserId`, `_sourceAudienceId`, `_similarityPercentage` (1-10), `_location`, `_size`, `_createdAt` + - Behavior methods: `UpdateSize()` + +- **AudienceSource** (Enumeration): CustomerList (1, "customer_list"), WebsiteVisitors (2, "website_visitors"), AppUsers (3, "app_users"), EngagementCustomers (4, "engagement_customers") + +**Note**: No repository interface or implementation exists for Audience aggregates. No EF configuration exists for Audience entities. The migration creates basic `CustomAudiences` and `LookalikeAudiences` tables with minimal columns (only Id and SourceId for CustomAudience, only Id for LookalikeAudience) -- these are incomplete. + +### SeedWork (Base Classes) +- **Entity**: Base class with `Guid Id`, `DomainEvents` collection, equality by ID +- **IAggregateRoot**: Marker interface +- **IRepository\**: Generic interface with `IUnitOfWork UnitOfWork` +- **IUnitOfWork**: `SaveChangesAsync()`, `SaveEntitiesAsync()` +- **ValueObject**: Equality by component values +- **Enumeration**: Type-safe enum with `int Id`, `string Name`, parsing methods + +### Exceptions +- **DomainException**: Base exception for domain errors +- **AdsDomainException**: Ads-specific domain exception (extends Exception, not DomainException) + +### Domain Events +- **CampaignCreatedDomainEvent**: Contains `Campaign` reference. Raised when a campaign is created. +- **CampaignActivatedDomainEvent**: Contains `CampaignId`, `PreviousStatus`, `NewStatus`. Raised when a campaign is activated. +- **CampaignStatusChangedDomainEvent**: Contains `CampaignId`, `PreviousStatus`, `NewStatus`. Raised on pause, complete, or archive. + +**Note**: No domain event handlers are implemented. Events are dispatched by DbContext but not consumed. + +--- + +## 6. Database Schema + +**Database**: `ads_manager_service` (PostgreSQL) + +**Migration**: `20260117171043_InitialCreate` + +### Table: `campaigns` + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| id | uuid | NOT NULL | PK | +| name | varchar(255) | NOT NULL | | +| description | varchar(1000) | NULL | | +| advertiser_id | uuid | NOT NULL | | +| status_id | integer | NOT NULL | | +| objective_id | integer | NOT NULL | | +| budget_type | integer | NOT NULL | Owned (CampaignBudget) | +| budget_amount | numeric(18,2) | NOT NULL | Owned (CampaignBudget) | +| currency | varchar(10) | NOT NULL | Owned (CampaignBudget) | +| start_date | timestamp with time zone | NULL | | +| end_date | timestamp with time zone | NULL | | +| total_spend | numeric(18,2) | NOT NULL | Default: 0 | +| created_at | timestamp with time zone | NOT NULL | | +| updated_at | timestamp with time zone | NULL | | + +**Indexes**: +- `idx_campaigns_advertiser_id` on `advertiser_id` +- `idx_campaigns_status_id` on `status_id` +- `idx_campaigns_created_at` on `created_at` + +### Table: `ad_sets` + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| id | uuid | NOT NULL | PK | +| name | varchar(255) | NOT NULL | | +| campaign_id | uuid | NOT NULL | | +| status_id | integer | NOT NULL | | +| target_min_age | integer | NULL | Owned (Targeting) | +| target_max_age | integer | NULL | Owned (Targeting) | +| target_genders | varchar(50) | NULL | Owned (Targeting) | +| target_locations | varchar(500) | NULL | Owned (Targeting) | +| target_interests | varchar(500) | NULL | Owned (Targeting) | +| custom_audience_ids | varchar(500) | NULL | Owned (Targeting) | +| lookalike_audience_ids | varchar(500) | NULL | Owned (Targeting) | +| bid_type | integer | NOT NULL | Owned (BidStrategy) | +| bid_amount | numeric(18,6) | NULL | Owned (BidStrategy) | +| target_cost | numeric(18,6) | NULL | Owned (BidStrategy) | +| daily_budget | numeric(18,2) | NOT NULL | | +| start_date | timestamp with time zone | NULL | | +| end_date | timestamp with time zone | NULL | | +| created_at | timestamp with time zone | NOT NULL | | +| updated_at | timestamp with time zone | NULL | | + +**Indexes**: +- `idx_ad_sets_campaign_id` on `campaign_id` +- `idx_ad_sets_status_id` on `status_id` + +**Note**: No foreign key to `campaigns` table is defined. + +### Table: `ads` + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| id | uuid | NOT NULL | PK | +| name | varchar(255) | NOT NULL | | +| ad_set_id | uuid | NOT NULL | | +| format_id | integer | NOT NULL | | +| status_id | integer | NOT NULL | | +| review_status_id | integer | NOT NULL | | +| headline | varchar(255) | NULL | | +| primary_text | varchar(1000) | NULL | | +| description | varchar(500) | NULL | | +| call_to_action | varchar(50) | NULL | | +| destination_url | varchar(2048) | NULL | | +| creative_url | varchar(2048) | NULL | | +| created_at | timestamp with time zone | NOT NULL | | +| updated_at | timestamp with time zone | NULL | | + +**Indexes**: +- `idx_ads_ad_set_id` on `ad_set_id` +- `idx_ads_status_id` on `status_id` +- `idx_ads_review_status_id` on `review_status_id` + +**Note**: No foreign key to `ad_sets` table is defined. + +### Table: `CustomAudiences` (incomplete) + +| Column | Type | Nullable | +|--------|------|----------| +| Id | uuid | NOT NULL (PK) | +| SourceId | integer | NOT NULL | + +**Note**: No EF configuration exists for this table. Migration only creates minimal columns. Most domain entity fields (Name, AdvertiserId, Size, CreatedAt, UpdatedAt) are not mapped. + +### Table: `LookalikeAudiences` (incomplete) + +| Column | Type | Nullable | +|--------|------|----------| +| Id | uuid | NOT NULL (PK) | + +**Note**: No EF configuration exists. Only Id column in migration. All other domain fields unmapped. + +--- + +## 7. Integration Events + +**Published Domain Events** (dispatched via MediatR before SaveChanges): +- `CampaignCreatedDomainEvent` - When a campaign is created +- `CampaignActivatedDomainEvent` - When a campaign is activated +- `CampaignStatusChangedDomainEvent` - When campaign status changes (pause, complete, archive) + +**Cross-service Integration Events**: None implemented. No RabbitMQ integration. No event publishers or consumers for cross-service communication. + +**Idempotency**: `IRequestManager` / `RequestManager` infrastructure exists for duplicate request detection, but it is not currently used by any command handler. + +--- + +## 8. Dependencies + +### NuGet Packages + +**API Layer** (`AdsManagerService.API.csproj`): +- MediatR 12.4.1 +- FluentValidation 11.11.0 +- FluentValidation.DependencyInjectionExtensions 11.11.0 +- Microsoft.EntityFrameworkCore.Design 10.0.2 +- Swashbuckle.AspNetCore 7.2.0 +- Asp.Versioning.Mvc 8.1.0 +- Asp.Versioning.Mvc.ApiExplorer 8.1.0 +- AspNetCore.HealthChecks.NpgSql 8.0.2 +- AspNetCore.HealthChecks.Redis 8.0.1 +- Hellang.Middleware.ProblemDetails 6.5.1 +- Serilog.AspNetCore 8.0.3 +- Serilog.Sinks.Console 6.0.0 +- Serilog.Sinks.Seq 8.0.0 + +**Domain Layer** (`AdsManagerService.Domain.csproj`): +- MediatR.Contracts 2.0.1 + +**Infrastructure Layer** (`AdsManagerService.Infrastructure.csproj`): +- Microsoft.EntityFrameworkCore 10.0.0 +- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 +- Microsoft.EntityFrameworkCore.Tools 10.0.0 +- MediatR 12.4.1 +- Dapper 2.1.35 +- Microsoft.Extensions.Http.Polly 9.0.0 +- Polly 8.5.0 +- StackExchange.Redis 2.8.16 + +**Test Projects**: +- xunit 2.9.2 +- FluentAssertions 6.12.2 +- Moq 4.20.72 (UnitTests only) +- Microsoft.AspNetCore.Mvc.Testing 10.0.0 (FunctionalTests) +- Microsoft.EntityFrameworkCore.InMemory 10.0.0 (FunctionalTests) +- Testcontainers.PostgreSql 4.1.0 (FunctionalTests) +- coverlet.collector 6.0.2 + +### External Service Dependencies +- **PostgreSQL**: Primary database (Neon cloud or local) +- **Redis**: Connection string configured but not actively used in code (health check registered) + +**Note**: No authentication/authorization middleware is configured. No JWT validation. All endpoints are unauthenticated. + +--- + +## 9. Configuration + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "Serilog": { ... } +} +``` + +**Note**: JWT settings are present in configuration but JWT authentication middleware is NOT registered in Program.cs. These settings are unused. + +### Environment Variables (.env.example) + +| Variable | Description | Default | +|----------|-------------|---------| +| ASPNETCORE_ENVIRONMENT | Runtime environment | Development | +| DATABASE_URL | PostgreSQL connection string (fallback) | - | +| REDIS_URL | Redis connection | localhost:6379 | +| JWT_SECRET | JWT signing key | - | +| JWT_ISSUER | JWT issuer | goodgo-platform | +| JWT_AUDIENCE | JWT audience | goodgo-services | +| API_PORT | API port | 5000 | +| LOG_LEVEL | Minimum log level | Information | +| SEQ_URL | Seq logging endpoint | http://localhost:5341 | + +### Docker Configuration +- **Base image**: mcr.microsoft.com/dotnet/aspnet:10.0 +- **Build image**: mcr.microsoft.com/dotnet/sdk:10.0 +- **Port**: 8080 +- **User**: dotnetuser (UID 1001, GID 1001) - non-root +- **Health check**: `curl -f http://localhost:8080/health/live` (30s interval, 3 retries) + +### MediatR Pipeline Behaviors (registered in order) +1. **LoggingBehavior** - Logs request name and elapsed time (with Stopwatch) +2. **ValidatorBehavior** - Runs FluentValidation validators (throws ValidationException on failure) +3. **TransactionBehavior** - Wraps commands in a database transaction (skips queries based on type name ending in "Query") + +### Database Connection Resolution Order +1. `ConnectionStrings:DefaultConnection` from appsettings +2. `DATABASE_URL` environment variable +3. Throws `InvalidOperationException` if neither is configured + +### Npgsql Resilience +- Retry on failure: max 5 retries, max 30s delay + +--- + +## 10. Known Issues and Gaps + +1. **No Authentication**: No JWT/Bearer auth middleware registered. All endpoints are publicly accessible. +2. **Audience handlers missing**: `ListAudiencesQuery` and `GetAudienceByIdQuery` have no handler implementations. Endpoints will throw at runtime. +3. **Audience tables incomplete**: `CustomAudiences` and `LookalikeAudiences` tables in migration have minimal columns. No EF configurations exist. +4. **No foreign keys**: Campaign -> AdSet -> Ad relationships have no FK constraints in the database. +5. **No FluentValidation validators**: No validator classes exist for any command. +6. **No domain event handlers**: Domain events are dispatched but no handlers consume them. +7. **No cross-service events**: No RabbitMQ/message broker integration. +8. **Idempotency unused**: `IRequestManager` is registered but never called by any handler. +9. **Dapper unused**: Dapper is referenced but no raw SQL queries exist. +10. **Redis unused**: Redis package included and health check registered, but no caching logic implemented. +11. **Unit tests empty**: The UnitTests project has no test files. +12. **Functional tests minimal**: Only 2 tests (list campaigns returns 200, health check returns 200). +13. **GetCampaignStatsQuery**: Loads ALL campaigns into memory before counting -- no SQL-level aggregation. +14. **AdsDomainException**: Extends `Exception` directly, not `DomainException`. Inconsistent hierarchy. +15. **ListPendingAdsQuery handler**: Filters by `ReviewStatus.Name == "Pending"` but the actual enum name is `"pending_review"` -- potential mismatch depending on EF query translation. diff --git a/services/ads-serving-service-net/SERVICE_DOCS.md b/services/ads-serving-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..6f40d9eb --- /dev/null +++ b/services/ads-serving-service-net/SERVICE_DOCS.md @@ -0,0 +1,441 @@ +# AdsServingService - Service Documentation + +## 1. Overview + +**Purpose**: Real-time ad serving microservice that implements a second-price auction system. It receives ad serve requests, runs real-time bidding (RTB) auctions among eligible ad candidates, determines winners via eCPM scoring, and tracks impressions/clicks. It also provides admin endpoints for monitoring auctions, budget pacing, and frequency capping. + +**Port**: `5012` (Development, via launchSettings.json) +**Docker Port**: `8080` (Production, via `ASPNETCORE_URLS=http://+:8080`) +**Database**: `ads_serving_service` (PostgreSQL, Neon cloud in appsettings.json) +**Framework**: .NET 10.0, C# 14 +**Solution File**: `AdsServingService.slnx` +**Initial Migration**: `20260117181413_InitialCreate` + +--- + +## 2. API Endpoints + +### AdsController (`api/v1/ads`) + +| Method | Route | Description | Request Body | Response | +|--------|-------|-------------|--------------|----------| +| POST | `/api/v1/ads/serve` | Serve an ad via real-time auction (target < 100ms) | `ServeAdRequest` | `200 OK` with `ServedAdDto` or `204 NoContent` if no eligible ads | +| POST | `/api/v1/ads/events/impression` | Track ad impression (fire-and-forget) | `ImpressionEvent` | `202 Accepted` | +| POST | `/api/v1/ads/events/click` | Track ad click (fire-and-forget) | `ClickEvent` | `202 Accepted` | + +**Request/Response DTOs (AdsController)**: + +``` +ServeAdRequest { + UserId: Guid + PlacementType: string (default "feed") + UserContext: Dictionary? (optional) +} + +ServedAdDto { + AdId: Guid + CampaignId: Guid + AdFormat: string + Headline: string? + PrimaryText: string? + CallToAction: string? + CreativeUrl: string? + DestinationUrl: string? + FinalPrice: decimal + ServedAt: DateTime +} + +ImpressionEvent { AdId: Guid, UserId: Guid, Timestamp: DateTime } +ClickEvent { AdId: Guid, UserId: Guid, Timestamp: DateTime } +``` + +### AdminAuctionsController (`api/v1/admin/auctions`) + +| Method | Route | Description | Query Params | Response | +|--------|-------|-------------|--------------|----------| +| GET | `/api/v1/admin/auctions` | Paginated list of auctions with filters | `userId`, `placementType`, `startDate`, `endDate`, `page` (default 1), `pageSize` (default 20) | `200 OK` with `PagedResult` | +| GET | `/api/v1/admin/auctions/statistics` | Aggregate auction statistics | none | `200 OK` with `AuctionStatisticsDto` | + +### AdminBudgetController (`api/v1/admin/budget`) + +| Method | Route | Description | Query Params | Response | +|--------|-------|-------------|--------------|----------| +| GET | `/api/v1/admin/budget/pacers` | Paginated list of budget pacers | `campaignId`, `page` (default 1), `pageSize` (default 20) | `200 OK` with `PagedResult` | +| GET | `/api/v1/admin/budget/campaigns/{campaignId}` | Budget status for a specific campaign | none | `200 OK` with `BudgetPacerDto` or `404` | +| PUT | `/api/v1/admin/budget/campaigns/{campaignId}/reset` | Reset daily spend for a campaign | none | `200 OK` or `404` | +| GET | `/api/v1/admin/budget/statistics` | Budget utilization statistics | none | `200 OK` with `BudgetStatisticsDto` | + +### AdminFrequencyController (`api/v1/admin/frequency`) + +| Method | Route | Description | Query Params | Response | +|--------|-------|-------------|--------------|----------| +| GET | `/api/v1/admin/frequency/caps` | List frequency caps | `adId` (optional filter) | `200 OK` with `List` | +| GET | `/api/v1/admin/frequency/caps/{id}` | Get frequency cap by ID | none | `200 OK` with `FrequencyCapDto` or `404` | +| POST | `/api/v1/admin/frequency/caps` | Create a new frequency cap | body: `CreateFrequencyCapRequest` | `201 Created` with `FrequencyCapDto` | +| DELETE | `/api/v1/admin/frequency/caps/{id}` | Delete a frequency cap | none | `204 NoContent` or `404` | + +**Admin DTOs**: + +``` +AuctionDto { Id, UserId, PlacementType, AuctionTime, BidCount, WinningAdId?, FinalPrice?, WinningeCPM? } +AuctionStatisticsDto { TotalAuctions, AverageWinRate, AverageeCPM, TotalBidsPlaced } +BudgetPacerDto { Id, CampaignId, DailyBudget, SpentToday, RemainingBudget, UtilizationPercent, Strategy, LastUpdated } +BudgetStatisticsDto { TotalCampaigns, TotalDailyBudget, TotalSpentToday, AverageUtilization, CampaignsExceeded } +FrequencyCapDto { Id, AdId, MaxImpressionsPerUser, Window } +CreateFrequencyCapRequest { AdId: Guid, MaxImpressionsPerUser: int, Window: string (default "Day") } +PagedResult { Items, TotalCount, Page, PageSize, TotalPages (computed) } +``` + +### Health Check Endpoints + +| Route | Description | +|-------|-------------| +| `/health` | Full health check (includes PostgreSQL) | +| `/health/live` | Liveness probe (app is running, no dependency checks) | +| `/health/ready` | Readiness probe (includes PostgreSQL check) | + +--- + +## 3. Commands + +**No MediatR Commands exist in this service.** The service is primarily read/query-oriented. Write operations (frequency cap CRUD, budget reset) are handled directly in controllers via DbContext without MediatR commands. + +--- + +## 4. Queries + +### ServeAdQuery +- **File**: `Application/Queries/ServeAdQuery.cs` +- **Parameters**: `UserId` (Guid), `PlacementType` (string), `UserContext` (Dictionary) +- **Returns**: `ServedAdDto?` (nullable - returns null if no eligible ads or auction fails) +- **Handler**: `ServeAdQueryHandler` - orchestrates the full RTB auction pipeline: + 1. Fetches eligible ads via `IEligibleAdsProvider` + 2. Creates an `Auction` domain entity + 3. Scores each candidate via `IAuctionScoringService` (predicted CTR + quality score) + 4. Adds bids to auction + 5. Runs second-price auction (winner determined by highest eCPM) + 6. Persists auction to database (bids stored as JSONB) + 7. Publishes `AdServedEvent` via `IAdServingEventPublisher` + 8. Returns winning ad details with final price + +### GetAuctionsQuery +- **File**: `Application/Queries/GetAuctionsQuery.cs` +- **Parameters**: `UserId?` (Guid), `PlacementType?` (string), `StartDate?` (DateTime), `EndDate?` (DateTime), `Page` (int, default 1), `PageSize` (int, default 20) +- **Returns**: `PagedResult` - paginated auction history with bid counts parsed from JSONB +- **Handler**: `GetAuctionsQueryHandler` - queries auctions from DB with optional filters, ordered by auction_time descending + +### GetAuctionStatisticsQuery +- **File**: `Application/Queries/GetAuctionStatisticsQuery.cs` +- **Parameters**: none +- **Returns**: `AuctionStatisticsDto` with TotalAuctions, AverageWinRate (%), AverageeCPM, TotalBidsPlaced +- **Handler**: `GetAuctionStatisticsQueryHandler` - loads all auctions into memory and computes aggregates (bid counts parsed from JSONB) + +### GetBudgetPacersQuery +- **File**: `Application/Queries/GetBudgetPacersQuery.cs` +- **Parameters**: `CampaignId?` (Guid), `Page` (int, default 1), `PageSize` (int, default 20) +- **Returns**: `PagedResult` - paginated budget pacer list ordered by last_updated descending +- **Handler**: `GetBudgetPacersQueryHandler` + +--- + +## 5. Domain Model + +### Aggregates + +#### Auction (AuctionAggregate) +- **File**: `Domain/AggregatesModel/AuctionAggregate/Auction.cs` +- **Type**: Entity + IAggregateRoot +- **Fields**: `_bids` (List), `_result` (AuctionResult?), `_auctionTime` (DateTime) +- **Public Properties**: `UserId`, `PlacementType`, `Bids` (IReadOnlyCollection), `Result`, `AuctionTime` +- **Constructor**: `Auction(Guid userId, string placementType)` - generates new Id, sets auction time to UTC now +- **Behavior Methods**: + - `AddBid(adId, campaignId, bidAmount, predictedCTR, qualityScore)` - adds a Bid to the auction + - `RunAuction()` - sorts bids by eCPM descending, selects winner, calculates second-price (second-highest eCPM + 0.01) + +#### Bid (child entity of Auction) +- **Type**: Entity (not aggregate root) +- **Properties**: `AdId`, `CampaignId`, `BidAmount`, `PredictedCTR`, `QualityScore` +- **Computed**: `eCPM = BidAmount * PredictedCTR * QualityScore` +- **Note**: Bids are stored as JSONB in the auctions table, not as a separate table. The `Bids` navigation is ignored by EF Core. + +#### FrequencyCap (FrequencyAggregate) +- **File**: `Domain/AggregatesModel/FrequencyAggregate/FrequencyCap.cs` +- **Type**: Entity + IAggregateRoot +- **Fields**: `_adId` (Guid), `_maxImpressionsPerUser` (int), `_window` (FrequencyWindow) +- **Constructor**: `FrequencyCap(Guid adId, int maxImpressionsPerUser, FrequencyWindow window)` - validates maxImpressions > 0 +- **Behavior Methods**: + - `IsUserCapped(int currentImpressions)` - returns true if user has reached the cap + - `Daily(Guid adId, int maxImpressions)` - static factory for daily caps + +#### BudgetPacer (PacingAggregate) +- **File**: `Domain/AggregatesModel/PacingAggregate/BudgetPacer.cs` +- **Type**: Entity + IAggregateRoot +- **Fields**: `_campaignId` (Guid), `_dailyBudget` (decimal), `_spentToday` (decimal), `_strategy` (PacingStrategy), `_lastUpdated` (DateTime) +- **Computed Properties**: `RemainingBudget = _dailyBudget - _spentToday`, `UtilizationPercent = (_spentToday / _dailyBudget) * 100` +- **Constructor**: `BudgetPacer(Guid campaignId, decimal dailyBudget, PacingStrategy strategy)` - validates dailyBudget > 0 +- **Behavior Methods**: + - `CanServeAd(decimal estimatedCost)` - checks budget availability; Smooth strategy enforces hourly pacing with 20% buffer + - `RecordSpend(decimal amount)` - adds spend, validates non-negative + - `ResetDailySpend()` - resets spent to 0 + +### Value Objects + +#### AuctionResult +- **Properties**: `WinningAdId` (Guid), `WinningCampaignId` (Guid), `FinalPrice` (decimal), `WinningeCPM` (decimal) +- **Equality**: based on WinningAdId + FinalPrice +- **Mapped as**: Owned type within Auction table + +#### UserAdHistory +- **Properties**: `UserId` (Guid), `AdImpressionCounts` (Dictionary), `Date` (DateTime) +- **Methods**: `RecordImpression(Guid adId)`, `GetImpressionCount(Guid adId)` +- **Note**: Designed for Redis caching (key format `freq:{userId}:{date}`), not persisted in PostgreSQL + +### Enumerations + +#### FrequencyWindow (enum) +- `Hour = 1`, `Day = 2`, `Week = 3`, `Month = 4`, `Lifetime = 5` + +#### PacingStrategy (enum) +- `Smooth = 1` (spread evenly throughout the day) +- `Accelerated = 2` (spend as fast as possible) + +### Exceptions + +- **AdsServingDomainException** - business rule violations specific to ads serving +- **DomainException** - generic domain exception base class + +### SeedWork (Base Classes) + +- `Entity` - base entity with `Id` (Guid), `DomainEvents` (IReadOnlyCollection), equality by Id +- `ValueObject` - immutable value comparison base +- `Enumeration` - type-safe enum pattern with parse/lookup +- `IAggregateRoot` - marker interface +- `IRepository` - generic repository with `IUnitOfWork` +- `IUnitOfWork` - `SaveChangesAsync()`, `SaveEntitiesAsync()` (dispatches domain events) + +--- + +## 6. Database Schema + +**Database**: PostgreSQL (Neon cloud) +**Migration**: `20260117181413_InitialCreate` + +### Table: `auctions` + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | uuid | NOT NULL (PK) | Auction ID | +| `user_id` | uuid | NOT NULL | User who triggered the ad request | +| `placement_type` | varchar(50) | NOT NULL | Ad placement type (feed, story, banner) | +| `auction_time` | timestamp with time zone | NOT NULL | When the auction occurred | +| `bids` | jsonb | NULL | All bids serialized as JSON array | +| `winning_ad_id` | uuid | NULL | Winning ad ID (owned AuctionResult) | +| `winning_campaign_id` | uuid | NULL | Winning campaign ID (owned AuctionResult) | +| `final_price` | numeric(18,4) | NULL | Second-price auction final price (owned AuctionResult) | +| `winning_ecpm` | numeric(18,4) | NULL | Winning bid eCPM (owned AuctionResult) | + +**Indexes**: +- `ix_auctions_user_id` on `user_id` +- `ix_auctions_placement_type` on `placement_type` +- `ix_auctions_auction_time` on `auction_time` +- `ix_auctions_placement_user` composite on (`placement_type`, `user_id`) + +### Table: `budget_pacers` + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | uuid | NOT NULL (PK) | Pacer ID | +| `campaign_id` | uuid | NOT NULL | Associated campaign ID | +| `daily_budget` | numeric(18,4) | NOT NULL | Daily budget limit | +| `spent_today` | numeric(18,4) | NOT NULL | Amount spent today | +| `strategy` | varchar(20) | NOT NULL | Pacing strategy ("Smooth" or "Accelerated") | +| `last_updated` | timestamp with time zone | NOT NULL | Last update timestamp | + +**Indexes**: +- `ix_budget_pacers_campaign_id` UNIQUE on `campaign_id` +- `ix_budget_pacers_last_updated` on `last_updated` + +### Table: `frequency_caps` + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | uuid | NOT NULL (PK) | Frequency cap ID | +| `ad_id` | uuid | NOT NULL | Associated ad ID | +| `max_impressions_per_user` | integer | NOT NULL | Max impressions per user in window | +| `window` | varchar(20) | NOT NULL | Time window ("Hour", "Day", "Week", "Month", "Lifetime") | + +**Indexes**: +- `ix_frequency_caps_ad_id` on `ad_id` + +--- + +## 7. Integration Events + +The service defines an event publishing abstraction (`IAdServingEventPublisher`) with three event types. The **current implementation** (`LoggingAdServingEventPublisher`) only logs events -- no actual message broker integration exists yet. + +### Published Events + +| Event | Topic/Key | Payload Fields | +|-------|-----------|----------------| +| `AdServedEvent` | `ad.served.v1` | AuctionId, AdId, CampaignId, UserId, PlacementType, FinalPrice, WinningEcpm, ServedAt | +| `AdImpressionTrackedEvent` | `ads.impression.tracked.v1` | AdId, UserId, Timestamp | +| `AdClickTrackedEvent` | `ads.click.tracked.v1` | AdId, UserId, Timestamp | + +### Consumed Events + +None. The service does not consume any external events. + +### Application Services + +#### IEligibleAdsProvider / InMemoryEligibleAdsProvider +- Returns eligible ad candidates for a serve request +- **Current implementation**: Returns 2 hardcoded in-memory candidates with deterministic IDs (based on userId + placementType hash) +- Candidate fields: AdId, CampaignId, BidAmount, Format, Headline, PrimaryText, CallToAction, CreativeUrl, DestinationUrl +- **Note**: This is a stub. Production would integrate with ads-manager-service to fetch real campaigns/ads. + +#### IAuctionScoringService / DefaultAuctionScoringService +- Scores ad candidates to produce `AuctionBidSignals(PredictedCtr, QualityScore)` +- **Predicted CTR** heuristic: base CTR by placement type (story=0.018, banner=0.012, default=0.020), boosted by user segment ("high-intent" +25%) and recency (purchase within 14 days +10%) +- **Quality Score** heuristic: by format (single_image=1.0, video=1.1, default=0.95) + +--- + +## 8. Dependencies + +### NuGet Packages + +**API Layer**: +| Package | Version | +|---------|---------| +| MediatR | 12.4.1 | +| FluentValidation | 11.11.0 | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | +| Swashbuckle.AspNetCore | 7.2.0 | +| Asp.Versioning.Mvc | 8.1.0 | +| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | +| AspNetCore.HealthChecks.Redis | 8.0.1 | +| Hellang.Middleware.ProblemDetails | 6.5.1 | +| Serilog.AspNetCore | 8.0.3 | +| Serilog.Sinks.Console | 6.0.0 | +| Serilog.Sinks.Seq | 8.0.0 | +| Microsoft.EntityFrameworkCore.Design | 10.0.1 | + +**Domain Layer**: +| Package | Version | +|---------|---------| +| MediatR.Contracts | 2.0.1 | + +**Infrastructure Layer**: +| Package | Version | +|---------|---------| +| Microsoft.EntityFrameworkCore | 10.0.0 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.0 | +| MediatR | 12.4.1 | +| Dapper | 2.1.35 | +| Microsoft.Extensions.Http.Polly | 9.0.0 | +| Polly | 8.5.0 | +| StackExchange.Redis | 2.8.16 | + +### External Service Dependencies + +- **PostgreSQL** (Neon cloud) - primary data store +- **Redis** (configured but not actively used in code) - intended for frequency cap caching (UserAdHistory) +- **ads-manager-service** (not integrated yet) - intended source for eligible ad campaigns (currently stubbed with InMemoryEligibleAdsProvider) +- **ads-tracking-service** (not integrated yet) - intended consumer of impression/click events (currently logged only) + +--- + +## 9. Configuration + +### Connection Strings (appsettings.json) + +| Key | Description | +|-----|-------------| +| `ConnectionStrings:DefaultConnection` | PostgreSQL connection string (Neon cloud endpoint in default config) | +| `DATABASE_URL` | Alternative env var for connection string (fallback) | + +### Redis + +| Key | Default | +|-----|---------| +| `Redis:ConnectionString` | `localhost:6379` | + +### JWT (configured but not enforced -- no `[Authorize]` attributes on controllers) + +| Key | Default | +|-----|---------| +| `Jwt:Secret` | `your-super-secret-key-min-32-characters` | +| `Jwt:Issuer` | `goodgo-platform` | +| `Jwt:Audience` | `goodgo-services` | +| `Jwt:AccessTokenExpiryMinutes` | `15` | +| `Jwt:RefreshTokenExpiryDays` | `7` | + +### Environment Variables (.env.example) + +| Variable | Description | +|----------|-------------| +| `ASPNETCORE_ENVIRONMENT` | Runtime environment (Development/Production) | +| `DATABASE_URL` | PostgreSQL connection string | +| `REDIS_URL` | Redis connection string | +| `REDIS_PASSWORD` | Redis password | +| `JWT_SECRET` | JWT signing key | +| `API_PORT` | API listening port | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry exporter endpoint | +| `SEQ_URL` | Seq logging endpoint | +| `RATE_LIMIT_PERMITS_PER_MINUTE` | Rate limiting config | + +### Build Configuration (Directory.Build.props) + +- Target Framework: `net10.0` +- Language Version: C# 14 +- Nullable: enabled +- TreatWarningsAsErrors: true +- GenerateDocumentationFile: true + +### Docker + +- Multi-stage build: `sdk:10.0` (build) -> `aspnet:10.0` (runtime) +- Non-root user: `dotnetuser` (UID/GID 1001) +- Port: 8080 +- Health check: `curl -f http://localhost:8080/health/live` (30s interval, 3 retries) + +### Startup Behavior + +- Auto-applies EF Core migrations on startup (catches and logs errors without crashing) +- Swagger UI enabled in Development at `/swagger` +- CORS: allows any origin/method/header (open) +- MediatR pipeline: LoggingBehavior -> ValidatorBehavior -> TransactionBehavior (skips transactions for queries) + +--- + +## 10. Tests + +### Functional Tests (`tests/AdsServingService.FunctionalTests/`) + +Uses `CustomWebApplicationFactory` with InMemoryDatabase (unique DB per factory instance). + +| Test | Description | +|------|-------------| +| `ServeAd_ShouldReturnServedAdPayload` | POST /api/v1/ads/serve returns 200 with valid adId and positive finalPrice | +| `GetAuctionStatistics_AfterServe_ShouldReflectPersistedAuctions` | After serving an ad, statistics show >= 1 auction and >= 2 bids | +| `TrackImpression_ShouldReturnAccepted` | POST /api/v1/ads/events/impression returns 202 Accepted | +| `HealthCheck_ShouldReturnHealthy` | GET /health/live returns 200 | + +### Unit Tests (`tests/AdsServingService.UnitTests/`) + +Project exists but contains no test files yet (only generated assembly info). + +--- + +## 11. Notable Architecture Decisions + +1. **Second-Price Auction**: Winner pays second-highest eCPM + $0.01 (standard RTB model) +2. **eCPM Formula**: `BidAmount * PredictedCTR * QualityScore` +3. **Bids stored as JSONB**: Instead of a separate `bids` table, bids are serialized to JSON in the `auctions.bids` column for high-throughput performance. EF Core `OwnsMany` + `ToJson` was avoided due to EF Core 10 compatibility issues. +4. **No authentication**: Controllers have no `[Authorize]` attributes despite JWT being configured +5. **Stub eligible ads provider**: `InMemoryEligibleAdsProvider` returns hardcoded candidates -- needs real integration with ads-manager-service +6. **Logging-only event publisher**: Events are logged but not published to RabbitMQ or any message broker +7. **No repositories**: The service accesses `AdsServingServiceContext` directly in handlers and controllers (repository pattern is commented out in DependencyInjection.cs) +8. **Idempotency infrastructure**: `IRequestManager` / `RequestManager` / `ClientRequest` exist but are not used by any command or controller diff --git a/services/ads-tracking-service-net/SERVICE_DOCS.md b/services/ads-tracking-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..aa5d03c5 --- /dev/null +++ b/services/ads-tracking-service-net/SERVICE_DOCS.md @@ -0,0 +1,474 @@ +# AdsTrackingService - Service Documentation + +## 1. Overview + +**Purpose**: Ads event tracking microservice for the GoodGo advertising platform. Handles tracking pixel management, client-side and server-side event tracking, conversion recording, and multi-model attribution analytics. + +**Port**: `5014` (Development, via launchSettings.json), `8080` (Docker/Production) + +**Database**: PostgreSQL — `ads_tracking_service` on Neon (`ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech`) + +**Schema**: `ads_tracking` + +**Framework**: .NET 10.0, C# 14 + +**Architecture**: Clean Architecture (API / Domain / Infrastructure) + CQRS via MediatR + +**Migration**: Single migration `20260117182759_InitialCreate`. Auto-applied on startup via `dbContext.Database.MigrateAsync()`. + +--- + +## 2. API Endpoints + +### PixelsController — `api/v1/ads-tracking/pixels` + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/{advertiserId:guid}` | Get pixel code for an advertiser | Path: `advertiserId` (Guid) | `PixelCodeDto` (200) or 404 | +| POST | `/` | Create a new tracking pixel | Body: `CreatePixelRequest { AdvertiserId }` | `TrackingPixelResult` (201) or 400 | + +### EventsController — `api/v1/ads-tracking/events` + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| POST | `/` | Track a client-side pixel event | Body: `TrackPixelEventRequest { PixelCode, AdId, UserId, EventType }` | 202 Accepted or 400 | +| POST | `/server` | Track a server-side event (no pixel required) | Body: `TrackServerSideEventRequest { AdId, UserId, EventType }` | 202 Accepted or 400 | + +### ConversionsController — `api/v1/ads-tracking/conversions` + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/` | Get conversions with optional filtering | Query: `campaignId?`, `userId?`, `from?`, `to?`, `skip=0`, `take=20` | `IEnumerable` (200) | +| GET | `/{id:guid}/attribution` | Get attribution details for a conversion | Path: `id` (Guid) | `AttributionDto` (200) or 404 | + +### AdminPixelsController — `api/v1/admin/ads-tracking/pixels` + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/` | List all tracking pixels (paginated) | Query: `page=1`, `pageSize=20`, `isActive?` | `List` (200) — **MOCK** | +| GET | `/{pixelId:guid}/events` | Get pixel event history | Query: `from?`, `to?`, `page=1`, `pageSize=50` | `List` (200) — **MOCK** | +| GET | `/{pixelId:guid}/stats` | Get pixel statistics | Query: `from?`, `to?` | `PixelStatsDto` (200) — **MOCK** | +| PUT | `/{pixelId:guid}/activate` | Activate a tracking pixel | Path: `pixelId` | 200 — **MOCK** | +| PUT | `/{pixelId:guid}/deactivate` | Deactivate a tracking pixel | Path: `pixelId` | 200 — **MOCK** | + +### AdminConversionsController — `api/v1/admin/ads-tracking/conversions` + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/` | List all conversions (admin, filtered) | Query: `advertiserId?`, `campaignId?`, `conversionType?`, `from?`, `to?`, `page=1`, `pageSize=20` | `IEnumerable` (200) | +| GET | `/stats` | Get conversion statistics | Query: `campaignId?`, `from?`, `to?` | `ConversionStatsDto` (200) — **MOCK** | +| GET | `/{id:guid}` | Get conversion details by ID | Path: `id` (Guid) | `ConversionDetailDto` (200) — **MOCK** | + +### AdminAttributionController — `api/v1/admin/ads-tracking/attribution` + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/stats` | Get attribution statistics by model | Query: `from?`, `to?` | `AttributionStatsDto` (200) — **MOCK** | +| GET | `/campaigns/{campaignId:guid}` | Get attribution report for a campaign | Query: `from?`, `to?` | `CampaignAttributionReportDto` (200) — **MOCK** | + +### Health Endpoints + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/health` | Full health check (includes PostgreSQL) | +| GET | `/health/live` | Liveness probe (app running check only) | +| GET | `/health/ready` | Readiness probe (includes all checks) | + +--- + +## 3. Commands + +### CreateTrackingPixelCommand + +- **File**: `Application/Commands/CreateTrackingPixelCommand.cs` +- **Parameters**: `Guid AdvertiserId` +- **Returns**: `TrackingPixelResult { PixelId, PixelCode, IsActive }` +- **Behavior**: Checks if a pixel already exists for the advertiser (returns existing if found). Otherwise creates a new `TrackingPixel` entity with an auto-generated 16-char uppercase hex pixel code. Persists via `ITrackingPixelRepository` + `UnitOfWork.SaveEntitiesAsync`. +- **Validator**: `CreateTrackingPixelCommandValidator` — requires `AdvertiserId` to be non-empty. + +### TrackPixelEventCommand + +- **File**: `Application/Commands/TrackPixelEventCommand.cs` +- **Parameters**: `string PixelCode`, `Guid AdId`, `Guid UserId`, `PixelEventType EventType`, `string? UserAgent`, `string? IpAddress` +- **Returns**: `bool` (success/failure) +- **Behavior**: Looks up the pixel by code, validates it exists and is active. Creates a `PixelEvent` entity in-memory but does NOT persist it (noted as incomplete — only logs the event). Returns `false` if pixel not found or inactive. + +### RecordConversionCommand + +- **File**: `Application/Commands/RecordConversionCommand.cs` +- **Parameters**: `Guid AdvertiserId`, `Guid CampaignId`, `Guid UserId`, `string ConversionType`, `decimal ConversionValue`, `string Currency = "VND"` +- **Returns**: `ConversionResult { ConversionId, ConversionTime }` +- **Behavior**: Creates a new `Conversion` entity and persists via `IConversionRepository` + `UnitOfWork.SaveEntitiesAsync`. +- **Note**: This command has a handler but is not exposed via any controller endpoint. It is available for internal/cross-service use only. + +--- + +## 4. Queries + +### GetPixelCodeQuery + +- **File**: `Application/Queries/GetPixelCodeQuery.cs` +- **Parameters**: `Guid AdvertiserId` +- **Returns**: `PixelCodeDto? { Code, IsActive, CreatedAt }` (nullable) +- **Behavior**: Fetches pixel by advertiser ID via repository. Returns null if not found. + +### GetConversionsQuery + +- **File**: `Application/Queries/GetConversionsQuery.cs` +- **Parameters**: `Guid? CampaignId`, `Guid? UserId`, `DateTime? From`, `DateTime? To`, `int Skip = 0`, `int Take = 20` +- **Returns**: `IEnumerable` where `ConversionDto { Id, AdvertiserId, CampaignId, UserId, ConversionType, ConversionValue, Currency, ConversionTime }` +- **Behavior**: Filters by CampaignId (prioritized), then UserId, then time range. Default: last 30 days. Applies Skip/Take pagination. + +### GetAttributionDetailsQuery + +- **File**: `Application/Queries/GetAttributionDetailsQuery.cs` +- **Parameters**: `Guid ConversionId` +- **Returns**: `AttributionDto? { Id, ConversionId, AdId, CampaignId, Model, AttributedValue, AttributedAt }` (nullable) +- **Behavior**: Fetches attribution record by conversion ID. Returns null if not found. Model is returned as string via `.ToString()`. + +--- + +## 5. Domain Model + +### Aggregates + +#### TrackingPixel (Aggregate Root) + +- **File**: `Domain/AggregatesModel/TrackingPixelAggregate/TrackingPixel.cs` +- **Fields**: `_advertiserId` (Guid), `_pixelCode` (string, 16 chars), `_isActive` (bool), `CreatedAt` (DateTime) +- **Behavior Methods**: `Activate()`, `Deactivate()` +- **Factory**: Constructor generates pixel code via `Guid.NewGuid().ToString("N")[..16].ToUpper()` +- **Repository**: `ITrackingPixelRepository` — `GetByAdvertiserIdAsync`, `GetByPixelCodeAsync`, `GetActivePixelsAsync` + +#### PixelEvent (Entity, non-aggregate) + +- **File**: Same file as TrackingPixel +- **Fields**: `_pixelId` (Guid), `_adId` (Guid), `_userId` (Guid), `_eventType` (PixelEventType), `_userAgent` (string?), `_ipAddress` (string?), `_timestamp` (DateTime) +- **Note**: Not an aggregate root. No dedicated repository. Currently created in-memory only (not persisted by handler). + +#### Conversion (Aggregate Root) + +- **File**: `Domain/AggregatesModel/ConversionAggregate/Conversion.cs` +- **Fields**: `_advertiserId` (Guid), `_campaignId` (Guid), `_userId` (Guid), `_conversionType` (string), `_conversionValue` (decimal), `_currency` (string, default "VND"), `_conversionTime` (DateTime) +- **Static Factory Methods**: `Conversion.Purchase(advertiserId, campaignId, userId, amount)`, `Conversion.Lead(advertiserId, campaignId, userId)` +- **Repository**: `IConversionRepository` — `GetByCampaignIdAsync`, `GetByUserIdAsync`, `GetByTimeRangeAsync` + +#### Attribution (Aggregate Root) + +- **File**: `Domain/AggregatesModel/AttributionAggregate/Attribution.cs` +- **Fields**: `_conversionId` (Guid), `_adId` (Guid), `_campaignId` (Guid), `_model` (AttributionModel enum), `_attributedValue` (decimal), `_attributedAt` (DateTime) +- **Repository**: `IAttributionRepository` — `GetByConversionIdAsync`, `GetByCampaignIdAsync`, `GetByAdIdAsync`, `GetByModelAsync` + +### Enumerations + +#### PixelEventType (enum) + +``` +Impression = 1, Click = 2, PageView = 3, ViewContent = 4, +AddToCart = 5, InitiateCheckout = 6, Purchase = 7, Lead = 8 +``` + +#### AttributionModel (enum) + +``` +LastClick = 1, FirstClick = 2, Linear = 3, TimeDecay = 4 +``` + +### Value Objects + +#### AttributionWindow + +- **File**: `Domain/AggregatesModel/AttributionAggregate/AttributionWindow.cs` +- **Properties**: `ClickWindowDays` (int), `ViewWindowDays` (int) +- **Validation**: Neither can be negative; both cannot be zero simultaneously +- **Predefined Windows**: `SevenDayClick` (7,0), `OneDayView` (0,1), `SevenOne` (7,1), `TwentyEightOne` (28,1) +- **Methods**: `IsWithinWindow(eventTime, conversionTime, isClick)` — checks if conversion falls within the attribution window +- **Note**: Defined but not currently used by any command/query handler. + +### SeedWork (Base Classes) + +- **Entity**: Base class with `Guid Id`, `DomainEvents` list, equality by ID +- **IAggregateRoot**: Marker interface +- **IRepository**: Generic interface with `UnitOfWork`, `GetByIdAsync`, `AddAsync`, `Update`, `Delete` +- **IUnitOfWork**: `SaveChangesAsync`, `SaveEntitiesAsync` (dispatches domain events) +- **ValueObject**: Immutable base with structural equality +- **Enumeration**: Type-safe enum pattern with `Id`/`Name`, parsing utilities + +### Exceptions + +- **DomainException**: Base domain exception +- **AdsTrackingDomainException**: Service-specific domain exception (neither currently thrown by any code) + +--- + +## 6. Database Schema + +All tables in schema `ads_tracking`. Single migration: `20260117182759_InitialCreate`. + +### Table: `tracking_pixels` + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK, NOT NULL | +| `advertiser_id` | uuid | NOT NULL | +| `pixel_code` | varchar(16) | NOT NULL | +| `is_active` | boolean | NOT NULL | +| `created_at` | timestamp with time zone | NOT NULL | + +**Indexes**: +- `ix_tracking_pixels_advertiser_id` on `advertiser_id` +- `uix_tracking_pixels_pixel_code` on `pixel_code` (UNIQUE) +- `ix_tracking_pixels_is_active` on `is_active` + +### Table: `pixel_events` + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK, NOT NULL | +| `pixel_id` | uuid | NOT NULL | +| `ad_id` | uuid | NOT NULL | +| `user_id` | uuid | NOT NULL | +| `event_type` | integer | NOT NULL | +| `user_agent` | varchar(500) | nullable | +| `ip_address` | varchar(45) | nullable | +| `timestamp` | timestamp with time zone | NOT NULL | + +**Indexes**: +- `ix_pixel_events_pixel_id_timestamp` on (`pixel_id`, `timestamp`) +- `ix_pixel_events_ad_id_timestamp` on (`ad_id`, `timestamp`) +- `ix_pixel_events_user_id_timestamp` on (`user_id`, `timestamp`) +- `ix_pixel_events_event_type` on `event_type` +- `ix_pixel_events_event_type_timestamp` on (`event_type`, `timestamp`) + +### Table: `conversions` + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK, NOT NULL | +| `advertiser_id` | uuid | NOT NULL | +| `campaign_id` | uuid | NOT NULL | +| `user_id` | uuid | NOT NULL | +| `conversion_type` | varchar(50) | NOT NULL | +| `conversion_value` | numeric(18,2) | NOT NULL | +| `currency` | varchar(3) | NOT NULL | +| `conversion_time` | timestamp with time zone | NOT NULL | + +**Indexes**: +- `ix_conversions_advertiser_id` on `advertiser_id` +- `ix_conversions_campaign_id_conversion_time` on (`campaign_id`, `conversion_time`) +- `ix_conversions_user_id_conversion_time` on (`user_id`, `conversion_time`) +- `ix_conversions_conversion_type` on `conversion_type` +- `ix_conversions_conversion_time` on `conversion_time` + +### Table: `attributions` + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK, NOT NULL | +| `conversion_id` | uuid | NOT NULL | +| `ad_id` | uuid | NOT NULL | +| `campaign_id` | uuid | NOT NULL | +| `model` | integer | NOT NULL | +| `attributed_value` | numeric(18,2) | NOT NULL | +| `attributed_at` | timestamp with time zone | NOT NULL | + +**Indexes**: +- `ix_attributions_conversion_id` on `conversion_id` +- `ix_attributions_ad_id_attributed_at` on (`ad_id`, `attributed_at`) +- `ix_attributions_campaign_id_attributed_at` on (`campaign_id`, `attributed_at`) +- `ix_attributions_model` on `model` +- `ix_attributions_model_campaign_id_attributed_at` on (`model`, `campaign_id`, `attributed_at`) + +### Idempotency Table (implicit via ClientRequest entity) + +The `ClientRequest` entity is registered with the DbContext but has no explicit `IEntityTypeConfiguration`. It would use EF Core conventions (table `ClientRequest`, columns `Id`, `Name`, `Time`). + +--- + +## 7. Integration Events + +**None implemented.** No integration events are published or consumed. There are no event handlers, no RabbitMQ configuration, and no cross-service event classes in the codebase. + +The `RecordConversionCommand` exists as an internal command but is not exposed via any API endpoint, suggesting it may be intended for future cross-service invocation. + +--- + +## 8. Dependencies + +### NuGet Packages + +#### API Layer (`AdsTrackingService.API`) + +| Package | Version | +|---------|---------| +| MediatR | 12.4.1 | +| FluentValidation | 11.11.0 | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | +| Microsoft.EntityFrameworkCore.Design | 10.0.2 | +| Swashbuckle.AspNetCore | 7.2.0 | +| Asp.Versioning.Mvc | 8.1.0 | +| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | +| AspNetCore.HealthChecks.Redis | 8.0.1 | +| Hellang.Middleware.ProblemDetails | 6.5.1 | +| Serilog.AspNetCore | 8.0.3 | +| Serilog.Sinks.Console | 6.0.0 | +| Serilog.Sinks.Seq | 8.0.0 | + +#### Domain Layer (`AdsTrackingService.Domain`) + +| Package | Version | +|---------|---------| +| MediatR.Contracts | 2.0.1 | + +#### Infrastructure Layer (`AdsTrackingService.Infrastructure`) + +| Package | Version | +|---------|---------| +| Microsoft.EntityFrameworkCore | 10.0.0 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.0 | +| MediatR | 12.4.1 | +| Dapper | 2.1.35 | +| Microsoft.Extensions.Http.Polly | 9.0.0 | +| Polly | 8.5.0 | +| StackExchange.Redis | 2.8.16 | + +#### Test Projects + +| Package | Version | Project | +|---------|---------|---------| +| Microsoft.NET.Test.Sdk | 17.12.0 | Both | +| xunit | 2.9.2 | Both | +| xunit.runner.visualstudio | 2.8.2 | Both | +| FluentAssertions | 6.12.2 | Both | +| Moq | 4.20.72 | UnitTests | +| Microsoft.AspNetCore.Mvc.Testing | 10.0.0 | FunctionalTests | +| Microsoft.EntityFrameworkCore.InMemory | 10.0.0 | FunctionalTests | +| Testcontainers.PostgreSql | 4.1.0 | FunctionalTests | +| coverlet.collector | 6.0.2 | Both | + +### Build-level + +| Package | Version | +|---------|---------| +| Microsoft.SourceLink.GitHub | 8.0.0 | + +### External Service Dependencies + +- **PostgreSQL** (Neon): Primary datastore +- **Redis**: Connection string configured (`localhost:6379`) but not used in application code (only health check package referenced) +- **No RabbitMQ**: No message broker integration +- **No external HTTP services**: No outbound API calls + +--- + +## 9. Configuration + +### Connection Strings (`appsettings.json`) + +``` +ConnectionStrings__DefaultConnection (or DATABASE_URL env var) +``` + +Resolution order in code: `GetConnectionString("DefaultConnection")` -> `Configuration["DATABASE_URL"]` + +### Redis + +``` +Redis__ConnectionString = localhost:6379 +``` + +Referenced in config but not actively used in application code. + +### JWT Settings + +``` +Jwt__Secret = your-super-secret-key-min-32-characters +Jwt__Issuer = goodgo-platform +Jwt__Audience = goodgo-services +Jwt__AccessTokenExpiryMinutes = 15 +Jwt__RefreshTokenExpiryDays = 7 +``` + +Configured in `appsettings.json` but NOT consumed — there is no authentication middleware (`app.UseAuthentication()` / `app.UseAuthorization()` are absent from `Program.cs`). All endpoints are currently unauthenticated. + +### Environment Variables (`.env.example`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `ASPNETCORE_ENVIRONMENT` | Development | Runtime environment | +| `DATABASE_URL` | localhost PostgreSQL | Fallback connection string | +| `REDIS_URL` | localhost:6379 | Redis connection | +| `JWT_SECRET` | (placeholder) | JWT signing key | +| `JWT_ISSUER` | goodgo-platform | Token issuer | +| `JWT_AUDIENCE` | goodgo-services | Token audience | +| `API_PORT` | 5000 | API port | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | localhost:4317 | OpenTelemetry endpoint | +| `LOG_LEVEL` | Information | Serilog minimum level | +| `SEQ_URL` | localhost:5341 | Seq log sink | +| `RATE_LIMIT_PERMITS_PER_MINUTE` | 100 | Rate limiting | +| `HEALTHCHECK_TIMEOUT_SECONDS` | 5 | Health check timeout | + +### Serilog + +Configured via `appsettings.json` with console sink. Seq sink package is included but not configured in settings. Development mode enables Debug-level logging for EF Core commands. + +### API Versioning + +- Default: v1.0 +- Readers: URL segment (`api/v{version}`) + Header (`X-Api-Version`) +- Reports API versions in responses + +### CORS + +Open policy: `AllowAnyOrigin`, `AllowAnyMethod`, `AllowAnyHeader`. + +### Swagger + +Available in Development environment at `/swagger`. + +### Docker + +- Multi-stage build: `sdk:10.0` (build) -> `aspnet:10.0` (runtime) +- Non-root user: `dotnetuser` (UID/GID 1001) +- Port: 8080 +- Health check: `curl -f http://localhost:8080/health/live` (30s interval, 3 retries) + +### MediatR Pipeline (execution order) + +1. `LoggingBehavior` — logs request name, measures elapsed time with Stopwatch +2. `ValidatorBehavior` — runs FluentValidation validators, throws `ValidationException` on failure +3. `TransactionBehavior` — wraps Commands in DB transactions (skips Queries by name suffix check), uses `ExecutionStrategy` for retry + +--- + +## 10. Maturity Assessment + +### Working + +- Tracking pixel CRUD (create + get by advertiser) +- Client-side pixel event tracking endpoint (validation only, no persistence) +- Server-side event tracking endpoint (logging only) +- Conversion querying with filters (campaign, user, time range) +- Attribution detail lookup by conversion ID +- Database schema and migration +- Health check endpoints +- Functional tests (2 tests: pixel not found + health check) + +### Incomplete / Mock + +- **Admin pixel endpoints**: All 5 endpoints return hardcoded/mock data +- **Admin conversion stats**: Returns hardcoded data +- **Admin conversion details by ID**: Returns hardcoded data +- **Admin attribution stats + campaign report**: Returns hardcoded data +- **PixelEvent persistence**: `TrackPixelEventCommandHandler` creates the entity but does not save it to any repository +- **RecordConversionCommand**: Has handler but no controller endpoint exposes it +- **AttributionWindow value object**: Fully defined but not used anywhere +- **Authentication/Authorization**: JWT settings configured but no auth middleware applied — all endpoints are public +- **Redis**: Package referenced but not used in code +- **Dapper**: Package referenced but not used (all queries use EF Core) +- **Polly**: Package referenced but not used (only EF Core retry configured) +- **Unit tests**: Project exists but contains zero test files +- **Integration events**: None defined — no cross-service communication +- **Domain events**: Entity base class supports them but no domain events are raised by any aggregate diff --git a/services/booking-service-net/SERVICE_DOCS.md b/services/booking-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..e75b9ac9 --- /dev/null +++ b/services/booking-service-net/SERVICE_DOCS.md @@ -0,0 +1,534 @@ +# BookingService - Service Documentation + +> Auto-generated from source code audit on 2026-03-13 + +## Overview + +BookingService is a microservice for managing appointment bookings, resource allocation, staff schedules, and spa/beauty therapists. It supports multi-vertical booking workflows (Spa, Karaoke, Restaurant, etc.) with slot-finding algorithms based on staff availability and resource capacity. + +- **Framework**: .NET 10.0 (C# 14), ASP.NET Core Web API +- **Architecture**: Clean Architecture + CQRS (MediatR 12.4.1) +- **Database**: PostgreSQL (Neon cloud) via EF Core 10 + Npgsql 10 +- **Port**: 5020 (Development) +- **Database Name**: `booking_service` +- **Health Checks**: `/health`, `/health/live`, `/health/ready` +- **Auth**: JWT Bearer via IAM IdentityServer OIDC discovery (default authority: `http://localhost:5001`) +- **API Response Format**: `{ success: bool, data: T, message?: string, errors?: string[] }` + +## API Endpoints + +### Appointments (`/api/v1/appointments`) + +| Method | Path | Auth | Description | Request | Response | +|--------|------|------|-------------|---------|----------| +| GET | `/api/v1/appointments?shopId=&customerId=&startDate=&endDate=&status=&page=&pageSize=` | No | Get appointments by shop or customer with filtering/pagination | Query params | `ApiResponse>` | +| GET | `/api/v1/appointments/{id}` | No | Get appointment by ID | - | `ApiResponse` | +| POST | `/api/v1/appointments` | No | Create a new appointment | `CreateAppointmentRequest` | `ApiResponse` (201) | +| PATCH | `/api/v1/appointments/{id}/status` | No | Update appointment status (confirm/start/complete/noshow) | `UpdateStatusRequest` | `ApiResponse` | +| PATCH | `/api/v1/appointments/{id}/noshow` | No | Mark appointment as no-show | - | `ApiResponse` | +| DELETE | `/api/v1/appointments/{id}` | No | Cancel appointment | `CancelRequest` | `ApiResponse` | + +### Resources (`/api/v1/resources`) + +| Method | Path | Auth | Description | Request | Response | +|--------|------|------|-------------|---------|----------| +| GET | `/api/v1/resources?shopId=&isActive=` | No | Get resources by shop | Query params | `ApiResponse>` | +| POST | `/api/v1/resources` | No | Create a new resource | `CreateResourceRequest` | `ApiResponse` (201) | +| PUT | `/api/v1/resources/{id}` | No | Update a resource | `UpdateResourceRequest` | `ApiResponse` | + +### Slots (`/api/v1/slots`) + +| Method | Path | Auth | Description | Request | Response | +|--------|------|------|-------------|---------|----------| +| POST | `/api/v1/slots/find` | No | Find available time slots | `FindSlotsRequest` | `ApiResponse>` | + +### Staff Schedules (`/api/v1/staff/{staffId}/schedule`) + +| Method | Path | Auth | Description | Request | Response | +|--------|------|------|-------------|---------|----------| +| GET | `/api/v1/staff/{staffId}/schedule?shopId=` | No | Get staff schedule | Query params | `ApiResponse>` | +| PUT | `/api/v1/staff/{staffId}/schedule` | No | Update staff schedule (replace all) | `UpdateScheduleRequest` | `ApiResponse>` | + +### Schedules (`/api/v1/schedules`) + +| Method | Path | Auth | Description | Request | Response | +|--------|------|------|-------------|---------|----------| +| GET | `/api/v1/schedules?shopId=` | No | Get all staff schedules for a shop | Query params | `ApiResponse>` | +| POST | `/api/v1/schedules` | No | Create a single schedule entry | `CreateScheduleRequest` | `ApiResponse` (201) | +| DELETE | `/api/v1/schedules/{id}` | No | Delete a schedule entry | - | `{ success, message }` | + +### Therapists (`/api/v1/therapists`) + +| Method | Path | Auth | Description | Request | Response | +|--------|------|------|-------------|---------|----------| +| GET | `/api/v1/therapists?shopId=&isActive=` | No | Get therapists by shop | Query params | `ApiResponse>` | +| POST | `/api/v1/therapists` | No | Create a new therapist | `CreateTherapistRequest` | `ApiResponse` (201) | +| PUT | `/api/v1/therapists/{id}` | No | Update a therapist | `UpdateTherapistRequest` | `ApiResponse` | +| DELETE | `/api/v1/therapists/{id}` | No | Deactivate therapist (soft delete) | - | `ApiResponse` | + +### Admin Appointments (`/api/v1/admin/appointments`) - **Requires Auth: Admin, ShopOwner** + +| Method | Path | Auth | Description | Request | Response | +|--------|------|------|-------------|---------|----------| +| GET | `/api/v1/admin/appointments?shopId=&startDate=&endDate=&status=&page=&pageSize=` | Admin/ShopOwner | Get all appointments with advanced filtering | Query params | `ApiResponse>` | +| GET | `/api/v1/admin/appointments/statistics?shopId=&startDate=&endDate=` | Admin/ShopOwner | Get appointment statistics | Query params | `ApiResponse` | + +### Admin Resources (`/api/v1/admin/resources`) - **Requires Auth: Admin, ShopOwner** + +| Method | Path | Auth | Description | Request | Response | +|--------|------|------|-------------|---------|----------| +| GET | `/api/v1/admin/resources/{shopId}` | Admin/ShopOwner | Get all resources including inactive | Path param | `ApiResponse>` | + +--- + +## Commands + +### CreateAppointmentCommand +- **Input**: `ShopId (Guid)`, `ServiceId (Guid)`, `StartTime (DateTime)`, `EndTime (DateTime)`, `CustomerId? (Guid)`, `StaffId? (Guid)`, `ResourceId? (Guid)`, `Notes? (string)` +- **Logic**: Creates Appointment domain entity, raises `AppointmentCreatedDomainEvent`, saves via repository +- **Validator**: `CreateAppointmentCommandValidator` + - ShopId: NotEmpty + - ServiceId: NotEmpty + - StartTime: NotEmpty, must be in future (with 5min tolerance) + - EndTime: NotEmpty, must be after StartTime + - Notes: MaxLength(1000) + +### UpdateAppointmentStatusCommand +- **Input**: `AppointmentId (Guid)`, `Action (string)` - one of: "confirm", "start", "complete", "noshow" +- **Logic**: Loads appointment, executes domain action via switch (Confirm/MarkAsInProgress/Complete/MarkNoShow), saves +- **Validator**: `UpdateAppointmentStatusCommandValidator` + - AppointmentId: NotEmpty + - Action: NotEmpty, must be one of valid actions + +### CancelAppointmentCommand +- **Input**: `AppointmentId (Guid)`, `Reason (string)` +- **Logic**: Loads appointment, calls `Cancel(reason)`, raises `AppointmentCancelledDomainEvent` +- **Validator**: `CancelAppointmentCommandValidator` + - AppointmentId: NotEmpty + - Reason: NotEmpty, MaxLength(500) + +### MarkNoShowCommand +- **Input**: `AppointmentId (Guid)` +- **Logic**: Loads appointment, calls `MarkNoShow()`, saves +- **Validator**: `MarkNoShowCommandValidator` + - AppointmentId: NotEmpty + +### CreateResourceCommand +- **Input**: `ShopId (Guid)`, `Name (string)`, `ResourceType (string)`, `Capacity (int, default 1)` +- **Logic**: Creates Resource entity, saves via repository +- **Validator**: `CreateResourceCommandValidator` + - ShopId: NotEmpty + - Name: NotEmpty, MaxLength(200) + - ResourceType: NotEmpty, MaxLength(50) + - Capacity: GreaterThan(0) + +### UpdateResourceCommand +- **Input**: `ResourceId (Guid)`, `Name (string)`, `Capacity (int)`, `IsActive (bool)` +- **Logic**: Loads resource, toggles Activate/Deactivate, saves +- **Note**: Name and Capacity are accepted but NOT actually applied to the entity (only IsActive is used in handler). This is a potential bug. + +### UpdateStaffScheduleCommand +- **Input**: `StaffId (Guid)`, `ShopId (Guid)`, `Schedule (List)` +- **ScheduleDay**: `DayOfWeek (int, 0=Sunday..6=Saturday)`, `StartTime (TimeOnly)`, `EndTime (TimeOnly)` +- **Logic**: Deletes all existing schedules for staff+shop, creates new ones from input +- **Validator**: None + +### CreateTherapistCommand +- **Input**: `ShopId (Guid)`, `Name (string)`, `Specialties (string[])`, `WorkingHours (WorkingHoursDto)` +- **Logic**: Serializes WorkingHours to JSON, creates Therapist entity, raises `TherapistCreatedDomainEvent` +- **Validator**: `CreateTherapistCommandValidator` + - ShopId: NotEmpty + - Name: NotEmpty, MaxLength(200) + - WorkingHours: NotNull + - WorkingHours.Days: NotEmpty + - Each Day: DayOfWeek 0-6, StartTime/EndTime required when IsWorking + +### UpdateTherapistCommand +- **Input**: `TherapistId (Guid)`, `Name (string)`, `Specialties (string[])`, `WorkingHours (WorkingHoursDto)` +- **Logic**: Loads therapist, serializes WorkingHours to JSON, calls `Update(name, specialties, workingHoursJson)`, raises `TherapistUpdatedDomainEvent` +- **Validator**: `UpdateTherapistCommandValidator` (same rules as Create) + +### DeactivateTherapistCommand +- **Input**: `TherapistId (Guid)` +- **Logic**: Loads therapist, calls `Deactivate()` (soft delete), saves +- **Validator**: `DeactivateTherapistCommandValidator` + - TherapistId: NotEmpty + +--- + +## Queries + +### GetAppointmentQuery +- **Input**: `AppointmentId (Guid)` +- **Logic**: Loads from repository by ID, returns null if not found + +### GetAppointmentsByShopQuery +- **Input**: `ShopId (Guid)`, `StartDate? (DateTime)`, `EndDate? (DateTime)`, `Status? (string)`, `Page (int, default 1)`, `PageSize (int, default 20)` +- **Logic**: Filters by ShopId, optional date range and status, ordered by StartTime DESC, paginated + +### GetAppointmentsByCustomerQuery +- **Input**: `CustomerId (Guid)`, `Page (int, default 1)`, `PageSize (int, default 20)` +- **Logic**: Filters by CustomerId, ordered by StartTime DESC, paginated + +### FindAvailableSlotsQuery +- **Input**: `ShopId (Guid)`, `ServiceId (Guid)`, `Date (DateTime)`, `ServiceDurationMinutes (int)`, `StaffId? (Guid)`, `ResourceId? (Guid)` +- **Logic**: Algorithm: `Available Slots = Staff Schedule intersection Resource Availability - Booked Appointments` + 1. Gets staff schedules for the day (filtered by specific staff if provided) + 2. Gets existing non-cancelled appointments for the date + 3. For each staff schedule, generates 15-minute interval slots + 4. Excludes slots that conflict with existing appointments (overlap check) + 5. Returns sorted by StartTime +- **Validator**: `FindAvailableSlotsQueryValidator` + - ShopId: NotEmpty + - ServiceId: NotEmpty + - Date: NotEmpty + - ServiceDurationMinutes: GreaterThan(0), LessThanOrEqualTo(480) + +### GetResourcesByShopQuery +- **Input**: `ShopId (Guid)`, `IsActive? (bool)` +- **Logic**: Filters resources by ShopId, optional active filter + +### GetStaffScheduleQuery +- **Input**: `StaffId (Guid)`, `ShopId (Guid)` +- **Logic**: Gets schedules for specific staff in a shop + +### GetSchedulesByShopQuery +- **Input**: `ShopId (Guid)` +- **Logic**: Gets all staff schedules for a shop, ordered by StaffId then DayOfWeek + +### GetTherapistsQuery +- **Input**: `ShopId (Guid)`, `IsActive? (bool)` +- **Logic**: Gets therapists for a shop, optional active filter, ordered by Name + +--- + +## Domain Model + +### Appointment (Aggregate Root) + +**Fields**: +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| Id | Guid | Yes | Auto-generated UUID | +| ShopId | Guid | Yes | Shop this appointment belongs to | +| CustomerId | Guid? | No | Customer who booked | +| StaffId | Guid? | No | Assigned staff/therapist | +| ResourceId | Guid? | No | Assigned resource (room/bed) | +| ServiceId | Guid | Yes | Service being booked | +| StartTime | DateTime | Yes | Appointment start | +| EndTime | DateTime | Yes | Must be after StartTime | +| Status | string | Yes | Pending/Confirmed/InProgress/Completed/Cancelled/NoShow | +| Notes | string? | No | Max 1000 chars | +| CreatedAt | DateTime | Yes | UTC timestamp | + +**Behavior Methods**: +- `Confirm()` - Pending -> Confirmed, raises `AppointmentConfirmedDomainEvent` +- `MarkAsInProgress()` - Confirmed -> InProgress +- `Complete()` - InProgress -> Completed, raises `AppointmentCompletedDomainEvent` +- `Cancel(reason)` - Any (except Completed/Cancelled) -> Cancelled, raises `AppointmentCancelledDomainEvent` +- `MarkNoShow()` - Pending/Confirmed -> NoShow + +**State Machine**: +``` +Pending --> Confirmed --> InProgress --> Completed + | | + | v + +--------> NoShow + | + v +Cancelled (from any state except Completed) +``` + +**Domain Events**: +- `AppointmentCreatedDomainEvent(Appointment)` - on creation +- `AppointmentConfirmedDomainEvent(Appointment)` - on confirm +- `AppointmentCompletedDomainEvent(Appointment)` - on complete +- `AppointmentCancelledDomainEvent(Appointment, Reason)` - on cancel + +### AppointmentStatus (Enumeration) + +Type-safe enum: Pending(1), Confirmed(2), InProgress(3), Completed(4), Cancelled(5), NoShow(6) + +**Note**: The Appointment entity uses string-based status internally, not the Enumeration. The AppointmentStatus Enumeration is only used as a seed data reference table. + +### Resource (Aggregate Root) + +**Fields**: +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| Id | Guid | Yes | Auto-generated UUID | +| ShopId | Guid | Yes | Shop this resource belongs to | +| Name | string | Yes | Max 200 chars | +| ResourceType | string | Yes | Room/Bed/Equipment, max 50 chars | +| Capacity | int | Yes | Default 1 | +| IsActive | bool | Yes | Default true | +| CreatedAt | DateTime | Yes | UTC timestamp | + +**Behavior Methods**: +- `Activate()` - Sets IsActive = true +- `Deactivate()` - Sets IsActive = false + +### StaffSchedule (Entity, NOT Aggregate Root) + +**Fields**: +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| Id | Guid | Yes | Auto-generated UUID | +| StaffId | Guid | Yes | Staff member ID | +| ShopId | Guid | Yes | Shop ID | +| DayOfWeek | int | Yes | 0=Sunday, 6=Saturday | +| StartTime | TimeOnly | Yes | Work start time | +| EndTime | TimeOnly | Yes | Work end time | + +### Therapist (Aggregate Root) + +**Fields**: +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| Id | Guid | Yes | Auto-generated UUID | +| ShopId | Guid | Yes | Shop this therapist belongs to | +| Name | string | Yes | Max 200 chars | +| Specialties | string[] | Yes | PostgreSQL text[] (e.g., massage, facial, nails) | +| WorkingHours | string | Yes | JSONB weekly schedule | +| IsActive | bool | Yes | Default true | +| CreatedAt | DateTime | Yes | UTC timestamp | +| UpdatedAt | DateTime? | No | Last update timestamp | + +**Behavior Methods**: +- `Activate()` - Sets IsActive = true, raises error if already active +- `Deactivate()` - Sets IsActive = false, raises error if already inactive +- `UpdateSpecialties(specialties)` - Updates specialties, raises `TherapistUpdatedDomainEvent` +- `UpdateWorkingHours(workingHours)` - Updates working hours JSON, raises `TherapistUpdatedDomainEvent` +- `UpdateName(name)` - Updates name, raises `TherapistUpdatedDomainEvent` +- `Update(name, specialties, workingHours)` - Bulk update, raises `TherapistUpdatedDomainEvent` + +**Domain Events**: +- `TherapistCreatedDomainEvent(Therapist)` - on creation +- `TherapistUpdatedDomainEvent(Therapist)` - on any update + +--- + +## Database Schema + +**Database**: `booking_service` (PostgreSQL) + +### Table: `appointments` +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | uuid | NOT NULL | PK, ValueGeneratedNever | +| shop_id | uuid | NOT NULL | Shop FK | +| customer_id | uuid | NULL | Customer FK | +| staff_id | uuid | NULL | Staff FK | +| resource_id | uuid | NULL | Resource FK | +| service_id | uuid | NOT NULL | Service FK | +| start_time | timestamp with time zone | NOT NULL | | +| end_time | timestamp with time zone | NOT NULL | | +| status | varchar(50) | NOT NULL | | +| notes | varchar(1000) | NULL | Added in PhaseTwo migration | +| created_at | timestamp with time zone | NOT NULL | | + +**Indexes**: +- `ix_appointments_shop_id` on (shop_id) +- `ix_appointments_customer_id` on (customer_id) +- `ix_appointments_staff_id` on (staff_id) +- `ix_appointments_start_time` on (start_time) + +### Table: `appointment_statuses` (Reference/Seed) +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | integer | NOT NULL | PK, ValueGeneratedNever | +| name | varchar(50) | NOT NULL | Status name | + +**Seed Data**: Pending(1), Confirmed(2), InProgress(3), Completed(4), Cancelled(5), NoShow(6) + +### Table: `resources` +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | uuid | NOT NULL | PK, ValueGeneratedNever | +| shop_id | uuid | NOT NULL | Shop FK | +| name | varchar(200) | NOT NULL | | +| resource_type | varchar(50) | NOT NULL | Room/Bed/Equipment | +| capacity | integer | NOT NULL | | +| is_active | boolean | NOT NULL | | +| created_at | timestamp with time zone | NOT NULL | | + +**Indexes**: +- `ix_resources_shop_id` on (shop_id) + +### Table: `staff_schedules` +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | uuid | NOT NULL | PK, ValueGeneratedNever | +| staff_id | uuid | NOT NULL | Staff member FK | +| shop_id | uuid | NOT NULL | Shop FK | +| day_of_week | integer | NOT NULL | 0=Sunday..6=Saturday | +| start_time | time without time zone | NOT NULL | | +| end_time | time without time zone | NOT NULL | | + +**Indexes**: +- `ix_staff_schedules_staff_day` on (staff_id, day_of_week) +- `ix_staff_schedules_shop_id` on (shop_id) + +### Table: `therapists` +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | uuid | NOT NULL | PK, ValueGeneratedNever | +| shop_id | uuid | NOT NULL | Shop FK | +| name | varchar(200) | NOT NULL | | +| specialties | text[] | NOT NULL | PostgreSQL array | +| working_hours | jsonb | NOT NULL | Weekly schedule JSON | +| is_active | boolean | NOT NULL | | +| created_at | timestamp with time zone | NOT NULL | | +| updated_at | timestamp with time zone | NULL | | + +**Indexes**: +- `ix_therapists_shop_id` on (shop_id) +- `ix_therapists_shop_active` on (shop_id, is_active) + +### Migrations +1. `20260117181734_InitialCreate` - appointments, appointment_statuses (seeded), resources, staff_schedules + all indexes +2. `20260306175525_PhaseTwo` - Added `notes` column to appointments, added `therapists` table + indexes + +--- + +## Dependencies + +### NuGet Packages (API Layer) +| Package | Version | +|---------|---------| +| MediatR | 12.4.1 | +| FluentValidation | 11.11.0 | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | +| Microsoft.EntityFrameworkCore.Design | 10.0.0 | +| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.1 | +| Swashbuckle.AspNetCore | 7.2.0 | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | +| AspNetCore.HealthChecks.Redis | 8.0.1 | +| Hellang.Middleware.ProblemDetails | 6.5.1 | +| Serilog.AspNetCore | 8.0.3 | +| Serilog.Sinks.Console | 6.0.0 | +| Serilog.Sinks.Seq | 8.0.0 | + +### NuGet Packages (Domain Layer) +| Package | Version | +|---------|---------| +| MediatR.Contracts | 2.0.1 | + +### NuGet Packages (Infrastructure Layer) +| Package | Version | +|---------|---------| +| Microsoft.EntityFrameworkCore | 10.0.0 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.0 | +| MediatR | 12.4.1 | +| Dapper | 2.1.35 | +| Microsoft.Extensions.Http.Polly | 9.0.0 | +| Polly | 8.5.0 | +| StackExchange.Redis | 2.8.16 | + +### NuGet Packages (Functional Tests) +| Package | Version | +|---------|---------| +| Microsoft.NET.Test.Sdk | 17.12.0 | +| xunit | 2.9.2 | +| Microsoft.AspNetCore.Mvc.Testing | 10.0.0 | +| Microsoft.EntityFrameworkCore.InMemory | 10.0.0 | +| FluentAssertions | 6.12.2 | +| Testcontainers.PostgreSql | 4.1.0 | +| coverlet.collector | 6.0.2 | + +### Service Dependencies +- **IAM Service** (port 5001): JWT token validation via OIDC discovery +- **PostgreSQL**: `booking_service` database (Neon cloud for staging/prod, local for dev) +- **Redis**: Configured but not actively used in current code (connection string in appsettings) + +--- + +## Configuration + +### appsettings.json +```json +{ + "ConnectionStrings": { + "DefaultConnection": "" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "...", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "Serilog": { ... } +} +``` + +### Environment Variables +- `ASPNETCORE_ENVIRONMENT` - Development/Staging/Production +- `DATABASE_URL` - Fallback connection string +- `Jwt:Authority` - IAM IdentityServer URL (default: `http://localhost:5001`) + +### MediatR Pipeline +``` +Request --> LoggingBehavior --> ValidatorBehavior --> TransactionBehavior --> Handler +``` +- **LoggingBehavior**: Logs request name + elapsed time (Stopwatch) +- **ValidatorBehavior**: Runs FluentValidation, throws `ValidationException` on failure +- **TransactionBehavior**: Wraps Commands in DB transaction (skips Queries by name suffix check), uses `ExecutionStrategy` for retry + +### DI Registrations (Infrastructure) +- `BookingContext` (DbContext + IUnitOfWork) +- `IAppointmentRepository` -> `AppointmentRepository` +- `IResourceRepository` -> `ResourceRepository` +- `IStaffScheduleRepository` -> `StaffScheduleRepository` +- `ITherapistRepository` -> `TherapistRepository` +- `IRequestManager` -> `RequestManager` + +### Startup Behavior +- Auto-applies EF Core migrations on startup (catches and logs errors without crashing) +- Swagger UI available at `/swagger` in Development + +--- + +## Tests + +### Functional Tests (`tests/BookingService.FunctionalTests/`) +- **CustomWebApplicationFactory**: Swaps PostgreSQL DbContext for InMemoryDatabase +- **ResourcesControllerTests**: 2 tests + - `GetResources_ShouldReturnOk` - GET resources with random shopId returns 200 + - `HealthCheck_ShouldReturnHealthy` - GET /health/live returns 200 + +### Test Gaps +- No unit tests exist (no `BookingService.UnitTests` project) +- No tests for appointment CRUD operations +- No tests for slot-finding algorithm +- No tests for therapist operations +- No tests for domain entity behavior methods +- No validator tests + +--- + +## Known Issues / Observations + +1. **UpdateResourceCommand handler bug**: The handler receives `Name` and `Capacity` fields but only uses `IsActive` to toggle Activate/Deactivate. Name and Capacity values are ignored. + +2. **IAppointmentRepository in wrong layer**: `IAppointmentRepository` is defined in `BookingService.Infrastructure.Repositories` instead of `BookingService.Domain.AggregatesModel.AppointmentAggregate`. Same for `IResourceRepository` and `IStaffScheduleRepository`. Only `ITherapistRepository` correctly resides in the Domain layer. This violates Clean Architecture (Domain should define interfaces, Infrastructure implements them). + +3. **String-based status instead of Enumeration**: The `Appointment` entity uses raw strings for status ("Pending", "Confirmed", etc.) rather than the `AppointmentStatus` Enumeration class. The Enumeration exists only as a seed data reference table. + +4. **No [Authorize] on public endpoints**: Most controllers (Appointments, Resources, Schedules, Slots, StaffSchedules, Therapists) lack `[Authorize]` attributes. Only Admin controllers require authorization. + +5. **SchedulesController bypasses CQRS**: The `CreateSchedule` and `DeleteSchedule` actions directly use `IStaffScheduleRepository` instead of dispatching commands through MediatR. + +6. **No DomainEvents on Resource**: Resource entity has no domain events (unlike Appointment and Therapist). + +7. **Redis configured but unused**: Redis connection string is in appsettings but no caching logic exists. + +8. **Idempotency infrastructure exists but is unused**: `ClientRequest`, `IRequestManager`, `RequestManager` are registered but never called from any command handler. + +9. **Dapper registered but unused**: Dapper is in the Infrastructure csproj but no Dapper queries exist. + +10. **Missing `.AsNoTracking()` on read queries**: Query handlers directly use `BookingContext` but don't call `.AsNoTracking()` for read-only operations, which could impact performance. diff --git a/services/catalog-service-net/SERVICE_DOCS.md b/services/catalog-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..5fcd2f46 --- /dev/null +++ b/services/catalog-service-net/SERVICE_DOCS.md @@ -0,0 +1,403 @@ +# CatalogService - Service Documentation + +> Auto-generated from source code audit on 2026-03-13. + +## Overview + +**CatalogService** is a product catalog microservice supporting multi-vertical (Retail, F&B, Spa/Salon) polymorphic products and hierarchical categories. It provides CRUD operations for products and categories, scoped per shop (multi-tenant). Products use a type-discriminator pattern (`ProductType` DDD Enumeration) with JSONB `attributes` for type-specific data. + +- **Port**: 5016 (Development) +- **Database**: PostgreSQL (`catalog_service` on Neon) +- **Architecture**: Clean Architecture + CQRS (MediatR) +- **Multi-tenancy**: EF Core global query filters (shop-level) + PostgreSQL RLS session variables +- **Auth**: JWT Bearer via IAM IdentityServer OIDC discovery +- **API Version**: v1 (URL segment + `X-Api-Version` header) + +--- + +## API Endpoints + +### Products (`ProductsController`) + +| Method | Route | Description | Auth | Request | Response | +|--------|-------|-------------|------|---------|----------| +| GET | `/api/v1/products?shopId=&isActive=&type=&categoryId=&page=&pageSize=` | Get products with filtering + pagination | No | Query params | `PagedResult` | +| GET | `/api/v1/shops/{shopId}/products` | Get shop products (RESTful alias) | No | Path + query params | `PagedResult` | +| GET | `/api/v1/products/lookup?shopId=&barcode=` | Lookup product by barcode/SKU (POS scanner) | No | Query params | `{ success, data: ProductDto }` or 404 | +| GET | `/api/v1/products/{id}` | Get product by ID | No | Path param | `ProductDto` or 404 | +| POST | `/api/v1/products` | Create product | No | `CreateProductCommand` body | 201 + `Guid` | +| PUT | `/api/v1/products/{id}` | Update product | No | `UpdateProductCommand` body | 204 | +| DELETE | `/api/v1/products/{id}` | Soft-delete (deactivate) product | No | Path param | 204 | + +### Categories (`CategoriesController`) + +| Method | Route | Description | Auth | Request | Response | +|--------|-------|-------------|------|---------|----------| +| GET | `/api/v1/categories?shopId=&parentId=` | Get categories (hierarchical) | No | Query params | `List` | +| GET | `/api/v1/shops/{shopId}/categories` | Get shop categories (RESTful alias) | No | Path + query params | `List` | +| POST | `/api/v1/categories` | Create category | No | `CreateCategoryCommand` body | 201 + `Guid` | +| PUT | `/api/v1/categories/{categoryId}` | Update category | No | `UpdateCategoryCommand` body | 200 or 404 | +| DELETE | `/api/v1/categories/{categoryId}` | Soft-delete (deactivate) category | No | Path param | 200 or 404 | + +### Health Checks + +| Route | Description | +|-------|-------------| +| `/health` | Full health check (includes PostgreSQL) | +| `/health/live` | Liveness probe (app running) | +| `/health/ready` | Readiness probe (PostgreSQL reachable) | + +--- + +## Commands + +### CreateProductCommand +- **Input**: `ShopId` (Guid), `Name` (string), `Description` (string?), `Price` (decimal), `Type` (string: Physical/Service/PreparedFood), `Attributes` (Dictionary?), `Sku` (string?), `ImageUrl` (string?), `CategoryId` (Guid?) +- **Returns**: `Guid` (new product ID) +- **Logic**: Resolves `ProductType` from Enumeration, converts attributes dict to `JsonDocument`, creates `Product` aggregate, optionally sets image, saves via repository + UnitOfWork +- **Domain Event**: `ProductCreatedDomainEvent` raised in constructor +- **Validator**: `CreateProductCommandValidator` + - ShopId: NotEmpty + - Name: NotEmpty, MaxLength(200) + - Description: MaxLength(2000) when not null + - Price: >= 0 + - Type: NotEmpty, must be Physical/Service/PreparedFood + - Sku: MaxLength(100) when not null + - ImageUrl: MaxLength(2048) when not null + +### UpdateProductCommand +- **Input**: `ProductId` (Guid), `Name` (string), `Description` (string?), `Price` (decimal), `Attributes` (Dictionary?), `ImageUrl` (string?), `CategoryId` (Guid?) +- **Returns**: `bool` +- **Logic**: Loads product by ID (throws `DomainException` if not found), calls `UpdateInfo()`, `SetCategory()`, optionally `UpdateAttributes()` and `UpdateImage()`, saves via repository + UnitOfWork +- **Validator**: `UpdateProductCommandValidator` + - ProductId: NotEmpty + - Name: NotEmpty, MaxLength(200) + - Description: MaxLength(2000) when not null + - Price: >= 0 + - ImageUrl: MaxLength(2048) when not null + +### DeleteProductCommand +- **Input**: `ProductId` (Guid) +- **Returns**: `bool` +- **Logic**: Loads product by ID (throws `DomainException` if not found), calls `Deactivate()` (soft delete), saves via repository + UnitOfWork +- **Validator**: `DeleteProductCommandValidator` - ProductId: NotEmpty + +### CreateCategoryCommand +- **Input**: `ShopId` (Guid), `Name` (string), `Description` (string?), `ParentId` (Guid?), `DisplayOrder` (int), `ImageUrl` (string?) +- **Returns**: `Guid` (new category ID) +- **Logic**: Creates `Category` entity, optionally sets image, saves directly via `CatalogContext` +- **Validator**: `CreateCategoryCommandValidator` + - ShopId: NotEmpty + - Name: NotEmpty, MaxLength(200) + - Description: MaxLength(1000) when not null + - DisplayOrder: >= 0 + - ImageUrl: MaxLength(2048) when not null + +### UpdateCategoryCommand +- **Input**: `CategoryId` (Guid), `Name` (string), `Description` (string?), `DisplayOrder` (int), `ImageUrl` (string?) +- **Returns**: `bool` +- **Logic**: Loads category from context, calls `UpdateInfo()`, optionally `UpdateImage()`, saves via context +- **Validator**: `UpdateCategoryCommandValidator` + - CategoryId: NotEmpty + - Name: NotEmpty, MaxLength(200) + - Description: MaxLength(1000) when not null + - DisplayOrder: >= 0 + - ImageUrl: MaxLength(2048) when not null + +### DeleteCategoryCommand +- **Input**: `CategoryId` (Guid) +- **Returns**: `bool` +- **Logic**: Loads category from context, calls `Deactivate()` (soft delete), saves via context +- **Validator**: `DeleteCategoryCommandValidator` - CategoryId: NotEmpty + +--- + +## Queries + +### GetProductsQuery +- **Input**: `ShopId` (Guid), `IsActive` (bool?), `Type` (string?), `CategoryId` (Guid?), `Page` (int, default 1), `PageSize` (int, default 20) +- **Returns**: `PagedResult` +- **Logic**: Filters by ShopId (required), then optionally by IsActive, TypeId (resolved from Enumeration name), CategoryId. Orders by Name. Resolves type name from `Enumeration.GetAll()` and category name from batch lookup. Server-side pagination via Skip/Take. + +### GetProductByIdQuery +- **Input**: `ProductId` (Guid) +- **Returns**: `ProductDto?` (null if not found) +- **Logic**: Finds product by ID, resolves type name from Enumeration, resolves category name if CategoryId exists. + +### GetProductByBarcodeQuery +- **Input**: `ShopId` (Guid), `Barcode` (string) +- **Returns**: `ProductDto?` (null if not found) +- **Logic**: Uses `IProductRepository.GetByBarcodeOrSkuAsync()` to find active product matching barcode or SKU within shop. Resolves type name from Enumeration. + +### GetCategoriesQuery +- **Input**: `ShopId` (Guid), `ParentId` (Guid?) +- **Returns**: `List` +- **Logic**: Filters by ShopId. If ParentId provided, returns children; otherwise returns root categories (ParentId == null). Orders by DisplayOrder then Name. + +--- + +## Domain Model + +### Product (Aggregate Root) + +**Entity**: `CatalogService.Domain.AggregatesModel.ProductAggregate.Product` extends `Entity`, implements `IAggregateRoot` + +**Private Fields / Public Getters**: +| Field | Type | Description | +|-------|------|-------------| +| `_shopId` / `ShopId` | `Guid` | Owning shop ID | +| `_name` / `Name` | `string` | Product name | +| `_description` / `Description` | `string?` | Product description | +| `_price` / `Price` | `decimal` | Product price | +| `TypeId` | `int` | ProductType enumeration ID (private set) | +| `_attributes` / `Attributes` | `JsonDocument?` | Type-specific attributes (JSONB) | +| `_imageUrl` / `ImageUrl` | `string?` | Image URL | +| `_sku` / `Sku` | `string?` | Stock Keeping Unit | +| `_barcode` / `Barcode` | `string?` | Barcode (EAN-13, UPC) for POS scanner | +| `_categoryId` / `CategoryId` | `Guid?` | Category FK | +| `_isActive` / `IsActive` | `bool` | Active status (default true) | +| `_createdAt` / `CreatedAt` | `DateTime` | Creation timestamp (UTC) | +| `_updatedAt` / `UpdatedAt` | `DateTime?` | Last update timestamp (UTC) | + +**Constructor Validation**: ShopId not empty, Name not blank, Price >= 0, Type not null. Raises `ProductCreatedDomainEvent`. + +**Behavior Methods**: +- `UpdateInfo(name, description, price)` - validates name/price +- `UpdateAttributes(JsonDocument?)` - replaces JSONB attributes +- `UpdateImage(string?)` - sets image URL +- `SetCategory(Guid?)` - sets category reference +- `UpdateBarcode(string?)` - sets barcode value +- `UpdateSku(string?)` - sets SKU value +- `Activate()` - throws if already active +- `Deactivate()` - throws if already inactive + +### Category (Entity, not Aggregate Root) + +**Entity**: `CatalogService.Domain.AggregatesModel.ProductAggregate.Category` extends `Entity` + +**Private Fields / Public Getters**: +| Field | Type | Description | +|-------|------|-------------| +| `_shopId` / `ShopId` | `Guid` | Owning shop ID | +| `_name` / `Name` | `string` | Category name | +| `_description` / `Description` | `string?` | Description | +| `_parentId` / `ParentId` | `Guid?` | Parent category (hierarchical) | +| `_displayOrder` / `DisplayOrder` | `int` | Sort order | +| `_imageUrl` / `ImageUrl` | `string?` | Image URL | +| `_isActive` / `IsActive` | `bool` | Active status (default true) | +| `_createdAt` / `CreatedAt` | `DateTime` | Creation timestamp (UTC) | +| `_updatedAt` / `UpdatedAt` | `DateTime?` | Last update timestamp (UTC) | + +**Constructor Validation**: ShopId not empty, Name not blank. + +**Behavior Methods**: +- `UpdateInfo(name, description, displayOrder)` - validates name +- `UpdateImage(string?)` - sets image URL +- `UpdateParent(Guid?)` - throws if self-referencing +- `Activate()` / `Deactivate()` - toggle active state + +### ProductType (DDD Enumeration) + +Type-safe enum resolved in-memory (not via EF Core navigation): +| Id | Name | Description | +|----|------|-------------| +| 1 | Physical | Retail products requiring inventory | +| 2 | Service | Spa/Salon services requiring booking | +| 3 | PreparedFood | F&B products requiring kitchen | + +### Domain Events + +| Event | Trigger | +|-------|---------| +| `ProductCreatedDomainEvent(Product)` | Product constructor | + +--- + +## Database Schema + +### Table: `products` + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| `id` | uuid | NO | - | PK, app-generated | +| `shop_id` | uuid | NO | - | Owning shop | +| `name` | varchar(255) | NO | - | Product name | +| `description` | varchar(2000) | YES | - | Description | +| `price` | decimal(18,2) | NO | - | Price | +| `type_id` | integer | NO | - | ProductType enum ID | +| `attributes` | jsonb | YES | - | Type-specific attributes | +| `image_url` | varchar(500) | YES | - | Image URL | +| `sku` | varchar(100) | YES | - | Stock Keeping Unit | +| `barcode` | varchar(100) | YES | - | Barcode/EAN-13/UPC | +| `category_id` | uuid | YES | - | Category FK (no DB constraint) | +| `is_active` | boolean | NO | true | Soft delete flag | +| `created_at` | timestamptz | NO | - | Creation time | +| `updated_at` | timestamptz | YES | - | Last update time | + +**Indexes**: +- `ix_products_shop_id` (shop_id) +- `ix_products_type_id` (type_id) +- `ix_products_sku` (sku) +- `ix_products_barcode` (barcode) +- `ix_products_is_active` (is_active) +- `ix_products_category_id` (category_id) + +### Table: `categories` + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| `id` | uuid | NO | - | PK, app-generated | +| `shop_id` | uuid | NO | - | Owning shop | +| `name` | varchar(200) | NO | - | Category name | +| `description` | varchar(1000) | YES | - | Description | +| `parent_id` | uuid | YES | - | Parent category (self-ref) | +| `display_order` | integer | NO | 0 | Sort order | +| `image_url` | varchar(500) | YES | - | Image URL | +| `is_active` | boolean | NO | true | Soft delete flag | +| `created_at` | timestamptz | NO | - | Creation time | +| `updated_at` | timestamptz | YES | - | Last update time | + +**Indexes**: +- `ix_categories_shop_id` (shop_id) +- `ix_categories_parent_id` (parent_id) +- `ix_categories_display_order` (display_order) + +### Table: `product_types` (seed-only, dropped in PhaseTwo migration) + +Initially created with seed data (Physical=1, Service=2, PreparedFood=3) in `InitialCatalog` migration. Dropped in `PhaseTwo` migration — type is now resolved in-memory via `Enumeration` pattern. The `type_id` column in `products` remains as a plain integer (no FK constraint). + +### Migrations + +| Migration | Date | Changes | +|-----------|------|---------| +| `20260117173734_InitialCatalog` | 2026-01-17 | Create `products`, `categories`, `product_types` tables with indexes and seed data | +| `20260306175523_PhaseTwo` | 2026-03-06 | Drop `product_types` table, add `barcode` + `category_id` to products, add `image_url` to categories, add indexes | + +--- + +## Multi-Tenancy + +The service implements shop-level tenant isolation via two layers: + +1. **EF Core Global Query Filters**: Both `Product` and `Category` entities have query filters that restrict results to the current shop, extracted from JWT claims (`shop_id` claim) or `X-Shop-Id` header. Bypassed for admin/system roles and service-to-service calls (`X-Service-Call: internal`). + +2. **PostgreSQL RLS Session Variables**: `TenantMiddleware` sets `SET LOCAL app.current_shop_id` and `app.current_merchant_id` on the Npgsql connection for defense-in-depth (requires RLS policies configured at DB level). + +**Tenant Provider Chain**: `HttpContextTenantProvider` (API layer) -> `CatalogTenantProviderAdapter` -> `ICatalogTenantProvider` (Infrastructure layer, consumed by `CatalogContext`). + +--- + +## MediatR Pipeline + +Request flow through behaviors (in order): +1. **LoggingBehavior** - Logs request name, elapsed time, errors (Stopwatch) +2. **ValidatorBehavior** - Runs all FluentValidation validators, throws `ValidationException` on failure +3. **TransactionBehavior** - Wraps Commands in DB transaction (skips Queries by name suffix `"Query"`), uses `ExecutionStrategy` for retry-on-failure + +--- + +## Repository + +### IProductRepository + +| Method | Returns | Description | +|--------|---------|-------------| +| `Add(Product)` | `Product` | Add new product | +| `Update(Product)` | `void` | Mark product as modified | +| `GetByIdAsync(Guid, CancellationToken)` | `Product?` | Get by ID | +| `GetByShopIdAsync(Guid, CancellationToken)` | `IEnumerable` | Get all by shop | +| `GetByTypeAsync(Guid, ProductType, CancellationToken)` | `IEnumerable` | Get by shop + type | +| `GetByBarcodeOrSkuAsync(Guid, string, CancellationToken)` | `Product?` | Lookup active product by barcode or SKU in shop | + +**Implementation**: `ProductRepository` uses `CatalogContext`. UnitOfWork exposed via context. + +**Note**: Category CRUD handlers use `CatalogContext` directly (no separate `ICategoryRepository`). + +--- + +## DTOs + +### ProductDto +`Id`, `ShopId`, `Name`, `Description`, `Price`, `Type` (resolved name), `Attributes` (Dictionary), `ImageUrl`, `Sku`, `Barcode`, `CategoryId`, `CategoryName` (resolved), `IsActive`, `CreatedAt`, `UpdatedAt` + +### CategoryDto +`Id`, `ShopId`, `Name`, `Description`, `ParentId`, `DisplayOrder`, `ImageUrl`, `IsActive`, `CreatedAt`, `UpdatedAt` + +### PagedResult\ +`Items` (IReadOnlyList\), `TotalCount`, `Page`, `PageSize`, `TotalPages` (computed), `HasPrevious` (computed), `HasNext` (computed) + +--- + +## Dependencies (NuGet) + +| Package | Version | Purpose | +|---------|---------|---------| +| MediatR | 12.4.1 | CQRS pipeline | +| FluentValidation | 11.11.0 | Command validation | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | Auto-registration | +| Microsoft.EntityFrameworkCore.Design | 10.0.0 | EF migrations tooling | +| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.1 | JWT auth | +| Swashbuckle.AspNetCore | 7.2.0 | Swagger/OpenAPI | +| Asp.Versioning.Mvc | 8.1.0 | API versioning | +| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 | Version discovery | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | PostgreSQL health check | +| AspNetCore.HealthChecks.Redis | 8.0.1 | Redis health check | +| Hellang.Middleware.ProblemDetails | 6.5.1 | RFC 7807 errors | +| Serilog.AspNetCore | 8.0.3 | Structured logging | +| Serilog.Sinks.Console | 6.0.0 | Console output | +| Serilog.Sinks.Seq | 8.0.0 | Seq sink | + +**Project References**: CatalogService.Domain, CatalogService.Infrastructure + +--- + +## Configuration + +### appsettings.json + +| Key | Value/Description | +|-----|-------------------| +| `ConnectionStrings:DefaultConnection` | Neon PostgreSQL connection string | +| `Redis:ConnectionString` | `localhost:6379` | +| `Jwt:Authority` | IAM IdentityServer URL (default `http://localhost:5001`) | +| `Jwt:Secret` | JWT signing key | +| `Jwt:Issuer` | `goodgo-platform` | +| `Jwt:Audience` | `goodgo-services` | +| `Jwt:AccessTokenExpiryMinutes` | 15 | +| `Jwt:RefreshTokenExpiryDays` | 7 | +| `Serilog` | Console output with structured template | + +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `DATABASE_URL` | Fallback connection string (overrides `DefaultConnection`) | +| `ASPNETCORE_ENVIRONMENT` | `Development` enables Swagger, sensitive data logging | + +--- + +## Tests + +### Unit Tests (`tests/CatalogService.UnitTests/`) +- `Domain/ProductAggregateTests.cs` - Tests for Product entity behavior + +### Functional Tests (`tests/CatalogService.FunctionalTests/`) +- `CustomWebApplicationFactory.cs` - WebApplicationFactory with InMemory DB +- `Controllers/ProductsControllerTests.cs` - API endpoint tests + +--- + +## Idempotency + +`IRequestManager` / `RequestManager` provides duplicate request detection via `ClientRequest` entity (Id, Name, Time). Registered in DI but not currently wired into any command handler. + +--- + +## Notes & Observations + +1. **No [Authorize] attributes** on any controller endpoint. Authentication middleware is registered but authorization is not enforced at the controller level. Tenant filtering via global query filters provides shop-level isolation. +2. **Category handlers bypass repository pattern** — they use `CatalogContext` directly instead of through an `ICategoryRepository`. +3. **ProductType table dropped** in PhaseTwo migration. The `type_id` column has no FK constraint; type resolution is done in-memory via `Enumeration.GetAll()`. +4. **JSONB attributes** use a custom `ValueConverter` + `ValueComparer` for EF Core change tracking on `JsonDocument`. +5. **Auto-migration on startup** — `dbContext.Database.MigrateAsync()` runs at app start with error swallowing. +6. **Sample aggregate** (`SampleAggregate/`) exists from the template but is not used by any controller, command, or query. diff --git a/services/chat-service-net/SERVICE_DOCS.md b/services/chat-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..c516757b --- /dev/null +++ b/services/chat-service-net/SERVICE_DOCS.md @@ -0,0 +1,521 @@ +# ChatService - Service Documentation + +> Auto-generated from source code audit on 2026-03-13 + +## Overview + +**ChatService** is a real-time chat microservice providing end-to-end encrypted (E2EE) messaging with SignalR WebSocket support and AI integration (OpenAI). It implements the X3DH key exchange protocol and supports both direct (1:1) and group conversations. + +- **Framework**: .NET 10.0 (C# 14) +- **Architecture**: Clean Architecture + CQRS (MediatR 12.4.1) +- **Database**: PostgreSQL (Neon cloud / local Docker) +- **Real-time**: SignalR with Redis backplane + MessagePack protocol +- **AI**: OpenAI GPT-4 streaming integration (optional) +- **Port**: 5010 (development) +- **Database**: `chat_service` +- **Health Checks**: `/health`, `/health/live`, `/health/ready` + +--- + +## API Endpoints + +### ConversationsController (`api/[controller]`) - [Authorize] + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| POST | `api/Conversations` | Create a new conversation | `CreateConversationRequest` | `CreateConversationResult` | +| GET | `api/Conversations` | Get user's conversations (paginated) | `?userId={guid}&page=1&pageSize=20` | `GetConversationsResult` | +| GET | `api/Conversations/{conversationId}` | Get a specific conversation | `?userId={guid}` | `ConversationDto` | + +### MessagesController (`api/[controller]`) - [Authorize] + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| POST | `api/Messages` | Send an encrypted message | `SendMessageRequest` | `SendMessageResult` | +| GET | `api/Messages/conversation/{conversationId}` | Get messages (paginated) | `?userId={guid}&page=1&pageSize=50&before={datetime}` | `GetMessagesResult` | +| POST | `api/Messages/read` | Mark messages as read | `MarkReadRequest` | `MarkMessagesReadResult` | + +### KeysController (`api/[controller]`) - [Authorize] + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| POST | `api/Keys/register` | Register/update E2EE key bundle | `RegisterUserKeysRequest` | `RegisterUserKeysResult` | +| POST | `api/Keys/rotate` | Rotate signed pre-key | `RotatePreKeyRequest` | `RotatePreKeyResult` | +| POST | `api/Keys/prekeys` | Upload one-time pre-keys | `UploadPreKeysRequest` | `UploadOneTimeKeysResult` | +| GET | `api/Keys/bundle/{userId}` | Get user's key bundle for E2EE session | - | `UserKeyBundleDto` | +| GET | `api/Keys/my-bundle` | Get current user's key bundle status | - | `MyKeyBundleDto` | + +### SignalR Hub (`/hubs/chat`) - [Authorize] + +| Method | Direction | Description | +|--------|-----------|-------------| +| `JoinRoom(Guid roomId)` | Client -> Server | Join a conversation room | +| `LeaveRoom(Guid roomId)` | Client -> Server | Leave a conversation room | +| `SendMessage(Guid roomId, string content, string? messageType)` | Client -> Server | Send message to room | +| `SendTypingIndicator(Guid roomId, bool isTyping)` | Client -> Server | Send typing indicator | +| `MarkMessageRead(Guid roomId, Guid messageId)` | Client -> Server | Mark message as read | +| `StreamAIResponse(string prompt, Guid roomId)` | Client -> Server (streaming) | Stream AI response to caller | +| `ReceiveMessage(MessageNotification)` | Server -> Client | Receive new message | +| `UserJoined(userId, userName, roomId)` | Server -> Client | User joined room notification | +| `UserLeft(userId, roomId)` | Server -> Client | User left room notification | +| `ReceiveAIChunk(chunk, messageId)` | Server -> Client | Receive AI streaming chunk | +| `AIResponseComplete(messageId, fullResponse)` | Server -> Client | AI response complete notification | +| `TypingIndicator(userId, userName, roomId, isTyping)` | Server -> Client | Typing indicator | +| `MessageRead(userId, messageId, roomId)` | Server -> Client | Read receipt | +| `UserStatusChanged(userId, isOnline, lastSeen)` | Server -> Client | Online/offline status change | + +**AI Trigger**: Messages starting with `@gpt ` auto-trigger AI streaming response to the room. + +**Connection Lifecycle**: On connect, user auto-joins up to 100 conversation rooms. On disconnect (last connection), broadcasts offline status. + +--- + +## Commands + +### CreateConversationCommand +- **Input**: `CreatorId (Guid)`, `ParticipantIds (IEnumerable)`, `Name? (string)`, `AvatarUrl? (string)`, `IsGroup (bool)` +- **Logic**: Validates all participants exist as ChatUsers. For direct (1:1), checks for existing conversation and returns it if found. For group, creator becomes admin. Saves via UnitOfWork. +- **Output**: `CreateConversationResult { ConversationId, Type, Name, Participants, CreatedAt }` +- **Domain Events**: `ConversationCreatedDomainEvent` +- **Validator**: `CreateConversationCommandValidator` -- CreatorId required; ParticipantIds not empty + unique; group requires name (max 100 chars) + >=1 participant; direct requires exactly 1 participant; AvatarUrl max 500 chars + +### SendMessageCommand +- **Input**: `ConversationId (Guid)`, `SenderId (Guid)`, `EncryptedContent (string)`, `Nonce (string)`, `AuthTag? (string)`, `MessageType (string, default "text")`, `Metadata? (string)`, `ReplyToMessageId? (Guid)` +- **Logic**: Loads conversation, parses MessageType from name, calls `conversation.SendMessage()` which validates sender is participant. Updates conversation's LastMessageId/LastMessageAt. +- **Output**: `SendMessageResult { MessageId, ConversationId, SenderId, Status, CreatedAt }` +- **Domain Events**: `MessageSentDomainEvent` +- **Validator**: `SendMessageCommandValidator` -- ConversationId + SenderId required; EncryptedContent required (max 100000 chars); Nonce required (max 100); AuthTag max 100; MessageType must be one of: text, image, video, audio, file, location, contact, sticker, system; Metadata max 10000 + +### MarkMessagesReadCommand +- **Input**: `ConversationId (Guid)`, `UserId (Guid)`, `LastReadMessageId? (Guid)`, `ReadUpTo? (DateTime)` +- **Logic**: Loads conversation with messages. Finds participant. Marks read by: (1) specific message ID, (2) timestamp, or (3) all messages. Updates participant's LastReadMessageId and LastReadAt. +- **Output**: `MarkMessagesReadResult { MessagesMarked, LastReadAt }` +- **Validator**: `MarkMessagesReadCommandValidator` -- ConversationId + UserId required; all mark scenarios valid + +### RegisterUserKeysCommand +- **Input**: `IdentityUserId (string)`, `DisplayName (string)`, `AvatarUrl? (string)`, `IdentityPublicKey (string)`, `SignedPreKey (string)`, `SignedPreKeySignature (string)`, `OneTimePreKeys? (IEnumerable)` +- **Logic**: Checks if ChatUser exists by IdentityUserId. If exists, updates key bundle and uploads pre-keys. If not, creates new ChatUser, registers key bundle, uploads pre-keys. +- **Output**: `RegisterUserKeysResult { ChatUserId, OneTimePreKeysUploaded }` +- **Domain Events**: `ChatUserCreatedDomainEvent` (new user), `UserKeyBundleUpdatedDomainEvent` (key registration) +- **Validator**: `RegisterUserKeysCommandValidator` -- IdentityUserId required; DisplayName required (max 255); IdentityPublicKey, SignedPreKey, SignedPreKeySignature required + valid Base64; OneTimePreKeys: KeyId >= 0, PublicKey required + valid Base64 + +### RotatePreKeyCommand +- **Input**: `ChatUserId (Guid)`, `NewSignedPreKey (string)`, `NewSignedPreKeySignature (string)` +- **Logic**: Loads ChatUser, calls `user.RotateSignedPreKey()` which creates a new UserKeyBundle with updated SignedPreKey/Signature and current timestamp. +- **Output**: `RotatePreKeyResult { RotatedAt }` +- **Domain Events**: `UserKeyBundleUpdatedDomainEvent` +- **Validator**: `RotatePreKeyCommandValidator` -- ChatUserId required; NewSignedPreKey + NewSignedPreKeySignature required + valid Base64 + +### UploadOneTimeKeysCommand +- **Input**: `ChatUserId (Guid)`, `OneTimePreKeys (IEnumerable)` +- **Logic**: Loads ChatUser with keys, calls `user.UploadOneTimePreKeys()` which adds non-duplicate keys. +- **Output**: `UploadOneTimeKeysResult { KeysUploaded, TotalAvailableKeys }` +- **Validator**: `UploadOneTimeKeysCommandValidator` -- ChatUserId required; at least 1 pre-key required; max 100 pre-keys per upload; each key: KeyId >= 0, PublicKey required + valid Base64 + +--- + +## Queries + +### GetConversationsQuery +- **Input**: `UserId (Guid)`, `Page (int, default 1)`, `PageSize (int, default 20)` +- **Logic**: Gets conversations where user is active participant, ordered by LastMessageAt desc. For each conversation, fetches participant user info, unread count, and last message details. +- **Output**: `GetConversationsResult { Conversations (ConversationDto[]), TotalCount, Page, PageSize }` + +### GetMessagesQuery +- **Input**: `ConversationId (Guid)`, `UserId (Guid)`, `Page (int, default 1)`, `PageSize (int, default 50)`, `Before? (DateTime)` +- **Logic**: Verifies user is participant (throws UnauthorizedAccessException if not). Fetches messages paginated with sender info. +- **Output**: `GetMessagesResult { Messages (MessageDto[]), TotalCount, Page, PageSize, HasMore }` + +### GetUserKeyBundleQuery +- **Input**: `TargetUserId (Guid)` +- **Logic**: Gets key bundle for target user and **consumes** one one-time pre-key (marks as used). Saves the consumed state. +- **Output**: `UserKeyBundleDto { UserId, IdentityPublicKey, SignedPreKey, SignedPreKeySignature, SignedPreKeyTimestamp, OneTimePreKey? }` or null + +### GetMyKeyBundleQuery +- **Input**: `IdentityUserId (string)` +- **Logic**: Looks up ChatUser by identity user ID. Returns key bundle status including rotation needs check (>30 days). +- **Output**: `MyKeyBundleDto { ChatUserId, HasKeyBundle, SignedPreKeyTimestamp, NeedsKeyRotation, AvailableOneTimeKeys }` or null + +--- + +## Domain Model + +### Conversation (Aggregate Root) + +**Table**: `conversations` + +**Fields**: +- `Id (Guid)` - PK +- `_typeId (int)` - FK to conversation_types +- `_type (ConversationType)` - Direct (1) or Group (2) +- `_name (string?)` - Conversation name (required for group) +- `_avatarUrl (string?)` - Group avatar URL +- `_participants (List)` - Participants collection +- `_messages (List)` - Messages collection +- `_lastMessageId (Guid?)` - Last message ID +- `_lastMessageAt (DateTime?)` - Last message timestamp +- `_createdAt (DateTime)` - Created timestamp +- `_updatedAt (DateTime)` - Updated timestamp + +**Factory Methods**: +- `CreateDirect(Guid user1Id, Guid user2Id)` - Creates 1:1 conversation, raises ConversationCreatedDomainEvent +- `CreateGroup(string name, Guid creatorId, IEnumerable participantIds, string? avatarUrl)` - Creates group chat, creator is admin, raises ConversationCreatedDomainEvent + +**Behavior Methods**: +- `SendMessage(senderId, encryptedContent, nonce, type?, authTag?, metadata?, replyToMessageId?)` - Validates sender is participant, creates Message, updates last message info, raises MessageSentDomainEvent +- `AddParticipant(userId, addedByUserId)` - Group only, admin-only +- `RemoveParticipant(userId, removedByUserId)` - Group only, self or admin-only +- `UpdateName(name, updatedByUserId)` - Group only, admin-only +- `UpdateAvatar(avatarUrl, updatedByUserId)` - Group only, admin-only +- `GetActiveParticipantCount()` - Count active participants +- `IsParticipant(userId)` - Check membership +- `GetOtherParticipant(userId)` - Get other user in direct conversation + +**Domain Events**: `ConversationCreatedDomainEvent`, `MessageSentDomainEvent` + +### ConversationParticipant (Entity) + +**Table**: `conversation_participants` + +**Fields**: +- `Id (Guid)` - PK +- `ConversationId (Guid)` - FK to conversations +- `UserId (Guid)` - FK reference to chat_users +- `JoinedAt (DateTime)` - Join timestamp +- `LastReadAt (DateTime?)` - Last read timestamp +- `LastReadMessageId (Guid?)` - Last read message ID +- `IsAdmin (bool)` - Admin flag (group chats) +- `IsMuted (bool)` - Mute notifications flag +- `LeftAt (DateTime?)` - Leave timestamp (null = active) + +**Methods**: `UpdateLastRead(messageId)`, `SetMuted(bool)`, `SetAdmin(bool)`, `Leave()`, `IsActive` (computed) + +### Message (Entity) + +**Table**: `messages` + +**Fields**: +- `Id (Guid)` - PK +- `_conversationId (Guid)` - FK to conversations +- `_senderId (Guid)` - Sender chat user ID +- `_encryptedContent (string)` - E2EE encrypted content (Base64, AES-256-GCM) +- `_nonce (string)` - Nonce/IV for AES-GCM (Base64) +- `_authTag (string?)` - Authentication tag (Base64) +- `_typeId (int)` - FK to message_types +- `_type (MessageType)` - Text(1), Image(2), File(3), Voice(4), System(5) +- `_statusId (int)` - FK to message_statuses +- `_status (MessageStatus)` - Sent(1), Delivered(2), Read(3), Failed(4) +- `_createdAt (DateTime)` - Sent timestamp +- `_updatedAt (DateTime?)` - Updated timestamp +- `_deliveredAt (DateTime?)` - Delivered timestamp +- `_readAt (DateTime?)` - Read timestamp +- `_metadata (string?)` - Optional JSON metadata +- `_replyToMessageId (Guid?)` - Reply thread reference + +**Methods**: `MarkAsDelivered()`, `MarkAsRead()`, `MarkAsFailed()` + +### ChatUser (Aggregate Root) + +**Table**: `chat_users` + +**Fields**: +- `Id (Guid)` - PK +- `_identityUserId (string)` - FK to IAM Service user ID (unique index) +- `_displayName (string)` - Display name +- `_avatarUrl (string?)` - Avatar URL +- `_statusId (int)` - FK to user_statuses +- `_status (UserStatus)` - Offline(1), Online(2), Away(3), DoNotDisturb(4) +- `_keyBundle (UserKeyBundle?)` - E2EE public key bundle (owned entity) +- `_oneTimePreKeys (List)` - One-time pre-keys for X3DH +- `_lastSeenAt (DateTime)` - Last activity timestamp +- `_createdAt (DateTime)` - Created timestamp +- `_updatedAt (DateTime)` - Updated timestamp + +**Methods**: +- `RegisterKeyBundle(identityPublicKey, signedPreKey, signedPreKeySignature)` - Register/update key bundle, raises UserKeyBundleUpdatedDomainEvent +- `RotateSignedPreKey(newSignedPreKey, newSignature)` - Rotate signed pre-key, raises UserKeyBundleUpdatedDomainEvent +- `UploadOneTimePreKeys(IEnumerable<(keyId, publicKey)>)` - Add one-time pre-keys (skips duplicates) +- `ConsumeOneTimePreKey()` - Consume first available one-time pre-key +- `GetAvailableOneTimePreKeyCount()` - Count unused one-time pre-keys +- `UpdateDisplayName(displayName)`, `UpdateAvatarUrl(avatarUrl)`, `SetOnlineStatus(status)`, `UpdateLastSeen()` + +**Domain Events**: `ChatUserCreatedDomainEvent`, `UserKeyBundleUpdatedDomainEvent` + +### UserKeyBundle (Value Object, Owned Entity) + +Stored inline in `chat_users` table. + +**Fields**: +- `IdentityPublicKey (string)` - Long-term identity public key (Curve25519) +- `SignedPreKey (string)` - Signed pre-key (rotated periodically) +- `SignedPreKeySignature (string)` - Signature of signed pre-key +- `SignedPreKeyTimestamp (DateTime)` - When signed pre-key was generated + +**Methods**: `RotateSignedPreKey(newKey, newSignature)` - Returns new UserKeyBundle, `NeedsRotation(maxAgeDays=30)` - Check if rotation needed + +### OneTimePreKey (Entity) + +**Table**: `one_time_pre_keys` + +**Fields**: +- `Id (Guid)` - PK +- `UserId (Guid)` - FK to chat_users +- `KeyId (int)` - Client-assigned key identifier +- `PublicKey (string)` - Curve25519 public key +- `IsUsed (bool)` - Consumed flag +- `CreatedAt (DateTime)` - Created timestamp +- `UsedAt (DateTime?)` - Consumed timestamp + +**Methods**: `MarkAsUsed()` - Marks key as consumed (one-time use) + +### Enumerations + +| Enumeration | Table | Values | +|-------------|-------|--------| +| ConversationType | `conversation_types` | Direct (1), Group (2) | +| MessageType | `message_types` | Text (1), Image (2), File (3), Voice (4), System (5) | +| MessageStatus | `message_statuses` | Sent (1), Delivered (2), Read (3), Failed (4) | +| UserStatus | `user_statuses` | Offline (1), Online (2), Away (3), DoNotDisturb (4) | + +### Domain Events + +| Event | Trigger | +|-------|---------| +| `ConversationCreatedDomainEvent` | New conversation created (direct or group) | +| `MessageSentDomainEvent` | Message sent in conversation | +| `MessageDeliveredDomainEvent` | Message delivered to recipient | +| `MessageReadDomainEvent` | Message read by recipient | +| `UserJoinedRoomDomainEvent` | User joined conversation room | +| `UserLeftRoomDomainEvent` | User left conversation room | +| `TypingDomainEvent` | User typing indicator | +| `ChatUserCreatedDomainEvent` | New ChatUser created | +| `UserKeyBundleUpdatedDomainEvent` | User's E2EE key bundle updated | + +--- + +## Database Schema + +### Tables + +#### `chat_users` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK, not auto-generated | +| `identity_user_id` | varchar(256) | Required, UNIQUE INDEX | +| `display_name` | varchar(256) | Required | +| `avatar_url` | varchar(2048) | Nullable | +| `status_id` | int | Required, FK -> user_statuses | +| `identity_public_key` | varchar(1024) | Nullable (owned: KeyBundle) | +| `signed_pre_key` | varchar(1024) | Nullable (owned: KeyBundle) | +| `signed_pre_key_signature` | varchar(1024) | Nullable (owned: KeyBundle) | +| `signed_pre_key_timestamp` | timestamp | Nullable (owned: KeyBundle) | +| `last_seen_at` | timestamp | | +| `created_at` | timestamp | Required | +| `updated_at` | timestamp | Required | + +#### `one_time_pre_keys` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK | +| `user_id` | uuid | Required, FK -> chat_users (CASCADE) | +| `key_id` | int | Required | +| `public_key` | varchar(1024) | Required | +| `is_used` | bool | Required | +| `created_at` | timestamp | Required | +| `used_at` | timestamp | Nullable | + +#### `conversations` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK | +| `type_id` | int | Required, FK -> conversation_types | +| `name` | varchar(256) | Nullable | +| `avatar_url` | varchar(2048) | Nullable | +| `last_message_id` | uuid | Nullable | +| `last_message_at` | timestamp | Nullable | +| `created_at` | timestamp | Required | +| `updated_at` | timestamp | Required | + +#### `conversation_participants` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK | +| `conversation_id` | uuid | Required, FK -> conversations (CASCADE) | +| `user_id` | uuid | Required | +| `joined_at` | timestamp | Required | +| `last_read_at` | timestamp | Nullable | +| `is_admin` | bool | Required | +| `is_muted` | bool | Required | +| `left_at` | timestamp | Nullable | + +#### `messages` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK | +| `conversation_id` | uuid | Required, FK -> conversations (CASCADE) | +| `sender_id` | uuid | Required | +| `encrypted_content` | text | Required | +| `nonce` | varchar(256) | Required | +| `auth_tag` | varchar(256) | Nullable | +| `type_id` | int | Required, FK -> message_types | +| `status_id` | int | Required, FK -> message_statuses | +| `metadata` | text | Nullable | +| `reply_to_message_id` | uuid | Nullable | +| `created_at` | timestamp | Required | +| `updated_at` | timestamp | Nullable | + +#### Enumeration Lookup Tables +- `conversation_types` (id, name) - Seeded: direct, group +- `message_types` (id, name) - Seeded: text, image, file, voice, system +- `message_statuses` (id, name) - Seeded: sent, delivered, read, failed +- `user_statuses` (id, name) - Seeded: offline, online, away, donotdisturb + +### Indexes + +| Table | Index | Columns | +|-------|-------|---------| +| `chat_users` | Unique | `identity_user_id` | +| `one_time_pre_keys` | Composite | `user_id, is_used` | +| `conversations` | Single | `last_message_at` | +| `conversation_participants` | Composite | `conversation_id, user_id` | +| `conversation_participants` | Single | `user_id` | +| `messages` | Composite | `conversation_id, created_at` | +| `messages` | Single | `sender_id` | + +### Migration +- `20260115165500_InitialCreate` - Creates all tables, indexes, and seed data + +--- + +## Dependencies + +### NuGet Packages + +**API Layer**: +- MediatR 12.4.1 +- FluentValidation 11.11.0 + DI Extensions +- Swashbuckle.AspNetCore 7.2.0 +- Asp.Versioning.Mvc 8.1.0 +- AspNetCore.HealthChecks.NpgSql 8.0.2 +- AspNetCore.HealthChecks.Redis 8.0.1 +- Hellang.Middleware.ProblemDetails 6.5.1 +- Serilog.AspNetCore 8.0.3, Serilog.Sinks.Console 6.0.0, Serilog.Sinks.Seq 8.0.0 +- Microsoft.AspNetCore.SignalR.StackExchangeRedis 9.0.0 +- Microsoft.AspNetCore.SignalR.Protocols.MessagePack 9.0.0 +- Microsoft.EntityFrameworkCore.Design 10.0.0 + +**Domain Layer**: +- MediatR.Contracts 2.0.1 (only dependency) + +**Infrastructure Layer**: +- Microsoft.EntityFrameworkCore 10.0.0 +- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 +- MediatR 12.4.1 +- Dapper 2.1.35 +- Polly 8.5.0 + Microsoft.Extensions.Http.Polly 9.0.0 +- StackExchange.Redis 2.8.16 + +### Service Dependencies +- **IAM Service**: User identity (IdentityUserId links to IAM user ID) +- **Redis**: SignalR backplane + channel prefix "ChatService" +- **PostgreSQL**: Primary data store +- **OpenAI API** (optional): AI chat assistant via `OPENAI_API_KEY` env var + +--- + +## Configuration + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=...;Database=chat_service;...", + "Redis": "localhost:6379" + }, + "SignalR": { + "EnableMessagePack": true, + "StatefulReconnectBufferSize": 32768, + "KeepAliveInterval": 15, + "ClientTimeoutInterval": 30 + }, + "AI": { + "Provider": "OpenAI", + "Model": "gpt-4", + "MaxHistoryMessages": 20, + "MaxTokens": 1000, + "Temperature": 0.7, + "SystemPrompt": "You are a helpful assistant..." + }, + "Jwt": { + "Secret": "...", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + } +} +``` + +### Environment Variables +- `DATABASE_URL` - Alternative to ConnectionStrings:DefaultConnection +- `OPENAI_API_KEY` - Required for AI features (falls back to NullAIService if absent) +- `ASPNETCORE_ENVIRONMENT` - Development enables Swagger, sensitive data logging, detailed errors + +### MediatR Pipeline +1. `LoggingBehavior` - Logs request name + elapsed time (ms) +2. `ValidatorBehavior` - FluentValidation in pipeline (throws ValidationException) +3. `TransactionBehavior` - Auto-wraps Commands in transactions (skips Queries), uses ExecutionStrategy with retry + +### DI Registration (DependencyInjection.cs) +- `ChatServiceContext` (DbContext, Npgsql, retry 5x / 30s delay) +- `IChatUserRepository` -> `ChatUserRepository` (scoped) +- `IConversationRepository` -> `ConversationRepository` (scoped) +- `IRequestManager` -> `RequestManager` (scoped) +- `IAIService` -> `AIService` (HttpClient) or `NullAIService` (singleton fallback) +- `IUserIdProvider` -> `ClaimsUserIdProvider` (singleton) + +### SignalR Configuration +- Redis backplane with channel prefix "ChatService" +- MessagePack protocol enabled by default +- Stateful reconnect enabled (buffer 32KB) +- KeepAlive: 15s, Client timeout: 30s +- Hub endpoint: `/hubs/chat` with AllowStatefulReconnects=true + +### CORS +- Configured origins from `AllowedOrigins` config section +- Defaults: `http://localhost:3000`, `http://localhost:5173` +- AllowCredentials required for SignalR + +--- + +## Tests + +### Unit Tests (`tests/ChatService.UnitTests/`) +- `Application/Hubs/ClaimsUserIdProviderTests.cs` +- `Domain/Contracts/AIServiceContractTests.cs` +- `Domain/Contracts/ChatHubClientTests.cs` +- `Domain/Events/ConversationDomainEventsTests.cs` +- `Infrastructure/Services/AIServiceTests.cs` + +### Functional Tests (`tests/ChatService.FunctionalTests/`) +- `Controllers/ConversationsControllerTests.cs` +- `CustomWebApplicationFactory.cs` (InMemory DB swap) + +--- + +## Architecture Notes + +1. **E2EE Protocol**: Implements X3DH (Extended Triple Diffie-Hellman) key exchange. Server stores ONLY public keys and encrypted content -- cannot decrypt without client's private key. Keys are Curve25519, encryption is AES-256-GCM. + +2. **Key Rotation**: Signed pre-key should be rotated every 30 days (`NeedsRotation()` check). One-time pre-keys are consumed on first use for forward secrecy. + +3. **User ID Provider**: `ClaimsUserIdProvider` extracts user ID from JWT claims (NameIdentifier, "sub", or "user_id") for SignalR user targeting. + +4. **Connection Tracking**: Static `Dictionary>` tracks multiple connections per user (multi-device). Online/offline status broadcast only on first/last connection. + +5. **AI Integration**: Triggered by `@gpt ` prefix in hub messages. Streams via OpenAI SSE. Includes conversation history (last 20 messages) for context. Falls back to `NullAIService` when API key not configured. + +6. **Idempotency**: `RequestManager` + `ClientRequest` entity available but not actively wired into controllers/commands (infrastructure ready). + +7. **Note**: Controllers use `api/[controller]` routing (no versioned prefix `api/v{version}`) -- differs from platform convention. diff --git a/services/fnb-engine-net/SERVICE_DOCS.md b/services/fnb-engine-net/SERVICE_DOCS.md new file mode 100644 index 00000000..9a79ac6d --- /dev/null +++ b/services/fnb-engine-net/SERVICE_DOCS.md @@ -0,0 +1,573 @@ +# FnB Engine Service - Documentation + +> Auto-generated from source code audit on 2026-03-13 + +## Overview + +**Service Name**: fnb-engine-net (FnbEngine) +**Port**: 5019 (Development) +**Database**: `fnb_engine` (PostgreSQL via Neon) +**SDK**: .NET 10.0 (C# 14) +**Architecture**: Clean Architecture + CQRS (MediatR 12.4.1) + +The FnB Engine is the core Food & Beverage operations microservice. It manages: +- **Table Management** -- CRUD, status tracking, QR code ordering, floor plan positioning +- **Session Management** -- Dining sessions (open/close) linked to tables +- **Kitchen Display System (KDS)** -- Kitchen tickets with station routing and status workflow +- **Barista Queue** -- Drink preparation queue for cafe/bar operations with priority, assignment, and stats +- **Recipe Management** -- Product recipes with ingredients, COGS tracking, and inventory item linking +- **Reservation System** -- Table reservations with guest info, status management +- **Inventory Integration** -- Auto-deduction of inventory when kitchen tickets are served (via inventory-service HTTP client) +- **Multi-Tenant Isolation** -- Row-level security via EF Core global query filters + PostgreSQL session variables + +--- + +## API Endpoints + +### Tables (`/api/v1/tables`) + +| Method | Path | Description | Request | Response | +|--------|------|-------------|---------|----------| +| GET | `/api/v1/tables?shopId={id}` | Get tables by shop | Query: `shopId` (Guid) | `ApiResponse>` | +| POST | `/api/v1/tables` | Create table | Body: `CreateTableRequest` | `ApiResponse` (201) | +| PUT | `/api/v1/tables/{id}` | Update table | Body: `UpdateTableRequest` | `ApiResponse` | +| PATCH | `/api/v1/tables/{id}/status` | Change table status | Body: `ChangeStatusRequest` | `ApiResponse` | +| POST | `/api/v1/tables/{id}/generate-qr` | Generate QR token | - | `ApiResponse<{qrToken}>` | +| GET | `/api/v1/tables/by-token/{token}` | Get table by QR token (public) | Path: `token` | `ApiResponse<{Id,ShopId,TableNumber,Capacity,Zone}>` | + +### Sessions (`/api/v1/sessions`) + +| Method | Path | Description | Request | Response | +|--------|------|-------------|---------|----------| +| POST | `/api/v1/sessions` | Open session | Body: `OpenSessionRequest` | `ApiResponse` (201) | +| GET | `/api/v1/sessions/{id}` | Get session by ID | Path: `id` | `ApiResponse` | +| POST | `/api/v1/sessions/{id}/close` | Close session | Path: `id` | `ApiResponse` | + +### Kitchen (`/api/v1/kitchen`) + +| Method | Path | Description | Request | Response | +|--------|------|-------------|---------|----------| +| GET | `/api/v1/kitchen/tickets?shopId={id}&status={s}` | Get tickets by shop+status | Query: `shopId`, `status` | `ApiResponse>` | +| GET | `/api/v1/kitchen/tickets?station={s}` | Get pending tickets by station | Query: `station` | `ApiResponse>` | +| POST | `/api/v1/kitchen/tickets` | Create kitchen ticket | Body: `CreateTicketRequest` | `CreateTicketResponse` | +| PATCH | `/api/v1/kitchen/tickets/{id}/status` | Update ticket status | Body: `UpdateStatusRequest` | `ApiResponse` | +| GET | `/api/v1/kitchen/recipes?shopId={id}` | Get recipes by shop | Query: `shopId` | `ApiResponse>` | +| GET | `/api/v1/kitchen/recipes/by-product?productId={id}&shopId={id}` | Get recipe by product | Query: `productId`, `shopId` | `ApiResponse` | +| POST | `/api/v1/kitchen/recipes` | Create recipe | Body: `CreateRecipeCommand` | `ApiResponse` | +| PUT | `/api/v1/kitchen/recipes/{id}` | Update recipe | Body: `UpdateRecipeCommand` | `ApiResponse` | +| DELETE | `/api/v1/kitchen/recipes/{id}` | Delete (soft) recipe | Path: `id` | `ApiResponse` | + +### Barista (`/api/v1/fnb/barista`) + +| Method | Path | Description | Request | Response | +|--------|------|-------------|---------|----------| +| GET | `/api/v1/fnb/barista/queue?shopId={id}` | Get active barista queue | Query: `shopId` | `ApiResponse>` | +| GET | `/api/v1/fnb/barista/stats?shopId={id}` | Get queue statistics | Query: `shopId` | `ApiResponse` | +| POST | `/api/v1/fnb/barista/queue` | Queue a drink | Body: `QueueDrinkCommand` | `ApiResponse` | +| PUT | `/api/v1/fnb/barista/queue/{id}/start` | Start preparing drink | Body: `StartPreparingRequest` | `ApiResponse` | +| PUT | `/api/v1/fnb/barista/queue/{id}/ready` | Mark drink ready | - | `ApiResponse` | +| PUT | `/api/v1/fnb/barista/queue/{id}/delivered` | Mark drink delivered | - | `ApiResponse` | +| DELETE | `/api/v1/fnb/barista/queue/{id}` | Cancel queue item | Path: `id` | `ApiResponse` | + +### Reservations (`/api/v1/reservations`) + +| Method | Path | Description | Request | Response | +|--------|------|-------------|---------|----------| +| GET | `/api/v1/reservations?shopId={id}&date={d}` | Get reservations | Query: `shopId`, `date?` | `ApiResponse>` | +| POST | `/api/v1/reservations` | Create reservation | Body: `CreateReservationRequest` | `ApiResponse` (201) | +| PATCH | `/api/v1/reservations/{id}/status` | Update reservation status | Body: `UpdateReservationStatusRequest` | `ApiResponse` | + +### Health Checks + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/health` | Full health check (PostgreSQL) | +| GET | `/health/live` | Liveness probe | +| GET | `/health/ready` | Readiness probe | + +--- + +## Commands + +### CreateTableCommand +- **Input**: `ShopId` (Guid), `TableNumber` (string), `Capacity` (int), `Zone?` (string), `HourlyRate?` (decimal) +- **Logic**: Checks duplicate table number per shop, creates Table entity, optionally sets hourly rate +- **Validator**: `CreateTableValidator` -- ShopId required, TableNumber required (max 20), Capacity > 0, Zone max 100 + +### UpdateTableCommand +- **Input**: `TableId` (Guid), `Capacity?` (int), `Zone?` (string), `PositionX?` (int), `PositionY?` (int), `HourlyRate?` (decimal) +- **Logic**: Loads table, sets position and/or hourly rate +- **Validator**: `UpdateTableCommandValidator` -- TableId required, Capacity > 0, Zone max 100, HourlyRate >= 0 + +### ChangeTableStatusCommand +- **Input**: `TableId` (Guid), `Status` (string: "Available"/"Occupied"/"Cleaning") +- **Logic**: Loads table, calls domain methods (MarkAsAvailable/MarkAsOccupied/MarkAsCleaning) +- **Validator**: `ChangeTableStatusCommandValidator` -- Valid statuses: Available, Occupied, Cleaning, Reserved, OutOfService + +### OpenSessionCommand +- **Input**: `TableId` (Guid), `ShopId` (Guid), `GuestCount` (int, default 1) +- **Logic**: Validates table exists, checks no active session on table, creates Session, marks table as Occupied +- **Validator**: `OpenSessionCommandValidator` -- TableId/ShopId required, GuestCount 1-100 + +### CloseSessionCommand +- **Input**: `SessionId` (Guid) +- **Logic**: Loads session, calls `session.Close()`, marks table as Available +- **Validator**: `CloseSessionCommandValidator` -- SessionId required + +### CreateKitchenTicketCommand +- **Input**: `SessionId` (Guid), `OrderItemId` (Guid), `ItemName` (string), `Station?` (string), `Priority` (int, default 0), `ProductId?` (Guid), `Quantity` (int, default 1) +- **Logic**: Creates KitchenTicket entity with product ID for recipe lookup +- **Validator**: `CreateKitchenTicketCommandValidator` -- SessionId/OrderItemId/ItemName required, Priority 0-10, Quantity > 0 + +### UpdateTicketStatusCommand +- **Input**: `TicketId` (Guid), `Status` (string: "InProgress"/"Ready"/"Served") +- **Logic**: Loads ticket, calls domain methods. When "Served", raises KitchenTicketServedDomainEvent for inventory deduction +- **Validator**: `UpdateTicketStatusCommandValidator` -- Valid statuses: Pending, InProgress, Ready, Served, Cancelled + +### QueueDrinkCommand +- **Input**: `ShopId` (Guid), `OrderId` (Guid), `OrderItemId` (Guid), `DrinkName` (string), `Customizations?` (string), `Priority` (int, default 0), `EstimatedMinutes` (int, default 5) +- **Logic**: Creates BaristaQueueItem, raises DrinkQueuedDomainEvent +- **Validator**: `QueueDrinkCommandValidator` -- All IDs required, DrinkName required (max 200), Customizations max 2000, Priority 0-10, EstimatedMinutes 1-60 + +### StartPreparingCommand +- **Input**: `QueueItemId` (Guid), `BaristaName` (string) +- **Logic**: Loads queue item, calls `StartPreparing(baristaName)` (status Queued -> Preparing) +- **Validator**: `StartPreparingCommandValidator` -- QueueItemId required, BaristaName required (max 100) + +### MarkDrinkReadyCommand +- **Input**: `QueueItemId` (Guid) +- **Logic**: Loads queue item, calls `MarkReady()` (Preparing -> Ready), raises DrinkReadyDomainEvent +- **Validator**: `MarkDrinkReadyCommandValidator` -- QueueItemId required + +### MarkDrinkDeliveredCommand +- **Input**: `QueueItemId` (Guid) +- **Logic**: Loads queue item, calls `MarkDelivered()` (Ready -> Delivered) +- **Validator**: `MarkDrinkDeliveredCommandValidator` -- QueueItemId required + +### CancelQueueItemCommand +- **Input**: `QueueItemId` (Guid) +- **Logic**: Loads queue item, calls `Cancel()` (any status except Delivered/Cancelled -> Cancelled) +- **Validator**: `CancelQueueItemCommandValidator` -- QueueItemId required + +### CreateRecipeCommand +- **Input**: `ShopId` (Guid), `ProductId` (Guid), `Name` (string), `Instructions?` (string), `PrepTimeMinutes` (int), `Ingredients?` (List of IngredientItem) +- **Logic**: Creates Recipe entity, adds ingredients with optional inventory item links +- **Validator**: None (no dedicated validator) + +### UpdateRecipeCommand +- **Input**: `RecipeId` (Guid), `ShopId` (Guid), `ProductId` (Guid), `Name` (string), `Instructions?` (string), `PrepTimeMinutes` (int), `Ingredients?` (List of IngredientItem) +- **Logic**: Loads recipe, updates fields, clears and re-adds ingredients +- **Validator**: None + +### DeleteRecipeCommand +- **Input**: `RecipeId` (Guid) +- **Logic**: Loads recipe, calls `Deactivate()` (soft delete via IsActive=false) +- **Validator**: None + +--- + +## Queries + +### GetTablesQuery +- **Input**: `ShopId` (Guid) +- **Logic**: Gets all tables for shop, maps StatusId to status name via Enumeration pattern +- **Output**: `IEnumerable` (Id, ShopId, TableNumber, Capacity, Zone, Status, HourlyRate, PositionX, PositionY, QrToken) + +### GetSessionQuery +- **Input**: `SessionId` (Guid) +- **Logic**: Gets session by ID, returns null if not found +- **Output**: `SessionDto?` (Id, TableId, ShopId, GuestCount, StartedAt, ClosedAt, Status) + +### GetPendingTicketsQuery +- **Input**: `Station?` (string) +- **Logic**: Gets tickets with status Pending or InProgress, optionally filtered by station, sorted by priority desc then createdAt asc +- **Output**: `IEnumerable` (Id, SessionId, OrderItemId, ItemName, Station, Priority, Status, CreatedAt) + +### GetTicketsByShopQuery +- **Input**: `ShopId` (Guid), `Status?` (string) +- **Logic**: Finds sessions by shop, then gets tickets by session IDs with optional status filter +- **Output**: `IEnumerable` + +### GetBaristaQueueQuery +- **Input**: `ShopId` (Guid) +- **Logic**: Gets active queue items (Queued/Preparing/Ready) for shop, sorted by priority desc then createdAt asc +- **Output**: `IEnumerable` (Id, ShopId, OrderId, OrderItemId, DrinkName, Customizations, Priority, StatusId, StatusName, AssignedTo, EstimatedMinutes, CreatedAt, StartedAt, CompletedAt) + +### GetQueueStatsQuery +- **Input**: `ShopId` (Guid) +- **Logic**: Aggregates counts by status and calculates average prep time from completed items +- **Output**: `QueueStatsDto` (TotalQueued, TotalPreparing, TotalReady, TotalDelivered, TotalCancelled, AveragePrepTimeMinutes) + +### GetReservationsQuery +- **Input**: `ShopId` (Guid), `Date?` (DateTime) +- **Logic**: Gets reservations by shop, optionally filtered to a single day, sorted by reservation time +- **Output**: `IEnumerable` (Id, ShopId, TableId, GuestName, Phone, PartySize, ReservationTime, Status, Note, CreatedAt) + +### GetRecipesByShopQuery +- **Input**: `ShopId` (Guid) +- **Logic**: Gets active recipes for shop with ingredients, ordered by name +- **Output**: `IEnumerable` (Id, ProductId, ShopId, Name, Instructions, PrepTimeMinutes, IsActive, CreatedAt, Ingredients[]) + +### GetRecipeByProductQuery +- **Input**: `ProductId` (Guid), `ShopId` (Guid) +- **Logic**: Gets active recipe by product ID and shop ID (for inventory deduction lookup) +- **Output**: `RecipeDto?` + +--- + +## Domain Model + +### Table (Aggregate Root) +- **Fields**: `_shopId`, `_tableNumber`, `_capacity`, `_zone`, `_status` (TableStatus), `StatusId`, `_positionX`, `_positionY`, `_qrToken`, `_hourlyRate`, `_createdAt`, `_updatedAt` +- **Methods**: + - `MarkAsOccupied()` -- Only from Available/Reserved + - `MarkAsAvailable()` -- From any status + - `MarkAsCleaning()` -- From any status + - `SetHourlyRate(decimal rate)` + - `SetPosition(int x, int y)` + - `GenerateQrToken()` -- Returns 16-char hex token + - `ClearQrToken()` +- **Validation**: ShopId not empty, TableNumber not blank, Capacity > 0 +- **Events**: None + +### TableStatus (Enumeration) +- 1 = Available, 2 = Occupied, 3 = Reserved, 4 = Cleaning + +### Session (Aggregate Root) +- **Fields**: `_tableId`, `_shopId`, `_guestCount`, `_startedAt`, `_closedAt`, `_status` +- **Methods**: + - `Close()` -- Sets status to "Closed", records ClosedAt. Throws if already closed. +- **Status Values**: "Active", "Closed" +- **Validation**: TableId/ShopId not empty, GuestCount > 0 +- **Events**: None + +### KitchenTicket (Aggregate Root) +- **Fields**: `_sessionId`, `_orderItemId`, `_productId`, `_itemName`, `_station`, `_priority`, `_quantity`, `_status`, `_createdAt`, `_completedAt` +- **Methods**: + - `MarkAsInProgress()` -- Sets status to "InProgress" + - `MarkAsReady()` -- Sets status to "Ready", records CompletedAt + - `MarkAsServed()` -- Sets status to "Served", raises `KitchenTicketServedDomainEvent` +- **Status Values**: "Pending" -> "InProgress" -> "Ready" -> "Served" +- **Events**: `KitchenTicketServedDomainEvent` (on Served) + +### BaristaQueueItem (Aggregate Root) +- **Fields**: `_shopId`, `_orderId`, `_orderItemId`, `_drinkName`, `_customizations` (jsonb), `_priority`, `_statusId`, `_assignedTo`, `_estimatedMinutes`, `_createdAt`, `_startedAt`, `_completedAt` +- **Methods**: + - `StartPreparing(string baristaName)` -- Queued(1) -> Preparing(2), assigns barista + - `MarkReady()` -- Preparing(2) -> Ready(3), raises `DrinkReadyDomainEvent` + - `MarkDelivered()` -- Ready(3) -> Delivered(4) + - `Cancel()` -- Any except Delivered/Cancelled -> Cancelled(5) +- **Status IDs**: 1=Queued, 2=Preparing, 3=Ready, 4=Delivered, 5=Cancelled +- **Events**: `DrinkQueuedDomainEvent` (on creation), `DrinkReadyDomainEvent` (on ready) + +### Recipe (Aggregate Root) +- **Fields**: `_shopId`, `_productId`, `_name`, `_instructions`, `_prepTimeMinutes`, `_isActive`, `_createdAt`, `_updatedAt`, `_ingredients` (List) +- **Methods**: + - `Update(productId, name, instructions, prepTimeMinutes)` + - `AddIngredient(ingredientName, quantity, unit, costPerUnit, inventoryItemId?, quantityPerServing?)` + - `ClearIngredients()` + - `Deactivate()` -- Soft delete +- **Events**: None + +### RecipeIngredient (Entity, child of Recipe) +- **Fields**: `_recipeId`, `_ingredientName`, `_quantity`, `_unit`, `_costPerUnit`, `_inventoryItemId` (optional link to inventory-service), `_quantityPerServing` (for COGS) + +### Reservation (Aggregate Root) +- **Fields**: `_shopId`, `_tableId` (optional), `_guestName`, `_phone`, `_partySize`, `_reservationTime`, `_status`, `_note`, `_createdAt` +- **Methods**: + - `Confirm()`, `Seat()`, `Cancel()`, `NoShow()` + - `AssignTable(Guid tableId)` + - `UpdateStatus(string status)` -- Validates against allowed statuses +- **Status Values**: "pending", "confirmed", "seated", "cancelled", "no_show" +- **Validation**: ShopId not empty, GuestName not blank, PartySize > 0 + +### Domain Events + +| Event | Trigger | Handler | +|-------|---------|---------| +| `DrinkQueuedDomainEvent` | BaristaQueueItem constructor | No handler (available for SignalR notifications) | +| `DrinkReadyDomainEvent` | BaristaQueueItem.MarkReady() | No handler (available for SignalR notifications) | +| `KitchenTicketServedDomainEvent` | KitchenTicket.MarkAsServed() | `KitchenTicketServedDomainEventHandler` -- Looks up recipe, calls inventory-service for auto-deduction | + +### Integration Events + +| Event | Description | +|-------|-------------| +| `KitchenTicketServedIntegrationEvent` | Defined but not currently published (available for RabbitMQ) | + +--- + +## Database Schema + +### Table: `tables` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK | +| `shop_id` | uuid | NOT NULL, indexed | +| `table_number` | varchar(20) | NOT NULL | +| `capacity` | int | NOT NULL | +| `zone` | varchar(100) | nullable | +| `status_id` | int | NOT NULL | +| `position_x` | int | nullable | +| `position_y` | int | nullable | +| `qr_token` | varchar(64) | nullable, unique (filtered) | +| `hourly_rate` | decimal(18,2) | default 0 | +| `created_at` | timestamp | NOT NULL | +| `updated_at` | timestamp | nullable | + +**Indexes**: +- `ix_tables_shop_id` on (`shop_id`) +- `ix_tables_shop_table_number` on (`shop_id`, `table_number`) UNIQUE +- `ix_tables_qr_token` on (`qr_token`) UNIQUE WHERE qr_token IS NOT NULL + +### Table: `table_statuses` (seed data) +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | int | PK (no auto-increment) | +| `name` | varchar(50) | NOT NULL | + +**Seed Data**: 1=Available, 2=Occupied, 3=Reserved, 4=Cleaning + +### Table: `sessions` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK | +| `table_id` | uuid | NOT NULL | +| `shop_id` | uuid | NOT NULL | +| `guest_count` | int | NOT NULL | +| `started_at` | timestamp | NOT NULL | +| `closed_at` | timestamp | nullable | +| `status` | varchar(50) | NOT NULL | + +**Indexes**: +- `ix_sessions_table_status` on (`table_id`, `status`) +- `ix_sessions_shop` on (`shop_id`) + +### Table: `kitchen_tickets` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK | +| `session_id` | uuid | NOT NULL | +| `order_item_id` | uuid | NOT NULL | +| `product_id` | uuid | NOT NULL | +| `item_name` | varchar(200) | NOT NULL | +| `station` | varchar(100) | nullable | +| `priority` | int | NOT NULL | +| `quantity` | int | NOT NULL, default 1 | +| `status` | varchar(50) | NOT NULL | +| `created_at` | timestamp | NOT NULL | +| `completed_at` | timestamp | nullable | + +**Indexes**: +- `ix_kitchen_tickets_session` on (`session_id`) +- `ix_kitchen_tickets_station_status_priority` on (`station`, `status`, `priority`) + +### Table: `barista_queue_items` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK | +| `shop_id` | uuid | NOT NULL | +| `order_id` | uuid | NOT NULL | +| `order_item_id` | uuid | NOT NULL | +| `drink_name` | varchar(200) | NOT NULL | +| `customizations` | jsonb | nullable | +| `priority` | int | NOT NULL, default 0 | +| `status_id` | int | NOT NULL, default 1 | +| `assigned_to` | varchar(100) | nullable | +| `estimated_minutes` | int | NOT NULL, default 5 | +| `created_at` | timestamp | NOT NULL | +| `started_at` | timestamp | nullable | +| `completed_at` | timestamp | nullable | + +**Indexes**: +- `ix_barista_queue_shop_status_priority` on (`shop_id`, `status_id`, `priority`) +- `ix_barista_queue_order_id` on (`order_id`) +- `ix_barista_queue_created_at` on (`created_at`) + +### Table: `recipes` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK (no auto-increment) | +| `shop_id` | uuid | NOT NULL | +| `product_id` | uuid | NOT NULL | +| `name` | varchar(255) | NOT NULL | +| `instructions` | varchar(2000) | nullable | +| `prep_time_minutes` | int | | +| `is_active` | bool | default true | +| `created_at` | timestamp | NOT NULL | +| `updated_at` | timestamp | nullable | + +**Indexes**: +- `ix_recipes_shop_id` on (`shop_id`) +- `ix_recipes_product_id` on (`product_id`) + +### Table: `recipe_ingredients` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK (no auto-increment) | +| `recipe_id` | uuid | NOT NULL, FK -> recipes (cascade delete) | +| `ingredient_name` | varchar(200) | NOT NULL | +| `quantity` | decimal(18,4) | NOT NULL | +| `unit` | varchar(50) | NOT NULL | +| `cost_per_unit` | decimal(18,2) | | +| `inventory_item_id` | uuid | nullable (link to inventory-service) | +| `quantity_per_serving` | decimal(18,4) | | + +### Table: `reservations` +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK | +| `shop_id` | uuid | NOT NULL | +| `table_id` | uuid | nullable | +| `guest_name` | varchar(200) | NOT NULL | +| `phone` | varchar(20) | nullable | +| `party_size` | int | NOT NULL | +| `reservation_time` | timestamp | NOT NULL | +| `status` | varchar(20) | NOT NULL | +| `note` | varchar(500) | nullable | +| `created_at` | timestamp | NOT NULL | + +**Indexes**: +- `ix_reservations_shop_id` on (`shop_id`) +- `ix_reservations_shop_time` on (`shop_id`, `reservation_time`) + +### Tenant Global Query Filters +EF Core global query filters applied on: `Table` (shop_id), `Session` (shop_id), `Reservation` (shop_id), `BaristaQueueItem` (shop_id). +Bypassed for service-to-service calls and admin users. + +--- + +## Migrations + +| Migration | Date | Description | +|-----------|------|-------------| +| `20260117194222_InitialCreate` | 2026-01-17 | Initial schema (tables, sessions, kitchen_tickets, table_statuses) | +| `20260304221850_AddReservations` | 2026-03-04 | Add reservations table | +| `20260305003518_AddTablePositionFields` | 2026-03-05 | Add position_x, position_y to tables | +| `20260305011822_AddQrTokenToTable` | 2026-03-05 | Add qr_token to tables | +| `20260305095701_AddInventoryLinkToRecipeIngredient` | 2026-03-05 | Add inventory_item_id to recipe_ingredients | +| `20260305100324_AddInventoryItemIdToRecipeIngredient` | 2026-03-05 | Refine inventory item ID column | +| `20260306175525_PhaseTwo` | 2026-03-06 | Barista queue, recipe enhancements, quantity on tickets | + +--- + +## Dependencies + +### NuGet Packages (API Layer) +- MediatR 12.4.1 +- FluentValidation 11.11.0 +- FluentValidation.DependencyInjectionExtensions 11.11.0 +- Microsoft.AspNetCore.Authentication.JwtBearer 10.0.1 +- Swashbuckle.AspNetCore 7.2.0 +- AspNetCore.HealthChecks.NpgSql 8.0.2 +- AspNetCore.HealthChecks.Redis 8.0.1 +- Hellang.Middleware.ProblemDetails 6.5.1 +- Serilog.AspNetCore 8.0.3 +- Serilog.Sinks.Console 6.0.0 +- Serilog.Sinks.Seq 8.0.0 +- Microsoft.EntityFrameworkCore.Design 10.0.0 + +### NuGet Packages (Domain Layer) +- MediatR.Contracts 2.0.1 + +### NuGet Packages (Infrastructure Layer) +- Microsoft.EntityFrameworkCore 10.0.0 +- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 +- Microsoft.EntityFrameworkCore.Tools 10.0.0 +- MediatR 12.4.1 +- Dapper 2.1.35 +- Polly 8.5.0 +- Microsoft.Extensions.Http.Polly 9.0.0 +- StackExchange.Redis 2.8.16 + +### External Service Calls +- **inventory-service**: HTTP client with Polly retry (3 retries, exponential backoff) + circuit breaker (5 failures / 30s). + Endpoint: `POST /api/v1/inventory/deduct`. Called from `KitchenTicketServedDomainEventHandler` when a ticket is served. + Default URL: `http://inventory-service:8080` (configurable via `Services:InventoryService:BaseUrl` or `INVENTORY_SERVICE_URL`). + +--- + +## Configuration + +### appsettings.json +```json +{ + "ConnectionStrings": { + "DefaultConnection": "" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "...", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + } +} +``` + +### Environment Variables +| Variable | Purpose | Default | +|----------|---------|---------| +| `DATABASE_URL` | Fallback DB connection string | - | +| `INVENTORY_SERVICE_URL` | Inventory service base URL | `http://inventory-service:8080` | +| `Jwt:Authority` | OIDC discovery authority | `http://localhost:5001` | + +### Middleware Pipeline +1. Serilog Request Logging +2. ProblemDetails (RFC 7807) +3. Swagger (Development only) +4. CORS (AllowAny) +5. Routing +6. Authentication (JWT Bearer via IAM IdentityServer OIDC) +7. Authorization +8. TenantMiddleware (sets PostgreSQL RLS session variables) +9. Health Checks (/health, /health/live, /health/ready) +10. Controllers + +### MediatR Pipeline Behaviors +1. `LoggingBehavior` -- Logs request name + elapsed time (Stopwatch) +2. `ValidatorBehavior` -- Runs FluentValidation validators, throws ValidationException on failure +3. `TransactionBehavior` -- Wraps Commands in DB transaction (skips Queries by name convention), uses ExecutionStrategy + +### Multi-Tenant Architecture +- **API Layer**: `ITenantProvider` extracts shop_id/merchant_id from JWT claims or `X-Shop-Id` header +- **Infrastructure Layer**: `IFnbTenantProvider` provides shop_id to DbContext global query filters +- **Middleware**: `TenantMiddleware` sets PostgreSQL session variables (`app.current_shop_id`, `app.current_merchant_id`) for RLS policies +- **Bypass**: Service-to-service calls (header `X-Service-Call: internal`) and admin/system roles bypass tenant filtering + +--- + +## Tests + +### Unit Tests (`tests/FnbEngine.UnitTests/`) +- `CreateTableCommandHandlerTests` +- `ChangeTableStatusCommandHandlerTests` +- `OpenSessionCommandHandlerTests` +- `CloseSessionCommandHandlerTests` +- `CreateKitchenTicketCommandHandlerTests` +- `UpdateTicketStatusCommandHandlerTests` +- `CreateReservationCommandHandlerTests` +- `RecipeCommandHandlersTests` +- `KitchenTicketServedDomainEventHandlerTests` +- `TableAggregateTests` +- `SessionTests` +- `KitchenTicketTests` +- `ReservationTests` +- `RecipeTests` + +### Functional Tests (`tests/FnbEngine.FunctionalTests/`) +- `TablesControllerTests` (with `CustomWebApplicationFactory`) + +--- + +## Key Design Decisions + +1. **Kitchen tickets are linked to sessions, not directly to shops**. Shop-level filtering for tickets goes through sessions (indirect isolation). +2. **Recipes are NOT tenant-filtered** at the DbContext level. They are filtered by shopId in repository queries since recipes could theoretically be shared. +3. **Inventory deduction is fire-and-forget**. Errors in inventory-service calls are logged but do not block the kitchen workflow (non-throwing handler). +4. **Barista queue uses integer status IDs** (1-5) rather than string status like kitchen tickets, for more efficient querying and indexing. +5. **QR tokens are 16-char hex strings** generated from GUIDs, with a unique filtered index. +6. **Auto-migration on startup** -- EF Core migrations are applied automatically when the service starts (with error suppression for PendingModelChangesWarning). diff --git a/services/iam-service-net/SERVICE_DOCS.md b/services/iam-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..ab8cb668 --- /dev/null +++ b/services/iam-service-net/SERVICE_DOCS.md @@ -0,0 +1,623 @@ +# IAM Service (iam-service-net) — Service Documentation +> Auto-generated from code audit on 2026-03-13 + +## Overview +Identity and Access Management service for the GoodGo Platform. Handles user authentication (OAuth2/OIDC via Duende IdentityServer), user registration, role-based access control (RBAC), organizations, groups, access requests/reviews, privileged access management (PAM), identity verification (phone/email/document), audit logging, and compliance reporting. + +- **Port**: 5001 (Development) +- **Database**: `iam_service` (PostgreSQL / Neon) +- **Base Route**: `api/v1` +- **Auth**: Duende IdentityServer (OAuth2 Password Grant, Authorization Code + PKCE, Client Credentials) +- **Framework**: .NET 10.0, C# 14 + +--- + +## API Endpoints + +### AuthController — `api/v1/auth` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/auth/register` | None | Register a new user | +| POST | `/auth/change-password` | Bearer | Change user password | +| POST | `/auth/logout` | Bearer | Logout and revoke tokens | +| POST | `/auth/send-verification-email` | None | Send email verification link | +| POST | `/auth/confirm-email` | None | Confirm email with token | +| POST | `/auth/2fa/enable` | Bearer | Enable 2FA (returns QR code + recovery codes) | +| POST | `/auth/2fa/verify` | Bearer | Verify 2FA TOTP code to complete setup | +| POST | `/auth/2fa/disable` | Bearer | Disable 2FA (requires current code) | +| GET | `/auth/external-login/{provider}` | None | Initiate OAuth with Google/Facebook | +| GET | `/auth/external-callback` | None | Handle OAuth callback | +| GET | `/auth/linked-accounts` | Bearer | List linked external providers | + +### OAuth2 Token Endpoint (Duende IdentityServer) +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/connect/token` | Client Credentials | OAuth2 token endpoint (password, authorization_code, client_credentials, refresh_token) | + +### UsersController — `api/v1/users` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/users` | Bearer + RequireAdmin | Get all users (paginated) | +| GET | `/users/{id}` | Bearer + OwnerOrAdmin | Get user by ID | +| PUT | `/users/{id}` | Bearer + OwnerOrAdmin | Update user (firstName, lastName) | +| DELETE | `/users/{id}` | Bearer + RequireAdmin | Soft-delete (deactivate) user | +| GET | `/users/me` | Bearer | Get current user info from JWT claims | +| GET | `/users/{id}/roles` | Bearer | Get user's assigned roles | +| GET | `/users/{id}/permissions` | Bearer | Get user's permissions (from claims) | + +### RolesController — `api/v1/roles` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/roles` | Bearer + RequireAdmin | Get all roles (paginated) | +| GET | `/roles/{id}` | Bearer + RequireAdmin | Get role by ID | +| POST | `/roles` | Bearer + RequireAdmin | Create a new role | +| PUT | `/roles/{id}` | Bearer + RequireAdmin | Update role (name, description) | +| DELETE | `/roles/{id}` | Bearer + RequireAdmin | Delete role (system roles protected) | +| POST | `/users/{userId}/roles` | Bearer + RequireAdmin | Assign role to user | +| DELETE | `/users/{userId}/roles/{roleName}` | Bearer + RequireAdmin | Remove role from user | + +### GroupsController — `api/v1/groups` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/groups?organizationId=` | Bearer + RequireAdmin | Get groups by organization | +| GET | `/groups/{id}` | Bearer + RequireAdmin | Get group by ID | +| POST | `/groups` | Bearer + RequireAdmin | Create group | +| DELETE | `/groups/{id}` | Bearer + RequireAdmin | Soft-delete group | +| POST | `/groups/{id}/members` | Bearer + RequireAdmin | Add member to group | +| DELETE | `/groups/{id}/members/{userId}` | Bearer + RequireAdmin | Remove member from group | + +### OrganizationsController — `api/v1/organizations` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/organizations/{id}` | Bearer + RequireAdmin | Get organization by ID | +| GET | `/organizations/slug/{slug}` | Bearer + RequireAdmin | Get organization by slug | +| POST | `/organizations` | Bearer + RequireAdmin | Create organization | +| PUT | `/organizations/{id}` | Bearer + RequireAdmin | Update organization | +| DELETE | `/organizations/{id}` | Bearer + RequireAdmin | Archive organization (soft delete) | +| GET | `/organizations/{id}/hierarchy` | Bearer + RequireAdmin | Get organization hierarchy | +| GET | `/organizations/{id}/children` | Bearer + RequireAdmin | Get child organizations | + +### AccessRequestsController — `api/v1/access-requests` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/access-requests` | Bearer + RequireAdmin | Create access request | +| GET | `/access-requests/{id}` | Bearer + RequireAdmin | Get access request by ID | +| GET | `/access-requests?requesterId=` | Bearer + RequireAdmin | Get my access requests | +| GET | `/access-requests/pending?approverId=` | Bearer + RequireAdmin | Get pending approvals | +| POST | `/access-requests/{id}/submit` | Bearer + RequireAdmin | Submit request for approval | +| POST | `/access-requests/{id}/approve` | Bearer + RequireAdmin | Approve access request | +| POST | `/access-requests/{id}/reject` | Bearer + RequireAdmin | Reject access request | +| DELETE | `/access-requests/{id}` | Bearer + RequireAdmin | Cancel access request | + +### AccessReviewsController — `api/v1/access-reviews` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/access-reviews` | Bearer + RequireAdmin | Create access review | +| GET | `/access-reviews/{id}` | Bearer + RequireAdmin | Get access review by ID | +| POST | `/access-reviews/{id}/items` | Bearer + RequireAdmin | Add item to review | +| POST | `/access-reviews/{id}/start` | Bearer + RequireAdmin | Start access review | +| POST | `/access-reviews/{id}/items/{itemId}/review` | Bearer + RequireAdmin | Certify or revoke an item | +| POST | `/access-reviews/{id}/complete` | Bearer + RequireAdmin | Complete access review | + +### PrivilegedAccessController — `api/v1/privileged-access` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/privileged-access/request` | Bearer + RequireSuperAdmin | Request JIT privileged access | +| GET | `/privileged-access/active?userId=` | Bearer + RequireSuperAdmin | Get active privileged grants | +| POST | `/privileged-access/{id}/revoke` | Bearer + RequireSuperAdmin | Revoke privileged access | + +### UserProfilesController — `api/v1/users` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/users/{userId}/profile` | Bearer | Get user profile | +| PUT | `/users/{userId}/profile` | Bearer | Update user profile (bio, timezone, locale, avatarUrl) | +| PUT | `/users/{userId}/profile/attributes/{key}` | Bearer | Set profile attribute (String/Number/Boolean/Date/Json) | + +### VerificationsController — `api/v1/verifications` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/verifications/phone` | Bearer + RequireAdmin | Request phone OTP verification | +| POST | `/verifications/email` | Bearer + RequireAdmin | Request email OTP verification | +| POST | `/verifications/{id}/confirm` | Bearer + RequireAdmin | Confirm verification with code | + +### AuditController — `api/v1/audit` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/audit/logs` | Bearer + RequireAuditor | Get audit logs (filtered by date, eventType, actor, resource) | + +### ComplianceController — `api/v1/compliance` +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/compliance/reports` | Bearer + RequireAuditor | Generate compliance report | +| GET | `/compliance/reports` | Bearer + RequireAuditor | Get compliance reports | +| GET | `/compliance/reports/{id}` | Bearer + RequireAuditor | Get report by ID | +| GET | `/compliance/violations` | Bearer + RequireAuditor | Get unresolved violations | +| POST | `/compliance/reports/{id}/complete` | Bearer + RequireAuditor | Complete compliance report | + +### Health Endpoints +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/health` | None | Full health check (PostgreSQL) | +| GET | `/health/live` | None | Liveness probe (app running) | +| GET | `/health/ready` | None | Readiness probe | + +--- + +## Commands (Write Operations) + +### Auth Commands + +**RegisterUserCommand** +- Input: `(Email, Password, FirstName, LastName)` +- Output: `RegisterUserCommandResult(UserId, Email, FullName)` +- Logic: Check if user exists by email -> create ApplicationUser -> CreateAsync with password via UserManager -> raise UserRegisteredDomainEvent +- Validator: Email required + valid format; Password 8+ chars, uppercase, lowercase, digit, special; FirstName/LastName required, max 100 + +**ChangePasswordCommand** +- Input: `(UserId, CurrentPassword, NewPassword)` +- Output: `ChangePasswordCommandResult(Success, Message)` +- Validator: UserId required; CurrentPassword required; NewPassword 8+ chars with complexity rules; must differ from current + +**LogoutCommand** +- Input: `(UserId)` +- Output: `LogoutCommandResult(Success, Message)` +- Logic: Signs out via SignInManager + +**SendVerificationEmailCommand** +- Input: `(Email)` +- Output: `SendVerificationEmailResult` +- Validator: Email required, valid format, max 256 + +**ConfirmEmailCommand** +- Input: `(Email, Token)` +- Output: `ConfirmEmailResult(Success, Message)` +- Validator: Email required + valid; Token required, max 1024 + +**Enable2FACommand** +- Input: `(UserId)` +- Output: `Enable2FACommandResult(QrCodeBase64, ManualEntryKey, RecoveryCodes)` +- Validator: UserId required + +**Verify2FACommand** +- Input: `(UserId, Code)` +- Output: `Verify2FAResult(Success, Message)` +- Validator: UserId required; Code required, exactly 6 digits + +**Disable2FACommand** +- Input: `(UserId, Code)` +- Output: `Disable2FAResult(Success, Message)` +- Validator: UserId required; Code required, exactly 6 digits + +**ExternalLoginCommand** +- Input: `(Provider, ProviderUserId, Email, Name?, PictureUrl?)` +- Output: `ExternalLoginResult(UserId, Email, IsNewUser, Success, Message)` +- Validator: Provider in [Google, Facebook, Apple]; ProviderUserId required max 256; Email required valid max 256; Name max 200; PictureUrl valid URI max 2048 + +### User Commands + +**UpdateUserCommand** +- Input: `(UserId, FirstName?, LastName?)` +- Output: `UpdateUserCommandResult(UserId, Email, FirstName, LastName, FullName)` +- Validator: UserId required; FirstName/LastName max 100 + +**DeleteUserCommand** +- Input: `(UserId)` +- Output: `DeleteUserCommandResult(Success, Message)` +- Validator: UserId required + +### Role Commands + +**CreateRoleCommand** +- Input: `(Name, Description?)` +- Output: `CreateRoleCommandResult(Id, Name, Description, CreatedAt)` +- Validator: Name required, max 100, alphanumeric + _-. only; Description max 500 + +**UpdateRoleCommand** +- Input: `(RoleId, Name, Description?)` +- Output: `UpdateRoleCommandResult(Id, Name, Description)` +- Validator: RoleId required; Name required max 100 alphanumeric; Description max 500 + +**DeleteRoleCommand** +- Input: `(RoleId)` +- Validator: RoleId required. System roles cannot be deleted. + +**AssignRoleToUserCommand** +- Input: `(UserId, RoleName)` +- Validator: UserId required; RoleName required max 100 + +**RemoveRoleFromUserCommand** +- Input: `(UserId, RoleName)` +- Validator: UserId required; RoleName required max 100 + +### Organization Commands + +**CreateOrganizationCommand** +- Input: `(Name, Slug, Description?, ParentOrganizationId?)` +- Output: `CreateOrganizationResult(Id, Name, Slug, Description, ParentOrganizationId, Status, CreatedAt)` + +**UpdateOrganizationCommand** +- Input: `(Id, Name, Description?)` +- Output: `UpdateOrganizationResult(Id, Name, Description, UpdatedAt)` + +**ArchiveOrganizationCommand** +- Input: `(Id)` +- Logic: Soft-deletes by setting status to Archived. Cannot archive if has children. + +### Group Commands + +**CreateGroupCommand** +- Input: `(Name, OrganizationId, Description?)` +- Output: `CreateGroupResult(Id, Name, Description, OrganizationId, CreatedAt)` + +**DeleteGroupCommand** +- Input: `(GroupId)` +- Logic: Soft-delete via IsDeleted flag + +**AddGroupMemberCommand** +- Input: `(GroupId, UserId, RoleId?)` — RoleId: 1=Member, 2=Admin, 3=Owner +- Output: `AddGroupMemberResult(GroupId, UserId, Role, JoinedAt)` + +**RemoveGroupMemberCommand** +- Input: `(GroupId, UserId)` +- Logic: Cannot remove last owner + +### Access Request Commands + +**CreateAccessRequestCommand** +- Input: `(RequesterId, ResourceType, ResourceId, RequestedPermission, Justification?, Priority?, ApproverIds)` +- Priority: 1=Low, 2=Medium, 3=High, 4=Critical + +**SubmitAccessRequestCommand** — Input: `(RequestId)` +**ApproveAccessRequestCommand** — Input: `(RequestId, ApproverId, Comments?)` +**RejectAccessRequestCommand** — Input: `(RequestId, ApproverId, Comments?)` +**CancelAccessRequestCommand** — Input: `(RequestId)` + +### Access Review Commands + +**CreateAccessReviewCommand** — Input: `(Name, Description?, OwnerId, Scope, DueDate)` +**AddAccessReviewItemCommand** — Input: `(ReviewId, UserId, ResourceType, ResourceId, Permission)` +**StartAccessReviewCommand** — Input: `(ReviewId)` +**ReviewItemCommand** — Input: `(ReviewId, ItemId, ReviewerUserId, Certify, Comments?)` +**CompleteAccessReviewCommand** — Input: `(ReviewId)` + +### Privileged Access Commands + +**RequestPrivilegedAccessCommand** — Input: `(UserId, RoleId, ResourceScope, Reason?, GrantedByUserId, DurationMinutes)` — Duration: 5-480 min +**RevokePrivilegedAccessCommand** — Input: `(GrantId, RevokedByUserId, Reason?)` + +### Verification Commands + +**RequestPhoneVerificationCommand** — Input: `(UserId, PhoneNumber)` — Generates 6-digit OTP, hashed with SHA256 +**RequestEmailVerificationCommand** — Input: `(UserId, Email)` — Same OTP flow +**ConfirmVerificationCommand** — Input: `(VerificationId, Code)` — Max 5 attempts, 10-min expiry + +### Compliance Commands + +**GenerateComplianceReportCommand** — Input: `(Name, ReportTypeId, GeneratedByUserId)` +**CompleteComplianceReportCommand** — Input: `(ReportId, TotalChecks, PassedChecks, Summary?)` + +--- + +## Queries (Read Operations) + +**GetUsersQuery** — Input: `(PageNumber, PageSize)` — Returns paginated user list +**GetUserByIdQuery** — Input: `(UserId)` — Returns single user or null +**GetRolesQuery** — Input: `(PageNumber, PageSize)` — Returns paginated role list +**GetRoleByIdQuery** — Input: `(RoleId)` — Returns single role or null +**GetGroupByIdQuery** — Input: `(GroupId)` — Returns group with members/permissions +**GetGroupsByOrganizationQuery** — Input: `(OrganizationId)` — Returns list of groups +**GetOrganizationByIdQuery** — Input: `(OrgId)` — Returns organization or null +**GetOrganizationBySlugQuery** — Input: `(Slug)` — Returns organization by slug +**GetOrganizationHierarchyQuery** — Input: `(OrgId)` — Returns hierarchy chain +**GetChildOrganizationsQuery** — Input: `(OrgId)` — Returns direct children +**GetAccessRequestByIdQuery** — Input: `(RequestId)` — Returns access request with approvers +**GetMyAccessRequestsQuery** — Input: `(RequesterId)` — Returns requester's requests +**GetPendingApprovalsQuery** — Input: `(ApproverId)` — Returns requests pending for approver +**GetUserProfileQuery** — Input: `(UserId)` — Returns profile with attributes, address, phone +**GetAuditLogsQuery** — Input: `(FromDate?, ToDate?, EventTypeId?, ActorId?, ResourceType?, Skip, Take)` — Filtered audit logs +**GetComplianceReportsQuery** — Input: `(ReportTypeId?, Take)` — Returns compliance reports +**GetComplianceReportByIdQuery** — Input: `(ReportId)` — Returns report with violations +**GetUnresolvedViolationsQuery** — Returns all unresolved violations +**GetActivePrivilegedAccessQuery** — Input: `(UserId)` — Returns active PAM grants + +--- + +## Domain Model + +### ApplicationUser (extends IdentityUser\, IAggregateRoot) +- **Private Fields**: `_firstName`, `_lastName`, `_status` (UserStatus), `_createdAt`, `_lastLoginAt` +- **Public Getters**: FirstName, LastName, FullName, Status, StatusId, CreatedAt, LastLoginAt, DomainEvents +- **Behavior Methods**: `UpdateProfile(firstName, lastName)`, `RecordLogin()`, `Lock(until?)`, `Unlock()`, `Activate()`, `Disable()` +- **Domain Events**: `UserRegisteredDomainEvent`, `UserLoggedInDomainEvent` +- **Statuses**: Active(1), Locked(2), Disabled(3), PendingVerification(4) + +### ApplicationRole (extends IdentityRole\, IAggregateRoot) +- **Private Fields**: `_description`, `_createdAt`, `_isSystemRole` +- **Behavior Methods**: `Update(name, description?)` +- **Domain Events**: `RoleAssignedDomainEvent` +- **System Roles (seeded)**: User, PremiumUser, Merchant, MerchantStaff, MerchantAdmin, Admin, SuperAdmin, Support + +### Organization (Entity, IAggregateRoot) +- **Private Fields**: `_name`, `_slug`, `_description`, `_parentOrganizationId`, `_status`, `_settings`, `_createdAt`, `_updatedAt` +- **Behavior Methods**: `UpdateInfo(name, description)`, `UpdateSlug(slug)`, `SetParent(parentId?)`, `UpdateSettings(settings)`, `Activate()`, `Suspend()`, `Archive()` +- **Domain Events**: `OrganizationCreatedEvent(Id, Name, Slug)`, `OrganizationUpdatedEvent(Id, Name)` +- **Statuses**: Active(1), Suspended(2), PendingApproval(3), Archived(4) +- **Owned Entity**: OrganizationSettings (AllowUserRegistration, RequireEmailVerification, Require2FA, MaxUsersLimit, CustomDomain, SessionTimeoutMinutes) + +### Group (Entity, IAggregateRoot) +- **Private Fields**: `_name`, `_description`, `_organizationId`, `_createdAt`, `_updatedAt`, `_isDeleted` +- **Collections**: `_members` (GroupMember), `_permissions` (GroupPermission) +- **Behavior Methods**: `Update(name, description)`, `AddMember(userId, role?, addedBy?)`, `RemoveMember(userId)`, `ChangeMemberRole(userId, newRole)`, `AddPermission(permission, resource?, grantedBy?)`, `RemovePermission(permission, resource?)`, `HasPermission(permission, resource?)`, `Delete()`, `Restore()` +- **Domain Events**: `GroupCreatedEvent`, `MemberAddedToGroupEvent`, `MemberRemovedFromGroupEvent` +- **Soft Delete**: Query filter on IsDeleted + +### GroupMember (Entity) +- Fields: GroupId, UserId, Role (GroupRole), JoinedAt, AddedByUserId +- GroupRole: Member(1), Admin(2), Owner(3) + +### GroupPermission (Entity) +- Fields: GroupId, Permission, Resource, GrantedAt, GrantedByUserId + +### UserProfile (Entity) +- **Private Fields**: `_userId`, `_bio`, `_avatarUrl`, `_phoneNumber` (PhoneNumber VO), `_address` (Address VO), `_timezone`, `_locale`, `_dateOfBirth`, `_createdAt`, `_updatedAt` +- **Collections**: `_attributes` (ProfileAttribute) +- **Behavior Methods**: `UpdateBasicInfo(bio, timezone, locale)`, `SetAvatar(url)`, `SetPhoneNumber(phone)`, `SetAddress(address)`, `SetDateOfBirth(dob)`, `SetAttribute(key, value)` (String/Number/Boolean/Date/Json overloads), `RemoveAttribute(key)`, `GetAttributeValue(key)`, `GetAge()` +- **Value Objects**: PhoneNumber (CountryCode, NationalNumber), Address (Street, Street2, City, State, PostalCode, Country) + +### ProfileAttribute (Entity) +- Fields: UserProfileId, Key, Value, ValueType (ProfileAttributeType) +- ProfileAttributeType: String(1), Number(2), Boolean(3), Date(4), Json(5) + +### IdentityVerification (Entity, IAggregateRoot) +- **Private Fields**: `_userId`, `_type` (VerificationType), `_status` (VerificationStatus), `_verificationData`, `_verificationCodeHash`, `_requestedAt`, `_verifiedAt`, `_expiresAt`, `_attemptCount`, `_rejectionReason`, `_metadata` +- **Factory Methods**: `CreatePhoneVerification(userId, phoneNumber)` returns (Verification, OTP), `CreateEmailVerification(userId, email)` returns (Verification, OTP), `CreateDocumentVerification(userId, documentUrl, documentType?)` +- **Behavior Methods**: `VerifyCode(code)` (max 5 attempts), `MarkAsVerified()`, `MarkAsRejected(reason)`, `MarkAsExpired()`, `Cancel()` +- **Domain Events**: `VerificationRequestedEvent`, `VerificationCompletedEvent` +- **OTP**: 6-digit, SHA256 hashed, 10-min expiry +- **VerificationType**: Email(1), Phone(2), Document(3), Identity(4) +- **VerificationStatus**: Pending(1), InProgress(2), Verified(3), Rejected(4), Expired(5), Cancelled(6) + +### AccessRequest (Entity, IAggregateRoot) +- **Private Fields**: `_requesterId`, `_resourceType`, `_resourceId`, `_requestedPermission`, `_justification`, `_status`, `_priority`, `_createdAt`, `_submittedAt`, `_resolvedAt`, `_expiresAt` +- **Collections**: `_approvers` (AccessRequestApprover) +- **Factory**: `Create(requesterId, resourceType, resourceId, permission, justification?, priority?)` +- **Behavior Methods**: `AddApprover(userId)`, `Submit(expirationDays=7)`, `Approve(approverId, comments?)`, `Reject(approverId, reason?)`, `Cancel()`, `Expire()`, `UpdateJustification(justification)` +- **Domain Events**: `AccessRequestCreatedEvent`, `AccessRequestSubmittedEvent`, `AccessRequestApprovedEvent`, `AccessRequestRejectedEvent` +- **Status Flow**: Draft -> Pending -> Approved/Rejected/Expired/Cancelled +- **Priority**: Low(1), Medium(2), High(3), Critical(4) + +### AccessReview (Entity, IAggregateRoot) +- **Private Fields**: `_name`, `_description`, `_ownerId`, `_scope`, `_status`, `_createdAt`, `_startedAt`, `_dueDate`, `_completedAt` +- **Collections**: `_items` (AccessReviewItem) +- **Behavior Methods**: `AddItem(userId, resourceType, resourceId, permission)`, `Start()`, `ReviewItem(itemId, reviewerUserId, certify, comments?)`, `Complete()`, `Cancel()` +- **Domain Events**: `AccessReviewCreatedEvent`, `AccessReviewStartedEvent`, `AccessReviewCompletedEvent` +- **Status Flow**: Draft -> Active -> Completed/Cancelled +- **ReviewDecision**: Pending, Certify, Revoke + +### PrivilegedAccessGrant (Entity, IAggregateRoot) +- **Private Fields**: `_userId`, `_roleId`, `_resourceScope`, `_reason`, `_grantedByUserId`, `_status`, `_createdAt`, `_startsAt`, `_expiresAt`, `_revokedAt`, `_revokedByUserId`, `_revocationReason` +- **Factory**: `Create(userId, roleId, resourceScope, reason?, grantedByUserId, durationMinutes=60)` (5-480 min), `CreateScheduled(...)` for future activation +- **Behavior Methods**: `Activate()`, `Revoke(revokedByUserId, reason?)`, `Expire()`, `Extend(additionalMinutes, extendedByUserId)` (5-240 min) +- **Domain Events**: `PrivilegedAccessGrantedEvent`, `PrivilegedAccessRevokedEvent` +- **Status**: Pending, Active, Expired, Revoked + +### AuditLog (Entity, IAggregateRoot) +- **Fields**: EventType, ActorId, ActorEmail, ResourceType, ResourceId, Action, Details, IpAddress, UserAgent, Success, Timestamp +- **Factory**: `Create(eventType, resourceType, ...)`, `LoginEvent(...)`, `AccessGrantedEvent(...)` +- **AuditEventType**: Login(1), LoginFailed(2), Logout(3), PasswordChanged(4), PasswordReset(5), UserCreated(6), UserUpdated(7), UserDeleted(8), RoleAssigned(9), RoleRemoved(10), AccessGranted(11), AccessRevoked(12), PolicyViolation(13), MfaEnabled(14), MfaDisabled(15) + +### ComplianceReport (Entity, IAggregateRoot) +- **Fields**: Name, ReportType, Status, GeneratedByUserId, CreatedAt, CompletedAt, Summary, TotalChecks, PassedChecks, FailedChecks +- **Collections**: `_violations` (ComplianceViolation) +- **Behavior Methods**: `StartGenerating()`, `SetResults(totalChecks, passedChecks, summary?)`, `AddViolation(rule, severity, description, remediation?)`, `Complete()`, `Fail(reason?)` +- **Domain Events**: `ComplianceReportCreatedEvent`, `ComplianceReportCompletedEvent` +- **ComplianceReportType**: GDPR, SOC2, HIPAA, ISO27001, PCI_DSS, Custom +- **ComplianceReportStatus**: Pending, Generating, Completed, Failed +- **ViolationSeverity**: Low, Medium, High, Critical + +--- + +## Database Schema + +### Identity Tables (ASP.NET Core Identity, custom table names) + +| Table | Columns | Notes | +|-------|---------|-------| +| `users` | id (Guid PK), email, normalized_email, username, normalized_username, password_hash, security_stamp, concurrency_stamp, phone_number, phone_number_confirmed, two_factor_enabled, lockout_end, lockout_enabled, access_failed_count, email_confirmed, **first_name** (max 100), **last_name** (max 100), **created_at**, **last_login_at**, **status_id** (FK) | Custom fields via private field mapping | +| `roles` | id (Guid PK), name, normalized_name, concurrency_stamp, **description**, **created_at**, **is_system_role** | Custom fields added | +| `user_roles` | user_id, role_id | Join table | +| `user_claims` | id, user_id, claim_type, claim_value | User claims | +| `user_logins` | login_provider, provider_key, provider_display_name, user_id | External logins | +| `user_tokens` | user_id, login_provider, name, value | User tokens | +| `role_claims` | id, role_id, claim_type, claim_value | Role claims | +| `user_statuses` | id (int PK), name (max 50) | Seeded: Active(1), Locked(2), Disabled(3), PendingVerification(4) | + +### Organizations + +| Table | Columns | Indexes | +|-------|---------|---------| +| `organizations` | id (Guid PK), name (max 200), slug (max 100), description (max 1000), parent_organization_id (FK self), status_id (int), settings_allow_user_registration, settings_require_email_verification, settings_require_2fa, settings_max_users_limit, settings_custom_domain (max 255), settings_session_timeout_minutes, created_at, updated_at | UNIQUE ix_organizations_slug; FK fk_organizations_parent (Restrict delete) | +| `organization_statuses` | id (int PK), name (max 50) | Seeded: Active(1), Suspended(2), PendingApproval(3), Archived(4) | + +### Groups + +| Table | Columns | Indexes | +|-------|---------|---------| +| `groups` | id (Guid PK), name (max 200), description (max 1000), organization_id (Guid), is_deleted (bool, default false), created_at, updated_at | ix_groups_organization_id; Query filter: is_deleted = false | +| `group_members` | id (Guid PK), group_id (Guid FK), user_id (Guid), role_id (int), joined_at, added_by_user_id (Guid?) | UNIQUE ix_group_members_group_user; ix_group_members_user_id | +| `group_permissions` | id (Guid PK), group_id (Guid FK), permission (max 100), resource (max 500), granted_at, granted_by_user_id (Guid?) | UNIQUE ix_group_permissions_unique (group_id, permission, resource) | +| `group_roles` | id (int PK), name (max 50) | Seeded: Member(1), Admin(2), Owner(3) | + +### User Profiles + +| Table | Columns | Indexes | +|-------|---------|---------| +| `user_profiles` | id (Guid PK), user_id (Guid), bio (max 2000), avatar_url (max 500), timezone (max 50), locale (max 10), date_of_birth, phone_country_code (max 5), phone_national_number (max 15), address_street (max 200), address_street2 (max 200), address_city (max 100), address_state (max 100), address_postal_code (max 20), address_country (max 2, ISO 3166-1), created_at, updated_at | UNIQUE ix_user_profiles_user_id | +| `profile_attributes` | id (Guid PK), user_profile_id (Guid FK Cascade), key (max 100), value (max 4000), value_type_id (int), created_at, updated_at | UNIQUE ix_profile_attributes_profile_key (user_profile_id, key) | +| `profile_attribute_types` | id (int PK), name (max 50) | Seeded: String(1), Number(2), Boolean(3), Date(4), Json(5) | + +### Identity Verification + +| Table | Columns | Indexes | +|-------|---------|---------| +| `identity_verifications` | id (Guid PK), user_id (Guid), type_id (int), status_id (int), verification_data (max 1000), verification_code_hash (max 100), requested_at, verified_at, expires_at, attempt_count (default 0), rejection_reason (max 500), metadata (jsonb) | ix_identity_verifications_user_id; ix_identity_verifications_user_type_status | +| `verification_types` | id (int PK), name (max 50) | Seeded: Email(1), Phone(2), Document(3), Identity(4) | +| `verification_statuses` | id (int PK), name (max 50) | Seeded: Pending(1), InProgress(2), Verified(3), Rejected(4), Expired(5), Cancelled(6) | + +### Access Requests + +| Table | Columns | Indexes | +|-------|---------|---------| +| `AccessRequests` | Id (Guid PK), RequesterId (Guid), ResourceType (max 100), ResourceId (Guid), RequestedPermission (max 100), Justification (max 2000), StatusId (int, value conversion), PriorityId (int, value conversion), CreatedAt, SubmittedAt, ResolvedAt, ExpiresAt | IX_AccessRequests_RequesterId; IX_AccessRequests_Resource (ResourceType, ResourceId) | +| `AccessRequestApprovers` | Id (Guid PK), RequestId (Guid FK Cascade), UserId (Guid), ApprovalOrder (int), StatusId (int, value conversion), RespondedAt, Comments (max 1000) | IX_AccessRequestApprovers_UserId | + +### Access Reviews + +| Table | Columns | Indexes | +|-------|---------|---------| +| `AccessReviews` | Id (Guid PK), Name (max 200), Description (max 1000), OwnerId (Guid), Scope (max 200), StatusId (int), CreatedAt, StartedAt, DueDate, CompletedAt | IX_AccessReviews_OwnerId | +| `AccessReviewItems` | Id (Guid PK), ReviewId (Guid FK Cascade), UserId (Guid), ResourceType (max 100), ResourceId (Guid), Permission (max 100), DecisionId (int), ReviewedByUserId (Guid?), ReviewedAt, Comments (max 500) | IX_AccessReviewItems_UserId | + +### Privileged Access + +| Table | Columns | Indexes | +|-------|---------|---------| +| `PrivilegedAccessGrants` | Id (Guid PK), UserId (Guid), RoleId (Guid), ResourceScope (max 200), Reason (max 500), GrantedByUserId (Guid), StatusId (int), CreatedAt, StartsAt, ExpiresAt, RevokedAt, RevokedByUserId (Guid?), RevocationReason (max 500) | IX_PrivilegedAccessGrants_UserId; IX_PrivilegedAccessGrants_Active (UserId, RoleId, Status) | + +### Audit & Compliance + +| Table | Columns | Indexes | +|-------|---------|---------| +| `AuditLogs` | Id (Guid PK), EventTypeId (int), ActorId (Guid?), ActorEmail (max 256), ResourceType (max 100), ResourceId (Guid?), Action (max 200), Details (max 4000), IpAddress (max 45), UserAgent (max 500), Success (bool), Timestamp | IX_AuditLogs_Timestamp; IX_AuditLogs_ActorId; IX_AuditLogs_EventType; IX_AuditLogs_Resource | +| `ComplianceReports` | Id (Guid PK), Name (max 200), ReportTypeId (int), StatusId (int), GeneratedByUserId (Guid), CreatedAt, CompletedAt, Summary (max 4000), TotalChecks, PassedChecks, FailedChecks | IX_ComplianceReports_CreatedAt | +| `ComplianceViolations` | Id (Guid PK), ReportId (Guid FK Cascade), Rule (max 200), SeverityId (int), Description (max 1000), Remediation (max 1000), AffectedResource (max 200), Resolved (bool), ResolvedAt | — | + +--- + +## Dependencies + +### External Services Called +- **Redis** (167.114.174.113:6379) — Session/token caching via `ICacheService` / `RedisCacheService` +- **SMTP** (smtp.mailgun.org:587) — Email verification via `IEmailService` / `SmtpEmailService` +- **Google OAuth** — External login (ClientId/Secret from config, currently empty) +- **Facebook OAuth** — External login (AppId/Secret from config, currently empty) + +### Infrastructure Services +- **ITwoFactorService** / `TotpTwoFactorService` — TOTP-based 2FA with QR code generation +- **ISocialLoginService** / `SocialLoginService` — Social login orchestration +- **IVerificationOtpDispatcher** — Dispatches OTP codes (registered in Program.cs) +- **ICacheService** / `RedisCacheService` — Redis-backed caching + +### Domain Events Raised (potential cross-service integration points) +- `UserRegisteredDomainEvent` — User registered +- `UserLoggedInDomainEvent` — User logged in +- `RoleAssignedDomainEvent` — Role assigned to user +- `OrganizationCreatedEvent` / `OrganizationUpdatedEvent` +- `GroupCreatedEvent` / `MemberAddedToGroupEvent` / `MemberRemovedFromGroupEvent` +- `VerificationRequestedEvent` / `VerificationCompletedEvent` +- `AccessRequestCreatedEvent` / `AccessRequestSubmittedEvent` / `AccessRequestApprovedEvent` / `AccessRequestRejectedEvent` +- `AccessReviewCreatedEvent` / `AccessReviewStartedEvent` / `AccessReviewCompletedEvent` +- `PrivilegedAccessGrantedEvent` / `PrivilegedAccessRevokedEvent` +- `ComplianceReportCreatedEvent` / `ComplianceReportCompletedEvent` + +### Repositories (10 total) +| Interface | Implementation | Aggregate | +|-----------|---------------|-----------| +| `IUserRepository` | `UserRepository` | ApplicationUser | +| `IRoleRepository` | `RoleRepository` | ApplicationRole | +| `IOrganizationRepository` | `OrganizationRepository` | Organization | +| `IGroupRepository` | `GroupRepository` | Group | +| `IIdentityVerificationRepository` | `IdentityVerificationRepository` | IdentityVerification | +| `IAccessRequestRepository` | `AccessRequestRepository` | AccessRequest | +| `IAccessReviewRepository` | `AccessReviewRepository` | AccessReview | +| `IPrivilegedAccessRepository` | `PrivilegedAccessRepository` | PrivilegedAccessGrant | +| `IAuditLogRepository` | `AuditLogRepository` | AuditLog | +| `IComplianceReportRepository` | `ComplianceReportRepository` | ComplianceReport | + +--- + +## IdentityServer Configuration + +### OAuth2 Clients +| Client ID | Grant Type | Token Lifetime | Notes | +|-----------|-----------|---------------|-------| +| `web-app` | Authorization Code + PKCE | 15 min (access), 7 days (refresh sliding) | Requires client secret | +| `mobile-app` | Authorization Code + PKCE | 15 min (access), 7 days (refresh sliding) | Public client, no secret | +| `service-client` | Client Credentials | Default | Service-to-service | +| `password-client` | Resource Owner Password | **8 hours** (access), 7 days (refresh) | Legacy/admin sessions | +| `swagger-ui` | Resource Owner Password | 1 hour (access), 1 day (refresh) | Testing convenience | + +### Scopes +- `openid`, `profile`, `email`, `roles` (identity resources) +- `api` (API scope with role, email, name claims) + +### API Resources +- `iam-api`, `goodgo-api`, `goodgo-services` — all use `api` scope with role/email/name claims + +--- + +## MediatR Pipeline Behaviors + +1. **LoggingBehavior** — Request/response logging with execution time +2. **ValidatorBehavior** — FluentValidation in pipeline (throws ValidationException) +3. **TransactionBehavior** — Auto-wraps Commands in DB transactions (skips Queries) + +--- + +## Authorization Policies + +| Policy | Requirement | +|--------|------------| +| `RequireAdmin` | User must have "Admin" or "SuperAdmin" role | +| `RequireSuperAdmin` | User must have "SuperAdmin" role | +| `RequireAuditor` | User must have "Admin", "SuperAdmin", or "Auditor" role | +| `OwnerOrAdmin` | User must be the resource owner or have Admin role | +| `LocalApi` | Bearer-authenticated user with "openid" scope | + +--- + +## Error Handling (ProblemDetails RFC 7807) + +| Exception | HTTP Status | +|-----------|-------------| +| `FluentValidation.ValidationException` | 400 Bad Request | +| `DuplicateResourceException` | 409 Conflict | +| `EntityNotFoundException` | 404 Not Found | +| `AuthenticationFailedException` | 401 Unauthorized | +| `BusinessRuleException` | 422 Unprocessable Entity | + +--- + +## Configuration + +### appsettings.json Key Settings +``` +ConnectionStrings:DefaultConnection — PostgreSQL (Neon cloud) +Redis:Host/Port/Password/Database — Remote Redis instance +Jwt:Secret/Issuer/Audience — JWT config (15 min access, 7 day refresh) +Email:SmtpServer/SmtpPort/SmtpLogin — Mailgun SMTP (smtp.mailgun.org:587) +TwoFactor:Issuer/CodeLength/Validity — TOTP config (6 digits, 30s validity) +SocialLogin:Google/Facebook — OAuth credentials (currently empty) +IdentityServer:Authority/IssuerUri — http://localhost:5001 / http://iam-service +``` + +### Identity Password Policy +- Minimum 8 characters +- Require digit, lowercase, uppercase, non-alphanumeric +- Lockout: 15 min after 5 failed attempts + +### Data Seeding (on startup) +System roles seeded via `DataSeeder.SeedRolesAsync()`: +User, PremiumUser, Merchant, MerchantStaff, MerchantAdmin, Admin, SuperAdmin, Support + +### Migrations (7 total) +1. `20260112104902_InitialCreate` — Users, roles, Identity tables +2. `20260114074030_Phase2_IdentityManagement` — Organizations, groups, verifications +3. `20260114074751_AddProfileAttributes` — Profile attributes system +4. `20260114084200_Phase3A_AccessRequests` — Access request workflow +5. `20260114084754_Phase3A_AccessRequests_ValueConversion` — Status/Priority value converters +6. `20260114085640_Phase3B_AccessReviewsAndPAM` — Access reviews, privileged access +7. `20260114091114_Phase4A_AuditAndCompliance` — Audit logs, compliance reports + +### Auto-Migration +EF Core migrations are auto-applied on startup in Development environment. diff --git a/services/inventory-service-net/SERVICE_DOCS.md b/services/inventory-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..9703deb3 --- /dev/null +++ b/services/inventory-service-net/SERVICE_DOCS.md @@ -0,0 +1,580 @@ +# Inventory Service - Service Documentation + +> Auto-generated from source code audit on 2026-03-13 + +## Overview + +**Service**: inventory-service-net +**Framework**: .NET 10.0 (C# 14), ASP.NET Core Web API +**Architecture**: Clean Architecture + CQRS (MediatR 12.4.1) +**Database**: PostgreSQL (Neon) via EF Core 10 + Npgsql 10 +**Port**: 5018 (Development) +**Base Route**: `api/v1/inventory` +**Database Name**: `inventory_service` + +The Inventory Service manages stock levels, reservations, transactions, and low-stock alerts for shop inventory items. It supports both catalog-linked finished goods and standalone raw materials/consumables. Features multi-tenant row-level security via global query filters and PostgreSQL session variables, idempotent bulk deduction for kitchen ticket integration, and a full audit trail of all inventory movements. + +--- + +## API Endpoints + +| Method | Route | Description | Request Body / Query | Response | +|--------|-------|-------------|---------------------|----------| +| GET | `/api/v1/inventory` | Get inventory by shop (paginated) | `?shopId={guid}&skip=0&take=50` | `ApiResponse>` | +| GET | `/api/v1/inventory/{productId}` | Get stock level by product+shop | `?shopId={guid}` | `ApiResponse` | +| POST | `/api/v1/inventory/stock/bulk` | Bulk stock check (POS cart validation) | `BulkStockCheckRequest` | `ApiResponse>` | +| PUT | `/api/v1/inventory/alerts` | Set low stock alert threshold | `SetLowStockAlertRequest` | `ApiResponse` | +| POST | `/api/v1/inventory/items` | Create new inventory item | `CreateInventoryItemRequest` | `ApiResponse` (201 Created) | +| POST | `/api/v1/inventory/stock-in` | Stock in (add inventory) | `StockInRequest` | `ApiResponse` | +| POST | `/api/v1/inventory/stock-out` | Stock out (by productId+shopId) | `StockOutRequest` | `ApiResponse` | +| POST | `/api/v1/inventory/stock-out-by-id` | Stock out by inventory item ID | `StockOutByIdRequest` | `ApiResponse` | +| POST | `/api/v1/inventory/reserve` | Reserve stock for order | `ReserveStockRequest` | `ApiResponse` | +| POST | `/api/v1/inventory/release` | Release stock reservation | `ReleaseReservationRequest` | `ApiResponse` | +| POST | `/api/v1/inventory/adjust` | Manual stock adjustment | `AdjustStockRequest` | `ApiResponse` | +| POST | `/api/v1/inventory/wastage` | Record wastage/shrinkage | `RecordWastageRequest` | `ApiResponse` | +| POST | `/api/v1/inventory/stocktake` | Perform stocktake (inventory count) | `StocktakeRequest` | `ApiResponse` | +| POST | `/api/v1/inventory/deduct` | Bulk deduct (kitchen ticket served) | `DeductInventoryRequest` | `ApiResponse` | +| DELETE | `/api/v1/inventory/items/{id}` | Delete inventory item | - | `ApiResponse` | +| GET | `/api/v1/inventory/transactions` | Get transaction history | `?inventoryItemId={guid}&shopId={guid}&skip=0&take=50` | `ApiResponse>` | +| GET | `/api/v1/inventory/low-stock` | Get low stock items | `?shopId={guid}&skip=0&take=50` | `ApiResponse>` | +| GET | `/health` | Health check (all) | - | Healthy/Unhealthy | +| GET | `/health/live` | Liveness probe | - | Healthy | +| GET | `/health/ready` | Readiness probe (DB check) | - | Healthy/Unhealthy | + +--- + +## Commands + +### CreateInventoryItemCommand +- **Input**: `ShopId (Guid)`, `Name (string)`, `ItemTypeId (int)`, `Unit (string)`, `CostPerUnit (decimal)`, `InitialQuantity (int)`, `ReorderLevel (int)`, `SupplierName (string?)`, `ExpiryDate (DateTime?)` +- **Returns**: `Guid` (new item ID) +- **Logic**: Resolves `ItemType` from `Enumeration.FromValue(ItemTypeId)`. Creates `InventoryItem` with full details (raw materials / consumables constructor). Persists via repository + UnitOfWork. +- **Validator**: `CreateInventoryItemCommandValidator` -- ShopId NotEmpty, Name NotEmpty + MaxLength(200), ItemTypeId InclusiveBetween(1,3), Unit NotEmpty + MaxLength(20), CostPerUnit >= 0, InitialQuantity >= 0, ReorderLevel >= 0. + +### StockInCommand +- **Input**: `ProductId (Guid)`, `ShopId (Guid)`, `Amount (int)`, `Notes (string?)`, `ReferenceId (Guid?)`, `InvoiceImageUrl (string?)`, `UnitCost (decimal?)` +- **Returns**: `Guid` (inventory item ID) +- **Logic**: Finds existing item by ProductId+ShopId, or creates a new `InventoryItem` (FinishedGood type). Calls `item.StockIn()` which increments quantity and creates an `InventoryTransaction(type=In)`. Raises `StockChangedDomainEvent`. +- **Validator**: `StockInCommandValidator` -- ProductId NotEmpty, ShopId NotEmpty, Amount > 0. + +### StockOutCommand +- **Input**: `ProductId (Guid)`, `ShopId (Guid)`, `Amount (int)`, `Notes (string?)`, `ReferenceId (Guid?)` +- **Returns**: `bool` +- **Logic**: Finds item by ProductId+ShopId. Calls `item.StockOut()` which validates available quantity, decrements quantity, creates `InventoryTransaction(type=Out)`. Raises `StockChangedDomainEvent`. Returns false if item not found. +- **Validator**: `StockOutCommandValidator` -- ProductId NotEmpty, ShopId NotEmpty, Amount > 0. + +### StockOutByIdCommand +- **Input**: `InventoryItemId (Guid)`, `Amount (int)`, `Notes (string?)`, `ReferenceId (Guid?)` +- **Returns**: `bool` +- **Logic**: Finds item by ID directly (for recipe ingredient deduction). Calls `item.StockOut()`. Returns false if not found. +- **Validator**: None (no dedicated validator). + +### ReserveStockCommand +- **Input**: `ProductId (Guid)`, `ShopId (Guid)`, `Amount (int)`, `OrderId (Guid)` +- **Returns**: `bool` +- **Logic**: Finds item by ProductId+ShopId. Calls `item.Reserve()` which validates available quantity, increments `_reservedQuantity`, creates `InventoryTransaction(type=Reserve)`. Returns false if not found. +- **Validator**: `ReserveStockCommandValidator` -- ProductId NotEmpty, ShopId NotEmpty, Amount > 0, OrderId NotEmpty. + +### ReleaseReservationCommand +- **Input**: `ProductId (Guid)`, `ShopId (Guid)`, `Amount (int)`, `OrderId (Guid)` +- **Returns**: `bool` +- **Logic**: Finds item by ProductId+ShopId. Calls `item.ReleaseReservation()` which validates reserved >= amount, decrements `_reservedQuantity`, creates `InventoryTransaction(type=Release)`. Returns false if not found. +- **Validator**: `ReleaseReservationCommandValidator` -- ProductId NotEmpty, ShopId NotEmpty, Amount > 0, OrderId NotEmpty. + +### AdjustStockCommand +- **Input**: `ProductId (Guid)`, `ShopId (Guid)`, `NewQuantity (int)`, `Notes (string)` +- **Returns**: `bool` +- **Logic**: Finds item by ProductId+ShopId. Calls `item.Adjust()` which sets quantity directly and creates `InventoryTransaction(type=Adjustment)` with diff. Raises `StockChangedDomainEvent`. Returns false if not found. +- **Validator**: `AdjustStockCommandValidator` -- ProductId NotEmpty, ShopId NotEmpty, NewQuantity >= 0, Notes NotEmpty. + +### SetLowStockAlertCommand +- **Input**: `ShopId (Guid)`, `ProductId (Guid)`, `MinimumLevel (int)` +- **Returns**: `bool` +- **Logic**: Finds item by ProductId+ShopId. Calls `item.SetReorderLevel()` which validates >= 0 and updates `_reorderLevel`. Returns false if not found. +- **Validator**: `SetLowStockAlertCommandValidator` -- ShopId NotEmpty, ProductId NotEmpty, MinimumLevel >= 0. + +### RecordWastageCommand +- **Input**: `InventoryItemId (Guid)`, `Amount (int)`, `Reason (string)`, `Notes (string?)` +- **Returns**: `bool` +- **Logic**: Finds item by ID. Calls `item.RecordWastage()` which validates amount > 0 and reason non-empty, decrements quantity, creates `InventoryTransaction(type=Wastage)`. Raises `StockChangedDomainEvent`. Returns false if not found. +- **Validator**: None (domain validation only). + +### StocktakeCommand +- **Input**: `ShopId (Guid)`, `Items (List)` where `StocktakeItem(InventoryItemId, CountedQuantity)` +- **Returns**: `StocktakeResult(Discrepancies, TotalItemsCounted)` +- **Logic**: Iterates items, compares counted vs expected quantity, records discrepancies, calls `item.Adjust()` for each. Saves all in one transaction. +- **Validator**: None. + +### DeleteInventoryItemCommand +- **Input**: `InventoryItemId (Guid)` +- **Returns**: `bool` +- **Logic**: Finds item by ID. Calls `repository.Delete()`. Saves via `SaveEntitiesAsync`. Returns false if not found. +- **Validator**: None. + +### DeductInventoryCommand +- **Input**: `ShopId (Guid)`, `ReferenceId (Guid)`, `ReferenceType (string)`, `Reason (string)`, `Items (List)` where `DeductionItem(InventoryItemId, Amount, Unit, IngredientName)` +- **Returns**: `DeductInventoryResult(Success, ItemsDeducted, ItemsSkipped, Items)` +- **Logic**: **Idempotency check** via `IRequestManager` -- skips if ReferenceId already processed. Records request. Iterates items: if item not found, skip; if insufficient stock, partially deducts what's available; otherwise full deduction via `item.StockOut()`. Each item failure is isolated (continues with remaining). Saves all changes in one transaction via `SaveEntitiesAsync`. +- **Validator**: `DeductInventoryCommandValidator` -- ShopId NotEmpty, ReferenceId NotEmpty, ReferenceType NotEmpty, Items NotEmpty, each item: InventoryItemId NotEmpty, Amount > 0. + +--- + +## Queries + +### GetInventoryByShopQuery +- **Input**: `ShopId (Guid)`, `Skip (int, default=0)`, `Take (int, default=50)` +- **Returns**: `PagedResult` +- **Logic**: Filters by ShopId, orders by ProductId, applies skip/take pagination. + +### GetStockLevelQuery +- **Input**: `ProductId (Guid)`, `ShopId (Guid)` +- **Returns**: `InventoryItemDto?` +- **Logic**: Finds single item by ProductId+ShopId. Returns null if not found. + +### GetStockLevelsQuery (Bulk) +- **Input**: `ShopId (Guid)`, `ProductIds (List)` +- **Returns**: `IReadOnlyList` +- **Logic**: Fetches all items matching ShopId and ProductIds. Maps to `StockLevelDto`. Products without inventory records return zero stock with `IsLowStock=true`. + +### GetTransactionsQuery +- **Input**: `InventoryItemId (Guid)`, `Skip (int)`, `Take (int)` +- **Returns**: `PagedResult` +- **Logic**: Loads item with `.Include(Transactions)`, orders by CreatedAt descending, applies pagination. + +### GetTransactionsByShopQuery +- **Input**: `ShopId (Guid)`, `Skip (int)`, `Take (int)` +- **Returns**: `PagedResult` +- **Logic**: Loads all items for shop with `.Include(Transactions)`, flattens all transactions, orders by CreatedAt descending, applies pagination. + +### GetLowStockItemsQuery +- **Input**: `ShopId (Guid?, optional)`, `Skip (int)`, `Take (int)` +- **Returns**: `PagedResult` +- **Logic**: Filters items where `(Quantity - ReservedQuantity) <= ReorderLevel`. Optional ShopId filter. Orders by available quantity ascending. + +--- + +## Domain Model + +### InventoryItem (Aggregate Root) + +**Entity**: `InventoryService.Domain.AggregatesModel.InventoryAggregate.InventoryItem` +**Extends**: `Entity`, implements `IAggregateRoot` + +**Private Fields / Public Properties**: +| Field | Type | Property | Description | +|-------|------|----------|-------------| +| `_productId` | `Guid` | `ProductId` | Product ID from Catalog Service | +| `_shopId` | `Guid` | `ShopId` | Shop that owns this inventory | +| `_name` | `string?` | `Name` | Item name (for raw materials not linked to catalog) | +| `_itemType` | `ItemType` | `ItemType` | Enumeration (RawMaterial/FinishedGood/Consumable) | +| - | `int` | `ItemTypeId` | EF Core mapping for item type (private set) | +| `_unit` | `string` | `Unit` | Unit of measure (g, ml, pcs, kg, L) default "pcs" | +| `_costPerUnit` | `decimal` | `CostPerUnit` | Purchase price per unit | +| `_supplierName` | `string?` | `SupplierName` | Supplier reference | +| `_expiryDate` | `DateTime?` | `ExpiryDate` | For perishable items | +| `_quantity` | `int` | `Quantity` | Total quantity in stock | +| `_reservedQuantity` | `int` | `ReservedQuantity` | Reserved for orders | +| - (computed) | `int` | `AvailableQuantity` | `Quantity - ReservedQuantity` | +| `_reorderLevel` | `int` | `ReorderLevel` | Low stock alert threshold, default 10 | +| `_transactions` | `List` | `Transactions` | Transaction history (readonly collection) | +| `_createdAt` | `DateTime` | `CreatedAt` | Creation timestamp | +| `_updatedAt` | `DateTime?` | `UpdatedAt` | Last update timestamp | + +**Constructors**: +1. `InventoryItem(productId, shopId, reorderLevel=10)` -- For finished goods linked to catalog product. Sets ItemType=FinishedGood, Unit="pcs", Quantity=0. +2. `InventoryItem(shopId, name, itemType, unit, costPerUnit, initialQuantity, reorderLevel, supplierName?, expiryDate?)` -- For raw materials/consumables. Generates own ProductId. + +**Behavior Methods**: +| Method | Logic | Domain Event | +|--------|-------|-------------| +| `StockIn(amount, notes?, referenceId?, invoiceImageUrl?, unitCost?)` | Validates amount > 0. Increments `_quantity`. Creates Transaction(type=In). | `StockChangedDomainEvent` | +| `StockOut(amount, notes?, referenceId?)` | Validates amount > 0 and AvailableQuantity >= amount. Decrements `_quantity`. Creates Transaction(type=Out, negative qty). | `StockChangedDomainEvent` | +| `Reserve(amount, orderId)` | Validates AvailableQuantity >= amount. Increments `_reservedQuantity`. Creates Transaction(type=Reserve). | None | +| `ReleaseReservation(amount, orderId)` | Validates `_reservedQuantity >= amount`. Decrements `_reservedQuantity`. Creates Transaction(type=Release, negative qty). | None | +| `SetReorderLevel(reorderLevel)` | Validates >= 0. Updates `_reorderLevel`. | None | +| `Adjust(newQuantity, notes)` | Calculates diff. Sets `_quantity = newQuantity`. Creates Transaction(type=Adjustment, diff). | `StockChangedDomainEvent` | +| `RecordWastage(amount, reason, notes?)` | Validates amount > 0 and reason non-empty. Decrements `_quantity`. Creates Transaction(type=Wastage, negative qty). | `StockChangedDomainEvent` | +| `UpdateItemDetails(name?, unit?, costPerUnit?, supplierName?, expiryDate?)` | Updates non-null fields. Validates cost >= 0. | None | + +### InventoryTransaction (Owned Entity) + +**Entity**: `InventoryService.Domain.AggregatesModel.InventoryAggregate.InventoryTransaction` +**Extends**: `Entity` (NOT an aggregate root -- owned by InventoryItem) + +| Field | Type | Property | Description | +|-------|------|----------|-------------| +| `_inventoryItemId` | `Guid` | `InventoryItemId` | Parent inventory item | +| `_type` | `TransactionType` | `Type` | Enumeration (In/Out/Adjustment/Reserve/Release/Wastage) | +| - | `int` | `TypeId` | EF Core mapping (private set) | +| `_quantity` | `int` | `Quantity` | Positive for in/reserve, negative for out/release/wastage | +| `_referenceId` | `Guid?` | `ReferenceId` | Order ID, PO ID, etc. | +| `_notes` | `string?` | `Notes` | Transaction notes (max 500 chars) | +| `_invoiceImageUrl` | `string?` | `InvoiceImageUrl` | Invoice image URL for stock-in (max 1000 chars) | +| `_unitCost` | `decimal?` | `UnitCost` | Cost per unit at time of stock-in | +| `_createdAt` | `DateTime` | `CreatedAt` | Transaction timestamp | + +### Enumerations + +**ItemType** (Enumeration pattern): +| Id | Name | Description | +|----|------|-------------| +| 1 | RawMaterial | Ingredients for production (coffee beans, milk, sugar) | +| 2 | FinishedGood | Ready-to-sell products (Cappuccino, Espresso) | +| 3 | Consumable | Operational supplies (cups, napkins, straws) | + +**TransactionType** (Enumeration pattern): +| Id | Name | Description | +|----|------|-------------| +| 1 | In | Stock in - receiving inventory | +| 2 | Out | Stock out - releasing inventory | +| 3 | Adjustment | Manual adjustment / stocktake | +| 4 | Reserve | Reserve for order | +| 5 | Release | Release reservation | +| 6 | Wastage | Damaged, expired, spilled items | + +### Domain Events + +| Event | Payload | Raised By | +|-------|---------|-----------| +| `StockChangedDomainEvent` | `InventoryItem Item` | `StockIn`, `StockOut`, `Adjust`, `RecordWastage` | + +### Domain Exception + +`DomainException` -- thrown for business rule violations (insufficient stock, invalid amounts, empty required fields). + +--- + +## Database Schema + +**Database**: `inventory_service` (PostgreSQL) + +### Table: `inventory_items` + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| `id` | `uuid` | NOT NULL | - | Primary key (ValueGeneratedNever) | +| `product_id` | `uuid` | NOT NULL | - | Product reference (catalog or self-generated) | +| `shop_id` | `uuid` | NOT NULL | - | Shop owner | +| `name` | `varchar(200)` | NULL | - | Item name (raw materials) | +| `item_type_id` | `int` | NOT NULL | `2` | Item type (1=RawMaterial, 2=FinishedGood, 3=Consumable) | +| `unit` | `varchar(20)` | NOT NULL | `'pcs'` | Unit of measure | +| `cost_per_unit` | `numeric(18,4)` | NOT NULL | `0` | Purchase price per unit | +| `supplier_name` | `varchar(200)` | NULL | - | Supplier reference | +| `expiry_date` | `timestamptz` | NULL | - | Expiry date | +| `quantity` | `int` | NOT NULL | - | Total quantity in stock | +| `reserved_quantity` | `int` | NOT NULL | - | Quantity reserved for orders | +| `reorder_level` | `int` | NOT NULL | `10` | Low stock alert threshold | +| `updated_at` | `timestamptz` | NULL | - | Last update timestamp | + +**Indexes**: +- `PK_inventory_items` -- PRIMARY KEY on `id` +- `ix_inventory_product_id` -- INDEX on `product_id` +- `ix_inventory_shop_id` -- INDEX on `shop_id` + +**Note**: `AvailableQuantity` is computed in-memory (`Quantity - ReservedQuantity`), not stored. `CreatedAt`, `Name`, `ItemType`, `Unit`, `CostPerUnit`, `SupplierName`, `ExpiryDate` are mapped via private fields and ignored as navigation properties in EF config. + +### Table: `inventory_transactions` (Owned by InventoryItem) + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| `id` | `uuid` | NOT NULL | - | Primary key (ValueGeneratedNever) | +| `inventory_item_id` | `uuid` | NOT NULL | - | FK to inventory_items.id (CASCADE delete) | +| `type_id` | `int` | NOT NULL | - | Transaction type (1-6) | +| `quantity` | `int` | NOT NULL | - | Amount (positive for in, negative for out) | +| `reference_id` | `uuid` | NULL | - | External reference (order ID, etc.) | +| `notes` | `varchar(500)` | NULL | - | Transaction notes | +| `invoice_image_url` | `varchar(1000)` | NULL | - | Invoice image URL | +| `unit_cost` | `numeric(18,4)` | NULL | - | Unit cost at time of transaction | +| `created_at` | `timestamptz` | NOT NULL | - | Transaction timestamp | + +**Indexes**: +- `PK_inventory_transactions` -- PRIMARY KEY on `id` +- `IX_inventory_transactions_inventory_item_id` -- INDEX on `inventory_item_id` + +### Table: `client_requests` (Idempotency) + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| `id` | `uuid` | NOT NULL | - | Request identifier (ReferenceId) | +| `name` | `varchar(200)` | NOT NULL | - | Command type name | +| `time` | `timestamptz` | NOT NULL | - | Request timestamp | + +**Note**: `transaction_types` and `item_types` tables were configured as EF seed data tables but are currently **ignored** in `OnModelCreating` (`modelBuilder.Ignore()` / `modelBuilder.Ignore()`). The `transaction_types` table was dropped in migration `AddItemTypeAndCostFields`. Enumerations are resolved in-memory via the DDD Enumeration pattern. + +### Migrations History + +| Migration | Date | Description | +|-----------|------|-------------| +| `20260117180439_InitialInventory` | 2026-01-17 | Creates `inventory_items`, `inventory_transactions`, `transaction_types` tables with seed data | +| `20260305095121_AddItemTypeAndCostFields` | 2026-03-05 | Drops `transaction_types` table. Adds `name`, `item_type_id`, `unit`, `cost_per_unit`, `supplier_name`, `expiry_date` to items. Adds `invoice_image_url`, `unit_cost` to transactions. | +| `20260306175523_PhaseTwo` | 2026-03-06 | Creates `client_requests` table for idempotency tracking | + +--- + +## Multi-Tenant Architecture + +### Row-Level Security (RLS) + +The service implements two-layer tenant isolation: + +1. **EF Core Global Query Filter** (`InventoryContext.OnModelCreating`): + - Filters `InventoryItem` by `ShopId == currentShopId` from `IInventoryTenantProvider` + - Bypassed for service-to-service calls (`X-Service-Call: internal` header) and admin/superadmin roles + +2. **PostgreSQL Session Variables** (`TenantMiddleware`): + - Sets `app.current_shop_id` and `app.current_merchant_id` via `SET LOCAL` for defense-in-depth + - Skipped for service calls and admin users + +### Tenant Context Sources +- **JWT Claims**: `shop_id`, `merchant_id`, `sub` (user ID), `role` +- **HTTP Headers**: `X-Shop-Id` (fallback for shop ID), `X-Service-Call: internal` (service-to-service bypass) + +--- + +## Dependencies + +### NuGet Packages + +**API Layer** (`InventoryService.API.csproj`): +| Package | Version | +|---------|---------| +| MediatR | 12.4.1 | +| FluentValidation | 11.11.0 | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | +| Microsoft.EntityFrameworkCore.Design | 10.0.0 | +| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.1 | +| Swashbuckle.AspNetCore | 7.2.0 | +| Swashbuckle.AspNetCore.Annotations | 7.2.0 | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | +| AspNetCore.HealthChecks.Redis | 8.0.1 | +| Hellang.Middleware.ProblemDetails | 6.5.1 | +| Serilog.AspNetCore | 8.0.3 | +| Serilog.Sinks.Console | 6.0.0 | +| Serilog.Sinks.Seq | 8.0.0 | + +**Domain Layer** (`InventoryService.Domain.csproj`): +| Package | Version | +|---------|---------| +| MediatR.Contracts | 2.0.1 | + +**Infrastructure Layer** (`InventoryService.Infrastructure.csproj`): +| Package | Version | +|---------|---------| +| Microsoft.EntityFrameworkCore | 10.0.0 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.0 | +| MediatR | 12.4.1 | +| Dapper | 2.1.35 | +| Microsoft.Extensions.Http.Polly | 9.0.0 | +| Polly | 8.5.0 | +| StackExchange.Redis | 2.8.16 | + +### Project References +- `InventoryService.API` -> `InventoryService.Domain`, `InventoryService.Infrastructure` +- `InventoryService.Infrastructure` -> `InventoryService.Domain` +- `InventoryService.Domain` -> None (pure domain, no external dependencies) + +--- + +## Configuration + +### appsettings.json +```json +{ + "ConnectionStrings": { + "DefaultConnection": "" + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "...", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + } +} +``` + +### Environment Variables +- `ASPNETCORE_ENVIRONMENT` -- Development / Production +- `DATABASE_URL` -- Fallback connection string +- `Jwt:Authority` -- IAM IdentityServer URL (default: `http://localhost:5001`) + +### Middleware Pipeline Order +1. `UseSerilogRequestLogging()` +2. `UseProblemDetails()` (RFC 7807) +3. `UseSwagger()` (Development only) +4. `UseCors()` (AllowAny) +5. `UseRouting()` +6. `UseAuthentication()` (JWT Bearer via IAM IdentityServer OIDC) +7. `UseAuthorization()` +8. `UseTenantMiddleware()` (after auth -- sets PG session variables for RLS) +9. `MapHealthChecks()` (`/health`, `/health/live`, `/health/ready`) +10. `MapControllers()` +11. Auto-apply EF Core migrations on startup + +### MediatR Pipeline Behaviors (in order) +1. `LoggingBehavior` -- Logs request name, elapsed time via Stopwatch +2. `ValidatorBehavior` -- Runs FluentValidation validators, throws `ValidationException` on failure +3. `TransactionBehavior` -- Wraps Commands in DB transaction (skips Queries by name suffix), uses `ExecutionStrategy` for retry + +--- + +## DTOs + +### Request DTOs + +| DTO | Fields | +|-----|--------| +| `CreateInventoryItemRequest` | ShopId, Name, ItemTypeId, Unit, CostPerUnit, InitialQuantity(=0), ReorderLevel(=10), SupplierName?, ExpiryDate? | +| `StockInRequest` | ProductId, ShopId, Amount, Notes?, ReferenceId?, InvoiceImageUrl?, UnitCost? | +| `StockOutRequest` | ProductId, ShopId, Amount, Notes?, ReferenceId? | +| `StockOutByIdRequest` | InventoryItemId, Amount, Notes?, ReferenceId? | +| `ReserveStockRequest` | ProductId, ShopId, Amount, OrderId | +| `ReleaseReservationRequest` | ProductId, ShopId, Amount, OrderId | +| `AdjustStockRequest` | ProductId, ShopId, NewQuantity, Notes | +| `SetLowStockAlertRequest` | ShopId, ProductId, MinimumLevel | +| `RecordWastageRequest` | InventoryItemId, Amount, Reason, Notes? | +| `StocktakeRequest` | ShopId, Items(List of {InventoryItemId, CountedQuantity}) | +| `BulkStockCheckRequest` | ShopId, ProductIds(List) | +| `DeductInventoryRequest` | ShopId, ReferenceId, ReferenceType, Reason?, Items(List of {InventoryItemId, Amount, Unit, IngredientName}) | + +### Response DTOs + +| DTO | Fields | +|-----|--------| +| `InventoryItemDto` | Id, ProductId, ShopId, Name?, ItemType, ItemTypeId, Unit, CostPerUnit, SupplierName?, ExpiryDate?, Quantity, ReservedQuantity, AvailableQuantity, ReorderLevel, UpdatedAt? | +| `InventoryTransactionDto` | Id, InventoryItemId, TransactionType, Quantity, ReferenceId?, Notes?, InvoiceImageUrl?, UnitCost?, CreatedAt | +| `StockLevelDto` | ProductId, Available, Reserved, Minimum, IsLowStock | +| `StocktakeResult` | Discrepancies(List), TotalItemsCounted | +| `StocktakeDiscrepancy` | InventoryItemId, ItemName?, ExpectedQuantity, CountedQuantity, Difference | +| `DeductInventoryResult` | Success, ItemsDeducted, ItemsSkipped, Items(List) | +| `DeductionResultItem` | InventoryItemId, IngredientName, Deducted, Error? | +| `ApiResponse` | Success, Data?, Error? | +| `PagedResult` | Items, TotalCount | + +--- + +## Tests + +### Unit Tests (`InventoryService.UnitTests`) + +**File**: `Application/Commands/DeductInventoryCommandHandlerTests.cs` + +| Test | Description | +|------|-------------| +| `Handle_WithValidCommand_ShouldDeductInventory` | Full deduction of available stock | +| `Handle_WithInsufficientStock_ShouldReturnPartialDeduction` | Partial deduction when stock < requested | +| `Handle_WithNonExistentItem_ShouldReturnNotFound` | Item not found returns Success=false | +| `Handle_WithZeroStockAvailable_ShouldSkipDeduction` | Zero stock skips with error message | +| `Handle_WithDuplicateRequest_ShouldReturnIdempotentResult` | Duplicate ReferenceId detected and skipped | +| `Handle_WithMultipleItems_ShouldDeductAll` | Multiple items deducted in single operation | +| `Handle_WithMixedResults_ShouldDeductFoundItemsAndSkipMissing` | Mix of found and missing items | +| `Handle_WithItemExceptionDuringDeduction_ShouldContinueWithRemainingItems` | Error resilience -- continues after individual item failure | +| `Constructor_WithNullRepository_ShouldThrowArgumentNullException` | Null guard | +| `Constructor_WithNullRequestManager_ShouldThrowArgumentNullException` | Null guard | +| `Constructor_WithNullLogger_ShouldThrowArgumentNullException` | Null guard | +| `Handle_ShouldRecordIdempotencyRequest` | Verifies idempotency request is recorded | + +### Functional Tests (`InventoryService.FunctionalTests`) + +**Factory**: `CustomWebApplicationFactory` -- swaps PostgreSQL for InMemoryDatabase + +| Test | Description | +|------|-------------| +| `GetInventory_WithoutShopId_ShouldReturnBadRequest` | Validates shopId is required | +| `GetLowStockItems_ShouldReturnOk` | Low stock endpoint returns 200 | +| `HealthCheck_ShouldReturnHealthy` | Liveness probe returns 200 | + +--- + +## Cross-Service Integration + +| Consumer Service | Endpoint Used | Purpose | +|-----------------|---------------|---------| +| **fnb-engine-net** | `POST /api/v1/inventory/deduct` | Bulk ingredient deduction when kitchen ticket is served | +| **order-service-net** (planned) | `POST /api/v1/inventory/reserve` / `POST /api/v1/inventory/release` | Reserve stock for orders, release on cancellation | +| **POS Frontend** | `POST /api/v1/inventory/stock/bulk` | Cart validation -- check stock availability before order | +| **POS Frontend** | `GET /api/v1/inventory` | Admin inventory management dashboard | +| **POS Frontend** | `GET /api/v1/inventory/transactions` | Transaction history display | + +--- + +## File Structure + +``` +services/inventory-service-net/ + global.json # SDK: .NET 10.0.101 + src/ + InventoryService.API/ + Program.cs # DI + middleware pipeline + auto-migration + Controllers/ + InventoryController.cs # 17 endpoints (1 controller) + Application/ + Behaviors/ + LoggingBehavior.cs # Request logging with Stopwatch + ValidatorBehavior.cs # FluentValidation pipeline + TransactionBehavior.cs # Auto-transaction for commands + Commands/ + InventoryCommands.cs # 11 command records + InventoryCommandHandlers.cs # 10 command handlers + DeductInventory/ + DeductInventoryCommand.cs # Bulk deduction command + result records + DeductInventoryCommandHandler.cs # Idempotent bulk deduction handler + Queries/ + InventoryQueries.cs # 6 query records + InventoryQueryHandlers.cs # 6 query handlers + Validations/ + InventoryValidators.cs # 7 validators + DeductInventoryCommandValidator.cs # Deduct command validator + DTOs/ + InventoryDtos.cs # All DTOs, request/response records, ApiResponse + Mappers/ + InventoryMapper.cs # Extension methods: ToDto() with fallback type resolution + Infrastructure/Tenant/ + ITenantProvider.cs # Tenant context interface + HttpContextTenantProvider.cs # JWT claims + header extraction + InventoryTenantProviderAdapter.cs # API->Infrastructure adapter + Middleware/ + TenantMiddleware.cs # PostgreSQL session variable setter for RLS + InventoryService.Domain/ + AggregatesModel/InventoryAggregate/ + InventoryItem.cs # Aggregate root (344 lines) + InventoryTransaction.cs # Owned entity + IInventoryRepository.cs # Repository interface (13 methods) + ItemType.cs # Enumeration (3 values) + TransactionType.cs # Enumeration (6 values) + Events/ + InventoryDomainEvents.cs # StockChangedDomainEvent + Exceptions/ + DomainException.cs # Business rule exception + SeedWork/ + Entity.cs, IAggregateRoot.cs, IRepository.cs, IUnitOfWork.cs, Enumeration.cs, ValueObject.cs + InventoryService.Infrastructure/ + InventoryContext.cs # DbContext + IUnitOfWork + domain event dispatch + multi-tenant filter + DependencyInjection.cs # AddInfrastructure() extension + Repositories/ + InventoryRepository.cs # IInventoryRepository implementation + EntityConfigurations/ + InventoryItemEntityTypeConfiguration.cs # Fluent API (snake_case, private field mapping, owned transactions) + ItemTypeEntityTypeConfiguration.cs # Seed data config (currently ignored in OnModelCreating) + TransactionTypeEntityTypeConfiguration.cs # Seed data config (currently ignored in OnModelCreating) + Idempotency/ + ClientRequest.cs # Idempotency entity + IRequestManager.cs # Interface + RequestManager.cs # Implementation + Migrations/ + 20260117180439_InitialInventory.cs + 20260305095121_AddItemTypeAndCostFields.cs + 20260306175523_PhaseTwo.cs + tests/ + InventoryService.UnitTests/ + Application/Commands/ + DeductInventoryCommandHandlerTests.cs # 12 tests + InventoryService.FunctionalTests/ + CustomWebApplicationFactory.cs # InMemoryDatabase swap + Controllers/ + InventoryControllerTests.cs # 3 tests +``` diff --git a/services/membership-service-net/SERVICE_DOCS.md b/services/membership-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..916241a6 --- /dev/null +++ b/services/membership-service-net/SERVICE_DOCS.md @@ -0,0 +1,537 @@ +# Membership Service - Service Documentation + +> Auto-generated from source code audit on 2026-03-13. + +## Overview + +**Service**: membership-service-net +**Port**: 5003 (Development) +**Database**: membership_service (Neon PostgreSQL) +**Framework**: .NET 10.0, C# 14 +**Architecture**: Clean Architecture + CQRS (MediatR 12.4.1) + +The Membership Service manages member profiles, experience-based leveling system, and loyalty stamp cards. It extends user identity from IAM Service with membership-specific data (levels, EXP, preferences, stamp cards). Profile information (avatar, phone, address, DOB) is managed by IAM Service; this service handles level progression, experience tracking, and loyalty mechanics. + +### Key Features +- Member profile management (linked to IAM user by shared ID) +- Configurable experience-based level system (admin-managed level definitions) +- Experience points tracking with 7 source types (Purchase, Referral, Activity, Promotion, Review, CheckIn, Admin) +- Automatic level-up when EXP thresholds are met +- Loyalty stamp cards (buy-N-get-1-free pattern) +- Stamp card lifecycle: create -> collect stamps -> complete -> claim reward -> reset +- IAM Service integration with in-memory caching +- Soft delete for members and level definitions + +--- + +## API Endpoints + +### MembersController +Route: `api/v{version:apiVersion}/members` | Auth: `[Authorize]` + +| Method | Path | Description | Auth | Request | Response | +|--------|------|-------------|------|---------|----------| +| GET | `/{id:guid}` | Get member by ID | Authorize | Path: `id` (Guid) | `MemberDto` or 404 | +| GET | `/me` | Get current user's member profile | Authorize | JWT `sub`/`id` claim | `MemberDto` or 404 | +| GET | `/` | Get paginated members list | Authorize | Query: `pageIndex`, `pageSize`, `search` | `GetMembersResult` | +| POST | `/` | Create new member | Authorize | Body: `CreateMemberCommand` | 201 `CreateMemberResult` or 409 | +| PUT | `/{id:guid}` | Update member profile | Authorize | Path: `id`, Body: `UpdateMemberProfileCommand` | `UpdateMemberProfileResult` or 404 | +| POST | `/{id:guid}/experience` | Add experience points | Authorize | Path: `id`, Body: `AddExperienceCommand` | `AddExperienceResult` or 404 | +| GET | `/{id:guid}/progress` | Get member level progress | Authorize | Path: `id` | `MemberProgressDto` or 404 | +| GET | `/{id:guid}/experience` | Get experience history | Authorize | Query: `pageIndex`, `pageSize` | `ExperienceHistoryResult` | + +### LevelsController +Route: `api/v{version:apiVersion}/levels` | Auth: `[Authorize]` + +| Method | Path | Description | Auth | Request | Response | +|--------|------|-------------|------|---------|----------| +| GET | `/` | Get all level definitions | AllowAnonymous | Query: `includeInactive` (bool) | `IEnumerable` | +| POST | `/` | Create level definition | Admin role | Body: `CreateLevelDefinitionCommand` | 201 `CreateLevelDefinitionResult` or 409 | +| PUT | `/{id:guid}` | Update level definition | Admin role | Path: `id`, Body: `UpdateLevelDefinitionCommand` | `UpdateLevelDefinitionResult` or 404 | +| DELETE | `/{id:guid}` | Deactivate level definition (soft delete) | Admin role | Path: `id` | 204 or 404 | + +### StampCardsController +Route: `api/v{version:apiVersion}/members/stamp-cards` | Auth: `[Authorize]` + +| Method | Path | Description | Auth | Request | Response | +|--------|------|-------------|------|---------|----------| +| GET | `/` | Get stamp card for member at shop | Authorize | Query: `shopId`, `memberId` | `{success, data: StampCardDto}` or 404 | +| GET | `/shop/{shopId:guid}` | Get all stamp cards for shop (admin) | Authorize | Path: `shopId`, Query: `pageIndex`, `pageSize` | `{success, data: GetStampCardsResult}` | +| POST | `/` | Create new stamp card | Authorize | Body: `CreateStampCardCommand` | 201 `{success, data: CreateStampCardResult}` or 409 | +| POST | `/stamp` | Add stamp (auto-creates card if needed) | Authorize | Body: `AddStampCommand` | `{success, data: AddStampResult}` | +| POST | `/{id:guid}/claim` | Claim reward on completed card | Authorize | Path: `id` | `{success, data: ClaimRewardResult}` or 400/404 | +| POST | `/{id:guid}/reset` | Reset card for new round | Authorize | Path: `id` | `{success, data: ResetStampCardResult}` or 400/404 | + +### Health Endpoints + +| Path | Description | +|------|-------------| +| `/health` | Full health check (PostgreSQL) | +| `/health/live` | Liveness probe (app running) | +| `/health/ready` | Readiness probe (dependencies ready) | + +--- + +## Commands + +### CreateMemberCommand +- **Input**: `UserId` (Guid, required), `CountryCode` (string, default "VN"), `Gender` (string?, optional), `Name` (string?, optional), `Phone` (string?, optional) +- **Logic**: Checks for existing member by userId. Creates Member entity with level=1, exp=0. Stores name/phone in preferences JSON if provided. Raises `MemberCreatedDomainEvent`. +- **Validator**: `CreateMemberCommandValidator` -- UserId NotEmpty, CountryCode NotEmpty + Length(2), Gender Must be Male/Female/Other + +### UpdateMemberProfileCommand +- **Input**: `MemberId` (Guid), `Gender` (string?), `Preferences` (string?, JSON), `CountryCode` (string?) +- **Logic**: Fetches member, applies partial updates via domain methods (UpdateGender, UpdateCountryCode, UpdatePreferences). Raises `MemberUpdatedDomainEvent`. +- **Validator**: `UpdateMemberProfileCommandValidator` -- MemberId NotEmpty, Gender must be valid, CountryCode Length(2), Preferences must be valid JSON + +### AddExperienceCommand +- **Input**: `MemberId` (Guid), `Points` (int), `SourceId` (int, 1-7), `ReferenceId` (string?), `Metadata` (string?) +- **Logic**: Gets member and active level rules. Calls `member.AddExperience()` which creates ExperienceTransaction, updates EXP, auto-levels-up if threshold met. Saves transaction + member. Raises `MemberExperienceAddedDomainEvent` and optionally `MemberLevelUpDomainEvent`. +- **Validator**: None (no dedicated validator file) + +### CreateLevelDefinitionCommand +- **Input**: `LevelNumber` (int, 1-100), `Name` (string, 1-100 chars), `RequiredExp` (int, >=0), `Description` (string?, 500 max), `IconUrl` (string?, 500 max), `BadgeColor` (string?, hex format) +- **Logic**: Checks level number uniqueness, creates LevelDefinition entity. +- **Validator**: None (uses DataAnnotations on command class) + +### UpdateLevelDefinitionCommand +- **Input**: `Id` (Guid), `Name` (string?), `RequiredExp` (int?), `Description` (string?), `IconUrl` (string?), `BadgeColor` (string?), `ClearDescription`/`ClearIconUrl`/`ClearBadgeColor` (bool) +- **Logic**: Fetches level definition, applies partial updates via domain methods. Supports clearing nullable fields. +- **Validator**: None (uses DataAnnotations) + +### DeactivateLevelDefinitionCommand +- **Input**: `Id` (Guid) +- **Logic**: Fetches level definition, calls `Deactivate()` (sets IsActive=false, soft delete). +- **Validator**: None + +### CreateStampCardCommand +- **Input**: `ShopId` (Guid), `MemberId` (Guid), `CardName` (string), `TotalStampsRequired` (int, default 10) +- **Logic**: Checks for existing active card at shop for member. Creates new StampCard. Raises `StampCardCreatedDomainEvent`. +- **Validator**: `CreateStampCardCommandValidator` -- ShopId/MemberId NotEmpty, CardName NotEmpty + MaxLength(200), TotalStampsRequired 1-100 + +### AddStampCommand +- **Input**: `ShopId` (Guid), `MemberId` (Guid), `OrderId` (Guid?, optional) +- **Logic**: Finds active card for member+shop. Auto-creates card (10 stamps, "Loyalty Card") if none exists. Calls `stampCard.AddStamp()`. Raises `StampAddedDomainEvent`, and `StampCardCompletedDomainEvent` if all stamps collected. +- **Validator**: `AddStampCommandValidator` -- ShopId/MemberId NotEmpty + +### ClaimRewardCommand +- **Input**: `StampCardId` (Guid) +- **Logic**: Finds stamp card, calls `ClaimReward()` (domain validates card is completed and reward not already claimed). Raises `RewardClaimedDomainEvent`. +- **Validator**: `ClaimRewardCommandValidator` -- StampCardId NotEmpty + +### ResetStampCardCommand +- **Input**: `StampCardId` (Guid) +- **Logic**: Finds stamp card, calls `Reset()` (domain validates reward was claimed). Resets stamps to 0, clears completion state. +- **Validator**: `ResetStampCardCommandValidator` -- StampCardId NotEmpty + +--- + +## Queries + +### GetMemberByIdQuery +- **Input**: `MemberId` (Guid) +- **Logic**: Fetches member by ID. Extracts `name` and `phone` from preferences JSON for DisplayName/Phone fields. +- **Output**: `MemberDto` (Id, UserId, Gender, CountryCode, Preferences, DisplayName, Phone, CurrentLevel, CurrentExp, TotalExpEarned, CreatedAt, UpdatedAt) + +### GetMembersQuery +- **Input**: `PageIndex` (int), `PageSize` (int), `SearchTerm` (string?) +- **Logic**: Paginated query with search by country code or gender. Ordered by created_at DESC. +- **Output**: `GetMembersResult` (Members, TotalCount, PageIndex, PageSize, TotalPages) + +### GetMemberProgressQuery +- **Input**: `MemberId` (Guid) +- **Logic**: Fetches member + active level definitions. Calculates current/next level, EXP to next level, progress percentage (0-100%). 100% if at max level. +- **Output**: `MemberProgressDto` (MemberId, CurrentLevel, CurrentLevelName, CurrentExp, TotalExpEarned, ExpToNextLevel, ProgressPercent, NextLevel, NextLevelName, BadgeColor) + +### GetExperienceHistoryQuery +- **Input**: `MemberId` (Guid), `PageIndex` (int), `PageSize` (int, default 20) +- **Logic**: Paginated experience transactions ordered by created_at DESC. Sets ExperienceSource from SourceId. +- **Output**: `ExperienceHistoryResult` (Transactions[], TotalCount, PageIndex, PageSize) + +### GetLevelDefinitionsQuery +- **Input**: `IncludeInactive` (bool, default false) +- **Logic**: Returns all level definitions ordered by level_number. Includes benefits. Filters by active status unless IncludeInactive=true. +- **Output**: `IEnumerable` (Id, LevelNumber, Name, RequiredExp, Description, IconUrl, BadgeColor, IsActive, Benefits[]) + +### GetStampCardQuery +- **Input**: `ShopId` (Guid), `MemberId` (Guid) +- **Logic**: Gets most recent stamp card for member at shop. +- **Output**: `StampCardDto` (Id, ShopId, MemberId, CardName, TotalStampsRequired, CurrentStamps, IsCompleted, RewardClaimed, CreatedAt, CompletedAt) + +### GetStampCardsQuery +- **Input**: `ShopId` (Guid), `PageIndex` (int), `PageSize` (int) +- **Logic**: Paginated stamp cards for a shop (admin view), ordered by created_at DESC. +- **Output**: `GetStampCardsResult` (Items[], TotalCount, PageIndex, PageSize) + +--- + +## Domain Model + +### Member (Aggregate Root) +**Table**: `members` + +| Field | Type | Description | +|-------|------|-------------| +| Id / UserId | Guid | Primary key, same as IAM User ID (ValueGeneratedNever) | +| _countryCode | string | ISO 3166-1 alpha-2 country code (default "VN") | +| _gender | string? | Male, Female, Other | +| _currentLevel | int | Current level number (default 1) | +| _currentExp | int | Current experience points (default 0) | +| _totalExpEarned | int | Lifetime total EXP earned (default 0) | +| _preferences | string? | JSON (jsonb) user preferences, stores name/phone | +| _createdAt | DateTime | UTC creation timestamp | +| _updatedAt | DateTime | UTC last update timestamp | +| _isDeleted | bool | Soft delete flag (default false, global query filter) | + +**Behavior Methods**: +- `AddExperience(points, source, levelRules, referenceId?, metadata?)` -> ExperienceTransaction -- adds EXP, auto-levels-up, creates transaction, raises MemberExperienceAddedDomainEvent + MemberLevelUpDomainEvent +- `UpdateGender(gender)` -- raises MemberUpdatedDomainEvent +- `UpdateCountryCode(countryCode)` -- raises MemberUpdatedDomainEvent +- `UpdatePreferences(preferencesJson)` -- raises MemberUpdatedDomainEvent +- `Delete()` -- soft delete +- `Restore()` -- undo soft delete + +**Domain Events**: MemberCreatedDomainEvent, MemberExperienceAddedDomainEvent, MemberLevelUpDomainEvent, MemberUpdatedDomainEvent + +### LevelDefinition (Aggregate Root) +**Table**: `level_definitions` + +| Field | Type | Description | +|-------|------|-------------| +| Id | Guid | Primary key (ValueGeneratedNever) | +| _levelNumber | int | Unique level number (1, 2, 3...) | +| _name | string | Level name (Bronze, Silver, Gold..., max 100) | +| _requiredExp | int | EXP threshold to reach this level (default 0) | +| _description | string? | Level description | +| _iconUrl | string? | Badge icon URL (max 500) | +| _badgeColor | string? | Hex color for badge (max 20) | +| _isActive | bool | Active status (default true) | +| _createdAt | DateTime | UTC creation timestamp | +| _updatedAt | DateTime | UTC last update timestamp | + +**Behavior Methods**: +- `UpdateName(name)`, `UpdateRequiredExp(requiredExp)`, `UpdateDescription(description?)`, `UpdateIconUrl(iconUrl?)`, `UpdateBadgeColor(badgeColor?)` +- `AddBenefit(benefit)`, `RemoveBenefit(benefit)` +- `Activate()`, `Deactivate()` (soft delete) + +**Relationship**: HasMany `LevelBenefit` (cascade delete) + +### LevelBenefit (Entity, owned by LevelDefinition) +**Table**: `level_benefits` + +| Field | Type | Description | +|-------|------|-------------| +| Id | Guid | Primary key (ValueGeneratedNever) | +| _levelDefinitionId | Guid | FK to level_definitions | +| _benefitType | string | Benefit type (discount_percent, free_shipping, etc., max 50) | +| _benefitValue | string | JSON value (jsonb) | +| _description | string? | Benefit description | +| _isActive | bool | Active status (default true) | +| _createdAt | DateTime | UTC creation timestamp | + +**Behavior Methods**: `UpdateBenefitValue(value)`, `UpdateDescription(description?)`, `Activate()`, `Deactivate()` + +### ExperienceTransaction (Entity) +**Table**: `experience_transactions` + +| Field | Type | Description | +|-------|------|-------------| +| Id | Guid | Primary key (ValueGeneratedNever) | +| _memberId | Guid | FK to members | +| _points | int | EXP points earned | +| _sourceId | int | Source enum ID (1-7) | +| _referenceId | string? | Reference ID (order, referral code, max 100) | +| _metadata | string? | Additional metadata (jsonb) | +| _levelAtTime | int | Member's level when EXP was earned | +| _createdAt | DateTime | UTC creation timestamp | + +**Note**: Source navigation property is ignored by EF (Enumeration pattern), resolved via `ExperienceSource.FromValue<>()` in repository. + +### ExperienceSource (Enumeration) +Not persisted as table. Stored as `source_id` integer in experience_transactions. + +| Id | Name | Description | +|----|------|-------------| +| 1 | Purchase | Order/purchase completion | +| 2 | Referral | Friend referral | +| 3 | Activity | App browsing/engagement | +| 4 | Promotion | Promotional campaigns | +| 5 | Review | Product reviews | +| 6 | CheckIn | Daily check-in | +| 7 | Admin | Manually granted by admin | + +### MembershipLevel (Enumeration) +**Table**: `membership_levels` (lookup table) + +| Id | Name | +|----|------| +| 1 | Free | +| 2 | Basic | +| 3 | Premium | + +**Note**: This enumeration is stored as a DB table but not actively used in current command/query logic. The actual leveling system uses the configurable `LevelDefinition` aggregate. + +### StampCard (Aggregate Root) +**Table**: `stamp_cards` + +| Field | Type | Description | +|-------|------|-------------| +| Id | Guid | Primary key | +| _shopId | Guid | Shop that issued the card | +| _memberId | Guid | Member who owns the card | +| _cardName | string | Display name (max 200) | +| _totalStampsRequired | int | Stamps needed for reward (default 10) | +| _currentStamps | int | Stamps collected so far (default 0) | +| _isCompleted | bool | All stamps collected (default false) | +| _rewardClaimed | bool | Reward claimed after completion (default false) | +| _createdAt | DateTime | UTC creation timestamp | +| _completedAt | DateTime? | UTC completion timestamp | + +**Behavior Methods**: +- `AddStamp()` -- increments stamps, marks completed when threshold met. Raises StampAddedDomainEvent, StampCardCompletedDomainEvent +- `ClaimReward()` -- validates completed + not yet claimed. Raises RewardClaimedDomainEvent +- `Reset()` -- validates reward was claimed, resets all state for new round + +**Domain Events**: StampCardCreatedDomainEvent, StampAddedDomainEvent, StampCardCompletedDomainEvent, RewardClaimedDomainEvent + +--- + +## Database Schema + +### Tables + +#### `members` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, ValueGeneratedNever | +| country_code | varchar(2) | NOT NULL | +| gender | varchar(10) | nullable | +| current_level | int | NOT NULL, default 1 | +| current_exp | int | NOT NULL, default 0 | +| total_exp_earned | int | NOT NULL, default 0 | +| preferences | jsonb | nullable | +| created_at | timestamp | NOT NULL | +| updated_at | timestamp | NOT NULL | +| is_deleted | bool | NOT NULL, default false | + +**Indexes**: ix_members_created_at, ix_members_current_level, ix_members_current_exp, ix_members_is_deleted +**Global Query Filter**: `WHERE is_deleted = false` + +#### `level_definitions` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, ValueGeneratedNever | +| level_number | int | NOT NULL, UNIQUE | +| name | varchar(100) | NOT NULL | +| required_exp | int | NOT NULL, default 0 | +| description | text | nullable | +| icon_url | varchar(500) | nullable | +| badge_color | varchar(20) | nullable | +| is_active | bool | NOT NULL, default true | +| created_at | timestamp | NOT NULL | +| updated_at | timestamp | NOT NULL | + +**Indexes**: ix_level_definitions_level_number (unique), ix_level_definitions_is_active + +#### `level_benefits` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, ValueGeneratedNever | +| level_definition_id | uuid | NOT NULL, FK -> level_definitions (cascade) | +| benefit_type | varchar(50) | NOT NULL | +| benefit_value | jsonb | NOT NULL | +| description | text | nullable | +| is_active | bool | NOT NULL, default true | +| created_at | timestamp | NOT NULL | + +**Indexes**: ix_level_benefits_level_definition_id + +#### `experience_transactions` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, ValueGeneratedNever | +| member_id | uuid | NOT NULL | +| points | int | NOT NULL | +| source_id | int | NOT NULL | +| reference_id | varchar(100) | nullable | +| metadata | jsonb | nullable | +| level_at_time | int | NOT NULL | +| created_at | timestamp | NOT NULL | + +**Indexes**: ix_experience_transactions_member_id, ix_experience_transactions_source, ix_experience_transactions_created_at + +#### `membership_levels` +| Column | Type | Constraints | +|--------|------|-------------| +| id | int | PK, ValueGeneratedNever | +| name | varchar(50) | NOT NULL | + +**Note**: Lookup table for MembershipLevel enumeration (Free=1, Basic=2, Premium=3). Not actively used in current business logic. + +#### `stamp_cards` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK | +| shop_id | uuid | NOT NULL | +| member_id | uuid | NOT NULL | +| card_name | varchar(200) | NOT NULL | +| total_stamps_required | int | NOT NULL, default 10 | +| current_stamps | int | NOT NULL, default 0 | +| is_completed | bool | NOT NULL, default false | +| reward_claimed | bool | NOT NULL, default false | +| created_at | timestamp | NOT NULL | +| completed_at | timestamp | nullable | + +**Indexes**: ix_stamp_cards_shop_member (composite: shop_id + member_id), ix_stamp_cards_shop_id, ix_stamp_cards_is_completed, ix_stamp_cards_created_at + +### Migration History +- `20260115105939_AddLevelAndExperienceSystem` -- Initial migration with all tables + +--- + +## Dependencies + +### NuGet Packages + +**API Layer**: +| Package | Version | +|---------|---------| +| MediatR | 12.4.1 | +| FluentValidation | 11.11.0 | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | +| Microsoft.EntityFrameworkCore.Design | 10.0.2 | +| Swashbuckle.AspNetCore | 7.2.0 | +| Swashbuckle.AspNetCore.Annotations | 7.2.0 | +| Asp.Versioning.Mvc | 8.1.0 | +| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | +| AspNetCore.HealthChecks.Redis | 8.0.1 | +| Hellang.Middleware.ProblemDetails | 6.5.1 | +| Serilog.AspNetCore | 8.0.3 | +| Serilog.Sinks.Console | 6.0.0 | +| Serilog.Sinks.Seq | 8.0.0 | +| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.0 | + +**Domain Layer**: +| Package | Version | +|---------|---------| +| MediatR.Contracts | 2.0.1 | + +**Infrastructure Layer**: +| Package | Version | +|---------|---------| +| Microsoft.EntityFrameworkCore | 10.0.0 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.0 | +| MediatR | 12.4.1 | +| Dapper | 2.1.35 | +| Microsoft.Extensions.Http.Polly | 9.0.0 | +| Polly | 8.5.0 | +| StackExchange.Redis | 2.8.16 | +| Microsoft.Extensions.Http | 9.0.0 | + +### Service Dependencies +- **IAM Service**: User validation, roles, permissions (via HttpIamServiceClient with in-memory caching) +- **PostgreSQL**: Primary data store (Neon PostgreSQL, connection with retry policy - 5 retries, 30s max delay) +- **Redis**: Configured in appsettings but not actively used in repositories (StackExchange.Redis package available) + +--- + +## Configuration + +### appsettings.json +``` +ConnectionStrings:DefaultConnection -- PostgreSQL connection string (also supports DATABASE_URL env var) +Redis:Host/Port/Password/Database -- Redis connection (configured but not actively used in code) +Jwt:Secret/Issuer/Audience -- JWT validation settings +Jwt:Authority -- OIDC authority URL (default: http://localhost:5001) +IamService:BaseUrl -- IAM Service base URL (default: http://iam-service-net:8080) +IamService:ServiceName -- Internal service name header (default: membership-service) +IamService:TimeoutSeconds -- HTTP client timeout (default: 30) +IamService:CacheDurationSeconds -- User info cache TTL (default: 300 = 5min) +IamService:HealthCheckCacheDurationSeconds -- Health check cache TTL (default: 60 = 1min) +``` + +### Middleware Pipeline +1. Serilog request logging +2. ProblemDetails (RFC 7807) +3. Swagger (Development only) +4. CORS (AllowAnyOrigin/Method/Header) +5. Routing +6. Authentication (JWT Bearer) +7. Authorization +8. Health check endpoints +9. Controller mapping + +### MediatR Pipeline Behaviors +1. `LoggingBehavior<,>` -- Request/response logging +2. `ValidatorBehavior<,>` -- FluentValidation in pipeline +3. `TransactionBehavior<,>` -- Auto-wraps commands in DB transactions + +### DI Registrations +- `MembershipServiceContext` (DbContext + IUnitOfWork) +- `IMemberRepository` -> `MemberRepository` +- `ILevelDefinitionRepository` -> `LevelDefinitionRepository` +- `IExperienceTransactionRepository` -> `ExperienceTransactionRepository` +- `IStampCardRepository` -> `StampCardRepository` +- `IRequestManager` -> `RequestManager` +- `IIamServiceClient` -> `HttpIamServiceClient` (HttpClient factory) + +### Auto-Migration +The service auto-applies EF Core migrations on startup. Migration failures are logged but do not crash the application. + +--- + +## Tests + +### Unit Tests (`tests/MembershipService.UnitTests/`) +- `Domain/MemberAggregateTests.cs` -- Member entity behavior +- `Domain/LevelDefinitionAggregateTests.cs` -- LevelDefinition entity behavior +- `Domain/ExperienceTransactionTests.cs` -- ExperienceTransaction entity +- `Domain/MembershipLevelTests.cs` -- MembershipLevel enumeration +- `Handlers/CreateLevelDefinitionCommandHandlerTests.cs` +- `Handlers/GetMemberProgressQueryHandlerTests.cs` +- `Handlers/UpdateLevelDefinitionCommandHandlerTests.cs` +- `Handlers/DeactivateLevelDefinitionCommandHandlerTests.cs` +- `Handlers/AddExperienceCommandHandlerTests.cs` + +### Functional Tests (`tests/MembershipService.FunctionalTests/`) +- `CustomWebApplicationFactory.cs` -- Test setup with in-memory DB + +--- + +## Domain Events Summary + +| Event | Raised By | Trigger | +|-------|-----------|---------| +| MemberCreatedDomainEvent | Member constructor | New member created | +| MemberUpdatedDomainEvent | Member.UpdateGender/CountryCode/Preferences | Profile updated | +| MemberExperienceAddedDomainEvent | Member.AddExperience | EXP added | +| MemberLevelUpDomainEvent | Member.AddExperience | Level threshold crossed | +| MembershipLevelChangedDomainEvent | (defined but not currently raised in code) | Membership tier change | +| StampCardCreatedDomainEvent | StampCard constructor | New stamp card created | +| StampAddedDomainEvent | StampCard.AddStamp | Stamp added to card | +| StampCardCompletedDomainEvent | StampCard.AddStamp | All stamps collected | +| RewardClaimedDomainEvent | StampCard.ClaimReward | Reward claimed | + +--- + +## Architecture Notes + +1. **Member.Id == IAM UserId**: Member entity uses the IAM Service's user ID as its primary key (ValueGeneratedNever). This ensures a 1:1 mapping without a separate FK. + +2. **Dual Leveling Systems**: The service has two level concepts: + - `MembershipLevel` (Enumeration: Free/Basic/Premium) -- legacy/unused, stored in `membership_levels` table + - `LevelDefinition` (configurable admin-managed levels with EXP thresholds) -- active system used for level progression + +3. **Experience Source as Enumeration**: ExperienceSource uses the Enumeration pattern (not stored as EF entity). The `source_id` integer is persisted; the source object is resolved in-memory via `FromValue<>()` in the repository layer. + +4. **StampCard Active Card Logic**: `GetActiveCardAsync` returns the most recent card where `reward_claimed = false` (either in-progress or completed-but-unclaimed). After reward is claimed and card is reset, it becomes active again. + +5. **Response Format Inconsistency**: MembersController returns raw DTOs; StampCardsController wraps responses in `{ success, data }` format. LevelsController returns raw DTOs. + +6. **Missing Validators**: `AddExperienceCommand`, `CreateLevelDefinitionCommand`, `UpdateLevelDefinitionCommand`, and `DeactivateLevelDefinitionCommand` do not have FluentValidation validators (some use DataAnnotations instead). + +7. **No RabbitMQ Integration**: Domain events are dispatched via MediatR (in-process) before SaveChanges. No cross-service event publishing to RabbitMQ is implemented. + +8. **Dapper Registered but Unused**: Dapper is included as a dependency but no Dapper-based read queries are implemented; all queries use EF Core. diff --git a/services/merchant-service-net/SERVICE_DOCS.md b/services/merchant-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..8994f76c --- /dev/null +++ b/services/merchant-service-net/SERVICE_DOCS.md @@ -0,0 +1,853 @@ +# MerchantService - Service Documentation + +> Auto-generated from source code audit. Last updated: 2026-03-13. + +--- + +## Overview + +| Property | Value | +|----------|-------| +| **Service Name** | MerchantService | +| **Framework** | .NET 10.0 / C# 14 | +| **Architecture** | Clean Architecture + CQRS (MediatR 12.4) | +| **Database** | PostgreSQL (Neon) via EF Core 10 + Npgsql 10 | +| **Auth** | JWT Bearer (Duende IdentityServer OIDC) | +| **Port** | 5005 (development) | +| **Database Name** | `merchant_service` | +| **Health Checks** | `/health`, `/health/live`, `/health/ready` | + +**Aggregates**: Merchant, Shop (+ ShopBranch), MerchantStaff (+ DeviceToken, ShopMember), AttendanceRecord, LeaveRequest + +**MediatR Pipeline**: `LoggingBehavior` -> `ValidatorBehavior` -> `TransactionBehavior` -> Handler + +--- + +## API Endpoints + +### MerchantsController +**Route**: `api/v1/merchants` | **Auth**: `[Authorize]` + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/me` | Get current merchant profile | JWT | +| POST | `/register` | Register a new merchant | JWT | +| PUT | `/me` | Update current merchant | JWT | +| POST | `/me/verify` | Submit merchant verification | JWT | +| GET | `/{merchantId}/shops` | Get merchant's shops (paginated) | JWT | +| PUT | `/{merchantId}/shops/{shopId}/set-default` | Set default shop | JWT | +| POST | `/{merchantId}/shops/{shopId}/transfer` | Transfer shop ownership | JWT | + +### ShopsController +**Route**: `api/v1/shops` | **Auth**: `[Authorize]` + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/stats` | Get shop statistics | JWT | +| GET | `/{shopId}/settings` | Get shop settings | JWT | +| PUT | `/{shopId}/settings` | Update shop settings | JWT | +| GET | `/` | Get my shops | JWT | +| GET | `/{shopId}` | Get shop by ID | JWT | +| GET | `/slug/{slug}` | Get shop by slug | Anonymous | +| POST | `/` | Create a new shop | JWT | +| PUT | `/{shopId}` | Update shop | JWT | +| POST | `/{shopId}/publish` | Publish shop (make visible) | JWT | +| POST | `/{shopId}/deactivate` | Deactivate shop | JWT | +| POST | `/{shopId}/close` | Close shop permanently | JWT | +| POST | `/{shopId}/branches` | Add branch to shop | JWT | +| GET | `/{shopId}/branches` | Get shop branches | Anonymous | +| PUT | `/{shopId}/branches/{branchId}` | Update branch | JWT | +| DELETE | `/{shopId}/branches/{branchId}` | Delete branch | JWT | +| GET | `/nearby` | Get nearby shops (Haversine) | Anonymous | + +### StaffController +**Route**: `api/v1/merchants/me/staff` | **Auth**: `[Authorize]` + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/` | Get merchant's staff list | JWT | +| POST | `/create-active` | Create staff directly as Active | JWT | +| POST | `/invite` | Invite a new staff member | JWT | +| PUT | `/{staffId}` | Update staff member | JWT | +| DELETE | `/{staffId}` | Delete/terminate staff member | JWT | + +### StaffPublicController +**Route**: `api/v1/staff` | **Auth**: `[Authorize]` + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/roles` | Get available staff roles | Anonymous | +| POST | `/accept-invite` | Accept staff invitation | JWT | +| GET | `/debug/all` | Debug: list all staff | Anonymous | +| POST | `/debug/seed` | Debug: seed test staff data | Anonymous | +| POST | `/debug/update-userid` | Debug: update staff userId | Anonymous | +| POST | `/debug/update-merchant` | Debug: update merchant userId | Anonymous | +| GET | `/lookup` | Lookup staff by email | Anonymous | + +### AttendanceController +**Route**: `api/v1/attendance` + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/staff/{staffId}` | Get attendance by staff (month/year) | - | +| GET | `/shop/{shopId}` | Get attendance by shop (month/year) | - | +| POST | `/check-in` | Staff check-in | - | +| POST | `/check-out` | Staff check-out | - | + +### LeaveRequestsController +**Route**: `api/v1/leave-requests` + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/staff/{staffId}` | Get leave requests by staff | - | +| GET | `/shop/{shopId}` | Get leave requests by shop | - | +| POST | `/` | Create leave request | - | +| POST | `/{id}/approve` | Approve leave request | - | +| POST | `/{id}/reject` | Reject leave request | - | + +### PosController +**Route**: `api/v1/pos` + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| POST | `/auth/pin` | PIN-based POS authentication | Anonymous | +| POST | `/devices/register` | Register POS device | JWT | +| GET | `/me` | Get current POS staff profile | JWT | + +### DevicesController +**Route**: `api/v1/devices` | **Auth**: `[Authorize]` + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/` | List registered devices | JWT | + +### SubscriptionsController +**Route**: `api/v1/subscriptions` | **Auth**: `[Authorize]` + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/me` | Get current subscription | JWT | +| GET | `/plans` | Get available plans | Anonymous | +| POST | `/subscribe` | Subscribe to a plan | JWT | +| GET | `/usage` | Get subscription usage | JWT | + +### AdminMerchantsController +**Route**: `api/v1/admin/merchants` | **Auth**: `[Authorize(Roles="Admin,SuperAdmin")]` + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/` | List all merchants (paginated) | Admin | +| GET | `/{merchantId}` | Get merchant detail | Admin | +| GET | `/statistics` | Get platform statistics | Admin | +| POST | `/{merchantId}/approve` | Approve merchant | Admin | +| POST | `/{merchantId}/reject` | Reject merchant | Admin | +| POST | `/{merchantId}/suspend` | Suspend merchant | Admin | +| POST | `/{merchantId}/reactivate` | Reactivate merchant | Admin | +| POST | `/{merchantId}/ban` | Ban merchant | Admin | + +### AdminShopsController +**Route**: `api/v1/admin/shops` | **Auth**: `[Authorize(Roles="Admin,SuperAdmin")]` + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/` | List all shops (paginated) | Admin | +| GET | `/{shopId}` | Get shop detail | Admin | +| POST | `/{shopId}/suspend` | Suspend shop | Admin | +| POST | `/{shopId}/reactivate` | Reactivate shop | Admin | +| POST | `/{shopId}/close` | Close shop | Admin | + +--- + +## Commands + +| # | Command | Handler | Location | +|---|---------|---------|----------| +| 1 | `RegisterMerchantCommand` | `RegisterMerchantCommandHandler` | Commands/Merchants/ | +| 2 | `UpdateMerchantCommand` | `UpdateMerchantCommandHandler` | Commands/Merchants/ | +| 3 | `SubmitMerchantVerificationCommand` | `SubmitMerchantVerificationCommandHandler` | Commands/Merchants/ | +| 4 | `CreateShopCommand` | `CreateShopCommandHandler` | Commands/Shops/ | +| 5 | `UpdateShopCommand` | `UpdateShopCommandHandler` | Commands/Shops/ | +| 6 | `UpdateShopSettingsCommand` | `UpdateShopSettingsCommandHandler` | Commands/Shops/ | +| 7 | `SetDefaultShopCommand` | `SetDefaultShopCommandHandler` | Commands/Shops/ | +| 8 | `TransferShopCommand` | `TransferShopCommandHandler` | Commands/Shops/ | +| 9 | `PublishShopCommand` | `PublishShopCommandHandler` | Commands/Shops/ | +| 10 | `SetShopInactiveCommand` | `SetShopInactiveCommandHandler` | Commands/Shops/ | +| 11 | `CloseShopCommand` | `CloseShopCommandHandler` | Commands/Shops/ | +| 12 | `AddShopBranchCommand` | `AddShopBranchCommandHandler` | Commands/Shops/ | +| 13 | `UpdateBranchCommand` | `UpdateBranchCommandHandler` | Commands/Shops/ | +| 14 | `DeleteBranchCommand` | `DeleteBranchCommandHandler` | Commands/Shops/ | +| 15 | `InviteStaffCommand` | `InviteStaffCommandHandler` | Commands/Staff/ | +| 16 | `CreateActiveStaffCommand` | `CreateActiveStaffCommandHandler` | Commands/Staff/ | +| 17 | `UpdateStaffCommand` | `UpdateStaffCommandHandler` | Commands/Staff/ | +| 18 | `DeleteStaffCommand` | `DeleteStaffCommandHandler` | Commands/Staff/ | +| 19 | `AcceptInviteCommand` | `AcceptInviteCommandHandler` | Commands/Staff/ | +| 20 | `PinAuthCommand` | `PinAuthCommandHandler` | Commands/Pos/ | +| 21 | `RegisterDeviceCommand` | `RegisterDeviceCommandHandler` | Commands/Pos/ | +| 22 | `CheckInCommand` | `CheckInCommandHandler` | Commands/Attendance/ | +| 23 | `CheckOutCommand` | `CheckOutCommandHandler` | Commands/Attendance/ | +| 24 | `CreateLeaveRequestCommand` | `CreateLeaveRequestCommandHandler` | Commands/LeaveRequests/ | +| 25 | `ApproveLeaveRequestCommand` | `ApproveLeaveRequestCommandHandler` | Commands/LeaveRequests/ | +| 26 | `RejectLeaveRequestCommand` | `RejectLeaveRequestCommandHandler` | Commands/LeaveRequests/ | +| 27 | `ApproveMerchantCommand` | `ApproveMerchantCommandHandler` | Commands/Admin/ | +| 28 | `RejectMerchantCommand` | `RejectMerchantCommandHandler` | Commands/Admin/ | +| 29 | `SuspendMerchantCommand` | `SuspendMerchantCommandHandler` | Commands/Admin/ | +| 30 | `ReactivateMerchantCommand` | `ReactivateMerchantCommandHandler` | Commands/Admin/ | +| 31 | `BanMerchantCommand` | `BanMerchantCommandHandler` | Commands/Admin/ | +| 32 | `SubscribeCommand` | `SubscribeCommandHandler` | Commands/Subscriptions/ | + +--- + +## Queries + +| # | Query | Handler | Location | +|---|-------|---------|----------| +| 1 | `GetMerchantProfileQuery` | `GetMerchantProfileQueryHandler` | Queries/Merchants/ | +| 2 | `GetMerchantByIdQuery` | `GetMerchantByIdQueryHandler` | Queries/Merchants/ | +| 3 | `GetMyShopsQuery` | `GetMyShopsQueryHandler` | Queries/Shops/ | +| 4 | `GetShopByIdQuery` | `GetShopByIdQueryHandler` | Queries/Shops/ | +| 5 | `GetShopBySlugQuery` | `GetShopBySlugQueryHandler` | Queries/Shops/ | +| 6 | `GetMerchantShopsQuery` | `GetMerchantShopsQueryHandler` | Queries/Shops/ (paginated) | +| 7 | `GetShopSettingsQuery` | `GetShopSettingsQueryHandler` | Queries/Shops/ | +| 8 | `GetShopStatsQuery` | `GetShopStatsQueryHandler` | Queries/Shops/ | +| 9 | `GetBranchesQuery` | `GetBranchesQueryHandler` | Queries/Shops/ | +| 10 | `GetNearbyShopsQuery` | `GetNearbyShopsQueryHandler` | Queries/Shops/ (Haversine formula) | +| 11 | `GetMyStaffQuery` | `GetMyStaffQueryHandler` | Queries/Staff/ | +| 12 | `GetStaffRolesQuery` | `GetStaffRolesQueryHandler` | Queries/Staff/ | +| 13 | `GetPosStaffQuery` | `GetPosStaffQueryHandler` | Queries/Pos/ | +| 14 | `GetDevicesQuery` | `GetDevicesQueryHandler` | Queries/Pos/ | +| 15 | `GetAllMerchantsQuery` | `GetAllMerchantsQueryHandler` | Queries/Admin/ (paginated) | +| 16 | `GetAllShopsQuery` | `GetAllShopsQueryHandler` | Queries/Admin/ (paginated) | +| 17 | `GetMerchantDetailQuery` | `GetMerchantDetailQueryHandler` | Queries/Admin/ | +| 18 | `GetMerchantStatisticsQuery` | `GetMerchantStatisticsQueryHandler` | Queries/Admin/ | +| 19 | `GetAttendanceByStaffQuery` | `GetAttendanceByStaffQueryHandler` | Queries/Attendance/ | +| 20 | `GetAttendanceByShopQuery` | `GetAttendanceByShopQueryHandler` | Queries/Attendance/ | +| 21 | `GetLeaveRequestsByStaffQuery` | `GetLeaveRequestsByStaffQueryHandler` | Queries/LeaveRequests/ | +| 22 | `GetLeaveRequestsByShopQuery` | `GetLeaveRequestsByShopQueryHandler` | Queries/LeaveRequests/ | +| 23 | `GetSubscriptionQuery` | `GetSubscriptionQueryHandler` | Queries/Subscriptions/ | +| 24 | `GetSubscriptionPlansQuery` | `GetSubscriptionPlansQueryHandler` | Queries/Subscriptions/ | +| 25 | `GetSubscriptionUsageQuery` | `GetSubscriptionUsageQueryHandler` | Queries/Subscriptions/ | + +--- + +## Validators + +| # | Validator | Target Command | +|---|-----------|---------------| +| 1 | `RegisterMerchantCommandValidator` | RegisterMerchantCommand | +| 2 | `UpdateMerchantCommandValidator` | UpdateMerchantCommand | +| 3 | `CreateShopCommandValidator` | CreateShopCommand | +| 4 | `UpdateShopCommandValidator` | UpdateShopCommand | +| 5 | `SetDefaultShopCommandValidator` | SetDefaultShopCommand | +| 6 | `TransferShopCommandValidator` | TransferShopCommand | +| 7 | `AddShopBranchCommandValidator` | AddShopBranchCommand | +| 8 | `ApproveMerchantCommandValidator` | ApproveMerchantCommand | +| 9 | `RejectMerchantCommandValidator` | RejectMerchantCommand | +| 10 | `SuspendMerchantCommandValidator` | SuspendMerchantCommand | +| 11 | `ReactivateMerchantCommandValidator` | ReactivateMerchantCommand | +| 12 | `BanMerchantCommandValidator` | BanMerchantCommand | +| 13 | `CheckInCommandValidator` | CheckInCommand | +| 14 | `CheckOutCommandValidator` | CheckOutCommand | +| 15 | `CreateLeaveRequestCommandValidator` | CreateLeaveRequestCommand | +| 16 | `ApproveLeaveRequestCommandValidator` | ApproveLeaveRequestCommand | +| 17 | `RejectLeaveRequestCommandValidator` | RejectLeaveRequestCommand | +| 18 | `SubscribeCommandValidator` | SubscribeCommand | + +--- + +## Domain Model + +### Aggregate: Merchant +**Entity**: `Merchant` (Aggregate Root) | **Table**: `merchants` + +**Fields**: +| Field | Type | Column | Required | +|-------|------|--------|----------| +| Id | Guid | `id` | Yes | +| _userId | Guid | `user_id` | Yes | +| _businessName | string | `business_name` (max 200) | Yes | +| TypeId | int | `type_id` | Yes | +| StatusId | int | `status_id` | Yes | +| VerificationStatusId | int | `verification_status_id` | Yes | +| _businessInfo | BusinessInfo (owned) | tax_id, business_license_number, company_registration_number, established_date | No | +| _settlementConfig | SettlementConfig (owned) | commission_rate, settlement_cycle_id, auto_settlement, bank_* | No | +| VerifiedAt | DateTime? | `verified_at` | No | +| VerifiedBy | Guid? | `verified_by` | No | +| _subscriptionPlanId | int | `subscription_plan_id` (default 0) | Yes | +| _createdAt | DateTime | `created_at` | Yes | +| _updatedAt | DateTime? | `updated_at` | No | +| _isDeleted | bool | `is_deleted` (default false) | Yes | + +**Behavior Methods**: `Register` (static factory), `UpdateBusinessName`, `UpdateBusinessInfo`, `UpdateSettlementConfig`, `SubmitForVerification`, `Approve`, `Suspend`, `Reactivate`, `Ban`, `UpdateSubscriptionPlan`, `Delete` + +**Domain Events**: `MerchantRegisteredDomainEvent`, `MerchantVerificationSubmittedDomainEvent`, `MerchantApprovedDomainEvent`, `MerchantSuspendedDomainEvent`, `MerchantBannedDomainEvent` + +**Enumerations**: +- `MerchantType`: Individual(1), Company(2) | Table: `merchant_types` +- `MerchantStatus`: PendingApproval(1), Active(2), Suspended(3), Banned(4) | Table: `merchant_statuses` +- `VerificationStatus`: Unverified(1), Pending(2), Verified(3), Rejected(4) | Table: `verification_statuses` +- `SettlementCycle`: Daily(1), Weekly(2), Monthly(3) | Table: `settlement_cycles` + +**Value Objects**: +- `BusinessInfo`: TaxId, BusinessLicenseNumber, CompanyRegistrationNumber, EstablishedDate +- `SettlementConfig`: CommissionRate, SettlementCycleId, AutoSettlement, BankAccount +- `BankAccount`: BankCode, BankName, AccountNumber, AccountHolderName + +--- + +### Aggregate: Shop +**Entity**: `Shop` (Aggregate Root) | **Table**: `shops` + +**Fields**: +| Field | Type | Column | Required | +|-------|------|--------|----------| +| Id | Guid | `id` | Yes | +| _merchantId | Guid | `merchant_id` | Yes | +| _name | string | `name` (max 100) | Yes | +| _slug | string | `slug` (max 100, unique) | Yes | +| TypeId | int | `type_id` | Yes | +| CategoryId | int | `category_id` | Yes | +| StatusId | int | `status_id` | Yes | +| _description | string? | `description` (max 2000) | No | +| _logoUrl | string? | `logo_url` (max 500) | No | +| _coverImageUrl | string? | `cover_image_url` (max 500) | No | +| _contactInfo | ContactInfo (owned) | phone, email, website | No | +| _operatingHours | OperatingHours (owned) | open_time, close_time, open_days | No | +| _features | ShopFeatures (owned) | `features_config` (JSONB) | No | +| _isDefault | bool | `is_default` (default false) | Yes | +| _createdAt | DateTime | `created_at` | Yes | +| _updatedAt | DateTime? | `updated_at` | No | +| _isDeleted | bool | `is_deleted` (default false) | Yes | +| Branches | List\ | FK: shop_id -> shop_branches | Navigation | + +**Behavior Methods**: `UpdateInfo`, `UpdateSlug`, `UpdateContactInfo`, `UpdateOperatingHours`, `UpdateImages`, `UpdateFeatures`, `Publish`, `SetInactive`, `Close`, `AddBranch`, `RemoveBranch`, `SetAsDefault`, `ClearDefault`, `TransferOwnership`, `Delete` + +**Domain Events**: `ShopCreatedDomainEvent`, `ShopPublishedDomainEvent`, `ShopClosedDomainEvent`, `ShopBranchAddedDomainEvent`, `ShopSetAsDefaultDomainEvent`, `ShopTransferredDomainEvent` + +**Enumerations**: +- `ShopType`: OnlineOnly(1), PhysicalOnly(2), Hybrid(3) | Table: `shop_types` +- `ShopStatus`: Draft(1), Active(2), Inactive(3), Closed(4) | Table: `shop_statuses` +- `BusinessCategory`: FoodBeverage(1), Fashion(2), Electronics(3), Healthcare(4), Beauty(5), Education(6), Entertainment(7), Services(8), Grocery(9), HomeFurniture(10), Other(11), Cafe(12), Restaurant(13), Karaoke(14), Spa(15) | Table: `business_categories` + +**Value Objects**: +- `ContactInfo`: Phone, Email, Website +- `OperatingHours`: OpenTime, CloseTime, OpenDays (DayOfWeek list, stored as comma-separated ints) +- `ShopFeatures`: Stored as JSONB in `features_config` column + +**Child Entity: ShopBranch** | **Table**: `shop_branches` + +| Field | Type | Column | Required | +|-------|------|--------|----------| +| Id | Guid | `id` | Yes | +| ShopId | Guid | `shop_id` (FK) | Yes | +| _name | string | `name` (max 100) | Yes | +| _code | string? | `code` (max 20) | No | +| _phone | string? | `phone` (max 20) | No | +| _isActive | bool | `is_active` (default true) | Yes | +| _address | Address (owned) | street, ward, district, city, province, postal_code, country_code | Yes | +| _location | GeoLocation (owned) | latitude, longitude | No | +| _operatingHours | OperatingHours (owned) | open_time, close_time, open_days | No | +| _createdAt | DateTime | `created_at` | Yes | +| _updatedAt | DateTime? | `updated_at` | No | + +**Value Objects (Branch)**: +- `Address`: Street, Ward, District, City, Province, PostalCode, CountryCode (default "VN") +- `GeoLocation`: Latitude, Longitude + +--- + +### Aggregate: MerchantStaff +**Entity**: `MerchantStaff` (Aggregate Root) | **Table**: `merchant_staff` + +**Fields**: +| Field | Type | Column | Required | +|-------|------|--------|----------| +| Id | Guid | `id` | Yes | +| UserId | Guid? | `user_id` | No | +| MerchantId | Guid | `merchant_id` | Yes | +| EmployeeCode | string? | `employee_code` (max 20) | No | +| RoleId | int | `role_id` | Yes | +| StatusId | int | `status_id` | Yes | +| Permissions | StaffPermissions | `permissions` (int) | No | +| Phone | string? | `phone` (max 20) | No | +| Email | string? | `email` (max 100) | No | +| PinCodeHash | string? | `pin_code_hash` (max 100) | No | +| FirstName | string? | `first_name` (max 100) | No | +| LastName | string? | `last_name` (max 100) | No | +| Address | string? | `address` (max 500) | No | +| ProfilePhotoUrl | string? | `profile_photo_url` (max 500) | No | +| DocumentFrontUrl | string? | `document_front_url` (max 500) | No | +| DocumentBackUrl | string? | `document_back_url` (max 500) | No | +| JoinedAt | DateTime? | `joined_at` | No | +| TerminatedAt | DateTime? | `terminated_at` | No | +| CreatedAt | DateTime | `created_at` | Yes | +| UpdatedAt | DateTime? | `updated_at` | No | +| DeviceTokens | List\ | FK: staff_id -> device_tokens | Navigation | +| ShopAssignments | List\ | FK: staff_id -> shop_members | Navigation | + +**Factory Methods**: `Invite` (status=Invited), `CreateActive` (status=Active, joinedAt=now) + +**Behavior Methods**: `AcceptInvitation`, `Update`, `UpdateRole`, `UpdatePermissions`, `SetPinCode`, `VerifyPinCode`, `RegisterDevice`, `RemoveDevice`, `AssignToShop`, `RemoveFromShop`, `Deactivate`, `Reactivate`, `Terminate`, `HasPermission` + +**Domain Events**: `StaffInvitedDomainEvent`, `StaffJoinedDomainEvent`, `StaffPinCodeSetDomainEvent`, `StaffDeviceRegisteredDomainEvent`, `StaffAssignedToShopDomainEvent`, `StaffTerminatedDomainEvent` + +**Enumerations**: +- `StaffRole`: Cashier(1), Waiter(2), Manager(3), Admin(4), Kitchen(5), Barista(6) | Table: `staff_roles` (seeded: Cashier, Waiter, Manager, Admin) +- `StaffStatus`: Invited(1), Active(2), Inactive(3), Terminated(4) | Table: `staff_statuses` +- `ShopRole`: Cashier(1), Waiter(2), Manager(3), Owner(4), Kitchen(5), Barista(6) | Table: `shop_roles` (seeded: Cashier, Waiter, Manager, Owner) +- `StaffPermissions` (Flags): None(0), ViewSales(1), ProcessPayment(2), RefundOrder(4), ManageInventory(8), ViewReports(16), ManageStaff(32), ManageSettings(64), All(MaxValue) + +**Child Entity: DeviceToken** | **Table**: `device_tokens` + +| Field | Type | Column | Required | +|-------|------|--------|----------| +| Id | Guid | `id` | Yes | +| StaffId | Guid | `staff_id` (FK) | Yes | +| DeviceId | string | `device_id` (max 100) | Yes | +| DeviceName | string? | `device_name` (max 100) | No | +| FcmToken | string? | `fcm_token` (max 500) | No | +| Platform | string | `platform` (max 20) | Yes | +| LastUsedAt | DateTime? | `last_used_at` | No | +| CreatedAt | DateTime | `created_at` | Yes | + +**Child Entity: ShopMember** | **Table**: `shop_members` + +| Field | Type | Column | Required | +|-------|------|--------|----------| +| Id | Guid | `id` | Yes | +| StaffId | Guid | `staff_id` (FK) | Yes | +| ShopId | Guid | `shop_id` | Yes | +| BranchId | Guid? | `branch_id` | No | +| RoleId | int | `role_id` | Yes | +| CustomPermissions | StaffPermissions? | `custom_permissions` (int?) | No | +| IsPrimary | bool | `is_primary` (default false) | Yes | +| AssignedAt | DateTime | `assigned_at` | Yes | + +--- + +### Aggregate: AttendanceRecord +**Entity**: `AttendanceRecord` (Aggregate Root) | **Table**: `attendance_records` + +**Fields**: +| Field | Type | Column | Required | +|-------|------|--------|----------| +| Id | Guid | `id` | Yes | +| StaffId | Guid | `staff_id` | Yes | +| ShopId | Guid | `shop_id` | Yes | +| Date | DateTime | `date` | Yes | +| CheckIn | DateTime? | `check_in` | No | +| CheckOut | DateTime? | `check_out` | No | +| HoursWorked | decimal? | `hours_worked` (precision 5,2) | No | +| Status | string | `status` (max 20): Working/Completed/Late/Absent | Yes | +| Notes | string? | `notes` (max 500) | No | +| CreatedAt | DateTime | `created_at` | Yes | +| _updatedAt | DateTime? | `updated_at` | No | + +**Factory**: `CheckInNow(staffId, shopId)` — creates with status "Working" + +**Behavior Methods**: `DoCheckOut` (calculates HoursWorked, sets status "Completed"), `MarkAbsent`, `MarkLate` + +**Indexes**: `ix_attendance_staff_date` (StaffId + Date, UNIQUE), `ix_attendance_shop_date` (ShopId + Date) + +--- + +### Aggregate: LeaveRequest +**Entity**: `LeaveRequest` (Aggregate Root) | **Table**: `leave_requests` + +**Fields**: +| Field | Type | Column | Required | +|-------|------|--------|----------| +| Id | Guid | `id` | Yes | +| StaffId | Guid | `staff_id` | Yes | +| ShopId | Guid | `shop_id` | Yes | +| LeaveType | string | `leave_type` (max 20): Annual/Sick/Personal/Maternity/Other | Yes | +| StartDate | DateTime | `start_date` | Yes | +| EndDate | DateTime | `end_date` | Yes | +| Reason | string? | `reason` (max 500) | No | +| Status | string | `status` (max 20): Pending/Approved/Rejected | Yes | +| ApprovedBy | Guid? | `approved_by` | No | +| ApprovedAt | DateTime? | `approved_at` | No | +| RejectionReason | string? | `rejection_reason` (max 500) | No | +| CreatedAt | DateTime | `created_at` | Yes | + +**Computed**: `Days` = (EndDate - StartDate).Days + 1 + +**Factory**: `Create(staffId, shopId, leaveType, startDate, endDate, reason)` — validates endDate >= startDate, sets UTC dates + +**Behavior Methods**: `Approve(approvedBy)`, `Reject(rejectedBy, reason?)` — both require status == "Pending" + +**Indexes**: `ix_leave_requests_staff_id`, `ix_leave_requests_shop_id`, `ix_leave_requests_status` + +--- + +## Database Schema + +### Tables (14 total) + +**Main Aggregate Tables**: +| Table | Aggregate | Type | +|-------|-----------|------| +| `merchants` | Merchant | Aggregate Root | +| `shops` | Shop | Aggregate Root | +| `shop_branches` | Shop | Child entity (FK: shop_id) | +| `merchant_staff` | MerchantStaff | Aggregate Root | +| `device_tokens` | MerchantStaff | Child entity (FK: staff_id) | +| `shop_members` | MerchantStaff | Child entity (FK: staff_id) | +| `attendance_records` | AttendanceRecord | Aggregate Root | +| `leave_requests` | LeaveRequest | Aggregate Root | + +**Enumeration Lookup Tables** (seeded via migrations): +| Table | Values | +|-------|--------| +| `merchant_types` | Individual(1), Company(2) | +| `merchant_statuses` | PendingApproval(1), Active(2), Suspended(3), Banned(4) | +| `verification_statuses` | Unverified(1), Pending(2), Verified(3), Rejected(4) | +| `settlement_cycles` | Daily(1), Weekly(2), Monthly(3) | +| `shop_types` | OnlineOnly(1), PhysicalOnly(2), Hybrid(3) | +| `shop_statuses` | Draft(1), Active(2), Inactive(3), Closed(4) | +| `business_categories` | FoodBeverage(1)...Spa(15) (15 values) | +| `staff_roles` | Cashier(1), Waiter(2), Manager(3), Admin(4) | +| `staff_statuses` | Invited(1), Active(2), Inactive(3), Terminated(4) | +| `shop_roles` | Cashier(1), Waiter(2), Manager(3), Owner(4) | + +### Indexes + +| Index Name | Table | Columns | Unique | +|------------|-------|---------|--------| +| `ix_merchants_user_id` | merchants | user_id | No | +| `ix_merchants_status` | merchants | status_id | No | +| `ix_shops_merchant_id` | shops | merchant_id | No | +| `ix_shops_slug` | shops | slug | Yes | +| `ix_shops_status` | shops | status_id | No | +| `ix_shops_category` | shops | category_id | No | +| `ix_shop_branches_shop_id` | shop_branches | shop_id | No | +| `ix_merchant_staff_user_id` | merchant_staff | user_id | No | +| `ix_merchant_staff_merchant_id` | merchant_staff | merchant_id | No | +| `ix_merchant_staff_email` | merchant_staff | email | No | +| `ix_device_tokens_staff_id` | device_tokens | staff_id | No | +| `ix_device_tokens_device_id` | device_tokens | device_id | No | +| `ix_shop_members_staff_id` | shop_members | staff_id | No | +| `ix_shop_members_shop_id` | shop_members | shop_id | No | +| `ix_attendance_staff_date` | attendance_records | staff_id, date | Yes | +| `ix_attendance_shop_date` | attendance_records | shop_id, date | No | +| `ix_leave_requests_staff_id` | leave_requests | staff_id | No | +| `ix_leave_requests_shop_id` | leave_requests | shop_id | No | +| `ix_leave_requests_status` | leave_requests | status | No | + +### Owned Entities (flattened into parent table columns) + +**merchants** table owns: +- `BusinessInfo` -> tax_id, business_license_number, company_registration_number, established_date +- `SettlementConfig` -> commission_rate, settlement_cycle_id, auto_settlement + - `BankAccount` -> bank_code, bank_name, bank_account_number, bank_account_holder_name + +**shops** table owns: +- `ContactInfo` -> phone, email, website +- `OperatingHours` -> open_time, close_time, open_days +- `ShopFeatures` -> features_config (JSONB) + +**shop_branches** table owns: +- `Address` -> street, ward, district, city, province, postal_code, country_code +- `GeoLocation` -> latitude, longitude +- `OperatingHours` -> open_time, close_time, open_days + +--- + +## Domain Events + +### Merchant Events +| Event | Trigger | +|-------|---------| +| `MerchantRegisteredDomainEvent(Merchant)` | Merchant.Register() | +| `MerchantVerificationSubmittedDomainEvent(Merchant)` | Merchant.SubmitForVerification() | +| `MerchantApprovedDomainEvent(Merchant, ApprovedBy)` | Merchant.Approve() | +| `MerchantSuspendedDomainEvent(Merchant, Reason)` | Merchant.Suspend() | +| `MerchantBannedDomainEvent(Merchant, Reason)` | Merchant.Ban() | + +### Shop Events +| Event | Trigger | +|-------|---------| +| `ShopCreatedDomainEvent(Shop)` | Shop constructor | +| `ShopPublishedDomainEvent(Shop)` | Shop.Publish() | +| `ShopClosedDomainEvent(Shop)` | Shop.Close() | +| `ShopBranchAddedDomainEvent(Shop, Branch)` | Shop.AddBranch() | +| `ShopSetAsDefaultDomainEvent(Shop)` | Shop.SetAsDefault() | +| `ShopTransferredDomainEvent(Shop, PreviousMerchantId, NewMerchantId)` | Shop.TransferOwnership() | + +### Staff Events +| Event | Trigger | +|-------|---------| +| `StaffInvitedDomainEvent(Staff)` | MerchantStaff.Invite() | +| `StaffJoinedDomainEvent(Staff)` | MerchantStaff.AcceptInvitation() | +| `StaffPinCodeSetDomainEvent(Staff)` | MerchantStaff.SetPinCode() | +| `StaffDeviceRegisteredDomainEvent(Staff, Device)` | MerchantStaff.RegisterDevice() | +| `StaffAssignedToShopDomainEvent(Staff, Assignment)` | MerchantStaff.AssignToShop() | +| `StaffTerminatedDomainEvent(Staff)` | MerchantStaff.Terminate() | + +--- + +## DbContext + +**Class**: `MerchantServiceContext` (implements `DbContext` + `IUnitOfWork`) + +**DbSets**: +- `Merchants` -> Merchant +- `Shops` -> Shop +- `ShopBranches` -> ShopBranch +- `MerchantStaff` -> MerchantStaff +- `ShopMembers` -> ShopMember +- `DeviceTokens` -> DeviceToken +- `AttendanceRecords` -> AttendanceRecord +- `LeaveRequests` -> LeaveRequest + +**Key Behaviors**: +- `SaveEntitiesAsync()`: Dispatches domain events via MediatR before SaveChangesAsync +- `BeginTransactionAsync()`: Starts ReadCommitted isolation transaction +- `CommitTransactionAsync()`: Commits + disposes transaction +- `RollbackTransaction()`: Rollback + disposes transaction +- `OnModelCreating()`: ApplyConfigurationsFromAssembly + Ignores StaffRole/StaffStatus/ShopRole enumerations + +**Ignored Enumerations in ModelBuilder** (resolved in-memory, not FK relationships): +- StaffRole, StaffStatus, ShopRole + +--- + +## Repositories + +### IMerchantRepository / MerchantRepository +| Method | Return | Description | +|--------|--------|-------------| +| `GetByIdAsync(id)` | `Merchant?` | Filters by _isDeleted=false | +| `GetByUserIdAsync(userId)` | `Merchant?` | Filters by _isDeleted=false | +| `ExistsByUserIdAsync(userId)` | `bool` | Check existence | +| `GetAllAsync(pageNumber, pageSize)` | `IReadOnlyList` | Paginated, ordered by createdAt desc | +| `GetByStatusAsync(status)` | `IReadOnlyList` | Filter by MerchantStatus | +| `Add(merchant)` | `Merchant` | Add new | +| `Update(merchant)` | `void` | Mark as Modified | + +### IShopRepository / ShopRepository +| Method | Return | Description | +|--------|--------|-------------| +| `GetByIdAsync(id)` | `Shop?` | Without branches | +| `GetByIdWithBranchesAsync(id)` | `Shop?` | Include branches | +| `GetBySlugAsync(slug)` | `Shop?` | Lowercase match | +| `SlugExistsAsync(slug)` | `bool` | Check slug uniqueness | +| `GetByMerchantIdAsync(merchantId)` | `IReadOnlyList` | All shops for merchant | +| `GetByCategoryAsync(category, page, size)` | `IReadOnlyList` | Active shops by category, paginated | +| `GetActiveShopsWithBranchesAsync()` | `IReadOnlyList` | All active shops with branches | +| `GetByMerchantIdPagedAsync(merchantId, statusId?, page, size)` | `(Items, TotalCount)` | Paginated with optional status filter | +| `GetDefaultByMerchantIdAsync(merchantId)` | `Shop?` | Get default shop | +| `Add(shop)` | `Shop` | Add new | +| `Update(shop)` | `void` | Mark as Modified | + +### IMerchantStaffRepository / MerchantStaffRepository +| Method | Return | Description | +|--------|--------|-------------| +| `GetByIdAsync(id)` | `MerchantStaff?` | Include ShopAssignments + DeviceTokens | +| `GetByUserIdAsync(userId)` | `MerchantStaff?` | Include ShopAssignments, exclude Terminated | +| `GetByUserIdAndMerchantIdAsync(userId, merchantId)` | `MerchantStaff?` | Include ShopAssignments, exclude Terminated | +| `GetByMerchantIdAsync(merchantId)` | `IReadOnlyList` | Include ShopAssignments, exclude Terminated | +| `GetByShopIdAsync(shopId)` | `IReadOnlyList` | Via ShopAssignments join | +| `ExistsByUserIdAndMerchantIdAsync(userId, merchantId)` | `bool` | Exclude Terminated | +| `GetByEmailAsync(email)` | `MerchantStaff?` | Include ShopAssignments, exclude Terminated | +| `GetByIdsAsync(ids)` | `IReadOnlyList` | Batch lookup by ID list | +| `Add(staff)` | `MerchantStaff` | Add new | +| `Update(staff)` | `void` | Mark as Modified | + +### IAttendanceRepository / AttendanceRepository +| Method | Return | Description | +|--------|--------|-------------| +| `GetByIdAsync(id)` | `AttendanceRecord?` | Simple lookup | +| `GetTodayRecordAsync(staffId)` | `AttendanceRecord?` | Today's record for staff | +| `GetByStaffAndMonthAsync(staffId, month, year)` | `List` | Monthly records, desc by date | +| `GetByShopAndMonthAsync(shopId, month, year)` | `List` | Monthly records for shop | +| `Add(record)` | `AttendanceRecord` | Add new | +| `Update(record)` | `void` | Mark as Modified | + +### ILeaveRequestRepository / LeaveRequestRepository +| Method | Return | Description | +|--------|--------|-------------| +| `GetByIdAsync(id)` | `LeaveRequest?` | Simple lookup | +| `GetByStaffAsync(staffId)` | `List` | All for staff, desc by createdAt | +| `GetByShopAsync(shopId)` | `List` | All for shop, desc by createdAt | +| `Add(request)` | `LeaveRequest` | Add new | +| `Update(request)` | `void` | Mark as Modified | + +--- + +## Dependencies + +### NuGet Packages (from .csproj) +- **MediatR** 12.4.1 — CQRS command/query dispatch +- **FluentValidation** 11.11 — Input validation in pipeline +- **EF Core 10** (Microsoft.EntityFrameworkCore) — ORM +- **Npgsql.EntityFrameworkCore.PostgreSQL** 10 — PostgreSQL provider +- **Serilog** 8 — Structured logging +- **Hellang.Middleware.ProblemDetails** — RFC 7807 error responses +- **Asp.Versioning** 8.1 — API versioning +- **Swashbuckle** 7.2 — Swagger/OpenAPI + +### Infrastructure Dependencies +- **PostgreSQL** (Neon cloud) — Primary database +- **Redis** 7 — Cache (configured but not actively used in this service) +- **IAM Service** (port 5001) — JWT token authority + +### DI Registration (`DependencyInjection.cs`) +``` +services.AddDbContext(PostgreSQL + Npgsql retry 5x/30s) +services.AddScoped() +services.AddScoped() +services.AddScoped() +services.AddScoped() +services.AddScoped() +services.AddScoped() +``` + +### MediatR Pipeline Registration (`Program.cs`) +``` +cfg.RegisterServicesFromAssemblyContaining() +cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)) +cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>)) +cfg.AddOpenBehavior(typeof(TransactionBehavior<,>)) +``` + +--- + +## Configuration + +### appsettings.json +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech;Database=merchant_service;..." + }, + "Redis": { "ConnectionString": "localhost:6379" }, + "Jwt": { + "Authority": "http://localhost:5001", + "Audience": "goodgo-api", + "RequireHttpsMetadata": false + } +} +``` + +### appsettings.Development.json +- Logging: Debug level +- EF Core SQL logging enabled + +### appsettings.Production.json +- Logging: Warning level only +- ConnectionString: via environment variable (DATABASE_URL) + +### Environment Variables +| Variable | Purpose | +|----------|---------| +| `DATABASE_URL` | Fallback connection string | +| `ASPNETCORE_ENVIRONMENT` | Development enables sensitive data logging | +| `Jwt:Authority` | JWT issuer authority URL | + +### JWT Configuration +- Authority: IAM IdentityServer (default `http://localhost:5001`) +- Development mode: Skips signature validation (accepts any valid JWT structure) +- Production mode: Full signature validation enabled + +### Health Checks +| Endpoint | Purpose | +|----------|---------| +| `/health` | Full health (includes PostgreSQL probe) | +| `/health/live` | Liveness probe (app running, no DB check) | +| `/health/ready` | Readiness probe (includes PostgreSQL probe) | + +### Subscription Plans +| Plan | ID | Price | +|------|----|-------| +| Starter | 0 | Free | +| Growth | 1 | 299,000 VND/month | +| Pro | 2 | 799,000 VND/month | +| Enterprise | 3 | 1,999,000 VND/month | + +--- + +## Idempotency + +**IRequestManager / RequestManager**: Used for duplicate command detection via `ClientRequest` entity. Registered as scoped service. + +--- + +## File Structure + +``` +services/merchant-service-net/ + src/ + MerchantService.API/ + Application/ + Behaviors/ + LoggingBehavior.cs + ValidatorBehavior.cs + TransactionBehavior.cs + Commands/ + Admin/ (Approve/Reject/Suspend/Ban/Reactivate Merchant) + Attendance/ (CheckIn, CheckOut) + LeaveRequests/ (Create/Approve/Reject LeaveRequest) + Merchants/ (Register/Update/Verify Merchant) + Pos/ (PinAuth, RegisterDevice) + Shops/ (Create/Update/Publish/Close/Branch/Transfer Shop) + Staff/ (Invite/CreateActive/Update/Delete/AcceptInvite Staff) + Subscriptions/ (Subscribe) + Queries/ + Admin/ (GetAllMerchants/Shops, GetMerchantDetail/Statistics) + Attendance/ (GetAttendanceByStaff/Shop) + LeaveRequests/ (GetLeaveRequestsByStaff/Shop) + Merchants/ (GetMerchantProfile/ById) + Pos/ (GetPosStaff, GetDevices) + Shops/ (GetMyShops/ById/BySlug/Settings/Stats/Branches/Nearby) + Staff/ (GetMyStaff, GetStaffRoles) + Subscriptions/ (GetSubscription/Plans/Usage) + Validations/ (18 validators) + Controllers/ + MerchantsController.cs + ShopsController.cs + StaffController.cs (StaffController + StaffPublicController) + AttendanceController.cs + LeaveRequestsController.cs + PosController.cs + DevicesController.cs + SubscriptionsController.cs + AdminMerchantsController.cs + AdminShopsController.cs + Program.cs + appsettings.json / .Development.json / .Production.json + + MerchantService.Domain/ + AggregatesModel/ + MerchantAggregate/ (Merchant, IMerchantRepository, MerchantStatus, MerchantType, VerificationStatus, SettlementCycle, BusinessInfo, SettlementConfig, BankAccount) + ShopAggregate/ (Shop, IShopRepository, ShopBranch, ShopStatus, ShopType, BusinessCategory, ContactInfo, OperatingHours, ShopFeatures, Address, GeoLocation) + MerchantStaffAggregate/ (MerchantStaff, IMerchantStaffRepository, StaffRole, StaffStatus, ShopRole, StaffPermissions, DeviceToken, ShopMember) + AttendanceAggregate/ (AttendanceRecord, IAttendanceRepository) + LeaveRequestAggregate/ (LeaveRequest, ILeaveRequestRepository) + Events/ + MerchantDomainEvents.cs (5 events) + ShopDomainEvents.cs (6 events) + StaffDomainEvents.cs (6 events) + SeedWork/ (Entity, IAggregateRoot, IRepository, IUnitOfWork, ValueObject, Enumeration) + Exceptions/ (DomainException) + + MerchantService.Infrastructure/ + MerchantServiceContext.cs (DbContext + IUnitOfWork) + DependencyInjection.cs (AddInfrastructure extension) + EntityConfigurations/ + MerchantEntityTypeConfiguration.cs (+ MerchantType, MerchantStatus, VerificationStatus, SettlementCycle configs) + ShopEntityTypeConfiguration.cs (+ ShopBranch, ShopType, ShopStatus, BusinessCategory configs) + MerchantStaffEntityTypeConfiguration.cs (+ DeviceToken, ShopMember, StaffRole, StaffStatus, ShopRole configs) + AttendanceRecordEntityTypeConfiguration.cs + LeaveRequestEntityTypeConfiguration.cs + Repositories/ + MerchantRepository.cs + ShopRepository.cs + MerchantStaffRepository.cs + AttendanceRepository.cs + LeaveRequestRepository.cs + Idempotency/ + IRequestManager.cs + RequestManager.cs + ClientRequest.cs + Migrations/ (EF Core migrations) +``` diff --git a/services/mining-service-net/SERVICE_DOCS.md b/services/mining-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..6c21f341 --- /dev/null +++ b/services/mining-service-net/SERVICE_DOCS.md @@ -0,0 +1,559 @@ +# MiningService - Service Documentation + +## 1. Overview + +**Purpose**: Pi Network-style point mining service. Users ("miners") earn Mining Points (MP) over time through 24-hour mining sessions. The service manages miner profiles, mining sessions with streak bonuses, security circles (trusted groups for bonus multipliers), and referral programs. + +**Port**: `5006` (Development, via launchSettings.json) / `8080` (Docker/Production) + +**Database**: PostgreSQL -- `mining_service` on Neon PostgreSQL (`ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech`) + +**Solution**: `MiningService.slnx` + +**Target Framework**: .NET 10.0, C# 14 + +**Architecture**: Clean Architecture + CQRS (MediatR) + +**Real-time**: SignalR Hub at `/hubs/mining` for live mining updates + +**Auth**: JWT Bearer (Duende IdentityServer), with SignalR token via query string + +--- + +## 2. API Endpoints + +### MiningController (`/api/v1/mining`) -- [Authorize] + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/api/v1/mining/me?userId={guid}` | Get current miner status | Query: `userId` (Guid) | `MinerStatusDto` or 404 | +| POST | `/api/v1/mining/start` | Start a new 24h mining session | Body: `StartMiningRequest { UserId }` | `StartMiningResult` | +| POST | `/api/v1/mining/claim` | Claim mining reward from completed session | Body: `ClaimMiningRequest { UserId }` | `ClaimMiningRewardResult` | +| GET | `/api/v1/mining/history?userId={guid}&page=1&pageSize=20` | Get mining history (paginated) | Query params | `MiningHistoryDto` | +| GET | `/api/v1/mining/rate?userId={guid}` | Get current mining rate breakdown | Query: `userId` (Guid) | `MiningRateDto` or 404 | +| GET | `/api/v1/mining/leaderboard?limit=100` | Get top miners leaderboard | Query: `limit` (int) | `LeaderboardDto` -- **[AllowAnonymous]** | + +### CirclesController (`/api/v1/circles`) -- [Authorize] + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/api/v1/circles/me?userId={guid}` | Get user's circle | Query: `userId` (Guid) | `CircleDto` or 404 | +| POST | `/api/v1/circles` | Create a new security circle | Body: `CreateCircleRequest { UserId, Name }` | `CreateCircleResult` (201) | +| POST | `/api/v1/circles/invite` | Invite a member to circle | Body: `InviteMemberRequest { UserId, TargetMinerId }` | 200 message | +| POST | `/api/v1/circles/accept/{inviteId}?userId={guid}` | Accept circle invitation | Path: `inviteId`, Query: `userId` | `AcceptCircleInviteResult` | +| DELETE | `/api/v1/circles/members/{memberId}?ownerId={guid}` | Remove member from circle | Path: `memberId`, Query: `ownerId` | `RemoveCircleMemberResult` | +| GET | `/api/v1/circles/trust-score?userId={guid}` | Get circle trust score | Query: `userId` (Guid) | `CircleTrustScoreDto` or 404 | + +### ReferralsController (`/api/v1/referrals`) -- [Authorize] + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/api/v1/referrals?userId={guid}` | Get referral code and referrals list | Query: `userId` (Guid) | `ReferralsDto` | +| POST | `/api/v1/referrals/apply` | Apply a referral code | Body: `ApplyReferralRequest { UserId, ReferralCode }` | `ApplyReferralResult` | +| GET | `/api/v1/referrals/code?userId={guid}` | Get my referral code | Query: `userId` (Guid) | `ReferralCodeDto` or 404 | +| GET | `/api/v1/referrals/stats?userId={guid}` | Get referral statistics | Query: `userId` (Guid) | `ReferralStatsDto` or 404 | + +### AdminController (`/api/v1/admin`) -- [Authorize(Roles = "Admin")] + +#### Configuration + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/api/v1/admin/config` | Get all system configuration | -- | `SystemConfigDto` | +| PUT | `/api/v1/admin/config` | Update system configuration | Body: `UpdateSystemConfigCommand` | `UpdateConfigResult` | +| GET | `/api/v1/admin/config/mining` | Get mining configuration | -- | `MiningConfigDto` | +| PUT | `/api/v1/admin/config/mining` | Update mining configuration | Body: `UpdateMiningConfigCommand` | `UpdateConfigResult` | +| GET | `/api/v1/admin/config/streak` | Get streak configuration | -- | `StreakConfigDto` | +| PUT | `/api/v1/admin/config/streak` | Update streak configuration | Body: `UpdateStreakConfigCommand` | `UpdateConfigResult` | +| GET | `/api/v1/admin/config/referral` | Get referral configuration | -- | `ReferralConfigDto` | +| PUT | `/api/v1/admin/config/referral` | Update referral configuration | Body: `UpdateReferralConfigCommand` | `UpdateConfigResult` | + +#### Miner Management + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/api/v1/admin/miners?page=1&pageSize=20&search=` | List all miners (paginated) | Query params | `MinersListDto` | +| GET | `/api/v1/admin/miners/{id}` | Get miner details | Path: `id` (Guid) | `MinerDetailsDto` or 404 | +| PUT | `/api/v1/admin/miners/{minerId}/suspend` | Suspend a miner account | Body: `SuspendRequest { Reason }` | 200 message | +| PUT | `/api/v1/admin/miners/{minerId}/ban` | Ban a miner account | Body: `BanRequest { Reason }` | `BanMinerResult` | +| PUT | `/api/v1/admin/miners/{minerId}/restore` | Restore a suspended miner | -- | 200 message | +| PUT | `/api/v1/admin/miners/{minerId}/adjust-points` | Adjust miner points | Body: `AdjustPointsRequest { Amount, Reason }` | `AdjustPointsResult` | +| PUT | `/api/v1/admin/miners/{minerId}/reset-streak` | Reset miner streak | Body: `ResetStreakRequest { Reason }` | `ResetStreakResult` | + +#### Analytics + +| Method | Route | Description | Response | +|--------|-------|-------------|----------| +| GET | `/api/v1/admin/analytics/overview` | Admin dashboard overview | `AdminOverviewDto` | +| GET | `/api/v1/admin/analytics/miners` | Miner analytics | `MinerAnalyticsDto` | +| GET | `/api/v1/admin/analytics/circles` | Circle analytics | `CircleAnalyticsDto` (stub) | +| GET | `/api/v1/admin/analytics/referrals` | Referral analytics | `ReferralAnalyticsDto` (stub) | +| GET | `/api/v1/admin/analytics/points` | Points analytics | `PointsAnalyticsDto` (stub -- no handler) | +| GET | `/api/v1/admin/analytics/streaks` | Streak analytics | `StreakAnalyticsDto` (stub -- no handler) | +| GET | `/api/v1/admin/audit-logs?page=1&pageSize=50` | Audit logs | `AuditLogsDto` (stub -- no handler) | + +### Health Endpoints (no auth) + +| Route | Description | +|-------|-------------| +| `/health` | Full health check (includes PostgreSQL) | +| `/health/live` | Liveness probe (app is running) | +| `/health/ready` | Readiness probe (ready for traffic) | + +### SignalR Hub + +| Endpoint | `/hubs/mining` | +|----------|----------------| +| **JoinMinerGroup(minerId)** | Subscribe to personal mining updates | +| **LeaveMinerGroup(minerId)** | Unsubscribe from personal mining updates | +| **JoinLeaderboardGroup()** | Subscribe to leaderboard updates | + +Server-to-client messages via `IMiningHubService`: +- `PointsUpdated` -- `{ earnedPoints, totalPoints, streakDays }` +- `SessionStarted` -- `{ endTime, hourlyRate }` +- `StreakMilestone` -- `{ streakDays, bonusPoints }` + +--- + +## 3. Commands + +### Core Commands + +| Command | Parameters | Result | Handler | +|---------|-----------|--------|---------| +| `StartMiningCommand` | `UserId` (Guid) | `StartMiningResult { SessionId, HourlyRate, EndTime, StreakDays }` | Validates miner active, no existing session. Recalculates rate, creates MiningSession. | +| `ClaimMiningRewardCommand` | `UserId` (Guid) | `ClaimMiningRewardResult { PointsEarned, TotalPoints, StreakDays, StreakBonus }` | Validates session ready to claim. Calculates points, increments streak, adds milestone bonuses, creates MiningHistory entries. | +| `CreateCircleCommand` | `UserId` (Guid), `Name` (string) | `CreateCircleResult { CircleId, Name }` | Validates miner exists, not already in/owning a circle. Creates Circle with owner as first member. | +| `InviteToCircleCommand` | `UserId` (Guid), `TargetMinerId` (Guid) | `bool` | Validates inviter owns a circle, target not in a circle. Adds member, recalculates rates if circle becomes valid. | +| `ApplyReferralCodeCommand` | `UserId` (Guid), `ReferralCode` (string) | `ApplyReferralResult { ReferralId, ReferrerId, IsActive }` | Validates no existing referrer, valid code, not self-referral. Creates inactive Referral (pending KYC). | + +### Circle Management Commands + +| Command | Parameters | Result | Handler | +|---------|-----------|--------|---------| +| `AcceptCircleInviteCommand` | `UserId` (Guid), `InviteId` (Guid) | `AcceptCircleInviteResult { Success, Message }` | Simplified: adds user as member to circle by InviteId. | +| `RemoveCircleMemberCommand` | `OwnerId` (Guid), `MemberId` (Guid) | `RemoveCircleMemberResult { Success, Message }` | Validates owner owns circle. Calls circle.RemoveMember(). | + +### Admin Commands + +| Command | Parameters | Result | Handler | +|---------|-----------|--------|---------| +| `SuspendMinerCommand` | `MinerId` (Guid), `Reason` (string) | `bool` | Calls miner.Suspend(). | +| `RestoreMinerCommand` | `MinerId` (Guid) | `bool` | Calls miner.Restore(). | +| `BanMinerCommand` | `MinerId` (Guid), `Reason` (string) | `BanMinerResult { Success, Message }` | Calls miner.Suspend() (note: uses Suspend, not Ban). | +| `AdjustMinerPointsCommand` | `MinerId` (Guid), `Amount` (decimal), `Reason` (string) | `AdjustPointsResult { Success, NewBalance, Message }` | Calls miner.AddBonusPoints(). | +| `ResetMinerStreakCommand` | `MinerId` (Guid), `Reason` (string) | `ResetStreakResult { Success, Message }` | Stub -- saves but does not actually reset streak. | +| `UpdateSystemConfigCommand` | `Mining?`, `Streak?`, `Referral?` | `UpdateConfigResult { Success, Message }` | Stub -- returns success without persisting. | +| `UpdateMiningConfigCommand` | `BaseRate?`, `SessionDurationHours?`, `MaxSessionsPerDay?` | `UpdateConfigResult` | No dedicated handler (uses UpdateSystemConfigCommandHandler pattern). | +| `UpdateStreakConfigCommand` | `Tiers?`, `GracePeriodDays?` | `UpdateConfigResult` | No dedicated handler registered. | +| `UpdateReferralConfigCommand` | `BonusPerReferral?`, `MaxBonusCap?`, `KycRequired?` | `UpdateConfigResult` | Stub handler -- returns success without persisting. | + +--- + +## 4. Queries + +| Query | Parameters | Returns | +|-------|-----------|---------| +| `GetMinerStatusQuery` | `UserId` (Guid) | `MinerStatusDto? { MinerId, Role, TotalMinedPoints, HourlyRate, DailyRate, CurrentStreak, LongestStreak, StreakBonus, HasActiveSession, SessionEndTime, Status }` | +| `GetMiningHistoryQuery` | `UserId`, `Page`, `PageSize` | `MiningHistoryDto { Items[], TotalCount, Page, PageSize }` -- in-memory pagination from miner's histories | +| `GetMiningRateQuery` | `UserId` (Guid) | `MiningRateDto? { BaseRate, RoleMultiplier, CircleBonus, ReferralBonus, StreakBonus, TotalRate, HourlyPoints, DailyPoints }` | +| `GetLeaderboardQuery` | `Limit` (int, default 100) | `LeaderboardDto { Entries[] }` -- top miners by TotalMinedPoints | +| `GetCircleQuery` | `UserId` (Guid) | `CircleDto? { CircleId, Name, OwnerId, MemberCount, TrustScore, BonusMultiplier, IsValid, Status, Members[] }` | +| `GetCircleTrustScoreQuery` | `UserId` (Guid) | `CircleTrustScoreDto? { CircleId, Name, MemberCount, TrustScore, IsValid, BonusMultiplier }` | +| `GetReferralsQuery` | `UserId` (Guid) | `ReferralsDto { MyReferralCode, TotalReferrals, ActiveReferrals, TotalBonusPercent, Referrals[] }` | +| `GetReferralCodeQuery` | `UserId` (Guid) | `ReferralCodeDto? { ReferralCode, ShareUrl }` -- URL: `https://goodgo.app/invite/{code}` | +| `GetReferralStatsQuery` | `UserId` (Guid) | `ReferralStatsDto? { ReferralCode, TotalReferrals, ActiveReferrals, PendingReferrals, TotalEarned, CurrentBonusRate }` | +| `GetSystemConfigQuery` | -- | `SystemConfigDto { Mining, Streak, Referral }` -- loads from in-memory ConfigurationRepository | +| `GetReferralConfigQuery` | -- | `ReferralConfigDto { BonusPerReferral, MaxBonusCap, KycRequired }` | +| `GetAdminOverviewQuery` | -- | `AdminOverviewDto { TotalMiners, ActiveMiners, MinersWithActiveSession, TotalPointsMined, TotalCircles, ValidCircles, TotalReferrals, ActiveReferrals }` -- uses DbContext directly | +| `GetMinersListQuery` | `Page`, `PageSize`, `Search?` | `MinersListDto { Items[], TotalCount, Page, PageSize }` | +| `GetMinerDetailsQuery` | `MinerId` (Guid) | `MinerDetailsDto?` -- full miner detail including rate, streak, circle | +| `GetMinerAnalyticsQuery` | -- | `MinerAnalyticsDto { TotalMiners, ActiveMiners, SuspendedMiners, TotalPointsMined, PointsMinedToday, RoleDistribution }` | +| `GetCircleAnalyticsQuery` | -- | `CircleAnalyticsDto` -- **stub**, returns zeros | +| `GetReferralAnalyticsQuery` | -- | `ReferralAnalyticsDto` -- **stub**, returns zeros | +| `GetPointsAnalyticsQuery` | -- | `PointsAnalyticsDto` -- **no handler registered** | +| `GetStreakAnalyticsQuery` | -- | `StreakAnalyticsDto` -- **no handler registered** | +| `GetAuditLogsQuery` | `Page`, `PageSize` | `AuditLogsDto` -- **no handler registered** | + +--- + +## 5. Domain Model + +### Aggregates + +#### Miner (Aggregate Root) +- **File**: `MiningService.Domain/AggregatesModel/MinerAggregate/Miner.cs` +- **Properties**: `UserId`, `Role` (MinerRole), `TotalMinedPoints`, `CurrentRate` (MiningRate, owned), `ActiveSession` (MiningSession?, owned), `Streak` (MiningStreak, owned), `ReferralCode`, `ReferredById?`, `CircleId?`, `Status` (MinerStatus), `CreatedAt`, `UpdatedAt`, `RowVersion` (concurrency token), `MiningHistories` (collection) +- **Factory**: `Miner.Create(userId, referralCode?, referredById?)` -- generates 8-char alphanumeric referral code, raises `MinerCreatedDomainEvent` +- **Behavior Methods**: + - `StartMiningSession(baseRate, sessionHours)` -- validates active status, no existing session; recalculates rate; creates session; raises `MiningSessionStartedDomainEvent` + - `ClaimMiningReward()` -- validates session ready; calculates points + streak milestone bonuses; increments streak; creates MiningHistory entries; raises `PointsMinedDomainEvent` + - `RecalculateRate(baseRate, circleBonus, referralBonus)` -- computes TotalRate formula + - `UpgradeRole(newRole)` -- only allows upgrades + - `JoinCircle(circleId)` -- auto-upgrades Pioneer to Contributor + - `LeaveCircle()` -- auto-downgrades Contributor to Pioneer + - `Suspend()`, `Restore()`, `Ban()` -- status transitions + - `AddBonusPoints(points, source)`, `DeductPoints(points, reason)` -- with history tracking + +**Milestone Bonuses** (on streak claim): +| Streak Day | Bonus MP | +|-----------|----------| +| 7 | 50 | +| 14 | 100 | +| 30 | 300 | +| 60 | 500 | +| 90 | 1000 | + +#### Circle (Aggregate Root) +- **File**: `MiningService.Domain/AggregatesModel/CircleAggregate/Circle.cs` +- **Properties**: `OwnerId`, `Name`, `TrustScore` (0-100), `BonusMultiplier`, `Status` (CircleStatus), `CreatedAt`, `UpdatedAt`, `Members` (collection of CircleMember) +- **Constants**: `MinMembers = 3`, `MaxMembers = 5`, `ValidCircleBonus = 0.25m` (25%) +- **Computed**: `ActiveMemberCount`, `IsValid` (Active status + 3+ active members) +- **Factory**: `Circle.Create(ownerId, name)` -- owner auto-added as first member, status = Incomplete +- **Behavior Methods**: + - `AddMember(minerId)` -- validates not disbanded, max 5, not duplicate; recalculates status; raises `CircleCompletedDomainEvent` when reaching 3 members + - `RemoveMember(minerId)` -- cannot remove owner; deactivates member + - `Disband()` -- sets Disbanded, zeroes bonus, deactivates all members +- **Trust Score**: 3 members = 60, 4 = 80, 5 = 100 + +#### CircleMember (Entity) +- **File**: `MiningService.Domain/AggregatesModel/CircleAggregate/CircleMember.cs` +- **Properties**: `CircleId`, `MinerId`, `JoinedAt`, `IsActive` +- **Methods**: `Deactivate()`, `Activate()` + +#### Referral (Aggregate Root) +- **File**: `MiningService.Domain/AggregatesModel/ReferralAggregate/Referral.cs` +- **Properties**: `ReferrerId`, `ReferredId`, `ReferralCode`, `BonusRate` (default 0.25 = 25%), `IsActive`, `Level` (default 1), `CreatedAt`, `ActivatedAt?` +- **Factory**: `Referral.Create(referrerId, referredId, referralCode, bonusRate, level)` -- validates no self-referral, starts inactive +- **Behavior Methods**: + - `Activate()` -- sets active + timestamp; raises `ReferralActivatedDomainEvent` + - `Deactivate()` -- sets inactive + - `CalculateBonus(baseRate)` -- returns `baseRate * BonusRate` if active, else 0 + +#### MiningHistory (Entity, child of Miner) +- **File**: `MiningService.Domain/AggregatesModel/MinerAggregate/MiningHistory.cs` +- **Properties**: `MinerId`, `PointsEarned`, `Source`, `SessionId?`, `EarnedAt`, `HourlyRateSnapshot`, `StreakDaySnapshot` +- **Factories**: `CreateFromSession(...)`, `CreateFromBonus(...)` + +### Configuration Aggregates (in-memory, not persisted to DB) + +#### MiningConfiguration +- **Properties**: `BaseRate` (0.25), `SessionDurationHours` (24), `MaxSessionsPerDay` (1), `IsGloballyEnabled` (true), `UpdatedAt`, `UpdatedBy` + +#### StreakConfiguration +- **Properties**: `Tiers` (collection of StreakTier), `GracePeriodEnabled` (true), `GracePeriodHours` (24), `RecoveryCost` (50 MP), `FreezeTokenDays` (7), `UpdatedAt`, `UpdatedBy` +- **Default Tiers**: + +| MinDays | MaxDays | BonusPercent | BadgeName | MilestoneMpBonus | +|---------|---------|-------------|-----------|-----------------| +| 1 | 2 | 0% | -- | 0 | +| 3 | 6 | 10% | 3-day badge | 0 | +| 7 | 13 | 25% | 7-day badge | 50 | +| 14 | 29 | 50% | 14-day badge | 100 | +| 30 | 59 | 100% | 30-day badge | 300 | +| 60 | 89 | 125% | 60-day badge | 500 | +| 90 | MAX | 150% | 90-day badge | 1000 | + +#### ReferralConfiguration +- **Properties**: `BonusPercentPerReferral` (0.25), `MaxBonusPercent` (1.0 = 100%), `KycRequired` (true), `MaxReferralLevels` (1), `UpdatedAt`, `UpdatedBy` + +### Value Objects + +#### MiningRate (record) +- `BaseRate`, `RoleBonus`, `CircleBonus`, `ReferralBonus`, `StreakBonus` +- `TotalRate = BaseRate * (1 + Role) * (1 + Circle) * (1 + Referral) * (1 + Streak)` +- `DailyRate = TotalRate * 24` +- Default: BaseRate = 0.25 MP/hour + +#### MiningStreak (record) +- `CurrentStreak`, `LongestStreak`, `LastMiningDate`, `FreezeTokens`, `IsGracePeriod` +- `BonusMultiplier`: 0% (days 1-2), 10% (3-6), 25% (7-13), 50% (14-29), 100% (30-59), 125% (60-89), 150% (90+) +- `IncrementStreak()` -- earns 1 freeze token per 7 days +- `Reset()` -- zeroes CurrentStreak + +#### MiningSession (record) +- `SessionId`, `StartTime`, `EndTime`, `HourlyRate`, `Status` (MiningSessionStatus), `AccumulatedPoints` +- `IsReadyToClaim` -- Active status and EndTime passed +- `CalculateEarnedPoints()` -- elapsed hours * HourlyRate, capped at 24h +- `MarkAsClaimed(earnedPoints)` -- sets Claimed status + +### Enumerations + +#### MinerRole +| Value | Name | Bonus | +|-------|------|-------| +| 0 | Pioneer | 0% (base user) | +| 1 | Contributor | +10% (has valid circle) | +| 2 | Ambassador | +25% (5+ referrals) | +| 3 | NodeOperator | +50% (runs node software) | + +#### MinerStatus +| Value | Name | +|-------|------| +| 0 | Active | +| 1 | Suspended | +| 2 | Banned | + +#### MiningSessionStatus +| Value | Name | +|-------|------| +| 0 | Active | +| 1 | Completed | +| 2 | Claimed | +| 3 | Expired | + +#### CircleStatus +| Value | Name | +|-------|------| +| 0 | Incomplete (< 3 members) | +| 1 | Active (3-5 members, valid for bonus) | +| 2 | Disbanded | + +### Domain Exceptions + +- `MiningDomainException` -- base exception +- `MinerNotFoundException` -- miner not found by ID or UserId +- `CircleDomainException` -- circle operation failures +- `ReferralDomainException` -- referral operation failures + +--- + +## 6. Database Schema + +**Database**: `mining_service` (PostgreSQL) + +**Migration**: `20260117103924_InitialCreate` + +### Table: `Miners` + +| Column | Type | Constraints | +|--------|------|-------------| +| `Id` | uuid | PK | +| `UserId` | uuid | NOT NULL, UNIQUE (IX_Miners_UserId) | +| `Role` | varchar(20) | NOT NULL, string conversion of MinerRole enum | +| `TotalMinedPoints` | numeric(18,4) | NOT NULL | +| `CurrentRate_BaseRate` | numeric(18,4) | NOT NULL (owned type) | +| `CurrentRate_RoleBonus` | numeric(5,4) | NOT NULL (owned type) | +| `CurrentRate_CircleBonus` | numeric(5,4) | NOT NULL (owned type) | +| `CurrentRate_ReferralBonus` | numeric(5,4) | NOT NULL (owned type) | +| `CurrentRate_StreakBonus` | numeric(5,4) | NOT NULL (owned type) | +| `ActiveSession_SessionId` | uuid | NULLABLE (owned type) | +| `ActiveSession_StartTime` | timestamp with time zone | NULLABLE | +| `ActiveSession_EndTime` | timestamp with time zone | NULLABLE | +| `ActiveSession_HourlyRate` | numeric(18,4) | NULLABLE | +| `ActiveSession_Status` | varchar(20) | NULLABLE | +| `ActiveSession_AccumulatedPoints` | numeric(18,4) | NULLABLE | +| `Streak_CurrentStreak` | integer | NOT NULL (owned type) | +| `Streak_LongestStreak` | integer | NOT NULL | +| `Streak_LastMiningDate` | timestamp with time zone | NOT NULL | +| `Streak_FreezeTokens` | integer | NOT NULL | +| `Streak_IsGracePeriod` | boolean | NOT NULL | +| `ReferralCode` | varchar(10) | NOT NULL, UNIQUE (IX_Miners_ReferralCode) | +| `ReferredById` | uuid | NULLABLE | +| `CircleId` | uuid | NULLABLE | +| `Status` | varchar(20) | NOT NULL | +| `CreatedAt` | timestamp with time zone | NOT NULL | +| `UpdatedAt` | timestamp with time zone | NOT NULL | +| `RowVersion` | bytea | NOT NULL, row version (concurrency token) | + +**Indexes**: `IX_Miners_UserId` (unique), `IX_Miners_ReferralCode` (unique) + +### Table: `MiningHistories` + +| Column | Type | Constraints | +|--------|------|-------------| +| `Id` | uuid | PK | +| `MinerId` | uuid | NOT NULL, FK -> Miners (CASCADE) | +| `PointsEarned` | numeric(18,4) | NOT NULL | +| `Source` | varchar(50) | NOT NULL | +| `SessionId` | uuid | NULLABLE | +| `EarnedAt` | timestamp with time zone | NOT NULL | +| `HourlyRateSnapshot` | numeric(18,4) | NOT NULL | +| `StreakDaySnapshot` | integer | NOT NULL | + +**Indexes**: `IX_MiningHistories_MinerId_EarnedAt` (composite) + +### Table: `Circles` + +| Column | Type | Constraints | +|--------|------|-------------| +| `Id` | uuid | PK | +| `OwnerId` | uuid | NOT NULL | +| `Name` | varchar(100) | NOT NULL | +| `TrustScore` | numeric(5,2) | NOT NULL | +| `BonusMultiplier` | numeric(5,4) | NOT NULL | +| `Status` | varchar(20) | NOT NULL | +| `CreatedAt` | timestamp with time zone | NOT NULL | +| `UpdatedAt` | timestamp with time zone | NOT NULL | + +**Indexes**: `IX_Circles_OwnerId` + +### Table: `CircleMembers` + +| Column | Type | Constraints | +|--------|------|-------------| +| `Id` | uuid | PK | +| `CircleId` | uuid | NOT NULL, FK -> Circles (CASCADE) | +| `MinerId` | uuid | NOT NULL | +| `JoinedAt` | timestamp with time zone | NOT NULL | +| `IsActive` | boolean | NOT NULL | + +**Indexes**: `IX_CircleMembers_CircleId_MinerId` (unique composite), `IX_CircleMembers_MinerId` + +### Table: `Referrals` + +| Column | Type | Constraints | +|--------|------|-------------| +| `Id` | uuid | PK | +| `ReferrerId` | uuid | NOT NULL | +| `ReferredId` | uuid | NOT NULL, UNIQUE (IX_Referrals_ReferredId) | +| `ReferralCode` | varchar(10) | NOT NULL | +| `BonusRate` | numeric(5,4) | NOT NULL | +| `IsActive` | boolean | NOT NULL | +| `Level` | integer | NOT NULL | +| `CreatedAt` | timestamp with time zone | NOT NULL | +| `ActivatedAt` | timestamp with time zone | NULLABLE | + +**Indexes**: `IX_Referrals_ReferrerId`, `IX_Referrals_ReferredId` (unique) + +**Note**: Table names use PascalCase (e.g., `Miners`, `MiningHistories`), not the platform-standard snake_case. Value objects (MiningRate, MiningStreak, MiningSession) are stored as owned types with `{OwnerProperty}_{ValueProperty}` column naming. + +--- + +## 7. Integration Events + +### Published Events (outbound) + +| Event | Fields | Description | +|-------|--------|-------------| +| `PointsMinedIntegrationEvent` | `EventId`, `OccurredOn`, `UserId`, `MinerId`, `Points`, `Source`, `StreakDays` | Points mined, should be credited to wallet | +| `ReferralActivatedIntegrationEvent` | `EventId`, `OccurredOn`, `ReferrerId`, `ReferredUserId`, `BonusRate` | Referral activated after KYC | +| `CircleCompletedIntegrationEvent` | `EventId`, `OccurredOn`, `CircleId`, `OwnerId`, `MemberCount`, `BonusMultiplier` | Security circle completed (3+ members) | + +### Consumed Events (inbound) + +| Event | Handler | Description | +|-------|---------|-------------| +| `UserRegisteredIntegrationEvent` | `UserRegisteredIntegrationEventHandler` | Creates new Miner profile when user registers in IAM. If referral code provided, creates Referral relationship. | +| `UserKycCompletedIntegrationEvent` | `UserKycCompletedIntegrationEventHandler` | Activates pending referral for user who completed KYC verification. | + +**Note**: Integration events are defined as records implementing `IIntegrationEvent`. Handlers use MediatR notification wrappers (`UserRegisteredNotification`, `UserKycCompletedNotification`). The actual message broker (RabbitMQ) integration for publishing/consuming is not yet wired up -- the event definitions and handlers exist but the transport layer is missing. + +### Domain Events + +| Event | Fields | Raised By | +|-------|--------|-----------| +| `MinerCreatedDomainEvent` | `MinerId`, `UserId` | `Miner.Create()` | +| `MiningSessionStartedDomainEvent` | `MinerId`, `SessionId`, `HourlyRate` | `Miner.StartMiningSession()` | +| `PointsMinedDomainEvent` | `MinerId`, `PointsEarned`, `TotalPoints`, `StreakDays` | `Miner.ClaimMiningReward()` | +| `StreakUpdatedDomainEvent` | `MinerId`, `PreviousStreak`, `NewStreak`, `NewBonusMultiplier` | Defined but not raised in current code | +| `CircleCompletedDomainEvent` | `CircleId`, `OwnerId`, `MemberCount` | `Circle.RecalculateStatus()` (when reaching 3 members) | +| `ReferralActivatedDomainEvent` | `ReferralId`, `ReferrerId`, `ReferredId` | `Referral.Activate()` | +| `ConfigurationUpdatedDomainEvent` | `ConfigType`, `UpdatedBy`, `UpdatedAt` | Defined but not raised in current code | + +--- + +## 8. Dependencies + +### NuGet Packages + +**API Layer** (`MiningService.API.csproj`): +| Package | Version | +|---------|---------| +| MediatR | 12.4.1 | +| FluentValidation | 11.11.0 | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | +| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.2 | +| Microsoft.EntityFrameworkCore.Design | 10.0.2 | +| Microsoft.Extensions.Http.Polly | 10.0.2 | +| Swashbuckle.AspNetCore | 7.2.0 | +| Asp.Versioning.Mvc | 8.1.0 | +| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | +| AspNetCore.HealthChecks.Redis | 8.0.1 | +| Hellang.Middleware.ProblemDetails | 6.5.1 | +| Serilog.AspNetCore | 8.0.3 | +| Serilog.Sinks.Console | 6.0.0 | +| Serilog.Sinks.Seq | 8.0.0 | + +**Infrastructure Layer** (`MiningService.Infrastructure.csproj`): +| Package | Version | +|---------|---------| +| Microsoft.EntityFrameworkCore | 10.0.0 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.0 | +| MediatR | 12.4.1 | +| Dapper | 2.1.35 | +| Microsoft.Extensions.Http.Polly | 9.0.0 | +| Polly | 8.5.0 | +| StackExchange.Redis | 2.8.16 | + +**Domain Layer** (`MiningService.Domain.csproj`): +| Package | Version | +|---------|---------| +| MediatR.Contracts | 2.0.1 | + +### External Service Dependencies + +| Service | Default URL | Client Interface | Purpose | +|---------|-------------|-----------------|---------| +| IAM Service | `http://iam-service-net:8080` | `IIamServiceClient` | Get user info, validate user, KYC status | +| Wallet Service | `http://wallet-service-net:8080` | `IWalletServiceClient` | Transfer mined points to wallet, check balance | +| Social Service | `http://social-service-net:8080` | `ISocialServiceClient` | Friend suggestions for circles, friendship checks | + +All external clients use Polly resilience policies: +- **Retry**: 3 retries with exponential backoff (2s, 4s, 8s) +- **Circuit Breaker**: Opens after 5 failures, stays open for 30s + +--- + +## 9. Configuration + +### Environment Variables / appsettings.json + +| Key | Default | Description | +|-----|---------|-------------| +| `ConnectionStrings:DefaultConnection` | Neon PostgreSQL connection string | Primary database connection | +| `DATABASE_URL` | -- | Fallback database connection string | +| `Redis:ConnectionString` | `localhost:6379` | Redis connection (declared but not actively used in repositories) | +| `Jwt:Secret` | `your-super-secret-key-min-32-characters` | JWT signing key | +| `Jwt:Issuer` | `goodgo-platform` | JWT issuer | +| `Jwt:Audience` | `goodgo-services` | JWT audience | +| `Jwt:AccessTokenExpiryMinutes` | 15 | Token expiry (declared, not used internally) | +| `Jwt:RefreshTokenExpiryDays` | 7 | Refresh token expiry (declared, not used internally) | +| `ExternalServices:IamService:BaseUrl` | `http://iam-service-net:8080` | IAM service URL | +| `ExternalServices:WalletService:BaseUrl` | `http://wallet-service-net:8080` | Wallet service URL | +| `ExternalServices:SocialService:BaseUrl` | `http://social-service-net:8080` | Social service URL | +| `AllowedOrigins` | `["http://localhost:3000", "http://localhost:5173"]` | CORS allowed origins | +| `ASPNETCORE_ENVIRONMENT` | `Development` | Environment name | +| `ASPNETCORE_URLS` | `http://+:8080` (Docker) | Listen URLs | + +### MediatR Pipeline Order +1. `LoggingBehavior` -- logs request name, elapsed time +2. `ValidatorBehavior` -- runs FluentValidation (no validators currently defined) +3. `TransactionBehavior` -- wraps Commands in DB transaction (skips Queries by name suffix) + +### Docker +- Multi-stage build: `sdk:10.0` (build) -> `aspnet:10.0` (runtime) +- Non-root user: `dotnetuser` (UID/GID 1001) +- Port: 8080 +- Health check: `curl -f http://localhost:8080/health/live` (30s interval, 3 retries) + +### Database Startup +- Auto-applies EF Core migrations on startup (`dbContext.Database.MigrateAsync()`) +- Npgsql retry on failure: 5 retries, 30s max delay + +### Idempotency +- `ClientRequest` entity and `RequestManager` are defined but not wired into the command pipeline (no commands use idempotency checks). + +### Known Gaps / Stubs +- **No FluentValidation validators** are defined for any command +- **ConfigurationRepository** is purely in-memory (static fields with lock); database persistence for configs is not implemented +- **Analytics queries** for Points, Streaks, and AuditLogs have no handlers +- **BanMinerCommand** handler calls `Suspend()` instead of `Ban()` +- **ResetMinerStreakCommand** handler does not actually reset the streak +- **UpdateSystemConfigCommand** and **UpdateMiningConfigCommand** handlers do not persist changes +- **RabbitMQ integration** for publishing/consuming integration events is not connected +- **Redis** is declared as a dependency but not used in any repository or service +- **Dapper** is referenced but no raw SQL queries exist +- **Table names** use PascalCase instead of the platform-standard snake_case diff --git a/services/mission-service-net/SERVICE_DOCS.md b/services/mission-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..578e94ff --- /dev/null +++ b/services/mission-service-net/SERVICE_DOCS.md @@ -0,0 +1,516 @@ +# MissionService - Service Documentation + +## 1. Overview + +**Purpose**: Gamification microservice that manages missions (tasks users complete for rewards), daily check-in streaks with tiered bonus points, and reward tracking. Supports mission types including video watching, link clicking, content uploading, friend invitations, daily check-ins, and social actions. + +**Port**: 5000 (development, via launchSettings.json), 8080 (Docker/production) + +**Database**: PostgreSQL (`myservice_db` default in appsettings.json, configurable via `ConnectionStrings:DefaultConnection` or `DATABASE_URL`) + +**Framework**: .NET 10.0, C# 14, Clean Architecture + CQRS + +**Solution file**: `MissionService.slnx` + +**Migration**: `20260117134348_InitialCreate` — auto-applied on startup via `dbContext.Database.MigrateAsync()` + +--- + +## 2. API Endpoints + +### MissionsController — `api/v1/missions` (Authorize) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/missions` | JWT required | Get all available missions for the authenticated user. Returns `MissionsListResult`. | +| GET | `/api/v1/missions/category/{category}` | AllowAnonymous | Get missions filtered by category name (e.g., "Daily", "Weekly"). Returns `MissionsListResult`. | +| GET | `/api/v1/missions/{id:guid}` | AllowAnonymous | Get mission details by ID. Returns `MissionDetailsResult` or 404. Optionally includes user progress if authenticated. | +| POST | `/api/v1/missions/{id:guid}/start` | JWT required | Start a mission task for the user. Returns `StartTaskResult`. | +| PUT | `/api/v1/missions/tasks/{taskId:guid}/progress` | JWT required | Update task progress. Body: `UpdateProgressRequest(CurrentValue, Evidence?)`. Returns `UpdateProgressResult`. | +| POST | `/api/v1/missions/tasks/{taskId:guid}/claim` | JWT required | Claim reward for a completed task. Returns `ClaimRewardResult`. | + +### CheckInsController — `api/v1/checkins` (Authorize) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/api/v1/checkins` | JWT required | Perform daily check-in. Returns `CheckInResult` with streak info and points earned. | +| GET | `/api/v1/checkins/status` | JWT required | Get current check-in status (streak, total, can check-in today, next reward preview). | +| GET | `/api/v1/checkins/history?year=&month=` | JWT required | Get check-in history for a specific month. Defaults to current month. | +| GET | `/api/v1/checkins/leaderboard?count=10` | AllowAnonymous | Get check-in streak leaderboard (max 100 entries). | +| GET | `/api/v1/checkins/config` | AllowAnonymous | Get streak bonus tier configuration for client display. | + +### AdminController — `api/v1/admin` (Authorize Roles="Admin") + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/api/v1/admin/checkins/users/{userId:guid}` | Get a user's check-in profile details. | +| POST | `/api/v1/admin/checkins/users/{userId:guid}/reset-streak` | Reset a user's current streak to 0. | +| GET | `/api/v1/admin/checkins/top-streaks?count=20` | Get top streak users (max 100). | +| GET | `/api/v1/admin/tasks/pending-verification` | Get all tasks pending admin verification. | +| POST | `/api/v1/admin/tasks/{taskId:guid}/approve` | Approve a pending verification task. | +| POST | `/api/v1/admin/tasks/{taskId:guid}/reject` | Reject a pending verification task. Body: `RejectTaskRequest(Reason)`. | +| GET | `/api/v1/admin/tasks/users/{userId:guid}` | Get all tasks for a specific user. | + +### AdminMissionsController — `api/v1/admin/missions` (Authorize Roles="Admin") + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/api/v1/admin/missions` | Get all active missions (admin view with full details). | +| POST | `/api/v1/admin/missions` | Create a new mission. Body: `CreateMissionRequest`. Returns 201. | +| GET | `/api/v1/admin/missions/{id:guid}` | Get mission by ID (admin view). | +| POST | `/api/v1/admin/missions/{id:guid}/activate` | Activate a draft or paused mission. | +| POST | `/api/v1/admin/missions/{id:guid}/pause` | Pause an active mission. | +| POST | `/api/v1/admin/missions/{id:guid}/archive` | Archive a mission (any status). | + +### Health Endpoints + +| Route | Description | +|-------|-------------| +| `/health` | Full health check (includes PostgreSQL). | +| `/health/live` | Liveness probe (app is running). | +| `/health/ready` | Readiness probe (includes PostgreSQL). | + +--- + +## 3. Commands + +### PerformCheckInCommand +- **File**: `Application/Commands/CheckInCommands.cs` +- **Parameters**: `Guid UserId` +- **Result**: `CheckInResult(Success, CurrentStreak, DailyPoints, StreakBonus, MilestoneBonus, TotalPoints, IsMilestone, Message)` +- **Behavior**: Gets or creates user's check-in profile, validates not already checked in today, calculates streak continuation or reset, applies tiered bonus config, saves CheckInDay record. + +### StartMissionTaskCommand +- **File**: `Application/Commands/TaskCommands.cs` +- **Parameters**: `Guid UserId, Guid MissionId` +- **Result**: `StartTaskResult(Success, TaskId?, Message)` +- **Behavior**: Validates mission exists and is available, checks no existing active task for this user+mission, checks max completions not reached, creates new `UserTask`. + +### UpdateTaskProgressCommand +- **File**: `Application/Commands/TaskCommands.cs` +- **Parameters**: `Guid UserId, Guid TaskId, int CurrentValue, TaskEvidenceDto? Evidence` +- **Result**: `UpdateProgressResult(Success, CurrentValue, TargetValue, PercentComplete, IsComplete, Message)` +- **Behavior**: Validates task ownership and InProgress status, updates progress value, auto-submits evidence if provided and complete, auto-completes if no verification needed. + +### ClaimTaskRewardCommand +- **File**: `Application/Commands/TaskCommands.cs` +- **Parameters**: `Guid UserId, Guid TaskId` +- **Result**: `ClaimRewardResult(Success, PointsEarned?, Message)` +- **Behavior**: Validates task ownership, completed status, and not already claimed. Looks up mission reward points, marks task as claimed. + +--- + +## 4. Queries + +### GetAvailableMissionsQuery +- **Parameters**: `Guid UserId` +- **Returns**: `MissionsListResult` — list of active missions with optional user task progress per mission. + +### GetMissionDetailsQuery +- **Parameters**: `Guid MissionId, Guid? UserId` +- **Returns**: `MissionDetailsResult?` — full mission details (bilingual titles, reward, rules, frequency) with optional user progress. Returns null if not found. + +### GetMissionsByCategoryQuery +- **Parameters**: `string Category` (display name, e.g., "Daily") +- **Returns**: `MissionsListResult` — active missions filtered by category. No user progress included. + +### GetUserMissionProgressQuery +- **Parameters**: `Guid UserId` +- **Returns**: `UserMissionProgressResult` — aggregated stats (total, completed, in-progress counts, total points, active/completed mission lists). +- **Note**: Query record is defined but no handler implementation exists in the codebase. + +### GetCheckInStatusQuery +- **Parameters**: `Guid UserId` +- **Returns**: `CheckInStatusResult(CurrentStreak, LongestStreak, TotalCheckIns, LastCheckInDate, CanCheckInToday, NextReward)` — includes a preview of the next reward milestone. + +### GetCheckInHistoryQuery +- **Parameters**: `Guid UserId, int Year, int Month` +- **Returns**: `CheckInHistoryResult(Year, Month, Days[])` — list of `CheckInDayDto(Date, PointsEarned, IsMilestone, StreakOnDay)` for the requested month. + +### GetCheckInLeaderboardQuery +- **Parameters**: `int Count` (default 10) +- **Returns**: `CheckInLeaderboardResult(Entries[])` — ranked list by longest streak, then total check-ins. + +--- + +## 5. Domain Model + +### Aggregates + +#### Mission (Aggregate Root) +- **File**: `Domain/AggregatesModel/MissionAggregate/Mission.cs` +- **Properties**: Code (unique), TitleEn, TitleVi, DescriptionEn, DescriptionVi, Type (MissionType), Category (MissionCategory), Reward (MissionReward), Frequency (FrequencyType), MaxCompletions, StartDate, EndDate, Status (MissionStatus), Priority, Rules (collection of MissionRule) +- **Behavior methods**: `Activate()` (Draft/Paused -> Active), `Pause()` (Active -> Paused), `Archive()` (any -> Archived), `AddRule(MissionRule)`, `IsAvailable()`, `UpdateDetails(...)` +- **State machine**: Draft -> Active <-> Paused -> Archived; any -> Archived + +#### UserCheckIn (Aggregate Root) +- **File**: `Domain/AggregatesModel/CheckInAggregate/UserCheckIn.cs` +- **Properties**: UserId (unique), CurrentStreak, LongestStreak, TotalCheckIns, LastCheckInDate (DateOnly?), CheckInDays (collection of CheckInDay) +- **Behavior methods**: `CanCheckInToday()`, `CheckIn(StreakBonusConfig)` — calculates streak continuation/reset and returns points, `GetMonthlyCheckIns(year, month)`, `ResetStreak()` (admin) + +#### UserTask (Aggregate Root) +- **File**: `Domain/AggregatesModel/TaskAggregate/UserTask.cs` +- **Properties**: UserId, MissionId, Status (TaskStatus), Progress (TaskProgress), Evidence (TaskEvidence?), Verification (VerificationResult?), RewardClaimed, StartedAt, CompletedAt, ClaimedAt +- **Behavior methods**: `UpdateProgress(int)`, `SubmitEvidence(TaskEvidence)`, `Complete(VerificationResult)`, `AutoComplete()`, `ClaimReward()`, `Cancel()` +- **State machine**: InProgress -> PendingVerification -> Completed/Rejected; InProgress -> Completed (auto); any (unclaimed) -> Cancelled + +#### UserReward (Aggregate Root) +- **File**: `Domain/AggregatesModel/RewardAggregate/UserReward.cs` +- **Properties**: UserId, SourceId (TaskId or CheckInId), Type (RewardType enum), Status (RewardStatus enum), Amount (RewardAmount), EarnedAt, ClaimedAt, ExpiresAt +- **Factory methods**: `ForMission(...)`, `ForCheckIn(...)`, `ForMilestone(...)`, `ForReferral(...)` +- **Behavior methods**: `Claim()`, `Expire()`, `Cancel(string reason)` + +### Entities (non-root) + +#### MissionRule +- **Properties**: RuleType, Operator, Value, Metadata (jsonb) +- **Factory methods**: `MinDuration(seconds)`, `MinWatchPercent(percent)`, `SpecificUrl(url)` +- **Owned by**: Mission (cascade delete) + +#### CheckInDay +- **Properties**: Date (DateOnly), PointsEarned, IsMilestone, StreakOnDay +- **Owned by**: UserCheckIn (cascade delete) + +### Value Objects + +#### MissionReward +- **Properties**: Points (decimal), MiningBoostPercent (decimal), ExperiencePoints (int), BadgeId (string?) +- **Factory**: `Create(points, miningBoost, xp, badgeId)`, `PointsOnly(points)` + +#### TaskProgress +- **Properties**: CurrentValue (int), TargetValue (int), LastUpdated (DateTime) +- **Computed**: PercentComplete, IsComplete +- **Methods**: `WithValue(int)`, `Increment(int)` + +#### TaskEvidence +- **Properties**: Type (EvidenceType enum), Data, ScreenshotUrl, VideoUrl, CapturedAt +- **Factory methods**: `WatchDuration(seconds)`, `Click(url)`, `Upload(fileUrl, isImage)`, `SocialProof(screenshotUrl)`, `InviteCode(code)` + +#### VerificationResult +- **Properties**: IsValid, FailureReason, Method (VerificationMethod enum), VerifiedBy (Guid?), VerifiedAt +- **Factory methods**: `AutoApproved()`, `AiApproved()`, `ManualApproved(adminId)`, `Rejected(reason, method, adminId?)` + +#### RewardAmount +- **Properties**: Points (decimal), BonusPoints (decimal), Currency (string, default "MP") +- **Computed**: TotalPoints +- **Methods**: `WithBonus(additionalBonus)` + +#### StreakBonusConfig +- **Properties**: BasePoints (decimal), Tiers (list of StreakTier) +- **Default tiers**: Days 1-6: 2 MP/day; Day 7: 3 MP + 20 MP milestone; Days 8-13: 3 MP; Day 14: 4 MP + 35 MP milestone; Days 15-20: 4 MP; Day 21: 5 MP + 50 MP milestone; Days 22-29: 5 MP; Day 30+: 10 MP + 100 MP milestone +- **Method**: `CalculateReward(streakDay)` returns (DailyPoints, StreakBonus, MilestoneBonus, IsMilestone) + +### Enumerations (Type-safe enum pattern) + +#### MissionType +| Id | Name | Description | +|----|------|-------------| +| 1 | Video | Watch video to earn rewards | +| 2 | Click | Click on links/ads | +| 3 | Upload | Upload user-generated content | +| 4 | Invite | Invite friends | +| 5 | CheckIn | Daily check-in | +| 6 | Social | Social actions (like, share, subscribe) | + +#### MissionCategory +| Id | Name | +|----|------| +| 1 | Daily | +| 2 | Weekly | +| 3 | Special | +| 4 | Onboarding | +| 5 | Event | + +#### MissionStatus +| Id | Name | +|----|------| +| 1 | Draft | +| 2 | Active | +| 3 | Paused | +| 4 | Expired | +| 5 | Archived | + +#### FrequencyType +| Id | Name | Description | +|----|------|-------------| +| 1 | Once | Complete once total | +| 2 | Daily | Once per day | +| 3 | Weekly | Once per week | +| 4 | Unlimited | Unlimited times | + +#### TaskStatus +| Id | Name | +|----|------| +| 1 | Pending | +| 2 | InProgress | +| 3 | PendingVerification | +| 4 | Completed | +| 5 | Rejected | +| 6 | Cancelled | +| 7 | Expired | + +### Enums (C# enum) + +#### EvidenceType +`WatchDuration = 1, ClickData = 2, UploadedContent = 3, SocialProof = 4, InviteCode = 5` + +#### VerificationMethod +`Automatic = 1, AI = 2, Manual = 3` + +#### RewardType +`MissionComplete = 1, CheckInDaily = 2, CheckInMilestone = 3, ReferralBonus = 4, SocialAction = 5` + +#### RewardStatus +`Pending = 1, Claimed = 2, Expired = 3, Cancelled = 4` + +--- + +## 6. Database Schema + +Database: PostgreSQL. All column names use snake_case. Migration: `20260117134348_InitialCreate`. + +### Table: `missions` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, not generated | +| code | varchar(100) | NOT NULL, UNIQUE INDEX | +| title_en | varchar(200) | NOT NULL | +| title_vi | varchar(200) | NOT NULL | +| description_en | varchar(2000) | nullable | +| description_vi | varchar(2000) | nullable | +| type_id | integer | NOT NULL (FK concept to mission_types) | +| category_id | integer | NOT NULL (FK concept to mission_categories) | +| reward_points | numeric(18,2) | NOT NULL (owned: MissionReward) | +| reward_mining_boost | numeric(5,2) | NOT NULL (owned: MissionReward) | +| reward_xp | integer | NOT NULL (owned: MissionReward) | +| reward_badge_id | varchar(50) | nullable (owned: MissionReward) | +| frequency_id | integer | NOT NULL (FK concept to frequency_types) | +| max_completions | integer | NOT NULL | +| start_date | timestamp with time zone | NOT NULL | +| end_date | timestamp with time zone | nullable | +| status_id | integer | NOT NULL (FK concept to mission_statuses) | +| priority | integer | NOT NULL | + +**Indexes**: `IX_missions_code` (unique on code) + +### Table: `mission_rules` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, not generated | +| mission_id | uuid | NOT NULL, FK -> missions(id) CASCADE | +| rule_type | varchar(50) | NOT NULL | +| operator | varchar(20) | NOT NULL | +| value | varchar(500) | NOT NULL | +| metadata | jsonb | nullable | + +**Indexes**: `IX_mission_rules_mission_id` + +### Table: `user_tasks` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, not generated | +| user_id | uuid | NOT NULL | +| mission_id | uuid | NOT NULL | +| status_id | integer | NOT NULL | +| progress_current | integer | NOT NULL (owned: TaskProgress) | +| progress_target | integer | NOT NULL (owned: TaskProgress) | +| progress_updated_at | timestamp with time zone | NOT NULL (owned: TaskProgress) | +| evidence_type | integer | nullable (owned: TaskEvidence) | +| evidence_data | varchar(4000) | nullable (owned: TaskEvidence) | +| evidence_screenshot_url | varchar(500) | nullable (owned: TaskEvidence) | +| evidence_video_url | varchar(500) | nullable (owned: TaskEvidence) | +| evidence_captured_at | timestamp with time zone | nullable (owned: TaskEvidence) | +| verification_valid | boolean | nullable (owned: VerificationResult) | +| verification_failure_reason | varchar(500) | nullable (owned: VerificationResult) | +| verification_method | integer | nullable (owned: VerificationResult) | +| verification_by | uuid | nullable (owned: VerificationResult) | +| verification_at | timestamp with time zone | nullable (owned: VerificationResult) | +| reward_claimed | boolean | NOT NULL | +| started_at | timestamp with time zone | NOT NULL | +| completed_at | timestamp with time zone | nullable | +| claimed_at | timestamp with time zone | nullable | + +**Indexes**: `IX_user_tasks_user_id`, `IX_user_tasks_mission_id`, `IX_user_tasks_user_id_mission_id` (composite) + +### Table: `user_checkins` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, not generated | +| user_id | uuid | NOT NULL, UNIQUE INDEX | +| current_streak | integer | NOT NULL | +| longest_streak | integer | NOT NULL | +| total_checkins | integer | NOT NULL | +| last_checkin_date | date | nullable | + +**Indexes**: `IX_user_checkins_user_id` (unique) + +### Table: `checkin_days` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, not generated | +| user_checkin_id | uuid | NOT NULL, FK -> user_checkins(id) CASCADE | +| date | date | NOT NULL | +| points_earned | numeric(18,2) | NOT NULL | +| is_milestone | boolean | NOT NULL | +| streak_on_day | integer | NOT NULL | + +**Indexes**: `IX_checkin_days_user_checkin_id_date` (unique composite) + +### Table: `user_rewards` +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK, not generated | +| user_id | uuid | NOT NULL | +| source_id | uuid | NOT NULL, UNIQUE INDEX | +| type | integer | NOT NULL (RewardType enum) | +| status | integer | NOT NULL (RewardStatus enum) | +| points | numeric(18,2) | NOT NULL (owned: RewardAmount) | +| bonus_points | numeric(18,2) | NOT NULL (owned: RewardAmount) | +| currency | varchar(10) | NOT NULL, default "MP" (owned: RewardAmount) | +| earned_at | timestamp with time zone | NOT NULL | +| claimed_at | timestamp with time zone | nullable | +| expires_at | timestamp with time zone | nullable | + +**Indexes**: `IX_user_rewards_user_id`, `IX_user_rewards_source_id` (unique), `IX_user_rewards_status_expires_at` (composite) + +### Lookup Tables (seeded) + +| Table | Columns | Seed Data | +|-------|---------|-----------| +| `mission_types` | id (int PK), name (varchar 50) | 1=Video, 2=Click, 3=Upload, 4=Invite, 5=CheckIn, 6=Social | +| `mission_categories` | id (int PK), name (varchar 50) | 1=Daily, 2=Weekly, 3=Special, 4=Onboarding, 5=Event | +| `mission_statuses` | id (int PK), name (varchar 50) | 1=Draft, 2=Active, 3=Paused, 4=Expired, 5=Archived | +| `frequency_types` | id (int PK), name (varchar 50) | 1=Once, 2=Daily, 3=Weekly, 4=Unlimited | +| `task_statuses` | id (int PK), name (varchar 50) | 1=Pending, 2=InProgress, 3=PendingVerification, 4=Completed, 5=Rejected, 6=Cancelled, 7=Expired | + +--- + +## 7. Integration Events + +**No integration events are currently implemented.** The domain supports domain events via the `Entity.AddDomainEvent()` / `MissionDbContext.DispatchDomainEventsAsync()` infrastructure, but no domain event records or handlers exist in the codebase. No RabbitMQ integration event publishers or consumers are present. + +The `Domain/Events/` directory referenced in the template pattern does not exist in this service. + +--- + +## 8. Dependencies + +### NuGet Packages + +**API Layer** (`MissionService.API.csproj`): +| Package | Version | +|---------|---------| +| MediatR | 12.4.1 | +| FluentValidation | 11.11.0 | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | +| Microsoft.EntityFrameworkCore.Design | 10.0.2 | +| Swashbuckle.AspNetCore | 7.2.0 | +| Asp.Versioning.Mvc | 8.1.0 | +| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | +| AspNetCore.HealthChecks.Redis | 8.0.1 | +| Hellang.Middleware.ProblemDetails | 6.5.1 | +| Serilog.AspNetCore | 8.0.3 | +| Serilog.Sinks.Console | 6.0.0 | +| Serilog.Sinks.Seq | 8.0.0 | +| Microsoft.AspNetCore.Authentication.JwtBearer | 9.0.0 | + +**Domain Layer** (`MissionService.Domain.csproj`): +| Package | Version | +|---------|---------| +| MediatR.Contracts | 2.0.1 | + +**Infrastructure Layer** (`MissionService.Infrastructure.csproj`): +| Package | Version | +|---------|---------| +| Microsoft.EntityFrameworkCore | 10.0.0 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.0 | +| MediatR | 12.4.1 | +| Dapper | 2.1.35 | +| Microsoft.Extensions.Http.Polly | 9.0.0 | +| Polly | 8.5.0 | +| StackExchange.Redis | 2.8.16 | + +**Shared** (`Directory.Build.props`): +| Package | Version | +|---------|---------| +| Microsoft.SourceLink.GitHub | 8.0.0 | + +### External Service Dependencies +- **PostgreSQL**: Primary database (connection via Npgsql) +- **IAM Service**: JWT token validation (Authority: `http://iam-service-net:8080` default) +- **Redis**: Package referenced but not actively used in application code (health check configured) + +--- + +## 9. Configuration + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `ASPNETCORE_ENVIRONMENT` | `Production` (Docker) / `Development` (launch) | Runtime environment | +| `ASPNETCORE_URLS` | `http://+:8080` (Docker) / `http://localhost:5000` (launch) | Listening URLs | +| `DATABASE_URL` | — | Fallback connection string if `ConnectionStrings:DefaultConnection` not set | + +### appsettings.json Keys + +| Key | Default Value | Description | +|-----|---------------|-------------| +| `ConnectionStrings:DefaultConnection` | `Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres` | PostgreSQL connection string | +| `Redis:ConnectionString` | `localhost:6379` | Redis connection string | +| `Jwt:Secret` | `your-super-secret-key-min-32-characters` | JWT signing key | +| `Jwt:Issuer` | `goodgo-platform` | JWT issuer | +| `Jwt:Audience` | `goodgo-services` | JWT audience | +| `Jwt:AccessTokenExpiryMinutes` | `15` | Access token lifetime | +| `Jwt:RefreshTokenExpiryDays` | `7` | Refresh token lifetime | +| `Jwt:Authority` | `http://iam-service-net:8080` | OIDC authority for token validation | + +### MediatR Pipeline (execution order) + +1. `LoggingBehavior` — Logs request name and elapsed time (Stopwatch) +2. `ValidatorBehavior` — Runs FluentValidation validators, throws `ValidationException` on failure +3. `TransactionBehavior` — Wraps commands in a database transaction (skips queries ending in "Query"), uses `ExecutionStrategy` for retry + +### Docker + +- Multi-stage build: `sdk:10.0` (build) -> `aspnet:10.0` (runtime) +- Non-root user: `dotnetuser` (UID/GID 1001) +- Health check: `curl -f http://localhost:8080/health/live` (30s interval, 3 retries, 10s start period) +- Exposed port: 8080 + +### Build Properties + +- Target: `net10.0`, C# `14.0` +- Nullable reference types: enabled +- Implicit usings: enabled +- Treat warnings as errors: true +- XML documentation generation: enabled + +--- + +## 10. Tests + +### Unit Tests (`tests/MissionService.UnitTests/`) +- `Application/Commands/PerformCheckInCommandHandlerTests.cs` — Tests for the check-in command handler +- `Domain/MissionAggregateTests.cs` — Tests for Mission entity behavior +- `Domain/UserCheckInAggregateTests.cs` — Tests for UserCheckIn streak logic + +### Functional Tests (`tests/MissionService.FunctionalTests/`) +- `CustomWebApplicationFactory.cs` — Test server factory (swaps DbContext) +- `Controllers/MissionsControllerTests.cs` — API endpoint integration tests + +--- + +## 11. Notable Gaps / Observations + +1. **GetUserMissionProgressQuery**: Query record is defined but has no handler implementation. +2. **No FluentValidation validators exist**: The `ValidatorBehavior` is registered but no `AbstractValidator` classes are present in the codebase. +3. **No domain events defined**: The infrastructure for dispatching domain events exists in `MissionDbContext`, but no domain event records (`INotification`) are defined or raised by any entity. +4. **No integration events**: No RabbitMQ publishers/consumers. Reward claiming does not notify external services (e.g., wallet service). +5. **UserReward aggregate**: Has a full repository and EF configuration but is not used by any command handler — rewards are tracked via `UserTask.RewardClaimed` flag only. +6. **Idempotency**: `IRequestManager`/`RequestManager` are registered but not used by any handler or behavior. +7. **Redis**: Package referenced and health check configured, but no caching logic exists in the codebase. +8. **Database name**: Default in appsettings is `myservice_db` (template default, not renamed to `mission_service`). +9. **JWT audience validation**: Disabled (`ValidateAudience = false`, `ValidateIssuer = false`) — relies on IAM service for token signing. diff --git a/services/mkt-facebook-service-net/SERVICE_DOCS.md b/services/mkt-facebook-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..68daf206 --- /dev/null +++ b/services/mkt-facebook-service-net/SERVICE_DOCS.md @@ -0,0 +1,463 @@ +# FacebookService (mkt-facebook-service-net) - Service Documentation + +## 1. Overview + +**Purpose**: Facebook Messenger integration microservice for the GoodGo platform. Handles incoming webhook events from Facebook Messenger, manages conversations with customers, supports AI-powered chatbot flows, and provides APIs for customer and conversation management. + +**Key Capabilities**: +- Facebook Messenger webhook verification and event processing +- Customer management (auto-create from Facebook user IDs, tagging, custom fields) +- Conversation lifecycle management (create, assign, close, archive) +- Chatbot flow automation (trigger-based flows with node graphs) +- AI chatbot configuration (OpenAI / Azure OpenAI integration) +- Outbound messaging via Facebook Send API (text, quick replies, templates) + +**Port**: `5000` (Development, from launchSettings.json), `8080` (Docker/Production) + +**Database**: PostgreSQL (`myservice_db` default from appsettings; actual DB name configured via `ConnectionStrings:DefaultConnection` or `DATABASE_URL` env var) + +**Framework**: .NET 10.0, C# 14, Clean Architecture + CQRS (MediatR) + +--- + +## 2. API Endpoints + +### WebhooksController + +Route base: `api/webhooks/facebook` (no API versioning) + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/webhooks/facebook?hub.mode=subscribe&hub.verify_token={token}&hub.challenge={challenge}` | Facebook webhook verification. Returns the `challenge` value if the verify token matches configuration. Returns 403 if token mismatch. | +| `POST` | `/api/webhooks/facebook` | Receive webhook events from Facebook. Validates `X-Hub-Signature-256` header if `Facebook:AppSecret` is configured. Processes `messaging` events (messages and postbacks) for `page` object type. Always returns `200 "EVENT_RECEIVED"`. | + +### ChatbotsController + +Route base: `api/v{version}/chatbots` (versioned, default v1.0) + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/chatbots/flows?shopId={guid}&activeOnly={bool}` | Get chatbot flows for a shop. Optional `activeOnly` filter (default false). Returns `ChatbotFlowSummaryDto[]`. | +| `GET` | `/api/v1/chatbots/flows/{id}` | Get a single chatbot flow by ID including all nodes. Returns `ChatbotFlowDto` or 404. | +| `POST` | `/api/v1/chatbots/flows` | Create a new chatbot flow. Body: `CreateFlowRequest`. Returns 201 with `CreateChatbotFlowCommandResult` or 400. | +| `GET` | `/api/v1/chatbots/ai-config?shopId={guid}` | Get AI chatbot configuration for a shop. Returns `AIChatbotConfigDto` or 404. | +| `PUT` | `/api/v1/chatbots/ai-config` | Create or update AI chatbot configuration. Body: `UpsertAIConfigRequest`. Returns 200 with result or 400. | + +### ConversationsController + +Route base: `api/v{version}/conversations` (versioned, default v1.0) + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/conversations/{id}?includeMessages={bool}` | Get conversation by ID. Optional `includeMessages` (default true). Returns `ConversationDto` or 404. | +| `GET` | `/api/v1/conversations?shopId={guid}&status={string}&skip={int}&take={int}` | Get conversations with pagination. All query params optional. Returns `ConversationSummaryDto[]` with `total` count. | + +### CustomersController + +Route base: `api/v{version}/customers` (versioned, default v1.0) + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/api/v1/customers/{id}` | Get customer by internal GUID ID. Returns `CustomerDto` or 404. | +| `GET` | `/api/v1/customers/facebook/{facebookUserId}` | Get customer by Facebook User ID string. Returns `CustomerDto` or 404. | +| `POST` | `/api/v1/customers` | Create a new customer (or update if Facebook ID already exists). Body: `CreateCustomerRequest`. Returns 201 with `CreateCustomerCommandResult`. | +| `PATCH` | `/api/v1/customers/{id}` | Update customer tags and custom fields. Body: `UpdateCustomerRequest`. Returns 200 or 404. | + +### Health Endpoints + +| Method | Route | Description | +|--------|-------|-------------| +| `GET` | `/health` | Full health check (includes PostgreSQL). | +| `GET` | `/health/live` | Liveness probe (app is running, no dependency checks). | +| `GET` | `/health/ready` | Readiness probe (includes PostgreSQL check). | + +--- + +## 3. Commands + +### ProcessIncomingMessageCommand +- **Parameters**: `PageId` (string), `SenderId` (string), `MessageText` (string), `FacebookMessageId` (string?), `Timestamp` (long?), `QuickReplyPayload` (string?) +- **Result**: `ProcessIncomingMessageResult` (ConversationId, CustomerId, MessageId, IsNewConversation, IsNewCustomer) +- **Logic**: Gets or creates a Customer by Facebook sender ID. Gets or creates an active Conversation for the customer+page pair. Adds an incoming Message entity to the conversation. Saves all via UnitOfWork. +- **Validator**: `ProcessIncomingMessageCommandValidator` - PageId required (max 50), SenderId required (max 50), MessageText required. + +### CreateCustomerCommand +- **Parameters**: `FacebookUserId` (string), `Name` (string?), `Email` (string?), `Phone` (string?), `ProfilePicUrl` (string?), `Locale` (string?), `Timezone` (string?) +- **Result**: `CreateCustomerCommandResult` (Id, IsNew) +- **Logic**: If customer with given FacebookUserId exists, updates their profile and returns `IsNew: false`. Otherwise creates a new Customer entity and returns `IsNew: true`. +- **Validator**: `CreateCustomerCommandValidator` - FacebookUserId required (max 50), Name max 255, Email valid format + max 255, Phone max 50. + +### UpdateCustomerCommand +- **Parameters**: `CustomerId` (Guid), `Tags` (IEnumerable?), `CustomFields` (Dictionary?) +- **Result**: `UpdateCustomerCommandResult` (Success, CustomerId) +- **Logic**: Finds customer by ID. If Tags provided, clears existing tags and adds new ones. If CustomFields provided, sets each key-value pair. Returns `Success: false` if customer not found. +- **Validator**: None. + +### CreateChatbotFlowCommand +- **Parameters**: `ShopId` (Guid), `Name` (string), `TriggerType` (string), `TriggerValue` (string) +- **Result**: `CreateChatbotFlowCommandResult` (Id, Success) +- **Logic**: Checks if trigger value already exists for the shop (unique constraint). Parses trigger type string. Creates ChatbotFlow entity. Returns `Success: false` if duplicate trigger or invalid type. +- **Validator**: `CreateChatbotFlowCommandValidator` - ShopId required, Name required (max 255), TriggerType required (must be GetStarted/Keyword/Postback/Fallback), TriggerValue required (max 255). + +### UpsertAIChatbotConfigCommand +- **Parameters**: `ShopId` (Guid), `Provider` (string), `Model` (string), `SystemPrompt` (string), `Temperature` (float, default 0.7), `MaxTokens` (int, default 500) +- **Result**: `UpsertAIChatbotConfigResult` (Id, IsNew, Success) +- **Logic**: Parses provider string. If config exists for shop, updates it. Otherwise creates new config. Returns `Success: false` on invalid provider or exception. +- **Validator**: `UpsertAIChatbotConfigCommandValidator` - ShopId required, Provider required (OpenAI/AzureOpenAI/Azure), Model required (max 50), SystemPrompt required, Temperature 0.0-2.0, MaxTokens 1-4096. + +--- + +## 4. Queries + +### GetChatbotFlowByIdQuery +- **Parameters**: `FlowId` (Guid) +- **Returns**: `ChatbotFlowDto?` (Id, ShopId, Name, TriggerType, TriggerValue, IsActive, CreatedAt, UpdatedAt, Nodes[]) +- **Logic**: Loads flow with nodes via `GetByIdWithNodesAsync`. Maps to DTO with ordered nodes. + +### GetChatbotFlowsQuery +- **Parameters**: `ShopId` (Guid), `ActiveOnly` (bool, default false) +- **Returns**: `IReadOnlyList` (Id, Name, TriggerType, TriggerValue, IsActive, CreatedAt) +- **Logic**: Gets all flows for shop, optionally filters to active only. + +### GetAIChatbotConfigQuery +- **Parameters**: `ShopId` (Guid) +- **Returns**: `AIChatbotConfigDto?` (Id, ShopId, Provider, Model, SystemPrompt, Temperature, MaxTokens, IsEnabled, CreatedAt, UpdatedAt) + +### GetConversationByIdQuery +- **Parameters**: `ConversationId` (Guid), `IncludeMessages` (bool, default true) +- **Returns**: `ConversationDto?` (Id, CustomerId, Customer, PageId, Status, Channel, AssignedAgentId, LastMessageAt, CreatedAt, Messages[]) +- **Logic**: Loads conversation with or without messages based on flag. Customer field is always null (not loaded in this handler). + +### GetConversationsQuery +- **Parameters**: `ShopId` (Guid?), `Status` (string?), `Skip` (int, default 0), `Take` (int, default 20) +- **Returns**: `GetConversationsQueryResult` (Conversations: `ConversationSummaryDto[]`, TotalCount: int) +- **Note**: Handler not found in code -- query and result types are defined but the handler implementation for GetConversationsQuery was not located in the codebase. + +### GetCustomerByIdQuery +- **Parameters**: `CustomerId` (Guid) +- **Returns**: `CustomerDto?` (Id, FacebookUserId, Name, Email, Phone, ProfilePicUrl, Locale, Timezone, Tags, CustomFields, FirstSeenAt, LastInteractionAt) + +### GetCustomerByFacebookIdQuery +- **Parameters**: `FacebookUserId` (string) +- **Returns**: `CustomerDto?` + +### GetCustomersQuery +- **Parameters**: `ShopId` (Guid?), `Tags` (IEnumerable?), `Search` (string?), `Skip` (int), `Take` (int) +- **Returns**: `GetCustomersQueryResult` (Customers: `CustomerSummaryDto[]`, TotalCount: int) +- **Note**: Query and result types are defined but no handler implementation was found in the codebase. + +--- + +## 5. Domain Model + +### Aggregates + +#### CustomerAggregate +- **Customer** (Entity, IAggregateRoot) + - Private fields: `_facebookUserId`, `_name`, `_email`, `_phone`, `_profilePicUrl`, `_locale`, `_timezone`, `_tags` (List), `_customFields` (Dictionary), `_firstSeenAt`, `_lastInteractionAt` + - Behavior: `UpdateProfile()`, `AddTag()`, `RemoveTag()`, `SetCustomField()` (max 50 fields), `RemoveCustomField()`, `RecordInteraction()` + - Domain event: `CustomerCreatedDomainEvent` (on construction) + +#### ConversationAggregate +- **Conversation** (Entity, IAggregateRoot) + - Private fields: `_customerId`, `_pageId`, `_status`, `_channel` (default "FacebookMessenger"), `_assignedAgentId`, `_lastMessageAt`, `_createdAt`, `_messages` (List) + - Behavior: `AddMessage()` (only if Active), `MarkAsRead()`, `Assign()`, `Unassign()`, `Close()`, `Reopen()`, `Archive()` + - Domain events: `ConversationCreatedDomainEvent` (on construction), `MessageAddedDomainEvent` (on AddMessage), `ConversationAssignedDomainEvent` (on Assign), `ConversationStatusChangedDomainEvent` (on Close/Reopen/Archive) + +- **Message** (Entity, child of Conversation) + - Private fields: `_senderId`, `_content`, `_messageType`, `_direction`, `_metadata` (Dictionary), `_sentAt`, `_facebookMessageId` + - Behavior: `AddMetadata()` + +#### ChatbotAggregate +- **ChatbotFlow** (Entity, IAggregateRoot) + - Private fields: `_name`, `_shopId`, `_triggerType`, `_triggerValue`, `_isActive`, `_createdAt`, `_updatedAt`, `_nodes` (List) + - Behavior: `AddNode()`, `RemoveNode()`, `ConnectNodes()`, `Update()`, `Activate()` (validates flow structure: requires 1 Start node, >= 1 End node, no unreachable nodes), `Deactivate()`, `GetStartNode()` + - Domain event: `ChatbotFlowCreatedDomainEvent` (on construction) + +- **FlowNode** (Entity, child of ChatbotFlow) + - Private fields: `_nodeType`, `_content`, `_config` (Dictionary), `_nextNodeIds` (List), `_orderIndex` + - Behavior: `ConnectTo()`, `DisconnectFrom()`, `UpdateContent()`, `UpdateConfig()`, `SetConfigValue()` + +- **AIChatbotConfig** (Entity, IAggregateRoot) + - Private fields: `_shopId`, `_provider`, `_model`, `_systemPrompt`, `_temperature` (0.0-2.0), `_maxTokens` (1-4096), `_isEnabled`, `_createdAt`, `_updatedAt` + - Behavior: `UpdateConfig()`, `UpdateSystemPrompt()`, `Enable()`, `Disable()` + +### Enumerations (Type-safe enum pattern via `Enumeration` base class) + +| Enumeration | Values | +|-------------|--------| +| `FlowTriggerType` | GetStarted (1), Keyword (2), Postback (3), Fallback (4) | +| `NodeType` | Start (1), Message (2), Question (3), Condition (4), Action (5), End (6) | +| `AIProvider` | OpenAI (1), AzureOpenAI (2) | +| `ConversationStatus` | Active (1), Closed (2), Archived (3) | +| `MessageType` | Text (1), Image (2), Video (3), Audio (4), File (5), Template (6), QuickReply (7) | +| `MessageDirection` | Incoming (1), Outgoing (2) | + +### Domain Events + +| Event | Payload | Raised When | +|-------|---------|-------------| +| `CustomerCreatedDomainEvent` | Customer entity | New customer created | +| `ConversationCreatedDomainEvent` | Conversation entity | New conversation created | +| `MessageAddedDomainEvent` | ConversationId (Guid), Message entity | Message added to conversation | +| `ConversationAssignedDomainEvent` | ConversationId (Guid), AgentId (string) | Conversation assigned to agent | +| `ConversationStatusChangedDomainEvent` | ConversationId (Guid), PreviousStatus, NewStatus | Conversation status changes (close/reopen/archive) | +| `ChatbotFlowCreatedDomainEvent` | ChatbotFlow entity | New chatbot flow created | + +### Exceptions + +- `DomainException` - Base domain exception +- `FacebookDomainException` - Service-specific domain exception (extends DomainException) + +--- + +## 6. Database Schema + +All tables use snake_case column naming via FluentAPI private field mapping. Database: PostgreSQL. + +### Table: `customers` + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK, required | +| `facebook_user_id` | varchar(50) | required | +| `name` | varchar(255) | nullable | +| `email` | varchar(255) | nullable | +| `phone` | varchar(50) | nullable | +| `profile_pic_url` | text | nullable | +| `locale` | varchar(10) | nullable | +| `timezone` | varchar(50) | nullable | +| `tags` | text[] | PostgreSQL array | +| `custom_fields` | jsonb | JSON object | +| `first_seen_at` | timestamp | required | +| `last_interaction_at` | timestamp | required | + +**Indexes**: +- `idx_customers_facebook_user_id` - UNIQUE on `facebook_user_id` +- `idx_customers_last_interaction` - on `last_interaction_at` + +### Table: `conversations` + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK, required | +| `customer_id` | uuid | required | +| `page_id` | varchar(50) | required | +| `status_id` | int | required, FK -> conversation_statuses | +| `channel` | varchar(50) | required | +| `assigned_agent_id` | varchar(100) | nullable | +| `last_message_at` | timestamp | nullable | +| `created_at` | timestamp | required | + +**Relationships**: Has many `messages` (cascade delete). Has one `conversation_statuses` (restrict delete). + +**Indexes**: +- `idx_conversations_customer_id` - on `customer_id` +- `idx_conversations_page_status` - on (`page_id`, `status_id`) +- `idx_conversations_created_at` - on `created_at` + +### Table: `messages` + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK, required | +| `ConversationId` | uuid | FK (shadow property) | +| `sender_id` | varchar(50) | required | +| `content` | text | required | +| `message_type_id` | int | required, FK -> message_types | +| `direction_id` | int | required, FK -> message_directions | +| `metadata` | jsonb | nullable | +| `sent_at` | timestamp | required | +| `facebook_message_id` | varchar(100) | nullable | + +**Indexes**: +- `idx_messages_conversation_sent` - on (`ConversationId`, `sent_at`) +- `idx_messages_fb_message_id` - on `facebook_message_id` + +### Table: `chatbot_flows` + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK, required | +| `name` | varchar(255) | required | +| `shop_id` | uuid | required | +| `trigger_type_id` | int | required, FK -> flow_trigger_types | +| `trigger_value` | varchar(255) | required | +| `is_active` | boolean | required | +| `created_at` | timestamp | required | +| `updated_at` | timestamp | nullable | + +**Relationships**: Has many `flow_nodes` (cascade delete). Has one `flow_trigger_types` (restrict delete). + +**Indexes**: +- `idx_chatbot_flows_shop_trigger` - UNIQUE on (`shop_id`, `trigger_value`) +- `idx_chatbot_flows_shop_active` - on (`shop_id`, `is_active`) + +### Table: `flow_nodes` + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK, required | +| `FlowId` | uuid | FK (shadow property) | +| `node_type_id` | int | required, FK -> node_types | +| `content` | text | nullable | +| `config` | jsonb | nullable | +| `next_node_ids` | uuid[] | PostgreSQL array | +| `order_index` | int | required | + +**Indexes**: +- `idx_flow_nodes_flow_order` - on (`FlowId`, `order_index`) + +### Table: `ai_chatbot_configs` + +| Column | Type | Constraints | +|--------|------|-------------| +| `id` | uuid | PK, required | +| `shop_id` | uuid | required | +| `provider_id` | int | required, FK -> ai_providers | +| `model` | varchar(50) | required | +| `system_prompt` | text | required | +| `temperature` | float | required | +| `max_tokens` | int | required | +| `is_enabled` | boolean | required | +| `created_at` | timestamp | required | +| `updated_at` | timestamp | required | + +**Indexes**: +- `idx_ai_configs_shop_id` - UNIQUE on `shop_id` (one config per shop) + +### Enumeration Lookup Tables (seeded data) + +| Table | Seeded Values | +|-------|---------------| +| `conversation_statuses` | Active (1), Closed (2), Archived (3) | +| `message_types` | Text (1), Image (2), Video (3), Audio (4), File (5), Template (6), QuickReply (7) | +| `message_directions` | Incoming (1), Outgoing (2) | +| `flow_trigger_types` | GetStarted (1), Keyword (2), Postback (3), Fallback (4) | +| `node_types` | Start (1), Message (2), Question (3), Condition (4), Action (5), End (6) | +| `ai_providers` | OpenAI (1), AzureOpenAI (2) | + +--- + +## 7. Integration Events + +**No cross-service integration events are published or consumed.** The service uses domain events internally (dispatched via MediatR `INotification` before `SaveChangesAsync`), but there are no RabbitMQ publishers or consumers implemented. + +Domain events are dispatched in-process only via `FacebookServiceContext.DispatchDomainEventsAsync()`. No domain event handlers (INotificationHandler) are implemented in the codebase. + +--- + +## 8. Dependencies + +### NuGet Packages + +**API Layer** (`FacebookService.API.csproj`): +| Package | Version | +|---------|---------| +| MediatR | 12.4.1 | +| FluentValidation | 11.11.0 | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | +| Swashbuckle.AspNetCore | 7.2.0 | +| Asp.Versioning.Mvc | 8.1.0 | +| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | +| AspNetCore.HealthChecks.Redis | 8.0.1 | +| Hellang.Middleware.ProblemDetails | 6.5.1 | +| Serilog.AspNetCore | 8.0.3 | +| Serilog.Sinks.Console | 6.0.0 | +| Serilog.Sinks.Seq | 8.0.0 | + +**Domain Layer** (`FacebookService.Domain.csproj`): +| Package | Version | +|---------|---------| +| MediatR.Contracts | 2.0.1 | + +**Infrastructure Layer** (`FacebookService.Infrastructure.csproj`): +| Package | Version | +|---------|---------| +| Microsoft.EntityFrameworkCore | 10.0.0 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.0 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.0 | +| MediatR | 12.4.1 | +| Dapper | 2.1.35 | +| Microsoft.Extensions.Http.Polly | 9.0.0 | +| Polly | 8.5.0 | +| StackExchange.Redis | 2.8.16 | + +**Build Props** (`Directory.Build.props`): +| Package | Version | +|---------|---------| +| Microsoft.SourceLink.GitHub | 8.0.0 | + +### External Service Dependencies + +| Service | Client Interface | Implementation | Purpose | +|---------|-----------------|----------------|---------| +| Facebook Graph API | `IFacebookMessengerClient` | `FacebookMessengerClient` | Send messages, get user profiles, mark as seen, typing indicators | +| OpenAI API | `IAIClient` | `OpenAIClient` | AI chat completions for chatbot | +| Azure OpenAI API | `IAIClient` | `AzureOpenAIClient` | AI chat completions (Azure variant) | + +--- + +## 9. Configuration + +### Connection Strings + +| Key | Description | Default | +|-----|-------------|---------| +| `ConnectionStrings:DefaultConnection` | PostgreSQL connection string | `Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres` | +| `DATABASE_URL` | Fallback PostgreSQL connection string (env var) | - | + +### Facebook Configuration + +| Key | Env Var Fallback | Description | +|-----|-----------------|-------------| +| `Facebook:VerifyToken` | `FACEBOOK_VERIFY_TOKEN` | Webhook verification token | +| `Facebook:AppSecret` | `FACEBOOK_APP_SECRET` | App secret for webhook signature verification (HMAC-SHA256) | +| `Facebook:PageAccessToken` | `FACEBOOK_PAGE_ACCESS_TOKEN` | Page access token for Send API | +| `Facebook:ApiVersion` | `FACEBOOK_API_VERSION` | Graph API version (default: `v18.0`) | + +### AI Configuration + +| Key | Env Var Fallback | Description | +|-----|-----------------|-------------| +| `AI:Provider` | `AI_PROVIDER` | AI provider selection: `openai` (default) or `azureopenai`/`azure` | +| `OpenAI:ApiKey` | `OPENAI_API_KEY` | OpenAI API key (required if provider is openai) | +| `AzureOpenAI:Endpoint` | `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL (required if provider is azure) | +| `AzureOpenAI:ApiKey` | `AZURE_OPENAI_KEY` | Azure OpenAI API key (required if provider is azure) | +| `AzureOpenAI:DeploymentName` | `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure deployment name (default: `gpt-4`) | +| `AzureOpenAI:ApiVersion` | - | Azure API version (default: `2024-02-01`) | + +### Other Configuration + +| Key | Env Var | Description | +|-----|---------|-------------| +| `Redis:ConnectionString` | `REDIS_URL` | Redis connection (referenced in appsettings but not used in DI registration) | +| `Jwt:Secret` | `JWT_SECRET` | JWT secret (configured but no auth middleware is active) | +| `Jwt:Issuer` | `JWT_ISSUER` | JWT issuer | +| `Jwt:Audience` | `JWT_AUDIENCE` | JWT audience | + +### MediatR Pipeline + +The pipeline executes in order: +1. `LoggingBehavior` - Logs request name and elapsed time (with Stopwatch) +2. `ValidatorBehavior` - Runs all FluentValidation validators, throws `ValidationException` on failure +3. `TransactionBehavior` - Wraps Commands in database transactions (skips Queries by name suffix check), uses `ExecutionStrategy` for retry + +### Docker + +- Base images: `sdk:10.0` (build), `aspnet:10.0` (runtime) +- Non-root user: `dotnetuser` (UID/GID 1001) +- Port: 8080 +- Healthcheck: `curl -f http://localhost:8080/health/live` (30s interval, 3 retries) +- **Note**: Dockerfile still references `MyService.API` naming (template not fully renamed) + +### Migrations + +No EF Core migrations exist in the codebase. The migrations assembly is configured to be the Infrastructure assembly. + +### Tests + +Test projects exist but are mostly empty scaffolds: +- `tests/FacebookService.UnitTests/` - No test files (only auto-generated obj files) +- `tests/FacebookService.FunctionalTests/` - Contains `CustomWebApplicationFactory.cs` and `ConversationsControllerTests.cs` diff --git a/services/mkt-whatsapp-service-net/SERVICE_DOCS.md b/services/mkt-whatsapp-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..b588df97 --- /dev/null +++ b/services/mkt-whatsapp-service-net/SERVICE_DOCS.md @@ -0,0 +1,502 @@ +# WhatsApp Service (mkt-whatsapp-service-net) - Service Documentation + +## 1. Overview + +**Purpose**: WhatsApp Business API integration service for GoodGo Platform. Manages WhatsApp Business Account connections, conversations with 24-hour messaging window enforcement, customer profiles with GDPR-compliant opt-in consent, automation flows (keyword-triggered chatbot rules), and AI agent configuration (LLM-powered chatbot via OpenAI). + +**Architecture**: Clean Architecture + CQRS (MediatR) + +**Framework**: .NET 10.0 / C# 14 + +**Default Port**: 5000 (development, via `launchSettings.json`) + +**Docker Port**: 8080 (`ASPNETCORE_URLS=http://+:8080`) + +**Database**: PostgreSQL (connection string key: `ConnectionStrings:DefaultConnection` or `DATABASE_URL`) +- Default DB name in config: `myservice_db` (placeholder from template) + +**DbContext**: `WhatsAppServiceContext` (file named `MyServiceContext.cs`) + +**Health Checks**: +- `/health` - Full health (includes PostgreSQL check) +- `/health/live` - Liveness (app is running, no dependency checks) +- `/health/ready` - Readiness (all dependencies healthy) + +--- + +## 2. API Endpoints + +### 2.1 SamplesController +**Route**: `api/v{version:apiVersion}/Samples` (versioned, default v1.0) + +| Method | Route | Description | Request Body | Response | +|--------|-------|-------------|-------------|----------| +| GET | `/api/v1/Samples` | Get all samples | - | `{ success, data: SampleViewModel[] }` | +| GET | `/api/v1/Samples/{id}` | Get sample by ID | - | `{ success, data: SampleViewModel }` or 404 | +| POST | `/api/v1/Samples` | Create a new sample | `{ name, description? }` | 201 `{ success, data: { id } }` | +| PUT | `/api/v1/Samples/{id}` | Update an existing sample | `{ name, description? }` | `{ success, message }` or 404 | +| DELETE | `/api/v1/Samples/{id}` | Delete a sample | - | 204 or 404 | +| PATCH | `/api/v1/Samples/{id}/status` | Change sample status | `{ status }` | `{ success, message }` or 400 | + +### 2.2 WhatsAppAccountsController +**Route**: `api/WhatsAppAccounts` (unversioned) + +| Method | Route | Description | Request Body | Response | +|--------|-------|-------------|-------------|----------| +| POST | `/api/WhatsAppAccounts` | Connect a WhatsApp Business Account | `{ shopId, phoneNumberId, accessToken, webhookUrl? }` | `{ success, data: { accountId } }` or 400 | +| GET | `/api/WhatsAppAccounts/shop/{shopId}` | Get account by shop ID | - | `{ success, data: { id, shopId, phoneNumberId, status, messageTier, webhookUrl, createdAt } }` or 404 | +| DELETE | `/api/WhatsAppAccounts/{id}` | Disconnect account | - | 204 or 404 | + +### 2.3 ConversationsController +**Route**: `api/Conversations` (unversioned) + +| Method | Route | Description | Query Params | Request Body | Response | +|--------|-------|-------------|-------------|-------------|----------| +| GET | `/api/Conversations` | Get conversations by shop | `shopId` (required), `status?`, `skip=0`, `take=20` | - | `{ success, data: [{ id, customerWaId, status, assignedAgentId, lastMessageAt, createdAt, expiresAt, messageCount }] }` | +| GET | `/api/Conversations/{id}` | Get conversation with messages | - | - | `{ success, data: { ..., messages: [{ id, direction, contentType, text, status, timestamp }] } }` or 404 | +| POST | `/api/Conversations/{id}/messages` | Send a message in conversation | - | `{ type, text?, mediaUrl?, caption?, interactive? }` | `{ success, data: { messageId, whatsAppMessageId } }` or 400 | +| POST | `/api/Conversations/{id}/close` | Close a conversation | - | - | 204 or 404 | + +### 2.4 CustomersController +**Route**: `api/Customers` (unversioned) + +| Method | Route | Description | Query Params | Request Body | Response | +|--------|-------|-------------|-------------|-------------|----------| +| GET | `/api/Customers` | Get customers by shop | `shopId` (required), `skip=0`, `take=20` | - | `{ success, data: [{ id, waId, name, profilePictureUrl, optInStatus, tags, firstContactedAt, createdAt }] }` | +| GET | `/api/Customers/{id}` | Get customer by ID | - | - | `{ success, data: { ..., optInTimestamp, optInSource, customFields } }` or 404 | +| PATCH | `/api/Customers/{id}` | Update customer profile | - | `{ name?, tags?, customFields? }` | 204 or 404 | +| POST | `/api/Customers/{id}/consent` | Update opt-in consent | - | `{ optIn, source? }` | 204 or 404 | + +### 2.5 WebhooksController +**Route**: `api/webhooks` (unversioned) + +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/api/webhooks/whatsapp` | WhatsApp webhook verification (hub.mode, hub.verify_token, hub.challenge) | +| POST | `/api/webhooks/whatsapp` | WhatsApp webhook event handler (processes incoming messages asynchronously) | + +The webhook POST handler: +1. Verifies `X-Hub-Signature-256` HMAC signature using `WhatsApp:AppSecret` config +2. Returns 200 immediately +3. Processes payload asynchronously via `Task.Run` +4. Parses `whatsapp_business_account` object entries/changes +5. Dispatches `ProcessIncomingMessageCommand` for each inbound message + +--- + +## 3. Commands (MediatR) + +### 3.1 WhatsApp-Specific Commands + +| Command | Parameters | Result | Handler Logic | +|---------|-----------|--------|---------------| +| `ConnectWhatsAppAccountCommand` | `ShopId`, `PhoneNumberId`, `AccessToken`, `WebhookUrl?` | `ConnectWhatsAppAccountResult(Success, AccountId?, Error?)` | Checks shop uniqueness + phone uniqueness, encrypts access token (AES-GCM), creates `WhatsAppAccount`, saves | +| `ProcessIncomingMessageCommand` | `PhoneNumberId`, `SenderWaId`, `MessageId`, `MessageType`, `TextBody?`, `MediaId?`, `MediaMimeType?`, `Interactive?`, `Timestamp` | `ProcessIncomingMessageResult(Success, ConversationId?, MessageId?, IsNewConversation, Error?)` | Finds account by phone number, gets/creates customer, gets/creates active conversation, creates `MessageContent`, adds message to conversation | +| `SendMessageCommand` | `ConversationId`, `MessageType`, `Text?`, `MediaUrl?`, `Caption?`, `Interactive?` | `SendMessageResult(Success, MessageId?, WhatsAppMessageId?, Error?)` | Validates conversation exists + within 24h window + account active, decrypts token, sends via WhatsApp Cloud API, adds outbound message to conversation | + +### 3.2 Sample (Template) Commands + +| Command | Parameters | Result | Handler Logic | +|---------|-----------|--------|---------------| +| `CreateSampleCommand` | `Name`, `Description?` | `CreateSampleCommandResult(Id)` | Creates `Sample` entity, adds to repo, saves | +| `UpdateSampleCommand` | `SampleId`, `Name`, `Description?` | `bool` | Finds sample, calls `sample.Update()`, saves | +| `DeleteSampleCommand` | `SampleId` | `bool` | Finds sample, calls `repo.Delete()`, saves | +| `ChangeSampleStatusCommand` | `SampleId`, `NewStatus` (activate/complete/cancel) | `bool` | Finds sample, calls appropriate status method, saves | + +--- + +## 4. Queries (MediatR) + +| Query | Parameters | Result | Handler Logic | +|-------|-----------|--------|---------------| +| `GetSampleQuery` | `SampleId` | `SampleViewModel?` (Id, Name, Description, Status, CreatedAt, UpdatedAt) | Finds by ID via repo, maps to ViewModel | +| `GetSamplesQuery` | (none) | `IEnumerable` | Gets all via repo, maps to ViewModels | +| `GetConversationsQuery` | `ShopId`, `StatusId?`, `Skip=0`, `Take=20` | `GetConversationsResult(Conversations, TotalCount)` | Defined but **no handler implementation exists** (controller uses repository directly instead) | + +**Note**: The ConversationsController and CustomersController bypass MediatR for read operations and query repositories directly. + +--- + +## 5. Domain Model + +### 5.1 Aggregates + +#### WhatsAppAccount Aggregate (Aggregate Root) +- **Entity**: `WhatsAppAccount` -- Represents a shop's WhatsApp Business Account connection +- **Properties**: ShopId, PhoneNumberId, AccessTokenEncrypted, WebhookUrl?, WebhookVerifyToken?, Status (enum), StatusId, MessageTier (tier_1/tier_2/tier_3/unlimited), CreatedAt, UpdatedAt? +- **Behavior Methods**: `Activate()`, `Suspend()`, `Disconnect()`, `UpdateWebhook()`, `UpdateAccessToken()`, `UpgradeMessageTier()` +- **Enumeration**: `WhatsAppAccountStatus` -- Pending(1), Active(2), Suspended(3), Disconnected(4) + +#### Conversation Aggregate (Aggregate Root) +- **Entity**: `Conversation` -- Represents a WhatsApp conversation with 24h window +- **Properties**: ShopId, CustomerWaId, WhatsAppAccountId, Status (enum), StatusId, AssignedAgentId?, LastMessageAt?, Tags (List), Messages (child collection), CreatedAt, ExpiresAt? +- **Behavior Methods**: `AddMessage()`, `AssignToAgent()`, `Unassign()`, `AddTag()`, `RemoveTag()`, `Close()`, `CheckExpiry()`, `IsWithinMessagingWindow()` +- **Constant**: `MessagingWindow = 24 hours` +- **Child Entity**: `Message` -- owned by Conversation + - **Properties**: ConversationId, WhatsAppMessageId?, Direction (inbound/outbound), Content (ValueObject), Status (sent/delivered/read/failed), Timestamp + - **Internal Methods**: `SetWhatsAppMessageId()`, `UpdateStatus()` +- **Enumeration**: `ConversationStatus` -- Active(1), Closed(2), Expired(3) + +#### Customer Aggregate (Aggregate Root) +- **Entity**: `Customer` -- WhatsApp customer profile per shop +- **Properties**: ShopId, WaId, Name?, ProfilePictureUrl?, Consent (ValueObject), Tags (List), CustomFields (Dictionary), FirstContactedAt, CreatedAt, UpdatedAt? +- **Behavior Methods**: `UpdateName()`, `UpdateProfilePicture()`, `UpdateOptIn()`, `AddTag()`, `RemoveTag()`, `UpdateCustomField()`, `RemoveCustomField()`, `HasTag()` + +#### AIAgent Aggregate (Aggregate Root) +- **Entity**: `AIAgent` -- LLM-powered chatbot configuration per shop +- **Properties**: ShopId, AgentName, Personality (ValueObject), KnowledgeBaseId?, IsActive, MaxPromptTokens?, MaxCompletionTokens?, DailyBudgetUsd?, MonthlyBudgetUsd?, CreatedAt, UpdatedAt? +- **Behavior Methods**: `UpdateName()`, `UpdatePersonality()`, `SetKnowledgeBase()`, `SetTokenLimits()`, `SetBudget()`, `Activate()`, `Deactivate()` + +#### AutomationFlow Aggregate (Aggregate Root) +- **Entity**: `AutomationFlow` -- Rule-based chatbot flow +- **Properties**: ShopId, FlowName, TriggerType (enum), TriggerTypeId, TriggerConfig (JSON), IsActive, Priority (0-100, default 50), ExecutionCount, Steps (child collection), CreatedAt, UpdatedAt? +- **Constant**: `MaxSteps = 20` +- **Behavior Methods**: `UpdateName()`, `UpdateTrigger()`, `SetPriority()`, `AddStep()`, `RemoveStep()`, `ValidateFlow()`, `Activate()`, `Deactivate()`, `IncrementExecutionCount()` +- **Child Entity**: `FlowStep` -- owned by AutomationFlow + - **Properties**: FlowId, Order, Action, ActionConfig (JSON), Conditions? (JSON), NextStepMapping? (JSON) + - **Valid Actions**: send_message, quick_reply, tag_customer, call_webhook, assign_to_agent, delay, conditional_branch +- **Enumeration**: `TriggerType` -- Keyword(1), Event(2), Schedule(3) + +#### Sample Aggregate (Template/Demo) +- **Entity**: `Sample` -- Template entity demonstrating DDD patterns +- **Properties**: Name, Description?, Status (enum), StatusId, CreatedAt, UpdatedAt? +- **Behavior Methods**: `Update()`, `Activate()`, `Complete()`, `Cancel()` +- **State Machine**: Draft -> Active -> Completed; Draft/Active -> Cancelled +- **Enumeration**: `SampleStatus` -- Draft(1), Active(2), Completed(3), Cancelled(4) + +### 5.2 Value Objects + +| Value Object | Properties | Factory Methods | +|-------------|-----------|----------------| +| `MessageContent` | Type, Text?, MediaUrl?, Caption?, MimeType?, InteractiveJson? | `CreateText()`, `CreateMedia()`, `CreateInteractive()`, `CreateTemplate()` | +| `OptInConsent` | Status (opted_in/opted_out/pending), Timestamp?, Source? (web/whatsapp/manual) | `CreatePending()`, `CreateOptedIn()`, `CreateOptedOut()` | +| `AgentPersonality` | Tone (friendly/professional/casual), Language (vietnamese/english), Constraints (List), PromptTemplate? | `CreateDefault()` (friendly, vietnamese) | + +### 5.3 Domain Events + +| Event | Raised By | Properties | +|-------|-----------|-----------| +| `WhatsAppAccountConnectedEvent` | WhatsAppAccount constructor | Account, ShopId, PhoneNumberId | +| `WhatsAppAccountStatusChangedEvent` | WhatsAppAccount.Activate/Suspend/Disconnect | AccountId, PreviousStatus, NewStatus | +| `WebhookVerifiedEvent` | WhatsAppAccount.UpdateWebhook | AccountId, WebhookUrl, VerifiedAt | +| `ConversationStartedEvent` | Conversation constructor | ConversationId, ShopId, CustomerWaId, StartedAt | +| `ConversationClosedEvent` | Conversation.Close/CheckExpiry | ConversationId, ShopId, CustomerWaId, ClosingStatus, ClosedAt, MessageCount | +| `MessageReceivedEvent` | Conversation.AddMessage (inbound only) | ConversationId, MessageId, ShopId, CustomerWaId, Content, ReceivedAt | +| `CustomerOptInChangedEvent` | Customer.UpdateOptIn | CustomerId, ShopId, WaId, PreviousConsent, NewConsent | +| `SampleCreatedDomainEvent` | Sample constructor | Sample | +| `SampleStatusChangedDomainEvent` | Sample.Activate/Complete/Cancel | SampleId, PreviousStatus, NewStatus | + +### 5.4 Domain Event Handlers + +| Handler | Handles | Logic | +|---------|---------|-------| +| `AutomationTriggerHandler` | `MessageReceivedEvent` | Finds active keyword-triggered automation flows for the shop, checks keyword match (exact or contains), increments execution count on matched flows | + +### 5.5 Domain Exceptions + +- `DomainException` -- Base exception +- `WhatsAppAccountDomainException` +- `ConversationDomainException` +- `CustomerDomainException` +- `AIAgentDomainException` +- `AutomationFlowDomainException` +- `SampleDomainException` + +--- + +## 6. Database Schema + +### 6.1 Tables + +#### `whatsapp_accounts` +| Column | Type | Constraints | +|--------|------|------------| +| id | uuid | PK | +| shop_id | uuid | NOT NULL, indexed | +| phone_number_id | varchar(50) | NOT NULL, UNIQUE | +| access_token_encrypted | text | NOT NULL | +| webhook_url | varchar(500) | nullable | +| webhook_verify_token | varchar(100) | nullable | +| status_id | int | NOT NULL, FK -> whatsapp_account_statuses | +| message_tier | varchar(20) | default 'tier_1' | +| created_at | timestamp | NOT NULL | +| updated_at | timestamp | nullable | + +**Indexes**: `idx_whatsapp_accounts_shop` (shop_id), `idx_whatsapp_accounts_phone` (phone_number_id, UNIQUE) + +#### `whatsapp_account_statuses` (seed data) +| id | name | +|----|------| +| 1 | Pending | +| 2 | Active | +| 3 | Suspended | +| 4 | Disconnected | + +#### `conversations` +| Column | Type | Constraints | +|--------|------|------------| +| id | uuid | PK | +| shop_id | uuid | NOT NULL | +| customer_wa_id | varchar(20) | NOT NULL | +| whatsapp_account_id | uuid | NOT NULL | +| status_id | int | NOT NULL, FK -> conversation_statuses | +| assigned_agent_id | uuid | nullable | +| last_message_at | timestamp | nullable | +| tags | jsonb | default '[]' | +| created_at | timestamp | NOT NULL | +| expires_at | timestamp | nullable | + +**Indexes**: `idx_conversations_shop_status` (shop_id, status_id), `idx_conversations_customer` (customer_wa_id), `idx_conversations_expires` (expires_at) + +#### `conversation_statuses` (seed data) +| id | name | +|----|------| +| 1 | Active | +| 2 | Closed | +| 3 | Expired | + +#### `messages` +| Column | Type | Constraints | +|--------|------|------------| +| id | uuid | PK | +| conversation_id | uuid | NOT NULL | +| whatsapp_message_id | varchar(100) | nullable, UNIQUE | +| direction | varchar(10) | NOT NULL | +| content_type | varchar(20) | NOT NULL (owned: MessageContent) | +| content_text | text | nullable (owned: MessageContent) | +| media_url | varchar(500) | nullable (owned: MessageContent) | +| caption | varchar(1000) | nullable (owned: MessageContent) | +| mime_type | varchar(100) | nullable (owned: MessageContent) | +| interactive_json | jsonb | nullable (owned: MessageContent) | +| status | varchar(20) | default 'sent' | +| timestamp | timestamp | NOT NULL | + +**Indexes**: `idx_messages_conversation_timestamp` (conversation_id, timestamp), `idx_messages_wa_id` (whatsapp_message_id, UNIQUE) + +#### `customers` +| Column | Type | Constraints | +|--------|------|------------| +| id | uuid | PK | +| shop_id | uuid | NOT NULL | +| wa_id | varchar(20) | NOT NULL | +| name | varchar(200) | nullable | +| profile_picture_url | varchar(500) | nullable | +| opt_in_status | varchar(20) | default 'pending' (owned: OptInConsent) | +| opt_in_timestamp | timestamp | nullable (owned: OptInConsent) | +| opt_in_source | varchar(50) | nullable (owned: OptInConsent) | +| tags | jsonb | default '[]' | +| custom_fields | jsonb | default '{}' | +| first_contacted_at | timestamp | NOT NULL | +| created_at | timestamp | NOT NULL | +| updated_at | timestamp | nullable | + +**Indexes**: `idx_customers_shop_waid` (shop_id, wa_id, UNIQUE), `idx_customers_shop` (shop_id) + +#### `ai_agents` +| Column | Type | Constraints | +|--------|------|------------| +| id | uuid | PK | +| shop_id | uuid | NOT NULL | +| agent_name | varchar(200) | NOT NULL | +| knowledge_base_id | uuid | nullable | +| is_active | bool | default false | +| max_prompt_tokens | int | nullable | +| max_completion_tokens | int | nullable | +| daily_budget_usd | decimal(10,2) | nullable | +| monthly_budget_usd | decimal(10,2) | nullable | +| personality_tone | varchar(50) | default 'friendly' (owned: AgentPersonality) | +| personality_language | varchar(20) | default 'vietnamese' (owned: AgentPersonality) | +| prompt_template | text | nullable (owned: AgentPersonality) | +| personality_constraints | jsonb | default '[]' (owned: AgentPersonality) | +| created_at | timestamp | NOT NULL | +| updated_at | timestamp | nullable | + +**Indexes**: `idx_ai_agents_shop` (shop_id), `idx_ai_agents_shop_active` (shop_id, is_active) + +#### `automation_flows` +| Column | Type | Constraints | +|--------|------|------------| +| id | uuid | PK | +| shop_id | uuid | NOT NULL | +| flow_name | varchar(200) | NOT NULL | +| trigger_type_id | int | NOT NULL, FK -> trigger_types | +| trigger_config | jsonb | NOT NULL | +| is_active | bool | default false | +| priority | int | default 50 | +| execution_count | int | default 0 | +| created_at | timestamp | NOT NULL | +| updated_at | timestamp | nullable | + +**Indexes**: `idx_flows_shop_active` (shop_id, is_active), `idx_flows_trigger` (trigger_type_id) + +#### `flow_steps` +| Column | Type | Constraints | +|--------|------|------------| +| id | uuid | PK | +| flow_id | uuid | NOT NULL | +| step_order | int | NOT NULL | +| action | varchar(50) | NOT NULL | +| action_config | jsonb | NOT NULL | +| conditions | jsonb | nullable | +| next_step_mapping | jsonb | nullable | + +**Indexes**: `idx_flow_steps_order` (flow_id, step_order, UNIQUE) + +#### `trigger_types` (seed data) +| id | name | +|----|------| +| 1 | Keyword | +| 2 | Event | +| 3 | Schedule | + +#### `samples` +| Column | Type | Constraints | +|--------|------|------------| +| id | uuid | PK | +| name | varchar(200) | NOT NULL, indexed | +| description | varchar(1000) | nullable | +| status_id | int | NOT NULL, FK -> sample_statuses, indexed | +| created_at | timestamp | NOT NULL, indexed | +| updated_at | timestamp | nullable | + +#### `sample_statuses` (seed data) +| id | name | +|----|------| +| 1 | Draft | +| 2 | Active | +| 3 | Completed | +| 4 | Cancelled | + +--- + +## 7. Integration Events & Background Jobs + +### 7.1 Domain Event Handlers +- `AutomationTriggerHandler` handles `MessageReceivedEvent` -- checks for keyword-matched automation flows and increments execution counts. Flow step execution is stubbed (commented out). + +### 7.2 Background Services +- `ConversationExpiryJob` (`BackgroundService`) -- runs every 5 minutes, finds active conversations past their `expires_at` timestamp, calls `conversation.CheckExpiry()` to transition status to Expired. + +### 7.3 Cross-Service Integration Events +No RabbitMQ/cross-service integration events are published or consumed. All events are in-process domain events only (MediatR `INotification`). + +--- + +## 8. Dependencies + +### 8.1 NuGet Packages + +**API Layer** (`WhatsAppService.API.csproj`): +- MediatR 12.4.1 +- FluentValidation 11.11.0 +- FluentValidation.DependencyInjectionExtensions 11.11.0 +- Swashbuckle.AspNetCore 7.2.0 +- Asp.Versioning.Mvc 8.1.0 +- Asp.Versioning.Mvc.ApiExplorer 8.1.0 +- AspNetCore.HealthChecks.NpgSql 8.0.2 +- AspNetCore.HealthChecks.Redis 8.0.1 +- Hellang.Middleware.ProblemDetails 6.5.1 +- Serilog.AspNetCore 8.0.3 +- Serilog.Sinks.Console 6.0.0 +- Serilog.Sinks.Seq 8.0.0 + +**Domain Layer** (`WhatsAppService.Domain.csproj`): +- MediatR.Contracts 2.0.1 + +**Infrastructure Layer** (`WhatsAppService.Infrastructure.csproj`): +- Microsoft.EntityFrameworkCore 10.0.0 +- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 +- Microsoft.EntityFrameworkCore.Tools 10.0.0 +- MediatR 12.4.1 +- Dapper 2.1.35 +- Microsoft.Extensions.Http.Polly 10.0.0 +- OpenAI 2.1.0 +- Polly 8.5.0 +- StackExchange.Redis 2.8.16 + +**Build Properties** (`Directory.Build.props`): +- TargetFramework: net10.0 +- LangVersion: 14.0 +- Nullable: enable +- TreatWarningsAsErrors: true + +### 8.2 External Service Dependencies + +1. **WhatsApp Cloud API** (Meta Graph API v18.0) + - Base URL: `https://graph.facebook.com` + - Client: `WhatsAppCloudApiClient` with Polly resilience (retry 3x exponential + circuit breaker 5 failures/30s) + - Operations: SendTextMessage, SendTemplateMessage, SendMediaMessage, SendInteractiveMessage, MarkMessageAsRead, GetMediaUrl + +2. **OpenAI API** + - Client: `OpenAILlmService` using official OpenAI SDK v2.1.0 + - Default model: `gpt-4o-mini` + - Operations: GenerateResponse (chat completion), ExtractIntent (intent classification) + - Settings: ApiKey, Model, MaxTokens (default 500), Temperature (default 0.7) + +--- + +## 9. Configuration + +### 9.1 appsettings.json Keys + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=...;Port=5432;Database=...;Username=...;Password=..." + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "...", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + }, + "Security": { + "AccessTokenEncryptionKey": "..." + } +} +``` + +### 9.2 Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `ASPNETCORE_ENVIRONMENT` | Runtime environment (Development/Production) | Yes | +| `DATABASE_URL` | PostgreSQL connection string (fallback for ConnectionStrings:DefaultConnection) | Yes (one of the two) | +| `ConnectionStrings__DefaultConnection` | PostgreSQL connection string | Yes (one of the two) | +| `Security__AccessTokenEncryptionKey` or `ACCESS_TOKEN_ENCRYPTION_KEY` | AES-GCM key for encrypting WhatsApp access tokens | Yes | +| `WhatsApp__WebhookVerifyToken` | Token for webhook verification handshake | For webhook | +| `WhatsApp__AppSecret` | App secret for HMAC-SHA256 webhook signature verification | For webhook | +| `OpenAI__ApiKey` | OpenAI API key for AI agent | For AI features | +| `OpenAI__Model` | OpenAI model name (default: gpt-4o-mini) | No | +| `OpenAI__MaxTokens` | Max completion tokens (default: 500) | No | +| `OpenAI__Temperature` | Temperature (default: 0.7) | No | +| `REDIS_URL` | Redis connection string | No | + +### 9.3 Security + +- **Access Token Encryption**: AES-GCM (256-bit key derived via SHA256 from config key). Encrypted tokens use `v1:` prefix format: `v1:`. Legacy plain-text tokens are handled for backward compatibility. +- **Webhook Signature Verification**: HMAC-SHA256 using WhatsApp App Secret, compared against `X-Hub-Signature-256` header. + +### 9.4 MediatR Pipeline Behaviors + +1. `LoggingBehavior` -- Logs request start/end with Stopwatch elapsed time +2. `ValidatorBehavior` -- Runs FluentValidation validators, throws `ValidationException` on failure +3. `TransactionBehavior` -- Wraps Commands in database transactions (skips Queries by name convention), uses EF Core `ExecutionStrategy` for retry + +### 9.5 Validators + +| Validator | Rules | +|-----------|-------| +| `CreateSampleCommandValidator` | Name: NotEmpty, MaxLength(200); Description: MaxLength(1000) when not null | +| `UpdateSampleCommandValidator` | SampleId: NotEmpty; Name: NotEmpty, MaxLength(200); Description: MaxLength(1000) when not null | + +### 9.6 Docker + +- Multi-stage build: `sdk:10.0` (build) -> `aspnet:10.0` (runtime) +- Non-root user: `dotnetuser` (UID 1001, GID 1001) +- Port: 8080 +- Healthcheck: `curl -f http://localhost:8080/health/live` (30s interval, 3s timeout, 10s start period, 3 retries) diff --git a/services/mkt-x-service-net/SERVICE_DOCS.md b/services/mkt-x-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..ccbbe553 --- /dev/null +++ b/services/mkt-x-service-net/SERVICE_DOCS.md @@ -0,0 +1,426 @@ +# MktXService (X/Twitter Marketing Integration) + +## Overview + +- **Purpose**: Microservice for integrating with X (Twitter) platform — managing Twitter accounts, conversations, contacts, campaigns, templates, automation flows, AI conversation sessions, and audience segments. +- **Port**: 5000 (configured in Program.cs via ASPNETCORE_URLS) +- **Database**: PostgreSQL (connection string: `DefaultConnection` or `DATABASE_URL`) +- **Architecture**: Clean Architecture + CQRS (MediatR 12.4.1) +- **API Versioning**: URL segment `api/v{version:apiVersion}` (v1.0) + +--- + +## API Endpoints + +### AccountsController (`api/v1/accounts`) +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/accounts?merchantId` | Get Twitter accounts for a merchant | +| GET | `/accounts/{id}` | Get Twitter account by ID | +| POST | `/accounts/connect` | Connect a new Twitter account | +| POST | `/accounts/{id}/disconnect` | Disconnect a Twitter account | +| POST | `/accounts/{id}/refresh-tokens` | Refresh OAuth tokens | + +### CampaignsController (`api/v1/campaigns`) +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/campaigns?merchantId&status&skip&take` | List campaigns with filtering + pagination | +| GET | `/campaigns/{id}` | Get campaign details | +| POST | `/campaigns` | Create a new campaign | +| POST | `/campaigns/{id}/start` | Start a campaign | +| POST | `/campaigns/{id}/pause` | Pause a running campaign | +| POST | `/campaigns/{id}/resume` | Resume a paused campaign | +| POST | `/campaigns/{id}/cancel` | Cancel a campaign | + +### ContactsController (`api/v1/contacts`) +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/contacts?accountId&search&tags&skip&take` | List contacts with search + filtering | +| GET | `/contacts/{id}` | Get contact details | + +### ConversationsController (`api/v1/conversations`) +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/conversations?accountId&status&assignedToUserId&skip&take` | List conversations with filtering | +| GET | `/conversations/{id}?includeMessages` | Get conversation details (optionally with messages) | +| POST | `/conversations/{id}/messages` | Send a message in a conversation | +| POST | `/conversations/{id}/close` | Close a conversation | +| POST | `/conversations/{id}/reopen` | Reopen a closed conversation | +| POST | `/conversations/{id}/assign` | Assign conversation to a user | +| POST | `/conversations/{id}/pending` | Mark conversation as pending | + +### TemplatesController (`api/v1/templates`) +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/templates?merchantId&type` | List templates by merchant and type | +| GET | `/templates/{id}` | Get template details | + +### SamplesController (`api/v1/samples`) +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/samples` | List all samples | +| GET | `/samples/{id}` | Get sample by ID | +| POST | `/samples` | Create a sample | +| PUT | `/samples/{id}` | Update a sample | +| DELETE | `/samples/{id}` | Delete a sample | +| PATCH | `/samples/{id}/status` | Change sample status | + +### WebhooksController (`api/v1/webhooks`) +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/webhooks/twitter` | Twitter CRC challenge verification | +| POST | `/webhooks/twitter` | Receive Twitter webhook events (DMs, follows, etc.) | +| POST | `/webhooks/twitter/register` | Register webhook URL with Twitter | +| POST | `/webhooks/twitter/subscribe` | Subscribe to Twitter webhook events | + +--- + +## Commands (28 total) + +### Twitter Account Commands (3) +| Command | Result | Description | +|---------|--------|-------------| +| `ConnectTwitterAccountCommand(MerchantId, TwitterUserId, Username, OAuthToken, OAuthTokenSecret, DisplayName?, ProfileImageUrl?)` | `ConnectTwitterAccountResult(Success, AccountId?, Error?)` | Connect a Twitter account | +| `DisconnectTwitterAccountCommand(AccountId)` | `bool` | Disconnect a Twitter account | +| `RefreshTwitterTokensCommand(AccountId, NewOAuthToken, NewOAuthTokenSecret)` | `bool` | Refresh OAuth tokens | + +### Campaign Commands (6) +| Command | Result | Description | +|---------|--------|-------------| +| `CreateCampaignCommand(MerchantId, Name, Type)` | `CreateCampaignResult(Success, CampaignId?, Error?)` | Create a campaign (types: bulk_dm, welcome, follow_up, drip, broadcast) | +| `StartCampaignCommand(CampaignId)` | `bool` | Start a campaign | +| `PauseCampaignCommand(CampaignId)` | `bool` | Pause a running campaign | +| `ResumeCampaignCommand(CampaignId)` | `bool` | Resume a paused campaign | +| `CancelCampaignCommand(CampaignId)` | `bool` | Cancel a campaign | +| `UpdateCampaignMetricsCommand(CampaignId, Sent, Delivered, Read, Clicked, Failed)` | `bool` | Update campaign metrics | + +### Conversation Commands (5) +| Command | Result | Description | +|---------|--------|-------------| +| `SendMessageCommand(ConversationId, Content, MediaUrls?)` | `bool` | Send a message (max 4 media) | +| `CloseConversationCommand(ConversationId, Reason?)` | `bool` | Close a conversation (reason max 500 chars) | +| `ReopenConversationCommand(ConversationId)` | `bool` | Reopen a conversation | +| `AssignConversationCommand(ConversationId, UserId)` | `bool` | Assign to a user | +| `MarkConversationPendingCommand(ConversationId)` | `bool` | Mark as pending | + +### Contact Commands (5) +| Command | Result | Description | +|---------|--------|-------------| +| `CreateContactCommand(AccountId, TwitterUserId, Username, Source, DisplayName?, ProfileImageUrl?, Attributes?, Tags?)` | `CreateContactResult(Success, ContactId?, Error?)` | Create a contact | +| `UpdateContactCommand(ContactId, Username, DisplayName?, ProfileImageUrl?)` | `bool` | Update contact profile | +| `AddContactTagCommand(ContactId, TagName)` | `bool` | Add tag to contact | +| `RemoveContactTagCommand(ContactId, TagName)` | `bool` | Remove tag from contact | +| `UpdateContactAttributesCommand(ContactId, Attributes)` | `bool` | Update custom attributes | + +### Template Commands (4) +| Command | Result | Description | +|---------|--------|-------------| +| `CreateTemplateCommand(MerchantId, Name, Type, Content)` | `CreateTemplateResult(Success, TemplateId?, Error?)` | Create a template | +| `UpdateTemplateCommand(TemplateId, Name, Content)` | `bool` | Update a template | +| `DeleteTemplateCommand(TemplateId)` | `bool` | Delete a template | +| `PreviewTemplateCommand(TemplateId, Variables)` | `PreviewTemplateResult(Success, RenderedContent?, Error?)` | Preview template rendering | + +### Sample Commands (4) +| Command | Result | Description | +|---------|--------|-------------| +| `CreateSampleCommand(Name, Description?)` | `CreateSampleResult(Success, SampleId?, Error?)` | Create a sample | +| `UpdateSampleCommand(Id, Name, Description?)` | `bool` | Update a sample | +| `DeleteSampleCommand(Id)` | `bool` | Delete a sample | +| `ChangeSampleStatusCommand(Id, NewStatus)` | `bool` | Change status (Draft->Active->Completed, Draft/Active->Cancelled) | + +--- + +## Queries + +### Twitter Account Queries +| Query | Result | +|-------|--------| +| `GetTwitterAccountsQuery(MerchantId)` | `List` | +| `GetTwitterAccountByIdQuery(AccountId)` | `TwitterAccountDto?` | + +### Campaign Queries +| Query | Result | +|-------|--------| +| `GetCampaignsQuery(MerchantId, Status?, Skip, Take)` | `CampaignsResult(Items, TotalCount)` | +| `GetCampaignByIdQuery(CampaignId)` | `CampaignDetailDto?` | + +### Conversation Queries +| Query | Result | +|-------|--------| +| `GetConversationsQuery(AccountId, Status?, AssignedToUserId?, Skip, Take)` | `ConversationsResult(Items, TotalCount)` | +| `GetConversationByIdQuery(ConversationId, IncludeMessages)` | `ConversationDetailDto?` | + +### Contact Queries +| Query | Result | +|-------|--------| +| `GetContactsQuery(AccountId, Search?, Tags?, Skip, Take)` | `ContactsResult(Items, TotalCount)` | +| `GetContactByIdQuery(ContactId)` | `ContactDetailDto?` | + +### Template Queries +| Query | Result | +|-------|--------| +| `GetTemplatesQuery(MerchantId, Type?)` | `List` | +| `GetTemplateByIdQuery(TemplateId)` | `TemplateDetailDto?` | + +### Sample Queries +| Query | Result | +|-------|--------| +| `GetSamplesQuery` | `List` | +| `GetSampleByIdQuery(Id)` | `SampleDto?` | + +### DTOs +- **TwitterAccountDto**: Id, MerchantId, TwitterUserId, Username, DisplayName, ProfileImageUrl, Status, ConnectedAt +- **CampaignDto**: Id, MerchantId, Name, Type, Status, CreatedAt, UpdatedAt +- **CampaignDetailDto**: CampaignDto + TemplateId, SegmentIds, Schedule (StartAt, EndAt, TimeZone), Metrics (Sent, Delivered, Read, Clicked, Failed) +- **ConversationDto**: Id, AccountId, ContactId, Status, LastMessagePreview, LastMessageAt, AssignedToUserId +- **ConversationDetailDto**: ConversationDto + Messages (list of MessageDto) +- **MessageDto**: Id, Content, Direction, Type, SentAt, IsFromBot, TwitterMessageId +- **ContactDto**: Id, TwitterUserId, Username, DisplayName, ProfileImageUrl, Tags, LastInteraction +- **ContactDetailDto**: ContactDto + Attributes, Source, CreatedAt +- **TemplateDto**: Id, Name, Type, CreatedAt +- **TemplateDetailDto**: TemplateDto + Content, Variables (extracted from {{var}} placeholders) + +--- + +## Domain Model + +### Aggregates + +#### TwitterAccount (Aggregate Root) +- **Properties**: MerchantId, TwitterUserId, Username, DisplayName, ProfileImageUrl, StatusId (TwitterAccountStatus), OAuthToken, OAuthTokenSecret, WebhookId, Settings (Dictionary), ConnectedAt, CreatedAt, UpdatedAt +- **Methods**: Activate(), SetWebhookId(), UpdateCredentials(), UpdateProfile(), UpdateSettings(), MarkAsError(reason), Deactivate(), Disconnect() +- **Domain Events**: TwitterAccountConnectedDomainEvent, TwitterAccountStatusChangedDomainEvent, TwitterAccountDisconnectedDomainEvent + +#### TwitterAccountStatus (Enumeration) +- Pending(1), Active(2), Inactive(3), Error(4), Disconnected(5) + +#### Campaign (Aggregate Root) +- **Properties**: MerchantId, Name, Type, StatusId (CampaignStatus), TemplateId, SegmentIds (List - JSONB), Schedule (CampaignSchedule value object), Metrics (CampaignMetrics value object), CreatedAt, UpdatedAt +- **Value Objects**: + - CampaignSchedule: StartAt, EndAt, TimeZone + - CampaignMetrics: Sent, Delivered, Read, Clicked, Failed +- **Methods**: SetTemplate(), AddSegment(), RemoveSegment(), SetSchedule(), Start(), Pause(), Resume(), Complete(), Cancel(), UpdateMetrics() +- **Domain Events**: CampaignStartedDomainEvent, CampaignCompletedDomainEvent + +#### CampaignStatus (Enumeration) +- Draft(1), Scheduled(2), Running(3), Paused(4), Completed(5), Cancelled(6) + +#### Conversation (Aggregate Root) +- **Properties**: AccountId, ContactId, Status (ConversationStatus), AssignedToUserId, StartedAt, EndedAt, LastMessagePreview, LastMessageAt, CreatedAt, UpdatedAt +- **Child Entity**: Message — Direction (inbound/outbound), Type (text/image/card/quick_reply), Content, Attachments (MessageAttachment value object with Url, Type), IsFromBot, TwitterMessageId, SentAt +- **Methods**: AddMessage(), Close(reason?), Reopen(), AssignTo(userId), Unassign(), MarkAsPending() +- **Domain Events**: ConversationStartedDomainEvent, MessageReceivedDomainEvent, MessageSentDomainEvent + +#### ConversationStatus (Enumeration) +- Open(1), Closed(2), Pending(3) + +#### Contact (Aggregate Root) +- **Properties**: AccountId, TwitterUserId, Username, DisplayName, ProfileImageUrl, Source, Attributes (Dictionary), CreatedAt, UpdatedAt, LastInteractionAt +- **Child Entity**: ContactTag — Name (normalized lowercase), CreatedAt +- **Methods**: UpdateProfile(), AddTag(), RemoveTag(), SetAttribute(), RemoveAttribute(), UpdateAttributes(), RecordInteraction() +- **Domain Events**: ContactCreatedDomainEvent, ContactTaggedDomainEvent + +#### Template (Aggregate Root) +- **Properties**: MerchantId, Name, Type, Content, Variables (extracted from `{{variable}}` via regex), CreatedAt, UpdatedAt +- **Constants**: MaxContentLength = 10000 +- **Methods**: Update(name, content), Render(variables), ValidateVariables(variables), GetMissingVariables(variables) + +#### Sample (Aggregate Root — template/demo) +- **Properties**: Name, Description, StatusId (SampleStatus), CreatedAt, UpdatedAt +- **Status Transitions**: Draft->Active->Completed, Draft/Active->Cancelled +- **Methods**: Activate(), Complete(), Cancel() +- **Domain Events**: SampleCreatedDomainEvent, SampleStatusChangedDomainEvent + +#### Segment (Aggregate Root) +- **Properties**: AccountId, Name, Description, CreatedAt, UpdatedAt +- **Child Entity**: SegmentCondition — Field, Operator, Value + +#### AIConversationSession (Aggregate Root) +- **Properties**: ConversationId, Intent, Slots (Dictionary), ContextItems (List), IsActive, CreatedAt, UpdatedAt, ExpiresAt +- **Constants**: MaxContextItems = 10, EscalationThreshold = 0.6 +- **Methods**: AddContext(), ClearContext(), SetIntent(), SetSlot(), Deactivate(), Reactivate(), IsExpired() +- **Domain Events**: AIEscalationRequestedDomainEvent, IntentDetectedDomainEvent + +#### AutomationFlow (Aggregate Root) +- **Properties**: AccountId, Name, Description, StatusId (FlowStatus), CreatedAt, UpdatedAt +- **Child Entities**: FlowTrigger, FlowNode, FlowConnection +- **FlowStatus**: Draft(1), Active(2), Inactive(3) +- **Methods**: AddNode(), RemoveNode(), Connect(), Activate(), Deactivate() + +--- + +## Database Schema + +### DbContext: MktXServiceContext (17 DbSets) +- Implements IUnitOfWork, dispatches domain events before SaveChanges +- Transaction support: BeginTransactionAsync, CommitTransactionAsync, RollbackTransaction + +### Tables + +#### twitter_accounts +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK | +| merchant_id | uuid | NOT NULL, indexed | +| twitter_user_id | varchar | NOT NULL, unique index | +| username | varchar | | +| display_name | varchar | | +| profile_image_url | varchar | | +| status_id | int | FK -> twitter_account_statuses, indexed | +| oauth_token | varchar | | +| oauth_token_secret | varchar | | +| webhook_id | varchar | | +| settings | jsonb | | +| connected_at | timestamp | | +| created_at | timestamp | NOT NULL | +| updated_at | timestamp | NOT NULL | + +**Indexes**: ix_twitter_accounts_twitter_user_id (unique), ix_twitter_accounts_merchant_id, ix_twitter_accounts_status_id + +#### twitter_account_statuses +| Column | Type | +|--------|------| +| id | int | PK | +| name | varchar | + +**Seed data**: 1=Pending, 2=Active, 3=Inactive, 4=Error, 5=Disconnected + +#### campaigns +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK | +| merchant_id | uuid | NOT NULL, indexed | +| name | varchar | NOT NULL | +| type | varchar | NOT NULL | +| status_id | int | FK -> campaign_statuses, indexed | +| template_id | uuid | nullable | +| segment_ids | jsonb | List | +| schedule | jsonb | CampaignSchedule value object | +| metrics | jsonb | CampaignMetrics value object | +| created_at | timestamp | NOT NULL, desc index | +| updated_at | timestamp | NOT NULL | + +**Indexes**: ix_campaigns_merchant_id, ix_campaigns_status_id, ix_campaigns_created_at (desc), ix_campaigns_merchant_status (composite) + +#### campaign_statuses +| Column | Type | +|--------|------| +| id | int | PK | +| name | varchar | + +**Seed data**: 1=Draft, 2=Scheduled, 3=Running, 4=Paused, 5=Completed, 6=Cancelled + +#### conversations, messages, contacts, contact_tags, templates, samples, segments, segment_conditions, ai_conversation_sessions, automation_flows, flow_triggers, flow_nodes, flow_connections +(Configured via EF Core FluentAPI — snake_case columns, private field mapping) + +--- + +## Domain Events (14 total) + +| Event | Payload | +|-------|---------| +| SampleCreatedDomainEvent | Sample entity | +| SampleStatusChangedDomainEvent | Sample entity | +| TwitterAccountConnectedDomainEvent | TwitterAccount entity | +| TwitterAccountDisconnectedDomainEvent | TwitterAccount entity | +| TwitterAccountStatusChangedDomainEvent | TwitterAccount entity | +| CampaignStartedDomainEvent | Campaign entity | +| CampaignCompletedDomainEvent | Campaign entity | +| ConversationStartedDomainEvent | Conversation entity | +| MessageReceivedDomainEvent | Conversation entity | +| MessageSentDomainEvent | Conversation entity | +| ContactCreatedDomainEvent | Contact entity | +| ContactTaggedDomainEvent | Contact entity, TagName | +| AIEscalationRequestedDomainEvent | AIConversationSession entity | +| IntentDetectedDomainEvent | AIConversationSession entity | + +--- + +## Validators + +| Validator | Rules | +|-----------|-------| +| CreateCampaignCommandValidator | MerchantId required; Name required, max 255; Type in [bulk_dm, welcome, follow_up, drip, broadcast] | +| StartCampaignCommandValidator | CampaignId required | +| PauseCampaignCommandValidator | CampaignId required | +| ResumeCampaignCommandValidator | CampaignId required | +| CancelCampaignCommandValidator | CampaignId required | +| UpdateCampaignMetricsCommandValidator | CampaignId required; all metric fields >= 0 | +| ConnectTwitterAccountCommandValidator | MerchantId required; TwitterUserId required; Username required; OAuthToken required; OAuthTokenSecret required | +| DisconnectTwitterAccountCommandValidator | AccountId required | +| RefreshTwitterTokensCommandValidator | AccountId required; NewOAuthToken required; NewOAuthTokenSecret required | +| SendMessageCommandValidator | ConversationId required; Content required, max 10000; MediaUrls max 4 items | +| CloseConversationCommandValidator | ConversationId required; Reason max 500 chars | +| AssignConversationCommandValidator | ConversationId required; UserId required | +| ReopenConversationCommandValidator | ConversationId required | +| MarkConversationPendingCommandValidator | ConversationId required | +| CreateSampleCommandValidator | Name required, max 200 | +| UpdateSampleCommandValidator | Id required; Name required, max 200 | + +--- + +## External Services + +### Twitter API Client (ITwitterApiClient / TwitterApiClient) +- **Authentication**: OAuth 1.0a HMAC-SHA1 signing +- **Resilience**: Polly retry (3 attempts, exponential backoff) + circuit breaker (5 failures, 1 min break) +- **Methods**: SendDirectMessage, GetDirectMessages, GetUserById, GetUserByUsername, RegisterWebhook, SubscribeWebhook, DeleteWebhook, UploadMedia, GetRateLimitStatus +- **Config**: TwitterApiOptions (ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret, EnvironmentName) + +### OpenAI Service Client (IAIServiceClient / OpenAIServiceClient) +- **Resilience**: Circuit breaker +- **Methods**: GetChatCompletionAsync, GetChatCompletionWithFunctionsAsync, StreamChatCompletionAsync, DetectIntentAsync, ExtractEntitiesAsync, GetEmbeddingsAsync, ModerateContentAsync +- **Config**: OpenAIOptions (ApiKey, DefaultModel=gpt-4o-mini, EmbeddingModel=text-embedding-3-small, MaxTokens, DefaultTemperature) + +--- + +## Dependencies (NuGet) + +### API Project +- MediatR 12.4.1 +- FluentValidation.DependencyInjectionExtensions 11.11.0 +- Swashbuckle.AspNetCore 7.2.0 +- Asp.Versioning.Mvc 8.1.0 +- Asp.Versioning.Mvc.ApiExplorer 8.1.0 +- Hellang.Middleware.ProblemDetails 6.5.1 +- Serilog.AspNetCore 8.0.3 +- AspNetCore.HealthChecks.NpgSql 8.0.2 + +### Infrastructure Project +- Microsoft.EntityFrameworkCore 10.0.0 +- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 +- MediatR 12.4.1 +- Dapper 2.1.35 +- Polly 8.5.0 +- Polly.Extensions.Http 3.0.0 +- StackExchange.Redis 2.8.16 +- Quartz 3.13.1 + +--- + +## Configuration + +### Environment Variables / Settings +- `ConnectionStrings:DefaultConnection` or `DATABASE_URL` — PostgreSQL connection +- `TwitterApi:ConsumerKey` — Twitter API consumer key +- `TwitterApi:ConsumerSecret` — Twitter API consumer secret +- `TwitterApi:AccessToken` — Twitter API access token +- `TwitterApi:AccessTokenSecret` — Twitter API access token secret +- `TwitterApi:EnvironmentName` — Twitter webhook environment +- `OpenAI:ApiKey` — OpenAI API key +- `OpenAI:DefaultModel` — Default model (gpt-4o-mini) +- `OpenAI:EmbeddingModel` — Embedding model (text-embedding-3-small) + +### MediatR Pipeline Behaviors +1. LoggingBehavior — Request/response logging with Stopwatch +2. ValidatorBehavior — FluentValidation in pipeline +3. TransactionBehavior — Auto transaction for Commands (skips Queries) + +### Health Checks +- PostgreSQL health check via NpgSql + +--- + +## Known Issues + +- **Incomplete DI Registration**: Only `ISampleRepository` is registered in `DependencyInjection.cs`. The other 8 repository interfaces (ITwitterAccountRepository, ICampaignRepository, IConversationRepository, IContactRepository, ITemplateRepository, ISegmentRepository, IAIConversationSessionRepository, IAutomationFlowRepository) are NOT registered. This means most functionality beyond Samples will fail at runtime until DI is fixed. diff --git a/services/mkt-zalo-service-net/SERVICE_DOCS.md b/services/mkt-zalo-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..db63196f --- /dev/null +++ b/services/mkt-zalo-service-net/SERVICE_DOCS.md @@ -0,0 +1,423 @@ +# MktZaloService (Zalo Marketing Integration) + +## Overview + +- **Purpose**: Microservice for integrating with Zalo Official Account (OA) — managing conversations, customers, chatbot automation rules, ZNS message templates, and webhook event processing. +- **Port**: 5000 (configured in Program.cs via ASPNETCORE_URLS) +- **Database**: PostgreSQL (connection string: `DefaultConnection` or `DATABASE_URL`) +- **Cache**: Redis (connection string: `Redis` or `REDIS_URL`) with fallback to in-memory cache +- **Architecture**: Clean Architecture + CQRS (MediatR 12.4.1) +- **API Versioning**: URL segment `api/v{version:apiVersion}` (v1.0) +- **Note**: Some .csproj files still use template naming (MyService.API.csproj, MyService.Domain.csproj, MyService.Infrastructure.csproj) + +--- + +## API Endpoints + +### WebhooksController (`api/v1/webhooks`) — AllowAnonymous +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/webhooks/zalo` | Zalo webhook challenge verification (returns challenge value) | +| POST | `/webhooks/zalo` | Receive Zalo webhook events; verifies X-ZaloOA-Signature via HMAC-SHA256 | + +**Supported webhook events**: +- `user_send_text` — Text message from user +- `user_send_image` — Image message from user +- `user_send_file` — File message from user +- `user_send_sticker` — Sticker message from user +- `follow` — User follows OA +- `unfollow` — User unfollows OA + +### ConversationsController (`api/v1/conversations`) — Authorize +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/conversations/{id}?skip&take` | Get conversation with paginated messages | +| POST | `/conversations/{id}/messages` | Send a message in a conversation | + +### CustomersController (`api/v1/customers`) — Authorize +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/customers/{id}` | Get customer by ID | +| GET | `/customers/by-zalo/{zaloUserId}` | Get customer by Zalo user ID | +| PATCH | `/customers/{id}` | Update customer profile | + +### ChatbotRulesController (`api/v1/chatbot-rules`) — Authorize +| Method | Route | Description | +|--------|-------|-------------| +| GET | `/chatbot-rules?includeInactive` | List chatbot rules | +| POST | `/chatbot-rules` | Create a new chatbot rule | +| DELETE | `/chatbot-rules/{id}` | Delete a chatbot rule | +| PATCH | `/chatbot-rules/{id}/toggle?activate` | Toggle rule active/inactive | + +--- + +## Commands (6 total) + +### Webhook Commands +| Command | Result | Description | +|---------|--------|-------------| +| `ProcessWebhookCommand(EventName, ZaloUserId, MessageText?, MessageId?, Attachments?)` | `ProcessWebhookResult(Success, Error?)` | Process incoming Zalo webhook event. Handles: text/image/file/sticker messages, follow/unfollow. Creates customer via GetOrCreate, finds/creates active conversation, adds message, triggers domain event for chatbot auto-response. | + +### Message Commands +| Command | Result | Description | +|---------|--------|-------------| +| `SendMessageCommand(ConversationId, Content, IsFromBot?)` | `SendMessageResult(Success, MessageId?, Error?)` | Send outgoing message via Zalo OA API. Adds message to conversation, sends via IZaloOfficialAccountClient, updates ZaloMessageId on success. | + +### Customer Commands +| Command | Result | Description | +|---------|--------|-------------| +| `UpdateCustomerCommand(CustomerId, DisplayName, AvatarUrl?, PhoneNumber?, Email?)` | `UpdateCustomerResult(Success, Error?)` | Update customer profile fields. | + +### Chatbot Rule Commands +| Command | Result | Description | +|---------|--------|-------------| +| `CreateChatbotRuleCommand(Name, Description?, Type, Priority, ActionType, ResponseText?, TemplateId?, Conditions)` | `CreateChatbotRuleResult(Success, RuleId?, Error?)` | Create a new chatbot automation rule with conditions. | +| `DeleteChatbotRuleCommand(RuleId)` | `DeleteChatbotRuleResult(Success, Error?)` | Delete a chatbot rule. | +| `ToggleChatbotRuleCommand(RuleId, Activate)` | `ToggleChatbotRuleResult(Success, Error?)` | Activate or deactivate a chatbot rule. | + +--- + +## Queries (4 total) + +| Query | Result | Description | +|-------|--------|-------------| +| `GetConversationHistoryQuery(ConversationId, Skip?, Take?)` | `ConversationHistoryDto?` | Get conversation with paginated messages (default: skip=0, take=50) | +| `GetCustomerQuery(CustomerId)` | `CustomerDto?` | Get customer by internal ID | +| `GetCustomerByZaloIdQuery(ZaloUserId)` | `CustomerDto?` | Get customer by Zalo user ID | +| `GetChatbotRulesQuery(IncludeInactive?)` | `List` | List chatbot rules (optionally include inactive) | + +### DTOs +- **ConversationHistoryDto**: ConversationId, ZaloUserId, CustomerId, Status, MessageCount, LastMessagePreview, LastMessageAt, StartedAt, EndedAt, Messages (list of MessageDto) +- **MessageDto**: Id, Type, Content, Direction, IsFromBot, SentAt, ZaloMessageId +- **CustomerDto**: Id, ZaloUserId, DisplayName, AvatarUrl, PhoneNumber, Email, Segment, ConversationCount, TotalMessageCount, IsActive, FirstInteractionAt, LastInteractionAt, Tags +- **ChatbotRuleDto**: Id, Name, Description, Type, Priority, IsActive, ActionType, ResponseText, TemplateId, Conditions (list of RuleConditionDto), MatchCount, LastMatchedAt, CreatedAt +- **RuleConditionDto**: Field, Operator, Value + +--- + +## Domain Model + +### Aggregates + +#### ZaloCustomer (Aggregate Root) +- **Properties**: ZaloUserId (unique), Profile (CustomerProfile value object), Segment (CustomerSegment enum), FirstInteractionAt, LastInteractionAt, ConversationCount, TotalMessageCount, IsActive, CreatedAt, UpdatedAt +- **Child Entity**: Tag — Name (normalized lowercase), CreatedAt, CustomerId (FK) +- **Value Object**: CustomerProfile — DisplayName, AvatarUrl, PhoneNumber, Email +- **Methods**: UpdateProfile(), AddTag() (idempotent), RemoveTag(), RecordInteraction(), IncrementConversationCount(), IncrementMessageCount(), MarkInactive(), MarkActive() +- **Auto-segmentation**: UpdateSegment() based on engagement: + - VIP: >50 conversations AND >500 messages + - Active: >20 conversations AND >200 messages + - Regular: >5 conversations AND >50 messages + - New: default +- **Domain Events**: CustomerCreatedDomainEvent, CustomerProfileUpdatedDomainEvent + +#### Conversation (Aggregate Root) +- **Properties**: ZaloUserId, CustomerId, Status (ConversationStatus enum), StartedAt, EndedAt, MessageCount, LastMessagePreview (max 100 chars + "..."), LastMessageAt, CreatedAt, UpdatedAt +- **Child Entity**: Message — Type (MessageType enum), Content, Direction (MessageDirection enum), IsFromBot, SentAt, ZaloMessageId, ConversationId (FK) +- **Methods**: AddMessage() (throws ConversationClosedException if closed), Close() (idempotent), Reopen() (idempotent) +- **Domain Events**: ConversationStartedDomainEvent, MessageReceivedDomainEvent, ConversationClosedDomainEvent + +#### ChatbotRule (Aggregate Root) +- **Properties**: Name, Description, Type (RuleType enum), Action (RuleAction value object), Priority (0-100, higher = evaluated first), IsActive, MatchCount, LastMatchedAt, CreatedAt, UpdatedAt +- **Child Entity**: ChatbotRuleCondition — Field, Operator, Value, RuleId (FK), CreatedAt +- **Value Objects**: + - RuleCondition — Field, Operator, Value + - RuleAction — ActionType (ActionType enum), ResponseText, TemplateId. Factory methods: SendText(), SendTemplate(), ForwardToHuman() +- **Methods**: AddCondition(), ClearConditions(), Evaluate(userMessage), RecordMatch(), Activate(), Deactivate(), Update() +- **Evaluation Logic**: + - Keyword: any condition with "contains" operator matches (case-insensitive) + - Regex: any condition matches regex pattern (1-second timeout) + - Intent: delegated to AI engine (returns false in Evaluate) +- **Domain Events**: ChatbotRuleMatchedDomainEvent + +#### MessageTemplate (Aggregate Root) +- **Properties**: ZaloTemplateId (unique, Zalo's ID), Name, Content, Status (TemplateStatus enum), SendCount, CreatedAt, UpdatedAt +- **Child Entity**: TemplateParameter — Name, IsRequired, DefaultValue, TemplateId (FK) +- **Methods**: AddParameter() (duplicate check), ValidateAndFillParameters() (fills defaults, throws MissingTemplateParametersException), Approve(), Reject(), RecordSend(), Update() + +### Enums +| Enum | Values | +|------|--------| +| ConversationStatus | Active(0), Closed(1) | +| MessageType | Text(0), Image(1), Link(2), Sticker(3), Audio(4) | +| MessageDirection | Incoming(0), Outgoing(1) | +| CustomerSegment | New(0), Regular(1), Active(2), VIP(3) | +| RuleType | Keyword(0), Regex(1), Intent(2) | +| ActionType | SendText(0), SendTemplate(1), ForwardToHuman(2) | +| TemplateStatus | Pending(0), Approved(1), Rejected(2) | + +### Exceptions +| Exception | Description | +|-----------|-------------| +| ZaloDomainException | Base domain exception | +| ConversationClosedException | Cannot operate on closed conversation | +| CustomerNotFoundException | Customer not found by Zalo user ID | +| InvalidRuleConfigurationException | Invalid rule configuration | +| MissingTemplateParametersException | Missing required template parameters | + +--- + +## Database Schema + +### DbContext: MktZaloServiceContext (8 DbSets) +- Implements IUnitOfWork, dispatches domain events before SaveChanges +- Transaction support: BeginTransactionAsync, CommitTransactionAsync, RollbackTransaction +- Migration: 20260118181258_InitialSchema + +### Tables + +#### ZaloCustomers +| Column | Type | Constraints | +|--------|------|-------------| +| Id | uuid | PK | +| ZaloUserId | varchar(50) | NOT NULL, unique index | +| DisplayName | varchar(255) | NOT NULL (owned, from Profile) | +| AvatarUrl | varchar(500) | nullable (owned, from Profile) | +| PhoneNumber | varchar(20) | nullable (owned, from Profile) | +| Email | varchar(255) | nullable (owned, from Profile) | +| Segment | varchar(20) | string conversion (enum), indexed | +| FirstInteractionAt | timestamp | | +| LastInteractionAt | timestamp | indexed | +| ConversationCount | int | default 0 | +| TotalMessageCount | int | default 0 | +| IsActive | bool | default true | +| CreatedAt | timestamp | NOT NULL | +| UpdatedAt | timestamp | NOT NULL | + +**Indexes**: IX_ZaloCustomers_ZaloUserId (unique), IX_ZaloCustomers_Segment, IX_ZaloCustomers_LastInteractionAt + +#### CustomerTags +| Column | Type | Constraints | +|--------|------|-------------| +| Id | uuid | PK | +| Name | varchar(100) | NOT NULL | +| CustomerId | uuid | FK -> ZaloCustomers (cascade delete) | +| CreatedAt | timestamp | NOT NULL | + +**Indexes**: IX_CustomerTags_CustomerId_Name (unique composite) + +#### Conversations +| Column | Type | Constraints | +|--------|------|-------------| +| Id | uuid | PK | +| ZaloUserId | varchar(50) | NOT NULL, indexed | +| CustomerId | uuid | indexed | +| Status | varchar(20) | string conversion (enum) | +| StartedAt | timestamp | | +| EndedAt | timestamp | nullable | +| MessageCount | int | default 0 | +| LastMessagePreview | varchar(200) | | +| LastMessageAt | timestamp | indexed | +| CreatedAt | timestamp | NOT NULL | +| UpdatedAt | timestamp | NOT NULL | + +**Indexes**: IX_Conversations_CustomerId, IX_Conversations_ZaloUserId, IX_Conversations_Status_StartedAt (composite), IX_Conversations_LastMessageAt + +#### Messages +| Column | Type | Constraints | +|--------|------|-------------| +| Id | uuid | PK | +| Type | varchar(20) | string conversion (enum) | +| Content | text | NOT NULL | +| Direction | varchar(20) | string conversion (enum) | +| IsFromBot | bool | | +| SentAt | timestamp | NOT NULL | +| ZaloMessageId | varchar(100) | indexed | +| ConversationId | uuid | FK -> Conversations (cascade delete) | + +**Indexes**: IX_Messages_ConversationId_SentAt (composite), IX_Messages_ZaloMessageId + +#### ChatbotRules +| Column | Type | Constraints | +|--------|------|-------------| +| Id | uuid | PK | +| Name | varchar(255) | NOT NULL, indexed | +| Description | varchar(1000) | nullable | +| Type | varchar(20) | string conversion (enum) | +| Priority | int | default 50 | +| IsActive | bool | default true | +| ActionType | varchar(30) | string conversion (owned from Action) | +| ActionResponseText | varchar(2000) | nullable (owned from Action) | +| ActionTemplateId | uuid | nullable (owned from Action) | +| MatchCount | int | default 0 | +| LastMatchedAt | timestamp | nullable | +| CreatedAt | timestamp | NOT NULL | +| UpdatedAt | timestamp | NOT NULL | + +**Indexes**: IX_ChatbotRules_IsActive_Priority (IsActive asc, Priority desc), IX_ChatbotRules_Name + +#### RuleConditions +| Column | Type | Constraints | +|--------|------|-------------| +| Id | uuid | PK | +| Field | varchar(50) | NOT NULL | +| Operator | varchar(50) | NOT NULL | +| Value | text | NOT NULL | +| RuleId | uuid | FK -> ChatbotRules (cascade delete), indexed | +| CreatedAt | timestamp | NOT NULL | + +**Indexes**: IX_RuleConditions_RuleId + +#### MessageTemplates +| Column | Type | Constraints | +|--------|------|-------------| +| Id | uuid | PK | +| ZaloTemplateId | varchar(50) | NOT NULL, unique index | +| Name | varchar(255) | NOT NULL | +| Content | text | NOT NULL | +| Status | varchar(20) | string conversion (enum), indexed | +| SendCount | int | default 0 | +| CreatedAt | timestamp | NOT NULL | +| UpdatedAt | timestamp | NOT NULL | + +**Indexes**: IX_MessageTemplates_ZaloTemplateId (unique), IX_MessageTemplates_Status + +#### TemplateParameters +| Column | Type | Constraints | +|--------|------|-------------| +| Id | uuid | PK | +| Name | varchar(100) | NOT NULL | +| IsRequired | bool | default true | +| DefaultValue | varchar(500) | nullable | +| TemplateId | uuid | FK -> MessageTemplates (cascade delete) | + +**Indexes**: IX_TemplateParameters_TemplateId_Name (unique composite) + +--- + +## Domain Events (7 total) + +| Event | Payload | +|-------|---------| +| MessageReceivedDomainEvent | ConversationId, MessageId, Content, IsFromBot | +| ConversationClosedDomainEvent | ConversationId | +| ConversationStartedDomainEvent | ConversationId, CustomerId, ZaloUserId | +| CustomerProfileUpdatedDomainEvent | CustomerId, ZaloUserId | +| CustomerCreatedDomainEvent | CustomerId, ZaloUserId, DisplayName | +| ChatbotRuleMatchedDomainEvent | RuleId, ConversationId, MatchedMessage | +| MessageSentDomainEvent | ConversationId, MessageId, ZaloMessageId | + +### Domain Event Handlers +- **MessageReceivedDomainEventHandler**: Triggered on MessageReceivedDomainEvent. Skips bot messages (avoids loops). Finds matching chatbot rule via IChatbotRulesService (priority order). Records rule match, then handles action: SendText sends auto-response via SendMessageCommand, SendTemplate logs template ID (not yet implemented), ForwardToHuman logs forwarding. + +--- + +## Application Services + +### ChatbotRulesService (IChatbotRulesService) +- **FindMatchingRuleAsync**: Gets active rules (cached via IChatbotRuleCacheService), evaluates rules in priority order, returns first match +- **GetResponseText**: Returns response text for SendText actions, null for SendTemplate/ForwardToHuman + +### AiChatbotEngine (IChatbotEngine) +- **Status**: Placeholder implementation (not connected to real AI) +- **Name**: "AI" +- **CanHandleAsync**: Always returns true (fallback engine) +- **GenerateResponseAsync**: Builds conversation history from cache context, returns placeholder Vietnamese responses based on keyword matching. System prompt instructs friendly Vietnamese assistant behavior. +- **Placeholder responses**: Thanks messages, question redirection, default acknowledgment + +--- + +## Caching Services + +### ChatbotRuleCacheService (IChatbotRuleCacheService) +- **Key**: `chatbot:rules:active` +- **Expiration**: 5 minutes (not fully implemented — always loads from DB in MVP) +- **InvalidateRulesCacheAsync**: Removes cache key + +### ConversationCacheService (IConversationCacheService) +- **Key pattern**: `conversation:{conversationId}` +- **Expiration**: 30 minutes +- **ConversationContext**: ConversationId, CustomerId, ZaloUserId, CustomerName, RecentMessages (list of CachedMessage), LastUpdated +- **GetOrSetAsync**: Generic factory pattern for cache-aside + +--- + +## External Services + +### Zalo Official Account Client (IZaloOfficialAccountClient / ZaloOfficialAccountClient) +- **Base URL**: `https://openapi.zalo.me` (configurable) +- **Authentication**: access_token header +- **Resilience**: Polly retry (configurable attempts, exponential backoff) + circuit breaker (5 failures, 30s break) +- **JSON**: snake_case naming policy +- **Methods**: + - `SendTextMessageAsync(zaloUserId, text)` — POST `/v3.0/oa/message/cs` + - `SendTemplateMessageAsync(phoneNumber, templateId, parameters)` — POST `/v3.0/oa/message/template` (ZNS) + - `GetUserProfileAsync(zaloUserId)` — GET `/v3.0/oa/user/detail?data={json}` + +### Zalo Webhook Verifier (IZaloWebhookVerifier / ZaloWebhookVerifier) +- **Algorithm**: HMAC-SHA256 +- **Secret**: Configured via ZaloOAOptions.WebhookSecret +- **VerifySignature**: Computes HMAC-SHA256 of request body with secret, compares to X-ZaloOA-Signature header + +--- + +## Dependencies (NuGet) + +### API Project +- MediatR 12.4.1 +- FluentValidation.DependencyInjectionExtensions 11.11.0 +- Swashbuckle.AspNetCore 7.2.0 +- Asp.Versioning.Mvc 8.1.0 +- Asp.Versioning.Mvc.ApiExplorer 8.1.0 +- Hellang.Middleware.ProblemDetails 6.5.1 +- Serilog.AspNetCore 8.0.3 +- AspNetCore.HealthChecks.NpgSql 8.0.2 + +### Infrastructure Project +- Microsoft.EntityFrameworkCore 10.0.0 +- Microsoft.EntityFrameworkCore.Design 10.0.0 +- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 +- MediatR 12.4.1 +- Dapper 2.1.35 +- Polly 8.5.0 +- Polly.Extensions.Http 3.0.0 +- StackExchange.Redis 2.8.16 +- Microsoft.Extensions.Caching.StackExchangeRedis 9.0.0 + +--- + +## Configuration + +### Environment Variables / Settings +- `ConnectionStrings:DefaultConnection` or `DATABASE_URL` — PostgreSQL connection +- `ConnectionStrings:Redis` or `REDIS_URL` — Redis connection (optional, falls back to in-memory) +- `ZaloOA:AppId` — Zalo application ID +- `ZaloOA:SecretKey` — Zalo application secret key +- `ZaloOA:AccessToken` — Zalo OA access token (1-year validity) +- `ZaloOA:WebhookSecret` — Webhook signature verification secret +- `ZaloOA:BaseUrl` — Zalo OpenAPI base URL (default: https://openapi.zalo.me) +- `ZaloOA:TimeoutSeconds` — HTTP timeout (default: 30) +- `ZaloOA:MaxRetryAttempts` — Retry attempts (default: 3) + +### MediatR Pipeline Behaviors +1. LoggingBehavior — Request/response logging with Stopwatch +2. ValidatorBehavior — FluentValidation in pipeline +3. TransactionBehavior — Auto transaction for Commands (skips Queries) + +### Health Checks +- PostgreSQL health check via NpgSql + +### Seed Data (DbSeeder) +- **Chatbot Rules** (5 default rules): + 1. Welcome Greeting (priority 100) — Keywords: xin chào, hello, hi, chào + 2. FAQ: Price Inquiry (priority 80) — Keywords: giá, bao nhiêu, price, cost + 3. FAQ: Opening Hours (priority 80) — Keywords: giờ mở cửa, mấy giờ, opening, hours + 4. FAQ: Location (priority 80) — Keywords: địa chỉ, ở đâu, location, address + 5. Request Human Support (priority 90) — Keywords: gặp nhân viên, hỗ trợ, human, support, agent → ForwardToHuman action +- **Message Templates** (2 default templates): + 1. Welcome Message (welcome_001) — Parameter: customer_name (required), pre-approved + 2. Order Confirmation (order_confirm_001) — Parameters: order_id, total_amount, delivery_date (all required), pre-approved + +--- + +## DI Registration (All Repositories Registered) +- `IConversationRepository` -> `ConversationRepository` (scoped) +- `ICustomerRepository` -> `CustomerRepository` (scoped) +- `IChatbotRuleRepository` -> `ChatbotRuleRepository` (scoped) +- `IMessageTemplateRepository` -> `MessageTemplateRepository` (scoped) +- `IZaloOfficialAccountClient` -> `ZaloOfficialAccountClient` (HttpClient factory) +- `IZaloWebhookVerifier` -> `ZaloWebhookVerifier` (singleton) +- `IConversationCacheService` -> `ConversationCacheService` (scoped) +- `IChatbotRuleCacheService` -> `ChatbotRuleCacheService` (scoped) +- `IRequestManager` -> `RequestManager` (scoped) +- `IChatbotRulesService` -> `ChatbotRulesService` (registered in Program.cs) diff --git a/services/order-service-net/SERVICE_DOCS.md b/services/order-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..a37fc57b --- /dev/null +++ b/services/order-service-net/SERVICE_DOCS.md @@ -0,0 +1,520 @@ +# Order Service - Service Documentation + +> Auto-generated from source code audit. Last updated: 2026-03-13. + +## Overview + +**Order Service** is the central order processing microservice for the GoodGo POS platform. It orchestrates order creation, payment, fulfillment, returns, exchanges, and reporting across multiple business verticals (Restaurant/F&B, Retail, Spa/Services). + +- **Framework**: .NET 10.0, C# 14 +- **Architecture**: Clean Architecture + CQRS (MediatR 12.4.1) +- **Database**: PostgreSQL (Neon) via EF Core 10 (writes) + Dapper 2.1 (reads) +- **Real-time**: SignalR with Redis backplane + MessagePack protocol +- **Port**: 5017 (Development) +- **Base Route**: `api/v1/orders`, `api/v1/admin/orders`, `api/v1/reports` +- **SignalR Hub**: `/hubs/pos` +- **Health Checks**: `/health`, `/health/live`, `/health/ready` + +### Key Features +- Multi-vertical order processing via Strategy pattern (Physical, PreparedFood, Service) +- Multi-payment support (cash, card, VNPay, Momo, QR, bank transfer) +- Real-time POS/KDS notifications via SignalR +- Multi-tenant row-level security (EF Core global query filters + PostgreSQL RLS) +- Return and exchange workflows +- Revenue analytics, staff performance, and End-of-Day reports +- Idempotency support for duplicate request detection + +--- + +## API Endpoints + +### OrdersController (`api/v1/orders`) + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| POST | `/api/v1/orders` | Create a new order | `CreateOrderCommand` (body) | `201 Created` - `CreateOrderResult` | +| GET | `/api/v1/orders/{id}` | Get order by ID | `id` (path), `shopId` (query) | `200 OK` - `OrderDto` / `404` | +| GET | `/api/v1/orders` | List orders by shop (paginated) | `shopId`, `status?`, `fromDate?`, `toDate?`, `page`, `pageSize` (query) | `200 OK` - `PagedResult` | +| POST | `/api/v1/orders/{id}/pay` | Process payment | `id` (path), `shopId` (query), `PayOrderRequest` (body) | `200 OK` - `PayOrderResult` / `400` | +| POST | `/api/v1/orders/{id}/payment-callback` | Payment gateway callback | `id` (path), `PaymentCallbackRequest` (body) | `200 OK` - `CompleteOrderPaymentResult` / `400` | +| POST | `/api/v1/orders/{id}/cancel` | Cancel an order | `id` (path), `shopId` (query), `CancelOrderRequest` (body) | `200 OK` - `CancelOrderResult` | +| POST | `/api/v1/orders/{id}/complete` | Complete an order | `id` (path), `shopId` (query) | `200 OK` - `CompleteOrderResult` | +| GET | `/api/v1/orders/dashboard` | POS dashboard stats | `shopId`, `period?` ("today"/"7d"/"30d") (query) | `200 OK` - `PosDashboardDto` | +| GET | `/api/v1/orders/active-by-table` | Active orders grouped by table | `shopId` (query) | `200 OK` - `List` | +| POST | `/api/v1/orders/returns` | Create a return | `CreateReturnCommand` (body) | `201 Created` - `CreateReturnResult` / `400` | +| POST | `/api/v1/orders/exchanges` | Create an exchange | `CreateExchangeCommand` (body) | `201 Created` - `CreateExchangeResult` / `400` | +| GET | `/api/v1/orders/{orderId}/returns` | Get return history | `orderId` (path) | `200 OK` - `List` | +| GET | `/api/v1/orders/customer/{customerId}` | Get orders by customer | `customerId` (path), `page`, `pageSize` (query) | `200 OK` - `PagedResult` | + +### AdminOrdersController (`api/v1/admin/orders`) + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/api/v1/admin/orders` | List all orders (admin) | `shopId?`, `customerId?`, `status?`, `fromDate?`, `toDate?`, `minAmount?`, `maxAmount?`, `page`, `pageSize` (query) | `200 OK` - `PagedResult` | +| GET | `/api/v1/admin/orders/stats` | Order statistics | `shopId?`, `fromDate?`, `toDate?` (query) | `200 OK` - `OrderStatsDto` | +| GET | `/api/v1/admin/orders/export` | Export orders as CSV | `shopId?`, `fromDate?`, `toDate?` (query) | `200 OK` - CSV file download | + +### ReportsController (`api/v1/reports`) + +| Method | Route | Description | Request | Response | +|--------|-------|-------------|---------|----------| +| GET | `/api/v1/reports/revenue` | Revenue report (daily/weekly/monthly) | `shopId`, `period`, `fromDate?`, `toDate?` (query) | `200 OK` - `RevenueReportDto` | +| GET | `/api/v1/reports/top-products` | Top selling products | `shopId`, `limit?`, `fromDate?`, `toDate?` (query) | `200 OK` - `List` | +| GET | `/api/v1/reports/revenue-analytics` | Advanced revenue analytics | `shopId`, `startDate`, `endDate`, `period?` (query) | `200 OK` - `RevenueAnalyticsDto` | +| GET | `/api/v1/reports/staff-performance` | Staff performance metrics | `shopId`, `startDate`, `endDate` (query) | `200 OK` - `StaffPerformanceDto` | +| GET | `/api/v1/reports/eod` | End-of-Day report | `shopId`, `date?` (query) | `200 OK` - `EodReportDto` | +| POST | `/api/v1/reports/close-day` | Close business day | `CloseDayRequest` (body) | `200 OK` - `CloseDayResult` / `400` | + +### SignalR Hub (`/hubs/pos`) + +| Method | Direction | Description | +|--------|-----------|-------------| +| `JoinShop(shopId)` | Client -> Server | Join shop group for all POS updates | +| `JoinKds(shopId)` | Client -> Server | Join KDS group for kitchen updates | +| `JoinPos(shopId)` | Client -> Server | Join POS terminal group | +| `LeaveShop(shopId)` | Client -> Server | Leave shop group | +| `LeaveKds(shopId)` | Client -> Server | Leave KDS group | +| `LeavePos(shopId)` | Client -> Server | Leave POS terminal group | +| `OrderCreated` | Server -> Client | New order created notification | +| `OrderUpdated` | Server -> Client | Order updated notification | +| `OrderStatusChanged` | Server -> Client | Order status change notification | +| `KitchenTicketCreated` | Server -> Client | New kitchen ticket (to KDS) | +| `KitchenTicketUpdated` | Server -> Client | Kitchen ticket update (to KDS) | +| `PaymentCompleted` | Server -> Client | Payment completed notification | +| `TableStatusChanged` | Server -> Client | Table status change notification | + +**Groups**: `shop:{shopId}`, `kds:{shopId}`, `pos:{shopId}` +**Auth**: JWT required (token via query string `access_token` for WebSocket) +**Shop Access**: Validated via `shop_id` JWT claim (prevents cross-tenant access) + +--- + +## Commands + +### CreateOrderCommand +- **Input**: `ShopId` (Guid), `CustomerId?` (Guid?), `Items` (List\), `DiscountAmount?`, `DiscountType?`, `DiscountReference?`, `TableId?` +- **OrderItemRequest**: `ProductId`, `ProductName`, `ProductType` ("Physical"|"Service"|"PreparedFood"), `Quantity`, `UnitPrice`, `TrackInventory` +- **Logic**: Creates Order aggregate -> adds items -> validates each item via strategy (RetailStrategy/ServiceStrategy/FnbStrategy) -> executes each item via strategy (inventory deduction / kitchen ticket / booking) -> applies discount -> marks as Validated -> saves -> sends SignalR notification +- **Result**: `CreateOrderResult(OrderId, TotalAmount, Status)` +- **Validator**: ShopId required, Items non-empty, each item validated (ProductId, ProductName, ProductType in ["Physical","Service","PreparedFood"], Quantity > 0, UnitPrice >= 0) + +### PayOrderCommand +- **Input**: `OrderId`, `ShopId`, `PaymentMethod` ("cash"|"card"|"vnpay"|"momo"|"qr"|"transfer"), `AmountTendered?`, `ReturnUrl?`, `IpAddress?` +- **Logic**: Loads order -> verifies shop ownership -> routes to payment flow: + - **Cash**: Validates sufficient amount -> generates CASH-* transaction ID -> calculates change -> marks Paid+Processing + - **Card/QR/Transfer**: Generates CARD-* transaction ID -> marks Paid+Processing (POS terminal confirmed) + - **VNPay/Momo**: Calls wallet-service CreatePaymentAsync -> marks PaymentPending -> returns payment URL for redirect +- **Result**: `PayOrderResult(Success, Status, PaymentUrl?, ChangeAmount?, TransactionId?, ErrorMessage?)` +- **Validator**: OrderId/ShopId required, PaymentMethod in supported list, AmountTendered required for cash, ReturnUrl required for vnpay/momo + +### CompleteOrderPaymentCommand +- **Input**: `OrderId`, `GatewayTransactionId`, `IsSuccess`, `GatewayResponseCode?` +- **Logic**: Called by wallet-service callback after online payment. If success -> CompletePayment + MarkAsProcessing. If failure -> Cancel order. +- **Result**: `CompleteOrderPaymentResult(Success, Status, ErrorMessage?)` +- **Validator**: OrderId required, GatewayTransactionId required (max 200), GatewayResponseCode max 50 + +### CancelOrderCommand +- **Input**: `OrderId`, `ShopId`, `Reason` +- **Logic**: Loads order -> verifies shop ownership -> calls Order.Cancel(reason) (domain validates not Completed/already Cancelled) -> saves -> sends SignalR notification +- **Result**: `CancelOrderResult(Success, Status)` +- **Validator**: OrderId/ShopId required, Reason required (max 500) + +### CompleteOrderCommand +- **Input**: `OrderId`, `ShopId` +- **Logic**: Loads order -> verifies shop ownership -> calls Order.MarkAsCompleted() (domain validates must be Processing) -> saves -> sends SignalR notification +- **Result**: `CompleteOrderResult(Success, Status)` +- **Validator**: OrderId/ShopId required + +### CreateReturnCommand +- **Input**: `ShopId`, `OriginalOrderId`, `Items` (List\), `Reason` +- **ReturnItemDto**: `OrderItemId`, `Quantity`, `Reason?` +- **Logic**: Validates original order (must be Completed/Paid, same shop) -> creates new return order with items from original -> marks Validated -> ProcessReturn (sets isReturn, returnReason, originalOrderId) -> saves +- **Result**: `CreateReturnResult(Success, ReturnOrderId?, RefundAmount?, ErrorMessage?)` +- **Validator**: ShopId/OriginalOrderId required, Reason required (max 1000), Items non-empty, each item: OrderItemId required, Quantity > 0 + +### CreateExchangeCommand +- **Input**: `ShopId`, `OriginalOrderId`, `ReturnItems` (List\), `NewItems` (List\), `Reason` +- **ExchangeItemDto**: `ProductId`, `Quantity`, `UnitPrice` +- **Logic**: Validates original order -> creates return order (step 1) -> creates new order with replacement items (step 2) -> calculates price difference -> saves both in same transaction -> raises OrderExchangedDomainEvent +- **Result**: `CreateExchangeResult(Success, ReturnOrderId?, NewOrderId?, PriceDifference?, ErrorMessage?)` +- **Validator**: ShopId/OriginalOrderId required, Reason required, ReturnItems/NewItems non-empty with item-level validation + +### CloseDayCommand +- **Input**: `ShopId`, `CloseDate` +- **Logic**: Checks for pending/in-progress orders (status 1,2,3,4,7) via Dapper -> generates EOD report via GetEodReportQuery -> returns report with warning if pending orders exist +- **Result**: `CloseDayResult(Success, Report?, Message?, PendingOrderCount)` +- **Validator**: ShopId required, CloseDate required and not in future + +--- + +## Queries + +### GetOrderByIdQuery +- **Input**: `OrderId`, `ShopId` +- **Logic**: Dapper query joining `orders` + `order_statuses` + `order_items`, filtered by OrderId and ShopId +- **Result**: `OrderDto` (with items) or null + +### ListOrdersByShopQuery +- **Input**: `ShopId`, `Status?`, `FromDate?`, `ToDate?`, `Page`, `PageSize` +- **Logic**: Dapper query with dynamic WHERE clause, paginated (LIMIT/OFFSET), ordered by created_at DESC +- **Result**: `PagedResult` + +### AdminListOrdersQuery +- **Input**: `ShopId?`, `CustomerId?`, `Status?`, `FromDate?`, `ToDate?`, `MinAmount?`, `MaxAmount?`, `Page`, `PageSize` +- **Logic**: Dapper query with all optional filters, no shop restriction (admin access) +- **Result**: `PagedResult` + +### GetOrdersByCustomerQuery +- **Input**: `CustomerId`, `Page`, `PageSize` +- **Logic**: Dapper query filtered by customer_id, paginated +- **Result**: `PagedResult` + +### GetActiveTableOrdersQuery +- **Input**: `ShopId` +- **Logic**: Gets orders with status_id=2 (Validated) that have table_id, then batch-loads items via ANY(@OrderIds) +- **Result**: `List` (each with items) + +### GetOrderReturnsQuery +- **Input**: `OrderId` +- **Logic**: Uses EF Core repository `GetReturnsByOriginalOrderIdAsync` to find return orders +- **Result**: `List` + +### GetOrderStatsQuery +- **Input**: `ShopId?`, `FromDate?`, `ToDate?` +- **Logic**: Dapper aggregate (COUNT, SUM, AVG) + GROUP BY status name +- **Result**: `OrderStatsDto(TotalOrders, TotalRevenue, AverageOrderValue, OrdersByStatus)` + +### ExportOrdersQuery +- **Input**: `ShopId?`, `FromDate?`, `ToDate?` +- **Logic**: Dapper query all matching orders -> generates CSV string +- **Result**: `ExportOrdersResult(FileName, CsvContent)` (served as file download) + +### GetPosDashboardQuery +- **Input**: `ShopId`, `Period?` ("today"|"7d"|"30d") +- **Logic**: Multiple Dapper queries: aggregate stats, top 5 products, payment breakdown by status, hourly revenue, last 10 orders +- **Result**: `PosDashboardDto(Revenue, OrderCount, ItemsSold, AvgOrderValue, PopularItems, PaymentBreakdown, HourlyRevenue, RecentOrders)` + +### GetRevenueReportQuery +- **Input**: `Period` ("daily"|"weekly"|"monthly"), `ShopId`, `FromDate?`, `ToDate?` +- **Logic**: Dapper with DATE_TRUNC grouping +- **Result**: `RevenueReportDto(Period, ShopId, TotalRevenue, TotalOrders, Data[])` + +### GetTopProductsQuery +- **Input**: `ShopId`, `Limit`, `FromDate?`, `ToDate?` +- **Logic**: Dapper GROUP BY product_id, product_name, ORDER BY quantity DESC +- **Result**: `List` + +### GetRevenueAnalyticsQuery +- **Input**: `ShopId`, `StartDate`, `EndDate`, `Period` +- **Logic**: 6 Dapper queries: current aggregate, previous period revenue, trends, payment method breakdown, top 10 products, vertical breakdown. Calculates growth percentage. +- **Result**: `RevenueAnalyticsDto(TotalRevenue, PreviousPeriodRevenue, GrowthPercentage, TotalOrders, AverageOrderValue, Trends, PaymentMethods, TopProducts, VerticalBreakdown)` +- **Validator**: ShopId required, StartDate < EndDate, EndDate not future, Period in ["daily","weekly","monthly"] + +### GetStaffPerformanceQuery +- **Input**: `ShopId`, `StartDate`, `EndDate` +- **Logic**: Dapper GROUP BY staff_id, staff_name with FILTER for completed/cancelled, calculates completion rate and avg handling time +- **Result**: `StaffPerformanceDto(Staff[], ShopAverage)` +- **Validator**: ShopId required, StartDate < EndDate, EndDate not future + +### GetEodReportQuery +- **Input**: `ShopId`, `ReportDate` +- **Logic**: 4 Dapper queries: aggregate (total/completed/cancelled/revenue by payment type/discounts), payment breakdown, top 10 items, hourly revenue +- **Result**: `EodReportDto(ReportDate, ShopId, TotalOrders, CompletedOrders, CancelledOrders, TotalRevenue, CashRevenue, CardRevenue, OnlineRevenue, DiscountTotal, PaymentBreakdown, TopItems, HourlyRevenue)` +- **Validator**: ShopId required, ReportDate required and not future + +--- + +## Domain Model + +### Order (Aggregate Root) + +**Table**: `orders` + +**Private Fields / Properties**: +| Field | Type | Description | +|-------|------|-------------| +| `_shopId` / `ShopId` | Guid | Shop that owns the order | +| `_customerId` / `CustomerId` | Guid? | Customer (null for walk-in) | +| `_tableId` / `TableId` | Guid? | Table for dine-in orders | +| `StatusId` | int | FK to order_statuses | +| `_status` / `Status` | OrderStatus | Resolved from StatusId | +| `_totalAmount` / `TotalAmount` | decimal | Calculated: sum(items) - discount | +| `_discountAmount` / `DiscountAmount` | decimal | Discount applied | +| `_discountType` / `DiscountType` | string? | Discount type (e.g. "percentage", "fixed") | +| `_discountReference` / `DiscountReference` | string? | Promotion/coupon reference | +| `_paymentMethod` / `PaymentMethod` | string? | "cash", "card", "vnpay", "momo", "qr", "transfer" | +| `_transactionId` / `TransactionId` | string? | External transaction ID | +| `_amountTendered` / `AmountTendered` | decimal? | Amount customer paid (cash) | +| `_changeAmount` / `ChangeAmount` | decimal? | Change returned (cash) | +| `_isReturn` / `IsReturn` | bool | Whether this is a return order | +| `_returnReason` / `ReturnReason` | string? | Reason for return | +| `_returnedAt` / `ReturnedAt` | DateTime? | When return was processed | +| `_originalOrderId` / `OriginalOrderId` | Guid? | Original order (for returns/exchanges) | +| `_createdAt` / `CreatedAt` | DateTime | Creation timestamp (UTC) | +| `_updatedAt` / `UpdatedAt` | DateTime? | Last update timestamp (UTC) | +| `_items` / `Items` | List\ | Line items (owned collection) | + +**Behavior Methods**: +| Method | Transitions | Domain Event | Validation | +|--------|-------------|--------------|------------| +| `Order(shopId, customerId?, tableId?)` | -> Draft | `OrderCreatedDomainEvent` | ShopId != empty | +| `AddItem(item)` | (Draft only) | - | Status must be Draft | +| `MarkAsValidated()` | Draft -> Validated | - | Must be Draft, must have items | +| `MarkAsPaid(method, txnId, amountTendered?)` | Validated/PaymentPending -> Paid | `OrderPaidDomainEvent` | Must be Validated or PaymentPending | +| `MarkAsPaymentPending(method, txnId)` | Validated -> PaymentPending | `OrderPaymentPendingDomainEvent` | Must be Validated | +| `CompletePayment(gatewayTxnId)` | PaymentPending -> Paid | `OrderPaidDomainEvent` | Must be PaymentPending | +| `MarkAsProcessing()` | Paid -> Processing | - | Must be Paid | +| `MarkAsCompleted()` | Processing -> Completed | `OrderCompletedDomainEvent` | Must be Processing | +| `Cancel(reason)` | Any (except Completed/Cancelled) -> Cancelled | `OrderCancelledDomainEvent` | Not Completed, not already Cancelled | +| `ProcessReturn(reason, originalOrderId?)` | -> Returned | `OrderReturnedDomainEvent` | Reason required | +| `ApplyDiscount(amount, type?, reference?)` | - | - | Amount >= 0 | + +### OrderItem (Entity, Owned by Order) + +**Table**: `order_items` + +| Field | Type | Description | +|-------|------|-------------| +| `Id` | Guid | Primary key | +| `_productId` / `ProductId` | Guid | Product from Catalog Service | +| `_productName` / `ProductName` | string | Snapshot of product name | +| `_productType` / `ProductType` | string | "Physical", "Service", "PreparedFood" | +| `_quantity` / `Quantity` | int | Quantity ordered | +| `_unitPrice` / `UnitPrice` | decimal | Unit price at order time | +| `TotalPrice` | decimal | Computed: Quantity * UnitPrice | +| `_status` / `Status` | string | "Pending", "Completed", "Failed" | +| `_trackInventory` / `TrackInventory` | bool | Auto-deduct inventory flag (default true) | +| `_metadata` / `Metadata` | string? | JSON metadata (e.g. appointment details) | + +**Methods**: `MarkAsCompleted()`, `MarkAsFailed()` + +### OrderStatus (Enumeration) + +| Id | Name | Description | +|----|------|-------------| +| 1 | Draft | Created but not validated | +| 2 | Validated | All items validated and available | +| 3 | Paid | Payment processed successfully | +| 4 | Processing | Being fulfilled | +| 5 | Completed | Successfully completed | +| 6 | Cancelled | Cancelled | +| 7 | PaymentPending | Waiting for online payment gateway | +| 8 | Returned | Items returned (defined in code, not seeded) | + +### Domain Events + +| Event | Raised By | Payload | +|-------|-----------|---------| +| `OrderCreatedDomainEvent` | Order constructor | `Order` | +| `OrderPaidDomainEvent` | `MarkAsPaid`, `CompletePayment` | `Order` | +| `OrderCompletedDomainEvent` | `MarkAsCompleted` | `Order` | +| `OrderPaymentPendingDomainEvent` | `MarkAsPaymentPending` | `Order` | +| `OrderCancelledDomainEvent` | `Cancel` | `Order`, `Reason` | +| `OrderReturnedDomainEvent` | `ProcessReturn` | `Order` | +| `OrderExchangedDomainEvent` | CreateExchangeCommandHandler | `ReturnOrder`, `NewOrder` | + +--- + +## Database Schema + +### Table: `orders` + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | uuid | NOT NULL (PK) | Order ID (generated in code) | +| `shop_id` | uuid | NOT NULL | Shop tenant ID | +| `customer_id` | uuid | nullable | Customer ID | +| `table_id` | uuid | nullable | Table ID (dine-in) | +| `status_id` | int | NOT NULL | FK to order_statuses | +| `total_amount` | decimal(18,2) | NOT NULL | Order total | +| `notes` | varchar(2000) | nullable | Notes | +| `discount_amount` | decimal(18,2) | NOT NULL | Discount amount | +| `discount_type` | varchar(50) | nullable | Discount type | +| `discount_reference` | varchar(255) | nullable | Promotion/coupon ref | +| `payment_method` | varchar(50) | nullable | Payment method used | +| `transaction_id` | varchar(255) | nullable | External transaction ID | +| `amount_tendered` | decimal(18,2) | nullable | Amount customer paid | +| `change_amount` | decimal(18,2) | nullable | Change returned | +| `is_return` | boolean | NOT NULL (default false) | Return order flag | +| `return_reason` | varchar(1000) | nullable | Return reason | +| `returned_at` | timestamp with time zone | nullable | Return timestamp | +| `original_order_id` | uuid | nullable | Original order (returns) | +| `created_at` | timestamp with time zone | NOT NULL | Creation time | +| `updated_at` | timestamp with time zone | nullable | Last update time | + +**Indexes**: +- `ix_orders_shop_id` on `shop_id` +- `ix_orders_customer_id` on `customer_id` +- `ix_orders_status_id` on `status_id` +- `ix_orders_created_at` on `created_at` +- `ix_orders_original_order_id` on `original_order_id` + +**Note**: Dapper queries reference `o.staff_id`, `o.staff_name`, `o.completed_at`, `o.vertical` columns that are NOT defined in the EF entity configuration. These queries will fail at runtime unless the columns exist in the database via manual migration. + +### Table: `order_items` (Owned by Order) + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | uuid | NOT NULL (PK) | Item ID | +| `order_id` | uuid | NOT NULL (FK) | Parent order | +| `product_id` | uuid | NOT NULL | Product from catalog | +| `product_name` | varchar(255) | NOT NULL | Product name snapshot | +| `product_type` | varchar(50) | NOT NULL | "Physical"/"Service"/"PreparedFood" | +| `quantity` | int | NOT NULL | Quantity | +| `unit_price` | decimal(18,2) | NOT NULL | Unit price | +| `status` | varchar(50) | NOT NULL | "Pending"/"Completed"/"Failed" | +| `track_inventory` | boolean | NOT NULL (default true) | Auto inventory deduction | +| `metadata` | jsonb | nullable | Additional JSON data | + +### Table: `order_statuses` (Lookup/Seed) + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| `id` | int | NOT NULL (PK) | Status ID | +| `name` | varchar(50) | NOT NULL | Status name | + +**Seeded data**: Draft(1), Validated(2), Paid(3), Processing(4), Completed(5), Cancelled(6), PaymentPending(7) + +**Note**: Returned(8) is defined in code but NOT included in seed data. + +### Migrations + +| Migration | Date | Description | +|-----------|------|-------------| +| `20260117175742_InitialOrder` | 2026-01-17 | Initial schema: orders, order_items, order_statuses (1-6) | +| `20260305004928_AddTableIdAndDiscountFields` | 2026-03-05 | Added table_id, discount fields | +| `20260306175520_PhaseTwo` | 2026-03-06 | Added payment fields, return fields, track_inventory, PaymentPending(7) status, original_order_id index | + +--- + +## Strategy Pattern (Line Item Processing) + +| Strategy | SupportedType | Validate | Execute | +|----------|---------------|----------|---------| +| `RetailStrategy` | "Physical" | Checks inventory availability via InventoryServiceClient | Deducts stock via InventoryServiceClient | +| `FnbStrategy` | "PreparedFood" | Always returns true (no pre-check) | Creates kitchen ticket via FnbEngineClient + deducts recipe ingredients via InventoryServiceClient | +| `ServiceStrategy` | "Service" | Checks availability via BookingServiceClient (parses metadata for StartTime/DurationMinutes) | Creates appointment via BookingServiceClient | + +**Interface**: `ILineItemStrategy` (Domain layer) with `SupportedType`, `ValidateAsync`, `ExecuteAsync` +**Factory**: `StrategyFactory` resolves strategy by product type (case-insensitive) + +--- + +## Multi-Tenant Security + +### EF Core Global Query Filter +- `Order` entity filtered by `_shopId == tenantProvider.GetCurrentShopId()` +- Bypassed when: `_tenantProvider` is null, `ShouldBypassTenantFilter()` returns true, or `GetCurrentShopId()` returns null + +### PostgreSQL RLS (Defense-in-Depth) +- `TenantMiddleware` sets `SET LOCAL app.current_shop_id` and `app.current_merchant_id` session variables +- Runs after authentication middleware +- Skipped for service-to-service calls (`X-Service-Call: internal`) and admin users + +### Tenant Provider +- `HttpContextTenantProvider` extracts tenant context from: + 1. JWT claim `shop_id` + 2. HTTP header `X-Shop-Id` (POS fallback) +- Admin detection via `role` claims: "admin", "system", "superadmin" +- `OrderTenantProviderAdapter` bridges `ITenantProvider` (API) to `IOrderTenantProvider` (Infrastructure) + +--- + +## Dependencies (Cross-Service Communication) + +### HTTP Clients (with Polly retry + circuit breaker) + +| Client | Target Service | Default URL | Timeout | Usage | +|--------|---------------|-------------|---------|-------| +| `CatalogServiceClient` | Catalog Service | `http://catalog-service-net:8080` | 10s | Get product details | +| `InventoryServiceClient` | Inventory Service | `http://inventory-service-net:8080` | 10s | Check/deduct stock, deduct by ID | +| `FnbEngineClient` | F&B Engine | `http://fnb-engine-net:8080` | 10s | Kitchen tickets, recipe lookup | +| `BookingServiceClient` | Booking Service | `http://booking-service-net:8080` | 10s | Availability check, create appointment | +| `WalletServiceClient` | Wallet Service | `http://wallet-service-net:8080` | 15s | Create online payment (VNPay/Momo) | + +**Polly Policies**: Retry 3x with exponential backoff (2^attempt seconds), Circuit breaker (5 failures, 30s break) + +--- + +## MediatR Pipeline + +``` +Request -> LoggingBehavior -> ValidatorBehavior -> TransactionBehavior -> Handler +``` + +1. **LoggingBehavior**: Logs request name and elapsed time (Stopwatch) +2. **ValidatorBehavior**: Runs all registered FluentValidation validators, throws `ValidationException` on failure +3. **TransactionBehavior**: Auto-wraps Commands in DB transaction (skips Queries by name convention `*Query`), uses `ExecutionStrategy` for retry-safe transactions + +--- + +## Configuration + +### appsettings.json + +| Key | Description | Default | +|-----|-------------|---------| +| `ConnectionStrings:DefaultConnection` | PostgreSQL connection string | Neon PostgreSQL | +| `Redis:ConnectionString` | Redis for SignalR backplane | `localhost:6379` | +| `SignalR:KeepAliveInterval` | SignalR keep-alive (seconds) | 15 | +| `SignalR:ClientTimeoutInterval` | Client timeout (seconds) | 30 | +| `SignalR:StatefulReconnectBufferSize` | Reconnect buffer size | 32768 | +| `SignalR:EnableMessagePack` | Enable MessagePack protocol | true | +| `Jwt:Authority` | JWT issuer authority | `http://localhost:5001` | +| `Jwt:Secret` | JWT secret key | (configured) | +| `AllowedOrigins` | CORS allowed origins | localhost:3000, 5173, 5000 | +| `Services:CatalogService` | Catalog service URL | `http://catalog-service-net:8080` | +| `Services:InventoryService` | Inventory service URL | `http://inventory-service-net:8080` | +| `Services:BookingService` | Booking service URL | `http://booking-service-net:8080` | +| `Services:FnbEngine` | F&B engine URL | `http://fnb-engine-net:8080` | +| `Services:WalletService` | Wallet service URL | `http://wallet-service-net:8080` | + +### NuGet Dependencies (API) + +| Package | Version | +|---------|---------| +| MediatR | 12.4.1 | +| FluentValidation | 11.11.0 | +| FluentValidation.DependencyInjectionExtensions | 11.11.0 | +| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.1 | +| Swashbuckle.AspNetCore | 7.2.0 | +| AspNetCore.HealthChecks.NpgSql | 8.0.2 | +| AspNetCore.HealthChecks.Redis | 8.0.1 | +| Hellang.Middleware.ProblemDetails | 6.5.1 | +| Serilog.AspNetCore | 8.0.3 | +| Microsoft.AspNetCore.SignalR.StackExchangeRedis | 9.0.0 | +| Microsoft.AspNetCore.SignalR.Protocols.MessagePack | 9.0.0 | +| Microsoft.EntityFrameworkCore.Design | 10.0.0 | + +--- + +## Tests + +### Unit Tests (`tests/OrderService.UnitTests/`) + +| Test Class | Tests | +|------------|-------| +| `OrderAggregateTests` | `CreateOrder_WithValidShopId_ShouldStartInDraftStatus`, `AddItem_InDraftStatus_ShouldRecalculateTotalAmount`, `MarkAsValidated_WithoutItems_ShouldThrowDomainException`, `FullLifecycle_DraftToCompleted_ShouldUpdateStatusSequentially`, `Cancel_CompletedOrder_ShouldThrowDomainException` | + +### Functional Tests (`tests/OrderService.FunctionalTests/`) + +| Test Class | Description | +|------------|-------------| +| `HealthChecksControllerTests` | Health endpoint tests | +| `OrdersControllerTests` | API endpoint integration tests | +| `CustomWebApplicationFactory` | Test server setup with InMemory DB | +| `TestAuthHandler` | Fake auth handler for tests | + +--- + +## Known Issues / Gaps + +1. **Missing DB columns**: Dapper queries in `GetStaffPerformanceQuery` reference `o.staff_id`, `o.staff_name`, `o.completed_at` columns, and `GetRevenueAnalyticsQuery` references `o.vertical` column. These are NOT defined in Order entity or EF configurations -- queries will fail at runtime unless columns exist via manual migration. + +2. **Returned status not seeded**: `OrderStatus.Returned` (Id=8) is defined in code but NOT included in the `OrderStatusEntityTypeConfiguration` seed data. Return orders will fail to load if status is queried via JOIN to `order_statuses`. + +3. **Exchange item names**: `CreateExchangeCommandHandler` hardcodes product name as "Exchange Item" and type as "Physical" for new items in exchanges -- these should be resolved from the catalog or passed by the frontend. + +4. **No [Authorize] on controllers**: `OrdersController`, `AdminOrdersController`, and `ReportsController` do not have `[Authorize]` attributes. Authentication is configured in Program.cs but not enforced at controller level (only the SignalR hub has `[Authorize]`). + +5. **Template remnants**: Build artifacts contain references to "MyService" namespace (from `_template_dot_net`), indicating the service was scaffolded from the template. diff --git a/services/promotion-service-net/SERVICE_DOCS.md b/services/promotion-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..1f3bb4b0 --- /dev/null +++ b/services/promotion-service-net/SERVICE_DOCS.md @@ -0,0 +1,637 @@ +# PromotionService - Service Documentation + +Generated from actual source code audit on 2026-03-13. + +--- + +## 1. Overview + +**Purpose**: Manages marketing campaigns with vouchers for merchants. Supports campaign lifecycle (draft, active, paused, completed, cancelled), voucher generation/claiming/redemption, and escrow integration with the Wallet Service. + +**Port**: 5008 (local development via launchSettings.json), 8080 (Docker container) + +**Database**: PostgreSQL - `promotion_service` on Neon (cloud) +- Connection string configured via `ConnectionStrings:DefaultConnection` or `DATABASE_URL` env var +- Cloud host: `ep-holy-glitter-a4hongg7-pooler.us-east-1.aws.neon.tech` + +**Framework**: .NET 10.0 (C# 14), Clean Architecture + CQRS (MediatR) + +**Solution file**: `PromotionService.slnx` + +**Migration**: Single migration `20260117144846_InitialCreate` - auto-applied on startup via `dbContext.Database.MigrateAsync()` + +--- + +## 2. API Endpoints + +### 2.1 CampaignsController (`api/v1/campaigns`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/api/v1/campaigns` | Yes | Create a new campaign | +| GET | `/api/v1/campaigns/{id}` | No | Get campaign by ID | +| GET | `/api/v1/campaigns?merchantId=&activeOnly=` | No | Get campaigns list (optional filters) | +| POST | `/api/v1/campaigns/{id}/activate` | Yes | Activate a campaign | +| POST | `/api/v1/campaigns/{id}/pause` | Yes | Pause a campaign | +| POST | `/api/v1/campaigns/{id}/cancel` | Yes | Cancel a campaign (releases escrow) | + +### 2.2 VouchersController (`api/v1/vouchers`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/api/v1/vouchers/claim` | Yes | Claim a free voucher | +| GET | `/api/v1/vouchers/validate/{code}?userId=` | Yes | Validate a voucher code for a user | +| POST | `/api/v1/vouchers/redeem` | Yes | Redeem a voucher against an order | +| GET | `/api/v1/vouchers/user/{userId}` | Yes | Get all vouchers owned by a user | + +### 2.3 AdminCampaignsController (`api/v1/admin/campaigns`) + +All endpoints require `[Authorize]`. + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/admin/campaigns?pageNumber=&pageSize=&merchantId=&status=&searchTerm=` | Yes | Paginated campaign list with filters | +| GET | `/api/v1/admin/campaigns/{id}/statistics` | Yes | Campaign statistics (voucher counts, redemption totals, utilization rate) | +| GET | `/api/v1/admin/campaigns/{id}/vouchers?pageNumber=&pageSize=&status=` | Yes | Paginated vouchers for a campaign | +| PUT | `/api/v1/admin/campaigns/{id}` | Yes | Update campaign details (name, description, dates, maxPerUser) | +| POST | `/api/v1/admin/campaigns/{id}/complete` | Yes | Force complete a campaign | +| DELETE | `/api/v1/admin/campaigns/{id}` | Yes | Soft delete (cancels) a campaign | + +### 2.4 AdminVouchersController (`api/v1/admin/vouchers`) + +All endpoints require `[Authorize]`. + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/admin/vouchers?pageNumber=&pageSize=&campaignId=&userId=&status=&codeSearch=` | Yes | Paginated voucher list with filters | +| GET | `/api/v1/admin/vouchers/search?q=` | Yes | Search vouchers by code (NOTE: handler not implemented) | +| GET | `/api/v1/admin/vouchers/by-user/{userId}?pageNumber=&pageSize=` | Yes | Vouchers by user | +| POST | `/api/v1/admin/vouchers/{id}/revoke` | Yes | Revoke a voucher (marks as expired) | +| POST | `/api/v1/admin/vouchers/{id}/extend` | Yes | Extend voucher expiry by N days | + +### 2.5 AdminRedemptionsController (`api/v1/admin/redemptions`) + +All endpoints require `[Authorize]`. + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/admin/redemptions?pageNumber=&pageSize=&campaignId=&voucherId=&userId=&dateFrom=&dateTo=` | Yes | Paginated redemption list with filters | +| GET | `/api/v1/admin/redemptions/by-campaign/{campaignId}?pageNumber=&pageSize=` | Yes | Redemptions by campaign | +| GET | `/api/v1/admin/redemptions/by-voucher/{voucherId}?pageNumber=&pageSize=` | Yes | Redemptions by voucher | +| GET | `/api/v1/admin/redemptions/statistics?campaignId=` | Yes | Redemption statistics (totals, averages, today/week/month counts) | + +### 2.6 Health Check Endpoints + +| Route | Description | +|-------|-------------| +| `/health` | Full health check (PostgreSQL + Wallet Service) | +| `/health/live` | Liveness probe (always passes if app is running) | +| `/health/ready` | Readiness probe (checks all dependencies) | + +--- + +## 3. Commands + +### 3.1 Campaign Commands + +#### CreateCampaignCommand +- **Parameters**: MerchantId (Guid), MerchantWalletId (Guid), Name (string), Description (string?), BackingAssetType (string: "Point"/"Currency"), BackingAssetCode (string), FaceValue (decimal), AcquisitionType (string: "Free"/"ExchangePoints"/"Purchase"), AcquisitionPrice (decimal), TotalVouchers (int), StartDate (DateTime), EndDate (DateTime), VoucherValidityDays (int, default 30), MaxPerUser (int, default 1) +- **Returns**: CampaignDto +- **Handler**: CreateCampaignCommandHandler +- **Logic**: Parses enums, creates Campaign aggregate, optionally creates escrow hold via Wallet Service, generates voucher codes, persists. Raises `CampaignCreatedDomainEvent`. + +#### ActivateCampaignCommand +- **Parameters**: CampaignId (Guid) +- **Returns**: bool +- **Handler**: ActivateCampaignCommandHandler +- **Logic**: Finds campaign, calls `campaign.Activate()`. Requires escrow to be set. Raises `CampaignActivatedDomainEvent`. + +#### PauseCampaignCommand +- **Parameters**: CampaignId (Guid) +- **Returns**: bool +- **Handler**: PauseCampaignCommandHandler +- **Logic**: Finds campaign, calls `campaign.Pause()`. Only works on Active campaigns. + +#### CancelCampaignCommand +- **Parameters**: CampaignId (Guid) +- **Returns**: bool +- **Handler**: CancelCampaignCommandHandler +- **Logic**: Releases escrow hold via Wallet Service if exists, then calls `campaign.Cancel()`. Raises `CampaignCancelledDomainEvent`. + +#### UpdateCampaignCommand (Admin) +- **Parameters**: CampaignId (Guid), Name (string?), Description (string?), StartDate (DateTime?), EndDate (DateTime?), MaxPerUser (int?) +- **Returns**: bool +- **Handler**: UpdateCampaignCommandHandler + +#### CompleteCampaignCommand (Admin) +- **Parameters**: CampaignId (Guid) +- **Returns**: bool +- **Handler**: CompleteCampaignCommandHandler +- **Logic**: Force completes campaign. Only from Active or Paused state. + +#### DeleteCampaignCommand (Admin) +- **Parameters**: CampaignId (Guid) +- **Returns**: bool +- **Handler**: DeleteCampaignCommandHandler +- **Logic**: Soft delete implemented as Cancel(). + +### 3.2 Voucher Commands + +#### ClaimVoucherCommand +- **Parameters**: CampaignId (Guid), UserId (Guid) +- **Returns**: VoucherDto +- **Handler**: ClaimVoucherCommandHandler +- **Logic**: Verifies campaign is Free acquisition type, calls `campaign.IssueVoucher()`, persists. Raises `VoucherClaimedDomainEvent`. + +#### ExchangeVoucherCommand +- **Parameters**: CampaignId (Guid), UserId (Guid), UserWalletId (Guid) +- **Returns**: VoucherDto +- **Handler**: NOT IMPLEMENTED (command defined, no handler) + +#### PurchaseVoucherCommand +- **Parameters**: CampaignId (Guid), UserId (Guid), UserWalletId (Guid) +- **Returns**: VoucherDto +- **Handler**: NOT IMPLEMENTED (command defined, no handler) + +#### RedeemVoucherCommand +- **Parameters**: VoucherCode (string), UserId (Guid), OrderId (Guid?), OrderAmount (decimal) +- **Returns**: RedemptionDto +- **Handler**: RedeemVoucherCommandHandler +- **Logic**: Finds voucher by code, verifies ownership and validity, calculates amounts (min of order amount and voucher value), executes escrow via Wallet Service, creates Redemption record. Surplus refunded to merchant. + +#### RevokeVoucherCommand (Admin) +- **Parameters**: VoucherId (Guid), Reason (string) +- **Returns**: bool +- **Handler**: RevokeVoucherCommandHandler +- **Logic**: Marks voucher as Expired (revoke = expire). + +#### ExtendVoucherExpiryCommand (Admin) +- **Parameters**: VoucherId (Guid), AdditionalDays (int) +- **Returns**: bool +- **Handler**: ExtendVoucherExpiryCommandHandler + +--- + +## 4. Queries + +### 4.1 Public Queries + +#### GetCampaignQuery +- **Parameters**: CampaignId (Guid) +- **Returns**: CampaignDto? (null if not found) +- **Handler**: GetCampaignQueryHandler + +#### GetCampaignsQuery +- **Parameters**: MerchantId (Guid?), ActiveOnly (bool) +- **Returns**: IEnumerable\ +- **Handler**: GetCampaignsQueryHandler +- **Logic**: If ActiveOnly=true, returns active campaigns within date range. If MerchantId provided, returns by merchant. Otherwise defaults to active campaigns. + +#### GetCampaignStatisticsQuery +- **Parameters**: CampaignId (Guid) +- **Returns**: CampaignStatisticsDto? +- **Handler**: NOT IMPLEMENTED (query defined, no handler) + +#### ValidateVoucherQuery +- **Parameters**: VoucherCode (string), UserId (Guid) +- **Returns**: VoucherValidationDto +- **Handler**: ValidateVoucherQueryHandler +- **Logic**: Checks voucher exists, ownership matches, and is valid for redemption. Returns validation result with remaining value and campaign name. + +#### GetUserVouchersQuery +- **Parameters**: UserId (Guid) +- **Returns**: IEnumerable\ +- **Handler**: GetUserVouchersQueryHandler + +### 4.2 Admin Queries + +#### GetAllCampaignsQuery +- **Parameters**: PageNumber (int), PageSize (int), MerchantId (Guid?), Status (string?), SearchTerm (string?) +- **Returns**: PaginatedResponse\ +- **Handler**: GetAllCampaignsQueryHandler (uses PromotionServiceContext directly) + +#### GetAdminCampaignStatisticsQuery +- **Parameters**: CampaignId (Guid) +- **Returns**: AdminCampaignStatisticsDto? +- **Handler**: GetAdminCampaignStatisticsQueryHandler +- **Logic**: Loads campaign with vouchers, loads redemptions, calculates claimed/redeemed/expired counts, total redeemed/refunded values, utilization rate. + +#### GetCampaignVouchersQuery +- **Parameters**: CampaignId (Guid), PageNumber (int), PageSize (int), Status (string?) +- **Returns**: PaginatedResponse\ +- **Handler**: NOT IMPLEMENTED (query defined, no handler -- uses GetAllVouchersQueryHandler with CampaignId filter via controller) + +#### GetAllVouchersQuery +- **Parameters**: PageNumber (int), PageSize (int), CampaignId (Guid?), UserId (Guid?), Status (string?), CodeSearch (string?) +- **Returns**: PaginatedResponse\ +- **Handler**: GetAllVouchersQueryHandler (uses join with Campaigns for CampaignName) + +#### SearchVouchersQuery +- **Parameters**: SearchTerm (string) +- **Returns**: IEnumerable\ +- **Handler**: NOT IMPLEMENTED (query defined, no handler) + +#### GetAllRedemptionsQuery +- **Parameters**: PageNumber (int), PageSize (int), CampaignId (Guid?), VoucherId (Guid?), UserId (Guid?), DateFrom (DateTime?), DateTo (DateTime?) +- **Returns**: PaginatedResponse\ +- **Handler**: GetAllRedemptionsQueryHandler (uses double join with Vouchers and Campaigns) + +#### GetRedemptionStatisticsQuery +- **Parameters**: CampaignId (Guid?) +- **Returns**: AdminRedemptionStatisticsDto +- **Handler**: GetRedemptionStatisticsQueryHandler +- **Logic**: Calculates total count, total amount used/refunded, average amount, and counts for today/this week/this month. + +--- + +## 5. Domain Model + +### 5.1 Campaign Aggregate + +#### Campaign (Aggregate Root) +- **File**: `src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Campaign.cs` +- **Extends**: Entity, IAggregateRoot +- **Properties**: + - MerchantId (Guid) - owning merchant + - Name (string, max 255) - campaign name + - Description (string?, max 1000) + - BackingAssetTypeId (int) / BackingAssetType (AssetType) - Currency(1) or Point(2) + - BackingAssetCode (string, max 10) - e.g., "VND", "USD", "PPoint" + - FaceValue (decimal, 18,2) - value of each voucher + - AcquisitionTypeId (int) / AcquisitionType (AcquisitionType) - Free(1), ExchangePoints(2), Purchase(3) + - AcquisitionPrice (decimal, 18,2) - cost to acquire (0 for free) + - EscrowHoldId (Guid?) - Wallet Service hold reference + - EscrowWalletId (Guid?) - Wallet providing escrow + - EscrowAmount (decimal, 18,2) - total held (FaceValue * TotalVouchers) + - TotalVouchers (int) - total planned vouchers + - IssuedVouchers (int) - count of claimed vouchers + - MaxPerUser (int) - max vouchers per user (0 = unlimited) + - StartDate (DateTime) - campaign start + - EndDate (DateTime) - campaign end + - VoucherValidityDays (int) - days voucher is valid after claiming + - StatusId (int) / Status (CampaignStatus) + - CreatedAt (DateTime) + - UpdatedAt (DateTime) + - Vouchers (IReadOnlyCollection\) - child vouchers + +- **Behavior Methods**: + - `SetEscrowHold(walletId, holdId)` - set escrow references (Draft only) + - `Activate()` - Draft/Paused -> Active (requires escrow) + - `Pause()` - Active -> Paused + - `Cancel()` - any except Completed/Cancelled -> Cancelled + - `Complete()` - Active/Paused -> Completed + - `Update(name?, description?, startDate?, endDate?, maxPerUser?)` - update fields + - `GenerateVouchers(count)` - create voucher codes (Draft only, format: "V" + 6 alphanumeric) + - `IssueVoucher(userId)` - claim an available voucher for a user (Active only, enforces MaxPerUser) + - `GetVoucherByCode(code)` - find voucher by code + - `GetUserVouchers(userId)` - get user's vouchers + - Computed: `AvailableVoucherCount`, `ClaimedVoucherCount`, `RedeemedVoucherCount`, `TotalRedeemedValue` + +- **Validation (constructor)**: + - Name cannot be empty + - FaceValue must be > 0 + - TotalVouchers must be > 0 + - EndDate must be after StartDate + - Non-free campaigns require AcquisitionPrice > 0 + +#### Voucher (Entity, child of Campaign) +- **File**: `src/PromotionService.Domain/AggregatesModel/CampaignAggregate/Voucher.cs` +- **Extends**: Entity +- **Properties**: + - CampaignId (Guid) - parent campaign FK + - Code (string, max 20, unique) - voucher code + - OwnerId (Guid?) - user who claimed (null if unclaimed) + - FaceValue (decimal, 18,2) - original value + - RemainingValue (decimal, 18,2) - value left after partial redemptions + - StatusId (int) / Status (VoucherStatus) + - ClaimedAt (DateTime?) - when claimed + - ExpiresAt (DateTime?) - expiry date (set on claim) + - RedeemedAt (DateTime?) - last redemption time + - CreatedAt (DateTime) + - UpdatedAt (DateTime) + +- **Behavior Methods**: + - `Claim(userId, validityDays)` - Available -> Claimed, sets owner and expiry + - `Redeem(amount)` - deducts from RemainingValue, returns actual amount deducted, status becomes PartiallyRedeemed or FullyRedeemed + - `Expire()` - marks as Expired + - `ExtendExpiry(additionalDays)` - extends ExpiresAt + - `IsValidForRedemption()` - checks status is Claimed/PartiallyRedeemed, has remaining value, not expired + - `IsExpired` (computed) - checks ExpiresAt vs now + +### 5.2 Redemption Aggregate + +#### Redemption (Aggregate Root) +- **File**: `src/PromotionService.Domain/AggregatesModel/RedemptionAggregate/Redemption.cs` +- **Extends**: Entity, IAggregateRoot +- **Properties**: + - VoucherId (Guid) - redeemed voucher + - CampaignId (Guid) - campaign reference + - UserId (Guid) - user who redeemed + - OrderId (Guid?) - linked order (optional) + - AmountUsed (decimal, 18,2) - amount applied to order + - AmountRefunded (decimal, 18,2) - surplus returned to merchant + - ExecutionReference (string?, max 100) - Wallet Service execution ref + - RedeemedAt (DateTime) - redemption timestamp + - CreatedAt (DateTime) + +### 5.3 Enumerations (Type-Safe Enum Pattern) + +#### CampaignStatus +| Id | Name | Description | +|----|------|-------------| +| 1 | Draft | Campaign being prepared | +| 2 | Active | Campaign running, vouchers can be claimed | +| 3 | Paused | Campaign temporarily stopped | +| 4 | Completed | Campaign ended normally | +| 5 | Cancelled | Campaign cancelled, escrow released | + +#### VoucherStatus +| Id | Name | Description | +|----|------|-------------| +| 1 | Available | Not claimed yet | +| 2 | Claimed | Owned by a user | +| 3 | PartiallyRedeemed | Some value used | +| 4 | FullyRedeemed | All value used | +| 5 | Expired | Expired without full use | + +#### AssetType +| Id | Name | Description | +|----|------|-------------| +| 1 | Currency | VND, USD, etc. | +| 2 | Point | Loyalty points (PPoint) | + +#### AcquisitionType +| Id | Name | Description | +|----|------|-------------| +| 1 | Free | Free giveaway | +| 2 | ExchangePoints | Exchange with loyalty points | +| 3 | Purchase | Purchase with currency | + +### 5.4 Domain Exceptions +- `PromotionDomainException` - base for business rule violations +- `CampaignNotActiveException` - operating on non-active campaign +- `VoucherAlreadyClaimedException` - claiming an already-claimed voucher +- `VoucherExpiredException` - using an expired voucher +- `InsufficientVoucherValueException` - voucher has insufficient remaining value +- `DomainException` - generic base (from template) +- `SampleDomainException` - leftover from template (unused) + +--- + +## 6. Database Schema + +### 6.1 Table: `campaigns` + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| id | uuid | NO | PK, ValueGeneratedNever | +| merchant_id | uuid | NO | | +| name | varchar(255) | NO | | +| description | varchar(1000) | YES | | +| backing_asset_type_id | integer | NO | | +| backing_asset_code | varchar(10) | NO | | +| face_value | numeric(18,2) | NO | | +| acquisition_type_id | integer | NO | | +| acquisition_price | numeric(18,2) | NO | | +| escrow_hold_id | uuid | YES | | +| escrow_wallet_id | uuid | YES | | +| escrow_amount | numeric(18,2) | NO | | +| total_vouchers | integer | NO | | +| issued_vouchers | integer | NO | | +| max_per_user | integer | NO | | +| start_date | timestamp with time zone | NO | | +| end_date | timestamp with time zone | NO | | +| voucher_validity_days | integer | NO | | +| status_id | integer | NO | | +| created_at | timestamp with time zone | NO | | +| updated_at | timestamp with time zone | NO | | + +**Indexes**: +- `ix_campaigns_merchant_id` on (merchant_id) +- `ix_campaigns_status_id` on (status_id) +- `ix_campaigns_date_range` on (start_date, end_date) + +### 6.2 Table: `vouchers` + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| id | uuid | NO | PK, ValueGeneratedNever | +| campaign_id | uuid | NO | FK -> campaigns.id (CASCADE) | +| code | varchar(20) | NO | | +| owner_id | uuid | YES | | +| face_value | numeric(18,2) | NO | | +| remaining_value | numeric(18,2) | NO | | +| status_id | integer | NO | | +| claimed_at | timestamp with time zone | YES | | +| expires_at | timestamp with time zone | YES | | +| redeemed_at | timestamp with time zone | YES | | +| created_at | timestamp with time zone | NO | | +| updated_at | timestamp with time zone | NO | | + +**Indexes**: +- `ix_vouchers_code` on (code) UNIQUE +- `ix_vouchers_owner_id` on (owner_id) +- `ix_vouchers_campaign_id` on (campaign_id) +- `ix_vouchers_status_id` on (status_id) + +### 6.3 Table: `redemptions` + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| id | uuid | NO | PK, ValueGeneratedNever | +| voucher_id | uuid | NO | | +| campaign_id | uuid | NO | | +| user_id | uuid | NO | | +| order_id | uuid | YES | | +| amount_used | numeric(18,2) | NO | | +| amount_refunded | numeric(18,2) | NO | | +| execution_reference | varchar(100) | YES | | +| redeemed_at | timestamp with time zone | NO | | +| created_at | timestamp with time zone | NO | | + +**Indexes**: +- `ix_redemptions_voucher_id` on (voucher_id) +- `ix_redemptions_user_id` on (user_id) +- `ix_redemptions_campaign_id` on (campaign_id) +- `ix_redemptions_order_id` on (order_id) + +**Note**: The `redemptions` table does NOT have a foreign key to `vouchers` or `campaigns` at the database level (no FK constraint defined in EF configuration). + +--- + +## 7. Domain Events + +All defined in `src/PromotionService.Domain/Events/PromotionDomainEvents.cs`. These are `INotification` records dispatched via MediatR before SaveChanges. + +| Event | Parameters | Raised By | +|-------|-----------|-----------| +| CampaignCreatedDomainEvent | CampaignId, MerchantId, CampaignName | Campaign constructor | +| CampaignActivatedDomainEvent | CampaignId, MerchantId | Campaign.Activate() | +| CampaignCancelledDomainEvent | CampaignId, MerchantId, EscrowHoldId? | Campaign.Cancel() | +| VoucherClaimedDomainEvent | VoucherId, CampaignId, UserId, VoucherCode | Campaign.IssueVoucher() | +| VoucherRedeemedDomainEvent | VoucherId, CampaignId, UserId, AmountUsed, AmountRefunded | Defined but NOT raised by any code | + +**Note**: No domain event handlers are implemented in the service. Events are dispatched via `PromotionServiceContext.DispatchDomainEventsAsync()` but no `INotificationHandler` implementations exist. These events are available for future cross-service integration (e.g., via RabbitMQ integration events). + +--- + +## 8. Dependencies + +### 8.1 NuGet Packages + +**API Layer** (`PromotionService.API`): +- MediatR 12.4.1 +- FluentValidation 11.11.0 +- FluentValidation.DependencyInjectionExtensions 11.11.0 +- Microsoft.AspNetCore.Authentication.JwtBearer 10.0.0 +- Microsoft.EntityFrameworkCore.Design 10.0.0 +- Swashbuckle.AspNetCore 7.2.0 +- Asp.Versioning.Mvc 8.1.0 +- Asp.Versioning.Mvc.ApiExplorer 8.1.0 +- AspNetCore.HealthChecks.NpgSql 8.0.2 +- AspNetCore.HealthChecks.Redis 8.0.1 +- Hellang.Middleware.ProblemDetails 6.5.1 +- Serilog.AspNetCore 8.0.3 +- Serilog.Sinks.Console 6.0.0 +- Serilog.Sinks.Seq 8.0.0 + +**Domain Layer** (`PromotionService.Domain`): +- MediatR.Contracts 2.0.1 (for INotification only) + +**Infrastructure Layer** (`PromotionService.Infrastructure`): +- Microsoft.EntityFrameworkCore 10.0.0 +- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 +- Microsoft.EntityFrameworkCore.Tools 10.0.0 +- MediatR 12.4.1 +- Dapper 2.1.35 +- Microsoft.Extensions.Http.Polly 9.0.0 +- Polly 8.5.0 +- StackExchange.Redis 2.8.16 + +**Build** (Directory.Build.props): +- Microsoft.SourceLink.GitHub 8.0.0 + +### 8.2 External Service Dependencies + +| Service | Purpose | Base URL (Docker) | Base URL (Dev) | +|---------|---------|-------------------|----------------| +| Wallet Service | Escrow holds (create, execute, release, cancel) | http://wallet-service-net:8080 | http://localhost:5003 | +| IAM Service | JWT authentication (Authority for token validation) | http://iam-service-net:8080 | http://localhost:5001 | + +### 8.3 Wallet Service Client (IWalletServiceClient) + +HTTP client with Polly retry policy (3 retries, exponential backoff). Methods: +- `CreateHoldAsync` - POST `/api/v1/wallets/{walletId}/holds` +- `ExecuteHoldAsync` - POST `/api/v1/wallets/{walletId}/holds/{holdId}/execute` +- `ReleaseHoldAsync` - POST `/api/v1/wallets/{walletId}/holds/{holdId}/release` +- `CancelHoldAsync` - POST `/api/v1/wallets/{walletId}/holds/{holdId}/cancel` +- `GetWalletByUserIdAsync` - GET `/api/v1/wallets/user/{userId}` + +--- + +## 9. Configuration + +### 9.1 appsettings.json + +``` +ConnectionStrings:DefaultConnection - PostgreSQL connection string (or DATABASE_URL env var) +WalletService:BaseUrl - Wallet service URL (default: http://wallet-service-net:8080) +WalletService:TimeoutSeconds - HTTP timeout (30s) +IamService:BaseUrl - IAM service URL +IamService:ServiceName - "promotion-service" +Jwt:Authority - JWT issuer authority URL +Jwt:Audience - "goodgo-api" +Jwt:RequireHttpsMetadata - false +Jwt:Secret - JWT signing key +Jwt:Issuer - "goodgo-platform" +Jwt:AccessTokenExpiryMinutes - 15 +Jwt:RefreshTokenExpiryDays - 7 +RabbitMQ:Host - RabbitMQ host +RabbitMQ:Port - 5672 +RabbitMQ:Username/Password - guest/guest +Redis:ConnectionString - Redis connection string +Serilog - Structured logging config (Console sink) +``` + +### 9.2 Environment Variables + +| Variable | Description | +|----------|-------------| +| ASPNETCORE_ENVIRONMENT | Development / Production | +| ASPNETCORE_URLS | http://+:8080 (Docker) | +| DATABASE_URL | Fallback connection string | + +### 9.3 Build Configuration + +- Target Framework: net10.0 +- Language Version: C# 14 +- Nullable: enabled +- ImplicitUsings: enabled +- TreatWarningsAsErrors: true +- GenerateDocumentationFile: true +- Suppressed warnings: 1591 (missing XML doc), CA2017 + +--- + +## 10. MediatR Pipeline Behaviors + +Registered in order: +1. **LoggingBehavior** - logs request name, elapsed time (Stopwatch), and errors +2. **ValidatorBehavior** - runs all FluentValidation validators for the request type, throws ValidationException on failure +3. **TransactionBehavior** - wraps Commands in a database transaction (skips Queries based on name ending with "Query"), uses EF Core ExecutionStrategy for retry + +--- + +## 11. Infrastructure Patterns + +### DbContext (PromotionServiceContext) +- Implements `IUnitOfWork` +- DbSets: Campaigns, Vouchers, Redemptions +- `SaveEntitiesAsync()` dispatches domain events before saving +- Transaction management: `BeginTransactionAsync()`, `CommitTransactionAsync()`, `RollbackTransaction()` + +### Repositories +- `CampaignRepository` - ICampaignRepository implementation, uses Include(Vouchers) for GetByIdAsync +- `RedemptionRepository` - IRedemptionRepository implementation + +### Idempotency +- `IRequestManager` / `RequestManager` - tracks client request IDs to prevent duplicate processing +- `ClientRequest` entity (Id, Name, Time) - registered but table not included in migration + +### DI Registration (DependencyInjection.cs) +- PromotionServiceContext with Npgsql (retry on failure: 5 attempts, 30s max delay) +- ICampaignRepository -> CampaignRepository (Scoped) +- IRedemptionRepository -> RedemptionRepository (Scoped) +- IRequestManager -> RequestManager (Scoped) + +--- + +## 12. Docker + +- **Dockerfile**: Multi-stage build (sdk:10.0 -> aspnet:10.0) +- **Runtime user**: dotnetuser (UID 1001, GID 1001) +- **Port**: 8080 +- **Healthcheck**: curl http://localhost:8080/health/live (30s interval, 3 retries) +- **docker-compose.yml**: Template-level (uses "myservice" naming, not production-ready), includes PostgreSQL 16 and Redis 7 + +--- + +## 13. Known Gaps and Issues + +1. **Missing Handlers**: `ExchangeVoucherCommand`, `PurchaseVoucherCommand`, `SearchVouchersQuery`, `GetCampaignStatisticsQuery`, and `GetCampaignVouchersQuery` are defined but have no handler implementations. + +2. **VoucherRedeemedDomainEvent**: Defined but never raised by any code. + +3. **No FluentValidation Validators**: No validator classes exist in the codebase despite FluentValidation being registered in the pipeline. Commands have no request validation beyond domain-level checks. + +4. **No Integration Event Handlers**: Domain events are dispatched but no handlers exist. No RabbitMQ integration despite RabbitMQ config being present in appsettings. + +5. **ClientRequest Table Missing from Migration**: The `ClientRequest` entity for idempotency is registered in DI but its table is not created in the migration. + +6. **docker-compose.yml uses template naming**: Service is named "myservice-api" instead of "promotion-service". + +7. **SampleDomainException**: Leftover from template, unused. + +8. **Redemption table has no FK constraints**: No foreign keys to vouchers or campaigns tables at the database level. + +9. **Redis health check package included** but Redis is not used in the service code. diff --git a/services/social-service-net/SERVICE_DOCS.md b/services/social-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..a7e99a22 --- /dev/null +++ b/services/social-service-net/SERVICE_DOCS.md @@ -0,0 +1,580 @@ +# SocialService - Service Documentation + +## 1. Overview + +The Social Service manages user-to-user social relationships within the GoodGo platform. It handles friendships (bidirectional, requires acceptance), following (unidirectional, auto-accepted), user blocking, and graph-based queries such as mutual friends and friend suggestions. + +- **Service Name**: social-service-net +- **Port**: 5009 (Development, HTTP) +- **Docker Port**: 8080 +- **Database**: PostgreSQL - `social_service` (Neon cloud) +- **Database (local)**: configurable via `DATABASE_URL` env var +- **Framework**: .NET 10.0, C# 14 +- **Architecture**: Clean Architecture + CQRS (MediatR) +- **Solution File**: `SocialService.slnx` +- **Migration**: `20260112164356_InitialCreate` (auto-applied on startup) + +--- + +## 2. API Endpoints + +### 2.1 RelationshipsController + +**Base Route**: `api/v1/relationships` + +| Method | Route | Description | Request Body | Response | +|--------|-------|-------------|-------------|----------| +| GET | `/users/{userId}/friends?skip=0&take=20` | Get friends of a user (paginated) | - | `GetFriendsResult` | +| POST | `/friend-requests` | Send a friend request | `SendFriendRequestRequest` | `SendFriendRequestResult` (201) | +| PUT | `/friend-requests/{relationshipId}` | Accept or reject a friend request | `RespondToFriendRequestRequest` | `{ success: bool }` | +| GET | `/users/{userId1}/mutual-friends/{userId2}` | Get mutual friends between two users | - | `GetMutualFriendsResult` | +| GET | `/users/{userId}/suggestions?limit=10` | Get friend suggestions for a user | - | `GetFriendSuggestionsResult` | +| POST | `/follow` | Follow a user | `FollowUserRequest` | `FollowUserResult` (201) | +| DELETE | `/follow` | Unfollow a user | `UnfollowUserRequest` | `{ success: bool }` | + +**Request DTOs** (defined in RelationshipsController.cs): +- `SendFriendRequestRequest(Guid RequesterId, Guid AddresseeId)` +- `RespondToFriendRequestRequest(Guid UserId, bool Accept)` +- `FollowUserRequest(Guid FollowerId, Guid FolloweeId)` +- `UnfollowUserRequest(Guid FollowerId, Guid FolloweeId)` + +### 2.2 BlocksController + +**Base Route**: `api/v1/blocks` + +| Method | Route | Description | Request Body | Response | +|--------|-------|-------------|-------------|----------| +| POST | `/` | Block a user | `BlockUserRequest` | `BlockUserResult` (201) | +| DELETE | `/` | Unblock a user | `UnblockUserRequest` | `{ success: bool }` | +| GET | `/users/{userId}?skip=0&take=20` | Get list of blocked users (paginated) | - | `GetBlockedUsersResult` | + +**Request DTOs** (defined in BlocksController.cs): +- `BlockUserRequest(Guid BlockerId, Guid BlockedId, string? Reason = null)` +- `UnblockUserRequest(Guid BlockerId, Guid BlockedId)` + +### 2.3 AdminController + +**Base Route**: `api/v1/admin/social` + +| Method | Route | Description | Query Params | Response | +|--------|-------|-------------|-------------|----------| +| GET | `/relationships?skip=0&take=20&status=&type=` | Get all relationships (filtered, paginated) | `skip`, `take`, `status` (pending/accepted/rejected/cancelled), `type` (friendship/following) | `GetAllRelationshipsResult` | +| GET | `/relationships/{id:guid}` | Get relationship by ID | - | `RelationshipDetailDto` or 404 | +| DELETE | `/relationships/{id:guid}` | Admin delete a relationship | - | `{ success, message }` or 404 | +| GET | `/blocks?skip=0&take=20` | Get all blocks (paginated) | `skip`, `take` | `GetAllBlocksResult` | +| DELETE | `/blocks/{id:guid}` | Admin delete a block | - | `{ success, message }` or 404 | +| GET | `/statistics` | Get social statistics overview | - | `SocialStatisticsDto` | + +### 2.4 Health Check Endpoints + +| Route | Description | +|-------|-------------| +| `/health` | Full health check (includes PostgreSQL) | +| `/health/live` | Liveness probe (app is running, no dependency checks) | +| `/health/ready` | Readiness probe (includes PostgreSQL) | + +--- + +## 3. Commands + +All commands are MediatR `IRequest` records. The MediatR pipeline wraps commands in transactions automatically (via `TransactionBehavior`, which skips requests ending in "Query"). + +### 3.1 SendFriendRequestCommand +- **File**: `Application/Commands/SendFriendRequestCommand.cs` +- **Parameters**: `Guid RequesterId`, `Guid AddresseeId` +- **Returns**: `SendFriendRequestResult(Guid RelationshipId, string Status)` +- **Behavior**: + - Checks for blocks between users (throws if blocked) + - Checks for existing friendship (throws if already friends or pending) + - If reverse pending request exists, auto-accepts it (mutual request) + - Otherwise creates new Relationship with `Pending` status +- **Dependencies**: `IRelationshipRepository`, `IUserBlockRepository` + +### 3.2 RespondToFriendRequestCommand +- **File**: `Application/Commands/RespondToFriendRequestCommand.cs` +- **Parameters**: `Guid RelationshipId`, `Guid UserId`, `bool Accept` +- **Returns**: `bool` +- **Behavior**: + - Verifies relationship exists (throws if not found) + - Verifies user is the addressee (throws if not) + - Calls `Accept()` or `Reject()` on the Relationship entity +- **Dependencies**: `IRelationshipRepository` + +### 3.3 FollowUserCommand +- **File**: `Application/Commands/FollowUserCommand.cs` +- **Parameters**: `Guid FollowerId`, `Guid FolloweeId` +- **Returns**: `FollowUserResult(Guid RelationshipId, bool Success)` +- **Behavior**: + - Prevents self-follow + - Checks for blocks between users + - Checks for existing follow (throws if already following) + - Creates Relationship with `Following` type, calls `Accept()` (auto-accepted) +- **Dependencies**: `IRelationshipRepository`, `IUserBlockRepository` + +### 3.4 UnfollowUserCommand +- **File**: `Application/Commands/UnfollowUserCommand.cs` +- **Parameters**: `Guid FollowerId`, `Guid FolloweeId` +- **Returns**: `bool` +- **Behavior**: + - Finds existing Following relationship (throws if not following) + - Calls `Remove()` on the Relationship entity (sets status to Cancelled) +- **Dependencies**: `IRelationshipRepository` + +### 3.5 BlockUserCommand +- **File**: `Application/Commands/BlockUserCommand.cs` +- **Parameters**: `Guid BlockerId`, `Guid BlockedId`, `string? Reason = null` +- **Returns**: `BlockUserResult(Guid BlockId, bool Success)` +- **Behavior**: + - Prevents self-block + - Checks for existing block (throws if already blocked) + - Creates UserBlock entity + - Removes all existing relationships between the two users (friendships and followings in both directions) by calling `Remove()` on each +- **Dependencies**: `IUserBlockRepository`, `IRelationshipRepository` + +### 3.6 UnblockUserCommand +- **File**: `Application/Commands/UnblockUserCommand.cs` +- **Parameters**: `Guid BlockerId`, `Guid BlockedId` +- **Returns**: `bool` +- **Behavior**: + - Finds existing block (throws if not blocked) + - Removes the UserBlock entity +- **Dependencies**: `IUserBlockRepository` + +### 3.7 AdminDeleteRelationshipCommand +- **File**: `Application/Commands/AdminDeleteRelationshipCommand.cs` +- **Parameters**: `Guid RelationshipId` +- **Returns**: `bool` +- **Behavior**: Hard-deletes a relationship from the database. Returns false if not found. +- **Dependencies**: `IRelationshipRepository` + +### 3.8 AdminDeleteBlockCommand +- **File**: `Application/Commands/AdminDeleteBlockCommand.cs` +- **Parameters**: `Guid BlockId` +- **Returns**: `bool` +- **Behavior**: Hard-deletes a block from the database. Returns false if not found. +- **Dependencies**: `IUserBlockRepository` + +--- + +## 4. Queries + +### 4.1 GetFriendsQuery +- **File**: `Application/Queries/GetFriendsQuery.cs` +- **Parameters**: `Guid UserId`, `int Skip = 0`, `int Take = 20` +- **Returns**: `GetFriendsResult(IEnumerable Friends, int TotalCount)` +- **FriendDto**: `(Guid UserId, string DisplayName, string? AvatarUrl, DateTime FriendsSince)` +- **Behavior**: Gets accepted friendships where user is requester or addressee, enriches with UserProfile data. + +### 4.2 GetMutualFriendsQuery +- **File**: `Application/Queries/GetMutualFriendsQuery.cs` +- **Parameters**: `Guid UserId1`, `Guid UserId2` +- **Returns**: `GetMutualFriendsResult(IEnumerable MutualFriends, int Count)` +- **Behavior**: Uses `IGraphQueryService` with raw SQL (CTE) to find users who are friends with both users. Enriches with UserProfile data. + +### 4.3 GetFriendSuggestionsQuery +- **File**: `Application/Queries/GetFriendSuggestionsQuery.cs` +- **Parameters**: `Guid UserId`, `int Limit = 10` +- **Returns**: `GetFriendSuggestionsResult(IEnumerable Suggestions)` +- **FriendSuggestionDto**: `(Guid UserId, string DisplayName, string? AvatarUrl, int MutualFriendsCount)` +- **Behavior**: Uses `IGraphQueryService` with raw SQL (friends-of-friends CTE). Excludes already-friends and blocked users. Orders by mutual friend count descending. + +### 4.4 GetBlockedUsersQuery +- **File**: Defined in `Controllers/BlocksController.cs` +- **Parameters**: `Guid UserId`, `int Skip = 0`, `int Take = 20` +- **Returns**: `GetBlockedUsersResult(IEnumerable BlockedUsers, int TotalCount)` +- **BlockedUserDto**: `(Guid UserId, string DisplayName, string? AvatarUrl, DateTime BlockedAt, string? Reason)` +- **Behavior**: Gets blocks by blocker user ID, enriches with UserProfile data. + +### 4.5 GetAllRelationshipsQuery (Admin) +- **File**: `Application/Queries/GetAllRelationshipsQuery.cs` +- **Parameters**: `int Skip`, `int Take`, `string? StatusFilter`, `string? TypeFilter` +- **Returns**: `GetAllRelationshipsResult(IEnumerable Relationships, int TotalCount)` +- **RelationshipAdminDto**: `(Guid Id, Guid RequesterId, Guid AddresseeId, string Type, string Status, DateTime CreatedAt, DateTime? UpdatedAt)` + +### 4.6 GetRelationshipByIdQuery (Admin) +- **File**: `Application/Queries/GetRelationshipByIdQuery.cs` +- **Parameters**: `Guid Id` +- **Returns**: `RelationshipDetailDto?` (nullable) +- **RelationshipDetailDto**: `(Guid Id, Guid RequesterId, Guid AddresseeId, string Type, string Status, DateTime CreatedAt, DateTime? UpdatedAt)` + +### 4.7 GetAllBlocksQuery (Admin) +- **File**: `Application/Queries/GetAllBlocksQuery.cs` +- **Parameters**: `int Skip`, `int Take` +- **Returns**: `GetAllBlocksResult(IEnumerable Blocks, int TotalCount)` +- **BlockAdminDto**: `(Guid Id, Guid BlockerId, Guid BlockedId, string? Reason, DateTime CreatedAt)` + +### 4.8 GetSocialStatisticsQuery (Admin) +- **File**: `Application/Queries/GetSocialStatisticsQuery.cs` +- **Parameters**: none +- **Returns**: `SocialStatisticsDto(int TotalFriendships, int TotalFollowings, int TotalBlocks, int PendingFriendRequests, int AcceptedFriendships, int TotalRelationships)` + +--- + +## 5. Domain Model + +### 5.1 Aggregates + +#### Relationship (Aggregate Root) +- **File**: `Domain/AggregatesModel/RelationshipAggregate/Relationship.cs` +- **Extends**: `Entity`, implements `IAggregateRoot` +- **Private Fields**: `_requesterId`, `_addresseeId`, `_type`, `_status`, `_createdAt`, `_updatedAt` +- **Public Properties**: `RequesterId`, `AddresseeId`, `Type`, `TypeId`, `Status`, `StatusId`, `CreatedAt`, `UpdatedAt` +- **Constructor**: `Relationship(Guid requesterId, Guid addresseeId, RelationshipType type)` + - Validates: no empty GUIDs, no self-relationship + - Following type: auto-sets status to `Accepted`, raises `UserFollowedDomainEvent` + - Friendship type: sets status to `Pending`, raises `FriendRequestSentDomainEvent` +- **Behavior Methods**: + - `Accept()` - Only for pending friendships. Sets status to Accepted. Raises `FriendshipCreatedDomainEvent` and `RelationshipStatusChangedDomainEvent`. + - `Reject()` - Only for pending friendships. Sets status to Rejected. Raises `RelationshipStatusChangedDomainEvent`. + - `Cancel()` - Only for pending requests. Sets status to Cancelled. Raises `RelationshipStatusChangedDomainEvent`. + - `Remove()` - Only for accepted relationships. Sets status to Cancelled. Raises `RelationshipRemovedDomainEvent` and `RelationshipStatusChangedDomainEvent`. + +#### UserBlock (Aggregate Root) +- **File**: `Domain/AggregatesModel/UserBlockAggregate/UserBlock.cs` +- **Extends**: `Entity`, implements `IAggregateRoot` +- **Private Fields**: `_blockerId`, `_blockedId`, `_reason`, `_createdAt` +- **Public Properties**: `BlockerId`, `BlockedId`, `Reason`, `CreatedAt` +- **Constructor**: `UserBlock(Guid blockerId, Guid blockedId, string? reason = null)` + - Validates: no empty GUIDs, no self-block + - Raises `UserBlockedDomainEvent` +- **No behavior methods** (immutable once created; removal is done via repository `Remove()`) + +#### UserProfile (Aggregate Root) +- **File**: `Domain/AggregatesModel/UserProfileAggregate/UserProfile.cs` +- **Extends**: `Entity`, implements `IAggregateRoot` +- **Private Fields**: `_userId`, `_displayName`, `_avatarUrl`, `_bio`, `_lastSyncedAt`, `_createdAt` +- **Public Properties**: `UserId`, `DisplayName`, `AvatarUrl`, `Bio`, `LastSyncedAt`, `CreatedAt` +- **Constructor**: `UserProfile(Guid userId, string displayName, string? avatarUrl = null, string? bio = null)` +- **Behavior Methods**: + - `UpdateFromEvent(string displayName, string? avatarUrl, string? bio)` - Updates profile data from IAM integration event + +### 5.2 Enumerations (Type-Safe Enum Pattern) + +#### RelationshipType +- **File**: `Domain/AggregatesModel/RelationshipAggregate/RelationshipType.cs` +- **Values**: + - `Friendship` (Id: 1) - Bidirectional, requires acceptance + - `Following` (Id: 2) - Unidirectional, auto-accepted + +#### RelationshipStatus +- **File**: `Domain/AggregatesModel/RelationshipAggregate/RelationshipStatus.cs` +- **Values**: + - `Pending` (Id: 1) - Waiting for acceptance + - `Accepted` (Id: 2) - Active relationship + - `Rejected` (Id: 3) - Request was rejected + - `Cancelled` (Id: 4) - Cancelled by requester or removed + +### 5.3 Domain Services + +#### IGraphQueryService +- **File**: `Domain/Services/IGraphQueryService.cs` +- **Implementation**: `Infrastructure/Services/GraphQueryService.cs` (PostgreSQL raw SQL with CTEs) +- **Methods**: + - `GetMutualFriendsAsync(Guid userId1, Guid userId2)` - Returns mutual friend IDs using SQL CTE joins + - `GetFriendSuggestionsAsync(Guid userId, int limit)` - Friends-of-friends who aren't already friends, excludes blocked users, ordered by mutual count + - `GetConnectionDegreeAsync(Guid userId1, Guid userId2)` - Recursive CTE BFS, max depth 6, returns degrees of separation (-1 if not connected) + - `GetMutualFriendsCountAsync(Guid userId1, Guid userId2)` - Count of mutual friends +- **Helper DTO**: `FriendSuggestion(Guid UserId, int MutualFriendsCount)` + +### 5.4 Domain Events + +**Relationship Events** (`Domain/Events/RelationshipDomainEvents.cs`): +- `FriendRequestSentDomainEvent` - Raised when a friendship request is created +- `FriendshipCreatedDomainEvent` - Raised when a friendship request is accepted +- `UserFollowedDomainEvent` - Raised when a follow relationship is created +- `RelationshipStatusChangedDomainEvent` - Raised on any status transition (includes PreviousStatus and NewStatus) +- `RelationshipRemovedDomainEvent` - Raised when an accepted relationship is removed + +**Block Events** (`Domain/Events/UserBlockDomainEvents.cs`): +- `UserBlockedDomainEvent` - Raised when a user block is created +- `UserUnblockedDomainEvent` - Defined but not currently raised in code (the UnblockUserCommandHandler does not add this event) + +### 5.5 Exceptions + +- `DomainException` - Base exception class +- `SocialDomainException` - Extends DomainException, used for all social domain rule violations +- `SampleDomainException` - Leftover from template, extends DomainException (unused) + +### 5.6 SeedWork + +Base classes shared across the domain: +- `Entity` - Base entity with `Id` (Guid), `DomainEvents` collection, equality by ID +- `IAggregateRoot` - Marker interface +- `IRepository` - Generic repository with `IUnitOfWork UnitOfWork` +- `IUnitOfWork` - `SaveChangesAsync()`, `SaveEntitiesAsync()` (dispatches domain events) +- `Enumeration` - Type-safe enum base class with `Id`, `Name`, `GetAll()`, `FromValue()`, `FromDisplayName()` +- `ValueObject` - Immutable value comparison base class (not currently used by any entity) + +--- + +## 6. Database Schema + +**Database**: `social_service` (PostgreSQL) +**Migration**: `20260112164356_InitialCreate` + +### 6.1 Table: `relationships` + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| `id` | uuid | NO | PK, ValueGeneratedNever | +| `requester_id` | uuid | NO | | +| `addressee_id` | uuid | NO | | +| `type_id` | integer | NO | FK -> relationship_types.id (RESTRICT) | +| `status_id` | integer | NO | FK -> relationship_statuses.id (RESTRICT) | +| `created_at` | timestamp with time zone | NO | | +| `updated_at` | timestamp with time zone | YES | | + +**Indexes**: +- `ix_relationships_requester_addressee_type` - UNIQUE on (requester_id, addressee_id, type_id) +- `ix_relationships_requester` - on (requester_id) +- `ix_relationships_addressee` - on (addressee_id) +- `ix_relationships_type_status` - on (type_id, status_id) +- `IX_relationships_status_id` - on (status_id) (auto-generated by EF for FK) + +### 6.2 Table: `relationship_types` (Enumeration) + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| `id` | integer | NO | PK, ValueGeneratedNever | +| `name` | varchar(50) | NO | | + +**Seed Data**: `(1, "Friendship")`, `(2, "Following")` + +### 6.3 Table: `relationship_statuses` (Enumeration) + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| `id` | integer | NO | PK, ValueGeneratedNever | +| `name` | varchar(50) | NO | | + +**Seed Data**: `(1, "Pending")`, `(2, "Accepted")`, `(3, "Rejected")`, `(4, "Cancelled")` + +### 6.4 Table: `user_blocks` + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| `id` | uuid | NO | PK, ValueGeneratedNever | +| `blocker_id` | uuid | NO | | +| `blocked_id` | uuid | NO | | +| `reason` | varchar(500) | YES | | +| `created_at` | timestamp with time zone | NO | | + +**Indexes**: +- `ix_user_blocks_blocker_blocked` - UNIQUE on (blocker_id, blocked_id) +- `ix_user_blocks_blocker` - on (blocker_id) +- `ix_user_blocks_blocked` - on (blocked_id) + +### 6.5 Table: `user_profiles` + +| Column | Type | Nullable | Constraints | +|--------|------|----------|-------------| +| `id` | uuid | NO | PK, ValueGeneratedNever | +| `user_id` | uuid | NO | | +| `display_name` | varchar(255) | NO | | +| `avatar_url` | varchar(500) | YES | | +| `bio` | varchar(500) | YES | | +| `last_synced_at` | timestamp with time zone | NO | | +| `created_at` | timestamp with time zone | NO | | + +**Indexes**: +- `ix_user_profiles_user_id` - UNIQUE on (user_id) +- `ix_user_profiles_display_name` - on (display_name) + +--- + +## 7. Integration Events + +### 7.1 Domain Events (Internal) + +The following domain events are raised within the service and dispatched via MediatR `INotification` through the `SocialServiceContext.DispatchDomainEventsAsync()` method before `SaveChanges`. Currently, there are **no domain event handlers** registered in the service -- the events are raised but not consumed. + +- `FriendRequestSentDomainEvent` +- `FriendshipCreatedDomainEvent` +- `UserFollowedDomainEvent` +- `RelationshipStatusChangedDomainEvent` +- `RelationshipRemovedDomainEvent` +- `UserBlockedDomainEvent` + +### 7.2 Cross-Service Integration + +- **Inbound (Expected)**: `UserProfile.UpdateFromEvent()` method exists for receiving IAM profile update events. However, there is **no integration event handler or RabbitMQ consumer implemented** -- the UserProfile entity and repository exist but have no controllers or commands for creating/updating profiles. Profiles must currently be populated directly in the database. +- **Outbound**: No integration events are published to RabbitMQ or any message broker. Domain events remain internal only. + +--- + +## 8. Dependencies + +### 8.1 NuGet Packages + +**SocialService.API**: +- MediatR 12.4.1 +- FluentValidation 11.11.0 +- FluentValidation.DependencyInjectionExtensions 11.11.0 +- Microsoft.EntityFrameworkCore.Design 10.0.1 +- Swashbuckle.AspNetCore 7.2.0 +- Asp.Versioning.Mvc 8.1.0 +- Asp.Versioning.Mvc.ApiExplorer 8.1.0 +- AspNetCore.HealthChecks.NpgSql 8.0.2 +- AspNetCore.HealthChecks.Redis 8.0.1 +- Hellang.Middleware.ProblemDetails 6.5.1 +- Serilog.AspNetCore 8.0.3 +- Serilog.Sinks.Console 6.0.0 +- Serilog.Sinks.Seq 8.0.0 + +**SocialService.Domain**: +- MediatR.Contracts 2.0.1 + +**SocialService.Infrastructure**: +- Microsoft.EntityFrameworkCore 10.0.0 +- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 +- Microsoft.EntityFrameworkCore.Tools 10.0.0 +- MediatR 12.4.1 +- Dapper 2.1.35 +- Microsoft.Extensions.Http.Polly 9.0.0 +- Polly 8.5.0 +- StackExchange.Redis 2.8.16 + +**Shared (Directory.Build.props)**: +- Microsoft.SourceLink.GitHub 8.0.0 +- Target: net10.0, LangVersion 14.0, Nullable enabled, TreatWarningsAsErrors + +**Unit Tests**: +- Microsoft.NET.Test.Sdk 17.12.0 +- xunit 2.9.2 +- FluentAssertions 6.12.2 +- NSubstitute 5.3.0 +- coverlet.collector 6.0.2 + +**Functional Tests**: +- Microsoft.AspNetCore.Mvc.Testing 10.0.0 +- Microsoft.EntityFrameworkCore.InMemory 10.0.0 +- FluentAssertions 6.12.2 +- Testcontainers.PostgreSql 4.1.0 + +### 8.2 External Service Dependencies + +- **PostgreSQL** (Neon cloud or local) - primary data store +- **Redis** (referenced in health checks and .env.example, but **not actively used** in code -- no Redis caching is implemented) +- **IAM Service** - UserProfile entity is designed to sync from IAM, but no integration is implemented + +--- + +## 9. Configuration + +### 9.1 appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=...neon.tech;Database=social_service;..." + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "your-super-secret-key-min-32-characters", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + } +} +``` + +**Note**: JWT configuration is present in settings but **JWT authentication middleware is NOT configured** in `Program.cs`. There is no `AddAuthentication()` or `UseAuthentication()` call. All endpoints are currently unauthenticated. + +### 9.2 Environment Variables (.env.example) + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASPNETCORE_ENVIRONMENT` | Environment name | Development | +| `DATABASE_URL` | PostgreSQL connection string | localhost:5432 | +| `REDIS_URL` | Redis connection string | localhost:6379 | +| `REDIS_PASSWORD` | Redis password | (empty) | +| `JWT_SECRET` | JWT signing key | (placeholder) | +| `JWT_ISSUER` | JWT issuer | goodgo-platform | +| `JWT_AUDIENCE` | JWT audience | goodgo-services | +| `JWT_ACCESS_TOKEN_EXPIRY_MINUTES` | Access token TTL | 15 | +| `JWT_REFRESH_TOKEN_EXPIRY_DAYS` | Refresh token TTL | 7 | +| `API_PORT` | API port | 5000 | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry endpoint | localhost:4317 | +| `OTEL_SERVICE_NAME` | OpenTelemetry service name | socialservice | +| `SEQ_URL` | Seq logging endpoint | localhost:5341 | +| `RATE_LIMIT_PERMITS_PER_MINUTE` | Rate limit | 100 | +| `RATE_LIMIT_QUEUE_LIMIT` | Rate limit queue | 10 | +| `HEALTHCHECK_TIMEOUT_SECONDS` | Health check timeout | 5 | + +### 9.3 MediatR Pipeline (Behavior Order) + +1. `LoggingBehavior` - Logs request name, measures execution time with Stopwatch +2. `ValidatorBehavior` - Runs FluentValidation validators (none currently registered) +3. `TransactionBehavior` - Wraps commands in a database transaction (skips requests ending in "Query"), uses `CreateExecutionStrategy()` for retry support + +### 9.4 DI Registration (Infrastructure) + +Registered in `DependencyInjection.AddInfrastructure()`: +- `SocialServiceContext` (DbContext with Npgsql, retry on failure: 5 retries, 30s delay) +- `IRelationshipRepository` -> `RelationshipRepository` (Scoped) +- `IUserProfileRepository` -> `UserProfileRepository` (Scoped) +- `IUserBlockRepository` -> `UserBlockRepository` (Scoped) +- `IGraphQueryService` -> `GraphQueryService` (Scoped) +- `IRequestManager` -> `RequestManager` (Scoped) + +### 9.5 Docker Configuration + +- Multi-stage build: `sdk:10.0` (build) -> `aspnet:10.0` (runtime) +- Non-root user: `dotnetuser` (UID 1001, GID 1001) +- Port: 8080 (`ASPNETCORE_URLS=http://+:8080`) +- Health check: `curl -f http://localhost:8080/health/live` (30s interval, 3 retries) + +### 9.6 SDK Version + +- .NET SDK: 10.0.101 (rollForward: latestMinor) + +--- + +## 10. Test Coverage + +### 10.1 Unit Tests (`SocialService.UnitTests`) + +**Handler Tests**: +- `SendFriendRequestCommandHandlerTests` (4 tests): + - Valid request creates new relationship + - Block exists throws exception + - Already friends throws exception + - Request already pending throws exception + - Mutual request auto-accepts + +- `BlockUserCommandHandlerTests` (5 tests): + - Valid request creates block + - Self-block throws exception + - Already blocked throws exception + - With existing friendship removes friendship + - No reason creates block without reason + +**Domain Tests**: +- `RelationshipAggregateTests` (14 tests): Constructor, Accept, Reject, Cancel, Remove with valid and invalid state transitions +- `UserBlockAggregateTests` (6 tests): Constructor with valid/invalid inputs, domain event verification + +### 10.2 Functional Tests (`SocialService.FunctionalTests`) + +Uses `CustomWebApplicationFactory` with InMemoryDatabase. + +- `RelationshipsControllerTests` (4 tests): GET friends, mutual friends, suggestions, health check +- `BlocksControllerTests` (2 tests): GET blocked users, with pagination +- `AdminControllerTests` (6 tests): GET all relationships (with/without filter), GET by ID (404), DELETE relationship (404), GET all blocks, DELETE block (404), GET statistics + +--- + +## 11. Known Gaps and Notes + +1. **No Authentication**: JWT config exists in settings but authentication middleware is not wired up. All endpoints are public. +2. **No FluentValidation Validators**: The `ValidatorBehavior` is in the pipeline but no `AbstractValidator` classes exist for any command. +3. **No UserProfile CRUD API**: The `UserProfile` entity and repository exist, but there are no controllers or commands to create/update profiles. The `GetFriendsQuery` and other queries reference UserProfile for display names, but profiles must be populated externally. +4. **No IAM Integration Event Consumer**: `UserProfile.UpdateFromEvent()` method exists but no RabbitMQ consumer or integration event handler is implemented. +5. **No Redis Usage**: Redis package is referenced and health check is configured, but no caching logic exists. +6. **UserUnblockedDomainEvent**: Defined but never raised -- the `UnblockUserCommandHandler` does not add this domain event before removing the block. +7. **SampleDomainException**: Leftover from template, unused. +8. **Idempotency**: `IRequestManager`/`RequestManager` and `ClientRequest` are registered but not used by any command handler. +9. **CORS**: Configured to allow any origin/method/header (development-only concern). +10. **Swagger**: Only enabled in Development environment. diff --git a/services/storage-service-net/SERVICE_DOCS.md b/services/storage-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..2689384d --- /dev/null +++ b/services/storage-service-net/SERVICE_DOCS.md @@ -0,0 +1,547 @@ +# StorageService - Service Documentation + +> Auto-generated from source code audit on 2026-03-13 + +## Overview + +**StorageService** is a file storage microservice providing S3-compatible object storage with support for direct uploads, multipart uploads, file sharing, file versioning, logical folders, and per-user storage quotas. It supports two storage backends: **MinIO** (primary) and **Aliyun OSS**. + +- **Port**: 5002 (Development) +- **Database**: `storage_service` (PostgreSQL / Neon) +- **Storage Backend**: MinIO (S3-compatible) at `167.114.174.113:9000` +- **Cache**: Redis at `167.114.174.113:6379` +- **Default Bucket**: `goodgo` +- **Max File Size**: 100MB (configurable), validator limit 500MB +- **Auth**: JWT Bearer via IAM Service +- **Framework**: .NET 10.0, Clean Architecture + CQRS + +--- + +## API Endpoints + +### Files (`/api/v1/files`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/api/v1/files/upload` | Bearer | Upload a file (max 100MB) | +| GET | `/api/v1/files` | Bearer | List user files (skip, take, search) | +| GET | `/api/v1/files/{fileId}` | Bearer | Get file metadata by ID | +| GET | `/api/v1/files/{fileId}/download-url` | Bearer | Get pre-signed download URL | +| GET | `/api/v1/files/{fileId}/cdn-url` | Bearer | Get CDN URL (public) or fallback pre-signed URL | +| DELETE | `/api/v1/files/{fileId}` | Bearer | Soft delete a file | + +### Direct Upload - Pre-Signed URL (`/api/v1/storage`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/api/v1/storage/sign-upload` | Bearer | Get pre-signed PUT URL for direct upload to MinIO | +| POST | `/api/v1/storage/confirm-upload` | Bearer | Confirm upload and save metadata after direct upload | + +### Multipart Upload (`/api/v1/files/multipart`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/api/v1/files/multipart/initiate` | Bearer | Initiate multipart upload session | +| POST | `/api/v1/files/multipart/upload-part` | Bearer | Upload a single part (max 20MB per part) | +| POST | `/api/v1/files/multipart/complete` | Bearer | Complete and merge all parts | +| DELETE | `/api/v1/files/multipart/abort` | Bearer | Abort and cleanup | +| GET | `/api/v1/files/multipart/{uploadId}` | Bearer | Get upload progress | + +### File Sharing (`/api/v1/storage`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/api/v1/storage/files/{fileId}/shares` | Bearer | Create share link (with optional password, expiry, max downloads) | +| GET | `/api/v1/storage/files/{fileId}/shares` | Bearer | Get all shares for a file | +| DELETE | `/api/v1/storage/shares/{shareId}` | Bearer | Revoke a share link | +| GET | `/api/v1/storage/shares/public/{token}` | Anonymous | Access shared file by token (public endpoint) | + +### File Versioning (`/api/v1/storage/files/{fileId}/versions`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/storage/files/{fileId}/versions` | Bearer | Get all versions of a file | +| GET | `/api/v1/storage/files/{fileId}/versions/{versionId}/download` | Bearer | Get download URL for specific version | +| POST | `/api/v1/storage/files/{fileId}/versions/{versionId}/restore` | Bearer | Restore file to a specific version | + +### Folders (`/api/v1/storage/folders`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| POST | `/api/v1/storage/folders` | Bearer | Create folder (root or child) | +| GET | `/api/v1/storage/folders` | Bearer | List folders (root or children of parentId) | +| GET | `/api/v1/storage/folders/{folderId}` | Bearer | Get folder by ID | +| PUT | `/api/v1/storage/folders/{folderId}` | Bearer | Rename folder (O(1) operation) | +| DELETE | `/api/v1/storage/folders/{folderId}` | Bearer | Soft delete folder | + +### Quota (`/api/v1/quota`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/quota` | Bearer | Get current user's storage quota | + +### Admin - Files (`/api/v1/admin/files`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/admin/files` | Admin/SuperAdmin | Get all files with filtering and pagination | +| DELETE | `/api/v1/admin/files/{fileId}` | Admin/SuperAdmin | Delete file (bypasses ownership, requires reason) | + +### Admin - Quotas (`/api/v1/admin/quotas`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/admin/quotas` | Admin/SuperAdmin | Get all users' quotas with filtering | +| GET | `/api/v1/admin/quotas/{userId}` | Admin/SuperAdmin | Get quota for specific user | +| PUT | `/api/v1/admin/quotas/{userId}` | Admin/SuperAdmin | Update user quota limits | + +### Admin - Shares (`/api/v1/admin/shares`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/admin/shares` | Admin/SuperAdmin | Get all file shares with filtering | +| DELETE | `/api/v1/admin/shares/{shareId}` | Admin/SuperAdmin | Revoke share (requires reason) | + +### Admin - Statistics (`/api/v1/admin/statistics`) + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/api/v1/admin/statistics` | Admin/SuperAdmin | Get aggregated storage statistics | +| GET | `/api/v1/admin/statistics/users-near-limit` | Admin/SuperAdmin | Get users with usage >= 80% | + +### Health Checks + +| Method | Route | Auth | Description | +|--------|-------|------|-------------| +| GET | `/health` | None | Full health check (incl. PostgreSQL) | +| GET | `/health/live` | None | Liveness probe (app running) | +| GET | `/health/ready` | None | Readiness probe | + +--- + +## Commands + +### UploadFileCommand +- **Input**: `Stream FileStream, string FileName, string ContentType, long FileSizeBytes, string UserId, string? TenantId, FileAccessLevel AccessLevel` +- **Logic**: Check file size limit -> Check user quota -> Generate object key (`{accessLevel}/{userId}/{date}/{uniqueId}_{filename}`) -> Upload to storage provider -> Save StorageFile entity -> Update quota -> SaveEntities +- **Validator**: FileName (required, max 255, no `..` `/` `\`), ContentType (required, max 100), FileSizeBytes (> 0, <= 500MB), UserId (required, max 128), TenantId (max 128) +- **Result**: `UploadFileResult(bool Success, Guid? FileId, string? ObjectKey, string? Error)` + +### SignUploadCommand +- **Input**: `string UserId, string FileName, string ContentType, long FileSizeBytes, FileAccessLevel AccessLevel, string? TenantId` +- **Logic**: Validate file size -> Check user quota -> Generate object key -> Ensure bucket exists -> Generate pre-signed PUT URL +- **Validator**: Same rules as UploadFileCommand +- **Result**: `SignUploadResult(bool Success, string? UploadUrl, string? ObjectKey, DateTime? ExpiresAt, string? Error)` + +### ConfirmUploadCommand +- **Input**: `string UserId, string ObjectKey, string FileName, string ContentType, long FileSizeBytes, FileAccessLevel AccessLevel, string? TenantId` +- **Logic**: Validate object key ownership (userId in path) -> Verify file exists in storage -> Idempotency check (existing file by objectKey) -> Double-check quota -> Create StorageFile entity -> Update quota -> Invalidate caches +- **Validator**: UserId (required, max 128), ObjectKey (required, max 1024), FileName (required, max 255), ContentType (required, max 100), FileSizeBytes (> 0) +- **Result**: `ConfirmUploadResult(bool Success, Guid? FileId, FileDto? Metadata, string? Error)` + +### DeleteFileCommand +- **Input**: `Guid FileId, string UserId` +- **Logic**: Get file -> Check ownership -> Delete from storage provider (best-effort) -> Soft delete record -> Update quota -> Invalidate caches +- **Validator**: FileId (required), UserId (required, max 128) +- **Result**: `DeleteFileResult(bool Success, string? Error)` + +### CreateFileShareCommand +- **Input**: `Guid FileId, string UserId, SharePermission Permission, string? SharedWith, string? Password, DateTime? ExpiresAt, int? MaxDownloads` +- **Logic**: Verify file exists + ownership -> Create FileShare entity (generates random token, hashes password with PBKDF2) -> Save -> Return share URL +- **Validator**: FileId (required), UserId (required, max 128), SharedWith (max 256), Password (min 4, max 100), MaxDownloads (> 0, <= 10000), ExpiresAt (must be future) +- **Result**: `CreateFileShareResult(bool Success, Guid? ShareId, string? ShareToken, string? ShareUrl, string? Error)` + +### RevokeFileShareCommand +- **Input**: `Guid ShareId, string UserId` +- **Logic**: Get share -> Verify ownership -> Revoke (idempotent) -> Save +- **Result**: `RevokeFileShareResult(bool Success, string? Error)` + +### InitiateMultipartUploadCommand +- **Input**: `string FileName, long FileSizeBytes, string ContentType, string UserId, int? ChunkSizeBytes, FileAccessLevel AccessLevel, string? TenantId` +- **Logic**: Validate file size -> Check quota -> Determine chunk size (default 5MB, min 5MB, max 100MB) -> Generate object key -> Initiate multipart upload at provider -> Create MultipartUpload entity (expiration: 24h) -> Save +- **Result**: `InitiateMultipartUploadResult(... UploadId, ProviderUploadId, ObjectKey, BucketName, TotalChunks, ChunkSizeBytes, ExpiresAt ...)` + +### UploadPartCommand +- **Input**: `Guid UploadId, int PartNumber, Stream DataStream, string UserId` +- **Logic**: Get upload with parts -> Verify ownership -> Check status (InProgress) -> Check expiration -> Upload part to provider -> Add part to domain entity -> Save +- **Result**: `UploadPartResult(bool Success, string? ETag, string? Error)` + +### CompleteMultipartUploadCommand +- **Input**: `Guid UploadId, string UserId` +- **Logic**: Get upload with parts -> Verify ownership -> Domain validation (all parts uploaded) -> Complete at provider -> Create StorageFile entity -> Update quota -> Save +- **Result**: `CompleteMultipartUploadResult(bool Success, Guid? FileId, string? ObjectKey, string? Error)` + +### AbortMultipartUploadCommand +- **Input**: `Guid UploadId, string UserId` +- **Logic**: Get upload -> Verify ownership -> Abort at provider (best-effort) -> Mark as aborted in DB -> Save +- **Result**: `AbortMultipartUploadResult(bool Success, string? Error)` + +### AdminDeleteFileCommand +- **Input**: `Guid FileId, string AdminUserId, string Reason` +- **Logic**: Same as DeleteFileCommand but bypasses ownership check. Logs admin action with reason. +- **Result**: `AdminDeleteFileResult(bool Success, string? DeletedFileName, string? FileOwnerUserId, string? Error)` + +### AdminRevokeShareCommand +- **Input**: `Guid ShareId, string AdminUserId, string Reason` +- **Logic**: Get share -> Revoke -> Log admin action with reason -> Save +- **Result**: `AdminRevokeShareResult(bool Success, Guid? FileId, string? ShareOwnerUserId, string? Error)` + +### UpdateUserQuotaCommand +- **Input**: `string TargetUserId, long MaxStorageBytes, int MaxFileCount, string? QuotaTier` +- **Logic**: Get or create quota -> Update limits (validates max >= current usage) -> Invalidate cache -> Save +- **Validator**: TargetUserId (required, max 128), MaxStorageBytes (> 0), MaxFileCount (> 0), QuotaTier (max 50) +- **Result**: `UpdateUserQuotaResult(bool Success, QuotaUpdatedDto? Data, string? Error)` + +--- + +## Queries + +### GetFileQuery +- **Input**: `Guid FileId, string UserId` +- **Logic**: Cache-aside pattern (10min TTL) -> Get from DB -> Check ownership +- **Result**: `FileDto?` + +### GetUserFilesQuery +- **Input**: `string UserId, int Skip, int Take, string? SearchTerm` +- **Logic**: If search term: search by filename; otherwise: list by userId -> Count total -> Map to DTOs +- **Result**: `UserFilesResult(IReadOnlyList Files, int TotalCount)` + +### GetUserQuotaQuery +- **Input**: `string UserId` +- **Logic**: Cache-aside pattern (5min TTL) -> GetOrCreate quota -> Map to DTO +- **Result**: `QuotaDto?` + +### GetDownloadUrlQuery +- **Input**: `Guid FileId, string UserId, int ExpirationSeconds` +- **Logic**: Get file -> Check access (private files: owner only) -> Mark accessed -> Generate pre-signed download URL +- **Result**: `DownloadUrlResult(bool Success, string? Url, int? ExpiresInSeconds, string? Error)` + +### GetFileSharesQuery +- **Input**: `Guid FileId, string UserId` +- **Logic**: Verify file ownership -> Get all shares for file -> Map to DTOs +- **Result**: `IEnumerable` + +### AccessSharedFileQuery +- **Input**: `string Token, string? Password` +- **Logic**: Get share by token -> Validate (not expired/revoked/limit reached) -> Validate password (PBKDF2) -> Get file -> Generate pre-signed download URL -> Increment download count -> Save +- **Result**: `SharedFileAccessResult(bool Success, string? DownloadUrl, string? FileName, ...)` + +### GetMultipartUploadProgressQuery +- **Input**: `Guid UploadId, string UserId` +- **Logic**: Get upload with parts -> Verify ownership -> Calculate progress +- **Result**: `MultipartUploadProgressResult?` + +### AdminGetFilesQuery +- **Input**: `int PageNumber, int PageSize, string? UserId, string? AccessLevel, string? ContentType, DateTime? UploadedAfter/Before, string? SortBy, bool Descending` +- **Logic**: IgnoreQueryFilters (includes soft-deleted) -> Apply filters -> Sort -> Paginate +- **Result**: `AdminFilesResult(IReadOnlyList Items, int TotalCount, int PageNumber, int PageSize, int TotalPages)` + +### GetAllUsersQuotaQuery +- **Input**: `int PageNumber, int PageSize, string? QuotaTier, double? MinUsagePercentage, string? SortBy, bool Descending` +- **Logic**: Filter by tier, min usage% -> Sort -> Paginate +- **Result**: `AllUsersQuotaResult(IReadOnlyList Items, ...)` + +### GetStorageStatisticsQuery +- **Input**: (none) +- **Logic**: Aggregate stats: total users, total storage used/allocated, total files, avg usage%, users by tier, users near limit (>80%), users over limit (>=100%) +- **Result**: `StorageStatisticsDto` + +### AdminGetSharesQuery +- **Input**: `int PageNumber, int PageSize, string? Status, string? SharedBy` +- **Logic**: Filter by status, sharedBy -> Paginate -> Order by CreatedAt desc +- **Result**: `AdminSharesResult(IReadOnlyList Items, ...)` + +--- + +## Domain Model + +### StorageFile (Aggregate Root) +- **Table**: `storage_files` +- **Fields**: Id, FileName, BucketName, ObjectKey, ContentType, FileSizeBytes, UserId, TenantId, Provider (enum->string), AccessLevel (enum->string), UploadedAt, LastAccessedAt, ExpiresAt, Checksum, IsDeleted, DeletedAt +- **Behavior Methods**: `MarkAccessed()`, `UpdateAccessLevel(level)`, `Delete()` (soft), `SetExpiration(dt)`, `UpdateFromVersion(objectKey, size, contentType)` +- **Domain Events**: `FileUploadedDomainEvent` (on create), `FileDeletedDomainEvent` (on delete) +- **Query Filter**: `!IsDeleted` (global) + +### UserStorageQuota (Aggregate Root) +- **Table**: `user_storage_quotas` +- **Fields**: Id, UserId, MaxStorageBytes (default 1GB), UsedStorageBytes, MaxFileCount (default 1000), CurrentFileCount, QuotaTier (default "free"), LastUpdatedAt, CreatedAt +- **Computed**: `RemainingStorageBytes`, `RemainingFileCount`, `UsagePercentage` +- **Behavior Methods**: `CanUpload(size)`, `AddUsage(size, count)`, `RemoveUsage(size, count)`, `UpdateLimits(max, maxFiles, tier)`, `RecalculateUsage(totalBytes, totalFiles)` +- **Domain Events**: `UserQuotaUpdatedDomainEvent` (on AddUsage/RemoveUsage) +- **Validation**: `UpdateLimits` throws if new max < current usage + +### FileShare (Aggregate Root) +- **Table**: `file_shares` +- **Fields**: Id, FileId (FK -> storage_files), SharedBy, SharedWith, Permission (enum->string), ShareToken (unique, 32 random bytes base64), PasswordHash (PBKDF2-SHA256, 100K iterations), ExpiresAt, MaxDownloads, DownloadCount, Status (enum->string), CreatedAt, RevokedAt +- **Behavior Methods**: `IsValid()` (checks status, expiry, download limit), `ValidatePassword(pwd)`, `IncrementDownloadCount()`, `Revoke()` +- **Enums**: `SharePermission` (View=0, Download=1, Edit=2, Admin=3), `FileShareStatus` (Active=0, Expired=1, Revoked=2, LimitReached=3) + +### FileVersion (Entity) +- **Table**: `file_versions` +- **Fields**: Id, FileId (FK -> storage_files), VersionNumber, ObjectKey, SizeBytes, ContentType, Checksum, CreatedAt, CreatedBy, Comment, IsCurrent +- **Behavior Methods**: `MarkAsCurrent()`, `UnmarkAsCurrent()` + +### Folder (Aggregate Root) +- **Table**: `folders` +- **Fields**: Id, UserId, ParentId (FK self-referencing, restrict delete), Name, Path (materialized path e.g. `/parent/child/`), Level (0=root), CreatedAt, UpdatedAt, IsDeleted, DeletedAt +- **Behavior Methods**: `CreateRoot(userId, name)` (static), `CreateChild(name)`, `Rename(newName)`, `MoveTo(newParent)`, `Delete()` (soft) +- **Design Note**: Folders are LOGICAL only. Storage uses flat UUID keys. Rename/move is O(1) -- database-only. +- **Query Filter**: `!IsDeleted` (global) + +### MultipartUpload (Aggregate Root) +- **Table**: `multipart_uploads` +- **Fields**: Id, UserId, FileName, TotalSizeBytes, ChunkSizeBytes, TotalChunks (computed), UploadedChunks, ProviderUploadId, BucketName, ObjectKey, ContentType, Status (enum->string), CreatedAt, CompletedAt, ExpiresAt +- **Behavior Methods**: `AddPart(partNum, etag, size)`, `Complete()` (validates all parts uploaded), `Abort()`, `MarkFailed()`, `IsExpired()`, `GetProgressPercentage()` +- **Domain Events**: `MultipartUploadInitiatedDomainEvent`, `MultipartUploadCompletedDomainEvent`, `MultipartUploadAbortedDomainEvent` +- **Child Entity**: `MultipartUploadPart` (Id, MultipartUploadId, PartNumber, ETag, SizeBytes, UploadedAt) +- **Enums**: `MultipartUploadStatus` (InProgress=0, Completed=1, Aborted=2, Failed=3, Expired=4) + +--- + +## Database Schema + +### Table: `storage_files` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK | +| file_name | varchar(255) | NOT NULL | +| bucket_name | varchar(100) | NOT NULL | +| object_key | varchar(500) | NOT NULL, UNIQUE | +| content_type | varchar(100) | NOT NULL | +| file_size_bytes | bigint | NOT NULL | +| user_id | varchar(100) | NOT NULL | +| tenant_id | varchar(100) | nullable | +| provider | varchar (string enum) | NOT NULL | +| access_level | varchar (string enum) | NOT NULL | +| uploaded_at | timestamp | NOT NULL | +| last_accessed_at | timestamp | nullable | +| expires_at | timestamp | nullable | +| checksum | varchar(100) | nullable | +| is_deleted | boolean | NOT NULL, default false | +| deleted_at | timestamp | nullable | + +**Indexes**: object_key (unique), user_id, tenant_id, uploaded_at, is_deleted +**Query Filter**: `is_deleted = false` + +### Table: `user_storage_quotas` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK | +| user_id | varchar(100) | NOT NULL, UNIQUE | +| max_storage_bytes | bigint | NOT NULL | +| used_storage_bytes | bigint | NOT NULL | +| max_file_count | int | NOT NULL | +| current_file_count | int | NOT NULL | +| quota_tier | varchar(50) | nullable | +| created_at | timestamp | NOT NULL | +| last_updated_at | timestamp | NOT NULL | + +**Indexes**: user_id (unique) + +### Table: `file_shares` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK | +| file_id | uuid | NOT NULL, FK -> storage_files (CASCADE) | +| shared_by | varchar(255) | NOT NULL | +| shared_with | varchar(255) | nullable | +| permission | varchar(50) (string enum) | NOT NULL | +| share_token | varchar(255) | NOT NULL, UNIQUE | +| password_hash | varchar(255) | nullable | +| expires_at | timestamp | nullable | +| max_downloads | int | nullable | +| download_count | int | NOT NULL, default 0 | +| status | varchar(50) (string enum) | NOT NULL | +| created_at | timestamp | NOT NULL | +| revoked_at | timestamp | nullable | + +**Indexes**: file_id, share_token (unique), shared_with, shared_by + +### Table: `file_versions` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK | +| file_id | uuid | NOT NULL, FK -> storage_files (CASCADE) | +| version_number | int | NOT NULL | +| object_key | varchar(500) | NOT NULL | +| size_bytes | bigint | NOT NULL | +| content_type | varchar(100) | NOT NULL | +| checksum | varchar(100) | nullable | +| created_at | timestamp | NOT NULL | +| created_by | varchar(255) | NOT NULL | +| comment | varchar(500) | nullable | +| is_current | boolean | default false | + +**Indexes**: file_id, (file_id + version_number) unique, (file_id + is_current) + +### Table: `folders` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK | +| user_id | varchar(255) | NOT NULL | +| parent_id | uuid | nullable, FK self (RESTRICT) | +| name | varchar(255) | NOT NULL | +| path | varchar(1000) | NOT NULL | +| level | int | NOT NULL | +| created_at | timestamp | NOT NULL | +| updated_at | timestamp | NOT NULL | +| is_deleted | boolean | NOT NULL, default false | +| deleted_at | timestamp | nullable | + +**Indexes**: user_id, parent_id, (user_id + parent_id + name) unique filtered `is_deleted = false`, path +**Query Filter**: `is_deleted = false` + +### Table: `multipart_uploads` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK | +| user_id | varchar(255) | NOT NULL | +| file_name | varchar(255) | NOT NULL | +| total_size_bytes | bigint | NOT NULL | +| chunk_size_bytes | int | NOT NULL | +| total_chunks | int | NOT NULL | +| uploaded_chunks | int | NOT NULL | +| upload_id | varchar(255) | NOT NULL (provider upload ID) | +| bucket_name | varchar(255) | NOT NULL | +| object_key | varchar(500) | NOT NULL | +| content_type | varchar(100) | NOT NULL | +| status | varchar(50) (string enum) | NOT NULL | +| created_at | timestamp | NOT NULL | +| completed_at | timestamp | nullable | +| expires_at | timestamp | NOT NULL | + +**Indexes**: (user_id + status), upload_id, created_at + +### Table: `multipart_upload_parts` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | uuid | PK | +| multipart_upload_id | uuid | NOT NULL, FK -> multipart_uploads (CASCADE) | +| part_number | int | NOT NULL | +| etag | varchar(255) | NOT NULL | +| size_bytes | bigint | NOT NULL | +| uploaded_at | timestamp | NOT NULL | + +**Indexes**: (multipart_upload_id + part_number) unique + +### Migrations +1. `20260112185402_InitialCreate` - storage_files, user_storage_quotas +2. `20260113155939_AddMultipartUploadTables` - multipart_uploads, multipart_upload_parts +3. `20260113170635_AddFileSharing` - file_shares +4. `20260113171238_AddFileVersioning` - file_versions +5. `20260113171612_AddFolders` - folders + +--- + +## Dependencies + +### NuGet Packages + +**API Layer:** +- MediatR 12.4.1 +- FluentValidation 11.11.0 +- Microsoft.AspNetCore.Authentication.JwtBearer 10.0.1 +- Swashbuckle.AspNetCore 7.2.0 +- Asp.Versioning.Mvc 8.1.0 +- AspNetCore.HealthChecks.NpgSql 8.0.2 +- AspNetCore.HealthChecks.Redis 8.0.1 +- Hellang.Middleware.ProblemDetails 6.5.1 +- Serilog.AspNetCore 8.0.3 + +**Domain Layer:** +- MediatR.Contracts 2.0.1 + +**Infrastructure Layer:** +- Microsoft.EntityFrameworkCore 10.0.0 +- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 +- MediatR 12.4.1 +- Dapper 2.1.35 +- Polly 8.5.0 +- StackExchange.Redis 2.8.16 +- Minio 6.0.4 +- AWSSDK.S3 3.7.400 (for low-level multipart upload) +- Aliyun.OSS.SDK.NetCore 2.14.1 + +### External Services +- **IAM Service** (`http://localhost:5001`) - JWT token validation +- **MinIO** (`167.114.174.113:9000`) - Object storage backend +- **Redis** (`167.114.174.113:6379`) - Caching (quota, file metadata, user files) +- **PostgreSQL** (Neon) - Metadata persistence + +--- + +## Configuration + +### appsettings.json + +| Section | Key | Default | Description | +|---------|-----|---------|-------------| +| ConnectionStrings | DefaultConnection | Neon PostgreSQL | Database connection | +| Storage | Provider | `minio` | Active provider (`minio` or `aliyun`) | +| Storage | DefaultBucket | `goodgo` | Default S3 bucket | +| Storage | PreSignedUrlExpirationSeconds | 3600 | Pre-signed URL TTL | +| Storage | MaxFileSizeBytes | 104857600 (100MB) | Max upload size | +| Storage:MinIO | Endpoint | `167.114.174.113:9000` | MinIO endpoint | +| Storage:MinIO | AccessKey | minioadmin | MinIO access key | +| Storage:MinIO | UseSSL | false | SSL for MinIO | +| Storage:MinIO | Region | us-east-1 | MinIO region | +| Storage:AliyunOSS | Endpoint | (empty) | Aliyun OSS endpoint | +| Redis | Host | `167.114.174.113` | Redis host | +| Redis | Port | 6379 | Redis port | +| Redis | Database | 0 | Redis database index | +| Jwt | Secret | goodgo-iam-service-secret-key-32chars! | JWT secret | +| Jwt | Issuer | goodgo-platform | JWT issuer | +| Jwt | AccessTokenExpiryMinutes | 15 | Token TTL | +| CDN | Enabled | false | CDN integration toggle | +| CDN | BaseUrl | (empty) | CDN base URL | +| IamService | BaseUrl | `http://localhost:5001` | IAM service URL | +| IamService | TimeoutSeconds | 30 | HTTP timeout | + +--- + +## Architecture Notes + +### Upload Patterns +1. **Server-Side Upload** (`POST /files/upload`): File goes through the API, uploaded to MinIO by the handler. Simple but doesn't scale for millions of concurrent uploads. +2. **Direct Client Upload** (`POST /storage/sign-upload` + `POST /storage/confirm-upload`): Client gets pre-signed PUT URL, uploads directly to MinIO, then confirms. Bypasses backend for file transfer -- highly scalable. +3. **Multipart Upload** (`POST /files/multipart/initiate` -> upload parts -> `POST /files/multipart/complete`): For large files, uploaded in 5MB chunks. 24-hour expiration. + +### Caching Strategy +- **File metadata**: Redis cache-aside, 10min TTL +- **User quota**: Redis cache-aside, 5min TTL +- Cache invalidation on: file delete, upload confirm, quota update + +### Security +- All user-facing endpoints require JWT Bearer auth +- File ownership validation on all operations (except admin endpoints) +- Share passwords hashed with PBKDF2-SHA256 (100K iterations, 16-byte salt) +- Share tokens: 32 random bytes, base64url-encoded +- Admin endpoints require `Admin` or `SuperAdmin` role +- Object key ownership validation via path structure (`{access}/{userId}/...`) + +### Object Key Structure +``` +{accessLevel}/{userId}/{yyyyMMdd}/{8-char-uuid}_{sanitized-filename} +``` +- accessLevel: `public`, `private`, or `shared` +- File names sanitized (invalid chars removed, max 100 chars + extension) + +### MediatR Pipeline +`LoggingBehavior` -> `ValidatorBehavior` -> `TransactionBehavior` -> Handler + +### Resilience +- PostgreSQL: retry on failure (5 retries, 30s max delay) +- IAM HTTP client: Polly retry (3 retries, exponential backoff) + circuit breaker (5 failures, 30s break) +- Storage delete: best-effort (continues with soft delete even if provider delete fails) +- Multipart upload abort: best-effort provider cleanup + +### Tests +- **Unit Tests**: StorageFile, FileShare, Folder domain entity tests; UploadFileCommandHandler, DeleteFileCommandHandler, SignUploadCommandHandler, FileShareCommandHandler tests +- **Functional Tests**: FilesApi, FileSharingApi, SignedUrlApi tests with CustomWebApplicationFactory (InMemory DB) diff --git a/services/wallet-service-net/SERVICE_DOCS.md b/services/wallet-service-net/SERVICE_DOCS.md new file mode 100644 index 00000000..2e46ac8f --- /dev/null +++ b/services/wallet-service-net/SERVICE_DOCS.md @@ -0,0 +1,600 @@ +# WalletService - Service Documentation + +> Auto-generated from source code audit. Last updated: 2026-03-13. + +## Overview + +**WalletService** is a microservice responsible for digital wallet management, loyalty points, escrow/hold operations, and payment gateway integration. It follows Clean Architecture + CQRS patterns with MediatR. + +- **Port**: 5004 (Development) +- **Database**: `wallet_service` (Neon PostgreSQL) +- **Framework**: .NET 10.0, ASP.NET Core +- **Auth**: JWT Bearer (Duende IdentityServer) +- **Payment Gateway**: VNPay (sandbox, API v2.1.0) +- **Multi-tenancy**: User-level tenant isolation via EF Core global query filters + PostgreSQL RLS session variables +- **Multi-currency**: VND (id=1), USD (id=2), PPoint/Loyalty Points (id=3) + +### Key Capabilities + +1. **Wallet Management** - Create, deposit, withdraw, transfer, freeze/unfreeze/close wallets with multi-currency support +2. **Currency Exchange** - Atomic in-wallet exchange between VND, USD, and PPoint with configurable rates +3. **Loyalty Points** - Separate point account system with earn, spend, expire, bonus, and admin adjustment +4. **Escrow/Holds** - Lock funds for campaigns/orders with partial execute/release/cancel +5. **Payment Gateway** - VNPay integration for external payments with IPN callback validation +6. **Admin Backoffice** - Full admin CRUD, statistics, search, freeze/unfreeze, balance/points adjustment + +--- + +## API Endpoints + +### Wallets (`/api/v1/wallets`) - [Authorize] + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/{userId:guid}` | Get wallet by user ID | Bearer | +| POST | `/` | Create new wallet | Bearer | +| POST | `/{userId:guid}/deposit` | Deposit money into wallet | Bearer | +| POST | `/{userId:guid}/withdraw` | Withdraw money from wallet | Bearer | +| GET | `/{userId:guid}/transactions` | Get wallet transactions (paginated) | Bearer | + +### Points (`/api/v1/points`) - [Authorize] + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/{userId:guid}` | Get point account by user ID | Bearer | +| POST | `/` | Create new point account | Bearer | +| POST | `/{userId:guid}/earn` | Earn points | Bearer | +| POST | `/{userId:guid}/spend` | Spend points | Bearer | +| GET | `/{userId:guid}/transactions` | Get point transactions (paginated) | Bearer | + +### Payments (`/api/v1/payments`) + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| POST | `/create` | Create payment via gateway (VNPay) | Bearer | +| GET | `/{orderId:guid}` | Get payment by order ID | Bearer | +| GET | `/vnpay/callback` | VNPay IPN callback | Anonymous | +| GET | `/vnpay/return` | VNPay customer return URL | Anonymous | + +### Escrow/Holds (`/api/v1/wallets/{walletId:guid}/holds`) - [Authorize] + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| POST | `/` | Create escrow hold | Bearer | +| GET | `/{holdId:guid}` | Get hold by ID (501 Not Implemented) | Bearer | +| POST | `/{holdId:guid}/execute` | Execute (commit) hold portion | Bearer | +| POST | `/{holdId:guid}/release` | Release hold portion back to wallet | Bearer | +| POST | `/{holdId:guid}/cancel` | Cancel hold, release all remaining | Bearer | + +### Admin Wallets (`/api/v1/admin/wallets`) - [Authorize(Roles = "Admin,SuperAdmin")] + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/` | Get all wallets (paginated, filterable) | Admin | +| GET | `/{walletId:guid}` | Get wallet by ID with details | Admin | +| POST | `/{walletId:guid}/freeze` | Freeze wallet | Admin | +| POST | `/{walletId:guid}/unfreeze` | Unfreeze wallet | Admin | +| POST | `/{walletId:guid}/adjust` | Adjust wallet balance (credit/debit) | Admin | +| GET | `/statistics` | Get wallet statistics | Admin | +| GET | `/search` | Search wallets by userId/walletId/status | Admin | + +### Admin Points (`/api/v1/admin/points`) - [Authorize(Roles = "Admin,SuperAdmin")] + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/` | Get all point accounts (paginated, filterable) | Admin | +| GET | `/{accountId:guid}` | Get point account by ID | Admin | +| POST | `/{accountId:guid}/adjust` | Adjust points (add/subtract) | Admin | +| POST | `/{accountId:guid}/bonus` | Grant bonus points | Admin | +| GET | `/statistics` | Get points statistics | Admin | +| GET | `/search` | Search point accounts by userId | Admin | + +### Health (`/api/v1/health`) - [AllowAnonymous] + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/live` | Liveness probe | +| GET | `/ready` | Readiness probe | + +Also mapped via `MapHealthChecks`: `/health`, `/health/live`, `/health/ready` + +--- + +## Commands + +### Wallet Commands + +| Command | Input | Logic | Validator | +|---------|-------|-------|-----------| +| `CreateWalletCommand` | UserId, Currency="VND" | Check duplicate by userId, parse CurrencyType, create Wallet + initial WalletItem(balance=0), raise WalletCreatedDomainEvent | UserId NotEmpty; Currency NotEmpty, MaxLength(10) | +| `DepositCommand` | UserId, Amount, Description, ReferenceId? | Get wallet by userId, deposit with default CurrencyType, add WalletTransaction(Credit), raise WalletBalanceChangedDomainEvent | UserId NotEmpty; Amount > 0, <= 1B; Description NotEmpty, MaxLength(500); ReferenceId MaxLength(100) | +| `WithdrawCommand` | UserId, Amount, Description, ReferenceId? | Get wallet by userId, validate sufficient balance, withdraw with default CurrencyType, add WalletTransaction(Debit), raise WalletBalanceChangedDomainEvent | Same as Deposit | +| `ExchangeCommand` | UserId, FromAmount, FromCurrencyTypeId, ToCurrencyTypeId, CustomRate? | Get wallet, parse CurrencyTypes from IDs, calculate rate (custom or from BaseExchangeRate), atomic Withdraw + Deposit within aggregate, raise WalletExchangedDomainEvent | UserId NotEmpty; FromAmount > 0; From/ToCurrencyTypeId > 0 and not equal; CustomRate > 0 (if provided) | + +### Escrow Commands + +| Command | Input | Logic | Validator | +|---------|-------|-------|-----------| +| `CreateHoldCommand` | UserId, Amount, CurrencyCode, ReferenceType, ReferenceId, Description, ExpiresAt? | Get wallet by userId, parse CurrencyType, check balance, subtract from available, create HoldItem, add WalletTransaction(HoldCreated), raise EscrowHeldDomainEvent | None | +| `ExecuteHoldCommand` | WalletId, HoldId, Amount, ExecutionRef? | Get wallet by ID, find hold, validate active+not expired, execute portion, add WalletTransaction(HoldExecuted), raise EscrowExecutedDomainEvent | None | +| `ReleaseHoldCommand` | WalletId, HoldId, Amount? (null=all) | Get wallet by ID, find hold, release portion back to available balance, add WalletTransaction(HoldReleased), raise EscrowReleasedDomainEvent | None | +| `CancelHoldCommand` | WalletId, HoldId | Get wallet by ID, release all remaining hold amount back to wallet | None | + +### Points Commands + +| Command | Input | Logic | Validator | +|---------|-------|-------|-----------| +| `CreatePointAccountCommand` | UserId | Check duplicate, create PointAccount with 0 points | UserId NotEmpty | +| `EarnPointsCommand` | UserId, Points, Source, Description, ExpiryMonths?=12 | Get account by userId, earn points, add PointTransaction(Earn), raise PointsEarnedDomainEvent | UserId NotEmpty; Points > 0; Source NotEmpty, MaxLength(100); Description NotEmpty, MaxLength(500); ExpiryMonths > 0, <= 120 | +| `SpendPointsCommand` | UserId, Points, Source, Description | Get account by userId, validate sufficient points, spend, add PointTransaction(Spend), raise PointsSpentDomainEvent | UserId NotEmpty; Points > 0; Source NotEmpty, MaxLength(100); Description NotEmpty, MaxLength(500) | + +### Payment Commands + +| Command | Input | Logic | Validator | +|---------|-------|-------|-----------| +| `CreatePaymentCommand` | OrderId, Amount, Currency, GatewayName, ReturnUrl, IpAddress | Find gateway by name, create Payment entity, call gateway.CreatePaymentAsync, mark Processing or Failed, raise PaymentCreatedDomainEvent | OrderId NotEmpty; Amount > 0; Currency NotEmpty, MaxLength(10); GatewayName NotEmpty, MaxLength(50); ReturnUrl valid URL; IpAddress NotEmpty | +| `ProcessPaymentCallbackCommand` | GatewayName, Parameters (dict) | Find gateway, validate HMAC-SHA512 hash, find payment by orderId (vnp_TxnRef), complete or fail based on vnp_ResponseCode, raise PaymentCompletedDomainEvent or PaymentFailedDomainEvent | GatewayName NotEmpty; Parameters NotNull, Count > 0 | + +### Admin Commands + +| Command | Input | Logic | Validator | +|---------|-------|-------|-----------| +| `AdminFreezeWalletCommand` | WalletId, Reason, AdminId | Get wallet by ID, call Freeze() (status Active->Frozen) | None | +| `AdminUnfreezeWalletCommand` | WalletId, Reason, AdminId | Get wallet by ID, call Unfreeze() (status Frozen->Active) | None | +| `AdminAdjustBalanceCommand` | WalletId, Amount, CurrencyTypeId, Reason, AdminId | Get wallet, positive amount=Deposit, negative=Withdraw, with admin reason | None | +| `AdminAdjustPointsCommand` | AccountId, Points, Reason, AdminId | Get point account by ID, call AdjustPoints (positive=add, negative=subtract) | None | +| `AdminGrantBonusCommand` | AccountId, Points, Reason, ExpiryMonths?, AdminId | Get point account, call AddBonusPoints with expiry date | None | + +--- + +## Queries + +| Query | Input | Output | Logic | +|-------|-------|--------|-------| +| `GetWalletQuery` | UserId | WalletDto? (Id, UserId, Balance, Currency, Status, CreatedAt, UpdatedAt) | Get wallet by userId, return default currency balance | +| `GetWalletTransactionsQuery` | UserId, Page=1, PageSize=20 | WalletTransactionsDto (paginated list) | Get wallet, then paginated transactions | +| `GetPointAccountQuery` | UserId | PointAccountDto? (Id, UserId, TotalPoints, AvailablePoints, dates) | Get point account by userId | +| `GetPointTransactionsQuery` | UserId, Page=1, PageSize=20 | PointTransactionsDto (paginated list) | Get account, then paginated transactions | +| `GetPaymentByOrderIdQuery` | OrderId | PaymentDto? | Find payment by orderId | +| `GetPaymentByTransactionIdQuery` | TransactionId (string) | PaymentDto? | Find payment by gateway transaction ID | +| `GetAllWalletsQuery` | Page, PageSize, Status?, Currency? | AdminWalletsListDto (paginated) | Admin: filter by status, include balances | +| `GetWalletByIdQuery` | WalletId | AdminWalletDetailDto? | Admin: include balances + transactions count | +| `SearchWalletsQuery` | UserId?, WalletId?, Status? | List\ (max 50) | Admin: flexible search | +| `GetWalletStatisticsQuery` | (none) | WalletStatisticsDto | Admin: total/active/frozen/closed counts, balance by currency, today's transactions | +| `GetAllPointAccountsQuery` | Page, PageSize, MinPoints?, MaxPoints? | AdminPointAccountsListDto (paginated) | Admin: filter by point range | +| `GetPointAccountByIdQuery` | AccountId | AdminPointAccountDetailDto? | Admin: include transaction count | +| `SearchPointAccountsQuery` | UserId? | List\ (max 50) | Admin: search by userId | +| `GetPointsStatisticsQuery` | (none) | PointsStatisticsDto | Admin: totals for issued/available/spent/expired points, today's activity | + +--- + +## Domain Model + +### Aggregate: Wallet (Root) + +**Entity**: `Wallet` (extends Entity, IAggregateRoot) + +| Property | Type | Description | +|----------|------|-------------| +| Id | Guid | Wallet ID (generated) | +| UserId | Guid | User ID from IAM Service | +| DefaultCurrencyTypeId | int | Default currency (1=VND, 2=USD, 3=PPoint) | +| StatusId | int | Wallet status (1=Active, 2=Frozen, 3=Closed) | +| CreatedAt | DateTime | Creation timestamp | +| UpdatedAt | DateTime | Last update timestamp | +| Balances | IReadOnlyCollection\ | Multi-currency balances (one per currency) | +| Transactions | IReadOnlyCollection\ | Transaction history | +| Holds | IReadOnlyCollection\ | Active escrow holds | + +**Behavior Methods**: +- `Deposit(amount, currencyType, description, referenceId?)` - Add funds +- `Withdraw(amount, currencyType, description, referenceId?)` - Remove funds (validates balance) +- `Exchange(fromAmount, fromCurrency, toCurrency, customRate?)` - Atomic currency exchange +- `TransferOut(amount, toWalletId, description)` - Send to another wallet +- `TransferIn(amount, fromWalletId, description)` - Receive from another wallet +- `Hold(amount, currencyType, referenceType, referenceId, description, expiresAt?)` - Create escrow hold +- `ExecuteHold(holdId, amount, executionRef?)` - Commit hold portion +- `ReleaseHold(holdId, amount?)` - Return hold back to wallet +- `CancelHold(holdId)` - Release all remaining +- `Freeze()` - Status -> Frozen (blocks transactions) +- `Unfreeze()` - Status -> Active +- `Close()` - Status -> Closed (requires zero balances) + +**Entity**: `WalletItem` (child of Wallet) + +| Property | Type | Description | +|----------|------|-------------| +| Id | Guid | Item ID | +| WalletId | Guid | Parent wallet FK | +| CurrencyTypeId | int | Currency type | +| Balance | decimal | Current balance | +| CreatedAt / UpdatedAt | DateTime | Timestamps | + +**Entity**: `WalletTransaction` + +| Property | Type | Description | +|----------|------|-------------| +| Id | Guid | Transaction ID | +| WalletId | Guid | Parent wallet FK | +| Amount | Money (value object) | Amount + Currency | +| TypeId | int | TransactionType enum ID | +| ReferenceId | string? | External reference | +| Description | string | Transaction description | +| BalanceAfter | decimal | Balance after this transaction | +| CreatedAt | DateTime | Timestamp | + +**Entity**: `HoldItem` (child of Wallet) + +| Property | Type | Description | +|----------|------|-------------| +| Id | Guid | Hold ID | +| WalletId | Guid | Parent wallet FK | +| OriginalAmount | decimal | Initial held amount | +| RemainingAmount | decimal | Currently held | +| ExecutedAmount | decimal | Total committed | +| ReleasedAmount | decimal | Total returned | +| CurrencyTypeId | int | Currency type | +| ReferenceType | string | e.g., "CAMPAIGN", "ORDER" | +| ReferenceId | Guid | External reference ID | +| Description | string | Hold description | +| StatusId | int | HoldStatus enum ID | +| ExpiresAt | DateTime? | Optional expiration | +| CreatedAt / UpdatedAt | DateTime | Timestamps | + +**Value Object**: `Money` +- `Amount` (decimal) + `Currency` (string, uppercase) +- Methods: Add, Subtract, IsGreaterThanOrEqual + +**Enumerations**: + +| Enumeration | Values | +|-------------|--------| +| CurrencyType | VND(1, rate=1), USD(2, rate=25000), PPoint(3, rate=1000) | +| TransactionType | Credit(1), Debit(2), TransferOut(3), TransferIn(4), Refund(5), HoldCreated(6), HoldExecuted(7), HoldReleased(8) | +| WalletStatus | Active(1), Frozen(2), Closed(3) | +| HoldStatus | Active(1), PartiallyReleased(2), Released(3), Executed(4), Cancelled(5) | + +### Aggregate: PointAccount (Root) + +**Entity**: `PointAccount` (extends Entity, IAggregateRoot) + +| Property | Type | Description | +|----------|------|-------------| +| Id | Guid | Account ID | +| UserId | Guid | User ID from IAM Service | +| TotalPoints | long | Lifetime earned points | +| AvailablePoints | long | Currently usable points | +| CreatedAt / UpdatedAt | DateTime | Timestamps | +| Transactions | IReadOnlyCollection\ | Transaction history | + +**Behavior Methods**: +- `EarnPoints(points, source, description, expiresAt?)` - Add points +- `SpendPoints(points, source, description)` - Deduct points (validates balance) +- `ExpirePoints(points, description)` - System expiration +- `AddBonusPoints(points, source, description, expiresAt?)` - Special promotion bonus +- `AdjustPoints(adjustment, reason)` - Admin manual adjustment + +**Entity**: `PointTransaction` + +| Property | Type | Description | +|----------|------|-------------| +| Id | Guid | Transaction ID | +| AccountId | Guid | Parent account FK | +| Points | long | Point amount | +| TypeId | int | PointTransactionType enum ID | +| Source | string | e.g., order ID, "ADMIN" | +| Description | string | Transaction description | +| ExpiresAt | DateTime? | Point expiration date | +| BalanceAfter | long | Balance after transaction | +| CreatedAt | DateTime | Timestamp | + +**Enumeration**: PointTransactionType +- Earn(1), Spend(2), Expire(3), Adjust(4), Bonus(5) + +### Aggregate: Payment (Root) + +**Entity**: `Payment` (extends Entity, IAggregateRoot) - uses private fields + +| Property | Type | Description | +|----------|------|-------------| +| Id | Guid | Payment ID | +| OrderId | Guid | Associated order | +| Amount | decimal | Payment amount | +| Currency | string | Currency code | +| GatewayName | string | e.g., "VNPAY" | +| TransactionId | string? | Gateway transaction ID | +| PaymentUrl | string? | Customer redirect URL | +| StatusId | int | PaymentStatus enum ID | +| ErrorCode / ErrorMessage | string? | Failure details | +| CreatedAt | DateTime | Creation timestamp | +| CompletedAt | DateTime? | Completion timestamp | + +**Behavior Methods**: +- `MarkAsProcessing(paymentUrl, transactionId?)` - Pending -> Processing +- `Complete(transactionId)` - Pending/Processing -> Completed +- `Fail(errorCode, errorMessage)` - Pending/Processing -> Failed +- `Refund()` - Completed -> Refunded + +**Enumeration**: PaymentStatus +- Pending(1), Processing(2), Completed(3), Failed(4), Refunded(5) + +### Domain Events + +| Event | Properties | Raised When | +|-------|------------|-------------| +| `WalletCreatedDomainEvent` | WalletId, UserId, OccurredAt | Wallet constructor | +| `WalletBalanceChangedDomainEvent` | WalletId, UserId, TransactionType, Amount, NewBalance | Deposit, Withdraw, Transfer | +| `WalletExchangedDomainEvent` | WalletId, UserId, FromCurrencyId, ToCurrencyId, FromAmount, ToAmount, Rate | Currency exchange | +| `EscrowHeldDomainEvent` | WalletId, HoldId, UserId, ReferenceType, ReferenceId, Amount, CurrencyTypeId | Hold created | +| `EscrowExecutedDomainEvent` | WalletId, HoldId, UserId, Amount, RemainingAmount, ExecutionRef | Hold portion executed | +| `EscrowReleasedDomainEvent` | WalletId, HoldId, UserId, Amount, RemainingAmount | Hold portion released | +| `PointsEarnedDomainEvent` | AccountId, UserId, Points, NewBalance, Source | Points earned/bonus added | +| `PointsSpentDomainEvent` | AccountId, UserId, Points, NewBalance, Source | Points spent | +| `PaymentCreatedDomainEvent` | PaymentId, OrderId, Amount, Currency, GatewayName | Payment created | +| `PaymentCompletedDomainEvent` | PaymentId, OrderId, TransactionId, Amount, Currency | Payment completed | +| `PaymentFailedDomainEvent` | PaymentId, OrderId, ErrorCode, ErrorMessage | Payment failed | + +### Domain Exceptions + +| Exception | Base | Description | +|-----------|------|-------------| +| `WalletDomainException` | Exception | Base wallet domain error | +| `InsufficientBalanceException` | WalletDomainException | Balance < requested amount | +| `PointsDomainException` | Exception | Base points domain error | +| `InsufficientPointsException` | PointsDomainException | Points < requested amount | + +--- + +## Database Schema + +### Table: `wallets` + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | uuid | NO | PK, ValueGeneratedNever | +| user_id | uuid | NO | User ID from IAM | +| default_currency_type_id | int | NO | Default currency (default=1/VND) | +| status_id | int | NO | WalletStatus enum ID | +| created_at | timestamp | NO | Creation time | +| updated_at | timestamp | NO | Last update | + +**Indexes**: `ix_wallets_user_id` (unique) +**Query Filter**: Tenant isolation by `user_id` (bypassed for admin/service calls) + +### Table: `wallet_items` + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | uuid | NO | PK | +| wallet_id | uuid | NO | FK -> wallets | +| currency_type_id | int | NO | CurrencyType enum ID | +| balance | decimal(18,2) | NO | Current balance | +| created_at | timestamp | NO | | +| updated_at | timestamp | NO | | + +**Indexes**: `ix_wallet_items_wallet_currency` (unique: wallet_id + currency_type_id), `ix_wallet_items_wallet_id` +**Cascade**: Delete with parent wallet + +### Table: `wallet_transactions` + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | uuid | NO | PK | +| wallet_id | uuid | NO | FK -> wallets | +| amount | decimal(18,2) | NO | Transaction amount (owned: Money.Amount) | +| currency | varchar(3) | NO | Currency code (owned: Money.Currency) | +| type_id | int | NO | TransactionType enum ID | +| reference_id | varchar(100) | YES | External reference | +| description | varchar(500) | NO | Description | +| balance_after | decimal(18,2) | NO | Balance after transaction | +| created_at | timestamp | NO | | + +**Indexes**: `ix_wallet_transactions_wallet_id`, `ix_wallet_transactions_created_at` +**Cascade**: Delete with parent wallet + +### Table: `wallet_holds` + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | uuid | NO | PK | +| wallet_id | uuid | NO | FK -> wallets | +| original_amount | decimal(18,2) | NO | Initially held | +| remaining_amount | decimal(18,2) | NO | Currently held | +| executed_amount | decimal(18,2) | NO | Total committed | +| released_amount | decimal(18,2) | NO | Total returned | +| currency_type_id | int | NO | FK (restrict delete) | +| reference_type | varchar(50) | NO | e.g., "CAMPAIGN" | +| reference_id | uuid | NO | External ref ID | +| description | varchar(500) | NO | | +| status_id | int | NO | FK (restrict delete) | +| created_at | timestamp | NO | | +| updated_at | timestamp | NO | | +| expires_at | timestamp | YES | Optional expiry | + +**Indexes**: `ix_wallet_holds_wallet_id`, `ix_wallet_holds_reference` (reference_type + reference_id), `ix_wallet_holds_status_id` +**Cascade**: Delete with parent wallet + +### Table: `point_accounts` + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | uuid | NO | PK | +| user_id | uuid | NO | User ID from IAM | +| total_points | bigint | NO | Lifetime earned | +| available_points | bigint | NO | Currently usable | +| created_at | timestamp | NO | | +| updated_at | timestamp | NO | | + +**Indexes**: `ix_point_accounts_user_id` (unique) + +### Table: `point_transactions` + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | uuid | NO | PK | +| account_id | uuid | NO | FK -> point_accounts | +| points | bigint | NO | Point amount | +| type_id | int | NO | PointTransactionType enum ID | +| source | varchar(100) | NO | Source identifier | +| description | varchar(500) | NO | | +| expires_at | timestamp | YES | Point expiration | +| balance_after | bigint | NO | Balance after transaction | +| created_at | timestamp | NO | | + +**Indexes**: `ix_point_transactions_account_id`, `ix_point_transactions_created_at`, `ix_point_transactions_expires_at` +**Cascade**: Delete with parent account + +### Table: `payments` + +| Column | Type | Nullable | Description | +|--------|------|----------|-------------| +| id | uuid | NO | PK | +| order_id | uuid | NO | Associated order | +| amount | decimal(18,2) | NO | Payment amount | +| currency | varchar(10) | NO | Currency code | +| gateway_name | varchar(50) | NO | e.g., "VNPAY" | +| transaction_id | varchar(255) | YES | Gateway transaction ID | +| payment_url | varchar(2048) | YES | Customer redirect URL | +| status_id | int | NO | PaymentStatus enum ID | +| error_code | varchar(50) | YES | | +| error_message | varchar(500) | YES | | +| created_at | timestamp | NO | | +| completed_at | timestamp | YES | | + +**Indexes**: `ix_payments_order_id`, `ix_payments_transaction_id`, `ix_payments_status_id`, `ix_payments_gateway_name` + +### Migrations + +1. `20260117141548_AddWalletHolds` - Initial schema with holds +2. `20260306175521_PhaseTwo` - Phase 2 additions (payments, multi-currency, etc.) + +--- + +## Dependencies + +### NuGet Packages + +**API Layer**: +- MediatR 12.4.1 (CQRS) +- FluentValidation 11.11.0 + DI Extensions +- Microsoft.AspNetCore.Authentication.JwtBearer 10.0.1 +- Asp.Versioning.Mvc 8.1.0 +- AspNetCore.HealthChecks.NpgSql 8.0.2, Redis 8.0.1 +- Hellang.Middleware.ProblemDetails 6.5.1 +- Serilog.AspNetCore 8.0.3, Console 6.0.0, Seq 8.0.0 +- Swashbuckle.AspNetCore 7.2.0 + Annotations +- EF Core Design 10.0.0 + +**Domain Layer**: +- MediatR.Contracts 2.0.1 (domain events only) + +**Infrastructure Layer**: +- Microsoft.EntityFrameworkCore 10.0.0 +- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 +- MediatR 12.4.1 (domain event dispatch) +- Dapper 2.1.35 (read-optimized queries) +- Polly 8.5.0, Microsoft.Extensions.Http.Polly 9.0.0 +- StackExchange.Redis 2.8.16 + +### Service Dependencies + +- **IAM Service**: JWT token validation, user ID claims +- **Order Service**: OrderId referenced in Payments + +--- + +## Configuration + +### appsettings.json + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=..neon.tech;Database=wallet_service;..." + }, + "Redis": { + "ConnectionString": "localhost:6379" + }, + "Jwt": { + "Secret": "...", + "Issuer": "goodgo-platform", + "Audience": "goodgo-services", + "AccessTokenExpiryMinutes": 15, + "RefreshTokenExpiryDays": 7 + } +} +``` + +### appsettings.Development.json + +```json +{ + "VnPay": { + "TmnCode": "GOODGO01", + "HashSecret": "GOODGOSECRETKEYFORSANDBOX2026", + "PaymentUrl": "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html", + "ReturnUrl": "http://localhost:3001/payment/return", + "ApiUrl": "https://sandbox.vnpayment.vn/merchant_webapi/api/transaction" + } +} +``` + +### Middleware Pipeline Order + +1. Serilog request logging +2. ProblemDetails (RFC 7807) +3. Swagger (Development only) +4. CORS (AllowAny) +5. Routing +6. Authentication (JWT Bearer) +7. Authorization +8. TenantMiddleware (RLS - sets PostgreSQL session variables) +9. Health checks + Controllers + +### MediatR Pipeline Behaviors + +1. `LoggingBehavior<,>` - Request/response logging +2. `ValidatorBehavior<,>` - FluentValidation in pipeline +3. `TransactionBehavior<,>` - Auto transaction wrapping for commands + +--- + +## Multi-Tenancy + +- **Wallet isolation**: User-level via EF Core global query filter on `Wallet.UserId` +- **Bypass**: Admin users and service-to-service calls (via `X-Service-Call: internal` header) +- **PostgreSQL RLS**: TenantMiddleware sets `SET LOCAL app.current_shop_id` and `app.current_merchant_id` session variables +- **Adapter pattern**: `ITenantProvider` (API layer) -> `WalletTenantProviderAdapter` -> `IWalletTenantProvider` (Infrastructure layer) + +--- + +## Tests + +### Unit Tests (`tests/WalletService.UnitTests/`) + +- `Domain/WalletTests.cs` - Wallet entity behavior tests +- `Domain/PointAccountTests.cs` - PointAccount entity behavior tests +- `Domain/HoldItemTests.cs` - HoldItem entity behavior tests +- `Application/Commands/CreatePaymentCommandHandlerTests.cs` - Payment creation handler tests +- `Application/Commands/ProcessPaymentCallbackCommandHandlerTests.cs` - Callback processing tests +- `Application/EscrowCommandHandlersTests.cs` - Escrow command handler tests + +### Functional Tests (`tests/WalletService.FunctionalTests/`) + +- `CustomWebApplicationFactory.cs` - Test server setup +- `Controllers/HealthControllerTests.cs` - Health endpoint tests + +--- + +## Exchange Rates (Hardcoded in CurrencyType) + +| From | To | Rate | Example | +|------|----|------|---------| +| VND | USD | 0.00004 | 25,000 VND = 1 USD | +| VND | PPoint | 0.001 | 1,000 VND = 1 PPoint | +| USD | VND | 25,000 | 1 USD = 25,000 VND | +| USD | PPoint | 25 | 1 USD = 25 PPoint | +| PPoint | VND | 1,000 | 1 PPoint = 1,000 VND | +| PPoint | USD | 0.04 | 25 PPoint = 1 USD | + +Formula: `rate = fromCurrency.BaseExchangeRate / toCurrency.BaseExchangeRate`