Each SERVICE_DOCS.md documents: Overview, API Endpoints, Commands, Queries, Domain Model, Database Schema, Integration Events, Dependencies, Configuration. Generated by 23 parallel audit agents reading actual source code. Key corrections from audit: - inventory-service: 12 commands/6 queries (was listed as scaffold) - promotion-service: 12 commands/10 queries (was listed as 0) - mission-service: 4 commands/7 queries (was listed as 0) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
522 lines
24 KiB
Markdown
522 lines
24 KiB
Markdown
# 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.
|