# 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<ConversionDto>` (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<PixelListDto>` (200) — **MOCK** |
| GET | `/{pixelId:guid}/events` | Get pixel event history | Query: `from?`, `to?`, `page=1`, `pageSize=50` | `List<PixelEventDto>` (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<ConversionDto>` (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<ConversionDto>` 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<T>**: 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
