Skip to content

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.yml

System 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 ​

DecisionRationale
Hono over ExpressType-safe, lightweight, edge-ready, native Zod validation
Drizzle over PrismaSQL-first, better RLS support, transparent PII encryption via custom types
BullMQ over Bull v3Active maintenance, better TypeScript, namespace-prefixed queues
SupabaseManaged PostgreSQL + RLS + Auth (Phase 2)
AES-256-GCM encryptionAuthenticated encryption for PII at rest, key rotation support
Plugin pattern for operatorsDecoupled 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 user

Security ​

  • 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 messageEn and messageAr
  • Audit Trail: Immutable audit logs with auditImmutabilityTrigger
  • Idempotency: Payment intents require idempotencyKey for 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
OrderMiddlewareSourcePurpose
1logger()hono/loggerStructured request/response logging
2cors()hono/corsCORS with environment-specific origins
3errorHandlerMiddlewaresrc/middleware/error-handler.tsGlobal error catch → bilingual JSON
4languageMiddlewaresrc/middleware/language.tsExtract Accept-Language → language + clientType context
5globalRateLimitMiddleware()src/middleware/rate-limit.tsConfigurable per-process HTTP request budget with health and verified web-cookie exemptions, sets X-RateLimit-* headers on counted requests
6authMiddlewaresrc/middleware/auth.tsJWT 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 ​

MethodPathAuthDescription
GET/healthNoneHealth check (API, database, Redis connectivity)
POST/auth/registerNoneRegister new user, send OTP
POST/auth/loginNoneLogin, send OTP to existing user
POST/auth/verify-otpNoneVerify OTP, create session (dual strategy: cookie for web, JWT for mobile)
POST/auth/logoutRequiredClear session (web: delete cookie, mobile: Redis blocklist)
GET/auth/sessionRequiredReturn current session user from JWT
GET/auth/meRequiredReturn user profile with decrypted PII
PUT/auth/meRequiredUpdate profile (name, email; phone via /change-phone)
POST/auth/change-phoneRequiredSend OTP to new phone number
GET/operatorsNoneList active operators with Arabic/English names
GET/packagesNoneList packages with filters (operator, data, voice, validity, promotional)
GET/packages/searchNoneFull-text + fuzzy trigram search across packages
GET/packages/compareNoneSide-by-side comparison (2–5 package IDs)
GET/packages/:idNoneSingle package detail with operator info
GET/packages/:id/availabilityNoneReal-time availability via operator plugin (Redis cache, 30s TTL)
POST/activations/validate-iccidNoneValidate ICCID via operator plugin (public pre-check)
POST/activationsRequiredCreate activation order with package selection
POST/activations/:id/verify-identityRequiredInitiate Nafath identity verification
GET/activations/:id/verify-identityRequiredCheck verification status (5 Nafath states)
GET/activations/:id/statusRequiredActivation + identity status with estimated timing
GET/activations/historyRequiredPaginated activation history (keyset cursor)
GET/activations/recordssuper_adminAdmin dashboard activation records with filters and related order/package/operator/user contact data
GET/payment-returnPublicBuilt-in neutral bilingual return page
POST/payments/:id/confirmRequiredVerify owned Moyasar or Paymob payment server-side with the gateway that created it
POST/payments/create-intentRequiredCreate payment intent (Mada, Visa, Mastercard)
POST/payments/apple-payRequiredCreate Apple Pay payment
POST/payments/stcpayRequiredCreate STC Pay SDK checkout via selected provider
GET/payments/:id/receiptRequiredOwner-scoped receipt with bilingual messages
GET/profileRequiredReturn authenticated user profile aggregate
PUT/profileRequiredUpdate allowed profile fields (name, email)
GET/profile/exportRequiredPDPL data portability export
DELETE/profileRequiredRequest account deletion (soft delete + retention period)
GET/notificationsRequiredOwn visible inbox with cursor pagination
GET/notifications/countRequiredOwn unread count
POST/notifications/:id/readRequiredMark one owned notification read
POST/notifications/read-allRequiredMark visible unread notifications read
POST/notifications/seenRequiredMark selected owned notifications seen atomically
POST/notifications/:id/archiveRequiredArchive one owned notification
GET/notifications/opt-outsRequiredRead channel registry and own opt-outs
PUT/notifications/opt-outsRequiredReplace own channel opt-outs
POST/admin/notificationssuper_admin or notifications (manage)Accept an immediate or scheduled notification batch
GET/admin/notifications/batchessuper_admin or notifications (view)Filter and paginate batches
GET/admin/notifications/batches/:idsuper_admin or notifications (view)Batch summary, delivery counts, and paginated failures
POST/admin/notifications/batches/:id/cancelsuper_admin or notifications (manage)Cancel a batch before execution starts
GET/admin/notifications/delivery-ratesuper_admin or notifications (view)Delivery outcomes by channel and provider
POST/compliance/retention/sweepsuper_adminExecute PDPL retention sweep
POST/compliance/breach-reportsuper_adminGenerate SDAIA breach notification
POST/webhooks/moyasarNone*Handle Moyasar webhook events (*body secret and server-side payment verification)
POST/webhooks/paymobNone*Handle Paymob transaction processed callbacks (*hmac query signature and server-side payment verification)
POST/webhooks/notifications/:channel/:providerSignaturePersist and enqueue verified email or SMS callbacks

