Skip to content

a full implementation of referral system module#1264

Closed
feyishola wants to merge 1 commit into
DistinctCodes:mainfrom
feyishola:feat/referral-system
Closed

a full implementation of referral system module#1264
feyishola wants to merge 1 commit into
DistinctCodes:mainfrom
feyishola:feat/referral-system

Conversation

@feyishola

@feyishola feyishola commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

closes

Referral System — Detailed Implementation Summary

Overview

A complete referral system was built as a NestJS backend module for ManageHub. The system covers the full lifecycle: code generation at registration, referral tracking via a dedicated database entity, reward triggering on the referred user's first payment, and real-time notification delivery to the referrer.


1. Database Schema

referrals table — new entity

Referral (referrals/entities/referral.entity.ts) stores the full lifecycle of each referral:

Column | Type | Notes -- | -- | -- id | UUID (PK) | Auto-generated referrerId | UUID (FK → users) | The member who shared their code referredUserId | UUID (FK → users) | The new member who used the code code | varchar(20) | The referral code used at registration status | enum | pending or completed rewardType | enum, nullable | discount or credit — set when completed rewardValue | int, nullable | Discount % or credit amount — set when completed awardedAt | timestamptz, nullable | Timestamp of reward assignment createdAt | timestamptz | Auto-set on insert

The admin endpoint uses RolesGuard + the @Roles(UserRole.ADMIN) decorator, consistent with the rest of the codebase's role hierarchy (SUPER_ADMIN > ADMIN > STAFF > USER).


7. Module Registration

ReferralsModule (referrals/referrals.module.ts):

  • Registers User and Referral in TypeOrmModule.forFeature
  • Imports NotificationsModule (to use NotificationsService)
  • Exports ReferralsService (consumed by PaymentsModule)

ReferralsModule was already present in AppModule's imports list — no change needed there.


8. Key Design Decisions

Referral entity is its own table, not a column on users. This keeps the user table lean and makes it easy to query all referrals for an admin dashboard, paginate them, and add future fields (e.g. expiredAt, notifiedAt) without touching the user schema.

referredByCode in body, not a query param. The frontend typically reads ?ref= from the URL and includes it in the registration POST body. This keeps the API RESTful and avoids coupling backend logic to URL structure.

Reward is idempotent by design. The completeReferral call runs on every successful booking payment, but because it filters by status: pending, it only acts once per referred user. There is no "first payment" counter needed.

All side effects in the webhook are fire-and-forget. This matches the existing pattern throughout handle-webhook.provider.ts — referral errors, notification failures, and email failures never block the payment confirmation response to Paystack.

autoLoadEntities: true handles migrations. Since the project uses TypeORM synchronize: true in development, the new referrals table and the referralCode column on users are created automatically when the server starts. For a production migration, typeorm:generate-migration would produce the correct diff from these entity changes.

Referral System — Detailed Implementation Summary Overview A complete referral system was built as a NestJS backend module for ManageHub. The system covers the full lifecycle: code generation at registration, referral tracking via a dedicated database entity, reward triggering on the referred user's first payment, and real-time notification delivery to the referrer.
  1. Database Schema
    referrals table — new entity

Referral (referrals/entities/referral.entity.ts) stores the full lifecycle of each referral:

Column Type Notes
id UUID (PK) Auto-generated
referrerId UUID (FK → users) The member who shared their code
referredUserId UUID (FK → users) The new member who used the code
code varchar(20) The referral code used at registration
status enum pending or completed
rewardType enum, nullable discount or credit — set when completed
rewardValue int, nullable Discount % or credit amount — set when completed
awardedAt timestamptz, nullable Timestamp of reward assignment
createdAt timestamptz Auto-set on insert
Three composite indexes are declared on the entity: referrerId, referredUserId, and (referredUserId, status) — the last one makes the pending-referral lookup in the webhook handler a single indexed scan.

Both FK relations use onDelete: CASCADE so referral records are cleaned up if either user is hard-deleted.

users table — column added

A referralCode column (varchar(20), UNIQUE, nullable) was added to the User entity. It holds the user's own shareable code. It is nullable to accommodate existing users who were registered before this feature existed.

  1. Enums
    Two new enum files were created under referrals/enums/:

ReferralStatus — pending | completed
RewardType — discount | credit
These are used as TypeORM enum column types on the Referral entity, which means Postgres enforces the valid values at the database level.

  1. Registration Flow Changes
    CreateUserDto (users/dto/createUser.dto.ts) gained one optional field:

referredByCode?: string // the referrer's code, validated 8–20 chars
This is what the frontend passes when a user lands on /register?ref=MH-XXXXXX and submits the form.

createUser.provider.ts (users/providers/createUser.provider.ts) was rewritten to do three new things:

Destructure referredByCode out of the DTO before spreading it into the entity creation call, so it never accidentally lands as a column on the users table.

