Prisma schema reference

The PostgreSQL schema is defined by the Payability web application’s Prisma schema. That file is the authority: it owns the tables, the enum types, the constraints, and the migrations.

This repository contains no migrations. morpheus_data_layer mirrors a subset of the schema so Python can read and write it. Every model docstring says the same thing — "Read/write only — schema modifications are not permitted via this layer."

A copy is checked in at docs/modules/data/examples/schema.prisma and reproduced in full at the bottom of this page.

The checked-in copy is a snapshot for reference. The live schema lives in the web application repository and can move without a commit here. When something disagrees, the web application’s schema wins — and this copy needs refreshing.

Adding a column, end to end

  1. Add the field to the web application’s Prisma schema and migrate it there.

  2. Add the matching mapped_column to the model in morpheus_data_layer/models/, using the exact camelCase column name.

  3. If it is an enum column, add the StrEnum to morpheus_data_layer/enums.py and build the PostgreSQL type with create_type=False — see Enumerations.

  4. Add it to update_columns anywhere it should participate in an upsert.

  5. Refresh the copy under examples/ and update ORM models.

Doing step 2 before step 1 produces a runtime error on the first query touching the column, not a startup failure — SQLAlchemy does not validate the mapping against the database.

Coverage

Prisma declares 28 models. Morpheus maps 13.

Prisma model Table Mapped in morpheus_data_layer

Seller

sellers

Yes

Contact

contacts

Yes

Activity

Activity

Yes

Call

Call

Yes

CampaignTarget

CampaignTarget

Yes

Configs

configs

Yes

Emails

emails

Yes — as Email

EmailCampaignTarget

email_campaign_targets

Yes

EmailVariation

email_variations

Yes

FollowUpTask

follow_up_tasks

Yes

SalesRep

sales_reps

Yes

TargetEmails

target_emails

Yes

TargetPhoneNumbers

target_phone_numbers

Yes — as TargetPhoneNumber

Subscription

subscriptions

No

SubscriptionUser

—

No

Campaign

—

No

Profile

—

No

AiAgent

ai_agents

No

AiAgentVersion

ai_agent_versions

No

AgentTestCase

agent_test_cases

No

CallAiAgentAnalysisResult

—

No

WebhookEvent

—

No

PhoneNumbers

—

No

BlockedPhoneNumbers

blocked_phone_numbers

No

Domain

domains

No

EmailSendingDomain

email_sending_domains

No

SequenceTemplate

sequence_templates

No

SequenceTemplateStep

sequence_template_steps

No

ContactSequence

contact_sequences

No

ContactSequenceStep

contact_sequence_steps

No

The unmapped half is the web application’s own domain — campaign definitions, sequence templates, AI agent configuration, user profiles, domain warm-up. Morpheus consumes the results of those systems through HubSpot and BigQuery rather than reading their tables.

Note the naming inconsistency the ORM inherits: some models declare @@map to a snake_case plural table, others do not and keep a PascalCase singular name. Activity, Call, and CampaignTarget are quoted identifiers in SQL for that reason.

Relations

Relation Meaning

Subscription 1—* everything

The tenant boundary. Every domain table carries subscriptionId.

Seller 1—* Contact

A business and its people. Seller maps to a HubSpot Company via hubspotCompanyId; contact ids belong on Contact.

Contact 1—* CampaignTarget, EmailCampaignTarget, TargetPhoneNumbers, TargetEmails, Activity, FollowUpTask, Emails, ContactSequence

The hub of the model. Almost everything hangs off a person.

CampaignTarget 1—* Call, FollowUpTask

One phone number in a campaign, and every attempt against it.

Call *—1 SalesRep, *—1 TargetPhoneNumbers

Who dialled, and the registry entry for the number dialled.

FollowUpTask *—1 SalesRep, *—1 Contact

A commitment and its owner. CampaignTarget and ContactSequence are optional.

Activity unique (subscriptionId, externalId)

The idempotency key for the engagement sync.

ForeignKey("subscriptions.id") is declared in Prisma but deliberately omitted from every SQLAlchemy model. The constraint is enforced by the database; declaring it in the ORM would make this layer claim ownership of a table it does not manage. Other foreign keys — such as Contact.sellerId → sellers.id — are declared.

Known drift

Real differences between Prisma and the SQLAlchemy models. None currently break at runtime, but each is a trap.

Area Prisma SQLAlchemy

SellerLifeCycleStage

6 values: new, enriched, qualified, opportunity, closed_won, closed_lost

11 values, a longer ladder including raw_lead, in_sequence, bdr_handoff, demo_scheduled, customer, disqualified

SellerPlatform

amazon, walmart, shopify

amazon, walmart — no shopify

Contact uniqueness

Composite: (subscriptionId, email), (subscriptionId, phone), (subscriptionId, hubspotContactId), (subscriptionId, supplierKey)

Global nullable-unique on email, phone, hubspotContactId

Contact fields

Has leadScore, customFields; source is a ContactSource enum

No leadScore or customFields; source is a plain String

EmailCampaignTarget.emailId

Required foreign key to TargetEmails

Nullable

EmailVariation

Has spamScore, markedAsSpam

Neither mapped

TargetEmails

Has newsletterGapDays, newsletterLastSentAt, @@unique([subscriptionId, email])

Neither field mapped

TargetPhoneNumbers

Has updatedAt

Not mapped

FollowUpTask

Has contactSequenceId, inngestRunId

Neither mapped

Two of these deserve attention:

The lifecycle enum mismatch is the significant one

If the Prisma set is what the database type actually holds, then writing raw_lead or bdr_handoff from Python would be rejected by PostgreSQL. Either the checked-in Prisma copy is stale or those stages are unreachable in practice. Verify against the live type before relying on any stage beyond enriched — see Enumerations.

Contact uniqueness is composite upstream, global here

Prisma scopes uniqueness to the subscription; the ORM does not. On a single tenant the two are equivalent, which is why nothing has broken. Onboarding a second subscription would make the ORM’s global constraint reject a legitimate row — two tenants cannot hold the same email address. upsert_for_seller() already works around collisions by stripping conflicting values (see Repositories); that workaround becomes load-bearing the moment a second tenant exists.

Everything else in the table is a field Morpheus simply does not read. Unmapped columns are harmless — SQLAlchemy ignores columns it does not declare — but they will not appear on a model instance, and an upsert that omits them leaves whatever the web application wrote intact.

Enums declared only in Prisma

CampaignStatus, AiAgentType, CampaignType, AiAgentLlmModelType, SubscriptionUserRole, SequenceType, SequenceStatus, SequenceStepChannel, DomainWarmupStatus.

All belong to unmapped tables. There is no reason to mirror them until a handler reads one.

Full schema

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
  output   = "generated"
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

/**
 * =========================
 * Enums
 * =========================
 */
enum SellerPlatform {
  amazon
  walmart
  shopify
}

enum SellerDatasource {
  smartscout
  manual
}

enum ContactHeatStatus {
  hot // active follow-up in progress
  warm // rep acted but deal not closed
  warming // task failed, automated email track running
  idle // 30 days of silence, deprioritized
}