Auth model notes:

  • Public routes skip JWT verification entirely (PUBLIC_PATHS list in auth.ts)
  • Protected routes require JWT from httpOnly cookie (web) or Authorization: Bearer header (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 hmac query 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:

MethodPurpose
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:

PluginFileEnvironment
MockOperatorPluginmock-operator.tsDefault (development/test)
STC, Mobily, ZainFutureOperator plugin registration and operator credentials (independent of payment gateway)

PaymentProvider (apps/api/src/plugins/payment-provider.ts) ​

MethodPurpose
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.

ProviderFileWhen Active
MockPaymentProvidermock-payment-provider.tsDefault
MoyasarPaymentProvidermoyasar-payment-provider.tsSelected in saved plugin settings (or initial environment fallback)
PaymobPaymentProviderpaymob-payment-provider.tsSelected 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) ​

MethodPurpose
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.

ProviderFile
MockSmsProvidermock-sms-provider.ts
TaqnyatSmsProvidertaqnyat-sms-provider.ts
TwilioSmsProvidertwilio-sms-provider.ts
OneSignalSmsProvideronesignal-providers.ts

EmailProvider (apps/api/src/plugins/email-plugin.ts) ​

MethodPurpose
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.

ProviderFile
MockEmailProvidermock-email-provider.ts
SmtpEmailProvidersmtp-email-provider.ts
OneSignalEmailProvideronesignal-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) ​

TablePrimary KeyKey ColumnsNotes
usersid (uuid)phone (encrypted), phoneHash, email (encrypted), emailHash, name (encrypted), idNumber (encrypted), identityType, appRoleSingle table for all roles. appRole is dynamic text (end_user, b2b_admin, super_admin, support, activation_officer, admin). Hash columns for deterministic lookups.
sessionsid (uuid)userId, tokenHash, ipAddress (inet), userAgent, clientType, revokedAtSupplements Supabase Auth JWT sessions with metadata tracking.

Catalog Domain (schema/catalog.ts) ​

TablePrimary KeyKey ColumnsNotes
operatorsid (uuid)slug (unique), nameEn, nameAr, isActive, serviceType, paymentMode, isNameArFallback, apiBaseUrl, apiConfigBilingual display names (rarely change). Slug: stc, mobily, zain. eSIM store providers are rows in this table with serviceType='esim'.
packagesid (uuid)operatorId (FK), nameEn, nameAr, descriptionEn, descriptionAr, searchVector (tsvector), priceSar, priceSarB2b, isNameCustomized, isPriceCustomized, isActiveCustomizedFull-text search via GIN index + pg_trgm fuzzy fallback. Admin customization flags protect overrides from sync.

Activation Domain (schema/activation.ts) ​

TablePrimary KeyKey ColumnsNotes
ordersid (uuid)userId (FK), companyId (FK, nullable), status, totalAmount, currencycurrency is 'SAR' or 'USD' (default 'SAR'). companyId set for B2B orders only.
order_itemsid (uuid)orderId (FK), packageId (FK), iccid, unitPriceSarOne order item per SIM + package selection.
activationsid (uuid)orderItemId (FK), userId (FK), operatorId (FK), iccid, packageId (FK), status, identityStatus, identityRef, assignedOfficerId, retainUntil, deletedAtCore activation record. Retention fields for PDPL compliance. Status transitions tracked in activation_status_events.
identity_verificationsid (uuid)activationId (FK), transId, randomCode, status, verifiedAtNafath transaction tracking. 5 states: waiting, completed, rejected, expired, error.
activation_status_eventsid (uuid)activationId (FK), userId (FK), oldStatus, newStatus, source, noteImmutable audit trail for every activation status transition. Source: system, user, officer, operator_api.

Payment Domain (schema/payment.ts) ​

