# MktXService (X/Twitter Marketing Integration)

## Overview

- **Purpose**: Microservice for integrating with X (Twitter) platform — managing Twitter accounts, conversations, contacts, campaigns, templates, automation flows, AI conversation sessions, and audience segments.
- **Port**: 5000 (configured in Program.cs via ASPNETCORE_URLS)
- **Database**: PostgreSQL (connection string: `DefaultConnection` or `DATABASE_URL`)
- **Architecture**: Clean Architecture + CQRS (MediatR 12.4.1)
- **API Versioning**: URL segment `api/v{version:apiVersion}` (v1.0)

---

## API Endpoints

### AccountsController (`api/v1/accounts`)
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/accounts?merchantId` | Get Twitter accounts for a merchant |
| GET | `/accounts/{id}` | Get Twitter account by ID |
| POST | `/accounts/connect` | Connect a new Twitter account |
| POST | `/accounts/{id}/disconnect` | Disconnect a Twitter account |
| POST | `/accounts/{id}/refresh-tokens` | Refresh OAuth tokens |

### CampaignsController (`api/v1/campaigns`)
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/campaigns?merchantId&status&skip&take` | List campaigns with filtering + pagination |
| GET | `/campaigns/{id}` | Get campaign details |
| POST | `/campaigns` | Create a new campaign |
| POST | `/campaigns/{id}/start` | Start a campaign |
| POST | `/campaigns/{id}/pause` | Pause a running campaign |
| POST | `/campaigns/{id}/resume` | Resume a paused campaign |
| POST | `/campaigns/{id}/cancel` | Cancel a campaign |

### ContactsController (`api/v1/contacts`)
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/contacts?accountId&search&tags&skip&take` | List contacts with search + filtering |
| GET | `/contacts/{id}` | Get contact details |

### ConversationsController (`api/v1/conversations`)
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/conversations?accountId&status&assignedToUserId&skip&take` | List conversations with filtering |
| GET | `/conversations/{id}?includeMessages` | Get conversation details (optionally with messages) |
| POST | `/conversations/{id}/messages` | Send a message in a conversation |
| POST | `/conversations/{id}/close` | Close a conversation |
| POST | `/conversations/{id}/reopen` | Reopen a closed conversation |
| POST | `/conversations/{id}/assign` | Assign conversation to a user |
| POST | `/conversations/{id}/pending` | Mark conversation as pending |

### TemplatesController (`api/v1/templates`)
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/templates?merchantId&type` | List templates by merchant and type |
| GET | `/templates/{id}` | Get template details |

### SamplesController (`api/v1/samples`)
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/samples` | List all samples |
| GET | `/samples/{id}` | Get sample by ID |
| POST | `/samples` | Create a sample |
| PUT | `/samples/{id}` | Update a sample |
| DELETE | `/samples/{id}` | Delete a sample |
| PATCH | `/samples/{id}/status` | Change sample status |

### WebhooksController (`api/v1/webhooks`)
| Method | Route | Description |
|--------|-------|-------------|
| GET | `/webhooks/twitter` | Twitter CRC challenge verification |
| POST | `/webhooks/twitter` | Receive Twitter webhook events (DMs, follows, etc.) |
| POST | `/webhooks/twitter/register` | Register webhook URL with Twitter |
| POST | `/webhooks/twitter/subscribe` | Subscribe to Twitter webhook events |

---

## Commands (28 total)

### Twitter Account Commands (3)
| Command | Result | Description |
|---------|--------|-------------|
| `ConnectTwitterAccountCommand(MerchantId, TwitterUserId, Username, OAuthToken, OAuthTokenSecret, DisplayName?, ProfileImageUrl?)` | `ConnectTwitterAccountResult(Success, AccountId?, Error?)` | Connect a Twitter account |
| `DisconnectTwitterAccountCommand(AccountId)` | `bool` | Disconnect a Twitter account |
| `RefreshTwitterTokensCommand(AccountId, NewOAuthToken, NewOAuthTokenSecret)` | `bool` | Refresh OAuth tokens |

