Architecture
Overview
Activation System is a Saudi Arabia telecom SIM activation platform built as a monorepo with Turborepo. It handles the full SIM lifecycle from ICCID validation through identity verification to payment — compliant with CITC regulations.
Monorepo Structure
activation-sys/
├── apps/
│ ├── api/ # Hono API server (Node.js)
│ ├── admin/ # Next.js admin and B2B portal
│ ├── web-b2c/ # Next.js B2C web activation flow
│ ├── site/ # Vite + React public landing page
│ └── mobile/ # Flutter mobile app (B2C)
├── packages/
│ ├── database/ # Drizzle ORM + PostgreSQL schemas
│ ├── shared/ # Types, Zod schemas, constants, errors
│ └── queue/ # BullMQ workers (Redis-backed)
├── docs/ # VitePress documentation site
└── docker-compose.ymlSystem Architecture
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Flutter/Mobile │ │ Next.js Web │ │ 3rd Party │
│ + B2C Web │ │ Admin/B2B │ │ Webhooks │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└────────────────────┼─────────────────────┘
│
┌────────▼────────┐
│ Hono API │
│ (REST + CORS) │
└────────┬────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌───────▼──────┐
│ PostgreSQL │ │ Redis │ │ Operator │
│ (Supabase) │ │ (BullMQ) │ │ Plugins │
│ │ │ │ │ (STC/Mobily/ │
│ PII Encrypted│ │ 9 Queues: │ │ Zain) │
│ RLS Policies │ │ • domain │ │ │
└─────────────┘ │ • sync / FX │ └───────┬──────┘
│ • notify │ │
└─────────────┘ │
┌───────────────▼───────────────┐
│ External APIs │
│ Absher/Elm │ Payment Gateways │
└───────────────────────────────┘Key Design Decisions
| Decision | Rationale |
|---|---|
| Hono over Express | Type-safe, lightweight, edge-ready, native Zod validation |
| Drizzle over Prisma | SQL-first, better RLS support, transparent PII encryption via custom types |
| BullMQ over Bull v3 | Active maintenance, better TypeScript, namespace-prefixed queues |
| Supabase | Managed PostgreSQL + RLS + Auth (Phase 2) |
| AES-256-GCM encryption | Authenticated encryption for PII at rest, key rotation support |
| Plugin pattern for operators | Decoupled operator logic — new operators via OperatorPlugin interface |
Data Flow: SIM Activation
1. User submits ICCID → Validate (19 digits, CITC format)
2. Check SIM limits per identity type (citizen: 10, resident: 2, visitor: 1)
3. Create order → Create payment intent
4. Process payment (STC Pay / Apple Pay / Mada)
5. Queue activation job → Operator plugin initiates activation
6. Identity verification (Absher/Elm)
7. Activation complete → Notify userSecurity
- PII Encryption: Phone, email, name, ID number encrypted with AES-256-GCM via
ENCRYPTION_KEY - Row Level Security: PostgreSQL RLS policies per domain (auth, catalog, activation, payment, support, b2b)
- Rate Limiting: HTTP counters are in memory per API process, not shared through Redis. The global guard exempts health checks and verified web-cookie requests on protected paths
- Bilingual Errors: All error responses include
messageEnandmessageAr - Audit Trail: Immutable audit logs with
auditImmutabilityTrigger - Idempotency: Payment intents require
idempotencyKeyfor safe retries
Middleware Chain
The Hono API applies middleware in a strict order. Each middleware runs on every request before the route handlers execute:
logger → cors → error-handler → language → rate-limit → auth → routes| Order | Middleware | Source | Purpose |
|---|---|---|---|
| 1 | logger() | hono/logger | Structured request/response logging |
| 2 | cors() | hono/cors | CORS with environment-specific origins |
| 3 | errorHandlerMiddleware | src/middleware/error-handler.ts | Global error catch → bilingual JSON |
| 4 | languageMiddleware | src/middleware/language.ts | Extract Accept-Language → language + clientType context |
| 5 | globalRateLimitMiddleware() | src/middleware/rate-limit.ts | Configurable per-process HTTP request budget with health and verified web-cookie exemptions, sets X-RateLimit-* headers on counted requests |
| 6 | authMiddleware | src/middleware/auth.ts | JWT verification, Redis blocklist check, set userId/appRole/identityType |
CORS origins:
Allowed origins are configured through CORS_ORIGINS as a comma-separated list. Development defaults to http://localhost:3000, http://localhost:3001, and http://localhost:8888; production must set real owned origins in the deployment environment.
Allowed headers: Content-Type, Authorization, Accept-Language, X-Client-Type, Idempotency-Key. Allowed methods: GET, POST, PUT, PATCH, DELETE, OPTIONS.
API Route Map
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health | None | Health check (API, database, Redis connectivity) |
| POST | /auth/register | None | Register new user, send OTP |
| POST | /auth/login | None | Login, send OTP to existing user |
| POST | /auth/verify-otp | None | Verify OTP, create session (dual strategy: cookie for web, JWT for mobile) |
| POST | /auth/logout | Required | Clear session (web: delete cookie, mobile: Redis blocklist) |
| GET | /auth/session | Required | Return current session user from JWT |
| GET | /auth/me | Required | Return user profile with decrypted PII |
| PUT | /auth/me | Required | Update profile (name, email; phone via /change-phone) |
| POST | /auth/change-phone | Required | Send OTP to new phone number |
| GET | /operators | None | List active operators with Arabic/English names |
| GET | /packages | None | List packages with filters (operator, data, voice, validity, promotional) |
| GET | /packages/search | None | Full-text + fuzzy trigram search across packages |
| GET | /packages/compare | None | Side-by-side comparison (2–5 package IDs) |
| GET | /packages/:id | None | Single package detail with operator info |
| GET | /packages/:id/availability | None | Real-time availability via operator plugin (Redis cache, 30s TTL) |
| POST | /activations/validate-iccid | None | Validate ICCID via operator plugin (public pre-check) |
| POST | /activations | Required | Create activation order with package selection |
| POST | /activations/:id/verify-identity | Required | Initiate Nafath identity verification |
| GET | /activations/:id/verify-identity | Required | Check verification status (5 Nafath states) |
| GET | /activations/:id/status | Required | Activation + identity status with estimated timing |
| GET | /activations/history | Required | Paginated activation history (keyset cursor) |
| GET | /activations/records | super_admin | Admin dashboard activation records with filters and related order/package/operator/user contact data |
| GET | /payment-return | Public | Built-in neutral bilingual return page |
| POST | /payments/:id/confirm | Required | Verify owned Moyasar or Paymob payment server-side with the gateway that created it |
| POST | /payments/create-intent | Required | Create payment intent (Mada, Visa, Mastercard) |
| POST | /payments/apple-pay | Required | Create Apple Pay payment |
| POST | /payments/stcpay | Required | Create STC Pay SDK checkout via selected provider |
| GET | /payments/:id/receipt | Required | Owner-scoped receipt with bilingual messages |
| GET | /profile | Required | Return authenticated user profile aggregate |
| PUT | /profile | Required | Update allowed profile fields (name, email) |
| GET | /profile/export | Required | PDPL data portability export |
| DELETE | /profile | Required | Request account deletion (soft delete + retention period) |
| GET | /notifications | Required | Own visible inbox with cursor pagination |
| GET | /notifications/count | Required | Own unread count |
| POST | /notifications/:id/read | Required | Mark one owned notification read |
| POST | /notifications/read-all | Required | Mark visible unread notifications read |
| POST | /notifications/seen | Required | Mark selected owned notifications seen atomically |
| POST | /notifications/:id/archive | Required | Archive one owned notification |
| GET | /notifications/opt-outs | Required | Read channel registry and own opt-outs |
| PUT | /notifications/opt-outs | Required | Replace own channel opt-outs |
| POST | /admin/notifications | super_admin or notifications (manage) | Accept an immediate or scheduled notification batch |
| GET | /admin/notifications/batches | super_admin or notifications (view) | Filter and paginate batches |
| GET | /admin/notifications/batches/:id | super_admin or notifications (view) | Batch summary, delivery counts, and paginated failures |
| POST | /admin/notifications/batches/:id/cancel | super_admin or notifications (manage) | Cancel a batch before execution starts |
| GET | /admin/notifications/delivery-rate | super_admin or notifications (view) | Delivery outcomes by channel and provider |
| POST | /compliance/retention/sweep | super_admin | Execute PDPL retention sweep |
| POST | /compliance/breach-report | super_admin | Generate SDAIA breach notification |
| POST | /webhooks/moyasar | None* | Handle Moyasar webhook events (*body secret and server-side payment verification) |
| POST | /webhooks/paymob | None* | Handle Paymob transaction processed callbacks (*hmac query signature and server-side payment verification) |
| POST | /webhooks/notifications/:channel/:provider | Signature | Persist and enqueue verified email or SMS callbacks |
Auth model notes:
- Public routes skip JWT verification entirely (
PUBLIC_PATHSlist inauth.ts) - Protected routes require JWT from httpOnly cookie (web) or
Authorization: Bearerheader (mobile) - Compliance routes add a second middleware layer requiring
appRole === 'super_admin' - Moyasar verifies the body secret_token against the saved environment secret instead of JWT; Paymob verifies the SHA-512 HMAC in its
hmacquery parameter against the saved HMAC secret; MobiMatter verifies its RSA signature. Notification callbacks use their registered provider's raw-body verifier
Plugin Architecture
The API uses a plugin pattern to decouple external service integrations. Each provider category has its own interface, manager class, and concrete implementations. Managers use a Map<string, Interface> registry pattern — the startup code registers a Mock provider by default, then conditionally registers production providers based on environment variables.
OperatorPlugin (apps/api/src/plugins/operator-plugin.ts)
The core interface for telecom operator integration. Each operator (STC, Mobily, Zain) implements these 6 domain operations:
| Method | Purpose |
|---|---|
getPackages(filters?) | Browse operator packages with optional filters |
validateICCID(iccid) | Validate SIM card serial number via operator API |
initiateActivation(params) | Start SIM activation on operator network |
checkActivationStatus(activationId) | Poll current activation status |
verifyIdentity(params) | Initiate Nafath identity verification |
checkVerificationStatus(transId) | Check verification transaction status |
healthCheck() | Verify operator API reachability |
A plugin also optionally declares capabilities: { repeatSafeActivation?, requestLookup?, lineLookup? } and implements lookupActivationRequest(reference) and lookupLineBySerial(iccid), so the officer console can retry a repeat-safe operator, or ask it directly what became of an activation whose answer never came back. A capability counts only when the plugin also implements the matching method. ActivationParams.reference carries our activation id into initiateActivation, so a repeated request is recognised and a later request can be looked up by it.
PluginManager (plugin-manager.ts) holds Map<string, OperatorPlugin>. Routes call pluginManager.get(operatorId).method(). Unregistered operators throw NotFoundError.
Implementations:
| Plugin | File | Environment |
|---|---|---|
MockOperatorPlugin | mock-operator.ts | Default (development/test) |
| STC, Mobily, Zain | Future | Operator plugin registration and operator credentials (independent of payment gateway) |
PaymentProvider (apps/api/src/plugins/payment-provider.ts)
| Method | Purpose |
|---|---|
createPaymentIntent(input) | Create card-like payment intent via gateway |
Cards and wallets share PaymentProvider; there is no separate STC Pay adapter. Saved plugin settings override the environment fallback.
| Provider | File | When Active |
|---|---|---|
MockPaymentProvider | mock-payment-provider.ts | Default |
MoyasarPaymentProvider | moyasar-payment-provider.ts | Selected in saved plugin settings (or initial environment fallback) |
PaymobPaymentProvider | paymob-payment-provider.ts | Selected in saved plugin settings (or initial environment fallback); KSA region, SAR only |
createPaymentProvider in payment-provider-factory.ts builds the adapter for the live provider, or for the gateway a saved payment was created with.
SmsProvider (apps/api/src/plugins/sms-plugin.ts)
| Method | Purpose |
|---|---|
sendOtp(phone, code) | Deliver OTP SMS to phone number |
sendInvite(phone, content) | Deliver a one-off bilingual invitation carrying the portal login link. Best-effort — resolves to whether it was actually delivered |
healthCheck() | Verify provider API reachability |
The active SMS provider is chosen in the admin portal and resolved per send by ServiceProviderRegistry (apps/api/src/services/service-provider-registry.ts); see Service providers. SmsProviderManager keeps only the mock, for delivery-report webhooks.
| Provider | File |
|---|---|
MockSmsProvider | mock-sms-provider.ts |
TaqnyatSmsProvider | taqnyat-sms-provider.ts |
TwilioSmsProvider | twilio-sms-provider.ts |
OneSignalSmsProvider | onesignal-providers.ts |
EmailProvider (apps/api/src/plugins/email-plugin.ts)
| Method | Purpose |
|---|---|
sendOtp(email, code, language?) | Deliver OTP email with bilingual subject |
sendInvite(email, content) | Deliver a one-off invitation/notice email (admin invite, company approval, magic-link QR) |
healthCheck() | Verify provider API reachability |
The active email provider is chosen in the admin portal and resolved per send by ServiceProviderRegistry, like SMS.
| Provider | File |
|---|---|
MockEmailProvider | mock-email-provider.ts |
SmtpEmailProvider | smtp-email-provider.ts |
OneSignalEmailProvider | onesignal-providers.ts |
PushProvider (apps/api/src/plugins/push-plugin.ts)
sendNotification(userId, rendered, meta) delivers to every device the account is signed in on; no device token is stored or queued. Providers: MockPushProvider (mock-push-provider.ts) and OneSignalPushProvider (onesignal-providers.ts).
Shared email shell (apps/api/src/email/shell.ts, templates.ts): every outbound email is composed from the same primitives — a dark header with the Skyte wordmark and cyan beacon bar, an optional bilingual status chip, a bilingual body split by a dotted divider, a CTA button, and one of three footer variants. Six composers render through this shell: activationLinkEmail (bilingual, app-store links, QR code attachment), adminInviteEmail, companyApprovedEmail, readinessEmail, testEmail, and otpEmail. Each returns { subject, body|text, html } (activationLinkEmail also returns attachments); the plain-text half is the multipart fallback for clients that don't render HTML.
Database Schema
Domain tables are defined in packages/database/src/schema/ and grouped below by domain. Each domain file exports tables, relations, and domain-specific infrastructure such as indexes and triggers. RLS policy SQL ships with the domain file, except the notification policies, which live in the custom migration 0016_notifications_rls.sql.
Auth Domain (schema/auth.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
users | id (uuid) | phone (encrypted), phoneHash, email (encrypted), emailHash, name (encrypted), idNumber (encrypted), identityType, appRole | Single table for all roles. appRole is dynamic text (end_user, b2b_admin, super_admin, support, activation_officer, admin). Hash columns for deterministic lookups. |
sessions | id (uuid) | userId, tokenHash, ipAddress (inet), userAgent, clientType, revokedAt | Supplements Supabase Auth JWT sessions with metadata tracking. |
Catalog Domain (schema/catalog.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
operators | id (uuid) | slug (unique), nameEn, nameAr, isActive, serviceType, paymentMode, isNameArFallback, apiBaseUrl, apiConfig | Bilingual display names (rarely change). Slug: stc, mobily, zain. eSIM store providers are rows in this table with serviceType='esim'. |
packages | id (uuid) | operatorId (FK), nameEn, nameAr, descriptionEn, descriptionAr, searchVector (tsvector), priceSar, priceSarB2b, isNameCustomized, isPriceCustomized, isActiveCustomized | Full-text search via GIN index + pg_trgm fuzzy fallback. Admin customization flags protect overrides from sync. |
Activation Domain (schema/activation.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
orders | id (uuid) | userId (FK), companyId (FK, nullable), status, totalAmount, currency | currency is 'SAR' or 'USD' (default 'SAR'). companyId set for B2B orders only. |
order_items | id (uuid) | orderId (FK), packageId (FK), iccid, unitPriceSar | One order item per SIM + package selection. |
activations | id (uuid) | orderItemId (FK), userId (FK), operatorId (FK), iccid, packageId (FK), status, identityStatus, identityRef, assignedOfficerId, retainUntil, deletedAt | Core activation record. Retention fields for PDPL compliance. Status transitions tracked in activation_status_events. |
identity_verifications | id (uuid) | activationId (FK), transId, randomCode, status, verifiedAt | Nafath transaction tracking. 5 states: waiting, completed, rejected, expired, error. |
activation_status_events | id (uuid) | activationId (FK), userId (FK), oldStatus, newStatus, source, note | Immutable audit trail for every activation status transition. Source: system, user, officer, operator_api. |
Payment Domain (schema/payment.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
payments | id (uuid) | orderId (FK), userId (FK), amount, currency, method, status, idempotencyKey (unique), gatewayTransactionId, gatewayResponse, receiptNumber (unique) | Methods: mada, apple_pay, credit_card, stc_pay. Idempotency key prevents double-charging. |
payment_webhook_events | id (uuid) | gateway, eventId (unique composite), eventType, paymentId (FK), processedAt, payload | Deduplication: (gateway, eventId) unique index. Gateway: moyasar, paymob, mock. |
invoices | id (uuid) | companyId (FK), orderId (FK), amountSar, zatcaInvoiceId, pdfUrl, status | B2B only. ZATCA e-invoicing integration. |
FX Domain (schema/fx.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
fx_rates | currency (text) | ratePerUsd (numeric 14,6), fetchedAt, source | USD-base rate table fed by a provider adapter. Deny-all RLS. |
Store Domain (schema/store.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
esim_products | id (uuid) | providerSlug, operatorId (FK), providerSku, nameEn, nameAr, destinationScope, destinationCountries, validityValue, wholesaleUsd, retailUsd, isActive, isNameCustomized, isPriceCustomized, isActiveCustomized, source*, providerPayload | Global eSIM catalog synced from the provider. Catalog prices are base USD. Customization flags protect admin overrides from the sync. The source* columns keep the provider's current values. providerPayload is opaque adapter metadata persisted at sync and handed back at issuance. |
esim_store_orders | id (uuid) | orderId (FK, unique), userId (FK), productId (FK), presentmentCurrency, presentmentAmount, fxRateUsed, retailUsdSnapshot, smdpAddress, matchingId, activationCodeRaw, iccid (partial unique when set), issuedAt, failedReason, issuanceState, externalOrderId, verificationUrl, retainUntil, deletionRequestedAt, deletedAt | One row per store purchase. Presentment currency, amount and FX rate are snapshotted at order time. matchingId and activationCodeRaw are encrypted. issuanceState + externalOrderId drive the provider-agnostic fulfillment state machine (in-flight stamps, reconciliation, async KYC verification). A partial unique index refuses the same issued ICCID on two orders. Retention fields for PDPL compliance — the sweep nulls the eSIM payload columns and stamps deletedAt, keeping the row as the financial record of the purchase. |
store_order_status_events | id (uuid) | storeOrderId (FK, cascade), oldState, newState, source, errorCode, note, createdAt | Store order timeline. The store worker adds a row in the same transaction as each change of issuanceState or errorCode. Indexed on (storeOrderId, createdAt, id). |
All three tables are API-only: no PostgREST or realtime client access, locked with deny-all client policies.
Support Domain (schema/support.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
tickets | id (uuid) | userId (FK), subject, description, status, priority, assignedTo, activationId (FK, nullable) | Linked to activation for context. Priorities: low, medium, high, critical. |
ticket_comments | id (uuid) | ticketId (FK), authorId (FK, nullable, cleared when the author is deleted), content, isInternal | Internal notes not visible to end users (RLS-enforced). |
B2B Domain (schema/b2b.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
companies | id (uuid) | crNumber (unique), name, contactName, contactEmail (nullable), contactPhone (encrypted), status, archivedFrom (nullable), paymentMode (nullable), approvedBy | Commercial registration number is unique. contactPhone is encrypted PII. contactEmail is the registering admin's account email, null when that admin signed up by phone only. status is pending, approved, rejected, suspended or archived; archivedFrom holds the status before archiving (suspended or rejected) and is set exactly while the company is archived, enforced by the companies_archived_from_check CHECK constraint. paymentMode is the company's own pre-contract pick (prepaid/flexible, null until chosen), inherited by the contract at confirm time. |
company_users | id (uuid) | companyId (FK), userId (FK), role | Many-to-many between companies and users. Role: admin, member. |
bulk_orders | id (uuid) | companyId (FK), orderId (FK), totalSims, status, csvData | Bulk SIM activation orders for B2B. |
company_roster | id (uuid) | companyId (FK), userId (FK, unique), nationality, status, packageId (FK, nullable), contractItemId (FK, nullable), unitPriceSar (nullable) | One row per CSV-staged B2B beneficiary (employee or visitor). Created eagerly at CSV upload alongside the users row; contact/identity PII stays on users. Status: staged, enabled. unitPriceSar is the flexible price snapshot — written at magic-link mint time (no contract line to snapshot against), null for prepaid rows. Removing a beneficiary (DELETE /b2b/roster/:rosterId) is a hard delete of this row, its magic_links history and the eagerly-created users row, and is refused once a link was redeemed or any activation exists. |
magic_links | id (uuid) | rosterId (FK), tokenHash (unique), status, expiresAt, usedAt, revokedAt | Ephemeral single-use invite tokens (24h TTL), modeled on sessions. Token stored as a SHA-256 hash only. Status: issued, sent, exported, used, expired, failed, revoked. |
quote_requests | id (uuid) | companyId (FK), version, parentId (self FK), needs (jsonb), status, note, companyNote, reviewedBy | Versioned needs-form → catalog quote, immutable once submitted. A counter creates a new version linked via parentId. note is the reviewer's, written by an internal admin on a counter or a rejection; company_note is the company's own, written only when it declines a counter, held apart so a decline reason can never overwrite or be mistaken for the offer it declines. Status: draft, submitted, under_review, countered, accepted, rejected, expired. needs.fundingSar carries a flexible funding/top-up amount — a zero-item quote is valid when it is present. |
quote_request_items | id (uuid) | quoteRequestId (FK), packageId (FK), qty | Quote line items, one set per quote version. Empty for a flexible funding/top-up request. |
contracts | id (uuid) | companyId (FK), quoteRequestId (FK), paymentMode, status, totalSar, confirmedBy | Created atomically from the accepted quote version. Payment mode: prepaid, flexible. Status: active, amended, terminated (only active used today). For flexible, totalSar is the declared funding amount and the contract holds zero contract_items ever. |
contract_items | id (uuid) | contractId (FK), packageId (FK), qty, unitPriceSar | Contract line items; unitPriceSar snapshots packages.priceSarB2b at confirmation time. Prepaid only — a flexible contract never has rows here. |
contract_payments | id (uuid) | contractId (FK), method, amountSar, documentName, documentMime, documentBytes (bytea), confirmedBy | Payment record with the supporting document stored in-row (size-capped, no file-storage infra) — prepaid pay-in-full or a flexible funding/top-up amount. Method: bank_transfer, cheque. Internal-only — no company-member RLS policy. |
Settings Domain (schema/settings.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
platform_settings | id (uuid) | key (unique), value (jsonb) | Generic admin-configurable key/value store. First consumer: the organization key — seller-of-record identity and a VAT toggle rendered onto generated PDFs. An unset key returns null; the documents renderer omits the seller block rather than failing. |
Audit Domain (schema/audit.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
audit_logs | id (uuid) | actorId (nullable FK), action, entityType, entityId, oldValues, newValues, ipAddress (inet), userAgent | Append-only. prevent_audit_log_modification trigger blocks UPDATE/DELETE. |
consent_records | id (uuid) | userId (FK), consentType, version, givenAt, ipAddress, revokedAt | PDPL explicit consent tracking. Types: terms_of_service, privacy_policy, marketing, data_processing. |
Notification Domain (schema/notification.ts)
| Table | Primary Key | Key Columns | Notes |
|---|---|---|---|
notifications | id (uuid) | userId (FK), batchId (FK, nullable), category, eventKey, templateKey, params (jsonb), severity, dedupeKey, readAt, seenAt, archivedAt, expiresAt, retainUntil, deletionRequestedAt, deletedAt, createdBy (FK, nullable) | One row per recipient per occurrence. params carries ids and admin-composed copy only, never an address, name, or credential. A soft delete scrubs the params while keeping the row and dedupe key. |
notification_deliveries | id (uuid) | notificationId (FK), batchId (FK, nullable), channel, outcome, provider, providerMessageId, attempts, errorCode, errorText, suppressionReason, queuedAt, sentAt, deliveredAt, failedAt | One channel outcome per row, unique on (notification_id, channel). queued_at stays null until the queue accepted the job, so persistence and handoff are separate states. |
notification_batches | id (uuid) | createdBy (FK, nullable), request (jsonb), category, status, idempotencyKey, requestFingerprint, responseSnapshot (jsonb), replayExpiresAt, scheduledAt, startedAt, finishedAt, cancelledAt, recipientsTotal | One row per admin send. The recipient total is written once at resolution. Sent, failed, suppressed, and pending counts are live aggregates over notification_deliveries. |
notification_channel_optouts | (userId, channel) composite | channel, createdAt | Recipient opt-out rows for email, sms, and push. in_app is the inbox record itself and can never be opted out of. |
notification_provider_webhook_events | id (uuid) | channel, provider, externalEventId, eventType, providerMessageId, normalisedStatus, reasonCode, providerTimestamp, enqueuedAt, processedAt | A projection of the provider callback, never the raw body. Unique on (channel, provider, external_event_id) because the mock providers share one provider id. enqueued_at is null until the normalisation job was accepted. |
Key Relationships
users ──< sessions
users ──< orders ──< order_items >── packages >── operators
users ──< activations ──< identity_verifications
users ──< activations ──< activation_status_events
orders ──< payments ──< payment_webhook_events
companies ──< company_users
companies ──< bulk_orders
companies ──< invoices
companies ──< company_roster ──< magic_links
companies ──< quote_requests ──< quote_request_items
quote_requests ──< contracts ──< contract_items
contracts ──< contract_payments
users ──< tickets ──< ticket_comments
users ──< audit_logs
users ──< consent_records
users ──< notifications ──< notification_deliveries
users ──< notification_batches
notification_batches ──< notifications
notification_batches ──< notification_deliveries
users ──< notification_channel_optoutsDocuments & PDF Generation
Generated PDFs (B2C receipts, corporate charge receipts, B2B contract-payment receipts) are served from one route, GET /documents/:type/:entityId.pdf, backed by a per-type registry (apps/api/src/documents/registry.ts) of { policy, provider, template } triples (documents/types.ts). The route resolves the type, runs the type's access policy (a pure boolean check that returns a bare false on denial — the route throws 403 AUTH_FORBIDDEN, leaking nothing about the entity's existence), loads the Facts a template needs via the type's provider, then hands facts + the organization's seller settings + a language to the type's template to build a renderer-agnostic DocumentModel.
The in-app renderer at apps/api/src/documents/pdf/ turns that model into a PDF Buffer with BiDi-correct Arabic and OpenType shaping via an embedded font. It has no receipt/order/contract concepts of its own — domain meaning lives entirely in the apps/api/src/documents/templates/ files that build the model.
| Document Type | Provider | Template |
|---|---|---|
receipt_b2c | providers/receipt-b2c.ts | templates/receipt.ts |
corporate_charge | providers/corporate-charge.ts | templates/receipt.ts |
contract_payment | providers/contract-payment.ts | templates/contract-payment.ts |
The seller-of-record block on every generated PDF comes from the organization platform setting (see Settings Domain above) — when unset, the template omits the block instead of failing.
Queue Architecture
Nine BullMQ queues. Six are in packages/queue, use shared constants from @activation-sys/shared, and get dedicated Redis connections with namespace-prefixed keys. Three live in apps/api, configure their options inline, and share the API's Redis singleton without a custom BullMQ prefix.
Queue Summary
| Queue | Name | Location | Purpose |
|---|---|---|---|
| Activation | activation | packages/queue | Drives operator activation steps (initiate, verify_identity, complete) |
| Payment | payment | packages/queue | Settles completed payments by enqueuing activation handoff |
| Notification Email | notification-email | packages/queue | Renders and sends persisted email deliveries |
| Notification SMS | notification-sms | packages/queue | Renders and sends persisted SMS deliveries through the mock provider |
| Notification Orchestration | notification-orchestration | packages/queue | Dispatches batch, handoff reconciliation, and callback-normalization hooks |
| Store Fulfillment | store-fulfillment | packages/queue | Issues eSIM profiles for paid store orders |
| Package Sync | package-sync | apps/api | Periodic sync of telecom operator packages |
| eSIM Product Sync | esim-product-sync | apps/api | Periodic sync of eSIM provider catalogs |
| FX Refresh | fx-refresh | apps/api | Scheduled fetch of USD exchange rates from the configured provider |
The packages/queue queues use QUEUE_NAMES and RETRY_CONFIG from @activation-sys/shared. The apps/api queues configure attempts, backoff, removeOnComplete, and removeOnFail directly at queue creation time.
Activation Queue (packages/queue/src/queues/activation-queue.ts)
- Job ID: Activation UUID (prevents duplicate jobs for same activation)
- Steps:
verify_identity(poll operator status),complete(finalize with operator),initiate(acknowledgment only — handled synchronously in route) - Custom backoff:
[1000, 5000, 30000]ms — exact delays rather than standard exponential doubling - Worker (
workers/activation-worker.ts): PluginManager injected at startup to avoid cross-package imports
Payment Queue (packages/queue/src/queues/payment-queue.ts)
- Job ID:
{paymentId}:{action}(deterministic deduplication) - Actions:
settle_payment(re-read DB status → enqueue activation job),reconcile_failure(log and skip) - Worker (
workers/payment-worker.ts): Re-reads payment status from DB before activation handoff (T-05-12). Derives operator from activation record, not client input (T-05-15) - Longer failed-job retention (7 days) supports payment reconciliation audits
Notification Delivery and Orchestration
The former shared notification stub and API-local status lane are removed. Their replacement is four queues in packages/queue: notification-email, notification-sms, notification-push, and notification-orchestration. In-app delivery is written directly to Postgres without a queue. A push delivery is addressed to the account id, so it is never suppressed for a missing address at trigger time.
- Persist before enqueue:
NotificationOrchestrationService.trigger()resolves audiences in chunks and commits each notification with its delivery rows before handing off external deliveries. In-app starts atdelivered. External deliveries start atqueuedorsuppressed. A unique recipient/occurrence key prevents duplicate notifications, including when a repeated trigger repairs an unstamped handoff. - Channel payloads:
deliveryId,notificationId, andchannelonly. Postgres holds recipient, content, and durable delivery state. Redis does not carry addresses or rendered bodies. - Independent channel limits: Each email worker has concurrency 5 and each SMS worker has concurrency 2. Their separate queue rate limits are 30 and 10 jobs/minute respectively. Email has 5 attempts and SMS 3, both with exponential backoff starting at 5 seconds. See notification queue options for priorities, retention, and callback overrides.
- Send-time checks: Workers reload delivery and notification state, acquire a conditional
sendingclaim, then re-read account activity, non-transactional opt-out, address, and locale. Blocked deliveries becomesuppressedwithout provider dispatch. After rendering, the worker checks that it still owns the claim before sending. A stale claim is recoverable after 60 seconds, or sooner after a recorded failed attempt. - Outcomes: Injected render/send functions preserve attachments and provider message IDs. Recorded
sent,delivered,failed_terminal, andsuppressedoutcomes are not resent. Retry bookkeeping does not movesendingbackwards toqueued. Exhaustion or an unrecoverable failure recordsfailed_terminalin Postgres, which remains authoritative after Redis removes a job. - Orchestration: A separate worker runs scheduled batches, handoff reconciliation, and callback normalization through injected hooks. It has concurrency 2 and no provider rate limiter. Batch and sweep jobs default to 5 attempts with a 1-second exponential base. Callback jobs carry a persisted event ID and override this with 9 attempts and a 10-second base.
- Recovery: The startup and recurring handoff sweep repairs persisted email/SMS deliveries that were not enqueued, lost their Redis job, or have a stale
sendingclaim. Stale unfinished batches (scheduled or running past the stale threshold, with a scheduled time that has also passed it, not finished, not cancelled) are re-queued with their idempotent run job in the same pass. Callback receipts are not swept. Their normalisation job retries on its own schedule until the receipt can be matched or is given up. See handoff reconciliation for selection bounds and recovery limits. - Provider boundaries: Every channel, and OTP, sends through its service's active provider from
ServiceProviderRegistry. A provider withoutsendNotificationproducessuppressedwith reasonprovider_unsupported; a provider that finds nobody to deliver to (no_recipient, such as a push to an account with no device) producessuppressedwith reasonno_address. SMTP email ends atsentbecause SMTP provides no delivery callback. - Duplicate window: Delivery claims and recorded outcomes prevent ordinary replays, but SMTP acceptance followed by a crash or lost acknowledgement before recording success can still produce a duplicate on recovery. The SMTP notification adapter bounds the send to 20 seconds and closes its transport, below the worker's 30-second lock duration. Closing a connection cannot retract accepted mail and SMTP has no provider-side idempotency operation.
Supabase Realtime continues to publish activation status changes independently of notification queues.
Automatic activation.completed and payment.succeeded notifications use the trigger, and so do admin invitations, company approval, contract readiness, and eSIM QR delivery — each with a dedupe key per event occurrence. OTP, magic-link delivery, and the email test send remain outside the queues.
Contract readiness uses the durable integer contracts.notification_readiness_episode, atomically incremented on a false-to-true readiness transition inside the producer's company advisory-lock transaction. Postcommit dispatch uses that captured episode, rather than notification-history counts, for the occurrence key.
Notification Data Lifecycle
Notification records, delivery outcomes, batches, channel opt-outs, and normalized callback receipts are persisted separately. User inbox reads are owner-scoped, hide archived, expired, and scrubbed rows by default, hide everything but archived rows when the archived view is requested, and render stored template parameters using the current user locale. Admin notification operations require super_admin or an admin holding the notifications area, at view or manage level depending on the route.
GET /profile/export includes the caller's notification history, stored parameters, read/seen/archive timestamps, delivery summaries, and channel opt-outs. Unlike the inbox, the export includes archived, expired, and scrubbed history. It does not render templates or include batch requests, callback receipts, provider message IDs, or raw delivery errors.
Account deletion marks the user's existing notifications with a deletion request and a 365-day retention deadline. The compliance retention sweep replaces eligible params with {} and stamps deletedAt. It keeps notification rows, occurrence dedupe keys, and delivery rows so scrubbing cannot re-enable an old send. Expiry alone only hides an inbox item and does not scrub it. The sweep does not purge notification batches, callback receipts, or channel opt-outs. Custom bilingual content is stored per recipient, so storage grows with audience size.
Independently of recipient deletion, that sweep reduces batch request to channels-only metadata once the batch has been finished or cancelled for at least 24 hours and its replay window has expired or is null. The batch row, response snapshot, fingerprint, and dedupe history remain intact. Scrubbing requires the compliance retention sweep to run.
Redis Connection Strategy (packages/queue/src/config/redis.ts)
The package creates dedicated Redis connections for queues and workers. Namespace isolation uses BullMQ's prefix, not ioredis keyPrefix: for example, notification-email:bull:notification-email:*, notification-sms:bull:notification-sms:*, and notification-orchestration:bull:notification-orchestration:*. Production connections enforce TLS 1.3 (rediss:// scheme with minVersion: 'TLSv1.3').
The API injects notification rendering, provider dispatch, locale resolution, and orchestration services through startup-workers.ts. On shutdown or partial startup failure it stops workers before calling closeNotificationQueues(), which closes all three notification producers and their supplied Redis clients. Notification worker cleanup also releases its supplied and blocking connections, with a five-second drain bound. Queue metrics come from worker events rather than separate notification QueueEvents listeners. Closing the default Redis singleton alone does not close dedicated clients. This notification cleanup is not a guarantee that the older activation, payment, store, or API-local queue clients are explicitly closed.
PII Encryption Flow
All personally identifiable information (PII) is encrypted at rest using AES-256-GCM authenticated encryption, implemented as a custom Drizzle ORM column type.
Custom Encrypted Type (packages/database/src/utils/encryption.ts)
const encryptedText = customType<{ data: string; driverData: string }>({
dataType() { return 'text'; },
toDriver(value: string): string { return encrypt(value); },
fromDriver(value: string): string { return decrypt(value); },
});- Algorithm: AES-256-GCM (provides both confidentiality and integrity)
- Key source:
ENCRYPTION_KEYenvironment variable (64-char hex string = 32 bytes) - Key rotation support: Key is read at call time (
getKey()) — not at module initialization — so rotating the env var takes effect without restart - Storage format: Hex-encoded
IV (12 bytes) + authTag (16 bytes) + ciphertext - IV: Random 12 bytes per encryption operation (non-deterministic ciphertext)
Encrypted Columns
| Table | Column | Note |
|---|---|---|
users | phone | Nullable (email-only users per D-25) |
users | email | Nullable (phone-only users) |
users | name | Not null |
users | idNumber | Nullable, collected during activation |
companies | contactPhone | B2B contact phone |
Lookup Strategy
Encrypted values are non-deterministic (random IV). For uniqueness lookups and searching, parallel hash columns store SHA-256 hashes:
| Encrypted Column | Hash Column | Purpose |
|---|---|---|
phone | phoneHash | Phone uniqueness + login lookup |
email | emailHash | Email uniqueness + login lookup |
The hash columns are indexed (e.g., idx_users_phone_hash, idx_users_email_hash) for efficient queries.
Threat Model
- T-04-06: Encryption key never logged or returned in API responses
- T-05-06: Payment metadata limited to non-PII fields only
- Corruption detection: GCM auth tag verification causes
decrypt()to throw if data is tampered or wrong key is used
Authentication Flow
Authentication uses JWTs issued by the Hono API (signed with SUPABASE_JWT_SECRET via HS256). Supabase Auth provides the underlying identity provider.
Registration → Login → Session
1. POST /auth/register → Create user + send OTP (phone/email)
2. POST /auth/login → Always 200; sends OTP only to an existing user on its matching client
3. POST /auth/verify-otp → Verify OTP + check client vs role + create JWT session
4. POST /auth/logout → Revoke session (web: clear cookie, mobile: Redis blocklist)Dual Session Strategy (Web vs Mobile)
The API detects client type via X-Client-Type header or User-Agent pattern matching (Flutter/Dart/Android/iPhone → mobile, otherwise → web). This affects how the JWT is delivered and revoked:
| Aspect | Web Client | Mobile Client |
|---|---|---|
| Token delivery | httpOnly, Secure, SameSite=Strict cookie named activation-sys-session | JWT in response body (session.token) |
| Token storage | Browser cookie jar | flutter_secure_storage on device |
| Token sending | Automatic via cookie | Authorization: Bearer <token> header |
| Logout | deleteCookie() — cookie cleared | SHA-256(JWT) → auth:blocklist:<hash> in Redis with TTL matching remaining JWT expiry |
| Session record | clientType: 'web' in sessions table | clientType: 'mobile' |
| Who signs in | Every role except end_user (portal) | end_user only (customer app) |
JWT Payload
{
"sub": "user-uuid",
"app_role": "end_user",
"identity_type": "citizen",
"iat": 1714896000,
"exp": 1714903200
}Auth Middleware Flow (apps/api/src/middleware/auth.ts)
- Check if path is in
PUBLIC_PATHS→ skip auth - Check if path matches a
PROTECTED_PREFIXESentry → enforce auth; unknown paths pass through - Extract token: try
activation-sys-sessioncookie first, thenAuthorization: Bearerheader - Check Redis blocklist:
SHA-256(token)→auth:blocklist:<hash>(prevents token leakage in logs) - Verify JWT signature and expiry with
SUPABASE_JWT_SECRET(HS256) - Set context:
userId,appRole,identityType,rawToken
Roles Enforced
| Role | Access Scope |
|---|---|
end_user | Own data only (users, orders, activations, payments, profile) |
b2b_admin | Own data + company-scoped data |
activation_officer | Assigned activations + linked users |
support | Read-only access to users, orders, activations, payments, tickets |
admin | Platform admin scoped to assigned areas, each held as View or Manage |
super_admin | Full read/write access + compliance routes + audit logs |
Bilingual Response System
All API error responses respect the Accept-Language header for Arabic/English bilingual output. The notification inbox is the exception, rendering copy from each recipient's stored locale. The language determination flows through middleware to the error handler and route handlers.
Flow
Accept-Language header → languageMiddleware → c.set('language', 'ar'|'en')
→ c.set('clientType', 'web'|'mobile')
Error path: errorHandlerMiddleware → toApiResponse(error, language)
Normal path: route handler reads c.get('language')Language Middleware (apps/api/src/middleware/language.ts)
- Reads
Accept-Languageheader (defaults toen) - If value starts with
ar→ language isar; otherwise →en - Also detects
X-Client-Typeheader orUser-Agentpattern forclientType
Error Response (Bilingual)
The global error handler (apps/api/src/middleware/error-handler.ts) converts AppError instances using toApiResponse(error, language), which selects messageAr or messageEn based on the detected language:
// Accept-Language: ar
{
"error": {
"code": "AUTH_REQUIRED",
"message": "مطلوب المصادقة"
}
}// Accept-Language: en
{
"error": {
"code": "AUTH_REQUIRED",
"message": "Authentication required"
}
}Health Check Response
// GET /health (200 or 503)
{
"status": "ok",
"version": "1.0.0",
"timestamp": "2026-05-05T08:00:00.000Z",
"checks": {
"database": "ok",
"redis": "ok"
}
}Auth Verify-OTP Response (Dual Strategy)
Web client — JWT set as httpOnly cookie, not in body:
// POST /auth/verify-otp (web client)
{
"user": {
"id": "a1b2c3d4-...",
"phone": "+966500000001",
"name": "محمد",
"email": null,
"appRole": "end_user",
"identityType": "citizen",
"isActive": true,
"createdAt": "2026-01-15T10:30:00.000Z"
},
"session": {
"clientType": "web",
"expiresAt": "2026-05-05T10:00:00.000Z"
}
}Mobile client — JWT returned in response body:
// POST /auth/verify-otp (mobile client)
{
"user": {
"id": "a1b2c3d4-...",
"phone": "+966500000001",
"name": "محمد",
"email": null,
"appRole": "end_user",
"identityType": "citizen",
"isActive": true,
"createdAt": "2026-01-15T10:30:00.000Z"
},
"session": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"clientType": "mobile",
"expiresAt": "2026-05-05T10:00:00.000Z"
}
}Catalog Bilingual Fields
Package and operator data is stored with separate Arabic and English columns. Route handlers select the appropriate field based on language:
// GET /packages (Accept-Language: ar)
{
"data": [{
"id": "pkg-uuid",
"name": "باقة الإنترنت - 50 جيجا",
"description": "باقة إنترنت شهرية من STC",
"priceSar": 100.00,
...
}]
}Operator plugins also return bilingual data using { ar: string; en: string } objects (e.g., OperatorPackage.name.ar, OperatorPackage.name.en).
Application Surfaces
| Surface | Workspace | Runtime | Responsibility |
|---|---|---|---|
| API | apps/api | Hono on Node.js | Validated REST contracts, auth, ownership, activation, payment, compliance, webhooks |
| Admin/B2B | apps/admin | Next.js | Operational admin workflows and company portal flows |
| B2C Web | apps/web-b2c | Next.js | Browser customer activation funnel, package comparison, status entry points |
| Landing Site | apps/site | Vite + React | Public marketing, bilingual positioning, routing into B2C web/docs |
| Mobile | apps/mobile | Flutter | Primary native customer app once Dart sources are added |
| Docs | docs | VitePress | Internal developer documentation |
B2C web, mobile, admin, and site remain thin clients. Identity verification, payment side effects, activation transitions, audit logging, and ownership checks remain server-side in apps/api and packages/queue.