Skip to content

Database Schema

The Sema Link API uses PostgreSQL via Drizzle ORM. All tables use UUID primary keys. Timestamps are stored in UTC.

Migration workflow

Two modes are used depending on the change:

  1. Generated migrations — run drizzle-kit generate to produce a timestamped SQL file from the Drizzle schema. Run drizzle-kit migrate to apply it. Use this for new tables and straightforward column additions.

  2. Manual SQL migrations — for changes Drizzle cannot express (self-referential foreign keys, backfill queries, seed data), write a raw .sql file directly in src/db/migrations/ and register it in src/db/migrations/meta/_journal.json. The journal entry must be added atomically so the migration tracker stays consistent.

Why Drizzle ORM?

We chose Drizzle over Prisma after evaluating both for this project:

  • Type safety — Drizzle's inference is tighter; query results are typed without a separate code-generation step
  • Raw SQL escape hatch — Drizzle lets you drop into sql\`` without losing the connection pool or type context
  • Lightweight — no engine binary, no Rust sidecar; just a thin TypeScript layer over pg
  • Schema-as-code — the schema file is the source of truth; no .prisma DSL to learn

The tradeoff is that Drizzle's migration workflow is less forgiving: always run db:generate to produce a migration before db:migrate. Manually authored SQL files are silently ignored.


Entity-Relationship Overview


Enums

account_type

ValueMeaning
directSelf-billing customer — pays Sema Link directly
agentReseller — manages their own sub-customers at a custom margin
agent_customerCustomer of an agent; billing flows through the agent

account_status

ValueMeaning
pendingNewly registered, awaiting first activation
activeFully operational
suspendedAccess restricted by the Sema Link team

user_role

ValueCapabilities
ownerFull account control — invite, role changes, member removal
adminCan invite new members; cannot change roles or remove others
memberRead/write own profile only

token_type

Used by the auth_tokens table to identify single-use token purpose.

ValuePurposeExpiry
email_verificationSent after registration to confirm the email address24 hours
password_resetSent via forgot-password flow1 hour
magic_linkPasswordless login link sent by request15 minutes

oauth_provider

ValueNotes
googleGoogle OAuth 2.0
microsoftMicrosoft Identity Platform (Azure AD)
githubGitHub OAuth App
appleSign in with Apple (JWT-based)

channel_type

ValueMeaning
smsStandard SMS (default)
whatsappWhatsApp Business (future)
emailEmail channel (future)

sender_id_status

ValueMeaning
pendingSubmitted; awaiting review by the Sema Link team
approvedApproved for use in messages and campaigns
rejectedNot approved — see rejection notes
suspendedPreviously approved but temporarily blocked

contact_status

ValueMeaning
pending_validationAwaiting MNO prefix lookup
validPhone prefix matched a known operator
invalidPrefix unmatched — see invalid_reason (e.g. unknown_network)

message_status

ValueMeaning
submittedAccepted by the API
queuedPublished to sms.priority or sms.normal RabbitMQ queue
sentSubmitted to Celcom Africa aggregator
deliveredDelivery report (DLR) received confirming delivery to handset
failedSend or delivery failed; reserved credits refunded
expiredMessage expired before delivery (aggregator TTL)

campaign_status

ValueMeaning
pending_content_checkAwaiting SMS Content Worker review
approvedContent approved; ready to schedule or send
rejectedContent rejected — see rejection_reason
scheduledApproved with a future send_at time; sitting in PostgreSQL
queuedDue campaigns being dispatched by the scheduler
sendingMessages actively being published to RabbitMQ
completedAll messages dispatched
cancelledCancelled before sending; credits refunded

credit_tx_type

ValueMeaning
topupCredit added (M-Pesa, manual)
reservationCredits held for an in-flight send
finalisationReservation confirmed after successful DLR
refundCredits returned on failed send
adjustmentManual admin correction

Tables

accounts

One row per organisation. All other records belong to an account.