### Campaign Commands (6)
| Command | Result | Description |
|---------|--------|-------------|
| `CreateCampaignCommand(MerchantId, Name, Type)` | `CreateCampaignResult(Success, CampaignId?, Error?)` | Create a campaign (types: bulk_dm, welcome, follow_up, drip, broadcast) |
| `StartCampaignCommand(CampaignId)` | `bool` | Start a campaign |
| `PauseCampaignCommand(CampaignId)` | `bool` | Pause a running campaign |
| `ResumeCampaignCommand(CampaignId)` | `bool` | Resume a paused campaign |
| `CancelCampaignCommand(CampaignId)` | `bool` | Cancel a campaign |
| `UpdateCampaignMetricsCommand(CampaignId, Sent, Delivered, Read, Clicked, Failed)` | `bool` | Update campaign metrics |

### Conversation Commands (5)
| Command | Result | Description |
|---------|--------|-------------|
| `SendMessageCommand(ConversationId, Content, MediaUrls?)` | `bool` | Send a message (max 4 media) |
| `CloseConversationCommand(ConversationId, Reason?)` | `bool` | Close a conversation (reason max 500 chars) |
| `ReopenConversationCommand(ConversationId)` | `bool` | Reopen a conversation |
| `AssignConversationCommand(ConversationId, UserId)` | `bool` | Assign to a user |
| `MarkConversationPendingCommand(ConversationId)` | `bool` | Mark as pending |

### Contact Commands (5)
| Command | Result | Description |
|---------|--------|-------------|
| `CreateContactCommand(AccountId, TwitterUserId, Username, Source, DisplayName?, ProfileImageUrl?, Attributes?, Tags?)` | `CreateContactResult(Success, ContactId?, Error?)` | Create a contact |
| `UpdateContactCommand(ContactId, Username, DisplayName?, ProfileImageUrl?)` | `bool` | Update contact profile |
| `AddContactTagCommand(ContactId, TagName)` | `bool` | Add tag to contact |
| `RemoveContactTagCommand(ContactId, TagName)` | `bool` | Remove tag from contact |
| `UpdateContactAttributesCommand(ContactId, Attributes)` | `bool` | Update custom attributes |

### Template Commands (4)
| Command | Result | Description |
|---------|--------|-------------|
| `CreateTemplateCommand(MerchantId, Name, Type, Content)` | `CreateTemplateResult(Success, TemplateId?, Error?)` | Create a template |
| `UpdateTemplateCommand(TemplateId, Name, Content)` | `bool` | Update a template |
| `DeleteTemplateCommand(TemplateId)` | `bool` | Delete a template |
| `PreviewTemplateCommand(TemplateId, Variables)` | `PreviewTemplateResult(Success, RenderedContent?, Error?)` | Preview template rendering |

### Sample Commands (4)
| Command | Result | Description |
|---------|--------|-------------|
| `CreateSampleCommand(Name, Description?)` | `CreateSampleResult(Success, SampleId?, Error?)` | Create a sample |
| `UpdateSampleCommand(Id, Name, Description?)` | `bool` | Update a sample |
| `DeleteSampleCommand(Id)` | `bool` | Delete a sample |
| `ChangeSampleStatusCommand(Id, NewStatus)` | `bool` | Change status (Draft->Active->Completed, Draft/Active->Cancelled) |

---

## Queries

### Twitter Account Queries
| Query | Result |
|-------|--------|
| `GetTwitterAccountsQuery(MerchantId)` | `List<TwitterAccountDto>` |
| `GetTwitterAccountByIdQuery(AccountId)` | `TwitterAccountDto?` |

### Campaign Queries
| Query | Result |
|-------|--------|
| `GetCampaignsQuery(MerchantId, Status?, Skip, Take)` | `CampaignsResult(Items, TotalCount)` |
| `GetCampaignByIdQuery(CampaignId)` | `CampaignDetailDto?` |

### Conversation Queries
| Query | Result |
|-------|--------|
| `GetConversationsQuery(AccountId, Status?, AssignedToUserId?, Skip, Take)` | `ConversationsResult(Items, TotalCount)` |
| `GetConversationByIdQuery(ConversationId, IncludeMessages)` | `ConversationDetailDto?` |

### Contact Queries
| Query | Result |
|-------|--------|
| `GetContactsQuery(AccountId, Search?, Tags?, Skip, Take)` | `ContactsResult(Items, TotalCount)` |
| `GetContactByIdQuery(ContactId)` | `ContactDetailDto?` |

### Template Queries
| Query | Result |
|-------|--------|
| `GetTemplatesQuery(MerchantId, Type?)` | `List<TemplateDto>` |
| `GetTemplateByIdQuery(TemplateId)` | `TemplateDetailDto?` |

### Sample Queries
| Query | Result |
|-------|--------|
| `GetSamplesQuery` | `List<SampleDto>` |
| `GetSampleByIdQuery(Id)` | `SampleDto?` |

### DTOs
- **TwitterAccountDto**: Id, MerchantId, TwitterUserId, Username, DisplayName, ProfileImageUrl, Status, ConnectedAt
- **CampaignDto**: Id, MerchantId, Name, Type, Status, CreatedAt, UpdatedAt
- **CampaignDetailDto**: CampaignDto + TemplateId, SegmentIds, Schedule (StartAt, EndAt, TimeZone), Metrics (Sent, Delivered, Read, Clicked, Failed)
- **ConversationDto**: Id, AccountId, ContactId, Status, LastMessagePreview, LastMessageAt, AssignedToUserId
- **ConversationDetailDto**: ConversationDto + Messages (list of MessageDto)
- **MessageDto**: Id, Content, Direction, Type, SentAt, IsFromBot, TwitterMessageId
- **ContactDto**: Id, TwitterUserId, Username, DisplayName, ProfileImageUrl, Tags, LastInteraction
- **ContactDetailDto**: ContactDto + Attributes, Source, CreatedAt
- **TemplateDto**: Id, Name, Type, CreatedAt
- **TemplateDetailDto**: TemplateDto + Content, Variables (extracted from {{var}} placeholders)

---

## Domain Model

### Aggregates

#### TwitterAccount (Aggregate Root)
- **Properties**: MerchantId, TwitterUserId, Username, DisplayName, ProfileImageUrl, StatusId (TwitterAccountStatus), OAuthToken, OAuthTokenSecret, WebhookId, Settings (Dictionary), ConnectedAt, CreatedAt, UpdatedAt
- **Methods**: Activate(), SetWebhookId(), UpdateCredentials(), UpdateProfile(), UpdateSettings(), MarkAsError(reason), Deactivate(), Disconnect()
- **Domain Events**: TwitterAccountConnectedDomainEvent, TwitterAccountStatusChangedDomainEvent, TwitterAccountDisconnectedDomainEvent

#### TwitterAccountStatus (Enumeration)
- Pending(1), Active(2), Inactive(3), Error(4), Disconnected(5)

#### Campaign (Aggregate Root)
- **Properties**: MerchantId, Name, Type, StatusId (CampaignStatus), TemplateId, SegmentIds (List<Guid> - JSONB), Schedule (CampaignSchedule value object), Metrics (CampaignMetrics value object), CreatedAt, UpdatedAt
- **Value Objects**:
  - CampaignSchedule: StartAt, EndAt, TimeZone
  - CampaignMetrics: Sent, Delivered, Read, Clicked, Failed
- **Methods**: SetTemplate(), AddSegment(), RemoveSegment(), SetSchedule(), Start(), Pause(), Resume(), Complete(), Cancel(), UpdateMetrics()
- **Domain Events**: CampaignStartedDomainEvent, CampaignCompletedDomainEvent

