Segmentation models

The segmentation tables turn re-engagement from an unrecorded, hardcoded selection into something measurable. Before them, a producer wrote call_list_date / call_priority / call_assigned_to straight to HubSpot and the run ended — yesterday’s cohort was unrecoverable and nothing joined the enrolled list back to what the rep actually did.

Manual refresh is wired: POST /api/segments/{id}/refresh enqueues segment/refresh.requested (one event per active Outreach or BigQuery strategy). An Inngest cron (segmentRefreshCron, 06:00 and 18:00 America/New_York) does the same for every active purpose = reengagement segment across tenants. Outreach compiles filterRules to Prisma. BigQuery runs customQuery (a single SELECT/WITH), resolves each row through ordered contactMappings (HubSpot id, then contact id, then email, then phone), and inserts contacts who are not already open members. When prioritySource = sql_view, refresh also copies priorityColumn (a BigQuery result column or Outreach numeric catalog field) onto SegmentMembership.priorityScore for inserts and for stayers this strategy owns. A truncated BigQuery job still updates scores for staged rows. When the qualifying scan is complete, the same run soft-closes open members this strategy enrolled (source = strategy:<id>) who are missing from the result. A truncated BigQuery job (hit the 100k row cap) does not remove. dailyVolumeCap limits new inserts (highest scores first), not the qualifying set. Runs use producer = outreach-refresh or bigquery-refresh. HubSpot strategies are still not executed. Empty filters, empty SQL, empty mappings, or a missing priorityColumn on a sql_view strategy fail the run with no writes.

The daily list is a later pick from those open members: POST /api/segments/{id}/daily-list enqueues segment/daily-list.requested for that segment. POST /api/reengagement/daily-list and the 07:00 America/New_York schedule run reengagementDailyListCron (segment/daily-list-cron.requested when manual, tenant-scoped). Before pick fan-out, pre-list find-or-creates a HubSpot MANUAL contact segment for every in-scope active sales rep named Outreach-Re-Engagement-{YYYY-MM-DD}-{First}-{Last} (US-Eastern date; single-token names omit {Last}). Lookup is by that name. Duplicate computed names or any HubSpot failure fail the step so list / enqueue never run. The cron then fans out segment/daily-list.requested per active Outreach or BigQuery strategy on an active purpose = reengagement segment. The picker writes reengagement_daily_list_entries from memberships this strategy owns (source = strategy:<id>), highest priorityScore first, honoring dailyVolumeCap (remaining for that Eastern day) and staleDays. Inserts are idempotent on (segmentId, hubspotContactId, listDate). arm is control; priorityAssigned is left null. Owner fields follow assignmentMode: keep_owner copies the contact’s HubSpot owner and matching sales rep; round_robin walks active sales reps (and their owner ids) in name order, continuing from how many this strategy already listed today; unassigned leaves both null. The pick copies membership source onto the daily-list row and, for assigned reps, stores hubspotListId then PUT`s those HubSpot contact ids onto that day’s MANUAL segment. Unassigned or unmapped rows stay off HubSpot. Operators see picks on the segment Daily list tab and at `/reengagement. The workspace page shows that day’s pick counts as sliced bars by segment and by owner, and can filter the table and bars by segment.

Three design decisions

Membership is the source of truth, not an executable segment definition. Segments are registry rows; membership is materialised by whichever producer wrote it. Nothing is re-evaluated after the fact, so point-in-time correctness comes for free and historical numbers never change retroactively. It also means each producer keeps its own query instead of being normalised into a DSL first.

Membership and the daily list are different things — different grains, different lifetimes. Membership is slow-moving and concurrent (a contact sits in several segments at once); the daily list is one contact, one day, one reason.

Rollups are not stored. There is deliberately no aggregate table. ReengagementDailyListEntry is the fact table, and per (segment, strategy, arm, day) summaries are expected to come from SQL views over it. A materialised rollup would have to be recomputed as its attribution window matured, which a view gets for free by always reading the current facts.

Segment

Table segments. The reusable audience registry.

Field Type Notes

subscriptionId

String @db.Uuid

key

String

Stable machine name, e.g. email_click. Unique per subscription

name, description

String / String?

purpose

SegmentPurpose

reengagement, email_campaign, reporting, other

precedence

Int

Reserved for a future cross-segment tiebreak; nothing enforces it yet

isActive

Boolean

Disabling a segment is a flag flip, not a deploy

purpose is what keeps the table general — the same registry can hold the email campaign audiences that currently exist only informally.

Unique: (subscriptionId, key). Relations: memberships, strategies, runs, dailyListEntries.

SegmentMembership

Table segment_memberships. Slow-moving and concurrent, not a daily snapshot.

Field Type Notes

segmentId

String @db.Uuid

hubspotContactId