enum ContactSource {
  campaign
  hubspot
  import
  manual
}

enum LineTypes {
  mobile
  landline
  fixedVoip
  nonFixedVoip
  tollFree
  sharedCost
  premium
  voicemail
  pager
  voip
  uan
  unknown
  other
}

enum RiskLevel {
  low
  medium
  high
  critical
}

// Mirrors HubSpot lifecyclestage values
enum ContactLifecycleStage {
  subscriber
  lead
  marketingqualifiedlead
  salesqualifiedlead
  opportunity
  customer
  evangelist
  other
}

// Mirrors HubSpot hs_lead_status values
enum ContactLeadStatus {
  new
  open
  in_progress
  open_deal
  unqualified
  attempted_to_contact
  connected
  bad_timing
}

enum ContactHubspotSyncStatus {
  synced
  pending
  failed
  skipped
}

enum CampaignStatus {
  draft
  running
  paused
  stopped
  completed
}

enum TargetState {
  pending
  in_progress
  dialing
  completed
  failed
  canceled
  skipped // Prior terminal disposition (Do_Not_Call, Not_a_Seller, Amazon_Seller) – do not call again
  // --- NOVOS STATUS ADICIONADOS ---
  DNC // Do Not Call
  WN // Wrong Number
  MAX // Max Attempts Reached
  DT_PENDING // Double Tap Pending (Aguardando retry rápido)
  VOICEMAIL // Voicemail Detected
}

enum CallStatus {
  created
  dialing
  answered
  ended
}

enum AiAgentType {
  analysis
  caller
  sms
  other
}

enum SellerLifeCycleStage {
  new
  enriched
  qualified
  opportunity
  closed_won
  closed_lost
}

enum ActivityType {
  sms
  email
  call
  task
  meeting
  note
  reminder
}

enum FollowUpTaskStatus {
  pending
  in_progress
  completed
  skipped
  failed
}

enum FollowUpTaskType {
  call
  email
  sequence_enrollment
}

enum CampaignType {
  call
  email
}

enum EmailTargetStatus {
  pending
  requested
  sent
  opened
  bounced
  unsubscribed
  blocked
  failed
}

/**
 * LLM-aligned reply / engagement category for an email campaign recipient.
 */
enum EmailTargetCategory {
  interested
  not_interested
  question
  out_of_office
  unsubscribe
  wrong_person
  other
}

enum EmailVariationStatus {
  pending_generation
  active
  archived
}

enum AiAgentLlmModelType {
  gpt
  claude
}

/**
 * =========================
 * Enums (Subscription)
 * =========================
 */

enum SubscriptionUserRole {
  owner
  admin
  member
}

enum SequenceType {
  onboarding
  re_engagement
  not_connected_outreach
}

enum SequenceStatus {
  pending // created, waiting for trigger (e.g. waiting for rep task window)
  active // currently running
  paused // manually paused
  completed // contact onboarded
  abandoned // ran out of steps, no response
  exited // pivoted to different sequence
}

enum SequenceStepChannel {
  email
  call_task
  sms
  wait
  data_gate // BigQuery condition check that branches the sequence
  crm_task // Create a HubSpot task via the CRM SQS queue
  crm_event // Publish a user-defined event to the CRM SQS queue (event_type from a registry, body from key/value pairs)
}

/**
 * =========================
 * Models
 * =========================
 */

model Subscription {
  id        String   @id @default(uuid()) @db.Uuid
  name      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  users                      SubscriptionUser[]
  campaigns                  Campaign[]
  phoneNumbers               PhoneNumbers[]
  calls                      Call[]
  campaignTargets            CampaignTarget[]
  aiAgents                   AiAgent[]
  aiAgentVersions            AiAgentVersion[]
  callAiAgentAnalysisResults CallAiAgentAnalysisResult[]
  configs                    Configs[]
  agentTestCases             AgentTestCase[]
  activities                 Activity[]
  sellers                    Seller[]
  contacts                   Contact[]
  emailCampaignTargets       EmailCampaignTarget[]
  emailVariations            EmailVariation[]
  emailSendingDomains        EmailSendingDomain[]
  domains                    Domain[]
  targetPhoneNumbers         TargetPhoneNumbers[]
  targetEmails               TargetEmails[]
  emails                     Emails[]
  salesReps                  SalesRep[]
  followUpTasks              FollowUpTask[]
  contactSequences           ContactSequence[]
  sequenceTemplates          SequenceTemplate[]
  blockedPhoneNumbers        BlockedPhoneNumbers[]
}

model SubscriptionUser {
  id             String               @id @default(uuid()) @db.Uuid
  subscriptionId String               @db.Uuid
  profileId      String               @db.Uuid
  role           SubscriptionUserRole @default(member)
  createdAt      DateTime             @default(now())
  updatedAt      DateTime             @updatedAt

  subscription Subscription @relation(fields: [subscriptionId], references: [id])
  profile      Profile      @relation(fields: [profileId], references: [id], onDelete: Cascade)

  @@unique([subscriptionId, profileId])
  @@index([profileId])
}

model Campaign {
  id                 String         @id @default(uuid()) @db.Uuid
  name               String
  type               CampaignType   @default(call)
  preferedPhone      String?
  status             CampaignStatus @default(draft)
  maxConcurrentCalls Int            @default(10)
  createdAt          DateTime       @default(now())
  updatedAt          DateTime       @updatedAt
  windowStart        DateTime?      @db.Time(6)
  windowEnd          DateTime?      @db.Time(6)
  allowWeekends      Boolean        @default(false)
  maxAttempts        Int?
  waitHoursToRecall  Int?           @default(24)
  preferredAiAgentId String?        @db.Uuid
  subscriptionId     String         @db.Uuid

  // Shared optional fields (available on all campaign types)
  description              String?
  goal                     String?
  resendTopicId            String?
  schedulerIntervalSeconds Int?    @default(30)

  // Email campaign config (nullable; only used when type = email)
  emailsPerHour             Int?
  maxEmailsPerDay           Int?
  emailsPerVariationPerHour Int?      @map("emailsPerVariation")
  emailWindowStart          DateTime? @db.Time(6)
  emailWindowEnd            DateTime? @db.Time(6)
  emailAllowWeekends        Boolean   @default(false)

  // Relations
  targets                    CampaignTarget[]
  calls                      Call[]
  preferredAiAgent           AiAgent?                    @relation("CampaignPreferredAnalysisAgent", fields: [preferredAiAgentId], references: [id])
  callAiAgentAnalysisResults CallAiAgentAnalysisResult[]
  subscription               Subscription                @relation(fields: [subscriptionId], references: [id])
  emailTargets               EmailCampaignTarget[]
  emailVariations            EmailVariation[]
  emails                     Emails[]
  contactSequences           ContactSequence[]

  @@index([status])
  @@index([subscriptionId])
  @@index([type])
}

