# 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<string, string>? (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<AuctionDto>` |
| 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<BudgetPacerDto>` |
| 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<FrequencyCapDto>` |
| 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<T> { 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<string, string>)
- **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<AuctionDto>` - 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<BudgetPacerDto>` - 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<Bid>), `_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<Guid, int>), `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<INotification>), equality by Id
- `ValueObject` - immutable value comparison base
- `Enumeration` - type-safe enum pattern with parse/lookup
- `IAggregateRoot` - marker interface
- `IRepository<T>` - 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
