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
auth✅✅✅Login, signup, verify email, reset password, OAuth, magic link, refresh tokens
accounts✅✅—List orgs, get/update current org, create org, switch org
contacts✅✅✅CRUD, lists, import (MNO validation), export, tags, bulk, trash
users✅✅✅Get/update profile, avatar upload, team management (invite, role, remove)
sender-ids⬜⬜⬜Folder scaffolded but empty
billing⬜⬜⬜Folder scaffolded but empty
messaging⬜⬜⬜Folder scaffolded but empty — campaign send, DLR processing
pricing⬜⬜⬜Folder scaffolded but empty
dlr⬜⬜⬜Folder scaffolded but empty — delivery receipt webhooks from Celcom
campaigns⬜⬜⬜Not yet scaffolded
analytics⬜⬜⬜Not yet scaffolded
api-keys⬜⬜⬜Not yet scaffolded
webhooks⬜⬜⬜Not yet scaffolded
admin✅✅✅Staff auth (login, logout, refresh token); separate JWT namespace (ADMIN_JWT_SECRET); admin_staff table with role, hashed password, login attempt tracking
agents⬜⬜⬜Not yet scaffolded — agent portal endpoints (sub-customer management, per-customer pricing)

Database migrations ​

MigrationStatusDescription
0000✅Base tables: accounts, users, contacts, messages, campaigns, credits, pricing_rates, sender_ids, api_keys, webhook_endpoints
0001✅auth_tokens — email verification, password reset, magic link
0002✅refresh_tokens — session management
0003✅oauth_accounts — Google, Microsoft, GitHub, Apple
0004✅contact_lists + contact_list_memberships junction table
0005✅credit_transactions ledger table
0006✅mno_prefixes lookup table
0007✅account_members junction table (multi-org support)
0008✅Business profile fields on accounts (address, city, state, zip)
0009✅valid_count + invalid_count columns on contact_lists
0010✅Schema audit: is_active on account_members, user_id NOT NULL on auth_tokens, channel/sender_id_status/consent/currency/country columns
0011✅Seed data: 151 MNO prefix rows across 53 African countries
0012✅admin_staff table — staff accounts, hashed passwords, role enum, login attempt tracking, account lockout
0013✅phone column on admin_staff
Sender IDs table extension⬜Add fields needed for full sender ID workflow (submitted_at, reviewed_by, rejection_reason)
Campaigns table extension⬜Add fields: scheduled_at, sent_at, recipient_count, delivered_count, failed_count
Agent tables⬜agent_accounts, agent_customers junction, per-customer pricing overrides
API keys table⬜Already in 0000 base — verify schema is sufficient or extend
Webhook endpoints table⬜Already in 0000 base — verify schema is sufficient or extend
Webhook delivery log table⬜New 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, logout✅Email/password + Google/GitHub/Microsoft OAuth
Auth — email verification✅Banner gates write actions until verified
Auth — forgot/reset password✅
Auth — magic link✅
Auth — refresh token rotation✅
Multi-org — create org, switch org✅account_members junction table
Settings — business profile✅Name, timezone, country, address
Settings — personal profile✅Name, phone, avatar upload (R2)
Settings — security (password change)✅
Settings — notifications⬜Email/in-app prefs for low credits, campaign done, sender ID status changes
Settings — MFA (TOTP)⬜Authenticator app setup + backup codes
Settings — active sessions⬜List and revoke sessions
Contacts — list management✅Create, edit, delete lists
Contacts — add/edit/delete contact✅Country code picker + libphonenumber validation
Contacts — CSV import wizard✅Column mapping, MNO validation, valid/invalid breakdown
Contacts — export (CSV/Excel/PDF, email)✅
Contacts — tags, search, filter✅
Contacts — trash + restore✅
Team — invite member✅Role-based: owner/admin/member
Team — edit role / remove member✅
Dashboard — stat cards✅Balance, messages sent, delivery rate, active campaigns
Dashboard — SMS activity chart✅7-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 IDs⬜Name, country, status badge (pending/approved/rejected/suspended)
Register new sender ID⬜Form: display name, country, use-case description
Delete / withdraw sender ID⬜Only permitted while status is pending
Status change notifications⬜In-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 display✅Shown on dashboard stat card with link to /billing
Transaction history⬜Paginated ledger: top-ups, campaign deductions, refunds
Top-up — M-Pesa STK push⬜Enter amount → STK push to registered phone → confirm
Top-up — card (Stripe or Flutterwave)⬜
Low-credit alert⬜Configurable threshold; in-app banner + email notification
Invoices / receipts⬜Downloadable PDF per top-up transaction
Pricing table⬜Per-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 view⬜Name, status, sent count, delivery rate, scheduled time
Create campaign⬜Name → sender ID → recipient list(s) → message → schedule or send now
Message composer⬜Character counter (160/SMS), multi-part SMS indicator, personalisation tokens ( etc.)
Campaign preview⬜Preview rendered message for a sample contact
Send now vs schedule⬜Datetime picker for scheduled sends
Campaign detail / report⬜Per-campaign: sent, delivered, failed, opted-out breakdown
Pause / cancel scheduled campaign⬜
Duplicate campaign⬜
Draft campaigns⬜Save 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 stats⬜Total sent, delivery rate, opt-out rate, cost — date range picker
Delivery trend chart⬜Sent vs delivered vs failed over time
Per-campaign table⬜Sortable by delivery rate, cost, date
Per-country breakdown⬜Where messages are going, cost by country
Export analytics⬜CSV download