model Profile {
  id                 String   @id @default(uuid()) @db.Uuid
  supabaseUserId     String   @unique @db.Uuid
  name               String
  justcallUserId     String?  @unique
  isAvailable        Boolean  @default(false)
  maxConcurrentCalls Int      @default(1)
  activeCalls        Int      @default(0)
  createdAt          DateTime @default(now())
  updatedAt          DateTime @updatedAt
  phoneNumber        String?  @unique
  email              String?  @unique

  // Relations: Profile can be in many subscriptions via SubscriptionUser (bridge)
  calls             Call[]
  subscriptionUsers SubscriptionUser[]
  salesReps         SalesRep[]

  @@index([isAvailable, activeCalls])
}

model AiAgent {
  id              String              @id @default(uuid()) @db.Uuid
  name            String
  type            AiAgentType
  llmModelType    AiAgentLlmModelType
  subscriptionId  String?             @db.Uuid
  /**
   * Vercel AI Gateway model slug (e.g. openai/gpt-4o). When set, used instead of llmModelType default.
   */
  modelId         String?
  /**
   * Retell AI agent_id. Only set for type=caller agents that are linked to Retell.
   */
  retellAgentId   String?             @unique
  /**
   * Points to the currently active AiAgentVersion (most recently published config).
   * Set to null only when no version exists yet.
   */
  latestVersionId String?             @db.Uuid
  createdAt       DateTime            @default(now())
  updatedAt       DateTime            @updatedAt

  subscription                  Subscription?               @relation(fields: [subscriptionId], references: [id])
  versions                      AiAgentVersion[]            @relation("AgentVersions")
  latestVersion                 AiAgentVersion?             @relation("LatestVersion", fields: [latestVersionId], references: [id])
  campaignsPreferredForAnalysis Campaign[]                  @relation("CampaignPreferredAnalysisAgent")
  callAiAgentAnalysisResults    CallAiAgentAnalysisResult[]
  calls                         Call[]

  @@index([subscriptionId])
  @@index([retellAgentId])
  @@map("ai_agents")
}

model AiAgentVersion {
  id             String   @id @default(uuid()) @db.Uuid
  aiAgentId      String   @db.Uuid
  subscriptionId String?  @db.Uuid
  version        Int // 1-based
  mainPrompt     String   @default("")
  /**
   * For type=caller agents: stores the full Retell IAgentData config as JSON.
   * For type=analysis agents: stores supplementary config (model overrides, etc.).
   */
  configJson     Json     @default("{}")
  createdAt      DateTime @default(now())

  subscription    Subscription?               @relation(fields: [subscriptionId], references: [id])
  aiAgent         AiAgent                     @relation("AgentVersions", fields: [aiAgentId], references: [id], onDelete: Cascade)
  agentAsLatest   AiAgent[]                   @relation("LatestVersion")
  analysisResults CallAiAgentAnalysisResult[]
  calls           Call[]

  @@unique([aiAgentId, version])
  @@index([aiAgentId])
  @@index([subscriptionId])
  @@map("ai_agent_versions")
}

model CampaignTarget {
  id                   String  @id @default(uuid()) @db.Uuid
  campaignId           String  @db.Uuid
  subscriptionId       String? @db.Uuid
  targetPhoneNumbersId String? @db.Uuid
  contactId            String? @db.Uuid

  phoneNumber String
  payloadJson Json   @default(dbgenerated("'{}'::jsonb"))

  // State Machine
  state        TargetState @default(pending)
  attempts     Int         @default(0)
  lastError    String?
  tz           String?
  amazonSeller String?

  // Business Logic Cache
  isAmazonSeller            Boolean?
  meetsMonthlyThreshold     Boolean?
  interestedInDailyPayments Boolean?
  stopCalling               Boolean?
  disposition               String?
  lastCallRecordingUrl      String?
  nextAction                String?
  hubspotEngagementId       String?
  hubspotSyncStatus         String?
  validConversation         Boolean?
  isTestTarget              Boolean? @default(false)

  // Integration Cache
  hubspotContactId String?

  // Scheduling
  lastAttemptAt         DateTime? @db.Timestamptz(6)
  nextEarliestAttemptAt DateTime? @db.Timestamptz(6)
  leaseExpiresAt        DateTime? @db.Timestamptz(6)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  // Relations
  campaign          Campaign            @relation(fields: [campaignId], references: [id])
  subscription      Subscription?       @relation(fields: [subscriptionId], references: [id])
  contact           Contact?            @relation(fields: [contactId], references: [id])
  targetPhoneNumber TargetPhoneNumbers? @relation(fields: [targetPhoneNumbersId], references: [id])
  calls             Call[]
  followUpTasks     FollowUpTask[]
  contactSequences  ContactSequence[]

  @@index([campaignId, state])
  @@index([phoneNumber, state], map: "idx_campaign_targets_phone_state")
  @@index([subscriptionId])
  @@index([state])
  @@index([contactId])
  @@index([nextEarliestAttemptAt], map: "idx_campaign_targets_next_attempt")
  @@index([leaseExpiresAt], map: "idx_campaign_targets_lease")
}