#### CampaignStatus (Enumeration)
- Draft(1), Scheduled(2), Running(3), Paused(4), Completed(5), Cancelled(6)

#### Conversation (Aggregate Root)
- **Properties**: AccountId, ContactId, Status (ConversationStatus), AssignedToUserId, StartedAt, EndedAt, LastMessagePreview, LastMessageAt, CreatedAt, UpdatedAt
- **Child Entity**: Message — Direction (inbound/outbound), Type (text/image/card/quick_reply), Content, Attachments (MessageAttachment value object with Url, Type), IsFromBot, TwitterMessageId, SentAt
- **Methods**: AddMessage(), Close(reason?), Reopen(), AssignTo(userId), Unassign(), MarkAsPending()
- **Domain Events**: ConversationStartedDomainEvent, MessageReceivedDomainEvent, MessageSentDomainEvent

#### ConversationStatus (Enumeration)
- Open(1), Closed(2), Pending(3)

#### Contact (Aggregate Root)
- **Properties**: AccountId, TwitterUserId, Username, DisplayName, ProfileImageUrl, Source, Attributes (Dictionary<string,object>), CreatedAt, UpdatedAt, LastInteractionAt
- **Child Entity**: ContactTag — Name (normalized lowercase), CreatedAt
- **Methods**: UpdateProfile(), AddTag(), RemoveTag(), SetAttribute(), RemoveAttribute(), UpdateAttributes(), RecordInteraction()
- **Domain Events**: ContactCreatedDomainEvent, ContactTaggedDomainEvent

#### Template (Aggregate Root)
- **Properties**: MerchantId, Name, Type, Content, Variables (extracted from `{{variable}}` via regex), CreatedAt, UpdatedAt
- **Constants**: MaxContentLength = 10000
- **Methods**: Update(name, content), Render(variables), ValidateVariables(variables), GetMissingVariables(variables)

#### Sample (Aggregate Root — template/demo)
- **Properties**: Name, Description, StatusId (SampleStatus), CreatedAt, UpdatedAt
- **Status Transitions**: Draft->Active->Completed, Draft/Active->Cancelled
- **Methods**: Activate(), Complete(), Cancel()
- **Domain Events**: SampleCreatedDomainEvent, SampleStatusChangedDomainEvent

#### Segment (Aggregate Root)
- **Properties**: AccountId, Name, Description, CreatedAt, UpdatedAt
- **Child Entity**: SegmentCondition — Field, Operator, Value

#### AIConversationSession (Aggregate Root)
- **Properties**: ConversationId, Intent, Slots (Dictionary), ContextItems (List), IsActive, CreatedAt, UpdatedAt, ExpiresAt
- **Constants**: MaxContextItems = 10, EscalationThreshold = 0.6
- **Methods**: AddContext(), ClearContext(), SetIntent(), SetSlot(), Deactivate(), Reactivate(), IsExpired()
- **Domain Events**: AIEscalationRequestedDomainEvent, IntentDetectedDomainEvent

#### AutomationFlow (Aggregate Root)
- **Properties**: AccountId, Name, Description, StatusId (FlowStatus), CreatedAt, UpdatedAt
- **Child Entities**: FlowTrigger, FlowNode, FlowConnection
- **FlowStatus**: Draft(1), Active(2), Inactive(3)
- **Methods**: AddNode(), RemoveNode(), Connect(), Activate(), Deactivate()

---

## Database Schema

### DbContext: MktXServiceContext (17 DbSets)
- Implements IUnitOfWork, dispatches domain events before SaveChanges
- Transaction support: BeginTransactionAsync, CommitTransactionAsync, RollbackTransaction

### Tables

