Domain model

Entity map

erDiagram
  Subscription ||--o{ Seller : owns
  Subscription ||--o{ Contact : owns
  Subscription ||--o{ SalesRep : owns
  Subscription ||--o{ Campaign : owns
  Subscription ||--o{ Configs : owns

  Seller ||--o{ Contact : "employs"
  Contact ||--o{ TargetPhoneNumbers : "reachable at"
  Contact ||--o{ TargetEmails : "reachable at"
  Contact ||--o{ CampaignTarget : "enrolled as"
  Contact ||--o{ EmailCampaignTarget : "enrolled as"
  Contact ||--o{ Activity : "has"
  Contact ||--o{ FollowUpTask : "subject of"

  Campaign ||--o{ CampaignTarget : contains
  Campaign ||--o{ EmailCampaignTarget : contains
  Campaign ||--o{ EmailVariation : has

  CampaignTarget ||--o{ Call : produced
  CampaignTarget ||--o{ FollowUpTask : triggered

  SalesRep ||--o{ FollowUpTask : "assigned"
  SalesRep ||--o{ Call : made

  EmailCampaignTarget ||--o{ Emails : "messages"
  FollowUpTask ||--o{ Activity : logged

The entities that matter

Subscription — the tenant

Every domain row carries subscriptionId. Production runs one: b191a905-5d19-4fa0-941b-d8eb81c0c477. The subscriptions table itself is not managed by morpheus-data-layer, which is why no ORM model declares a foreign key to it.

Seller — a marketplace business

Maps to a HubSpot Company, not a Contact. Keyed by sellerId, the external marketplace identifier (ATVPDKIKX0DER). Carries marketplace metrics — estimateSales, numberAsins, percentFba, growth rates — plus enrichment output (website, linkedinUrl, hubspotCompanyId) and payoutPainScore.

platform is amazon, walmart, or shopify. datasource is smartscout or manual.

Contact — a person

The single source of truth shared between campaign targets and HubSpot. Optionally linked to a Seller. Carries identity, address, lifecycle and lead status, calling context (disposition, stopCalling, nextAction), HubSpot sync fields, and supplier fields (supplierKey, supplierStatus).

Four unique constraints, all per subscription: email, phone, hubspotContactId, supplierKey. Nullable-unique — many rows may have NULL, but a non-null value must be unique.

TargetPhoneNumbers / TargetEmails — reachable endpoints

A contact may have several. These carry the channel state that does not belong on the person: line type and risk level for phones, opt-out flags and send spacing for emails. This is where suppression actually lives.

CampaignTarget — one phone number in a calling campaign

Its own state machine plus a cache of business-logic answers (isAmazonSeller, meetsMonthlyThreshold, interestedInDailyPayments, stopCalling, disposition) and scheduling fields (nextEarliestAttemptAt, leaseExpiresAt).

Call — one call attempt

Written by both dialer paths. Holds identifiers, status and timing, cost and latency, AI analysis (sentiment, disposition, intelligence flags, extracted business logic), actionable next steps, and HubSpot correlation. providerCallId is unique when set, which is what makes the Five9 upsert idempotent.

Activity — a timeline entry

sms, email, call, task, meeting, note, or reminder. Unique on (subscriptionId, externalId), which is how the BigQuery engagement sync upserts HubSpot engagements idempotently.

FollowUpTask — a commitment by a rep

Mirrored into HubSpot and optionally Nooks. Carries escalationStage for the Slack ladder. See Follow-up tasks and escalation.

SalesRep — a human who calls

Subscription-scoped, so the same person can be a rep in several workspaces with different CRM ids. Holds hubspotOwnerId, nooksUserId, activeListId, and hubspotCallSegmentId. isActive gates every selector.

Configs — runtime state

A key/value table used for watermarks, pagination cursors, round-robin position, and kill switches. key is globally unique, not unique per subscription — which is why the round-robin key embeds the subscription id. See Runtime state in configs.

Identity and matching

The hardest problem in the platform is deciding that two records are the same person.

Entity Natural key HubSpot correlation

Seller

sellerId (marketplace id)

hubspotCompanyId; search falls back to the amazon_seller_id property, then name

Contact

email, then phone

hubspotContactId; search is phone-first, then email and mobile

Call

providerCallId

hubspotEngagementId

Activity

(subscriptionId, externalId)

externalId is the HubSpot engagement_id

FollowUpTask

id

hubspotTaskId

SalesRep

(subscriptionId, email)

hubspotOwnerId

Three mechanisms keep identity converging:

Phone-first resolution

resolve_hubspot_contact_id_phone_first() tries phone, then email, then mobile phone, and reports which step matched plus whether there were multiple matches. Phone first because a dialer knows a number and may not know an email.

Contact backfill

ensure_local_contact_row() guarantees a local row exists for a HubSpot contact id, short-circuiting through: already loaded → local lookup by HubSpot id → fetch from the CRM and upsert → repair the campaign target’s foreign keys.

Scheduled id resolution

Hubspot.ContactResolve widens the search to every related target_phone_numbers and target_emails row, and stamps hubspotLastSyncedAt even on a miss so the batch keeps moving through fresh candidates.

The email-click BigQuery view deliberately resolves HubSpot contacts by email rather than by contacts.hubspotcontactid, because that mirror column is frequently null or stale at query time. Keying the joins on it let rows through unlinked, so suppression checks silently missed. The view’s header comment documents this at length.

Two lifecycle ladders

Seller.lifeCycleStage — the internal enrichment pipeline

new → raw_lead → enriched → in_sequence → engaged → qualified → bdr_handoff → demo_scheduled → opportunity → customer, plus disqualified. Morpheus writes only new and enriched.

Contact.lifecycleStage — a mirror of HubSpot’s lifecyclestage

subscriber → lead → marketingqualifiedlead → salesqualifiedlead → opportunity → customer → evangelist, plus other. Morpheus only ever promotes, via a rank guard. See Outbound funnel.

Alongside them, two columns act as pipeline gates rather than stages: enrichedAt (enrichment has run) and payoutPainScore (scoring has run). Both are IS NULL checks in the backfill queries.

Heat status

Contact.heatStatus is a coarse engagement temperature:

Value Meaning

hot

Active follow-up in progress

warm

A rep acted but the deal did not close

warming

The task failed; an automated email track is running

idle

30 days of silence, deprioritised

Morpheus writes hot in one place: after the dialer sync creates a follow-up task. The other transitions belong to the web application.