model Call {
  id             String  @id @default(uuid()) @db.Uuid
  /// Nullable for external-only rows (e.g. Nooks `call.logged`) when no campaign match exists.
  campaignId     String? @db.Uuid
  targetId       String? @db.Uuid
  agentId        String? @db.Uuid
  agentVersionId String? @db.Uuid
  subscriptionId String? @db.Uuid

  // Identifiers
  targetPhoneNumber String?
  aiAgentId         String?
  aiAgentName       String?

  // ⚠️ CHANGE: Added @unique constraint for Upsert logic
  providerCallId String? @unique

  // Core Status
  status      CallStatus @default(created)
  direction   String     @default("outbound")
  channelType String? // 'phone_call', 'web_call', 'nooks_call'

  // Timestamps (Time)
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @default(now()) @updatedAt
  startTimestamp DateTime?
  endTimestamp   DateTime?

  // --- METRICS & KPIs (Auditability) ---

  // 1. Duration & Cost
  durationSec Int? // Duration (Rounded)
  durationMs  Int? // Duration (Exact)
  callCost    Float? // Cost

  // 2. Technical / Connection
  latencyMs           Int? // End to End Latency
  disconnectionReason String? // End Reason (Technical)
  retellDisposition   String? // Session Outcome (Raw Retell status)

  // 3. AI Analysis - Disposition & Sentiment
  userSentiment  String? // User Sentiment
  disposition    String? // Call Disposition (Our Interpretation)
  callSuccessful String? // Call Outcome (Legacy/Custom)
  score          Int? // 1-10 agent score for disposition (user-set)

  // 4. AI Analysis - Intelligence Flags
  inVoicemail           Boolean @default(false)
  callScreeningDetected Boolean @default(false) // call_screening_detected
  aiSuspicionDetected   Boolean @default(false) // ai_suspicion_detected
  detectedLanguage      String? // detected_language

  // 5. AI Analysis - Business Logic Extraction
  monthlyRevenue            String? // monthly_revenue
  isAmazonSeller            Boolean?
  meetsMonthlyThreshold     Boolean?
  interestedInDailyPayments Boolean?
  isDecisionMaker           Boolean  @default(false) // is_decision_maker

  // 6. Actionable Data (Next Steps)
  nextAction        String? // next_action (e.g. Do_Not_Call, Sales_Handoff)
  terminationReason String? // termination_reason (AI Logic reason)

  // 7. Callback & Contact Updates
  callbackRequested    Boolean @default(false) // callback_requested
  proposedCallbackTime String? // proposed_callback_time (Raw string from AI)

  contactName        String? // contact_name (Extracted)
  correctContactInfo String? // correct_contact_info
  correctNumber      String? // correct_number (If user gave new number)

  // Content
  summary      String?
  transcript   String?
  recordingUrl String?
  notes        String?

  // Integrations
  hubspotEngagementId String?
  hubspotSyncStatus   String? // synced, failed, skipped

  // Phone Number Relation (our outbound dial-from numbers)
  phoneNumberId String? @db.Uuid

  /// Callee row in `target_phone_numbers` (same FK idea as `CampaignTarget.targetPhoneNumbersId`; Nooks, etc.)
  targetPhoneNumbersId String? @db.Uuid

  // Relations
  campaign               Campaign?                   @relation(fields: [campaignId], references: [id])
  target                 CampaignTarget?             @relation(fields: [targetId], references: [id])
  agent                  AiAgent?                    @relation(fields: [agentId], references: [id])
  agentVersion           AiAgentVersion?             @relation(fields: [agentVersionId], references: [id])
  subscription           Subscription?               @relation(fields: [subscriptionId], references: [id])
  phoneNumber            PhoneNumbers?               @relation(fields: [phoneNumberId], references: [id])
  registryTargetPhone    TargetPhoneNumbers?         @relation("CallRegistryTargetPhone", fields: [targetPhoneNumbersId], references: [id])
  WebhookEvent           WebhookEvent[]
  aiAgentAnalysisResults CallAiAgentAnalysisResult[]
  salesRep               SalesRep?                   @relation(fields: [salesRepId], references: [id])
  salesRepId             String?                     @db.Uuid
  profile                Profile?                    @relation(fields: [profileId], references: [id])
  profileId              String?                     @db.Uuid
  testCases              AgentTestCase[]             @relation("CallTestCases")
  contactSequenceSteps   ContactSequenceStep[]

  @@index([campaignId, status])
  @@index([targetPhoneNumber, disposition], map: "idx_calls_phone_disposition")
  @@index([targetPhoneNumber, campaignId, createdAt], map: "idx_calls_phone_campaign_created")
  @@index([subscriptionId])
  @@index([providerCallId])
  @@index([agentId])
  @@index([targetId])
  @@index([channelType])
  @@index([phoneNumberId])
  @@index([targetPhoneNumbersId])
  @@index([salesRepId])
}

model CallAiAgentAnalysisResult {
  id                   String   @id @default(uuid()) @db.Uuid
  callId               String   @db.Uuid
  campaignId           String?  @db.Uuid
  subscriptionId       String   @db.Uuid
  aiAgentId            String   @db.Uuid
  aiAgentVersionId     String   @db.Uuid
  correctDisposition   Boolean
  suggestedDisposition String?
  agentScore           Int // 1-10
  summary              String
  issues               Json     @default("[]") // CallAnalysisIssue[]
  createdAt            DateTime @default(now())

  call           Call           @relation(fields: [callId], references: [id], onDelete: Cascade)
  campaign       Campaign?      @relation(fields: [campaignId], references: [id], onDelete: SetNull)
  subscription   Subscription   @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
  aiAgent        AiAgent        @relation(fields: [aiAgentId], references: [id], onDelete: Cascade)
  aiAgentVersion AiAgentVersion @relation(fields: [aiAgentVersionId], references: [id], onDelete: Cascade)

  @@unique([callId, aiAgentId, aiAgentVersionId])
  @@index([callId])
  @@index([campaignId])
  @@index([subscriptionId])
  @@map("call_ai_agent_analysis_results")
}

model WebhookEvent {
  id             String    @id @default(uuid()) @db.Uuid
  source         String
  eventId        String
  eventType      String
  rawPayload     Json
  providerCallId String?
  callId         String?   @db.Uuid
  receivedAt     DateTime  @default(now())
  processedAt    DateTime?

  call Call? @relation(fields: [callId], references: [id])

  @@unique([source, eventId])
  @@index([providerCallId])
  @@index([callId])
  @@index([source, eventId])
}

model PhoneNumbers {
  id             String  @id @default(uuid()) @db.Uuid
  phoneNumber    String  @unique
  isDefault      Boolean @default(false)
  isActive       Boolean @default(true)
  isDeleted      Boolean @default(false)
  subscriptionId String  @db.Uuid

  maxAllowedCalls       Int?
  costPerSecond         Float?
  maxAllowedCallsPerDay Int?

  allowedOutboundShortCodes Json? @default("[]")

  // Phone Number Management
  state               String? // US state code (e.g., "CA", "NY")
  phoneNumberSid      String? // Microservice phone number SID
  retellPhoneNumberId String? // Retell phone number ID after registration
  monthlyCost         Float? // Monthly cost from microservice
  country             String  @default("US") // Country code
  provider            String  @default("external") // Purchase provider: "external" | "retell_twilio" | "retell_telnyx"
  smsEnabled          Boolean @default(false) // Whether this number is designated for SMS outreach
  isReleased          Boolean @default(false) // Soft delete

  releasedAt     DateTime?
  lastUsedAt     DateTime? // For optimization
  deletedAt      DateTime?
  dailyCallCount Int       @default(0) // Reset daily
  totalCallCount Int       @default(0) // Lifetime usage
  purchasedAt    DateTime  @default(now())
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @updatedAt

  // Relations
  calls        Call[]
  subscription Subscription @relation(fields: [subscriptionId], references: [id])

  @@index([phoneNumber])
  @@index([state, isActive, isDeleted])
  @@index([subscriptionId])
}

model Configs {
  id             String @id @default(uuid()) @db.Uuid
  key            String @unique
  subscriptionId String @db.Uuid

  value     String
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  subscription Subscription @relation(fields: [subscriptionId], references: [id])

  @@index([key])
  @@index([subscriptionId])
  @@map("configs")
}

model AgentTestCase {
  id             String @id @default(uuid()) @db.Uuid
  subscriptionId String @db.Uuid

  name  String?
  notes String?

  // Source call — null for manually-entered test cases
  sourceCallId String? @db.Uuid

  // Call inputs
  transcript   String?
  recordingUrl String?

  // Ground-truth values (the correct outcome this test case must produce)
  disposition           String?
  isAmazonSeller        Boolean?
  aiSuspicionDetected   Boolean?
  callScreeningDetected Boolean?
  isDecisionMaker       Boolean?
  userSentiment         String?
  nextAction            String?
  callSuccessful        String?
  inVoicemail           Boolean?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  subscription Subscription @relation(fields: [subscriptionId], references: [id])
  sourceCall   Call?        @relation("CallTestCases", fields: [sourceCallId], references: [id])

  @@index([subscriptionId])
  @@index([sourceCallId])
  @@map("agent_test_cases")
}

