All Projects

Live

SaaS Platform

ConnectCRM

A multi-tenant SaaS CRM platform that helps Nigerian businesses manage leads, deals, campaigns, and team workflows — all in one backend.

Role: Backend Lead / Sole Backend Developer


NestJSTypeScriptPostgreSQLRedisMeilisearchMulti-TenantPaystackCampaign EngineCRM
Problem Statement

Sales and marketing teams at small-to-mid-sized Nigerian businesses lack a purpose-built CRM that handles the full pipeline — from lead capture through deal close, plus email/SMS campaigns, file management, and team collaboration — without expensive foreign platforms that don't account for local payment infrastructure (Paystack) or SMS gateways (Termii, SMSLive247).

Target users: Sales operations teams, marketing managers, and organization admins at SMEs and agencies in Nigeria and West Africa who need a unified platform to track leads, close deals, run campaigns, and manage their CRM workspace.

Project Walkthrough

ConnectCRM started from a concrete observation: Nigerian sales and marketing teams were either using spreadsheets or paying for expensive Western CRM platforms that had no knowledge of Paystack, Termii, or SMSLive247. I was brought in to architect and build the entire backend from scratch — a multi-tenant SaaS CRM that could be white-labeled for any organization while keeping each tenant's data completely isolated.

The backbone of the system is a 24-module Domain-Driven NestJS application backed by PostgreSQL, Redis, and Meilisearch. I organized every feature into its own domain module — each with a controller, service, entity, and DTOs — so the codebase structure mirrors the business domain. Multi-tenancy is enforced at every layer: the JWT payload carries an organization_id, a middleware re-injects it into the request context on every call, and every single TypeORM query is hard-filtered to that org.

One of the most satisfying design decisions was building a unified polymorphic system for notes, tasks, files, activity logs, and tags. Instead of creating separate tables for 'lead notes' and 'deal notes' and 'contact notes', I used a single table per concern with an entity_type + entity_id discriminator pair. Every current and future entity type gets all five capabilities automatically, and adding a new entity to the CRM requires zero schema migrations.

The campaign engine was the most complex system in the project. It's a 4-step state machine supporting both email and SMS channels. I built a PersonalizationService that replaces Handlebars-style tokens per recipient, and a LinkProcessingService that rewrites every URL in the content to a short-code redirect. All sends are async through Bull queues backed by Redis, so launching a campaign to thousands of contacts never blocks the API.


Tech Stack

Languages

TypeScript

Frameworks

NestJS v11TypeORM v0.3Passport.js

Databases

PostgreSQL 14+ (primary relational store, TypeORM ORM)Redis 7+ (caching + Bull job queues)Meilisearch (full-text search on leads/contacts)

Cloud & Infrastructure

Railway (production)Vercel (staging frontend)AWS S3

Dev Tools

Swagger/OpenAPI (auto-generated, served at /api/docs)Docker + Docker ComposeJest (unit tests)Testcontainers (E2E tests with ephemeral PostgreSQL instances)GitLab CIPDFKit (PDF report generation)

Authentication

JWT (access token 1h, refresh token 30d) + Passport JWT strategy + in-memory token blacklisting on logout + bcrypt password hashing (12 rounds) + randomBytes(32) email verification and password reset tokens

Third-Party APIs & Integrations

Paystack — subscription payments, recurring billing, HMAC-SHA512 webhook verification

Mailgun — transactional email delivery (via mailgun.js)

Termii — SMS gateway (primary)

AWS S3 — file storage with presigned upload/download URLs

Firebase Admin (FCM) — push notifications

Google OAuth / Microsoft OAuth — SSO


System Architecture

Architecture Pattern

Domain-Driven Design (DDD) layered monolith. 24 domain modules each own their controller/service/entity/DTOs/enums. Shared infrastructure lives in src/common/. Background async work uses Bull queues (Redis). Scheduled jobs use @nestjs/schedule cron decorators.

Request Data Flow

Client -> HTTPS -> NestJS (OrganizationContextMiddleware reads org_id from JWT on every request) -> JwtAuthGuard validates token + blacklist check -> RolesGuard enforces RBAC -> Controller (@CurrentUser / @CurrentOrganization decorators) -> Service (all DB queries hard-scoped to organization_id) -> TypeORM -> PostgreSQL. Async paths (email, SMS, push): Service -> Bull Queue (Redis) -> Queue Worker -> Mailgun / Termii / FCM.

Key Engineering Decisions

