Applications
The monorepo contains separate runtime surfaces for API, operations, customer web, public landing, mobile, and docs.
| Workspace | Package | Stack | Local URL | Role |
|---|---|---|---|---|
apps/api | @activation-sys/api | Hono + TypeScript on Node.js | http://localhost:3001 | REST API, OpenAPI/Scalar docs, auth (OTP, magic links), activation, global eSIM store, payment, B2B companies/quotes/contracts, corporate billing, PDF documents, platform settings, notifications, compliance, webhooks |
apps/admin | @activation-sys/admin | Next.js 16 | http://localhost:3000 | Admin operations, notifications, and B2B company portal (quotes for prepaid; /b2b/catalog + /b2b/picks + /b2b/funding for flexible pay) |
apps/web-b2c | @activation-sys/web-b2c | Next.js 16 | http://localhost:3002 | Customer-facing browser activation flow |
apps/site | @activation-sys/site | Vite + React | http://localhost:3003 | Public landing page and product entry point |
apps/mobile | activation_sys | Flutter + Riverpod + go_router | Device / emulator (flutter run in apps/mobile) | B2C SIM activation and travel eSIM. Sources live under apps/mobile/lib |
docs | @activation-sys/docs | VitePress | VitePress dev URL | Internal developer documentation |
Mobile
The Flutter app in apps/mobile is the B2C client. It speaks Arabic and English, follows DESIGN.md (Alexandria and Nord, flat surfaces, 48 dp targets), and uses a four-tab shell:
| Tab | Path | Who can open it |
|---|---|---|
| Home | /home | Anyone |
| Plans | /plans | Anyone, including package and travel-product detail |
| My Lines | /sims | Signed-in. Guests see a sign-in prompt |
| Account | /account | Signed-in. Profile, orders, settings, and support live behind it |
Checkout (Saudi SIM and travel eSIM), payment (Moyasar; the app does not handle Paymob checkouts yet), and activation tracking require a session. The screen map is apps/mobile/docs/screens.md.
B2B portal screens
The company portal lives under apps/admin/src/app/[locale]/(portal)/b2b. Sidebar order is Dashboard, Beneficiaries, then Quotes (prepaid, a plain link) or an Ordering group with Catalog + Selected packages (flexible), then a Finance group with Contract, Billing, and Funding (flexible). See Sidebar navigation for how groups render.
- Beneficiaries (
/b2b/roster) — the roster screen. "Beneficiaries" is the user-facing name in both languages. The route, the API and the database still sayroster. The table carries a Package column (the assigned package's name, an em dash when unassigned, a muted "unavailable" when the package was delisted) and a checkbox column. The link-status chip reads the entry'sactivatedflag before its link status: an activated person shows an "Activated" chip, and everyone else keeps the link-status label, whereusedreads "Link opened" because a spent token says nothing about whether the person finished. The filter tabs keep the broader "Used" wording, since they filter on link status and both cases belong under it. In the per-person sheet an activated beneficiary is offered no send at all (the API answersLINK_USER_ALREADY_ACTIVE) and the lock note says the SIM is live; for everyone else a resend confirms first, because minting kills the link already delivered. Selecting rows opens a bulk bar with Assign package, Send links, Export links, Revoke and Remove. Select-all spans the whole current filter, not just the visible page, so a selection can exceed the batch routes' 500-id cap. Send, export and revoke call the batch routes in sequential 500-id chunks and sum the per-chunk results into one summary. Assign and remove loop the single-entry routes with progress and a per-reason failure summary. - Quote detail (
/b2b/quotes/[id]) — the version being negotiated, its items, its chain and the reviewer's note. An admin-authored counter still under review offers both answers side by side, Accept and Decline, each behind a confirm dialog; Decline takes an optional reason, which renders back on the page as "Your reason" and is held apart from the reviewer's note. A funding request — a version whoseneeds.fundingSaris set — shows the declared amount above the items, since a counter adds line items to a funding version rather than replacing it. Once the chain closes asrejectedorexpiredthe page offers a rebuild CTA rather than dead-ending: "Build a new quote" to/b2b/quotes/new, or "Declare a new amount" to/b2b/fundingfor a funding chain, which has no line items to rebuild. - Funding (
/b2b/funding) — flexible mode only. Declare or top up the funding amount; the transfer itself stays offline. The pending card and every history row navigate to that request's quote detail, which is where a countered funding request gets answered. - Selected packages (
/b2b/picks) — flexible mode only, with a nav count badge. Leads with what the company has actually assigned: the roster's package assignments, aggregated server-side per package from the same roster sweep the Beneficiaries screen reads, so every admin of the company sees the same numbers. Below it sits a clearly separated "planned on this device" cart held inlocalStorageper company, which the catalog's Continue writes and the bulk assign flow consumes. The badge counts the planned picks. The empty state shows only when both sections are empty. - Registration — the first-entry form for an invited company admin collects the CR number (with the in-KSA yes/no choice), company name, contact name and contact phone. It has no contact email field: the company's contact email is taken from the admin's own account email.
- Notification bell, inbox, settings and profile: shared with the internal admin, not portal-specific. See Shared portal screens (both portals) below.
Shared portal screens (both portals)
The header bell, the account menu, and the inbox and settings pages live under apps/admin/src/app/[locale]/(portal)/notifications and apps/admin/src/components/shell/{header,portal-shell}.tsx, and the profile page under apps/admin/src/app/[locale]/(portal)/profile. Every signed-in portal person reaches the same four screens, on the internal admin side and on the B2B company portal alike, and none of them carries a sidebar entry or an area gate. A shared date picker and a shared switch, under apps/admin/src/components/ui/date-picker.tsx and switch.tsx, serve the date fields and the on/off controls on these screens, on the composer, on the delivery-rate window and on the activation records filter. See Notifications API for the routes behind them.
- Notification bell: the badge in the header counts unread notifications, showing the exact count up to 99 and 99+ beyond that. One coordinator, mounted once by the header, keeps it current. It opens one same-origin stream at
/api/notifications/stream, a route handler on the admin origin that forwards the session cookie to the API'sGET /notifications/streamand pipes the event stream back. The API sendsreadywhen subscribed andnotifications.changedwhenever this reader's inbox changes, with no content, identifiers or counts, so a hint is only a trigger. Each hint (after a 300 millisecond trailing debounce, so a burst or the reader's own echo costs one read) runs the same read the interval used to run. A hint that lands while a read is in flight is owed one more read. Each check reads the unread count first. A count of zero ends the check right there. Otherwise every unread page is read in full and the row identifiers are compared against the previous complete set. The very first check after the page loads is silent: it stores that set and raises nothing, so opening a portal that already carries unread history does not flood the reader with toasts. Every check after that shows a toast only for identifiers absent from the previous set, because a row committed earlier can still surface after a newer one, and only a full identifier comparison catches that. A check whose unread read fails keeps the previous set and tries again on the next one, while a count that did answer still moves the badge. A check that finds the reader signed out stops the loop instead of redirecting, since it runs quietly behind whatever screen is open. The one-minute interval remains only as the floor while no stream is open, running from mount untilreadyand again after the stream drops. A read also runs whenever the window regains focus. A dropped or refused stream is closed, read once at once, then reopened with a wait that starts at one second, doubles to a thirty-second ceiling and carries a little random jitter, reset on the nextready. Losing the signed-in state closes the stream and every timer for good as well. Opening the bell's panel reads a short page of the newest rows, read and unread together, marks the unseen ones seen, and groups them under day headings, each row showing its title, one line of body, category, an optional severity mark, and time, with unread rows marked by an accent edge on the start side and a bold title. A row the API marksdegraded, meaning its template failed to render so the title is the category label and the body is empty, shows a short line saying the notification could not be displayed in place of the body, in the panel and in the inbox alike. The panel carries its own loading skeleton while that read is in flight, its own empty state when there is nothing to show, and its own failed state with a retry when the read fails, independent of the badge's polling loop. Its header shows the unread count as a chip and offers a mark all read control that clears every unread row from there, and its footer links to the inbox and carries a settings icon named for assistive technology. A navy toast under the header for a newly arrived row leaves on its own after six seconds, and opens the notification's own link when the portal can follow it — an absolutehttps://address in a new tab, or a root-relative staff ticket link (/support/tickets/…,/officer/tickets/…) under the reader's locale in the same tab — and opens the inbox otherwise, which is also where a root-relative mobile deep link the portal has no page for lands. - Account menu: opened from the avatar next to the bell, it holds three entries, profile, notification settings and sign out.
- Profile (
/profile): reached only from the account menu, with no area gate, and every role edits only itself because the API scopes the profile routes to the session's user. It readsGET /profileand shows the person's name, email and phone. Name and email save together throughPUT /profile; an emptied email is sent asnull, which removes it, and is refused when the account has no phone to sign in with instead. The phone changes in a two-step dialog:POST /auth/change-phonesends a code to the new number, andPOST /auth/verify-change-phonemakes it the account's phone, so nothing changes until the code is verified. The code step has a shared six-box code input and a 28-second resend countdown, the same wait as the sign-in code step, re-armed after a refused send too. Outside production the dialog also shows the issued code, read back from the API's dev-onlyGET /dev/otp/:identifierlookup with the identifierphone-change:<userId>:<newPhone>, the same way the sign-in screen shows its code (apps/admin/src/lib/auth/dev-otp.ts). A taken email or phone, a wrong or expired code, the per-number send limit and the per-user request limit each show inline in the panel or dialog and never sign the person out. - Inbox (
/notifications): every signed-in portal person's own feed, reached only from the bell. Two pill tabs, Inbox and Archived, switch on anarchivedflag in the page address, and the Archived tab is read only once it is opened, never ahead of time. The Inbox tab carries the unread count as a badge, and an "Unread only" switch narrows either tab to just its unread rows, kept in the page address asunread=1so the same narrowing survives a reload on both tabs. Rows are grouped under day headings, each with a category glyph, an importance mark, the title, one line of body and an archive control, and an unread row carries an accent edge and a bold title. Opening a row shows it in a reading pane beside the list on a desktop, or in place of the list with a back control on a phone, and marks it read once. The pane offers an open-link control only when the row carries a link the portal can follow — an absolute address, or a root-relative staff ticket link under the reader's locale — and shows nothing for a root-relative mobile deep link, which has no page here. "Mark all read" clears every unread row in one call, and archiving a row, from the row or from the pane, removes it from the Inbox tab for good with no undo and no confirmation and without marking it read. A Show more control loads further rows on the feed's own cursor rather than a page number. An empty feed, a failed read, and the unread narrowing turning up nothing each show their own message, a failed read offers a retry, and the unread empty state offers a way back to the whole feed. - Notification settings (
/notifications/settings): reached only from the account menu. The channel list shows every channel the registry knows, because saving replaces the muted list whole, and a channel left off the screen would read as the reader turning it back on. In-app shows locked, because the in-app notification is the inbox record itself and cannot be muted. Email, SMS and push each show a switch. Saving shows saving, saved and failed in place on the button. One line under the card names the language notifications render in and offers no control for it, because that language simply follows whichever language the portal itself is set to. The header's language toggle writes that language to the signed-in reader's stored profile on every switch (apps/admin/src/lib/notifications/locale.ts), and signing in writes it once more for whichever language the sign-in itself ran in (apps/admin/src/app/[locale]/login/actions.ts).
Sidebar navigation
apps/admin/src/components/shell/nav.ts lists every sidebar entry with the area that gates it and the module group it sits in, and groupNav turns the visible entries into the sidebar's top level. Ungrouped entries, the dashboards, lead the list, followed by the groups in a fixed order:
| Group | Entries |
|---|---|
| Operations | Activation records, Store orders, Catalog |
| Officer | The officer home, Complete activation, ICCID, Fallout queue, My tickets |
| Support | The support home, Tickets, Refund requests and Fallout queue (owner), Customers |
| — | Companies, as a plain link |
| — | Notifications, as a plain link |
| System | Plugins, Users, Settings, Audit |
| People | Beneficiaries (company portal) |
| Ordering | Quotes, or Catalog and Selected packages (company portal) |
| Finance | Contract, Billing, Funding (company portal) |
A group with only one visible link renders as that plain link, so a group appears only when the viewer can open at least two of its entries. Activation officers and support staff, who each see a single module, keep a flat list. On the expanded rail a group is a native disclosure that starts open when it holds the current page and otherwise keeps whatever the reader toggled. On the collapsed icon rail a group's icon opens its links in a flyout that Escape closes.
Internal admin screens
- Companies (
admin/companies) — the companies list for internal admins with theb2barea. The status filter opens on Active, which shows approved and pending companies; All and each single status, Archived included, stay one pick away. For the owner and admins holdingcompany-onboarding, a New company invites header link (with the pending count) opensadmin/companies/invites, which lists invited company managers who have not registered their company yet, so they belong to no company (GET /admin/companies/pending-admins); resend and remove needcompany-onboardingManage, and the invite dialog offers a first company manager only with it. A company with no contact email shows an em dash. Company names render bidi-isolated here and on the detail page, so an Arabic or English name keeps its own direction inside the other language's layout. - Company detail (
admin/companies/[companyId]) — a full company detail page, replacing the earlier row-click side sheet. The header carries lifecycle actions beside a contract quota meter, with company-profile and payment rail sections below. The lifecycle actions follow the status: approve or reject a pending company, suspend an approved one, reactivate or archive a suspended one, archive a rejected one, and unarchive an archived one. A viewer holdingb2bManage can edit the CR number, name, contact name, contact email and contact phone in a dialog (PATCH /admin/companies/:id) that keeps the typed values and an inline error after a refused save, such as a CR number another company holds. For the owner and admins holdingcompany-people, a People section lists the company's portal users with their company role (labelled Company manager or Company member) and active state (GET /admin/companies/:id/users); withcompany-peopleManage it offers resend, deactivate or reactivate, and remove through the/admin/users/:idroutes, and disables deactivate and remove on the last active company admin, which the API refuses with409 COMPANY_LAST_ADMIN. Its Invite button, shown withcompany-peopleManage, opens the invite dialog with the company preset and its picker hidden. An archived company is read-only: the edit and invite controls are hidden, and the invite picker elsewhere never offers it. - Staff users (
admin/users) — the staff list for the owner and admins holdingstaff-users; the sidebar entry and the section layout are gated on that area. Add and Manage needstaff-usersManage, and a person's own row is read-only. The access dialog offers None, View or Manage per area, enables only the levels the viewer may grant, locks an area while its parent is missing, and shows the fixed areas of a fixed staff role read-only. Non-owners see masked contacts, and callers withoutstaff-usersManage see no area chips. - Customers (
customers) — the customer search and record screen for readers with thecustomersarea. Search accepts an exact phone, email, ICCID, receipt number, order id, or provider order id, and a record shows the profile, activations, orders and payments, store orders, notifications, identity detail when the viewer also hasidentity-detail, and the viewer's available capabilities. Activation officers and admins with Manage onofficercan resend an issued activation or store-order delivery email from the record. A refund button shows on an activation or store order row only when its order is paid, it carries no live refund request, and the viewer holds support Manage without holding the refund decider role — the owner never sees it. The customer record also carries an audit history panel with load-more pagination. - Support (
support) — for readers with thesupportarea. The landing screen (support) is statistics only: five count tiles (open, in progress, overdue, awaiting first response, and refund requests awaiting a decision), each linking to the ticket list under its own filter, plus shortcut cards to Tickets, Refund requests, and Customer search.support/ticketsis the ticket list itself, with its filters by status, ticket type, overdue/awaiting-response marker, and assignee; the sidebar carries it as Tickets under Support. The table links to a ticket detail screen, which shows the customer, generatedTKT-reference, linked activation/store order state (a linked store order also shows its issuance timeline, read only, fromGET /admin/support/tickets/:id/store-order-events), full comments including internal notes, audit history, assignment for customer requests, public/internal reply controls, status controls, and the refund decision card, which only the refund decider role (the owner) sees. Closed tickets have no write controls; refund comments are internal only. The owner's sidebar also carries Refund requests (the ticket list filtered to open refund requests) and Fallout queue (the whole officer queue) as its own way into this work. - Officer console (
officer) — the activation officer home with failed, stuck, claimed-by-me and store attention counts and links to the completion tool, the fallout queue and officer tickets. Officers never create orders.officer/completeis the counter tool: search a customer by phone or SIM serial, see every paid activation of theirs that is not active (any age in progress, not only stuck ones), and open one to work it.officer/complete/[activationId]andofficer/queue/[activationId]share one detail body (_components/activation-work.tsx): a held-state banner, a facts card (customer, SIM, paid amount and method, when the request was sent, and whether a refund request is already open), the status timeline, and the available actions — claim, release, check with the operator directly when a lookup is usable, retry, resolve (mark activated or close as failed, with required evidence and reason kept in the audit trail only, never shown to the customer), open a support ticket for the customer, and open a refund request. What the row's operator declares it supports (repeat-safe retries, request lookup, line lookup) still decides which of these buttons show, without a card of its own. Resolving or checking never opens a refund request on its own; an officer opens one by hand when it is warranted, and never the viewer who decides refund requests. Company rows are visible but read-only everywhere.officer/queuehas activation and read-only store tabs, each carrying one search field — phone or SIM serial on the activations tab, phone or a store order number on the store tab — narrowed to the same rows the tab already shows the viewer; a failed row with no live refund request shows "No refund request yet". A super admin or an admin holding officer Manage sees the whole queue; an activation officer sees only rows linked to a ticket assigned to them that is still open or in progress, including the ones they hold, so work reaches an officer by assigning them a ticket, which moves the claim on the linked activation to them, rather than through a hand-to-another control.officer/ticketslists the tickets assigned to the officer; a ticket that links a store order shows that order's issuance timeline under it (fromGET /admin/officer/tickets/:id/store-order-events, only while the ticket is assigned to the officer and open or in progress, and unrestricted for a super admin or an admin holding officer Manage); ticket detail allows assigned-ticket comments and resolve, whileofficer/refund-requests/[ticketId]shows the officer's own refund request and decision state.officer/iccidremains the serial validation tool. - Activation records (
admin/activation-records) — the activation dashboard for super admins and admins with theactivation-recordsarea. Its summary row includes failed and stuck attention tiles, with the stuck tile showing the configured age threshold, and the records detail panel masks phone values for non-owner viewers and shows the failure code beside the reason when one is recorded. - eSIM store administration — the API exposes
/admin/store/products,/admin/store/orders,/admin/store/orders/counts,/admin/store/orders/:id,/admin/store/orders/:id/history,/admin/store/orders/:id/requeue,/admin/store/providers,/admin/settings/fx-rates, and/admin/store/sync. The provider, product, sync, and FX-rate routes stay owner only. The order list, detail, counts, and history routes require thestorearea at View level, and requeue requires Manage onstore. No fixed staff role holdsstore. Store providers are operators rows withservice_type = 'esim'.GET /admin/store/providersreturns{ data: [{ id, slug, nameEn, nameAr, isNameArFallback, isActive, paymentMode, health }] }(health is the provider's latest reading,nullfor unregistered rows), andPATCH /admin/store/providers/:idis the audited enable/disable toggle (store.provider_toggled, eSIM rows only,404 STORE_PROVIDER_NOT_FOUNDotherwise) whose effect reaches the destinations aggregate, browse, checkout, and payment initiation in the same request. Admin product lists use the house pagination envelope{ data, total, nextCursor, hasMore }, includeisActiveCustomized, and page byretailUsd|productId. Admin order lists use the same envelope, are newest-first on(createdAt, orderId), expose owneruserId,providerSlug, and the fulfillment fieldsfailedReason/errorCode/issuedAt/issuanceState/externalOrderId, and filter bystuck=true(paid orders that have never been issued),issuanceState,providerSlug,olderThanMinutes, anduserId.GET /admin/store/orders/countsreturns the stuck, needs attention, reconciliation required, failed, and pending verification counts under the provider, age, and customer filters. On an issued order the owner receives the profile fields with a masked activation code, and every other viewer receives only the ICCID suffix. The Store orders screen (admin/store/orders) shows those five counts as tiles that filter the list when clicked, a filter bar for issuance state, provider, age, the stuck toggle, and a customer chip carried over from the customer record, and a table whose rows open an order sheet with the order, product, issuance, and failure groups, a Timeline section of the order's issuance changes (thestatusEventsof the detail route, with a note instead when the order changed only before recording began), above the embedded history panel for staff actions, and a Requeue action for viewers holding Manage onstore(POST /admin/store/orders/:id/requeuere-enqueues a stuck fulfillment job, audited asstore.order_requeued, with409 STORE_ORDER_NOT_REQUEUEABLEwhen the order is not in the stuck state and503 STORE_REQUEUE_UNAVAILABLEwhen the queue is unreachable). Owners can also review provider catalog rows, manage public store overrides, view the live FX rates fetched from the provider (GET /admin/settings/fx-ratesreturns{ base, rates: [{currency, ratePerUsd, fetchedAt, source}] }), and trigger catalog syncs. The companion API reference pages record the Connect-Day Register and Currency & Market model. Manual sync returns202when queued and503 STORE_SYNC_UNAVAILABLEwhen Redis is unavailable. - Audit history (
admin/audit) — the standalone audit viewer for super admins and admins holding theauditarea. It filters the append-only log by entity type, actor, action, and date range, shows the actor and changed fields for each row, and lets the owner expand a row to inspect its before and after values. The same history panel is embedded in the customer record, the Store order sheet, and the company detail page. Those embedded panels load the entity history with a cursor and show changed fields to every viewer, while before and after values are available only to the owner. - Notifications (
admin/notifications): the control center. The sidebar shows it as a Bell entry gated on thenotificationsarea (apps/admin/src/components/shell/nav.ts), the section layout requires that area at View level, and composing a send or cancelling a batch each run their own check for Manage level on top (apps/admin/src/app/[locale]/(portal)/admin/notifications/layout.tsx). A reader who holds View but not Manage sees the batches list with no Compose button and a batch's detail with no Cancel button, and reaching the send screen directly redirects to the access-denied page. The landing screen lists every send, newest first, filtered by status (scheduled, running, finished, cancelled) and category through pills that apply on change, with Reset shown only while a filter is set, and loads more pages on a cursor rather than a page number since the list carries no running total, showing the number of rows loaded so far instead. Rows sit under day headings and each shows the message title with its importance mark, the category and the sender, the recipient count, and the five delivery counts as figures with a proportional bar, with running shown as a neutral badge rather than a warning because it also covers a send waiting on a retry. Clicking anywhere on a row, not only its trailing arrow, opens that batch's detail page.- Batch detail: A batch's detail page (
admin/notifications/batches/[id]) shows the message title and importance in its header with a shortened reference and a copy control, its recalculated counts (sent, delivered, failed, suppressed, pending) as tiles over a proportional bar, re-read every five seconds while the batch is running, a facts column that hides empty timestamps, and a paginated list of its delivery failures, each with the channel, the outcome, the provider, the attempt count, a suppression reason or an error code, and the time it failed, and a missing batch reads as gone rather than as a failed page. Only a Manage-level reader sees the Cancel button, and only while the batch is still scheduled. Cancelling opens a confirm dialog, and once confirmed it either cancels the batch or, if the batch has already started, tells the reader it is already under way rather than showing an error. - Delivery rate: The delivery-rate page (
admin/notifications/delivery-rate) reports outcomes over a time window, bucketed by channel and provider, plus a table of failure reasons, and a page address with no window redirects once to the default window, the last seven days up to the moment the reader opened the page, computed in the reader's own local time. The window is picked with the shared range date picker, future days blocked, and is always read in full days regardless of the moment within a day that was clicked. The channel and provider filters apply on change. A band carries the four window totals with no blended rate, each bucket row carries a rate bar, and the failure reasons sit beside the table, whole, each with a bar scaled to the largest count. - Composer: Composing a send starts at
admin/notifications/send, gated on Manage. The composer takes custom bilingual copy only, with no template selection, and lays the work out as five steps the reader moves through with Back and Next, with a step rail on the start edge and, from the second step, a summary rail on the end edge that carries an edit link back to each earlier step: who gets it, what it says, how it reaches them, when it goes out, and check and send. Next validates only the fields that belong to the step in front of the reader, so a field further along that is still empty never blocks it, and going back always finds the earlier answers intact. The first step sets the audience (everyone, a role, one company, or named recipients). The second step holds the bilingual title and body, each field carrying its own letter counter against a cap, 160 English or 80 Arabic letters for a title and 1000 English or 500 Arabic letters for a body, with the server's own byte limit still the real bound underneath those counts, plus an optional action link, image link and expiry. The third step sets the category and severity, a switch for urgency, and which queued channels to request (in-app always sends and cannot be turned off). Picking a non-transactional category, only Announcement today, for everyone or for the Customer role drops the queued channels to in-app only, the same narrowing the API applies at send time, and the form explains why the switches are disabled. The fourth step is a choice between sending now and setting a time. Setting a time reveals the shared date picker, with a time row, for the scheduled moment, and a card states the moment the send goes out. The fifth step checks the whole draft, with a Change link back to the owning step beside each editable value, and it mints one send key for that exact draft body that a retry or a repeat submission of the same draft reuses, so an attempt the API already accepted replays the first answer instead of creating a second batch. Sending then shows one of three outcomes: an accepted panel, which shows the batch id as a shortened reference with a copy control, the channels the API actually used, the recipient count, and one progress line while the handoff to the workers is still finishing, a conflict, which explains the draft was already used under this key and offers to start a new one, or a failure, which offers a retry under the same key. - Recipient picker: For a named audience the recipient picker offers three kinds of search, because a customer audience is reached through the role or everyone options instead: staff, company, which finds exactly one company and is used both for the company audience and as the first step of a member search, and member, which searches inside one already-picked company because the route scopes that search to a single company. The staff list, and a picked company's member list, appear as soon as the box opens with no term typed, and typing narrows them from there. A company search needs at least two letters before it runs. A list cut at 100 rows says so. Every search returns just an identifier and a name.
- Review and send: Submitting the composer opens a review step, which mints one send key for that exact draft body the moment it opens, and every attempt at that same draft, including a retry after a failure, reuses the same key, so a repeat the API already accepted returns the first attempt's answer instead of creating a second batch. Editing the draft changes its body, which the API treats as a conflict under the old key, so the review step mints a fresh key the moment the body changes. The review shows the channels the API is provisionally expected to use, marked provisional because the authoritative list is whatever the acceptance answer says. Sending shows one of three outcomes: acceptance, which opens the accepted panel with the batch id, the channels the API actually used, the recipient count, and one progress line while the handoff to the workers is still finishing, with a second successful attempt at the same draft shown as a replay of the first rather than a new batch, a conflict, which explains the draft was already used under this key and offers to start a new one, or a failure, which offers a retry under the same key.
- Batch detail: A batch's detail page (
- Notification bell, inbox and settings: shared with the B2B company portal, not admin-specific. See Shared portal screens (both portals) above.
Catalog Sync Engine — a shared policy engine (catalog-sync-engine.ts) drives both the eSIM product sync (sync-esim-products.ts — queue esim-product-sync) and the local package sync (sync-packages.ts — queue package-sync). Each lane implements a thin adapter around the engine with identical policy behavior: per-item validation (zod) with invalid counts, empty-catalog guard, zero-usable-key guard, customization-preserving upserts (CASE WHEN is_*_customized THEN existing ELSE source END), catalog-absence deactivation, per-owner error containment (one operator/provider failing does not abort the rest), and per-owner DB transactions. The local lane gained is_active_customized with the same CASE WHEN semantics as the store lane, preventing the resurrection hazard where a sync silently reactivates an admin-deactivated package. The engine itself imports no domain tables, plugin managers, BullMQ, or env to remain policy-only. The sync adapters refuse slug collisions across service types at runtime. The store lane also persists each item's opaque providerPayload for issuance-time use, requires explicit country coverage on global items (global is never worldwide), and refreshes provider health readings after every run — the data behind the admin providers list and the checkout circuit breaker (422 STORE_PROVIDER_UNAVAILABLE). Store fulfillment runs a provider-agnostic issuance state machine (issuance_state with in-flight stamping, classified provider errors, reconciliation instead of blind re-issue, and async KYC verification states with a customer verificationUrl). Every recorded fulfillment failure stores an errorCode beside failedReason, and both clear on the healthy paths. The code is a triage label and drives no retry or state change (see Issuance Lifecycle). Each change of the issuance state or the error code also adds a store_order_status_events row in the same transaction, written through the database package's transitionStoreOrder with each write's existing condition. eSIM provider registration is configuration-based via ESIM_STORE_PROVIDERS.
Activation lane — every status write on a local activation goes through the lifecycle functions in packages/database/src/activation/lifecycle.ts, which check the shared transition map and write a status event in the same transaction. The activation queue runs two steps. identity_poll reads the operator's answer for one identity attempt on the timing from the operator plugin's identityCheck policy. complete calls the operator once identity and payment are settled. A recurring recovery scan on the same queue settles activations whose queue work was lost or left behind and replays missed failure notifications. Every committed status change, from the API or the worker, is handed once to afterActivationChange in apps/api/src/services/activation-dispatch.ts, which sends the Realtime broadcast, the completion notification and eSIM delivery email, or the officers' failure notification. A failed activation carries an errorCode beside failedReason. See Activations API.
Notification runtime
The notification module lives in apps/api, with shared contracts and queue workers in the existing packages. See Notifications API for request shapes and delivery semantics.
| Method | API Surface | Access | Purpose |
|---|---|---|---|
| GET | /notifications | Authenticated owner | Paginated visible inbox |
| GET | /notifications/count | Authenticated owner | Unread count |
| GET | /notifications/stream | Authenticated owner | Change hints for the reader's inbox over server-sent events |
| POST | /notifications/:id/read | Authenticated owner | Mark one item read |
| POST | /notifications/read-all | Authenticated owner | Mark visible unread items read |
| POST | /notifications/seen | Authenticated owner | Mark selected items seen |
| POST | /notifications/:id/archive | Authenticated owner | Archive one item |
| GET | /notifications/opt-outs | Authenticated owner | Read channels and opt-outs |
| PUT | /notifications/opt-outs | Authenticated owner | Replace opt-outs |
| POST | /admin/notifications | super_admin or notifications area (manage) | Immediate or scheduled sends |
| GET | /admin/notifications/recipients | super_admin or notifications area (view) | Recipient picker search for the composer |
| GET | /admin/notifications/batches | super_admin or notifications area (view) | Filtered batch list |
| GET | /admin/notifications/batches/:id | super_admin or notifications area (view) | Batch counts and paginated failures |
| POST | /admin/notifications/batches/:id/cancel | super_admin or notifications area (manage) | Cancel before execution |
| GET | /admin/notifications/delivery-rate | super_admin or notifications area (view) | Delivery-rate report |
| GET | /push/config | Public | Active push provider and its public app id, for the mobile app |
| POST | /webhooks/notifications/:channel/:provider | Verified callback signature | Email and SMS reports from adapters with verification and parsing support |
The admin app reaches /notifications/stream through its own route handler at /api/notifications/stream, which forwards the session cookie because the browser holds none for the API origin.
src/index.ts mounts the inbox and admin routes. src/routes/webhooks.ts mounts the callback routes. src/startup-workers.ts starts the channel and orchestration workers with the API's renderer, locale resolver, sender, batch runner, and callback handler. It also schedules the handoff sweep and closes the notification workers and queues during shutdown.
| Queue | Owner | Work |
|---|---|---|
activation | packages/queue | Operator activation steps (identity_poll, complete) and the recurring recovery scan |
payment | packages/queue | Payment settlement |
moyasar-reconciliation | packages/queue | Server-side verification of Moyasar and Paymob webhook events (the original Moyasar queue, now shared by both gateways) |
store-fulfillment | packages/queue | Store fulfillment and recovery |
notification-email | packages/queue | Email delivery with its own retry budget and rate limit |
notification-sms | packages/queue | SMS delivery with its own retry budget and rate limit |
notification-push | packages/queue | Push delivery with its own retry budget and rate limit |
notification-orchestration | packages/queue | Scheduled batch runs, handoff reconciliation, and callback processing |
package-sync | apps/api/src/queues | Telecom package sync |
esim-product-sync | apps/api/src/queues | eSIM catalog sync |
fx-refresh | apps/api/src/queues | Exchange-rate refresh |
Email, SMS and push jobs carry notification and delivery IDs plus the channel. They do not carry recipient addresses, device tokens or message bodies. In-app delivery is recorded by the trigger without a queue. Activation status tracking remains separate from notification delivery.
| Package | Notification exports |
|---|---|
@activation-sys/shared | Trigger and job types, inbox and admin request/response schemas, callback schemas, event and channel registries, queue names, retry policies, content limits, and bilingual notification errors |
@activation-sys/database | notifications, notificationDeliveries, notificationChannelOptouts, notificationBatches, notificationProviderWebhookEvents, and their relation definitions |
@activation-sys/queue | Email, SMS, and orchestration queues, channel enqueue helpers and counts, batch enqueue and removal, handoff scheduling, worker start/stop functions, and queue cleanup |
Activation completion, payment success, admin invitations, company approval, contract readiness, and both activation and store eSIM QR emails all use the notification trigger with a dedupe key per event occurrence. OTP, magic-link delivery, and the email test send remain direct calls.
Each channel sends through its service's active provider, picked in Plugins (see Service providers). SMTP email records acceptance as sent, not delivered. Admin sends return 202 for acceptance. A finished batch means dispatch has finished, not that every message was delivered.
Turbo and workspace mapping
pnpm-workspace.yamlincludesapps/web-b2candapps/siteso pnpm resolves them as first-class workspaces.turbo.jsonbuild outputs include.next/**,dist/**, and.vitepress/dist/**for Next.js, Vite, and docs builds.- Root scripts expose filtered commands:
pnpm dev:web-b2c,pnpm dev:site,pnpm build:web-b2c, andpnpm build:site. pnpm devstill runs every workspacedevtask through Turbo, so fixed ports prevent app collisions.
Local port map
| Port | Surface |
|---|---|
3000 | Admin/B2B Next.js app |
3001 | API |
3002 | B2C web Next.js app |
3003 | Landing site Vite app |
Environment keys
Frontend public configuration is documented in .env.example:
NEXT_PUBLIC_API_BASE_URLfor Next.js browser-facing API calls.NEXT_PUBLIC_SITE_URLfor links from Next.js apps back to the public site.VITE_API_BASE_URLfor Vite browser-facing API calls.VITE_B2C_WEB_URLfor the landing site CTA into the B2C web app.VITE_SITE_URLfor deployment-specific landing links.
The API CORS allowlist should include the deployed origins for apps/admin, apps/web-b2c, and apps/site when those surfaces call the API directly.
Product boundaries
- B2C activation logic remains server-owned through
apps/api; clients must not submit trusted ownership fields. - eSIM activation requests must send
simType: "esim"; the API rejects eSIM attempts whenFEATURE_ESIMis off (ACTIVATION_ESIM_DISABLED) or when the resolved operator plugin did not explicitly declaresupportsEsim: true(ACTIVATION_ESIM_UNSUPPORTED_OPERATOR). - Public eSIM store paginated product browse uses
{ data, total, nextCursor, hasMore, currency }; the destinations aggregate (GET /store/destinations) is intentionally unpaginated{ data, currency }(bounded by distinct destinations); owner/admin store lists use{ data, total, nextCursor, hasMore }. Store-order cursors preserve full database timestamptz precision ascreatedAt|orderId. Admin order list paging follows the samecreatedAt|orderIdkeyset and surfaces the stuck filter via?stuck=true(status = paid AND issued_at IS NULL); admin order rows surfaceuserIdandfailedReasonfor triage. - Device-compatibility advisory list —
GET /store/device-compatibilityserves a versioned hard-coded constant of eSIM-capable phone models (public, ungated by the store flag so any surface can use it; matching is client-side, exact beats prefix, unknown devices warn and never block). - Store lane charges are in USD. The local Saudi activation lane continues to charge in SAR. Store order payments (
payments.currency = 'USD') and charges (chargeAmount.currency = 'USD') are always USD-denominated. Paymob charges SAR only, so a USD checkout while Paymob is live returns422 PAYMENT_CURRENCY_UNSUPPORTED. Receipt responses carry the payment's actual currency in thecurrencyfield while preserving theamountSarwire alias for mobile compatibility (the field name is historical — its value is the payment amount in the payment's currency, which may be SAR or USD). - User-visible B2C and site copy must keep Arabic and English parity.
- Security-sensitive operations, payment side effects, identity verification, eSIM store fulfillment, and activation state transitions stay in API/services/queues.
- Admin and B2B workflows continue to live in
apps/adminunless a future split is explicitly planned.