model Activity {
  id             String       @id @default(uuid()) @db.Uuid
  subscriptionId String       @db.Uuid
  type           ActivityType

  // FK to Contact — nullable for backward compat with existing rows
  contactId    String? @db.Uuid
  contactPhone String?
  contactEmail String?
  campaignId   String? @db.Uuid
  targetId     String? @db.Uuid
  taskId       String? @db.Uuid

  subject String? // email subject line
  body    String? // message preview / body

  status    String @default("sent") // sent | failed | delivered | received
  direction String @default("outbound")

  notes               String?
  retellChatId        String?
  hubspotContactId    String?
  hubspotEngagementId String?

  externalId String?

  metadata  Json?
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  subscription         Subscription          @relation(fields: [subscriptionId], references: [id])
  contact              Contact?              @relation(fields: [contactId], references: [id])
  task                 FollowUpTask?         @relation(fields: [taskId], references: [id])
  contactSequenceSteps ContactSequenceStep[]

  @@unique([subscriptionId, externalId])
  @@index([subscriptionId])
  @@index([type])
  @@index([hubspotEngagementId])
  @@index([createdAt])
  @@index([contactPhone])
  @@index([contactId])
  @@index([taskId])
}

// Represents a company that sells products on Amazon, Walmart, or Shopify
model Seller {
  id                    String               @id @default(uuid())
  subscriptionId        String               @db.Uuid
  platform              SellerPlatform
  datasource            SellerDatasource
  lifeCycleStage        SellerLifeCycleStage @default(new)
  name                  String?
  singleBrand           Boolean              @default(false)
  brandNameMatch        Boolean              @default(false)
  email                 String?              @unique
  phone                 String?              @unique
  primaryCategory       String?
  primarySubCategory    String?
  businessName          String?
  sellerId              String               @unique
  estimateSales         Int?
  avgPrice              Int?
  percentFba            Float?
  numberReviewsLifetime Int?
  numberReviews30Days   Int?
  numberWinningBrands   Int?
  numberAsins           Int?
  numberTopAsins        Int?

  street  String?
  city    String?
  state   String?
  country String?
  zipCode String?

  numBrands           Int?
  moMGrowth           Float?
  threeMonthGrowth    Float?
  sixMonthGrowth      Float?
  yearGrowth          Float?
  moMGrowthCount      Int?
  sixMonthGrowthCount Int?
  isSuspended         Boolean   @default(false)
  lastSuspendedDate   DateTime?
  startedSellingDate  DateTime?

  // ── HubSpot Company sync ──────────────────────────────────────
  // Sellers map to HubSpot Companies (not Contacts)
  hubspotCompanyId    String?                   @unique
  hubspotOwnerId      String?
  hubspotSyncStatus   ContactHubspotSyncStatus?
  hubspotLastSyncedAt DateTime?
  hubspotEngagementId String?

  // ── HubSpot Company enrichment fields ────────────────────────
  website           String?
  description       String?
  linkedinUrl       String?
  industry          String?
  annualRevenue     Int?
  numberOfEmployees Int?

  payoutPainScore Int?

  note   String?
  errors Json?   @default("[]")

  enrichedAt DateTime?
  scrapedAt  DateTime?
  createdAt  DateTime  @default(now())
  updatedAt  DateTime  @default(now()) @updatedAt

  subscription Subscription @relation(fields: [subscriptionId], references: [id])
  contacts     Contact[]

  @@index([subscriptionId])
  @@index([platform])
  @@index([sellerId])
  @@index([hubspotCompanyId])
  @@index([country])
  @@index([isSuspended])
  @@index([primaryCategory])
  @@map("sellers")
}

/**
 * Single source of truth for contacts — shared between CampaignTargets and HubSpot.
 * Phone and email are both nullable-unique: non-null values must be unique, multiple NULLs allowed.
 */
model Contact {
  id             String  @id @default(uuid()) @db.Uuid
  subscriptionId String  @db.Uuid
  sellerId       String?

  // ── Identity ──────────────────────────────────────────────────
  firstName   String?
  lastName    String?
  email       String?
  phone       String?
  mobilePhone String?
  companyName String?
  jobTitle    String?
  website     String?

  // ── Address ───────────────────────────────────────────────────
  street  String?
  city    String?
  state   String?
  country String?
  zipCode String?

  // ── Source & Lifecycle ────────────────────────────────────────
  source         String                 @default("manual")
  lifecycleStage ContactLifecycleStage?
  leadStatus     ContactLeadStatus?
  heatStatus     ContactHeatStatus      @default(idle) // hot | warm | warming | idle
  totalRevenue   Float?
  leadScore      Int?

  // ── Calling / Campaign context ────────────────────────────────
  disposition          String?
  nextAction           String?
  stopCalling          Boolean  @default(false)
  validConversation    Boolean?
  lastCallRecordingUrl String?

  // ── HubSpot sync ─────────────────────────────────────────────
  hubspotContactId    String?
  hubspotOwnerId      String?
  hubspotSyncStatus   ContactHubspotSyncStatus?
  hubspotLastSyncedAt DateTime?
  hubspotEngagementId String?
  hubspotSyncPriority Int?                      @default(0)
  hubspotDealId       String?
  hubspotDealStatus   String?
  hubspotDealStage    String?

  // ── Supplier sync ─────────────────────────────────────────────
  supplierKey                String?
  supplierStatus             String?
  supplierCancellationStatus String?
  supplierMarketplace        String?

  // ── Notes & metadata ──────────────────────────────────────────
  notes        String?
  customFields Json?   @default("{}")

  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  subscription         Subscription          @relation(fields: [subscriptionId], references: [id])
  seller               Seller?               @relation(fields: [sellerId], references: [id])
  emailCampaignTargets EmailCampaignTarget[]
  activities           Activity[]
  targetPhoneNumbers   TargetPhoneNumbers[]
  targetEmails         TargetEmails[]
  emails               Emails[]
  campaignTargets      CampaignTarget[]
  followUpTasks        FollowUpTask[]
  contactSequences     ContactSequence[]

  @@unique([subscriptionId, email])
  @@unique([subscriptionId, phone])
  @@unique([subscriptionId, hubspotContactId])
  @@unique([subscriptionId, supplierKey])
  @@index([subscriptionId])
  @@index([sellerId])
  @@index([email])
  @@index([phone])
  @@index([disposition])
  @@index([hubspotContactId])
  @@index([supplierKey])
  @@index([source])
  @@index([lifecycleStage])
  @@index([leadStatus])
  @@index([stopCalling])
  @@map("contacts")
}

/**
 * Mediation table linking a Contact to an email Campaign.
 * One record per (campaign, contact) pair. contactId is always set —
 * CSV imports upsert the Contact first before creating this record.
 */
