# FacebookService (mkt-facebook-service-net) - Service Documentation

## 1. Overview

**Purpose**: Facebook Messenger integration microservice for the GoodGo platform. Handles incoming webhook events from Facebook Messenger, manages conversations with customers, supports AI-powered chatbot flows, and provides APIs for customer and conversation management.

**Key Capabilities**:
- Facebook Messenger webhook verification and event processing
- Customer management (auto-create from Facebook user IDs, tagging, custom fields)
- Conversation lifecycle management (create, assign, close, archive)
- Chatbot flow automation (trigger-based flows with node graphs)
- AI chatbot configuration (OpenAI / Azure OpenAI integration)
- Outbound messaging via Facebook Send API (text, quick replies, templates)

**Port**: `5000` (Development, from launchSettings.json), `8080` (Docker/Production)

**Database**: PostgreSQL (`facebook_service` default from appsettings; actual DB name configured via `ConnectionStrings:DefaultConnection` or `DATABASE_URL` env var)

**Framework**: .NET 10.0, C# 14, Clean Architecture + CQRS (MediatR)

---

## 2. API Endpoints

### WebhooksController

Route base: `api/webhooks/facebook` (no API versioning)

| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/webhooks/facebook?hub.mode=subscribe&hub.verify_token={token}&hub.challenge={challenge}` | Facebook webhook verification. Returns the `challenge` value if the verify token matches configuration. Returns 403 if token mismatch. |
| `POST` | `/api/webhooks/facebook` | Receive webhook events from Facebook. Validates `X-Hub-Signature-256` header if `Facebook:AppSecret` is configured. Processes `messaging` events (messages and postbacks) for `page` object type. Always returns `200 "EVENT_RECEIVED"`. |

### ChatbotsController

Route base: `api/v{version}/chatbots` (versioned, default v1.0)

| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/v1/chatbots/flows?shopId={guid}&activeOnly={bool}` | Get chatbot flows for a shop. Optional `activeOnly` filter (default false). Returns `ChatbotFlowSummaryDto[]`. |
| `GET` | `/api/v1/chatbots/flows/{id}` | Get a single chatbot flow by ID including all nodes. Returns `ChatbotFlowDto` or 404. |
| `POST` | `/api/v1/chatbots/flows` | Create a new chatbot flow. Body: `CreateFlowRequest`. Returns 201 with `CreateChatbotFlowCommandResult` or 400. |
| `GET` | `/api/v1/chatbots/ai-config?shopId={guid}` | Get AI chatbot configuration for a shop. Returns `AIChatbotConfigDto` or 404. |
| `PUT` | `/api/v1/chatbots/ai-config` | Create or update AI chatbot configuration. Body: `UpsertAIConfigRequest`. Returns 200 with result or 400. |

### ConversationsController

Route base: `api/v{version}/conversations` (versioned, default v1.0)

| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/v1/conversations/{id}?includeMessages={bool}` | Get conversation by ID. Optional `includeMessages` (default true). Returns `ConversationDto` or 404. |
| `GET` | `/api/v1/conversations?shopId={guid}&status={string}&skip={int}&take={int}` | Get conversations with pagination. All query params optional. Returns `ConversationSummaryDto[]` with `total` count. |

### CustomersController

Route base: `api/v{version}/customers` (versioned, default v1.0)

| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/api/v1/customers/{id}` | Get customer by internal GUID ID. Returns `CustomerDto` or 404. |
| `GET` | `/api/v1/customers/facebook/{facebookUserId}` | Get customer by Facebook User ID string. Returns `CustomerDto` or 404. |
| `POST` | `/api/v1/customers` | Create a new customer (or update if Facebook ID already exists). Body: `CreateCustomerRequest`. Returns 201 with `CreateCustomerCommandResult`. |
| `PATCH` | `/api/v1/customers/{id}` | Update customer tags and custom fields. Body: `UpdateCustomerRequest`. Returns 200 or 404. |

### Health Endpoints

| Method | Route | Description |
|--------|-------|-------------|
| `GET` | `/health` | Full health check (includes PostgreSQL). |
| `GET` | `/health/live` | Liveness probe (app is running, no dependency checks). |
| `GET` | `/health/ready` | Readiness probe (includes PostgreSQL check). |