ColumnTypeNotes
iduuid PK
nametextCompany or account name
typeaccount_typeDefault: direct
statusaccount_statusDefault: pending
billing_account_iduuidSelf-referential: equals own id for direct; agent's id for agent_customer
parent_agent_iduuid nullableSet only for agent_customer accounts
timezonetextDefault: Africa/Nairobi
countrytextDefault: KE
created_attimestamp
updated_attimestamp

account_members

Junction table that links users to accounts. A single user can belong to multiple organisations. This replaced the original direct account_id foreign key on users.

ColumnTypeNotes
iduuid PK
user_iduuid FK → usersCascades delete
account_iduuid FK → accountsCascades delete
roleuser_roleRole within this specific org. Default: member
is_activebooleanDefault: true. Set false to revoke access without deleting the record
created_attimestamp

Unique constraint: (user_id, account_id) — a user can only have one membership per org.

Why multi-org? Agencies and resellers need to manage multiple brands under a single login. A user creates their personal account on signup, then creates or is invited into additional orgs. The JWT's accountId and role claims are derived from the active membership row chosen at login or via POST /api/v1/accounts/switch.


users

Team members. password_hash is nullable because OAuth-only users do not have a password. Users no longer carry a direct account_id — org affiliation is entirely through account_members.

ColumnTypeNotes
iduuid PK
account_iduuid FK → accountsCascades delete
first_nametext
last_nametext
emailtext unique
password_hashtext nullablebcrypt cost 12. Null for OAuth-only accounts
roleuser_roleDefault: member
is_verifiedbooleanDefault: false. Set true after email verification
is_activebooleanDefault: true. Deactivated users cannot log in
phonetext nullableOptional phone number for the user profile
profile_photo_urltext nullableFull HTTPS URL of photo stored in Cloudflare R2
last_login_attimestamp nullableUpdated on every successful login
created_attimestamp
updated_attimestamp

Design note: password_hash was made nullable (not added as a separate column) to keep the user record as a single source of truth regardless of auth method. If a user registers via OAuth and later adds a password, the field is simply populated — no record duplication.


refresh_tokens

Tracks issued refresh tokens. Only the SHA-256 hash is stored — the raw token is returned to the client once and never persisted.

ColumnTypeNotes
iduuid PK
user_iduuid FK → usersCascades delete
token_hashtext uniqueSHA-256 of the 128-char hex raw token
expires_attimestamp7 days from issuance
revoked_attimestamp nullableSet on logout or token rotation
created_attimestamp

Security note: Storing only the hash means a database breach does not expose usable tokens. Rotation on use (each refresh token is single-use) limits replay window.


auth_tokens

Single-use tokens for email verification, password reset, and magic link flows. Separate from refresh_tokens because these are short-lived and tied to an action rather than a session.

ColumnTypeNotes
iduuid PK
user_iduuid FK → usersCascades delete
emailtextTarget email address
token_hashtext uniqueSHA-256 of the raw token sent in the email link
typetoken_typeemail_verification, password_reset, magic_link
expires_attimestampVaries by type (see enum table above)
used_attimestamp nullableSet when redeemed; prevents replay
created_attimestamp

Why a shared table instead of three separate tables? The logic is identical across all three types — hash, check expiry, mark used. One table with a type discriminator keeps the service clean. If the flows diverge significantly in future, they can be split.


oauth_accounts

Links a user account to an OAuth provider identity. One user can link multiple providers.

ColumnTypeNotes
iduuid PK
user_iduuid FK → usersCascades delete
provideroauth_providergoogle, microsoft, github, apple
provider_idtextProvider's unique user ID (sub claim)
provider_emailtext nullableEmail returned by the provider
created_attimestamp

Unique index: (provider, provider_id) — prevents the same provider account being linked to two different Sema Link users.


api_keys

Machine-to-machine keys. Used by external integrations and the REST API. The raw key is shown once on creation and never stored.

ColumnTypeNotes
iduuid PK
account_iduuid FK → accounts
key_hashtext uniqueHash of the raw API key
labeltext nullableHuman-readable label
last_used_attimestamp nullable
revoked_attimestamp nullable
created_attimestamp

webhook_endpoints

Customer-configured URLs for DLR (delivery report) callbacks.