model EmailCampaignTarget {
  id             String @id @default(uuid()) @db.Uuid
  campaignId     String @db.Uuid
  contactId      String @db.Uuid
  subscriptionId String @db.Uuid
  emailId        String @db.Uuid

  // Cached contact fields for quick display without join
  email     String
  firstName String?
  lastName  String?

  // State
  status    EmailTargetStatus @default(pending)
  attempts  Int               @default(0)
  lastError String?

  // Tracking
  sentAt         DateTime?
  openedAt       DateTime?
  clickedAt      DateTime?
  unsubscribedAt DateTime?

  // Resend correlation + variation used + sending domain
  resendEmailId   String?
  variationId     String? @db.Uuid
  sendingDomainId String? @db.Uuid

  // Set when the target is enqueued to Inngest; used to prevent duplicate sends
  // during a lock window of 3× the campaign's schedulerIntervalSeconds.
  queuedAt DateTime?

  /**
   * True after outbound send CRM activity was successfully queued to HubSpot (SQS).
   */
  hubspotSynced Boolean @default(false)

  /// Inbound / LLM-derived engagement category (e.g. reply classification).
  category      EmailTargetCategory?
  categorisedAt DateTime?
  /// Audit: model id or label used when `category` was set.
  categoryModel String?

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  campaign         Campaign          @relation(fields: [campaignId], references: [id])
  contact          Contact           @relation(fields: [contactId], references: [id])
  subscription     Subscription      @relation(fields: [subscriptionId], references: [id])
  variation        EmailVariation?   @relation(fields: [variationId], references: [id])
  targetEmail      TargetEmails      @relation(fields: [emailId], references: [id])
  emails           Emails[]
  contactSequences ContactSequence[]

  @@unique([campaignId, contactId])
  @@unique([campaignId, email])
  @@index([campaignId, status])
  @@index([campaignId, status, queuedAt])
  @@index([campaignId, sentAt])
  @@index([emailId])
  @@index([subscriptionId])
  @@index([contactId])
  @@index([email])
  @@index([resendEmailId])
  @@map("email_campaign_targets")
}

model BlockedPhoneNumbers {
  id             String @id @default(uuid()) @db.Uuid
  subscriptionId String @db.Uuid
  phoneNumber    String @unique

  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  subscription Subscription @relation(fields: [subscriptionId], references: [id])

  @@index([subscriptionId])
  @@index([phoneNumber])
  @@map("blocked_phone_numbers")
}

/**
 * Content variation for an email campaign.
 * Multiple active variations ensure recipients see different content.
 */
model EmailVariation {
  id             String @id @default(uuid()) @db.Uuid
  campaignId     String @db.Uuid
  subscriptionId String @db.Uuid

  name     String
  subject  String
  htmlBody String @db.Text

  // Optional Resend template reference
  resendTemplateId  String?
  templateType      String  @default("custom") // "custom" | "resend_template"
  // Resend template variables – array of { key, type, fallbackValue }
  templateVariables Json    @default("[]")

  status EmailVariationStatus @default(active)

  spamScore    Int?
  markedAsSpam Boolean @default(false)
  usageCount   Int     @default(0)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  campaign     Campaign              @relation(fields: [campaignId], references: [id])
  subscription Subscription          @relation(fields: [subscriptionId], references: [id])
  targets      EmailCampaignTarget[]

  @@index([campaignId, status])
  @@index([subscriptionId])
  @@map("email_variations")
}

enum DomainWarmupStatus {
  warming_up
  active
}

/**
 * Parent sending domain per subscription — one row per Resend domain (e.g. payability.com
 * or a subdomain sales.payability.com). Holds the Resend domain id, a daily cap shared
 * across all its email accounts, and a warm-up status label.
 */
model Domain {
  id             String @id @default(uuid()) @db.Uuid
  subscriptionId String @db.Uuid

  name String // e.g. payability.com or sales.payability.com

  resendDomainId String?
  resendStatus   String? // mirror of Resend status at link time

  dailyCap     Int? // null = unlimited
  warmupStatus DomainWarmupStatus @default(active)
  isActive     Boolean            @default(true)

  totalSentCount Int @default(0) // lifetime sends across all accounts; never reset by the daily cron

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  subscription   Subscription         @relation(fields: [subscriptionId], references: [id])
  sendingDomains EmailSendingDomain[]

  @@unique([subscriptionId, name])
  @@index([subscriptionId])
  @@map("domains")
}

/**
 * Sending domain registry per subscription (an "email account" in the UI).
 * Supports multiple from-addresses that belong to a parent Domain for campaign rotation.
 */
model EmailSendingDomain {
  id             String  @id @default(uuid()) @db.Uuid
  subscriptionId String  @db.Uuid
  domainId       String? @db.Uuid

  domain    String
  fromName  String
  fromEmail String

  resendDomainId String?
  isVerified     Boolean @default(false)
  isActive       Boolean @default(true)

  dailyCap       Int? // per-account daily cap; null = unlimited
  dailySentCount Int  @default(0) // reset daily by cron; distinct from lifetime sentCount
  totalSentCount Int  @default(0) // lifetime handoff count, bumped with dailySentCount; never reset

  sentCount         Int @default(0)
  receivedCount     Int @default(0)
  openedCount       Int @default(0)
  clickedCount      Int @default(0)
  bouncedCount      Int @default(0)
  unsubscribedCount Int @default(0)

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  subscription Subscription @relation(fields: [subscriptionId], references: [id])
  parentDomain Domain?      @relation(fields: [domainId], references: [id])
  emails       Emails[]

  @@unique([subscriptionId, fromEmail])
  @@index([subscriptionId])
  @@index([domainId])
  @@map("email_sending_domains")
}

model TargetPhoneNumbers {
  id          String @id @default(uuid()) @db.Uuid
  phoneNumber String @unique

  subscriptionId String  @db.Uuid
  contactId      String? @db.Uuid

  countryCode       String?
  carrierName       String?
  mobileCountryCode String?
  mobileNetworkCode String?

  syncPriority Int @default(0)

  isActive  Boolean @default(true)
  isDeleted Boolean @default(false)
  isBlocked Boolean @default(false)

  syncTwilio  Boolean @default(false)
  syncHubspot Boolean @default(false)

  lineType  LineTypes?
  riskLevel RiskLevel?

  hubspotSyncedAt DateTime?
  createdAt       DateTime  @default(now())
  updatedAt       DateTime  @default(now()) @updatedAt

  subscription    Subscription     @relation(fields: [subscriptionId], references: [id])
  contact         Contact?         @relation(fields: [contactId], references: [id])
  campaignTargets CampaignTarget[]
  calls           Call[]           @relation("CallRegistryTargetPhone")

  @@index([contactId])
  @@map("target_phone_numbers")
}