Phase 4 — Developer Experience ​

FeatureStatusNotes
API keys — list⬜Name, created date, last used, permissions scope
API keys — create / revoke⬜
Webhooks — register endpoint⬜URL, secret, event types
Webhooks — event types⬜message.delivered, message.failed, contact.opted_out, credit.low
Webhooks — delivery log⬜Last N delivery attempts, HTTP status, response body
Webhooks — test fire⬜Send a sample payload to the registered endpoint
API playground / docs link⬜Link 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-staging → staging-admin.semalink.africa
DNS staging-admin.semalink.africa✅CNAME → 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 account✅evanson@semalink.africa, role owner, seeded on staging DB
Launcher home page✅Module cards grid; greyed-out coming-soon state for unbuilt modules
Cloudflare Zero Trust application⬜admin.semalink.africa, restricted to @semalink.africa Google accounts (for prod)
Cloudflare Pages project (production)⬜semalink-admin → admin.semalink.africa
DNS admin.semalink.africa⬜CNAME → Pages (for prod)
Permission middleware (API)⬜JWT carries permissions: { crm: 'admin', billing: 'read', ... }; route guards check level
cmd+k quick-jump⬜Fuzzy 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 status⬜All accounts with member count, balance, plan, status
Org detail — overview⬜Profile, members, balance, active sender IDs, recent campaigns
Org suspend / reactivate⬜Atomic: freezes credits + flags messages; owner/admin only
Org delete⬜Permanent, owner only, requires typed confirmation
User list across all orgs⬜Search by email/name, filter by role
User detail⬜Profile, org memberships, login history
User disable / re-enable⬜Blocks login without deleting data
Impersonate org⬜Read-only token, orange banner, full audit trail; admin+ only

Phase 3 — Sender IDs Module ​

FeatureStatusNotes
Approval queue⬜Pending requests sorted by submitted date
Approve sender ID⬜With optional note; triggers status-change notification to org
Reject sender ID⬜Required rejection reason; triggers notification
Sender ID history per org⬜All submissions with status history
Bulk approve⬜Select multiple, approve in one action

Phase 4 — Billing Module ​

FeatureStatusNotes
Credit balance per org⬜Current balance, payment model (prepay/postpay)
Full transaction ledger⬜All top-ups, deductions, adjustments across all time
Manual top-up⬜Add credits with amount + reason/reference; owner/admin only
Manual adjustment / refund⬜Deduct or refund credits; full audit entry written
Credit limit management⬜Set postpay credit limit per org; owner only
Low-balance alerts config⬜Set per-org alert threshold

Phase 5 — Pricing Module ​

FeatureStatusNotes
Per-country SMS rate table⬜Current rate, effective date, history
Set / update rate⬜Owner only; effective date picker (future-dated changes)
Agent wholesale rate per agent⬜Override global rate for specific agents
Celcom cost reference⬜Internal record of wholesale buy rate for margin visibility

Phase 6 — Agent Manager Module ​

FeatureStatusNotes
Agent list⬜All agent accounts, balance, sub-customer count, volume
Agent detail⬜Sub-customers, per-customer pricing set by agent, usage
Agent suspend / reactivate⬜Blocks all sub-customers' sends
Sub-customer visibility⬜Read-only view of agent's customer accounts and their rates

Phase 7 — Engineering Module ​

FeatureStatusNotes
RabbitMQ queue depth⬜Live view of each queue: message count, consumer count
Failed SMS queue⬜List failed messages, reason, retry count; manual retry trigger
DLR (delivery receipt) backlog⬜Unprocessed delivery receipts from Celcom
API health status⬜Response time, error rate, uptime
Migration history⬜List applied migrations, last run timestamp
Recent errors⬜Last N API errors with stack traces (from log aggregator)

Phase 8 — Audit Log Module ​

FeatureStatusNotes
Full audit log viewer⬜All staff actions, filterable by staff / module / action / date
Export audit log⬜CSV download for compliance

Phase 9 — Staff Module ​

FeatureStatusNotes
Staff list⬜Name, email, role, last login, module permissions
Invite staff⬜Email invite; account inactive until first login + password set
Edit staff permissions⬜Set per-module permission level; owner only
Remove staff⬜Revokes all tokens immediately; owner only
Staff login history⬜IP, 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 summary⬜Agent's own Sema Link credit balance + this month's total sends across all sub-customers
Sub-customer management — list⬜All customers under this agent with status, usage, balance
Sub-customer management — create⬜Agent creates a new customer account; customer receives invite email
Sub-customer management — deactivate⬜Block a customer's sends without deleting their data
Per-customer pricing⬜Agent sets per-SMS rate per sub-customer (visible to Sema Link admin)
Per-customer usage report⬜SMS volume and spend per customer, per day/month
Top-up — M-Pesa or bank transfer⬜Agent tops up their own Sema Link balance
Invoices from Sema Link⬜PDF billing statements from Sema Link to the agent

Phase 2 — Advanced Agent Features ​

