Sendeet v2 — Architecture

Technical reference for the server-v2 codebase.


Tech Stack

Concern Choice
Runtime Node.js 18+ with TypeScript
Framework Express 5
ORM Prisma 7 (PostgreSQL)
Auth JWT (bcrypt passwords, VerificationToken for OTP)
Email Nodemailer + Resend
WhatsApp Gupshup API
Payments Paystack
File storage AWS S3 (presigned upload URLs)
Real-time Socket.io v4
Scheduling node-cron v4
Docs Swagger UI via swagger-jsdoc

Folder Structure

server-v2/
  prisma/
    schema.prisma          Database schema
    prisma.config.ts       Prisma 7 config (datasource url lives here, not in schema)
    seed.ts                Seed script
  src/
    app.ts                 Express app + http.Server creation
    server.ts              Middleware wiring, socket init, cron init
    index.ts               server.listen
    constants/
      app.ts               API_OBJECTS, APP_NAME, DEFAULT_PAGE_SIZE, token names
      countries.ts         Static ISO country list (not in DB)
      messages.ts          All error and success strings
      pricing.ts           DEFAULT_CREDIT_RATES fallback constant
    controllers/           One class per domain, static RequestHandler methods
    cronJob/
      campaigns.ts         Polls every minute, dispatches scheduled campaigns
    middlewares/
      auth.middleware.ts   authenticate — verifies JWT, loads req.user
      roles.middleware.ts  requireRole, requireVerified, requireAdmin, requireSuperAdmin
    routes/                One file per domain, JSDoc Swagger comments on every route
    services/              Business logic, one class per domain
    socket/
      index.ts             setupSocket, emitToRoom, getIO
    types/                 express.d.ts augments Request with req.user
    utilities/
      common.ts            constructResponse, paginateItems, generateRandomString, etc.
      encrypt.ts           AES-256-GCM encrypt/decrypt (for Gupshup passwords at rest)
      file-parser.ts       parseCSV, parseXLSX, buildCSV, buildXLSX (exceljs)
      markdown.ts          renderMarkdownFile — renders .md files as styled HTML
      swagger.ts           swagger-jsdoc spec config

Database Design

Key models

Model Purpose
Account All users. Role: superadmin, admin, business, user
VerificationToken OTP for email verification and password reset. One per email at a time.
AccountDevice Push notification tokens (Expo)
Business 1-to-1 with Account. Holds company profile, Gupshup credentials, wallet, campaigns
Wallet 1-to-1 with Business. Stored running balance, updated atomically with Transactions
Transaction Audit log for all credit purchases, usage, plan purchases, and withdrawals
Campaign Belongs to Business. scheduledAt (UTC) drives the cron job
Contact Belongs to Business. @@unique([phoneNumber, creatorId]) for dedup on import
Plan Admin-created subscription tiers with per-message-type rates
UserPlan Active plan for a Business. Tracks freeCredit, balance, expiredDate
AppSetting Singleton. Global credit rates, low-credit threshold, affiliate %. Falls back to DEFAULT_CREDIT_RATES constant if empty
Setting Key/value notification preferences per Business or Account
Notification Persisted notifications. receiverId + receiverType identify the recipient room
ExchangeRate Per-country rate. Country list is a static TS constant, not a DB table

Affiliate pattern

There is no separate Affiliate model. A business becomes an affiliate by setting type = "affiliate". Referred businesses store referredBy (FK to an affiliate Business). Commission is credited to the affiliate's wallet at campaign publish time.

Gupshup credential security

gupshupHSMPassword, gupshupDashboardPassword, and gupshupTwowayPassword are encrypted at rest with AES-256-GCM before writing to the database. The key comes from the ENCRYPTION_KEY environment variable (64-char hex string). Credentials are decrypted only in BusinessService.getDecryptedCredentials(), which is called exclusively from the campaign dispatcher and the Gupshup webhook handler. All HTTP responses mask these fields to "true" or "false".


Auth Flow

POST /auth/sign-up
  → Account created (role=business, verified=false)
  → Business + Wallet + default Settings created
  → VerificationToken created
  → OTP email sent
  → JWT returned (limited access until verified)

POST /auth/verify-email  (requires JWT)
  → VerificationToken consumed
  → Account.verified = true
  → Admin notified via Notification + Socket.io

POST /auth/sign-in
  → Returns accessToken (14d) + refreshToken (21d)

POST /auth/refresh-token
  → Returns new accessToken

Campaign Scheduling

Campaigns store scheduledAt: DateTime? in UTC. null means send immediately on publish.

The cron job in src/cronJob/campaigns.ts runs every minute:

WHERE isScheduled = true
  AND isPublished = true
  AND isSentOut = false
  AND scheduledAt <= now()

All matching campaigns are dispatched via CampaignService.dispatchMessages. There are no nested cron jobs or in-memory maps — a server restart safely picks up any missed campaigns on the next tick.

The client is responsible for converting local time to UTC before sending scheduledAt.


File Uploads

The server never receives file bytes directly. The flow is:

POST /upload/presigned-url   → returns { uploadUrl, fileUrl, key }
                               client uploads to uploadUrl (PUT to S3)
POST /upload/confirm         → client sends { url, fileName, size, type }
                               creates UploadFile record in DB

For contact import from a file, the client can either:


Payment Flow

POST /payments/credit-link   → Paystack checkout URL + pending Transaction
  user pays on Paystack
POST /webhooks/paystack      → charge.success → wallet credited
POST /payments/verify        → manual fallback verification after redirect

Plan purchase follows the same pattern using POST /payments/plan-link.

Affiliate withdrawals go through Paystack transfers. The wallet is debited immediately and the Transaction stays pending until transfer.success arrives on the webhook.


Socket.io Rooms

Clients join rooms on connect:

Event Room key
joinBusinessRoom(businessId) businessId
joinAdminRoom(userId) accountId
rejoinRooms({ businessId?, userId? }) both

The server emits notification to the appropriate room whenever a Notification is created via NotificationService.createAndEmit. Clients listen on the notification event.

Triggered by: campaign completion, opt-in/out, payment success, business verification, new signup.


Environment Variables

Variable Purpose
DATABASE_URL PostgreSQL connection string
ACCESS_TOKEN_SECRET JWT access token signing key
REFRESH_TOKEN_SECRET JWT refresh token signing key
ENCRYPTION_KEY 64-char hex key for AES-256-GCM credential encryption
PAYSTACK_SECRET_KEY Paystack secret key (payments + webhooks)
AWS_ACCESS_KEY_ID S3 uploads
AWS_SECRET_ACCESS_KEY S3 uploads
AWS_REGION S3 region
AWS_BUCKET_NAME S3 bucket
ALLOWED_ORIGINS Comma-separated CORS origins
BASE_API_ENDPOINT Public base URL (used in docs and emails)
PORT Server port (default 4000)