---

## 3. Commands

### ProcessIncomingMessageCommand
- **Parameters**: `PageId` (string), `SenderId` (string), `MessageText` (string), `FacebookMessageId` (string?), `Timestamp` (long?), `QuickReplyPayload` (string?)
- **Result**: `ProcessIncomingMessageResult` (ConversationId, CustomerId, MessageId, IsNewConversation, IsNewCustomer)
- **Logic**: Gets or creates a Customer by Facebook sender ID. Gets or creates an active Conversation for the customer+page pair. Adds an incoming Message entity to the conversation. Saves all via UnitOfWork.
- **Validator**: `ProcessIncomingMessageCommandValidator` - PageId required (max 50), SenderId required (max 50), MessageText required.

### CreateCustomerCommand
- **Parameters**: `FacebookUserId` (string), `Name` (string?), `Email` (string?), `Phone` (string?), `ProfilePicUrl` (string?), `Locale` (string?), `Timezone` (string?)
- **Result**: `CreateCustomerCommandResult` (Id, IsNew)
- **Logic**: If customer with given FacebookUserId exists, updates their profile and returns `IsNew: false`. Otherwise creates a new Customer entity and returns `IsNew: true`.
- **Validator**: `CreateCustomerCommandValidator` - FacebookUserId required (max 50), Name max 255, Email valid format + max 255, Phone max 50.

### UpdateCustomerCommand
- **Parameters**: `CustomerId` (Guid), `Tags` (IEnumerable<string>?), `CustomFields` (Dictionary<string, string>?)
- **Result**: `UpdateCustomerCommandResult` (Success, CustomerId)
- **Logic**: Finds customer by ID. If Tags provided, clears existing tags and adds new ones. If CustomFields provided, sets each key-value pair. Returns `Success: false` if customer not found.
- **Validator**: None.

### CreateChatbotFlowCommand
- **Parameters**: `ShopId` (Guid), `Name` (string), `TriggerType` (string), `TriggerValue` (string)
- **Result**: `CreateChatbotFlowCommandResult` (Id, Success)
- **Logic**: Checks if trigger value already exists for the shop (unique constraint). Parses trigger type string. Creates ChatbotFlow entity. Returns `Success: false` if duplicate trigger or invalid type.
- **Validator**: `CreateChatbotFlowCommandValidator` - ShopId required, Name required (max 255), TriggerType required (must be GetStarted/Keyword/Postback/Fallback), TriggerValue required (max 255).

### UpsertAIChatbotConfigCommand
- **Parameters**: `ShopId` (Guid), `Provider` (string), `Model` (string), `SystemPrompt` (string), `Temperature` (float, default 0.7), `MaxTokens` (int, default 500)
- **Result**: `UpsertAIChatbotConfigResult` (Id, IsNew, Success)
- **Logic**: Parses provider string. If config exists for shop, updates it. Otherwise creates new config. Returns `Success: false` on invalid provider or exception.
- **Validator**: `UpsertAIChatbotConfigCommandValidator` - ShopId required, Provider required (OpenAI/AzureOpenAI/Azure), Model required (max 50), SystemPrompt required, Temperature 0.0-2.0, MaxTokens 1-4096.

---

## 4. Queries

### GetChatbotFlowByIdQuery
- **Parameters**: `FlowId` (Guid)
- **Returns**: `ChatbotFlowDto?` (Id, ShopId, Name, TriggerType, TriggerValue, IsActive, CreatedAt, UpdatedAt, Nodes[])
- **Logic**: Loads flow with nodes via `GetByIdWithNodesAsync`. Maps to DTO with ordered nodes.

### GetChatbotFlowsQuery
- **Parameters**: `ShopId` (Guid), `ActiveOnly` (bool, default false)
- **Returns**: `IReadOnlyList<ChatbotFlowSummaryDto>` (Id, Name, TriggerType, TriggerValue, IsActive, CreatedAt)
- **Logic**: Gets all flows for shop, optionally filters to active only.