#### twitter_accounts
| Column | Type | Constraints |
|--------|------|-------------|
| id | uuid | PK |
| merchant_id | uuid | NOT NULL, indexed |
| twitter_user_id | varchar | NOT NULL, unique index |
| username | varchar | |
| display_name | varchar | |
| profile_image_url | varchar | |
| status_id | int | FK -> twitter_account_statuses, indexed |
| oauth_token | varchar | |
| oauth_token_secret | varchar | |
| webhook_id | varchar | |
| settings | jsonb | |
| connected_at | timestamp | |
| created_at | timestamp | NOT NULL |
| updated_at | timestamp | NOT NULL |

**Indexes**: ix_twitter_accounts_twitter_user_id (unique), ix_twitter_accounts_merchant_id, ix_twitter_accounts_status_id

#### twitter_account_statuses
| Column | Type |
|--------|------|
| id | int | PK |
| name | varchar |

**Seed data**: 1=Pending, 2=Active, 3=Inactive, 4=Error, 5=Disconnected

#### campaigns
| Column | Type | Constraints |
|--------|------|-------------|
| id | uuid | PK |
| merchant_id | uuid | NOT NULL, indexed |
| name | varchar | NOT NULL |
| type | varchar | NOT NULL |
| status_id | int | FK -> campaign_statuses, indexed |
| template_id | uuid | nullable |
| segment_ids | jsonb | List<Guid> |
| schedule | jsonb | CampaignSchedule value object |
| metrics | jsonb | CampaignMetrics value object |
| created_at | timestamp | NOT NULL, desc index |
| updated_at | timestamp | NOT NULL |

**Indexes**: ix_campaigns_merchant_id, ix_campaigns_status_id, ix_campaigns_created_at (desc), ix_campaigns_merchant_status (composite)

#### campaign_statuses
| Column | Type |
|--------|------|
| id | int | PK |
| name | varchar |

**Seed data**: 1=Draft, 2=Scheduled, 3=Running, 4=Paused, 5=Completed, 6=Cancelled

#### conversations, messages, contacts, contact_tags, templates, samples, segments, segment_conditions, ai_conversation_sessions, automation_flows, flow_triggers, flow_nodes, flow_connections
(Configured via EF Core FluentAPI — snake_case columns, private field mapping)

---

## Domain Events (14 total)

| Event | Payload |
|-------|---------|
| SampleCreatedDomainEvent | Sample entity |
| SampleStatusChangedDomainEvent | Sample entity |
| TwitterAccountConnectedDomainEvent | TwitterAccount entity |
| TwitterAccountDisconnectedDomainEvent | TwitterAccount entity |
| TwitterAccountStatusChangedDomainEvent | TwitterAccount entity |
| CampaignStartedDomainEvent | Campaign entity |
| CampaignCompletedDomainEvent | Campaign entity |
| ConversationStartedDomainEvent | Conversation entity |
| MessageReceivedDomainEvent | Conversation entity |
| MessageSentDomainEvent | Conversation entity |
| ContactCreatedDomainEvent | Contact entity |
| ContactTaggedDomainEvent | Contact entity, TagName |
| AIEscalationRequestedDomainEvent | AIConversationSession entity |
| IntentDetectedDomainEvent | AIConversationSession entity |

---

## Validators

| Validator | Rules |
|-----------|-------|
| CreateCampaignCommandValidator | MerchantId required; Name required, max 255; Type in [bulk_dm, welcome, follow_up, drip, broadcast] |
| StartCampaignCommandValidator | CampaignId required |
| PauseCampaignCommandValidator | CampaignId required |
| ResumeCampaignCommandValidator | CampaignId required |
| CancelCampaignCommandValidator | CampaignId required |
| UpdateCampaignMetricsCommandValidator | CampaignId required; all metric fields >= 0 |
| ConnectTwitterAccountCommandValidator | MerchantId required; TwitterUserId required; Username required; OAuthToken required; OAuthTokenSecret required |
| DisconnectTwitterAccountCommandValidator | AccountId required |
| RefreshTwitterTokensCommandValidator | AccountId required; NewOAuthToken required; NewOAuthTokenSecret required |
| SendMessageCommandValidator | ConversationId required; Content required, max 10000; MediaUrls max 4 items |
| CloseConversationCommandValidator | ConversationId required; Reason max 500 chars |
| AssignConversationCommandValidator | ConversationId required; UserId required |
| ReopenConversationCommandValidator | ConversationId required |
| MarkConversationPendingCommandValidator | ConversationId required |
| CreateSampleCommandValidator | Name required, max 200 |
| UpdateSampleCommandValidator | Id required; Name required, max 200 |