TablePrimary KeyKey ColumnsNotes
paymentsid (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_eventsid (uuid)gateway, eventId (unique composite), eventType, paymentId (FK), processedAt, payloadDeduplication: (gateway, eventId) unique index. Gateway: moyasar, paymob, mock.
invoicesid (uuid)companyId (FK), orderId (FK), amountSar, zatcaInvoiceId, pdfUrl, statusB2B only. ZATCA e-invoicing integration.

FX Domain (schema/fx.ts) ​

TablePrimary KeyKey ColumnsNotes
fx_ratescurrency (text)ratePerUsd (numeric 14,6), fetchedAt, sourceUSD-base rate table fed by a provider adapter. Deny-all RLS.

Store Domain (schema/store.ts) ​

TablePrimary KeyKey ColumnsNotes
esim_productsid (uuid)providerSlug, operatorId (FK), providerSku, nameEn, nameAr, destinationScope, destinationCountries, validityValue, wholesaleUsd, retailUsd, isActive, isNameCustomized, isPriceCustomized, isActiveCustomized, source*, providerPayloadGlobal 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_ordersid (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, deletedAtOne 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_eventsid (uuid)storeOrderId (FK, cascade), oldState, newState, source, errorCode, note, createdAtStore 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) ​

TablePrimary KeyKey ColumnsNotes
ticketsid (uuid)userId (FK), subject, description, status, priority, assignedTo, activationId (FK, nullable)Linked to activation for context. Priorities: low, medium, high, critical.
ticket_commentsid (uuid)ticketId (FK), authorId (FK, nullable, cleared when the author is deleted), content, isInternalInternal notes not visible to end users (RLS-enforced).

B2B Domain (schema/b2b.ts) ​

TablePrimary KeyKey ColumnsNotes
companiesid (uuid)crNumber (unique), name, contactName, contactEmail (nullable), contactPhone (encrypted), status, archivedFrom (nullable), paymentMode (nullable), approvedByCommercial 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_usersid (uuid)companyId (FK), userId (FK), roleMany-to-many between companies and users. Role: admin, member.
bulk_ordersid (uuid)companyId (FK), orderId (FK), totalSims, status, csvDataBulk SIM activation orders for B2B.
company_rosterid (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_linksid (uuid)rosterId (FK), tokenHash (unique), status, expiresAt, usedAt, revokedAtEphemeral 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_requestsid (uuid)companyId (FK), version, parentId (self FK), needs (jsonb), status, note, companyNote, reviewedByVersioned 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_itemsid (uuid)quoteRequestId (FK), packageId (FK), qtyQuote line items, one set per quote version. Empty for a flexible funding/top-up request.
contractsid (uuid)companyId (FK), quoteRequestId (FK), paymentMode, status, totalSar, confirmedByCreated 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_itemsid (uuid)contractId (FK), packageId (FK), qty, unitPriceSarContract line items; unitPriceSar snapshots packages.priceSarB2b at confirmation time. Prepaid only — a flexible contract never has rows here.
contract_paymentsid (uuid)contractId (FK), method, amountSar, documentName, documentMime, documentBytes (bytea), confirmedByPayment 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) ​

TablePrimary KeyKey ColumnsNotes
platform_settingsid (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) ​

TablePrimary KeyKey ColumnsNotes
audit_logsid (uuid)actorId (nullable FK), action, entityType, entityId, oldValues, newValues, ipAddress (inet), userAgentAppend-only. prevent_audit_log_modification trigger blocks UPDATE/DELETE.
consent_recordsid (uuid)userId (FK), consentType, version, givenAt, ipAddress, revokedAtPDPL explicit consent tracking. Types: terms_of_service, privacy_policy, marketing, data_processing.

Notification Domain (schema/notification.ts) ​

TablePrimary KeyKey ColumnsNotes
notificationsid (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_deliveriesid (uuid)notificationId (FK), batchId (FK, nullable), channel, outcome, provider, providerMessageId, attempts, errorCode, errorText, suppressionReason, queuedAt, sentAt, deliveredAt, failedAtOne 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_batchesid (uuid)createdBy (FK, nullable), request (jsonb), category, status, idempotencyKey, requestFingerprint, responseSnapshot (jsonb), replayExpiresAt, scheduledAt, startedAt, finishedAt, cancelledAt, recipientsTotalOne 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) compositechannel, createdAtRecipient 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_eventsid (uuid)channel, provider, externalEventId, eventType, providerMessageId, normalisedStatus, reasonCode, providerTimestamp, enqueuedAt, processedAtA 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_optouts

Documents & 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 TypeProviderTemplate
receipt_b2cproviders/receipt-b2c.tstemplates/receipt.ts
corporate_chargeproviders/corporate-charge.tstemplates/receipt.ts
contract_paymentproviders/contract-payment.tstemplates/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 ​

QueueNameLocationPurpose
Activationactivationpackages/queueDrives operator activation steps (initiate, verify_identity, complete)
Paymentpaymentpackages/queueSettles completed payments by enqueuing activation handoff
Notification Emailnotification-emailpackages/queueRenders and sends persisted email deliveries
Notification SMSnotification-smspackages/queueRenders and sends persisted SMS deliveries through the mock provider
Notification Orchestrationnotification-orchestrationpackages/queueDispatches batch, handoff reconciliation, and callback-normalization hooks
Store Fulfillmentstore-fulfillmentpackages/queueIssues eSIM profiles for paid store orders
Package Syncpackage-syncapps/apiPeriodic sync of telecom operator packages
eSIM Product Syncesim-product-syncapps/apiPeriodic sync of eSIM provider catalogs
FX Refreshfx-refreshapps/apiScheduled 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 at delivered. External deliveries start at queued or suppressed. A unique recipient/occurrence key prevents duplicate notifications, including when a repeated trigger repairs an unstamped handoff.
  • Channel payloads: deliveryId, notificationId, and channel only. 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 sending claim, then re-read account activity, non-transactional opt-out, address, and locale. Blocked deliveries become suppressed without 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, and suppressed outcomes are not resent. Retry bookkeeping does not move sending backwards to queued. Exhaustion or an unrecoverable failure records failed_terminal in 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 sending claim. 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 without sendNotification produces suppressed with reason provider_unsupported; a provider that finds nobody to deliver to (no_recipient, such as a push to an account with no device) produces suppressed with reason no_address. SMTP email ends at sent because 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) ​