Auto-generate a unique referralCode for every new user using crypto.randomBytes(3).toString('hex').toUpperCase(), which produces a 6-character hex string giving codes like MH-A1B2C3. The generator retries up to 10 times on collision, then falls back to 8 hex characters if all 10 attempts collide (statistically impossible in practice but handled).

Create a pending Referral record after the user is saved — if and only if referredByCode was supplied and resolves to a real user who is not the registrant themselves. Failures here are swallowed (.catch(() => void 0)) so a bad or already-used code never blocks registration.

UsersModule was updated to include Referral in its TypeOrmModule.forFeature registration so the CreateUserProvider can inject @InjectRepository(Referral).

  1. Reward Trigger — Webhook Hook
    handle-webhook.provider.ts (payments/providers/handle-webhook.provider.ts) was updated to trigger the referral reward inside handleChargeSuccess, immediately after the booking is confirmed and the promo code is recorded — before invoice generation.

The hook is a single fire-and-forget call:

if (payment.userId) {
this.referralsService
.completeReferral(payment.userId)
.catch((err) => this.logger.error(...));
}
It only runs for booking payments with a known userId (not credit purchases, not guest bookings). Errors are logged but never bubble up to affect payment confirmation.

PaymentsModule was updated to import ReferralsModule so HandleWebhookProvider can receive ReferralsService via dependency injection.

  1. Core Service Logic
    ReferralsService (referrals/referrals.service.ts) has four methods:

getMyCode(userId)
Loads the user record and returns their referralCode plus a pre-built shareable URL ({APP_URL}/register?ref={code}). Falls back to managehub.app if APP_URL is not set.

getStats(userId)
Loads all referral records where the user is the referrer, then computes three counters client-side (no extra DB round-trip):

totalReferrals — all records regardless of status
conversions — count of completed records
rewardsEarned — sum of rewardValue across completed records
findAll(page, limit)
Paginated admin query using findAndCount, ordered createdAt DESC, with referrer and referredUser relations eagerly loaded for display purposes. Returns { items, total, page, limit }.

completeReferral(referredUserId)
The reward trigger. Looks for exactly one pending referral for the given user. If found:

Sets status → completed
Sets rewardType → discount, rewardValue → 10 (10% discount)
Sets awardedAt → now
Saves the record
Sends a GENERAL in-app notification to the referrer via NotificationsService.create(...), which both persists the notification to the database and pushes it in real-time over the existing WebSocket gateway to the referrer's browser session
Because the method only matches status: pending records, it is naturally idempotent — if the referred user makes a second payment later, the query finds nothing and returns immediately. No additional guard is needed.

  1. API Endpoints
    All three endpoints are registered under the referrals prefix with JwtAuthGuard applied at the controller level.

Method Path Access Returns
GET /referrals/my-code Authenticated { referralCode, shareableLink }
GET /referrals/stats Authenticated { totalReferrals, conversions, rewardsEarned }
GET /referrals Admin (ADMIN role or above) Paginated referral list with user relations
The admin endpoint uses RolesGuard + the @roles(UserRole.ADMIN) decorator, consistent with the rest of the codebase's role hierarchy (SUPER_ADMIN > ADMIN > STAFF > USER).

  1. Module Registration
    ReferralsModule (referrals/referrals.module.ts):

Registers User and Referral in TypeOrmModule.forFeature
Imports NotificationsModule (to use NotificationsService)
Exports ReferralsService (consumed by PaymentsModule)
ReferralsModule was already present in AppModule's imports list — no change needed there.

  1. Key Design Decisions
    Referral entity is its own table, not a column on users. This keeps the user table lean and makes it easy to query all referrals for an admin dashboard, paginate them, and add future fields (e.g. expiredAt, notifiedAt) without touching the user schema.

referredByCode in body, not a query param. The frontend typically reads ?ref= from the URL and includes it in the registration POST body. This keeps the API RESTful and avoids coupling backend logic to URL structure.

Reward is idempotent by design. The completeReferral call runs on every successful booking payment, but because it filters by status: pending, it only acts once per referred user. There is no "first payment" counter needed.

All side effects in the webhook are fire-and-forget. This matches the existing pattern throughout handle-webhook.provider.ts — referral errors, notification failures, and email failures never block the payment confirmation response to Paystack.

autoLoadEntities: true handles migrations. Since the project uses TypeORM synchronize: true in development, the new referrals table and the referralCode column on users are created automatically when the server starts. For a production migration, typeorm:generate-migration would produce the correct diff from these entity changes.

@vercel

vercel Bot commented Jun 28, 2026

Copy link
Copy Markdown

@feyishola is attempting to deploy a commit to the naijabuz's projects Team on Vercel.

A member of the Team first needs to authorize it.

@feyishola feyishola closed this Jun 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant