Skip to content

Full System Build Plan

This document is the canonical reference for what has been built, what is in progress, and what comes next — covering the API, customer web app, agent portal, internal admin app, and all infrastructure / deployment for every environment.

Update the status column as work is completed.


Status legend

SymbolMeaning
Complete
🔨In progress
Not started

The API is a Fastify + TypeScript + Drizzle ORM backend. Modules are listed below. A module is considered complete when it has routes, a service layer, input validation schemas, and is covered by the documented endpoint spec.

Modules

ModuleStatusRoutes fileService fileNotes
authLogin, signup, verify email, reset password, OAuth, magic link, refresh tokens
accountsList orgs, get/update current org, create org, switch org
contactsCRUD, lists, import (MNO validation), export, tags, bulk, trash
usersGet/update profile, avatar upload, team management (invite, role, remove)
sender-idsFolder scaffolded but empty
billingFolder scaffolded but empty
messagingFolder scaffolded but empty — campaign send, DLR processing
pricingFolder scaffolded but empty
dlrFolder scaffolded but empty — delivery receipt webhooks from Celcom
campaignsNot yet scaffolded
analyticsNot yet scaffolded
api-keysNot yet scaffolded
webhooksNot yet scaffolded
adminStaff auth (login, logout, refresh token); separate JWT namespace (ADMIN_JWT_SECRET); admin_staff table with role, hashed password, login attempt tracking
agentsNot yet scaffolded — agent portal endpoints (sub-customer management, per-customer pricing)

Database migrations

MigrationStatusDescription
0000Base tables: accounts, users, contacts, messages, campaigns, credits, pricing_rates, sender_ids, api_keys, webhook_endpoints
0001auth_tokens — email verification, password reset, magic link
0002refresh_tokens — session management
0003oauth_accounts — Google, Microsoft, GitHub, Apple
0004contact_lists + contact_list_memberships junction table
0005credit_transactions ledger table
0006mno_prefixes lookup table
0007account_members junction table (multi-org support)
0008Business profile fields on accounts (address, city, state, zip)
0009valid_count + invalid_count columns on contact_lists
0010Schema audit: is_active on account_members, user_id NOT NULL on auth_tokens, channel/sender_id_status/consent/currency/country columns
0011Seed data: 151 MNO prefix rows across 53 African countries
0012admin_staff table — staff accounts, hashed passwords, role enum, login attempt tracking, account lockout
0013phone column on admin_staff
Sender IDs table extensionAdd fields needed for full sender ID workflow (submitted_at, reviewed_by, rejection_reason)
Campaigns table extensionAdd fields: scheduled_at, sent_at, recipient_count, delivered_count, failed_count
Agent tablesagent_accounts, agent_customers junction, per-customer pricing overrides
API keys tableAlready in 0000 base — verify schema is sufficient or extend
Webhook endpoints tableAlready in 0000 base — verify schema is sufficient or extend
Webhook delivery log tableNew table needed: webhook_deliveries (attempt history)

Infrastructure / deployment (API-specific)

See Part 3 for the full infrastructure breakdown. API-specific notes:

  • Object storage: src/lib/r2.ts is fully implemented — avatarKey(), logoKey(), kycKey(), getPresignedDownloadUrl()
  • Email: src/lib/email.ts uses Mailgun; fully wired to auth flows
  • Queue: src/queue/ scaffolded — RabbitMQ connection initialised but no producers or consumers implemented yet
  • Redis: src/redis/ initialised and connected; used for token caching and rate limiting

Phase 1 — Foundation

FeatureStatusNotes
Auth — login, signup, logoutEmail/password + Google/GitHub/Microsoft OAuth
Auth — email verificationBanner gates write actions until verified
Auth — forgot/reset password
Auth — magic link
Auth — refresh token rotation
Multi-org — create org, switch orgaccount_members junction table
Settings — business profileName, timezone, country, address
Settings — personal profileName, phone, avatar upload (R2)
Settings — security (password change)
Settings — notificationsEmail/in-app prefs for low credits, campaign done, sender ID status changes
Settings — MFA (TOTP)Authenticator app setup + backup codes
Settings — active sessionsList and revoke sessions
Contacts — list managementCreate, edit, delete lists
Contacts — add/edit/delete contactCountry code picker + libphonenumber validation
Contacts — CSV import wizardColumn mapping, MNO validation, valid/invalid breakdown
Contacts — export (CSV/Excel/PDF, email)
Contacts — tags, search, filter
Contacts — trash + restore
Team — invite memberRole-based: owner/admin/member
Team — edit role / remove member
Dashboard — stat cardsBalance, messages sent, delivery rate, active campaigns
Dashboard — SMS activity chart7-day sent/delivered/failed
Dashboard — quick setup checklist

Phase 2 — Core Sending Flow

These form a strict dependency chain. Nothing can be sent without completing them in order.

2a — Sender IDs

A business must register a sender ID before it can send any campaign. Sender IDs require admin approval before they become active.

FeatureStatusNotes
List registered sender IDsName, country, status badge (pending/approved/rejected/suspended)
Register new sender IDForm: display name, country, use-case description
Delete / withdraw sender IDOnly permitted while status is pending
Status change notificationsIn-app banner + email when approved or rejected

API endpoints needed: GET /sender-ids, POST /sender-ids, DELETE /sender-ids/:id


2b — Billing & Credits

A business needs a credit balance before it can send campaigns.

FeatureStatusNotes
Credit balance displayShown on dashboard stat card with link to /billing
Transaction historyPaginated ledger: top-ups, campaign deductions, refunds
Top-up — M-Pesa STK pushEnter amount → STK push to registered phone → confirm
Top-up — card (Stripe or Flutterwave)
Low-credit alertConfigurable threshold; in-app banner + email notification
Invoices / receiptsDownloadable PDF per top-up transaction
Pricing tablePer-country SMS rate display so users know cost before sending

API endpoints needed: GET /credits, GET /credits/transactions, POST /credits/topup, GET /credits/pricing


2c — Campaigns

The core product feature.

FeatureStatusNotes
Campaign list viewName, status, sent count, delivery rate, scheduled time
Create campaignName → sender ID → recipient list(s) → message → schedule or send now
Message composerCharacter counter (160/SMS), multi-part SMS indicator, personalisation tokens ( etc.)
Campaign previewPreview rendered message for a sample contact
Send now vs scheduleDatetime picker for scheduled sends
Campaign detail / reportPer-campaign: sent, delivered, failed, opted-out breakdown
Pause / cancel scheduled campaign
Duplicate campaign
Draft campaignsSave without sending

API endpoints needed: GET /campaigns, POST /campaigns, GET /campaigns/:id, PATCH /campaigns/:id, POST /campaigns/:id/send, DELETE /campaigns/:id


Phase 3 — Analytics

FeatureStatusNotes
Overview statsTotal sent, delivery rate, opt-out rate, cost — date range picker
Delivery trend chartSent vs delivered vs failed over time
Per-campaign tableSortable by delivery rate, cost, date
Per-country breakdownWhere messages are going, cost by country
Export analyticsCSV download

Phase 4 — Developer Experience

FeatureStatusNotes
API keys — listName, created date, last used, permissions scope
API keys — create / revoke
Webhooks — register endpointURL, secret, event types
Webhooks — event typesmessage.delivered, message.failed, contact.opted_out, credit.low
Webhooks — delivery logLast N delivery attempts, HTTP status, response body
Webhooks — test fireSend a sample payload to the registered endpoint
API playground / docs linkLink out to public API docs

Staging environment is live at staging-admin.semalink.africa. Separate Vite + Vue 3 + TypeScript app; production deploys when the app is feature-complete.

Architecture decisions (locked)

Authentication — two-layer

  • Layer 1: Cloudflare Zero Trust — Google SSO, restricted to @semalink.africa domain. Staff cannot reach the login page without passing this first.
  • Layer 2: Username/password against the admin DB. Staff enter their own admin credentials after Zero Trust clears them.
  • Net effect: effectively 2FA without building TOTP. Stolen password alone is useless without the Google account.
  • No self-signup. Accounts are created by an owner only.
  • First account: evanson@semalink.africa seeded as owner via migration/seed script. Forced password change on first login.
  • Brute-force protection: stricter rate limiting on /admin/auth/login + account lockout after N failed attempts.

Permission model — three levels per module

owner  >  admin  >  read  |  none
LevelCan do
ownerAll actions including destructive/irreversible ones (delete org, change wholesale pricing, manage staff accounts)
adminDay-to-day operational actions (approve sender IDs, adjust credits, view all data)
readView only — no mutations
noneModule not visible in launcher

Permissions are stored in a staff_permissions table: (staff_id, module, level). Enforced at both the UI (hide/disable actions) and the API (/admin/<module>/* route middleware checks the JWT's permission scope).

Modular launcher (hub-and-spoke)

On login, staff land on a home page showing all modules as cards. Modules the staff member has no access to show as greyed-out "coming soon". Modules they have access to are clickable and navigate into that mini-app.

Top bar features:

  • cmd+k quick-jump: type "sender" → jumps to Sender IDs module
  • Grid icon (Google-style): dropdown panel with links to Customer App, Agent Portal, Docs

Cross-module actions — atomic

Actions that logically touch multiple modules (e.g. suspending an org affects the org record, freezes credits, and flags messages) are owned entirely by one module. CRM owns org suspension. No cross-module orchestration in the UI — the API handles side effects internally.

Impersonation — read-only

Staff can "view as" any customer org from the CRM module:

  • Issues a temporary read-only token scoped to that org
  • Staff are redirected to app.semalink.africa with a persistent orange banner: "Viewing as Acme Corp — Exit"
  • Every page view during impersonation is logged: staff:evanson impersonated account:acme-corp
  • Exit button returns staff to admin and revokes the impersonation token
  • Permission: admin or above in CRM module

Audit log — append-only

  • No DELETE or UPDATE endpoint on audit_log — ever
  • DB-level: Postgres role used by the API has INSERT-only on audit_log table
  • Every admin action writes a row: (staff_id, module, action, target_type, target_id, metadata, created_at)
  • Surfaced in its own read-only Audit Log module

Staff notifications

In-app + email alerts for actionable events:

  • New sender ID submitted → staff with sender-ids: admin+ notified
  • Credit top-up request above threshold → owner notified
  • Org suspended → all owners notified
  • Failed SMS spike → Engineering module viewers notified

Modules

ModulePermission neededAPI prefixStatus
CRMread+/admin/crm
Sender IDsread+/admin/sender-ids
Billingadmin+/admin/billing
Pricingowner/admin/pricing
Agent Manageradmin+/admin/agents
Campaignsread+/admin/campaigns
Engineeringread+/admin/engineering
Audit Logread+/admin/audit
Staffowner/admin/staff

Phase 1 — Shell + Auth

FeatureStatusNotes
Repo scaffold (semalink-admin)Vite + Vue 3 + TypeScript + Tailwind, same stack as frontend
Cloudflare Pages project (staging)semalink-admin-stagingstaging-admin.semalink.africa
DNS staging-admin.semalink.africaCNAME → semalink-admin-staging.pages.dev
GitHub Actions deploy workflow (staging)Push to staging branch → build → Cloudflare Pages → Slack notify
Login page (username/password)No self-signup; seeded owner account
Staff auth API (/admin/auth/*)Login, logout, refresh token; separate JWT namespace (ADMIN_JWT_SECRET)
Seed first owner accountevanson@semalink.africa, role owner, seeded on staging DB
Launcher home pageModule cards grid; greyed-out coming-soon state for unbuilt modules
Cloudflare Zero Trust applicationadmin.semalink.africa, restricted to @semalink.africa Google accounts (for prod)
Cloudflare Pages project (production)semalink-adminadmin.semalink.africa
DNS admin.semalink.africaCNAME → Pages (for prod)
Permission middleware (API)JWT carries permissions: { crm: 'admin', billing: 'read', ... }; route guards check level
cmd+k quick-jumpFuzzy search across module names and common actions
App switcher (top bar grid icon)Links to Customer App, Agent Portal, Docs
Staff notifications (in-app)Bell icon, unread count, notification feed

Phase 2 — CRM Module

FeatureStatusNotes
Org list — search, filter by statusAll accounts with member count, balance, plan, status
Org detail — overviewProfile, members, balance, active sender IDs, recent campaigns
Org suspend / reactivateAtomic: freezes credits + flags messages; owner/admin only
Org deletePermanent, owner only, requires typed confirmation
User list across all orgsSearch by email/name, filter by role
User detailProfile, org memberships, login history
User disable / re-enableBlocks login without deleting data
Impersonate orgRead-only token, orange banner, full audit trail; admin+ only

Phase 3 — Sender IDs Module

FeatureStatusNotes
Approval queuePending requests sorted by submitted date
Approve sender IDWith optional note; triggers status-change notification to org
Reject sender IDRequired rejection reason; triggers notification
Sender ID history per orgAll submissions with status history
Bulk approveSelect multiple, approve in one action

Phase 4 — Billing Module

FeatureStatusNotes
Credit balance per orgCurrent balance, payment model (prepay/postpay)
Full transaction ledgerAll top-ups, deductions, adjustments across all time
Manual top-upAdd credits with amount + reason/reference; owner/admin only
Manual adjustment / refundDeduct or refund credits; full audit entry written
Credit limit managementSet postpay credit limit per org; owner only
Low-balance alerts configSet per-org alert threshold

Phase 5 — Pricing Module

FeatureStatusNotes
Per-country SMS rate tableCurrent rate, effective date, history
Set / update rateOwner only; effective date picker (future-dated changes)
Agent wholesale rate per agentOverride global rate for specific agents
Celcom cost referenceInternal record of wholesale buy rate for margin visibility

Phase 6 — Agent Manager Module

FeatureStatusNotes
Agent listAll agent accounts, balance, sub-customer count, volume
Agent detailSub-customers, per-customer pricing set by agent, usage
Agent suspend / reactivateBlocks all sub-customers' sends
Sub-customer visibilityRead-only view of agent's customer accounts and their rates

Phase 7 — Engineering Module

FeatureStatusNotes
RabbitMQ queue depthLive view of each queue: message count, consumer count
Failed SMS queueList failed messages, reason, retry count; manual retry trigger
DLR (delivery receipt) backlogUnprocessed delivery receipts from Celcom
API health statusResponse time, error rate, uptime
Migration historyList applied migrations, last run timestamp
Recent errorsLast N API errors with stack traces (from log aggregator)

Phase 8 — Audit Log Module

FeatureStatusNotes
Full audit log viewerAll staff actions, filterable by staff / module / action / date
Export audit logCSV download for compliance

Phase 9 — Staff Module

FeatureStatusNotes
Staff listName, email, role, last login, module permissions
Invite staffEmail invite; account inactive until first login + password set
Edit staff permissionsSet per-module permission level; owner only
Remove staffRevokes all tokens immediately; owner only
Staff login historyIP, device, timestamp for each login

Not yet started. A separate Vite + Vue app at agents.semalink.africa. Agents are resellers who onboard their own sub-customers onto the Sema Link platform. The agent owns the customer relationship; Sema Link provides the infrastructure.

How it fits into the billing model:

  • Sema Link bills the agent at a wholesale per-SMS rate (set in the Admin App)
  • The agent independently sets their own per-SMS rate for each of their sub-customers
  • Sub-customers send SMS and use the platform exactly like direct customers; the cost draws from the agent's Sema Link balance
  • If the agent's balance reaches zero, all their sub-customers' sends are blocked

Phase 1 — Core Agent Features

FeatureStatusNotes
Agent auth (login, password reset)Separate auth flow; agents are a distinct account type
Dashboard — balance, usage summaryAgent's own Sema Link credit balance + this month's total sends across all sub-customers
Sub-customer management — listAll customers under this agent with status, usage, balance
Sub-customer management — createAgent creates a new customer account; customer receives invite email
Sub-customer management — deactivateBlock a customer's sends without deleting their data
Per-customer pricingAgent sets per-SMS rate per sub-customer (visible to Sema Link admin)
Per-customer usage reportSMS volume and spend per customer, per day/month
Top-up — M-Pesa or bank transferAgent tops up their own Sema Link balance
Invoices from Sema LinkPDF billing statements from Sema Link to the agent

Phase 2 — Advanced Agent Features

FeatureStatusNotes
Sub-customer sender ID statusAgent sees approval status for their customers' sender IDs (read-only; approval is done by Sema Link admin)
Agent-level analyticsAggregate delivery rates, volume trends, cost breakdown across all sub-customers
Postpay credit limit managementAgent can set per-customer send limits (independent of Sema Link's credit limit on the agent)
Bulk sub-customer importCSV upload to onboard many customers at once

API module needed: agents (not yet scaffolded)


Part 3 — Infrastructure & Deployment

This section tracks every piece of infrastructure across all three environments: staging, test, and production. Staging is the furthest ahead; test and production have not been provisioned yet.

Environment overview

LayerStagingTestProduction
Frontend URLstaging-app.semalink.africatest-app.semalink.africaapp.semalink.africa
API URLstaging-arc.semalink.africatest-arc.semalink.africaarc.semalink.africa
Agent Portal URLagents.semalink.africa
Admin URLadmin.semalink.africa
API hostDigitalOcean droplet (FRA1)DigitalOcean droplet (TBD)DigitalOcean droplet(s) (TBD)
DatabaseNeon — staging branchNeon — test branchNeon — main branch
RedisUpstash Frankfurt (free)Upstash Frankfurt (free)Upstash Frankfurt (paid)
QueueRabbitMQ in DockerRabbitMQ in DockerRabbitMQ in Docker (or CloudAMQP)
Object storageR2 — staging bucketR2 — test bucketR2 — production bucket
Frontend hostCloudflare PagesCloudflare PagesCloudflare Pages

3a — PostgreSQL (Neon)

Neon uses a branching model. Each environment is an isolated database branch that shares the base schema but has separate data. Branch from main when you want a schema-identical copy with clean data.

TaskStatusNotes
Create staging branchFrankfurt (eu-central-1), pooler URL in use
Connect API to staging DBDATABASE_URL injected via GitHub secret
Create test branchBranch from main; get pooler URL from Neon dashboard
Create production branch (or use main)main is the production branch in Neon by default
Configure test DATABASE_URL secretGitHub Environment: test
Configure production DATABASE_URL secretGitHub Environment: prod
Upgrade Neon plan for productionFree tier has limited compute hours and connections; upgrade before launch
Set max pool size in API configKeep ≤ 10 connections per instance to stay within Neon limits; use pooler URL

Key notes:

  • Always use the -pooler hostname in DATABASE_URL (Neon's PgBouncer) — reduces connection count from many to few
  • sslmode=require&channel_binding=require must be present on all connection strings
  • Migrations run automatically on container startup via drizzle-kit migrate — no separate migration step in CI

3b — Redis (Upstash)

Redis is used for caching, session/rate-limit state, and short-lived tokens.

TaskStatusNotes
Create staging Redis instanceUpstash Frankfurt, free tier, TLS
Connect API to staging RedisREDIS_URL (rediss://...) injected via GitHub secret
Create test Redis instanceNew Upstash database in Frankfurt; note different hostname
Create production Redis instanceUpstash paid tier — higher data limit, no LRU eviction
Configure test REDIS_URL secretGitHub Environment: test
Configure production REDIS_URL secretGitHub Environment: prod
Set eviction policy for productionFree tier uses LRU eviction silently; paid tier allows noeviction

Key notes:

  • Always use rediss:// (double-s) — plain redis:// connections are rejected by Upstash
  • Port 6379 is Upstash's TLS port — do not use 6380
  • For production, consider DigitalOcean Managed Redis as an alternative if Upstash latency from the droplet is noticeable

3c — RabbitMQ (Docker, on-droplet)

RabbitMQ runs as a Docker container on the same droplet as the API. No managed service is used for staging/test.

TaskStatusNotes
RabbitMQ running on staging dropletrabbitmq:3.13-management-alpine, non-default user semalink
RABBITMQ_PASS injected via GitHub secret32+ random chars
API connects via amqp://semalink:${PASS}@rabbitmq:5672Internal Docker network DNS
Add named rabbitmq_data volumeKnown issue — queue state lost on container destroy; fix before real workers land
Add API healthcheck to ComposeKnown issue — Caddy forwards traffic before API is ready; see api-ops.md
Document RabbitMQ password rotation runbookKnown issue — rotating RABBITMQ_PASS requires manual volume wipe; see api-ops.md
Provision RabbitMQ on test dropletSame setup as staging — part of new droplet provisioning
Provision RabbitMQ on production dropletConsider CloudAMQP (managed) for durability and HA in production
Add resource limits to Docker ComposeAPI: 1 GB, RabbitMQ: 512 MB — prevents OOM kill cascade on 4 GB droplet
Add message queue workersNo workers deployed yet; RabbitMQ is running but queues are empty

3d — Cloudflare R2 (Object Storage)

R2 is used for user avatar and logo uploads. KYC document storage will use separate private (presigned URL) access.

TaskStatusNotes
dev bucket createdPublic r2.dev URL enabled; used for local development
staging bucket createdPublic r2.dev URL enabled
test bucket createdPublic r2.dev URL enabled
production bucket createdPublic r2.dev URL enabled
R2 credentials in local .envR2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME=dev, R2_PUBLIC_URL
R2 credentials in staging GitHub secretsR2_BUCKET_NAME=staging, R2_PUBLIC_URL pointing to staging r2.dev URL
R2 credentials in test GitHub secretsAdd R2_BUCKET_NAME=test, R2_PUBLIC_URL, shared access key secrets
R2 credentials in production GitHub secretsAdd R2_BUCKET_NAME=production, R2_PUBLIC_URL
Custom domain for production R2Replace r2.dev URL with assets.semalink.africa via Cloudflare custom domain on R2
KYC document bucket (private)Requirements not yet defined — separate bucket, no public URL, presigned download only
Directory layout enforced in codeavatars/, logos/, kyc/ prefixes; helpers in src/lib/r2.ts

3e — Cloudflare Pages (Frontend Hosting)

Both the customer web app and (future) admin app are hosted on Cloudflare Pages. Each environment is a separate Pages project.

TaskStatusNotes
Pages project semalink-app-stagingDeploys on push to staging branch
Pages project semalink-app-testWorkflow file exists (deploy-test.yml), project needs creating in Cloudflare dashboard
Pages project semalink-app (production)Deploys on push to prod branch
Pages project semalink-agent-stagingFor agent portal — needs creating once agent app repo exists
Pages project semalink-agent (production)
Pages project semalink-admin-stagingDeployed at staging-admin.semalink.africa; deploy on push to staging branch
Pages project semalink-admin (production)
CLOUDFLARE_API_TOKEN secret setRepository-level secret, used by all Pages workflows
CLOUDFLARE_ACCOUNT_ID secret setRepository-level secret
VITE_API_BASE_URL per environmentStaging = https://staging-arc.semalink.africa, Prod = https://arc.semalink.africa
VITE_CF_ACCESS_CLIENT_ID/SECRET per environmentCloudflare Zero Trust service tokens for API access
Test environment GitHub secrets populatedVITE_API_BASE_URL=https://test-arc.semalink.africa + CF Zero Trust test tokens
Production environment GitHub secrets verifiedConfirm all secrets are set before first prod API deploy

3f — Cloudflare DNS

All DNS is managed in Cloudflare for semalink.africa.

RecordTypeStatusNotes
semalink.africa (apex)CNAME → PagesMarketing website
app.semalink.africaCNAME → PagesCustomer app production
staging-app.semalink.africaCNAME → PagesCustomer app staging
test-app.semalink.africaCNAME → PagesCustomer app test — add when Pages project created
staging-arc.semalink.africaA → droplet IPAPI staging (209.38.197.79)
test-arc.semalink.africaA → test dropletAdd when test droplet provisioned
arc.semalink.africaA → prod dropletAdd when production droplet provisioned
staging-admin.semalink.africaCNAME → PagesAdmin app staging — semalink-admin-staging.pages.dev
agents.semalink.africaCNAME → PagesAgent portal — add when agent app is built
admin.semalink.africaCNAME → PagesAdmin app production — add when prod deploy workflow is ready
mailer.semalink.africaMX + TXT (Mailgun)Transactional email sending domain
assets.semalink.africaCNAME → R2 custom domainProduction R2 custom domain (replaces r2.dev URL)

Cloudflare SSL mode: All API origin records (arc, staging-arc, test-arc) use Full SSL — Caddy on the droplet presents a self-signed cert (via tls internal). Do not switch to Full (strict) without replacing with a real cert first.


3g — Cloudflare Zero Trust (API Access Control)

The API is not publicly accessible — Cloudflare Zero Trust sits in front of all API origin records.

TaskStatusNotes
Zero Trust application for staging APIstaging-arc.semalink.africa — service token auth
Service token for staging frontendVITE_CF_ACCESS_CLIENT_ID/SECRET in staging GitHub environment
Zero Trust application for test APICreate new application for test-arc.semalink.africa
Service token for test frontendGenerate and add to test GitHub environment secrets
Zero Trust application for production APICreate application for arc.semalink.africa
Service token for production frontendGenerate and add to prod GitHub environment secrets
Zero Trust application for admin appadmin.semalink.africa — restrict to staff email domain only (not service token)
Staff identity provider configuredGoogle Workspace or GitHub org for staff SSO into admin app

3h — API Deployment — Staging

TaskStatusNotes
Droplet provisioned (2 vCPU / 4 GB, FRA1)209.38.197.79
Docker + Compose V2 installed
deploy user created, added to docker group
GitHub Actions SSH key deployedED25519 key in deploy authorized_keys
Repo cloned to /opt/semalink-api
Caddy reverse proxy runningtls internal, port 80 + 443
RabbitMQ runningManagement UI on port 15672
API container runningPort 3000 internal only
GitHub Actions workflow (deploy-staging.yml)Triggers on push to staging branch
All GitHub secrets populatedDATABASE_URL, REDIS_URL, RABBITMQ_PASS, JWT_SECRET, JWT_REFRESH_SECRET, MAILGUN_, CELCOM_, R2_*
Health endpoint respondinghttps://staging-arc.semalink.africa/health{"status":"ok"}
Fix: add rabbitmq_data named volumeQueue state now survives container destroy
Fix: add API healthcheck in Composewget -qO- http://127.0.0.1:3000/health; Caddy waits for service_healthy
Fix: document RabbitMQ password rotation runbookSee api-ops.md — manual volume-wipe procedure documented
Add container resource limitsAPI: 1 GB RAM, RabbitMQ: 512 MB RAM

3i — API Deployment — Test

Nothing has been done yet. Steps mirror staging exactly.

TaskStatusNotes
Provision DigitalOcean dropletSame spec as staging: 2 vCPU / 4 GB, Frankfurt, Ubuntu 24.04
Run one-time droplet setupDocker, deploy user, SSH key, repo clone — see API Staging Setup
Create Neon test database branchBranch from main in Neon dashboard
Create Upstash test Redis instanceNew database in Frankfurt
Create GitHub Environment testSeparate from staging environment
Populate all GitHub secrets for testDATABASE_URL (test branch), REDIS_URL, RABBITMQ_PASS (new random), JWT_SECRET, JWT_REFRESH_SECRET, MAILGUN_, CELCOM_, R2_* (test bucket), TEST_DROPLET_IP, TEST_SSH_KEY
Create docker-compose.test.ymlCopy from staging; no changes needed — differences are in env vars only
Create deploy-test.yml GitHub Actions workflowCopy from deploy-staging.yml; change branch to test, environment to test, droplet secret names
Add DNS record test-arc.semalink.africaA record → new droplet IP in Cloudflare
Create Zero Trust application for test APItest-arc.semalink.africa, generate service token
Add service token to test GitHub environmentVITE_CF_ACCESS_CLIENT_ID + VITE_CF_ACCESS_CLIENT_SECRET
Test deploy — push to test branchVerify pipeline runs and health endpoint responds

3j — API Deployment — Production

Nothing has been done yet. Production requires more care than staging/test.

TaskStatusNotes
Decide on production droplet specMinimum: 4 vCPU / 8 GB RAM ($48/mo); consider 2× droplets behind a load balancer for HA
Provision production DigitalOcean droplet(s)Frankfurt region (closest to primary markets)
Run one-time droplet setupSame as staging; see API Staging Setup
Set up DigitalOcean FirewallAllow only: 80, 443 inbound; restrict 15672 (RabbitMQ management) to known IPs only
Configure Neon production connectionUse main branch; upgrade to paid plan for higher compute hours and connections
Create Upstash production RedisPaid tier (noeviction policy); or DigitalOcean Managed Redis
Create GitHub Environment prodAdd required reviewers protection rule — no one-click prod deploys
Populate all prod GitHub secretsSame set as staging/test but with production values
Create docker-compose.prod.ymlAdd resource limits, named volumes; consider external RabbitMQ for HA
Create deploy-prod.yml GitHub Actions workflowCopy from staging; add manual approval gate before deploy step
Add DNS record arc.semalink.africaA record → production droplet IP
Create Zero Trust application for production APIarc.semalink.africa
Generate production service tokenFor frontend and any server-to-server calls
Set up log aggregationLogtail / Papertrail / Grafana Loki before going live — no SSH-only logs in prod
Set up uptime monitoringBetterStack, UptimeRobot, or Cloudflare Healthchecks for arc.semalink.africa/health
Set up error trackingSentry (API + frontend) — catch crashes in prod before users report them
Production smoke test checklistSign up → verify email → create org → add contact → create campaign (won't send yet, no sender ID approved)
Switch Mailgun to production sending modeMailgun sandbox only sends to verified addresses — enable production sending before launch

3k — Agent Portal Deployment

The agent portal repo does not exist yet.

TaskStatusNotes
Create semalink-agent GitHub repoVite + Vue 3 + TypeScript, same stack as semalink-frontend
Scaffold projectAgent auth, routing, basic layout
Create Cloudflare Pages project semalink-agent
Add DNS agents.semalink.africaCNAME → Pages
Set up GitHub Actions deploy workflowsstaging + prod triggers, same pattern as frontend
Configure GitHub secretsVITE_API_BASE_URL pointing to same API as customer app

3l — Admin App Deployment

Staging is live. Production deploy workflow is pending.

TaskStatusNotes
Create semalink-admin GitHub repoVite + Vue 3 + TypeScript + Tailwind
Scaffold projectStaff auth, login page, launcher home, routing
Create Cloudflare Pages project semalink-admin-stagingstaging-admin.semalink.africa
Add DNS staging-admin.semalink.africaCNAME → semalink-admin-staging.pages.dev
Set up GitHub Actions staging workflowPush to staging branch → build → Pages → Slack notify
Seed first staff owner accountevanson@semalink.africa on staging Neon DB
Configure Zero Trust — staff onlyAccess policy for admin.semalink.africa restricted to @semalink.africa domain
Create Cloudflare Pages project semalink-adminProduction project
Add DNS admin.semalink.africaCNAME → semalink-admin.pages.dev
Set up GitHub Actions production workflowTriggers on push to prod branch

Part 4 — Build order summary

API (semalink-api)
──────────────────────────────────────────────────
✅ auth module
✅ accounts module
✅ contacts module
✅ users module
✅ 14 migrations (0000–0013)
✅ admin module
⬜ sender-ids module   ◄── next API milestone
⬜ billing module
⬜ messaging / campaigns module
⬜ pricing module
⬜ dlr module (delivery receipts from Celcom)
⬜ analytics module
⬜ api-keys module
⬜ webhooks module
⬜ agents module

Customer app (semalink-frontend)       Admin app (semalink-admin)
──────────────────────────────         ──────────────────────────────────
✅ Auth                                ✅ Scaffold + staff auth (staging live)
✅ Contacts                            ⬜ Sender ID approval queue  ◄─┐
✅ Team                                ⬜ Org + user management       │
⬜ Sender IDs ◄── start here ──────────────────────────────────────────┘
⬜ Billing                             ⬜ Credits management
⬜ Campaigns                           ⬜ Pricing + MNO config
⬜ Analytics                           ⬜ System health + audit log
⬜ Developer (API keys, webhooks)
⬜ Settings (MFA, notifications)

Agent portal (semalink-agent)
──────────────────────────────
⬜ Scaffold + agent auth
⬜ Sub-customer management
⬜ Per-customer pricing
⬜ Balance + top-up
⬜ Usage analytics across sub-customers

Infrastructure
──────────────────────────────
✅ Staging — fully deployed (API + customer app + admin portal)
✅ Staging hardening — all 3 issues fixed (healthcheck, named volume, rotation runbook)
⬜ Test — not provisioned
⬜ Production — not provisioned
⬜ Production hardening (monitoring, log aggregation, Sentry, firewall)

Hard cross-app dependency: A business registers a sender ID in the customer app → a Sema Link staff member approves it in the admin app. Build both sides together as the first milestone. No campaigns can be sent until this flow is complete end-to-end.

Internal use only — Sema Link Engineering