FeatureStatusNotes
Sub-customer sender ID status⬜Agent sees approval status for their customers' sender IDs (read-only; approval is done by Sema Link admin)
Agent-level analytics⬜Aggregate delivery rates, volume trends, cost breakdown across all sub-customers
Postpay credit limit management⬜Agent can set per-customer send limits (independent of Sema Link's credit limit on the agent)
Bulk sub-customer import⬜CSV 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 URL——agents.semalink.africa
Admin URL——admin.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 branch✅Frankfurt (eu-central-1), pooler URL in use
Connect API to staging DB✅DATABASE_URL injected via GitHub secret
Create test branch⬜Branch 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 secret⬜GitHub Environment: test
Configure production DATABASE_URL secret⬜GitHub Environment: prod
Upgrade Neon plan for production⬜Free tier has limited compute hours and connections; upgrade before launch
Set max pool size in API config⬜Keep ≤ 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 instance✅Upstash Frankfurt, free tier, TLS
Connect API to staging Redis✅REDIS_URL (rediss://...) injected via GitHub secret
Create test Redis instance⬜New Upstash database in Frankfurt; note different hostname
Create production Redis instance⬜Upstash paid tier — higher data limit, no LRU eviction
Configure test REDIS_URL secret⬜GitHub Environment: test
Configure production REDIS_URL secret⬜GitHub Environment: prod
Set eviction policy for production⬜Free 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 droplet✅rabbitmq:3.13-management-alpine, non-default user semalink
RABBITMQ_PASS injected via GitHub secret✅32+ random chars
API connects via amqp://semalink:${PASS}@rabbitmq:5672✅Internal Docker network DNS
Add named rabbitmq_data volume⬜Known issue — queue state lost on container destroy; fix before real workers land
Add API healthcheck to Compose⬜Known issue — Caddy forwards traffic before API is ready; see api-ops.md
Document RabbitMQ password rotation runbook⬜Known issue — rotating RABBITMQ_PASS requires manual volume wipe; see api-ops.md
Provision RabbitMQ on test droplet⬜Same setup as staging — part of new droplet provisioning
Provision RabbitMQ on production droplet⬜Consider CloudAMQP (managed) for durability and HA in production
Add resource limits to Docker Compose⬜API: 1 GB, RabbitMQ: 512 MB — prevents OOM kill cascade on 4 GB droplet
Add message queue workers⬜No 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 created✅Public r2.dev URL enabled; used for local development
staging bucket created✅Public r2.dev URL enabled
test bucket created✅Public r2.dev URL enabled
production bucket created✅Public r2.dev URL enabled
R2 credentials in local .env✅R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME=dev, R2_PUBLIC_URL
R2 credentials in staging GitHub secrets✅R2_BUCKET_NAME=staging, R2_PUBLIC_URL pointing to staging r2.dev URL
R2 credentials in test GitHub secrets⬜Add R2_BUCKET_NAME=test, R2_PUBLIC_URL, shared access key secrets
R2 credentials in production GitHub secrets⬜Add R2_BUCKET_NAME=production, R2_PUBLIC_URL
Custom domain for production R2⬜Replace 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 code✅avatars/, 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-staging✅Deploys on push to staging branch
Pages project semalink-app-test⬜Workflow 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-staging⬜For agent portal — needs creating once agent app repo exists
Pages project semalink-agent (production)⬜
Pages project semalink-admin-staging✅Deployed at staging-admin.semalink.africa; deploy on push to staging branch
Pages project semalink-admin (production)⬜
CLOUDFLARE_API_TOKEN secret set✅Repository-level secret, used by all Pages workflows
CLOUDFLARE_ACCOUNT_ID secret set✅Repository-level secret
VITE_API_BASE_URL per environment✅Staging = https://staging-arc.semalink.africa, Prod = https://arc.semalink.africa
VITE_CF_ACCESS_CLIENT_ID/SECRET per environment✅Cloudflare Zero Trust service tokens for API access
Test environment GitHub secrets populated⬜VITE_API_BASE_URL=https://test-arc.semalink.africa + CF Zero Trust test tokens
Production environment GitHub secrets verified⬜Confirm 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 → Pages✅Marketing website
app.semalink.africaCNAME → Pages✅Customer app production
staging-app.semalink.africaCNAME → Pages✅Customer app staging
test-app.semalink.africaCNAME → Pages⬜Customer app test — add when Pages project created
staging-arc.semalink.africaA → droplet IP✅API staging (209.38.197.79)
test-arc.semalink.africaA → test droplet⬜Add when test droplet provisioned
arc.semalink.africaA → prod droplet⬜Add when production droplet provisioned
staging-admin.semalink.africaCNAME → Pages✅Admin app staging — semalink-admin-staging.pages.dev
agents.semalink.africaCNAME → Pages⬜Agent portal — add when agent app is built
admin.semalink.africaCNAME → Pages⬜Admin app production — add when prod deploy workflow is ready
mailer.semalink.africaMX + TXT (Mailgun)✅Transactional email sending domain
assets.semalink.africaCNAME → R2 custom domain⬜Production 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 API✅staging-arc.semalink.africa — service token auth
Service token for staging frontend✅VITE_CF_ACCESS_CLIENT_ID/SECRET in staging GitHub environment
Zero Trust application for test API⬜Create new application for test-arc.semalink.africa
Service token for test frontend⬜Generate and add to test GitHub environment secrets
Zero Trust application for production API⬜Create application for arc.semalink.africa
Service token for production frontend⬜Generate and add to prod GitHub environment secrets
Zero Trust application for admin app⬜admin.semalink.africa — restrict to staff email domain only (not service token)
Staff identity provider configured⬜Google 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 deployed✅ED25519 key in deploy authorized_keys
Repo cloned to /opt/semalink-api✅
Caddy reverse proxy running✅tls internal, port 80 + 443
RabbitMQ running✅Management UI on port 15672
API container running✅Port 3000 internal only
GitHub Actions workflow (deploy-staging.yml)✅Triggers on push to staging branch
All GitHub secrets populated✅DATABASE_URL, REDIS_URL, RABBITMQ_PASS, JWT_SECRET, JWT_REFRESH_SECRET, MAILGUN_, CELCOM_, R2_*
Health endpoint responding✅https://staging-arc.semalink.africa/health → {"status":"ok"}
Fix: add rabbitmq_data named volume✅Queue state now survives container destroy
Fix: add API healthcheck in Compose✅wget -qO- http://127.0.0.1:3000/health; Caddy waits for service_healthy
Fix: document RabbitMQ password rotation runbook✅See api-ops.md — manual volume-wipe procedure documented
Add container resource limits⬜API: 1 GB RAM, RabbitMQ: 512 MB RAM

3i — API Deployment — Test ​

Nothing has been done yet. Steps mirror staging exactly.

TaskStatusNotes
Provision DigitalOcean droplet⬜Same spec as staging: 2 vCPU / 4 GB, Frankfurt, Ubuntu 24.04
Run one-time droplet setup⬜Docker, deploy user, SSH key, repo clone — see API Staging Setup
Create Neon test database branch⬜Branch from main in Neon dashboard
Create Upstash test Redis instance⬜New database in Frankfurt
Create GitHub Environment test⬜Separate from staging environment
Populate all GitHub secrets for test⬜DATABASE_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.yml⬜Copy from staging; no changes needed — differences are in env vars only
Create deploy-test.yml GitHub Actions workflow⬜Copy from deploy-staging.yml; change branch to test, environment to test, droplet secret names
Add DNS record test-arc.semalink.africa⬜A record → new droplet IP in Cloudflare
Create Zero Trust application for test API⬜test-arc.semalink.africa, generate service token
Add service token to test GitHub environment⬜VITE_CF_ACCESS_CLIENT_ID + VITE_CF_ACCESS_CLIENT_SECRET
Test deploy — push to test branch⬜Verify 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 spec⬜Minimum: 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 setup⬜Same as staging; see API Staging Setup
Set up DigitalOcean Firewall⬜Allow only: 80, 443 inbound; restrict 15672 (RabbitMQ management) to known IPs only
Configure Neon production connection⬜Use main branch; upgrade to paid plan for higher compute hours and connections
Create Upstash production Redis⬜Paid tier (noeviction policy); or DigitalOcean Managed Redis
Create GitHub Environment prod⬜Add required reviewers protection rule — no one-click prod deploys
Populate all prod GitHub secrets⬜Same set as staging/test but with production values
Create docker-compose.prod.yml⬜Add resource limits, named volumes; consider external RabbitMQ for HA
Create deploy-prod.yml GitHub Actions workflow⬜Copy from staging; add manual approval gate before deploy step
Add DNS record arc.semalink.africa⬜A record → production droplet IP
Create Zero Trust application for production API⬜arc.semalink.africa
Generate production service token⬜For frontend and any server-to-server calls
Set up log aggregation⬜Logtail / Papertrail / Grafana Loki before going live — no SSH-only logs in prod
Set up uptime monitoring⬜BetterStack, UptimeRobot, or Cloudflare Healthchecks for arc.semalink.africa/health
Set up error tracking⬜Sentry (API + frontend) — catch crashes in prod before users report them
Production smoke test checklist⬜Sign up → verify email → create org → add contact → create campaign (won't send yet, no sender ID approved)
Switch Mailgun to production sending mode⬜Mailgun 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 repo⬜Vite + Vue 3 + TypeScript, same stack as semalink-frontend
Scaffold project⬜Agent auth, routing, basic layout
Create Cloudflare Pages project semalink-agent⬜
Add DNS agents.semalink.africa⬜CNAME → Pages
Set up GitHub Actions deploy workflows⬜staging + prod triggers, same pattern as frontend
Configure GitHub secrets⬜VITE_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 repo✅Vite + Vue 3 + TypeScript + Tailwind
Scaffold project✅Staff auth, login page, launcher home, routing
Create Cloudflare Pages project semalink-admin-staging✅staging-admin.semalink.africa
Add DNS staging-admin.semalink.africa✅CNAME → semalink-admin-staging.pages.dev
Set up GitHub Actions staging workflow✅Push to staging branch → build → Pages → Slack notify
Seed first staff owner account✅evanson@semalink.africa on staging Neon DB
Configure Zero Trust — staff only⬜Access policy for admin.semalink.africa restricted to @semalink.africa domain
Create Cloudflare Pages project semalink-admin⬜Production project
Add DNS admin.semalink.africa⬜CNAME → semalink-admin.pages.dev
Set up GitHub Actions production workflow⬜Triggers 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