# 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: `whatsapp_service`

**DbContext**: `WhatsAppServiceContext` (file: `WhatsAppServiceContext.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<SampleViewModel>` | 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:<base64(nonce + tag + ciphertext)>`. 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)