### GetAIChatbotConfigQuery
- **Parameters**: `ShopId` (Guid)
- **Returns**: `AIChatbotConfigDto?` (Id, ShopId, Provider, Model, SystemPrompt, Temperature, MaxTokens, IsEnabled, CreatedAt, UpdatedAt)

### GetConversationByIdQuery
- **Parameters**: `ConversationId` (Guid), `IncludeMessages` (bool, default true)
- **Returns**: `ConversationDto?` (Id, CustomerId, Customer, PageId, Status, Channel, AssignedAgentId, LastMessageAt, CreatedAt, Messages[])
- **Logic**: Loads conversation with or without messages based on flag. Customer field is always null (not loaded in this handler).

### GetConversationsQuery
- **Parameters**: `ShopId` (Guid?), `Status` (string?), `Skip` (int, default 0), `Take` (int, default 20)
- **Returns**: `GetConversationsQueryResult` (Conversations: `ConversationSummaryDto[]`, TotalCount: int)
- **Note**: Handler not found in code -- query and result types are defined but the handler implementation for GetConversationsQuery was not located in the codebase.

### GetCustomerByIdQuery
- **Parameters**: `CustomerId` (Guid)
- **Returns**: `CustomerDto?` (Id, FacebookUserId, Name, Email, Phone, ProfilePicUrl, Locale, Timezone, Tags, CustomFields, FirstSeenAt, LastInteractionAt)

### GetCustomerByFacebookIdQuery
- **Parameters**: `FacebookUserId` (string)
- **Returns**: `CustomerDto?`

### GetCustomersQuery
- **Parameters**: `ShopId` (Guid?), `Tags` (IEnumerable<string>?), `Search` (string?), `Skip` (int), `Take` (int)
- **Returns**: `GetCustomersQueryResult` (Customers: `CustomerSummaryDto[]`, TotalCount: int)
- **Note**: Query and result types are defined but no handler implementation was found in the codebase.

---

## 5. Domain Model

### Aggregates

#### CustomerAggregate
- **Customer** (Entity, IAggregateRoot)
  - Private fields: `_facebookUserId`, `_name`, `_email`, `_phone`, `_profilePicUrl`, `_locale`, `_timezone`, `_tags` (List<string>), `_customFields` (Dictionary<string, string>), `_firstSeenAt`, `_lastInteractionAt`
  - Behavior: `UpdateProfile()`, `AddTag()`, `RemoveTag()`, `SetCustomField()` (max 50 fields), `RemoveCustomField()`, `RecordInteraction()`
  - Domain event: `CustomerCreatedDomainEvent` (on construction)

#### ConversationAggregate
- **Conversation** (Entity, IAggregateRoot)
  - Private fields: `_customerId`, `_pageId`, `_status`, `_channel` (default "FacebookMessenger"), `_assignedAgentId`, `_lastMessageAt`, `_createdAt`, `_messages` (List<Message>)
  - Behavior: `AddMessage()` (only if Active), `MarkAsRead()`, `Assign()`, `Unassign()`, `Close()`, `Reopen()`, `Archive()`
  - Domain events: `ConversationCreatedDomainEvent` (on construction), `MessageAddedDomainEvent` (on AddMessage), `ConversationAssignedDomainEvent` (on Assign), `ConversationStatusChangedDomainEvent` (on Close/Reopen/Archive)

- **Message** (Entity, child of Conversation)
  - Private fields: `_senderId`, `_content`, `_messageType`, `_direction`, `_metadata` (Dictionary<string, object>), `_sentAt`, `_facebookMessageId`
  - Behavior: `AddMetadata()`

#### ChatbotAggregate
- **ChatbotFlow** (Entity, IAggregateRoot)
  - Private fields: `_name`, `_shopId`, `_triggerType`, `_triggerValue`, `_isActive`, `_createdAt`, `_updatedAt`, `_nodes` (List<FlowNode>)
  - Behavior: `AddNode()`, `RemoveNode()`, `ConnectNodes()`, `Update()`, `Activate()` (validates flow structure: requires 1 Start node, >= 1 End node, no unreachable nodes), `Deactivate()`, `GetStartNode()`
  - Domain event: `ChatbotFlowCreatedDomainEvent` (on construction)

