# 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\<AudienceDto\>) |
| 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\<AdDto\>) |
| 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\<TopAdvertiserDto\>) |
| 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\<CampaignDto\>, 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<AdDto>`
- **Handler**: Filters ads where ReviewStatus.Name == "Pending", ordered by CreatedAt ASC.

### GetTopAdvertisersQuery (defined in AdminReportsController)
- **Parameters**: `Limit` (int, default: 10)
- **Returns**: `List<TopAdvertiserDto>` (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<AudienceDto>`
- **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\<T\>**: 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": "<neon-postgresql-connection-string>"
  },
  "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.