model TargetEmails {
  id    String @id @default(uuid()) @db.Uuid
  email String

  subscriptionId String  @db.Uuid
  contactId      String? @db.Uuid

  isActive  Boolean @default(true)
  isDeleted Boolean @default(false)
  isBlocked Boolean @default(false)

  newsletterOptOut   Boolean   @default(false)
  newsletterOptOutAt DateTime?
  oneToOneOptOut     Boolean   @default(false)
  oneToOneOptOutAt   DateTime?
  marketingOptOut    Boolean   @default(false)
  marketingOptOutAt  DateTime?

  newsletterGapDays    Int?      @default(7)
  newsletterLastSentAt DateTime?

  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  subscription Subscription @relation(fields: [subscriptionId], references: [id])
  contact      Contact?     @relation(fields: [contactId], references: [id])

  emailCampaignTargets EmailCampaignTarget[]

  @@unique([subscriptionId, email])
  @@index([subscriptionId])
  @@index([subscriptionId, isDeleted, isBlocked, newsletterOptOut])
  @@index([contactId])
  @@map("target_emails")
}

enum EmailDirection {
  sent
  received
}

/**
 * Inbox list/detail taxonomy (filters: Campaign, Reply, Transactional, Other).
 * Distinct from EmailTargetCategory (LLM reply classification on EmailCampaignTarget).
 */
enum EmailInboxCategory {
  campaign
  reply
  transactional
  other
}

model Emails {
  id             String @id @default(uuid()) @db.Uuid
  subscriptionId String @db.Uuid

  sendingDomainId       String? @db.Uuid
  contactId             String? @db.Uuid
  campaignId            String? @db.Uuid
  /// Email campaign target row when this message belongs to a campaign send/reply chain.
  emailCampaignTargetId String? @db.Uuid

  /// Shared by all messages in a conversation; new UUID for each new thread.
  threadId String @db.Uuid

  direction     EmailDirection
  inboxCategory EmailInboxCategory?
  /// LLM-derived category when synced from EmailCampaignTarget (optional).
  category      EmailTargetCategory?

  /// Resend email_id (outbound or inbound).
  externalId          String
  /// Parent Message-Id / Resend id for reply correlation (optional).
  inReplyToExternalId String?

  toEmail   String
  fromEmail String
  subject   String
  body      String  @db.Text
  bodyHtml  String? @db.Text

  /// Pixel-open tracking (Resend email.opened).
  opened Boolean   @default(false)
  /// In-app read state (separate from opened).
  readAt DateTime?

  archivedAt DateTime?

  sentAt    DateTime?
  createdAt DateTime  @default(now())
  updatedAt DateTime  @default(now()) @updatedAt

  subscription         Subscription          @relation(fields: [subscriptionId], references: [id])
  sendingDomain        EmailSendingDomain?   @relation(fields: [sendingDomainId], references: [id])
  contact              Contact?              @relation(fields: [contactId], references: [id])
  campaign             Campaign?             @relation(fields: [campaignId], references: [id])
  emailCampaignTarget  EmailCampaignTarget?  @relation(fields: [emailCampaignTargetId], references: [id])
  contactSequenceSteps ContactSequenceStep[]

  @@unique([subscriptionId, externalId])
  @@index([subscriptionId, createdAt(sort: Desc)])
  @@index([subscriptionId, threadId, createdAt])
  @@index([subscriptionId, archivedAt])
  @@index([toEmail])
  @@index([fromEmail])
  @@index([contactId])
  @@index([emailCampaignTargetId])
  @@map("emails")
}

/**
 * Represents a sales rep who is assigned to follow up on leads.
 * Subscription-scoped so the same Supabase user can have different CRM IDs
 * across workspaces. profileId is optional — a rep can be added manually
 * before they have a Supabase account.
 */
model SalesRep {
  id             String @id @default(uuid()) @db.Uuid
  subscriptionId String @db.Uuid

  // Optional link to a Supabase-authenticated user
  profileId String? @db.Uuid

  name  String
  email String

  // CRM identifiers
  hubspotOwnerId       String? // HubSpot owner ID (used to assign tasks/contacts in HubSpot)
  nooksUserId          String? // Nooks user ID (used to assign tasks and sequences in Nooks)
  activeListId         String? // Active list ID (used the sales rep sequences in Hubspot)
  hubspotCallSegmentId String? // HubSpot call segment ID (used to assign calls to the sales rep)

  isActive Boolean @default(true)

  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  subscription  Subscription   @relation(fields: [subscriptionId], references: [id])
  profile       Profile?       @relation(fields: [profileId], references: [id])
  followUpTasks FollowUpTask[]
  calls         Call[]

  @@unique([subscriptionId, email])
  @@index([subscriptionId])
  @@index([profileId])
  @@index([hubspotOwnerId])
  @@index([nooksUserId])
  @@map("sales_reps")
}

/**
 * Tracks follow-up tasks created for sales reps against leads.
 * A task can be mirrored in HubSpot (hubspotTaskId) and/or Nooks (nooksTaskId)
 * and optionally backed by a Nooks sequence enrollment (nooksSequenceStateId).
 * Both external IDs are nullable — a task may only be pushed to one platform
 * depending on which integrations are active for the workspace.
 */
model FollowUpTask {
  id             String @id @default(uuid()) @db.Uuid
  subscriptionId String @db.Uuid

  // Who the task is assigned to
  salesRepId String @db.Uuid

  // What lead the task is about
  contactId         String  @db.Uuid
  campaignTargetId  String? @db.Uuid
  contactSequenceId String? @db.Uuid

  type   FollowUpTaskType   @default(call)
  status FollowUpTaskStatus @default(pending)

  // External system IDs
  hubspotTaskId        String? // HubSpot CRM Task object ID
  nooksTaskId          String? // Nooks task ID
  nooksSequenceStateId String? // Nooks sequence enrollment ID (when type = sequence_enrollment)

  // Sync status per platform
  hubspotSyncStatus String? // pending | synced | failed
  nooksSyncStatus   String? // pending | synced | failed
  notes             String?

  escalationStage Int       @default(0) // 0→1→2→3=failed
  inngestRunId    String? // store run ID so you can cancel it programmatically
  lastEscalatedAt DateTime?

  // Scheduling
  dueAt             DateTime? @db.Timestamptz(6)
  lastHubspotSyncAt DateTime? @db.Timestamptz(6)
  lastNooksSyncAt   DateTime? @db.Timestamptz(6)
  completedAt       DateTime? @db.Timestamptz(6)
  createdAt         DateTime  @default(now())
  updatedAt         DateTime  @default(now()) @updatedAt

  subscription         Subscription          @relation(fields: [subscriptionId], references: [id])
  salesRep             SalesRep              @relation(fields: [salesRepId], references: [id])
  contact              Contact               @relation(fields: [contactId], references: [id])
  campaignTarget       CampaignTarget?       @relation(fields: [campaignTargetId], references: [id])
  contactSequence      ContactSequence?      @relation(fields: [contactSequenceId], references: [id])
  activities           Activity[]
  contactSequenceSteps ContactSequenceStep[]

  @@index([subscriptionId])
  @@index([salesRepId])
  @@index([contactId])
  @@index([campaignTargetId])
  @@index([status])
  @@index([dueAt])
  @@index([hubspotTaskId])
  @@index([nooksTaskId])
  @@map("follow_up_tasks")
}