- **FlowNode** (Entity, child of ChatbotFlow)
  - Private fields: `_nodeType`, `_content`, `_config` (Dictionary<string, object>), `_nextNodeIds` (List<Guid>), `_orderIndex`
  - Behavior: `ConnectTo()`, `DisconnectFrom()`, `UpdateContent()`, `UpdateConfig()`, `SetConfigValue()`

- **AIChatbotConfig** (Entity, IAggregateRoot)
  - Private fields: `_shopId`, `_provider`, `_model`, `_systemPrompt`, `_temperature` (0.0-2.0), `_maxTokens` (1-4096), `_isEnabled`, `_createdAt`, `_updatedAt`
  - Behavior: `UpdateConfig()`, `UpdateSystemPrompt()`, `Enable()`, `Disable()`

### Enumerations (Type-safe enum pattern via `Enumeration` base class)

| Enumeration | Values |
|-------------|--------|
| `FlowTriggerType` | GetStarted (1), Keyword (2), Postback (3), Fallback (4) |
| `NodeType` | Start (1), Message (2), Question (3), Condition (4), Action (5), End (6) |
| `AIProvider` | OpenAI (1), AzureOpenAI (2) |
| `ConversationStatus` | Active (1), Closed (2), Archived (3) |
| `MessageType` | Text (1), Image (2), Video (3), Audio (4), File (5), Template (6), QuickReply (7) |
| `MessageDirection` | Incoming (1), Outgoing (2) |

### Domain Events

| Event | Payload | Raised When |
|-------|---------|-------------|
| `CustomerCreatedDomainEvent` | Customer entity | New customer created |
| `ConversationCreatedDomainEvent` | Conversation entity | New conversation created |
| `MessageAddedDomainEvent` | ConversationId (Guid), Message entity | Message added to conversation |
| `ConversationAssignedDomainEvent` | ConversationId (Guid), AgentId (string) | Conversation assigned to agent |
| `ConversationStatusChangedDomainEvent` | ConversationId (Guid), PreviousStatus, NewStatus | Conversation status changes (close/reopen/archive) |
| `ChatbotFlowCreatedDomainEvent` | ChatbotFlow entity | New chatbot flow created |

### Exceptions

- `DomainException` - Base domain exception
- `FacebookDomainException` - Service-specific domain exception (extends DomainException)

---

## 6. Database Schema

All tables use snake_case column naming via FluentAPI private field mapping. Database: PostgreSQL.

### Table: `customers`

| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK, required |
| `facebook_user_id` | varchar(50) | required |
| `name` | varchar(255) | nullable |
| `email` | varchar(255) | nullable |
| `phone` | varchar(50) | nullable |
| `profile_pic_url` | text | nullable |
| `locale` | varchar(10) | nullable |
| `timezone` | varchar(50) | nullable |
| `tags` | text[] | PostgreSQL array |
| `custom_fields` | jsonb | JSON object |
| `first_seen_at` | timestamp | required |
| `last_interaction_at` | timestamp | required |

**Indexes**:
- `idx_customers_facebook_user_id` - UNIQUE on `facebook_user_id`
- `idx_customers_last_interaction` - on `last_interaction_at`

### Table: `conversations`

| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK, required |
| `customer_id` | uuid | required |
| `page_id` | varchar(50) | required |
| `status_id` | int | required, FK -> conversation_statuses |
| `channel` | varchar(50) | required |
| `assigned_agent_id` | varchar(100) | nullable |
| `last_message_at` | timestamp | nullable |
| `created_at` | timestamp | required |

**Relationships**: Has many `messages` (cascade delete). Has one `conversation_statuses` (restrict delete).

**Indexes**:
- `idx_conversations_customer_id` - on `customer_id`
- `idx_conversations_page_status` - on (`page_id`, `status_id`)
- `idx_conversations_created_at` - on `created_at`

### Table: `messages`

| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK, required |
| `ConversationId` | uuid | FK (shadow property) |
| `sender_id` | varchar(50) | required |
| `content` | text | required |
| `message_type_id` | int | required, FK -> message_types |
| `direction_id` | int | required, FK -> message_directions |
| `metadata` | jsonb | nullable |
| `sent_at` | timestamp | required |
| `facebook_message_id` | varchar(100) | nullable |