String

Required — producers hold the HubSpot id, not the local one

contactId

String? @db.Uuid

Nullable FK to Contact, backfilled opportunistically

source

String

default "producer"; refresh writes strategy:<id>

priorityScore

Int

default 0. Written on sql_view refresh from priorityColumn

addedAt

DateTime @db.Timestamptz(6)

removedAt

DateTime? @db.Timestamptz(6)

Soft close

Soft-closing with removedAt preserves "was this contact in this segment in June" without writing a row per contact per day. Partial unique indexes enforce at most one open row per (segmentId, hubspotContactId) and per (segmentId, contactId) when the local contact is set — see Constraints outside the schema.

Refresh removals only close open rows with source = strategy:<that strategy’s id>. Legacy producer rows and members enrolled by another strategy are left alone. Stayers keep the same row and addedAt; sql_view refreshes update priorityScore in place. Qualifying identities for the run are staged in segment_refresh_qualifying (@@unique([runId, hubspotContactId]), with priorityScore) and deleted on finalize or run failure. Email import and the members list order open members by priorityScore DESC. Score updates stay scoped to source = strategy:<id> — a later sql_view strategy does not overwrite another strategy’s members.

SegmentStrategy

Table segment_strategies. The methodology layer.

Separating who (segment) from how we treat them (strategy) is what gives the control/treatment arms something meaningful to vary. Without the split, "arm" has nothing to test.

Field Type Notes

segmentId

String @db.Uuid

name, version

String, Int

Unique together per segment

prioritySource

SegmentPrioritySource

sql_view or strategy_rules — see below

priorityColumn

String?

Result / catalog field copied onto membership when prioritySource = sql_view. Drafts may be empty; refresh requires it

priorityRules

Json?

Only meaningful when prioritySource = strategy_rules

assignmentMode

SegmentAssignmentMode

Applied on daily-list pick: round_robin (active sales reps), keep_owner (contact owner), unassigned (leave blank)

staleDays, dailyVolumeCap

Int?

treatmentPercent

Int

Share routed to the treatment arm, 0–100

dataSource

SegmentDataSource

outreach, bigquery, hubspot. Default outreach

sourceTable

SegmentSourceTable

contacts, calls, campaign_targets, email_campaign_targets, custom_query. Default contacts

filterRules

Json?

react-querybuilder rule group. Outreach only

customQuery

String?

BigQuery SQL or HubSpot filter text. Empty drafts are allowed

contactMappings

Json?

Ordered BigQuery column → identity field fallbacks. Empty drafts are allowed

isActive, effectiveFrom

Boolean, DateTime

dataSource and sourceTable are paired in Zod, not the database: Outreach uses one of the four app tables plus filterRules; BigQuery and HubSpot require sourceTable = custom_query and store customQuery. BigQuery also stores contactMappings (source_column + contact_field). Manual Outreach refresh compiles filterRules in lib/segments/compile-filter-rules.ts. BigQuery refresh runs SQL via querySegmentRows and resolves rows in lib/segments/resolve-contact-mappings.ts. Empty filters, empty SQL, or empty mappings fail the run so they never enroll the whole tenant. A HubSpot-id mapping can enroll with contactId = null; email, phone, and contact id only enroll when that Contact already has a HubSpot id.

The filterable Outreach columns live in lib/segments/source-table-fields.ts. The query builder cannot invent a column the catalog does not list.

prioritySource exists because the five producers do not agree. For sql_view, the named priorityColumn is the score: BigQuery uses the result column (leaf-name fallback, same as contact mappings); Outreach uses a type: "number" catalog field, including contact leadScore / hubspotSyncPriority even when the filter is on a child table. Refresh coerces with Math.round into Int. strategy_rules scoring is not implemented — inserts stay at 0 and scores are not updated.

lost_deal and supplier_account compute a weighted priority_score in their BigQuery view and the producer only rounds it:

-- lib/bigquery/v_lost_deal_reeng.sql
ROUND(
  LEAST(4.0,
    CASE
      WHEN lc.last_call_date IS NULL THEN 4.0
      ELSE CASE lc.disposition_id ... END
        * LEAST(1.3, 1.0 + (TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), lc.last_call_date, DAY) / 30.0))
        * (1.0 / (1.0 + 0.25 * lc.total_calls))
    END
  )
, 1) AS priority_score

The rest compute priority in producer code. A single priorityRules column would have misdescribed two of the five.

treatmentPercent lives here rather than on Segment so it versions alongside the methodology it varies.

SegmentRun

Table segment_runs. Provenance for one producer run.

Because no executable definition is stored, the criteria in effect are frozen in criteriaSnapshot at write time — query name, thresholds, params. Descriptive, never re-evaluated.

Field Type Notes

segmentId, strategyId

