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
| Symbol | Meaning |
|---|---|
| ✅ | Complete |
| 🔨 | In progress |
| ⬜ | Not started |
Part 0 — API (semalink-api)
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
| Module | Status | Routes file | Service file | Notes |
|---|---|---|---|---|
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
| Migration | Status | Description |
|---|---|---|
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.tsis fully implemented —avatarKey(),logoKey(),kycKey(),getPresignedDownloadUrl() - Email:
src/lib/email.tsuses 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
Part 1 — Customer Web App (semalink-frontend)
Phase 1 — Foundation
| Feature | Status | Notes |
|---|---|---|
| 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.
| Feature | Status | Notes |
|---|---|---|
| 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.
| Feature | Status | Notes |
|---|---|---|
| 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.
| Feature | Status | Notes |
|---|---|---|
| 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
| Feature | Status | Notes |
|---|---|---|
| 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
| Feature | Status | Notes |
|---|---|---|
| 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 |
Part 2 — Internal Admin App (semalink-admin)
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.africadomain. 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.africaseeded asownervia 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| Level | Can do |
|---|---|
owner | All actions including destructive/irreversible ones (delete org, change wholesale pricing, manage staff accounts) |
admin | Day-to-day operational actions (approve sender IDs, adjust credits, view all data) |
read | View only — no mutations |
none | Module 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+kquick-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.africawith 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:
adminor 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_logtable - 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
| Module | Permission needed | API prefix | Status |
|---|---|---|---|
| CRM | read+ | /admin/crm | ⬜ |
| Sender IDs | read+ | /admin/sender-ids | ⬜ |
| Billing | admin+ | /admin/billing | ⬜ |
| Pricing | owner | /admin/pricing | ⬜ |
| Agent Manager | admin+ | /admin/agents | ⬜ |
| Campaigns | read+ | /admin/campaigns | ⬜ |
| Engineering | read+ | /admin/engineering | ⬜ |
| Audit Log | read+ | /admin/audit | ⬜ |
| Staff | owner | /admin/staff | ⬜ |
Phase 1 — Shell + Auth
| Feature | Status | Notes |
|---|---|---|
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
| Feature | Status | Notes |
|---|---|---|
| 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
| Feature | Status | Notes |
|---|---|---|
| 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
| Feature | Status | Notes |
|---|---|---|
| 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
| Feature | Status | Notes |
|---|---|---|
| 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
| Feature | Status | Notes |
|---|---|---|
| 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
| Feature | Status | Notes |
|---|---|---|
| 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
| Feature | Status | Notes |
|---|---|---|
| Full audit log viewer | ⬜ | All staff actions, filterable by staff / module / action / date |
| Export audit log | ⬜ | CSV download for compliance |
Phase 9 — Staff Module
| Feature | Status | Notes |
|---|---|---|
| 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 |
Part 2b — Agent Portal (semalink-agent)
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
| Feature | Status | Notes |
|---|---|---|
| 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
| Feature | Status | Notes |
|---|---|---|
| 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
| Layer | Staging | Test | Production |
|---|---|---|---|
| Frontend URL | staging-app.semalink.africa | test-app.semalink.africa | app.semalink.africa |
| API URL | staging-arc.semalink.africa | test-arc.semalink.africa | arc.semalink.africa |
| Agent Portal URL | — | — | agents.semalink.africa |
| Admin URL | — | — | admin.semalink.africa |
| API host | DigitalOcean droplet (FRA1) | DigitalOcean droplet (TBD) | DigitalOcean droplet(s) (TBD) |
| Database | Neon — staging branch | Neon — test branch | Neon — main branch |
| Redis | Upstash Frankfurt (free) | Upstash Frankfurt (free) | Upstash Frankfurt (paid) |
| Queue | RabbitMQ in Docker | RabbitMQ in Docker | RabbitMQ in Docker (or CloudAMQP) |
| Object storage | R2 — staging bucket | R2 — test bucket | R2 — production bucket |
| Frontend host | Cloudflare Pages | Cloudflare Pages | Cloudflare 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.
| Task | Status | Notes |
|---|---|---|
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
-poolerhostname inDATABASE_URL(Neon's PgBouncer) — reduces connection count from many to few sslmode=require&channel_binding=requiremust 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.
| Task | Status | Notes |
|---|---|---|
| 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) — plainredis://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.
| Task | Status | Notes |
|---|---|---|
| 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.
| Task | Status | Notes |
|---|---|---|
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.
| Task | Status | Notes |
|---|---|---|
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.
| Record | Type | Status | Notes |
|---|---|---|---|
semalink.africa (apex) | CNAME → Pages | ✅ | Marketing website |
app.semalink.africa | CNAME → Pages | ✅ | Customer app production |
staging-app.semalink.africa | CNAME → Pages | ✅ | Customer app staging |
test-app.semalink.africa | CNAME → Pages | ⬜ | Customer app test — add when Pages project created |
staging-arc.semalink.africa | A → droplet IP | ✅ | API staging (209.38.197.79) |
test-arc.semalink.africa | A → test droplet | ⬜ | Add when test droplet provisioned |
arc.semalink.africa | A → prod droplet | ⬜ | Add when production droplet provisioned |
staging-admin.semalink.africa | CNAME → Pages | ✅ | Admin app staging — semalink-admin-staging.pages.dev |
agents.semalink.africa | CNAME → Pages | ⬜ | Agent portal — add when agent app is built |
admin.semalink.africa | CNAME → Pages | ⬜ | Admin app production — add when prod deploy workflow is ready |
mailer.semalink.africa | MX + TXT (Mailgun) | ✅ | Transactional email sending domain |
assets.semalink.africa | CNAME → 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.
| Task | Status | Notes |
|---|---|---|
| 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
| Task | Status | Notes |
|---|---|---|
| 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.
| Task | Status | Notes |
|---|---|---|
| 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.
| Task | Status | Notes |
|---|---|---|
| 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.
| Task | Status | Notes |
|---|---|---|
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.
| Task | Status | Notes |
|---|---|---|
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.