**Indexes**:
- `idx_messages_conversation_sent` - on (`ConversationId`, `sent_at`)
- `idx_messages_fb_message_id` - on `facebook_message_id`

### Table: `chatbot_flows`

| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK, required |
| `name` | varchar(255) | required |
| `shop_id` | uuid | required |
| `trigger_type_id` | int | required, FK -> flow_trigger_types |
| `trigger_value` | varchar(255) | required |
| `is_active` | boolean | required |
| `created_at` | timestamp | required |
| `updated_at` | timestamp | nullable |

**Relationships**: Has many `flow_nodes` (cascade delete). Has one `flow_trigger_types` (restrict delete).

**Indexes**:
- `idx_chatbot_flows_shop_trigger` - UNIQUE on (`shop_id`, `trigger_value`)
- `idx_chatbot_flows_shop_active` - on (`shop_id`, `is_active`)

### Table: `flow_nodes`

| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK, required |
| `FlowId` | uuid | FK (shadow property) |
| `node_type_id` | int | required, FK -> node_types |
| `content` | text | nullable |
| `config` | jsonb | nullable |
| `next_node_ids` | uuid[] | PostgreSQL array |
| `order_index` | int | required |

**Indexes**:
- `idx_flow_nodes_flow_order` - on (`FlowId`, `order_index`)

### Table: `ai_chatbot_configs`

| Column | Type | Constraints |
|--------|------|-------------|
| `id` | uuid | PK, required |
| `shop_id` | uuid | required |
| `provider_id` | int | required, FK -> ai_providers |
| `model` | varchar(50) | required |
| `system_prompt` | text | required |
| `temperature` | float | required |
| `max_tokens` | int | required |
| `is_enabled` | boolean | required |
| `created_at` | timestamp | required |
| `updated_at` | timestamp | required |

**Indexes**:
- `idx_ai_configs_shop_id` - UNIQUE on `shop_id` (one config per shop)

### Enumeration Lookup Tables (seeded data)

| Table | Seeded Values |
|-------|---------------|
| `conversation_statuses` | Active (1), Closed (2), Archived (3) |
| `message_types` | Text (1), Image (2), Video (3), Audio (4), File (5), Template (6), QuickReply (7) |
| `message_directions` | Incoming (1), Outgoing (2) |
| `flow_trigger_types` | GetStarted (1), Keyword (2), Postback (3), Fallback (4) |
| `node_types` | Start (1), Message (2), Question (3), Condition (4), Action (5), End (6) |
| `ai_providers` | OpenAI (1), AzureOpenAI (2) |

---

## 7. Integration Events

**No cross-service integration events are published or consumed.** The service uses domain events internally (dispatched via MediatR `INotification` before `SaveChangesAsync`), but there are no RabbitMQ publishers or consumers implemented.

Domain events are dispatched in-process only via `FacebookServiceContext.DispatchDomainEventsAsync()`. No domain event handlers (INotificationHandler) are implemented in the codebase.

---

## 8. Dependencies

### NuGet Packages

**API Layer** (`FacebookService.API.csproj`):
| Package | Version |
|---------|---------|
| MediatR | 12.4.1 |
| FluentValidation | 11.11.0 |
| FluentValidation.DependencyInjectionExtensions | 11.11.0 |
| Swashbuckle.AspNetCore | 7.2.0 |
| Asp.Versioning.Mvc | 8.1.0 |
| Asp.Versioning.Mvc.ApiExplorer | 8.1.0 |
| AspNetCore.HealthChecks.NpgSql | 8.0.2 |
| AspNetCore.HealthChecks.Redis | 8.0.1 |
| Hellang.Middleware.ProblemDetails | 6.5.1 |
| Serilog.AspNetCore | 8.0.3 |
| Serilog.Sinks.Console | 6.0.0 |
| Serilog.Sinks.Seq | 8.0.0 |

**Domain Layer** (`FacebookService.Domain.csproj`):
| Package | Version |
|---------|---------|
| MediatR.Contracts | 2.0.1 |