ColumnTypeNotes
iduuid PK
account_iduuid FK → accounts
urltextHTTPS URL to receive POST callbacks
is_activebooleanDefault: true
created_attimestamp
updated_attimestamp

contact_lists

Named groups of contacts. Contacts can belong to multiple lists via the junction table.

ColumnTypeNotes
iduuid PK
account_iduuid FK → accountsCascades delete
nametext
descriptiontext nullableOptional description shown in the UI
total_countintegerDenormalised count — maintained by service layer
valid_countintegerContacts that passed phone validation
invalid_countintegerContacts that failed validation
created_attimestamp
updated_attimestamp

contacts

Individual contacts. Soft-deleted via deleted_at — trashed contacts remain in the database for 90 days before hard deletion.

ColumnTypeNotes
iduuid PK
account_iduuid indexed FK → accountsCascades delete
first_nametext nullable
last_nametext nullable
phonetextStored in international format (e.g. +254712345678)
emailtext nullable
statuscontact_statusDefault: pending_validation
invalid_reasontext nullableSet by Phone Validation Worker on failure
tagstext[]Array of freeform tag strings. Default: []
notestext nullableInternal notes about the contact
is_opted_outbooleanDefault: false. Opted-out contacts are excluded from campaigns
consent_sourcetext nullableHow consent was obtained (e.g. "opt-in form", "CSV import")
consent_attimestamp nullableWhen consent was recorded
consent_notetext nullableFree-form note for auditors (POPIA/KICA/NCC compliance)
deleted_attimestamp nullableSet when moved to trash; null for active contacts
created_attimestamp
updated_attimestamp

Why soft delete? Customer support frequently needs to restore accidentally deleted contacts. Hard-deleting immediately makes that impossible. Trash with a 90-day window matches user expectations (mirrors Gmail behaviour) while keeping the database tidy long-term.


contact_list_memberships

Junction table implementing the many-to-many relationship between contacts and lists. This replaced an earlier list_id foreign key on the contacts table.

ColumnTypeNotes
iduuid PK
contact_iduuid indexed FK → contactsCascades delete
list_iduuid indexed FK → contact_listsCascades delete
created_attimestamp

Unique constraint: (contact_id, list_id) — a contact cannot appear in the same list twice.

Why the migration from list_id to a junction table? The original design put a single list_id FK on the contacts table. This worked for the first phase but failed the real-world use case: a contact built for one campaign often needs to be added to another campaign's list without duplication. Migrating to a junction table makes this native. The service layer maintains total_count on contact_lists rather than querying COUNT(*) on each request.


mno_prefixes

Lookup table mapping phone number prefixes to mobile network operators. Used for synchronous validation at import and contact-create time.

ColumnTypeNotes
iduuid PK
prefixtext uniqueDigits only, no +. E.g. 254722 (Safaricom KE), 234 (Nigeria catch-all)
mnotextOperator name (e.g. Safaricom, Airtel Kenya, Multiple)
countrytextISO 3166-1 alpha-2 (e.g. KE, NG)
created_attimestamp

Two-tier prefix strategy:

TierPrefix lengthCoverage
1 — Country catch-all2–3 digits53 African countries; mno = 'Multiple'
2 — Operator-level5–6 digitsKE, NG, UG, TZ, RW, GH, ZA, ET

151 rows total, seeded via migration 0011_seed_mno_prefixes.sql. The lookup algorithm loads all rows once per import job (sorted longest-prefix-first) and walks them greedily — operator-level rows win over country catch-alls. A match returns the mno and country; no match means the number is not attributable to any African network.


messages

Individual SMS messages. Each campaign generates one row per recipient.

ColumnTypeNotes
iduuid PK
account_iduuid indexed
campaign_iduuid indexed nullablePresent for bulk sends
channelchannel_typeDefault: sms
totextRecipient phone number
fromtextSender ID string used
bodytextMessage content
statusmessage_statusDefault: submitted
aggregator_message_idtext nullableCelcom Africa's reference ID for DLR matching
credit_costnumeric(14, 4) nullableFinalised on DLR receipt
failure_reasontext nullable
sent_attimestamp nullable
delivered_attimestamp nullable
created_attimestamp indexed
updated_attimestamp

