Data model
The Prisma schema (apps/service/prisma/schema.prisma, ~1,970 lines) holds 52 models across eight
domains. This page groups them and calls out the constraints and design decisions that change how you
have to write code against them.
Migrations are hand-managed from a baseline. Use prisma migrate dev — never db push, which
desyncs the history.
Platform and tenancy
| Model | Purpose |
|---|---|
App |
Mirror of an Accounts app, so Tenant.appId has a local foreign key to satisfy |
Tenant |
The workspace everything is scoped to — @@unique([appId, externalRef]) |
IdempotencyRecord |
Write dedupe, @@unique([appId, tenantId, idempotencyKey]) |
TenantMerge |
Audit of a workspace merge |
Tenant also carries per-workspace counters and settings: ticketSeq and the globally-unique
ticketPrefix that render native ticket numbers, contactSeq for generated contact labels, and
defaultCountry (ISO alpha-2) which lets a bare national number like 0711… parse to a country and a
canonical E.164. That falls back to INBOX_DEFAULT_PHONE_REGION, then KE.
Connections
| Model | Purpose |
|---|---|
SocialConnection |
A connected account — @@unique([tenantId, network, externalAccountId]) |
ConnectionBinding |
Which integration serves which capability — @@unique([connectionId, capability]) |
ConnectSession |
OAuth handshake state |
NativeAccountClaim |
Single-owner claim on a native account — @@unique([network, externalAccountId]) |
ConnectionRequest |
An operator-actioned request to have a channel connected |
SocialConnection.ownerUserId records the Accounts user who connected the account. Credentials are
encrypted at rest (AES-256-GCM) and always read from the binding, never shared between integrations.
Threads and entries
| Model | Purpose |
|---|---|
InboxThread |
A conversation — @@unique([connectionId, threadType, externalThreadId]) |
InboxEntry |
A message or comment — @@unique([threadId, externalEntryId]) |
InboxParticipant |
The other party — @@unique([threadId, externalUserId]) |
InboxThreadEvent |
Provider-side events recorded against a thread |
InboxAccountConfig |
Per-connection inbox settings |
The two uniqueness keys are the deduplication story: a provider redelivering the same webhook upserts onto the same thread and the same entry rather than creating duplicates. It is why ingest can be retried freely.
seq is the ordering, not createdAt
InboxEntry.seq is a bigint assigned by a Postgres sequence, and it is the authoritative order.
createdAt is millisecond-precision, so two entries written in the same millisecond tie, and ordering
then falls to the cuid id — which is arbitrary, not chronological. A message and a fast auto-reply
could render in either order, and differently on each read. This was found while building the AI
channel, where a generated answer routinely lands in the same millisecond as the message it answers.
Sort by seq.
InboxThread.firstUnansweredInboundAt opens a response turn (set-if-null on ingest, cleared by any
non-campaign outbound); InboxEntry.responseTimeSeconds is measured from it, on the first outbound
answering an inbound only. Every SLA and response-time report derives from that column.
Tickets and annotations
| Model | Purpose |
|---|---|
InboxTicket |
A support session over a thread |
InboxLabel |
Workspace label — @@unique([tenantId, name]) |
InboxThreadLabel / InboxTicketLabel |
Label attachments |
InboxNote |
Internal note |
InboxCannedReply |
Saved reply — @@unique([tenantId, title]) |
InboxAutoResponse / InboxAutoResponseSend |
Away/welcome auto-replies and their send log |
Two invariants live in raw SQL
Prisma cannot express a partial unique index, so two constraints are hand-written in migration SQL and
are invisible if you only read schema.prisma:
CREATE UNIQUE INDEX "inbox_ticket_one_open_per_thread"
ON "inbox_ticket" ("threadId")
WHERE "status" = 'open';
CREATE UNIQUE INDEX IF NOT EXISTS "inbox_ticket_native_number_unique"
ON "inbox_ticket" ("ticketNumber")
WHERE "ticketNumber" IS NOT NULL AND "legacyTicketId" IS NULL AND "ticketNumber" ~ '[A-Za-z]';
The first enforces one open ticket per thread at the database, which is what makes concurrent webhook-driven opens safe — the loser of the race gets a constraint violation rather than a second open ticket. The second scopes ticket-number uniqueness to natively issued numbers, so imported tickets can keep their original (differently formatted) numbers without colliding.
Because ticketSeq is bumped atomically and a raced open wastes its number, native ticket numbers are
monotonic but not gapless.
Contacts and consent
| Model | Purpose |
|---|---|
InboxContact |
A person |
InboxContactIdentifier |
One way to reach them — @@unique([kind, value]) |
InboxContactTenant |
That person's membership of one workspace — @@unique([contactId, tenantId]) |
InboxContactChannelConsent |
Per-channel opt-out — @@unique([contactId, tenantId, channel]) |
InboxContactMerge |
Audit of a contact merge |
Contacts are not tenant-scoped
This is the surprise in the schema. InboxContact has no tenantId. Identity is global: the
@@unique([kind, value]) on the identifier means one phone number is one person, estate-wide. That
constraint is what makes cross-workspace merges work at all.
It is also why junk values must be filtered before an import. A blocklist miss collapses thousands of unrelated people onto one contact row, irreversibly.
Everything that is a fact about a person in one workspace lives on InboxContactTenant instead:
source, engagementScore, engagementRating, firstSeenAt, lastSeenAt, and marketing consent.
Unsubscribing from one workspace must not unsubscribe you from another, and an engagement score earned
in one says nothing about the other.
Consent is layered. InboxContactTenant.unsubscribedAt is the hard, all-channels opt-out for that
workspace and is checked first. A per-channel opt-out is one row per (contact, tenant, channel) in
InboxContactChannelConsent, so adding a channel is a data change, not a schema migration. The legacy
emailUnsubscribedAt column is readable only until its backfill lifts it into a channel='email'
consent row — new code must not write it.
Phone values are stored normalised (E.164) and emails lowercased, so matching is exact rather than fuzzy.
Segments, templates and campaigns
| Model | Purpose |
|---|---|
InboxSegment |
A named audience — @@unique([tenantId, name]) |
InboxSegmentMember |
Materialised membership — @@unique([segmentId, contactId]) |
InboxTemplate |
Message template — @@unique([tenantId, slug]) |
InboxEmailDomain |
Sending domain — @@unique([tenantId, domain]) |
InboxCampaign |
A bulk send |
InboxCampaignRecipient |
Per-recipient delivery lifecycle |
InboxCampaignLink / InboxCampaignLinkClick |
Tracked links and clicks |
Segment membership is materialised as explicit rows rather than stored as a query. A rule-based segment can be layered on later without changing how anything reads it.
Flows
| Model | Purpose |
|---|---|
InboxFlow |
An automated conversation |
InboxFlowVersion |
Published version — @@unique([flowId, versionNumber]) |
InboxFlowNode / InboxFlowEdge |
The graph |
InboxFlowRun |
One contact's traversal — @@unique([flowId, dedupeKey]) |
InboxFlowRunStep |
Per-step execution record |
@@unique([flowId, dedupeKey]) refuses a duplicate enrolment at the database rather than in application
logic, which is the only place that holds under concurrency.
Publishing
| Model | Purpose |
|---|---|
Post |
A post, across one or more surfaces |
PostDispatch |
Crash-safe per-surface send state — @@unique([postId, groupKey]) |
Reviews, warm-up, files, webhooks, import
| Model | Purpose |
|---|---|
InboxReview |
A public review — @@unique([connectionId, platformReviewId]) |
WarmupProfile |
A ramp profile |
WarmupChannelSetting |
Per-channel enablement — @@unique([tenantId, channel]) |
ConnectionWarmupState |
Per-sender warm-up state |
InboxFile |
Stored file — @@unique([tenantId, slug]) |
InboxFileAttachment |
Polymorphic attachment — @@unique([attachableType, attachableId, fileId]) |
WebhookInbox |
Verified inbound webhook deliveries |
AppWebhookEndpoint |
A consuming app's registered outbound webhook |
ImportJob |
Historical import run |
Two conventions that apply everywhere
Audit tables have no foreign keys, deliberately. InboxContactMerge stores targetContactId and
sourceContactId as plain strings; TenantMerge stores plain tenant ids plus the ws_ external refs
captured at merge time. A cascading foreign key would erase exactly the history these tables exist to
preserve — the audit has to outlive the row it describes, including a later hard delete. Dangling
references here are the design, not a bug.
Every *UserId is an Accounts id. ownerUserId, sentByUserId, actorUserId, assignedUserId
and the rest are opaque Accounts user identifiers with no local table behind them, because
Inbox stores no users. They will never join; resolve them against
Accounts. A null actorUserId on a merge row means a machine key with no user context performed it —
itself worth recording.