**Infrastructure Layer** (`FacebookService.Infrastructure.csproj`):
| 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 |

**Build Props** (`Directory.Build.props`):
| Package | Version |
|---------|---------|
| Microsoft.SourceLink.GitHub | 8.0.0 |

### External Service Dependencies

| Service | Client Interface | Implementation | Purpose |
|---------|-----------------|----------------|---------|
| Facebook Graph API | `IFacebookMessengerClient` | `FacebookMessengerClient` | Send messages, get user profiles, mark as seen, typing indicators |
| OpenAI API | `IAIClient` | `OpenAIClient` | AI chat completions for chatbot |
| Azure OpenAI API | `IAIClient` | `AzureOpenAIClient` | AI chat completions (Azure variant) |

---

## 9. Configuration

### Connection Strings

| Key | Description | Default |
|-----|-------------|---------|
| `ConnectionStrings:DefaultConnection` | PostgreSQL connection string | `Host=localhost;Port=5432;Database=facebook_service;Username=postgres;Password=postgres` |
| `DATABASE_URL` | Fallback PostgreSQL connection string (env var) | - |

### Facebook Configuration

| Key | Env Var Fallback | Description |
|-----|-----------------|-------------|
| `Facebook:VerifyToken` | `FACEBOOK_VERIFY_TOKEN` | Webhook verification token |
| `Facebook:AppSecret` | `FACEBOOK_APP_SECRET` | App secret for webhook signature verification (HMAC-SHA256) |
| `Facebook:PageAccessToken` | `FACEBOOK_PAGE_ACCESS_TOKEN` | Page access token for Send API |
| `Facebook:ApiVersion` | `FACEBOOK_API_VERSION` | Graph API version (default: `v18.0`) |

### AI Configuration

| Key | Env Var Fallback | Description |
|-----|-----------------|-------------|
| `AI:Provider` | `AI_PROVIDER` | AI provider selection: `openai` (default) or `azureopenai`/`azure` |
| `OpenAI:ApiKey` | `OPENAI_API_KEY` | OpenAI API key (required if provider is openai) |
| `AzureOpenAI:Endpoint` | `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL (required if provider is azure) |
| `AzureOpenAI:ApiKey` | `AZURE_OPENAI_KEY` | Azure OpenAI API key (required if provider is azure) |
| `AzureOpenAI:DeploymentName` | `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure deployment name (default: `gpt-4`) |
| `AzureOpenAI:ApiVersion` | - | Azure API version (default: `2024-02-01`) |

### Other Configuration

| Key | Env Var | Description |
|-----|---------|-------------|
| `Redis:ConnectionString` | `REDIS_URL` | Redis connection (referenced in appsettings but not used in DI registration) |
| `Jwt:Secret` | `JWT_SECRET` | JWT secret (configured but no auth middleware is active) |
| `Jwt:Issuer` | `JWT_ISSUER` | JWT issuer |
| `Jwt:Audience` | `JWT_AUDIENCE` | JWT audience |

### MediatR Pipeline

The pipeline executes in order:
1. `LoggingBehavior` - Logs request name and elapsed time (with Stopwatch)
2. `ValidatorBehavior` - Runs all FluentValidation validators, throws `ValidationException` on failure
3. `TransactionBehavior` - Wraps Commands in database transactions (skips Queries by name suffix check), uses `ExecutionStrategy` for retry

### Docker

- Base images: `sdk:10.0` (build), `aspnet:10.0` (runtime)
- Non-root user: `dotnetuser` (UID/GID 1001)
- Port: 8080
- Healthcheck: `curl -f http://localhost:8080/health/live` (30s interval, 3 retries)
- **Note**: Dockerfile references `FacebookService.API` naming (fixed from template)

### Migrations

No EF Core migrations exist in the codebase. The migrations assembly is configured to be the Infrastructure assembly.

### Tests

Test projects exist but are mostly empty scaffolds:
- `tests/FacebookService.UnitTests/` - No test files (only auto-generated obj files)
- `tests/FacebookService.FunctionalTests/` - Contains `CustomWebApplicationFactory.cs` and `ConversationsControllerTests.cs`