1. Application-layer multi-tenancy: organization_id in JWT payload + middleware injection + per-query filtering. Keeps isolation logic visible and auditable. 2. Polymorphic entity pattern: entity_type + entity_id discriminator on notes/tasks/files/activities/tags means one table per concern instead of one table per entity-type combination. 3. Bull + Redis for async — decouples email/SMS/push delivery from HTTP request lifecycle. 4. Meilisearch as dedicated search — avoids expensive ILIKE scans across large lead/contact tables. 5. Custom module nomenclature — organizations can rename any CRM entity with no schema change.

Database Design

All entities extend BaseEntity (UUID PK, created_at/updated_at as Unix int, soft-delete). Key tables: organizations (root of tenancy), users (org_id FK, role_id FK), leads (status/priority/source enums, lead_score int, custom_fields JSONB), contacts (lifecycle_stage enum, custom_fields JSONB), deals (value decimal, stage enum, probability, won/lost fields), activities (entity_type + entity_id polymorphic), campaigns (status machine, content JSONB, analytics counters), subscriptions (plan FK, billing_cycle, auto_renew bool, expires_at).

Module Structure

authorganizationsusersrolesleadscontactsbusinessesdealsactivitiesnotestasksfilestagscustom-fieldscampaignsnotificationsreportssubscriptionspayments

Key Features & Implementation

Multi-Tenant Architecture with Org-Scoped Data Isolation

Every organization's data is completely isolated on a shared database. Users can never access another org's leads, contacts, deals, or any other records.

How it was built

organization_id is embedded in the JWT payload at login time. OrganizationContextMiddleware runs on every inbound request, extracts org_id from the verified JWT, and injects it into the NestJS request context. Every service method receives organizationId as its first parameter, and every TypeORM query includes .where('entity.organization_id = :organizationId', { organizationId }).

Email & SMS Campaign Engine with Link Tracking

A 4-step campaign builder supporting email (plain text + full HTML) and SMS channels. Features audience targeting, personalization token substitution, click-tracked short links, delivery status tracking, and engagement analytics.

How it was built

Campaigns progress through a state machine (DRAFT -> READY_TO_LAUNCH -> ACTIVE -> COMPLETED). PersonalizationService replaces {{first_name}}, {{last_name}}, etc. per recipient at send time. LinkProcessingService extracts URLs, generates short_codes, stores them in campaign_links, and rewrites the content. Clicks hit GET /track/:short_code, which increments click counters then 302-redirects. Three cron jobs fire every 5 minutes: scheduled campaign launcher, delivery status poller, and completion detector.

Lead Management & Conversion Pipeline

Full lead lifecycle — create, score, assign, convert to contact. Includes round-robin auto-assignment, CSV bulk import/export, and polymorphic attachment of notes, tasks, files, and activities.

How it was built

Leads have a lead_score (int), priority enum, status enum (NEW/CONTACTED/INTERESTED/NEGOTIATION/WON/LOST), source enum. Converting a lead: ContactsService.create() is called with the lead data, converted_to_contact_id and converted_at are stamped on the lead. Round-robin auto-assign queries active org users ordered by lead assignment count and selects the user with fewest leads.

Custom Fields (Schema-less Per-Org Field Definitions)

Org admins can add custom data fields (text, number, date, boolean, dropdown, multi-select) to leads, contacts, businesses, and deals — without any database migration.

How it was built

CustomField entity stores field definition (name, type enum, required bool, default value, validation rules, options array) scoped to org + entity_type. Target entities have a custom_fields: Record<string, unknown> JSONB column. CustomFieldsService.validate() enforces type constraints and required fields at write time.


Challenges & Engineering Decisions
1

Application-layer multi-tenancy discipline: enforcing org scoping purely in service code means every developer must consistently pass organizationId.

2

Polymorphic relations without ORM support: TypeORM has no native polymorphic has-many. Every detail view requires N parallel repository queries (Promise.all) instead of a single JOIN.

3

Campaign fan-out at scale: delivery to thousands of recipients via Bull is batched, but delivery status polling is cron-driven (every 5 min) rather than webhook-driven — will become a bottleneck at volume.

4

Paystack webhook idempotency: HMAC signature verification is correct, but re-processing duplicate events relies on reference uniqueness checks in application code rather than a dedicated event log table.


API Documentation

API Route Namespaces

/api/v1/auth/api/v1/organizations/api/v1/users/api/v1/roles/api/v1/leads/api/v1/contacts/api/v1/businesses/api/v1/deals/api/v1/campaigns/api/v1/reports/api/v1/subscriptions/api/v1/payments/api/v1/payments/webhook/api/v1/track/:short_code