model SequenceTemplate {
  id             String @id @default(uuid()) @db.Uuid
  subscriptionId String @db.Uuid

  name        String
  goal        String? // sequence goal description for the ai to understand
  description String?

  callCampaignDefault  Boolean @default(false)
  emailCampaignDefault Boolean @default(false)
  smsCampaignDefault   Boolean @default(false)

  type SequenceType

  isActive  Boolean  @default(true)
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  subscription     Subscription           @relation(fields: [subscriptionId], references: [id])
  steps            SequenceTemplateStep[]
  contactSequences ContactSequence[]

  @@index([subscriptionId])
  @@map("sequence_templates")
}

model SequenceTemplateStep {
  id         String @id @default(uuid()) @db.Uuid
  templateId String @db.Uuid

  stepIndex    Int
  delayHours   Int // wait steps: the delay amount (interpreted per delayUnit)
  delayUnit    String @default("hours") // "hours" | "minutes" — only meaningful for wait steps
  timeoutHours Int? // for call_task: how long rep has

  name            String? // step name for the ai to understand
  description     String? // step description for the ai to understand
  instructions    String? // step instructions for the ai to understand
  expectedOutcome String? // step expected outcome for the ai to understand

  emailTone        String? // warm | direct | social_proof | urgency | breakup
  emailSubjectHint String? // optional hint to AI writer
  smsTemplate      String? // for SMS steps
  isAiGenerated    Boolean @default(true) // use AI writer or fixed template
  fixedEmailId     String? @db.Uuid // if not AI, point to a fixed EmailVariation

  channel SequenceStepChannel // email | call_task | sms | wait | data_gate | crm_task

  // Data gate / branching (only used when channel = data_gate).
  // The gate runs a BigQuery check against `gateBigqueryView` and routes the
  // sequence to a different step based on the boolean result.
  gateBigqueryView     String? // BQ view name (e.g. "outreach_app.contacts"); must expose a hubspotcontactid column
  // Multiple conditions evaluated against a single row for this contact.
  // Shape: [{ field: string, operator: string, value: string | null }]
  // operator: is_not_null | is_null | equals | not_equals | gt | lt | is_in | is_not_in
  gateConditions       Json? // list of field checks; combined per gateMatchMode
  gateMatchMode        String  @default("all") // "all" (AND) | "any" (OR)
  // Legacy single-condition fields (kept for backward compatibility; new writes use gateConditions).
  gateField            String? // deprecated: column to evaluate
  gateOperator         String? // deprecated: operator
  gateValue            String? // deprecated: comparison value
  trueBranchStepIndex  Int? // step to jump to when the condition is TRUE
  falseBranchStepIndex Int? // step to jump to when the condition is FALSE
  gateRetryDelayHours  Int? // when set, FALSE waits this long and re-checks instead of jumping
  gateMaxRetries       Int? // cap on re-checks before the sequence is abandoned (null = unbounded)

  // CRM task config (only used when channel = crm_task).
  crmTaskType        String? // call | email | follow_up
  crmTaskDescription String? // task body / notes
  crmTaskDueHours    Int? // due-date offset in hours from execution time

  extraConfigs Json?

  template      SequenceTemplate      @relation(fields: [templateId], references: [id], onDelete: Cascade)
  executedSteps ContactSequenceStep[]

  @@unique([templateId, stepIndex])
  @@index([templateId])
  @@map("sequence_template_steps")
}

model ContactSequence {
  id             String @id @default(uuid()) @db.Uuid
  subscriptionId String @db.Uuid
  contactId      String @db.Uuid
  templateId     String @db.Uuid

  currentStepIndex Int       @default(0)
  nextStepAt       DateTime? @db.Timestamptz(6) // when to execute next step

  // Inngest run id of the single execution that drives this sequence end-to-end.
  // All ContactSequenceStep logs for this run share the same executionId.
  executionId String?

  // Link back to what triggered this
  campaignTargetId      String? @db.Uuid
  emailCampaignTargetId String? @db.Uuid
  campaignId            String? @db.Uuid

  startedAt   DateTime? @db.Timestamptz(6)
  completedAt DateTime? @db.Timestamptz(6)
  abandonedAt DateTime? @db.Timestamptz(6)

  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  status SequenceStatus @default(pending)

  subscription        Subscription          @relation(fields: [subscriptionId], references: [id])
  template            SequenceTemplate      @relation(fields: [templateId], references: [id])
  contact             Contact?              @relation(fields: [contactId], references: [id])
  campaign            Campaign?             @relation(fields: [campaignId], references: [id])
  campaignTarget      CampaignTarget?       @relation(fields: [campaignTargetId], references: [id])
  emailCampaignTarget EmailCampaignTarget?  @relation(fields: [emailCampaignTargetId], references: [id])
  steps               ContactSequenceStep[]
  followUpTasks       FollowUpTask[]

  // "One active sequence per contact" is enforced in application code
  // (SequenceExecutionService.enrollContact), not by a DB unique constraint —
  // a (contactId, status) unique wrongly blocks multiple terminal/paused rows.
  @@index([contactId]) // lookups by contact (was covered by the removed unique)
  @@index([status, nextStepAt]) // scheduler query
  @@index([executionId])
  @@index([subscriptionId])
  @@index([campaignId])
  @@index([campaignTargetId])
  @@index([emailCampaignTargetId])
  @@map("contact_sequences")
}

model ContactSequenceStep {
  id             String  @id @default(uuid()) @db.Uuid
  sequenceId     String  @db.Uuid
  // Nullable + SetNull: a template step can be removed while editing a template
  // without destroying this historical log — the link is severed, the log kept.
  templateStepId String? @db.Uuid

  // Inngest run id of the execution that produced this log (mirrors ContactSequence.executionId).
  executionId String?

  status String @default("pending") // pending | executed | skipped | failed

  // Number of times a data_gate step has re-checked its condition (retry loop guard).
  gateRetryCount Int @default(0)

  // What was sent/created
  callId         String? @db.Uuid // FK to Call
  emailId        String? @db.Uuid // FK to Emails
  followUpTaskId String? @db.Uuid // FK to FollowUpTask (for call_task steps)
  activityId     String? @db.Uuid // FK to Activity (for SMS)

  executedAt DateTime? @db.Timestamptz(6)
  outcome    String? // replied | opened | connected | no_answer | no_reply

  // Full response payload from this execution (provider ids, CRM event body,
  // gate evaluation details, etc.) — one row per execution, never overwritten.
  response Json?

  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  sequence     ContactSequence       @relation(fields: [sequenceId], references: [id])
  call         Call?                 @relation(fields: [callId], references: [id])
  email        Emails?               @relation(fields: [emailId], references: [id])
  followUpTask FollowUpTask?         @relation(fields: [followUpTaskId], references: [id])
  activity     Activity?             @relation(fields: [activityId], references: [id])
  templateStep SequenceTemplateStep? @relation(fields: [templateStepId], references: [id], onDelete: SetNull)

  @@index([sequenceId])
  @@index([executionId])
  @@index([templateStepId])
  @@index([callId])
  @@index([emailId])
  @@index([followUpTaskId])
  @@index([activityId])
  @@map("contact_sequence_steps")
}