campaigns

Bulk SMS campaigns targeting a contact list.

ColumnTypeNotes
iduuid PK
account_iduuid indexed
nametextCampaign name
channelchannel_typeDefault: sms
messagetextSMS body
sender_iduuid FK → sender_ids
contact_list_iduuid FK → contact_lists
statuscampaign_status indexed
rejection_reasontext nullableSet by SMS Content Worker on rejection
total_recipientsinteger
sent_countinteger
delivered_countinteger
failed_countinteger
estimated_costnumeric(14, 4) nullableCalculated after content check
send_attimestamp nullable indexedNull = send immediately
started_attimestamp nullable
completed_attimestamp nullable
created_attimestamp
updated_attimestamp

sender_ids

Alphanumeric sender IDs registered for an account.

ColumnTypeNotes
iduuid PK
account_iduuid FK → accounts
nametextAlphanumeric sender string (e.g. AcmeCorp, max 11 chars for GSM)
statussender_id_statusDefault: pending. Only approved IDs can be used in sends
countrytext nullableISO 3166-1 alpha-2 country the sender ID is registered for
is_defaultbooleanDefault: false — used when no sender ID is specified
created_attimestamp
updated_attimestamp

credits

One row per account. Tracks the real-time balance. Denormalised from credit_transactions for fast reads.

ColumnTypeNotes
iduuid PK
account_iduuid unique FK → accounts
balancenumeric(14, 4)Available credit
reserved_balancenumeric(14, 4)Held for in-flight sends
currencytextISO 4217 code. Default: KES
updated_attimestamp

credit_transactions

Immutable ledger of all credit movements. Never updated — only inserted.

ColumnTypeNotes
iduuid PK
account_iduuid indexed
typecredit_tx_type
amountnumeric(14, 4)Positive or negative
balance_afternumeric(14, 4)Account balance snapshot after this transaction
descriptiontext nullableHuman-readable reason
reference_iduuid nullableLinks to messages.id or campaigns.id
created_attimestamp indexed

pricing_rates

Per-operator SMS rates. A null account_id is the global default applied to all accounts without a custom rate.

ColumnTypeNotes
iduuid PK
account_iduuid nullableNull = platform default
mnotextMobile operator (e.g. Safaricom, Airtel)
countrytext nullableISO 3166-1 alpha-2. Set for country-specific rate overrides
rate_per_smsnumeric(10, 6)Cost per SMS segment in platform credits
created_attimestamp
updated_attimestamp

Migration History

Migrations live in src/db/migrations/. Generated ones are produced by drizzle-kit generate; manual ones are hand-authored SQL files with a corresponding meta/_journal.json entry.

FileDescription
0000_initial_schema.sqlBase tables: accounts, users, contacts, messages, campaigns, credits, pricing_rates, sender_ids, api_keys, webhook_endpoints
0001_auth_tokens.sqlauth_tokens table — email verification, password reset, magic link
0002_refresh_tokens.sqlrefresh_tokens table — session management with SHA-256 hash storage
0003_oauth_accounts.sqloauth_accounts table — Google, Microsoft, GitHub, Apple provider links
0004_contact_lists.sqlcontact_lists + contact_list_memberships junction table
0005_credit_transactions.sqlcredit_transactions immutable ledger table
0006_mno_prefixes.sqlmno_prefixes lookup table schema
0007_account_members.sqlaccount_members junction table — multi-org support
0008_account_address.sqladdress, city, state, zip_code columns on accounts
0009_valid_count.sqlvalid_count + invalid_count columns on contact_lists
0010_schema_audit_fixes.sqlis_active on account_members; user_id NOT NULL on auth_tokens; channel on messages/campaigns; sender_id_status enum + columns; consent fields on contacts; currency on credits; country on pricing_rates; self-referential FKs on accounts
0011_seed_mno_prefixes.sqlSeed data: 151 MNO prefix rows across 53 African countries

Internal use only — Sema Link Engineering