# 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<Guid>)`, `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<OneTimePreKeyDto>)`
- **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<OneTimePreKeyDto>)`
- **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<ConversationParticipant>)` - Participants collection
- `_messages (List<Message>)` - 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<Guid> 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<OneTimePreKey>)` - 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<string, HashSet<string>>` 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.