String @db.Uuid, String? @db.Uuid

runDate

DateTime @db.Date

producer

String

Handler that wrote the rows

criteriaSnapshot

Json

default {}

rowCount, status, error

Int, SegmentRunStatus, String?

startedAt, completedAt

DateTime @db.Timestamptz(6)

ReengagementDailyListEntry

Table reengagement_daily_list_entries. The daily list plus arm tracking.

Field Type Notes

segmentId

String @db.Uuid

Which segment sourced the pick

strategyId

String @db.Uuid

Required — every summary groups by strategy, and a NULL would drop the row out of those aggregates

runId

String? @db.Uuid

The producer run that wrote the pick

arm

ReengagementArm

control or treatment

hubspotContactId

String

Required

contactId, hubspotOwnerId, salesRepId

nullable

hubspotListId

String?

HubSpot ILS list/segment id for the assigned rep’s daily list

source

String

Copied from SegmentMembership.source at pick (usually strategy:<id>)

listDate

DateTime @db.Date

US-Eastern calendar day

priorityAssigned

Int?

Rounded 1–4, as written to HubSpot

priorityScore

Float?

Unrounded score when the source view computes one

reason

String

Which rule fired

qualifyingContext

Unique: (segmentId, hubspotContactId, listDate).

That is deliberately not (contactId, listDate). Logging every qualifying pick keeps producer wiring behaviour-neutral, and makes cross-segment overlap measurable before any precedence rule is legislated:

SELECT "hubspotContactId", "listDate", count(DISTINCT "segmentId") AS segments_claiming
FROM reengagement_daily_list_entries
GROUP BY 1, 2
HAVING count(DISTINCT "segmentId") > 1;

A winner view or a Segment.precedence tiebreak can be layered on later with no data migration.

The native picker (not the Python producers) sets reason = open_member and qualifyingContext = { source, priority_score } — no PII. hubspotOwnerId and salesRepId are copied from the local contact when present, for display only.

reason records which rule fired; qualifyingContext records what the contact looked like when it fired (lifecycle stage, deal stage, days since last call, click timestamp). Contact properties drift, so without the frozen context later analysis cannot ask questions nobody thought to log for.

listDate is a US-Eastern calendar day, matching the call_list_date the producers write to HubSpot. The BigQuery views guard against re-picking with PARSE_DATE(…​) != CURRENT_DATE(), which is UTC and reads a Stitch mirror that lags. That guard stays advisory until this table is the authority.

Constraints outside the schema

prisma/sql/segmentation_constraints.sql holds things Prisma cannot express. The open-membership uniques also ship in prisma/migrations/20260902100000_segment_membership_active_unique.

CREATE UNIQUE INDEX IF NOT EXISTS "segment_memberships_active_unique"
  ON "segment_memberships" ("segmentId", "hubspotContactId")
  WHERE "removedAt" IS NULL;

CREATE UNIQUE INDEX IF NOT EXISTS "segment_memberships_active_contact_unique"
  ON "segment_memberships" ("segmentId", "contactId")
  WHERE "removedAt" IS NULL AND "contactId" IS NOT NULL;

ALTER TABLE "segment_strategies"
  ADD CONSTRAINT "segment_strategies_treatment_percent_range"
  CHECK ("treatmentPercent" BETWEEN 0 AND 100);

The partial unique indexes are what make the soft-close design safe. A plain unique constraint would reject a contact who legitimately re-enters a segment later; without any constraint, a contact can silently hold several open membership rows (same HubSpot id, or same local contactId with a different HubSpot id). Closed rows may repeat.

Identifiers are quoted camelCase. These models use @@map for the table name but leave column names as Prisma generates them, so raw SQL needs "hubspotContactId", not hubspot_contact_id.

Seeding

scripts/seed-segments.ts inserts the five existing re-engagement channels as Segment rows, each with one baseline strategy at treatmentPercent = 0 — 100% control, so logging can ship before any challenger strategy exists and nothing about who gets called changes.

npx tsx --env-file=.env scripts/seed-segments.ts            # dry-run
npx tsx --env-file=.env scripts/seed-segments.ts --confirm  # apply

It is a script rather than a migration because it needs a real subscriptionId. Re-running is safe.

Key Producer prioritySource

email_click

Hubspot.EmailClickReEngagement

strategy_rules

supplier_account

Hubspot.SupplierAccountReEngagement

sql_view

unattended_meeting

Hubspot.UnattendedMeetingReEngagement

strategy_rules

lost_deal

Hubspot.LostDealReEngagement

sql_view

campaign

Phone.CampaignTargetReEngagement

strategy_rules

staleDays is left null on the seeded strategies. The current thresholds live as constants in the producer repository, and guessing them here would put wrong numbers into the baseline that later strategy versions get compared against.