typescript
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_KEY environment 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 ​

TableColumnNote
usersphoneNullable (email-only users per D-25)
usersemailNullable (phone-only users)
usersnameNot null
usersidNumberNullable, collected during activation
companiescontactPhoneB2B contact phone

Lookup Strategy ​

Encrypted values are non-deterministic (random IV). For uniqueness lookups and searching, parallel hash columns store SHA-256 hashes:

Encrypted ColumnHash ColumnPurpose
phonephoneHashPhone uniqueness + login lookup
emailemailHashEmail 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:

AspectWeb ClientMobile Client
Token deliveryhttpOnly, Secure, SameSite=Strict cookie named activation-sys-sessionJWT in response body (session.token)
Token storageBrowser cookie jarflutter_secure_storage on device
Token sendingAutomatic via cookieAuthorization: Bearer <token> header
LogoutdeleteCookie() — cookie clearedSHA-256(JWT) → auth:blocklist:<hash> in Redis with TTL matching remaining JWT expiry
Session recordclientType: 'web' in sessions tableclientType: 'mobile'
Who signs inEvery role except end_user (portal)end_user only (customer app)

JWT Payload ​

json
{
  "sub": "user-uuid",
  "app_role": "end_user",
  "identity_type": "citizen",
  "iat": 1714896000,
  "exp": 1714903200
}

Auth Middleware Flow (apps/api/src/middleware/auth.ts) ​

  1. Check if path is in PUBLIC_PATHS → skip auth
  2. Check if path matches a PROTECTED_PREFIXES entry → enforce auth; unknown paths pass through
  3. Extract token: try activation-sys-session cookie first, then Authorization: Bearer header
  4. Check Redis blocklist: SHA-256(token) → auth:blocklist:<hash> (prevents token leakage in logs)
  5. Verify JWT signature and expiry with SUPABASE_JWT_SECRET (HS256)
  6. Set context: userId, appRole, identityType, rawToken

Roles Enforced ​

RoleAccess Scope
end_userOwn data only (users, orders, activations, payments, profile)
b2b_adminOwn data + company-scoped data
activation_officerAssigned activations + linked users
supportRead-only access to users, orders, activations, payments, tickets
adminPlatform admin scoped to assigned areas, each held as View or Manage
super_adminFull 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-Language header (defaults to en)
  • If value starts with ar → language is ar; otherwise → en
  • Also detects X-Client-Type header or User-Agent pattern for clientType

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:

json
// Accept-Language: ar
{
  "error": {
    "code": "AUTH_REQUIRED",
    "message": "مطلوب المصادقة"
  }
}
json
// Accept-Language: en
{
  "error": {
    "code": "AUTH_REQUIRED",
    "message": "Authentication required"
  }
}

Health Check Response ​

json
// 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:

json
// 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:

json
// 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:

json
// 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 ​

SurfaceWorkspaceRuntimeResponsibility
APIapps/apiHono on Node.jsValidated REST contracts, auth, ownership, activation, payment, compliance, webhooks
Admin/B2Bapps/adminNext.jsOperational admin workflows and company portal flows
B2C Webapps/web-b2cNext.jsBrowser customer activation funnel, package comparison, status entry points
Landing Siteapps/siteVite + ReactPublic marketing, bilingual positioning, routing into B2C web/docs
Mobileapps/mobileFlutterPrimary native customer app once Dart sources are added
DocsdocsVitePressInternal 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.

Internal documentation - Activation System