---

## External Services

### Twitter API Client (ITwitterApiClient / TwitterApiClient)
- **Authentication**: OAuth 1.0a HMAC-SHA1 signing
- **Resilience**: Polly retry (3 attempts, exponential backoff) + circuit breaker (5 failures, 1 min break)
- **Methods**: SendDirectMessage, GetDirectMessages, GetUserById, GetUserByUsername, RegisterWebhook, SubscribeWebhook, DeleteWebhook, UploadMedia, GetRateLimitStatus
- **Config**: TwitterApiOptions (ConsumerKey, ConsumerSecret, AccessToken, AccessTokenSecret, EnvironmentName)

### OpenAI Service Client (IAIServiceClient / OpenAIServiceClient)
- **Resilience**: Circuit breaker
- **Methods**: GetChatCompletionAsync, GetChatCompletionWithFunctionsAsync, StreamChatCompletionAsync, DetectIntentAsync, ExtractEntitiesAsync, GetEmbeddingsAsync, ModerateContentAsync
- **Config**: OpenAIOptions (ApiKey, DefaultModel=gpt-4o-mini, EmbeddingModel=text-embedding-3-small, MaxTokens, DefaultTemperature)

---

## Dependencies (NuGet)

### API Project
- MediatR 12.4.1
- FluentValidation.DependencyInjectionExtensions 11.11.0
- Swashbuckle.AspNetCore 7.2.0
- Asp.Versioning.Mvc 8.1.0
- Asp.Versioning.Mvc.ApiExplorer 8.1.0
- Hellang.Middleware.ProblemDetails 6.5.1
- Serilog.AspNetCore 8.0.3
- AspNetCore.HealthChecks.NpgSql 8.0.2

### Infrastructure Project
- Microsoft.EntityFrameworkCore 10.0.0
- Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0
- MediatR 12.4.1
- Dapper 2.1.35
- Polly 8.5.0
- Polly.Extensions.Http 3.0.0
- StackExchange.Redis 2.8.16
- Quartz 3.13.1

---

## Configuration

### Environment Variables / Settings
- `ConnectionStrings:DefaultConnection` or `DATABASE_URL` — PostgreSQL connection
- `TwitterApi:ConsumerKey` — Twitter API consumer key
- `TwitterApi:ConsumerSecret` — Twitter API consumer secret
- `TwitterApi:AccessToken` — Twitter API access token
- `TwitterApi:AccessTokenSecret` — Twitter API access token secret
- `TwitterApi:EnvironmentName` — Twitter webhook environment
- `OpenAI:ApiKey` — OpenAI API key
- `OpenAI:DefaultModel` — Default model (gpt-4o-mini)
- `OpenAI:EmbeddingModel` — Embedding model (text-embedding-3-small)

### MediatR Pipeline Behaviors
1. LoggingBehavior — Request/response logging with Stopwatch
2. ValidatorBehavior — FluentValidation in pipeline
3. TransactionBehavior — Auto transaction for Commands (skips Queries)

### Health Checks
- PostgreSQL health check via NpgSql

---

## Known Issues

- **Incomplete DI Registration**: Only `ISampleRepository` is registered in `DependencyInjection.cs`. The other 8 repository interfaces (ITwitterAccountRepository, ICampaignRepository, IConversationRepository, IContactRepository, ITemplateRepository, ISegmentRepository, IAIConversationSessionRepository, IAutomationFlowRepository) are NOT registered. This means most functionality beyond Samples will fail at runtime until DI is fixed.
