diff --git a/src/adapters/postgres/postgres-module.ts b/src/adapters/postgres/postgres-module.ts index 4e4a1e11..7e4edf1a 100644 --- a/src/adapters/postgres/postgres-module.ts +++ b/src/adapters/postgres/postgres-module.ts @@ -24,6 +24,7 @@ import { AcademicYear } from 'src/academicyears/entities/academicyears-entity'; import { CohortAcademicYearService } from './cohortAcademicYear-adapter'; import { AuthUtils } from '@utils/auth-util'; import { ElasticsearchModule } from 'src/elasticsearch/elasticsearch.module'; +import { ReferralsModule } from 'src/referrals/referrals.module'; @Module({ imports: [ @@ -44,6 +45,7 @@ import { ElasticsearchModule } from 'src/elasticsearch/elasticsearch.module'; AcademicYear, ]), ElasticsearchModule, + ReferralsModule, ], providers: [ PostgresUserService, diff --git a/src/adapters/postgres/user-adapter.ts b/src/adapters/postgres/user-adapter.ts index 839efa1f..f8f9a344 100644 --- a/src/adapters/postgres/user-adapter.ts +++ b/src/adapters/postgres/user-adapter.ts @@ -52,6 +52,8 @@ import { UserCreateSsoDto } from 'src/user/dto/user-create-sso.dto'; import { UserElasticsearchService } from '../../elasticsearch/user-elasticsearch.service'; import { IUser } from '../../elasticsearch/interfaces/user.interface'; import { isElasticsearchEnabled } from 'src/common/utils/elasticsearch.util'; +import { ReferralsService } from 'src/referrals/referrals.service'; +import { buildReferLink } from 'src/referrals/utils/referral-slug.util'; interface UpdateField { userId: string; // Required @@ -96,7 +98,8 @@ export class PostgresUserService implements IServicelocator { private postgresAcademicYearService: PostgresAcademicYearService, private readonly cohortAcademicYearService: CohortAcademicYearService, private readonly authUtils: AuthUtils, - private readonly userElasticsearchService: UserElasticsearchService + private readonly userElasticsearchService: UserElasticsearchService, + private readonly referralsService: ReferralsService ) { this.jwt_secret = this.configService.get('RBAC_JWT_SECRET'); this.jwt_password_reset_expires_In = this.configService.get( @@ -1991,6 +1994,21 @@ export class PostgresUserService implements IServicelocator { } LoggerUtil.log(API_RESPONSES.USER_CREATE_SUCCESSFULLY, apiId); + // Referral attribution (first-touch provided by client; do not block registration) + let referLinkForElastic: string | null = null; + try { + const incomingSlug = (userCreateDto as any)?.referralSlug ?? null; + const attr = await this.referralsService.createUserAttribution({ + userId: result.userId, + incomingSlug, + }); + referLinkForElastic = + attr.referLink ?? + (attr.resolvedSlug ? buildReferLink(attr.resolvedSlug) : null); + } catch (refError: any) { + LoggerUtil.error('Referral attribution failed', refError); + } + // Add Elasticsearch sync with custom fields try { if (isElasticsearchEnabled()) { @@ -2029,6 +2047,7 @@ export class PostgresUserService implements IServicelocator { email: result.email || '', mobile: result.mobile ? result.mobile.toString() : '', mobile_country_code: result.mobile_country_code || '', + referLink: referLinkForElastic ?? undefined, gender: result.gender, dob: result.dob instanceof Date @@ -2039,7 +2058,7 @@ export class PostgresUserService implements IServicelocator { currentCountry: result.currentCountry || '', status: result.status, customFields: elasticCustomFields, - }, + } as any, applications: [], courses: [], createdAt: result.createdAt diff --git a/src/app.module.ts b/src/app.module.ts index 10eb4b15..94c15deb 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -35,6 +35,7 @@ import { PaymentsModule } from './payments/payments.module'; import { PathwaysModule } from './pathways/pathways.module'; import { CountriesModule } from './countries/countries.module'; import { ContentModule } from './content/content.module'; +import { ReferralsModule } from './referrals/referrals.module'; /** * Main Application Module @@ -93,6 +94,7 @@ import { ContentModule } from './content/content.module'; PathwaysModule, CountriesModule, ContentModule, + ReferralsModule, ], controllers: [AppController, HealthController], providers: [ diff --git a/src/common/utils/api-id.config.ts b/src/common/utils/api-id.config.ts index dbc17c83..380d95c1 100644 --- a/src/common/utils/api-id.config.ts +++ b/src/common/utils/api-id.config.ts @@ -30,6 +30,15 @@ export const APIID = { USER_DELETE: "api.user.delete", USER_BULK_IMPORT: "api.user.bulkImport", + // Referral Management APIs + REFERRAL_CREATE: "api.referral.create", + REFERRAL_LIST: "api.referral.list", + REFERRAL_RESOLVE: "api.referral.resolve", + REFERRAL_UPDATE: "api.referral.update", + REFERRAL_IMPORT: "api.referral.import", + REFERRAL_BULK: "api.referral.bulk", + REFERRAL_REPORT: "api.referral.report", + // Role Management APIs ROLE_GET: "api.role.get", ROLE_CREATE: "api.role.create", diff --git a/src/common/utils/response.messages.ts b/src/common/utils/response.messages.ts index 58b75eb9..1599bce8 100644 --- a/src/common/utils/response.messages.ts +++ b/src/common/utils/response.messages.ts @@ -280,4 +280,13 @@ export const API_RESPONSES = { // Country Management Messages COUNTRY_LIST_SUCCESS: "Countries retrieved successfully", + + // Referral Management Messages + REFERRAL_CREATED_SUCCESSFULLY: "Referral created successfully", + REFERRAL_LIST_SUCCESS: "Referrals retrieved successfully", + REFERRAL_RESOLVED_SUCCESSFULLY: "Referral resolved successfully", + REFERRAL_UPDATED_SUCCESSFULLY: "Referral updated successfully", + REFERRAL_IMPORT_SUCCESS: "Referrals imported successfully", + REFERRAL_BULK_SUCCESS: "Bulk referrals processed successfully", + REFERRAL_REPORT_SUCCESS: "Referral report retrieved successfully", }; diff --git a/src/elasticsearch/elasticsearch.service.ts b/src/elasticsearch/elasticsearch.service.ts index 967c00d1..277cce6d 100644 --- a/src/elasticsearch/elasticsearch.service.ts +++ b/src/elasticsearch/elasticsearch.service.ts @@ -182,6 +182,7 @@ export class ElasticsearchService { email: source?.profile?.email || '', mobile: source?.profile?.mobile || '', mobile_country_code: source?.profile?.mobile_country_code || '', + ...(source?.profile?.referLink ? { referLink: source.profile.referLink } : {}), gender: source?.profile?.gender || '', dob:source?.profile?.dob || '', country:source?.profile?.country || '', diff --git a/src/elasticsearch/interfaces/user.interface.ts b/src/elasticsearch/interfaces/user.interface.ts index fb43e3fd..debaf5c0 100644 --- a/src/elasticsearch/interfaces/user.interface.ts +++ b/src/elasticsearch/interfaces/user.interface.ts @@ -48,6 +48,7 @@ export interface IProfile { email: string; mobile: string; mobile_country_code: string; + referLink?: string; gender: string; dob: string; country: string; diff --git a/src/elasticsearch/user-elasticsearch.service.ts b/src/elasticsearch/user-elasticsearch.service.ts index 80a970ff..f1218d01 100644 --- a/src/elasticsearch/user-elasticsearch.service.ts +++ b/src/elasticsearch/user-elasticsearch.service.ts @@ -81,6 +81,7 @@ export class UserElasticsearchService implements OnModuleInit { email: { type: 'keyword' }, mobile: { type: 'keyword' }, mobile_country_code: { type: 'keyword' }, + referLink: { type: 'keyword' }, gender: { type: 'keyword' }, dob: { type: 'date', null_value: null }, country: { @@ -379,6 +380,7 @@ export class UserElasticsearchService implements OnModuleInit { email: profile.email, mobile: profile.mobile, mobile_country_code: profile.mobile_country_code, + ...(profile.referLink ? { referLink: profile.referLink } : {}), gender: profile.gender, dob: profile.dob, country: this.normalizeCountryKeyword(profile.country), @@ -771,6 +773,17 @@ export class UserElasticsearchService implements OnModuleInit { return; } + // Handle referLink filtering — match by slug or full URL + if (field === 'referLink') { + const referLinkValue = typeof value === 'string' ? value : JSON.stringify(value); + searchQuery.bool.filter.push({ + wildcard: { + 'profile.referLink': `*${referLinkValue.toLowerCase()}*`, + }, + }); + return; + } + // Handle auto_tags filtering if (field === 'auto_tags') { const tags = Array.isArray(value) ? value : [value]; diff --git a/src/forms/forms.module.ts b/src/forms/forms.module.ts index 5785e4b7..202375a6 100644 --- a/src/forms/forms.module.ts +++ b/src/forms/forms.module.ts @@ -24,6 +24,7 @@ import { HttpModule } from '@nestjs/axios'; import { ConfigModule } from '@nestjs/config'; import { NotificationRequest } from 'src/common/utils/notification.axios'; import { CohortModule } from 'src/cohort/cohort.module'; +import { ReferralsModule } from 'src/referrals/referrals.module'; @Module({ controllers: [FormsController, FormSubmissionController], imports: [ @@ -44,6 +45,7 @@ import { CohortModule } from 'src/cohort/cohort.module'; HttpModule, ConfigModule, forwardRef(() => CohortModule), + ReferralsModule, ], providers: [ FormsService, diff --git a/src/forms/services/form-submission.service.ts b/src/forms/services/form-submission.service.ts index 040e9023..f495ab24 100644 --- a/src/forms/services/form-submission.service.ts +++ b/src/forms/services/form-submission.service.ts @@ -37,6 +37,7 @@ import { isElasticsearchEnabled } from 'src/common/utils/elasticsearch.util'; import { CohortMembers } from 'src/cohortMembers/entities/cohort-member.entity'; import { Cohort } from 'src/cohort/entities/cohort.entity'; import { FieldValueConverter } from 'src/utils/field-value-converter'; +import { ReferralsService } from 'src/referrals/referrals.service'; interface DateRange { start: string; @@ -94,7 +95,8 @@ export class FormSubmissionService { private readonly userElasticsearchService: UserElasticsearchService, private readonly formsService: FormsService, @Inject(forwardRef(() => PostgresCohortService)) - private readonly postgresCohortService: PostgresCohortService + private readonly postgresCohortService: PostgresCohortService, + private readonly referralsService: ReferralsService ) {} async create( @@ -3158,6 +3160,8 @@ export class FormSubmissionService { } } } + const referLink = await this.referralsService.getUserReferLink(user.userId); + // Build the IUser object return { userId: user.userId, @@ -3170,6 +3174,7 @@ export class FormSubmissionService { email: user.email || '', mobile: user.mobile ? user.mobile.toString() : '', mobile_country_code: user.mobile_country_code || '', + ...(referLink ? { referLink } : {}), gender: user.gender, dob: user.dob instanceof Date ? user.dob.toISOString() : user.dob || '', country: user.country, diff --git a/src/referrals/dto/create-referral-entity.dto.ts b/src/referrals/dto/create-referral-entity.dto.ts new file mode 100644 index 00000000..e1d431bc --- /dev/null +++ b/src/referrals/dto/create-referral-entity.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsEmail, IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + ReferralEntityStatus, + ReferralEntitySubType, + ReferralEntityType, +} from '../referrals.types'; + +export class CreateReferralEntityDto { + @ApiPropertyOptional({ description: 'Custom slug (any format accepted; will be normalized to lowercase a-z0-9_). If omitted, auto-generated.' }) + @IsOptional() + @IsString() + @MaxLength(100) + slug?: string; + + @ApiProperty({ description: 'First name (or full org/university name)' }) + @IsString() + @MaxLength(255) + firstName: string; + + @ApiPropertyOptional({ description: 'Last name (optional for org/university)' }) + @IsOptional() + @IsString() + @MaxLength(255) + lastName?: string; + + @ApiProperty({ enum: ReferralEntityType }) + @IsEnum(ReferralEntityType) + type: ReferralEntityType; + + @ApiProperty({ enum: ReferralEntitySubType }) + @IsEnum(ReferralEntitySubType) + subType: ReferralEntitySubType; + + @ApiPropertyOptional({ description: 'Region (optional)' }) + @IsOptional() + @IsString() + @MaxLength(100) + region?: string; + + @ApiPropertyOptional({ description: 'Linked internal entity UUID (optional)' }) + @IsOptional() + @IsString() + linkedEntityId?: string; + + @ApiPropertyOptional({ description: 'Primary contact email (optional)' }) + @IsOptional() + @IsEmail() + @MaxLength(255) + contactEmail?: string; + + @ApiPropertyOptional({ description: 'Additional emails (array of email strings)', type: [String] }) + @IsOptional() + @IsArray() + @IsEmail({}, { each: true }) + additionalEmails?: string[]; + + @ApiPropertyOptional({ description: 'Country (optional)' }) + @IsOptional() + @IsString() + @MaxLength(100) + country?: string; + + @ApiPropertyOptional({ enum: ReferralEntityStatus, default: ReferralEntityStatus.ACTIVE }) + @IsOptional() + @IsEnum(ReferralEntityStatus) + status?: ReferralEntityStatus; +} + diff --git a/src/referrals/dto/import-referrals.dto.ts b/src/referrals/dto/import-referrals.dto.ts new file mode 100644 index 00000000..cbe48166 --- /dev/null +++ b/src/referrals/dto/import-referrals.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class ImportReferralsDto { + @ApiProperty({ + description: + 'CSV content with header: firstName,lastName,type,subType,region,contactEmail,country', + }) + @IsString() + @IsNotEmpty() + csv: string; +} + diff --git a/src/referrals/dto/list-referrals.dto.ts b/src/referrals/dto/list-referrals.dto.ts new file mode 100644 index 00000000..7a53bc0b --- /dev/null +++ b/src/referrals/dto/list-referrals.dto.ts @@ -0,0 +1,55 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsEnum, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator'; +import { ReferralEntitySubType, ReferralEntityType } from '../referrals.types'; + +export class ReferralFiltersDto { + @ApiPropertyOptional({ enum: ReferralEntityType, description: 'Filter by referral type' }) + @IsOptional() + @IsEnum(ReferralEntityType) + type?: ReferralEntityType; + + @ApiPropertyOptional({ enum: ReferralEntitySubType, description: 'Filter by referral sub-type' }) + @IsOptional() + @IsEnum(ReferralEntitySubType) + subType?: ReferralEntitySubType; + + @ApiPropertyOptional({ type: String, description: 'Search across firstName, lastName, contactEmail, and slug' }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ type: [String], description: 'Filter by regions' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + regions?: string[]; + + @ApiPropertyOptional({ type: [String], description: 'Filter by countries' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + countries?: string[]; +} + +export class ListReferralsDto { + @ApiPropertyOptional({ description: 'Number of records to return', default: 10 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit?: number; + + @ApiPropertyOptional({ description: 'Number of records to skip', default: 0 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + offset?: number; + + @ApiPropertyOptional({ type: ReferralFiltersDto, description: 'Filters to apply' }) + @IsOptional() + @IsObject() + @Type(() => ReferralFiltersDto) + filters?: ReferralFiltersDto; +} diff --git a/src/referrals/dto/referral-report.dto.ts b/src/referrals/dto/referral-report.dto.ts new file mode 100644 index 00000000..a28342b5 --- /dev/null +++ b/src/referrals/dto/referral-report.dto.ts @@ -0,0 +1,87 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min, ValidateNested } from 'class-validator'; + +export enum ReferralUserStatus { + // Derived from users.temporaryPassword + REGISTERED = 'registered', // temporaryPassword = true (account created, never logged in) + ACTIVATED = 'activated', // temporaryPassword = false (has reset password / logged in) + // From users.status + ACTIVE = 'active', + INACTIVE = 'inactive', + ARCHIVED = 'archived', + // From cohortMembers.status + APPLIED = 'applied', + SUBMITTED = 'submitted', + SHORTLISTED = 'shortlisted', + REJECTED = 'rejected', + DROPOUT = 'dropout', +} + +export class ReferralReportFiltersDto { + @ApiPropertyOptional({ description: 'Filter by referral entity UUID (slug_id)' }) + @IsOptional() + @IsUUID() + slug_id?: string; + + @ApiPropertyOptional({ description: 'Filter by slug string (also checks slug history for old slugs)' }) + @IsOptional() + @IsString() + slug?: string; + + @ApiPropertyOptional({ description: 'Filter attributed users by cohort IDs', type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + cohortIds?: string[]; + + @ApiPropertyOptional({ + description: 'Filter by user status: registered | activated | active | inactive | applied | submitted | shortlisted | rejected | dropout', + enum: ReferralUserStatus, + isArray: true, + }) + @IsOptional() + @IsArray() + @IsEnum(ReferralUserStatus, { each: true }) + statuses?: ReferralUserStatus[]; + + @ApiPropertyOptional({ description: 'Filter users by auto_tags', type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ description: 'Filter attributed users by country (exact match, multiple OR)', type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + countries?: string[]; + + @ApiPropertyOptional({ description: 'Search by user first/last name or referral entity name (case-insensitive)' }) + @IsOptional() + @IsString() + name?: string; +} + +export class ReferralReportRequestDto { + @ApiPropertyOptional({ default: 10, minimum: 1, maximum: 1000 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(1000) + @Type(() => Number) + limit?: number; + + @ApiPropertyOptional({ default: 0, minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Type(() => Number) + offset?: number; + + @ApiPropertyOptional({ type: ReferralReportFiltersDto }) + @IsOptional() + @ValidateNested() + @Type(() => ReferralReportFiltersDto) + filters?: ReferralReportFiltersDto; +} diff --git a/src/referrals/dto/update-referral-slug.dto.ts b/src/referrals/dto/update-referral-slug.dto.ts new file mode 100644 index 00000000..e98f9232 --- /dev/null +++ b/src/referrals/dto/update-referral-slug.dto.ts @@ -0,0 +1,62 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsEmail, IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ReferralEntityStatus, ReferralEntitySubType, ReferralEntityType } from '../referrals.types'; + +export class UpdateReferralSlugDto { + @ApiPropertyOptional({ description: 'New slug (any format accepted; will be normalized to lowercase a-z0-9_)' }) + @IsOptional() + @IsString() + @MaxLength(100) + slug?: string; + + @ApiPropertyOptional({ description: 'First name (or full org/university name)' }) + @IsOptional() + @IsString() + @MaxLength(255) + firstName?: string; + + @ApiPropertyOptional({ description: 'Last name (optional for org/university)' }) + @IsOptional() + @IsString() + @MaxLength(255) + lastName?: string; + + @ApiPropertyOptional({ enum: ReferralEntityType }) + @IsOptional() + @IsEnum(ReferralEntityType) + type?: ReferralEntityType; + + @ApiPropertyOptional({ enum: ReferralEntitySubType }) + @IsOptional() + @IsEnum(ReferralEntitySubType) + subType?: ReferralEntitySubType; + + @ApiPropertyOptional({ description: 'Region (optional)' }) + @IsOptional() + @IsString() + @MaxLength(100) + region?: string; + + @ApiPropertyOptional({ description: 'Country (optional)' }) + @IsOptional() + @IsString() + @MaxLength(100) + country?: string; + + @ApiPropertyOptional({ description: 'Primary contact email' }) + @IsOptional() + @IsEmail() + @MaxLength(255) + contactEmail?: string; + + @ApiPropertyOptional({ description: 'Additional emails (array)', type: [String] }) + @IsOptional() + @IsArray() + @IsEmail({}, { each: true }) + additionalEmails?: string[]; + + @ApiPropertyOptional({ enum: ReferralEntityStatus }) + @IsOptional() + @IsEnum(ReferralEntityStatus) + status?: ReferralEntityStatus; +} diff --git a/src/referrals/entities/referral-entity.entity.ts b/src/referrals/entities/referral-entity.entity.ts new file mode 100644 index 00000000..86894e44 --- /dev/null +++ b/src/referrals/entities/referral-entity.entity.ts @@ -0,0 +1,65 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; +import { + ReferralEntityStatus, + ReferralEntitySubType, + ReferralEntityType, +} from '../referrals.types'; + +@Entity({ name: 'ReferralEntities' }) +export class ReferralEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + // As requested: use firstName/lastName instead of single "name". + // For organisations/universities, store the full display name in firstName and leave lastName null. + @Column({ type: 'varchar', length: 255 }) + firstName: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + lastName: string | null; + + @Index({ unique: true }) + @Column({ type: 'varchar', length: 100, unique: true }) + slug: string; + + @Column({ type: 'varchar', length: 50 }) + type: ReferralEntityType; + + @Column({ type: 'varchar', length: 50 }) + subType: ReferralEntitySubType; + + @Column({ type: 'varchar', length: 100, nullable: true }) + region: string | null; + + @Column({ type: 'uuid', nullable: true }) + linkedEntityId: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + contactEmail: string | null; + + @Column({ type: 'varchar', length: 500, nullable: true }) + additionalEmails: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + country: string | null; + + @Column({ type: 'varchar', length: 50, default: ReferralEntityStatus.ACTIVE }) + status: ReferralEntityStatus; + + @Column({ type: 'varchar', length: 255, nullable: true }) + createdBy: string | null; + + @CreateDateColumn({ type: 'timestamp', default: () => 'now()' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamp', default: () => 'now()' }) + updatedAt: Date; +} + diff --git a/src/referrals/entities/referral-slug-history.entity.ts b/src/referrals/entities/referral-slug-history.entity.ts new file mode 100644 index 00000000..59e79648 --- /dev/null +++ b/src/referrals/entities/referral-slug-history.entity.ts @@ -0,0 +1,30 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity({ name: 'ReferralSlugHistory' }) +export class ReferralSlugHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + referralEntityId: string; + + @Index({ unique: true }) + @Column({ type: 'varchar', length: 100, unique: true }) + oldSlug: string; + + @Column({ type: 'varchar', length: 100 }) + newSlug: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + changedBy: string | null; + + @CreateDateColumn({ type: 'timestamp', default: () => 'now()' }) + changedAt: Date; +} + diff --git a/src/referrals/entities/user-attribution.entity.ts b/src/referrals/entities/user-attribution.entity.ts new file mode 100644 index 00000000..3c9a34e0 --- /dev/null +++ b/src/referrals/entities/user-attribution.entity.ts @@ -0,0 +1,25 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity({ name: 'UserAttribution' }) +export class UserAttribution { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ type: 'uuid' }) + userId: string; + + @Index() + @Column({ type: 'uuid', nullable: true }) + referralEntityId: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + referralSlug: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + originalReferralSlug: string | null; + + @CreateDateColumn({ type: 'timestamp', default: () => 'now()' }) + createdAt: Date; +} + diff --git a/src/referrals/referrals.controller.ts b/src/referrals/referrals.controller.ts new file mode 100644 index 00000000..edf4a8f2 --- /dev/null +++ b/src/referrals/referrals.controller.ts @@ -0,0 +1,134 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UsePipes, ValidationPipe, UseFilters, Res, HttpStatus } from '@nestjs/common'; +import { ApiBody, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { CreateReferralEntityDto } from './dto/create-referral-entity.dto'; +import { ImportReferralsDto } from './dto/import-referrals.dto'; +import { UpdateReferralSlugDto } from './dto/update-referral-slug.dto'; +import { ListReferralsDto } from './dto/list-referrals.dto'; +import { ReferralReportRequestDto } from './dto/referral-report.dto'; +import { ReferralsService } from './referrals.service'; +import APIResponse from '../common/responses/response'; +import { APIID } from '../common/utils/api-id.config'; +import { API_RESPONSES } from '../common/utils/response.messages'; +import { AllExceptionsFilter } from '../common/filters/exception.filter'; + +@ApiTags('Referrals') +@Controller('referrals') +export class ReferralsController { + constructor(private readonly referralsService: ReferralsService) {} + + @UseFilters(new AllExceptionsFilter(APIID.REFERRAL_CREATE)) + @Post() + @UsePipes(new ValidationPipe({ transform: true })) + @ApiBody({ type: CreateReferralEntityDto }) + async create(@Body() dto: CreateReferralEntityDto, @Query('userId') userId: string, @Res() response: Response) { + const createdBy = userId ?? null; + const result = await this.referralsService.createReferralEntity(dto, createdBy); + return APIResponse.success( + response, + APIID.REFERRAL_CREATE, + result, + HttpStatus.CREATED, + API_RESPONSES.REFERRAL_CREATED_SUCCESSFULLY + ); + } + + @UseFilters(new AllExceptionsFilter(APIID.REFERRAL_LIST)) + @Post('list') + @UsePipes(new ValidationPipe({ transform: true })) + @ApiBody({ type: ListReferralsDto }) + @ApiOkResponse({ description: 'List referral entities with pagination and filters' }) + async list(@Body() dto: ListReferralsDto, @Res() response: Response) { + const result = await this.referralsService.listReferralEntities(dto); + return APIResponse.success( + response, + APIID.REFERRAL_LIST, + result, + HttpStatus.OK, + API_RESPONSES.REFERRAL_LIST_SUCCESS + ); + } + + @UseFilters(new AllExceptionsFilter(APIID.REFERRAL_RESOLVE)) + @Get('resolve') + async resolve(@Query('slug') slug: string, @Res() response: Response) { + const result = await this.referralsService.resolveSlug(slug); + return APIResponse.success( + response, + APIID.REFERRAL_RESOLVE, + result, + HttpStatus.OK, + API_RESPONSES.REFERRAL_RESOLVED_SUCCESSFULLY + ); + } + + @UseFilters(new AllExceptionsFilter(APIID.REFERRAL_UPDATE)) + @Patch(':id') + @UsePipes(new ValidationPipe({ transform: true })) + @ApiBody({ type: UpdateReferralSlugDto }) + async updateSlug( + @Param('id') id: string, + @Body() dto: UpdateReferralSlugDto, + @Query('userId') userId: string, + @Res() response: Response + ) { + const changedBy = userId ?? null; + const result = await this.referralsService.updateSlug(id, dto, changedBy); + return APIResponse.success( + response, + APIID.REFERRAL_UPDATE, + result, + HttpStatus.OK, + API_RESPONSES.REFERRAL_UPDATED_SUCCESSFULLY + ); + } + + @UseFilters(new AllExceptionsFilter(APIID.REFERRAL_IMPORT)) + @Post('import') + @UsePipes(new ValidationPipe({ transform: true })) + @ApiBody({ type: ImportReferralsDto }) + async import(@Body() dto: ImportReferralsDto, @Query('userId') userId: string, @Res() response: Response) { + const createdBy = userId ?? null; + const result = await this.referralsService.importFromCsv(dto, createdBy); + return APIResponse.success( + response, + APIID.REFERRAL_IMPORT, + result, + HttpStatus.CREATED, + API_RESPONSES.REFERRAL_IMPORT_SUCCESS + ); + } + + @UseFilters(new AllExceptionsFilter(APIID.REFERRAL_BULK)) + @Post('bulk') + @UsePipes(new ValidationPipe({ transform: true })) + @ApiBody({ type: [CreateReferralEntityDto] }) + async bulkInsert(@Body() dtos: CreateReferralEntityDto[], @Query('userId') userId: string, @Res() response: Response) { + const createdBy = userId ?? null; + const result = await this.referralsService.bulkInsert(dtos, createdBy); + return APIResponse.success( + response, + APIID.REFERRAL_BULK, + result, + HttpStatus.CREATED, + API_RESPONSES.REFERRAL_BULK_SUCCESS + ); + } + + @UseFilters(new AllExceptionsFilter(APIID.REFERRAL_REPORT)) + @Post('report') + @UsePipes(new ValidationPipe({ transform: true })) + @ApiBody({ type: ReferralReportRequestDto }) + @ApiOkResponse({ description: 'Referral tracking report with per-slug user counts aggregated by status, cohort and tag' }) + async getReport(@Body() dto: ReferralReportRequestDto, @Res() response: Response) { + const result = await this.referralsService.getReferralReport(dto); + return APIResponse.success( + response, + APIID.REFERRAL_REPORT, + result, + HttpStatus.OK, + API_RESPONSES.REFERRAL_REPORT_SUCCESS + ); + } +} + diff --git a/src/referrals/referrals.module.ts b/src/referrals/referrals.module.ts new file mode 100644 index 00000000..7de9297c --- /dev/null +++ b/src/referrals/referrals.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ReferralEntity } from './entities/referral-entity.entity'; +import { ReferralSlugHistory } from './entities/referral-slug-history.entity'; +import { UserAttribution } from './entities/user-attribution.entity'; +import { ReferralsController } from './referrals.controller'; +import { ReferralsService } from './referrals.service'; +import { User } from '../user/entities/user-entity'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ReferralEntity, ReferralSlugHistory, UserAttribution, User]), + ConfigModule, + ], + controllers: [ReferralsController], + providers: [ReferralsService], + exports: [ReferralsService], +}) +export class ReferralsModule {} + diff --git a/src/referrals/referrals.service.ts b/src/referrals/referrals.service.ts new file mode 100644 index 00000000..24ef1ce9 --- /dev/null +++ b/src/referrals/referrals.service.ts @@ -0,0 +1,632 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { ReferralReportFiltersDto, ReferralReportRequestDto, ReferralUserStatus } from './dto/referral-report.dto'; +import { ReferralEntity } from './entities/referral-entity.entity'; +import { ReferralSlugHistory } from './entities/referral-slug-history.entity'; +import { UserAttribution } from './entities/user-attribution.entity'; +import { User } from '../user/entities/user-entity'; +import { CreateReferralEntityDto } from './dto/create-referral-entity.dto'; +import { ImportReferralsDto } from './dto/import-referrals.dto'; +import { UpdateReferralSlugDto } from './dto/update-referral-slug.dto'; +import { ListReferralsDto } from './dto/list-referrals.dto'; +import { ReferralEntitySubType, ReferralEntityType } from './referrals.types'; +import { + buildReferLink, + generateReferralSlug, + isValidStandardSlug, + standardizeSlugInput, +} from './utils/referral-slug.util'; + +@Injectable() +export class ReferralsService { + constructor( + @InjectRepository(ReferralEntity) + private readonly referralRepo: Repository, + @InjectRepository(ReferralSlugHistory) + private readonly historyRepo: Repository, + @InjectRepository(UserAttribution) + private readonly attributionRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + private readonly configService: ConfigService, + private readonly dataSource: DataSource, + ) {} + + async createReferralEntity(dto: CreateReferralEntityDto, createdBy?: string) { + if (dto.contactEmail) { + const existingEmail = await this.referralRepo.findOne({ where: { contactEmail: dto.contactEmail } }); + if (existingEmail) { + throw new ConflictException(`Referral already exists for email ${dto.contactEmail}`); + } + + if (dto.type === ReferralEntityType.INTERNAL) { + const existingUser = await this.userRepo.findOne({ where: { email: dto.contactEmail.toLowerCase() } }); + if (!existingUser) { + throw new BadRequestException(`Internal user email ${dto.contactEmail} does not exist in the system`); + } + } + } + + const entity = this.referralRepo.create({ + ...dto, + lastName: dto.lastName ?? null, + region: dto.region ?? null, + linkedEntityId: dto.linkedEntityId ?? null, + contactEmail: dto.contactEmail ?? null, + additionalEmails: Array.isArray(dto.additionalEmails) + ? dto.additionalEmails.join(',') || null + : null, + country: dto.country ?? null, + createdBy: createdBy ?? null, + }); + + if (dto.slug) { + const normalizedSlug = standardizeSlugInput(dto.slug); + if (!normalizedSlug) { + throw new BadRequestException('Provided slug is invalid after normalization'); + } + const slugExists = await this.slugExistsAnywhere(normalizedSlug); + if (slugExists) { + throw new ConflictException(`Slug '${normalizedSlug}' already exists`); + } + entity.slug = normalizedSlug; + } else { + entity.slug = await this.generateUniqueSlug({ + type: dto.type, + subType: dto.subType, + firstName: dto.firstName, + lastName: dto.lastName ?? null, + }); + } + + try { + const saved = await this.referralRepo.save(entity); + return this.normalizeReferral({ ...saved, referLink: buildReferLink(saved.slug) }); + } catch (e: any) { + // Unique constraint race/collision + throw new ConflictException(e?.message ?? 'Failed to create referral entity'); + } + } + + async listReferralEntities(dto: ListReferralsDto = {}) { + const { limit = 10, offset = 0, filters } = dto; + const query = this.referralRepo.createQueryBuilder('referral'); + + if (filters) { + if (filters.type) { + query.andWhere('referral.type = :type', { type: filters.type }); + } + if (filters.subType) { + query.andWhere('referral.subType = :subType', { subType: filters.subType }); + } + if (filters.search) { + query.andWhere( + '(LOWER(referral.firstName) LIKE LOWER(:search) OR LOWER(referral.lastName) LIKE LOWER(:search) OR LOWER(referral.contactEmail) LIKE LOWER(:search) OR LOWER(referral.slug) LIKE LOWER(:search))', + { search: `%${filters.search}%` } + ); + } + if (filters.regions && filters.regions.length > 0) { + query.andWhere('referral.region IN (:...regions)', { regions: filters.regions }); + } + if (filters.countries && filters.countries.length > 0) { + query.andWhere('referral.country IN (:...countries)', { countries: filters.countries }); + } + } + + query.orderBy('referral.createdAt', 'DESC'); + query.skip(offset).take(limit); + + const [rows, total] = await query.getManyAndCount(); + + const signupCounts = rows.length + ? await this.dataSource.query<{ referralEntityId: string; signups: number }[]>( + `SELECT ua."referralEntityId", COUNT(DISTINCT ua."userId")::int AS signups + FROM "UserAttribution" ua + JOIN "Users" u ON u."userId" = ua."userId" + WHERE ua."referralEntityId" = ANY($1) + AND u."status" IN ('active', 'inactive') + GROUP BY ua."referralEntityId"`, + [rows.map((r) => r.id)], + ) + : []; + + const signupMap = new Map(signupCounts.map((s) => [s.referralEntityId, s.signups])); + + return { + data: rows.map((r) => ({ + ...this.normalizeReferral({ ...r, referLink: buildReferLink(r.slug) }), + signups: signupMap.get(r.id) ?? 0, + })), + total, + limit, + offset, + }; + } + + async resolveSlug(incomingSlug: string) { + const slug = standardizeSlugInput(incomingSlug); + if (!slug) { + throw new BadRequestException('Missing slug'); + } + + const active = await this.referralRepo.findOne({ where: { slug } }); + if (active) { + return { + entity: active, + originalSlug: incomingSlug, + resolvedSlug: active.slug, + referLink: buildReferLink(active.slug), + }; + } + + const hist = await this.historyRepo.findOne({ where: { oldSlug: slug } }); + if (!hist) { + return { + entity: null, + originalSlug: incomingSlug, + resolvedSlug: null, + referLink: null, + }; + } + + const resolved = await this.referralRepo.findOne({ + where: { id: hist.referralEntityId }, + }); + if (!resolved) { + return { + entity: null, + originalSlug: incomingSlug, + resolvedSlug: null, + referLink: null, + }; + } + + return { + entity: resolved, + originalSlug: incomingSlug, + resolvedSlug: resolved.slug, + referLink: buildReferLink(resolved.slug), + }; + } + + async updateSlug(referralEntityId: string, dto: UpdateReferralSlugDto, changedBy?: string) { + const entity = await this.referralRepo.findOne({ where: { id: referralEntityId } }); + if (!entity) { + throw new NotFoundException('Referral entity not found'); + } + + // ── Scalar field updates ────────────────────────────────────────────────── + if (dto.firstName !== undefined) entity.firstName = dto.firstName; + if (dto.lastName !== undefined) entity.lastName = dto.lastName ?? null; + if (dto.type !== undefined) entity.type = dto.type; + if (dto.subType !== undefined) entity.subType = dto.subType; + if (dto.region !== undefined) entity.region = dto.region ?? null; + if (dto.country !== undefined) entity.country = dto.country ?? null; + if (dto.status !== undefined) entity.status = dto.status; + if (dto.additionalEmails !== undefined) { + entity.additionalEmails = Array.isArray(dto.additionalEmails) + ? dto.additionalEmails.join(',') || null + : null; + } + + // ── contactEmail: check uniqueness + internal user validation ──────────── + const resolvedType = dto.type ?? entity.type; + if (dto.contactEmail !== undefined && dto.contactEmail !== entity.contactEmail) { + const existingEmail = await this.referralRepo.findOne({ where: { contactEmail: dto.contactEmail } }); + if (existingEmail && existingEmail.id !== entity.id) { + throw new ConflictException(`Contact email '${dto.contactEmail}' is already used by another referral`); + } + if (resolvedType === ReferralEntityType.INTERNAL) { + const existingUser = await this.userRepo.findOne({ where: { email: dto.contactEmail.toLowerCase() } }); + if (!existingUser) { + throw new BadRequestException(`Internal user email ${dto.contactEmail} does not exist in the system`); + } + } + entity.contactEmail = dto.contactEmail; + } else if (dto.type === ReferralEntityType.INTERNAL && entity.contactEmail) { + // Type changed to INTERNAL without changing email — validate existing contact email + const existingUser = await this.userRepo.findOne({ where: { email: entity.contactEmail.toLowerCase() } }); + if (!existingUser) { + throw new BadRequestException(`Internal user email ${entity.contactEmail} does not exist in the system`); + } + } + + // ── Slug: normalize any format, check uniqueness, preserve history ─────── + let pendingSlug: string | null = null; + if (dto.slug !== undefined) { + const newSlug = standardizeSlugInput(dto.slug); + if (!newSlug) { + throw new BadRequestException('Provided slug is invalid after normalization'); + } + if (!isValidStandardSlug(newSlug)) { + throw new BadRequestException('Slug must contain only lowercase a-z, 0-9, and _'); + } + if (newSlug !== entity.slug) { + await this.assertSlugUnique(newSlug); + pendingSlug = newSlug; + } + } + + // Wrap history record + entity save in a transaction so they succeed or fail together + const saved = await this.dataSource.transaction(async (manager) => { + if (pendingSlug !== null) { + await manager.save( + this.historyRepo.create({ + referralEntityId: entity.id, + oldSlug: entity.slug, + newSlug: pendingSlug, + changedBy: changedBy ?? null, + }), + ); + entity.slug = pendingSlug; + } + return manager.save(entity); + }); + return this.normalizeReferral({ ...saved, referLink: buildReferLink(saved.slug) }); + } + + async createUserAttribution(params: { + userId: string; + incomingSlug?: string | null; + }) { + const incoming = params.incomingSlug ? String(params.incomingSlug).trim() : ''; + if (!incoming) { + return { referLink: null, resolvedSlug: null, referralEntityId: null }; + } + + const resolved = await this.resolveSlug(incoming); + await this.attributionRepo.save( + this.attributionRepo.create({ + userId: params.userId, + referralEntityId: resolved.entity?.id ?? null, + referralSlug: resolved.resolvedSlug ?? null, + originalReferralSlug: incoming, + }) + ); + + return { + referLink: resolved.referLink ?? null, + resolvedSlug: resolved.resolvedSlug ?? null, + referralEntityId: resolved.entity?.id ?? null, + }; + } + + async getUserReferLink(userId: string): Promise { + const attr = await this.attributionRepo.findOne({ where: { userId } }); + if (!attr?.referralSlug) return null; + return buildReferLink(attr.referralSlug); + } + + async importFromCsv(dto: ImportReferralsDto, createdBy?: string) { + const csv = String(dto.csv || '').trim(); + if (!csv) throw new BadRequestException('CSV is empty'); + + const lines = csv.split(/\r?\n/).filter((l) => l.trim().length > 0); + if (lines.length < 2) throw new BadRequestException('CSV must include header and at least one row'); + + const header = lines[0].split(',').map((h) => h.trim()); + const idx = (name: string) => header.findIndex((h) => h.toLowerCase() === name.toLowerCase()); + + const iFirst = idx('firstName'); + const iLast = idx('lastName'); + const iType = idx('type'); + const iSubType = idx('subType'); + const iRegion = idx('region'); + const iEmail = idx('contactEmail'); + const iCountry = idx('country'); + + if (iFirst < 0 || iType < 0 || iSubType < 0) { + throw new BadRequestException('CSV header must include at least: firstName,type,subType'); + } + + const created: any[] = []; + const errors: any[] = []; + + for (let rowIdx = 1; rowIdx < lines.length; rowIdx++) { + const cols = lines[rowIdx].split(',').map((c) => c.trim()); + const firstName = cols[iFirst] ?? ''; + const lastName = iLast >= 0 ? cols[iLast] : ''; + const type = cols[iType] as ReferralEntityType; + const subType = cols[iSubType] as ReferralEntitySubType; + const region = iRegion >= 0 ? cols[iRegion] : undefined; + const contactEmail = iEmail >= 0 ? cols[iEmail] : undefined; + const country = iCountry >= 0 ? cols[iCountry] : undefined; + + try { + if (!firstName) throw new BadRequestException('firstName is required'); + if (!Object.values(ReferralEntityType).includes(type)) { + throw new BadRequestException(`Invalid type: ${type}`); + } + if (!Object.values(ReferralEntitySubType).includes(subType)) { + throw new BadRequestException(`Invalid subType: ${subType}`); + } + + const saved = await this.createReferralEntity( + { + firstName, + lastName: lastName || undefined, + type, + subType, + region, + contactEmail, + country, + }, + createdBy + ); + created.push(saved); + } catch (e: any) { + errors.push({ row: rowIdx + 1, error: e?.message ?? 'Failed' }); + } + } + + return { createdCount: created.length, errorCount: errors.length, created, errors }; + } + + async bulkInsert(dtos: CreateReferralEntityDto[], createdBy?: string) { + if (!Array.isArray(dtos) || dtos.length === 0) { + throw new BadRequestException('Payload must be a non-empty array of referrals'); + } + + const rawBatchSize = Number.parseInt(this.configService.get('REFERRAL_BULK_BATCH_SIZE') || '100', 10); + const batchSize = Number.isFinite(rawBatchSize) && rawBatchSize > 0 ? rawBatchSize : 100; + const created: any[] = []; + const errors: any[] = []; + + for (let i = 0; i < dtos.length; i += batchSize) { + const batch = dtos.slice(i, i + batchSize); + + const batchPromises = batch.map(async (dto, idx) => { + const globalIdx = i + idx; + try { + const saved = await this.createReferralEntity(dto, createdBy); + created.push(saved); + } catch (e: any) { + errors.push({ index: globalIdx, data: dto, error: e?.message ?? 'Failed' }); + } + }); + + await Promise.all(batchPromises); + } + + return { createdCount: created.length, errorCount: errors.length, created, errors }; + } + + private normalizeReferral(entity: ReferralEntity & { referLink?: string }) { + const emailsArr = entity.additionalEmails + ? String(entity.additionalEmails).split(',').map((e) => e.trim()).filter(Boolean) + : []; + return { + ...entity, + additionalEmails: emailsArr, + additionalEmailCount: emailsArr.length, + }; + } + + private async generateUniqueSlug(params: { + type: ReferralEntityType; + subType: ReferralEntitySubType; + firstName: string; + lastName?: string | null; + }) { + for (let attempt = 0; attempt < 10; attempt++) { + const slug = generateReferralSlug({ + type: params.type, + subType: params.subType, + firstName: params.firstName, + lastName: params.lastName ?? null, + }); + const exists = await this.slugExistsAnywhere(slug); + if (!exists) return slug; + } + throw new ConflictException('Failed to generate unique slug'); + } + + private async assertSlugUnique(slug: string) { + const exists = await this.slugExistsAnywhere(slug); + if (exists) { + throw new ConflictException('Slug already exists'); + } + } + + private async slugExistsAnywhere(slug: string) { + const active = await this.referralRepo.count({ where: { slug } }); + if (active > 0) return true; + const hist = await this.historyRepo.count({ where: { oldSlug: slug } }); + return hist > 0; + } + + // ── Referral Report (user-centric) ──────────────────────────────────────── + + async getReferralReport(dto: ReferralReportRequestDto) { + const { limit = 10, offset = 0, filters = {} } = dto; + + const { fromSql, whereClause, params } = this.buildReportBase(filters); + + const listSql = ` + SELECT + re."id" AS "slug_id", + re."slug", + re."firstName" AS "referralName", + re."lastName" AS "referralLastName", + re."contactEmail" AS "referralContactEmail", + re."type" AS "referralType", + re."subType" AS "referralSubType", + u."userId", + u."firstName", + u."lastName", + u."email", + u."status" AS "accountStatus", + u."country", + u."createdAt", + u."auto_tags" AS "tags", + ua."createdAt" AS "attributedAt" + ${fromSql} + ${whereClause} + ORDER BY ua."createdAt" DESC + LIMIT $${params.length + 1} OFFSET $${params.length + 2} + `; + + const countSql = `SELECT COUNT(DISTINCT ua."userId")::int AS count ${fromSql} ${whereClause}`; + + const [[countRow], userRows] = await Promise.all([ + this.dataSource.query(countSql, params), + this.dataSource.query(listSql, [...params, limit, offset]), + ]); + + const totalCount = countRow?.count ?? 0; + + if (userRows.length === 0) { + return { data: [], totalCount, limit, offset, hasMore: false }; + } + + // Fetch cohort memberships for returned users — scoped to filtered cohorts if provided + const userIds = [...new Set(userRows.map((r) => r.userId))]; + const membershipQuery = filters.cohortIds?.length + ? `SELECT cm."userId", cm."cohortId", cm."status", c."name" AS "cohortName" + FROM "CohortMembers" cm + LEFT JOIN "Cohort" c ON c."cohortId" = cm."cohortId" + WHERE cm."userId" = ANY($1) AND cm."cohortId" = ANY($2)` + : `SELECT cm."userId", cm."cohortId", cm."status", c."name" AS "cohortName" + FROM "CohortMembers" cm + LEFT JOIN "Cohort" c ON c."cohortId" = cm."cohortId" + WHERE cm."userId" = ANY($1)`; + const membershipParams = filters.cohortIds?.length ? [userIds, filters.cohortIds] : [userIds]; + + const memberships = await this.dataSource.query(membershipQuery, membershipParams); + const membershipMap = new Map(); + for (const m of memberships) { + if (!membershipMap.has(m.userId)) membershipMap.set(m.userId, []); + membershipMap.get(m.userId)?.push({ cohortId: m.cohortId, cohortName: m.cohortName ?? null, status: m.status }); + } + + const data = userRows.map((row) => { + const cohortMemberships = membershipMap.get(row.userId) ?? []; + + return { + slug_id: row.slug_id, + slug: row.slug, + referralName: [row.referralName, row.referralLastName].filter(Boolean).join(' '), + referralContactEmail: row.referralContactEmail, + referralType: row.referralType, + referralSubType: row.referralSubType, + userId: row.userId, + firstName: row.firstName, + lastName: row.lastName, + email: row.email, + country: row.country, + createdAt: row.createdAt, + attributedAt: row.attributedAt, + accountStatus: row.accountStatus, + tags: row.tags ?? [], + cohortMemberships, + }; + }); + + return { data, totalCount, limit, offset, hasMore: offset + limit < totalCount }; + } + + + private buildReportBase(filters: ReferralReportFiltersDto): { fromSql: string; whereClause: string; params: any[] } { + const conds: string[] = []; + const params: any[] = []; + let idx = 1; + + // ── Slug / slug_id filter ───────────────────────────────────────────────── + if (filters.slug_id && filters.slug) { + conds.push( + `(re."id" = $${idx} OR re."slug" = $${idx + 1} OR EXISTS(SELECT 1 FROM "ReferralSlugHistory" rsh WHERE rsh."referralEntityId" = re."id" AND rsh."oldSlug" = $${idx + 1}))`, + ); + params.push(filters.slug_id, filters.slug); + idx += 2; + } else if (filters.slug_id) { + conds.push(`re."id" = $${idx++}`); + params.push(filters.slug_id); + } else if (filters.slug) { + conds.push( + `(re."slug" = $${idx} OR EXISTS(SELECT 1 FROM "ReferralSlugHistory" rsh WHERE rsh."referralEntityId" = re."id" AND rsh."oldSlug" = $${idx}))`, + ); + params.push(filters.slug); + idx++; + } + + // ── Cohort filter ───────────────────────────────────────────────────────── + let cohortIdsParamIdx: number | null = null; + if (filters.cohortIds?.length) { + cohortIdsParamIdx = idx; // remember position so status filter can reuse it + conds.push( + `EXISTS (SELECT 1 FROM "CohortMembers" cf WHERE cf."userId" = ua."userId" AND cf."cohortId" = ANY($${idx++}))`, + ); + params.push(filters.cohortIds); + } + + // ── Tags filter ─────────────────────────────────────────────────────────── + if (filters.tags?.length) { + conds.push(`u."auto_tags" && $${idx++}`); + params.push(filters.tags); + } + + // ── Country filter ──────────────────────────────────────────────────────── + if (filters.countries?.length) { + conds.push(`u."country" = ANY($${idx++})`); + params.push(filters.countries); + } + + // ── Name search (user firstName/lastName or referral entity name) ───────── + if (filters.name?.trim()) { + const pattern = `%${filters.name.trim()}%`; + conds.push( + `(u."firstName" ILIKE $${idx} OR u."lastName" ILIKE $${idx} OR re."firstName" ILIKE $${idx} OR re."lastName" ILIKE $${idx})`, + ); + params.push(pattern); + idx++; + } + + // ── Status filter (OR across all provided statuses) ─────────────────────── + if (filters.statuses?.length) { + const sc: string[] = []; + const s = filters.statuses; + + if (s.includes(ReferralUserStatus.REGISTERED)) sc.push(`u."temporaryPassword" = true`); + if (s.includes(ReferralUserStatus.ACTIVATED)) sc.push(`u."temporaryPassword" = false`); + + const accountStatuses = s.filter((x) => ( + [ReferralUserStatus.ACTIVE, ReferralUserStatus.INACTIVE, ReferralUserStatus.ARCHIVED] as string[] + ).includes(x)); + if (accountStatuses.length) { + sc.push(`u."status" = ANY($${idx++})`); + params.push(accountStatuses); + } + + const cohortStatuses = s.filter((x) => ( + [ReferralUserStatus.APPLIED, ReferralUserStatus.SUBMITTED, + ReferralUserStatus.SHORTLISTED, ReferralUserStatus.REJECTED, ReferralUserStatus.DROPOUT] as string[] + ).includes(x)); + if (cohortStatuses.length) { + // When cohortIds filter is also active, scope status check to those same cohorts. + // This prevents a user with "applied" in cohort B from passing when cohort A is filtered. + const cohortScope = cohortIdsParamIdx === null + ? '' + : `AND csf."cohortId" = ANY($${cohortIdsParamIdx})`; + sc.push( + `EXISTS (SELECT 1 FROM "CohortMembers" csf WHERE csf."userId" = ua."userId" AND csf."status" = ANY($${idx++}) ${cohortScope})`, + ); + params.push(cohortStatuses); + } + + if (sc.length) conds.push(`(${sc.join(' OR ')})`); + } + + const whereClause = conds.length ? `WHERE ${conds.join(' AND ')}` : ''; + const fromSql = ` + FROM "UserAttribution" ua + JOIN "ReferralEntities" re ON re."id" = ua."referralEntityId" + JOIN "Users" u ON u."userId" = ua."userId" + `; + + return { fromSql, whereClause, params }; + } + +} + diff --git a/src/referrals/referrals.types.ts b/src/referrals/referrals.types.ts new file mode 100644 index 00000000..0cc40481 --- /dev/null +++ b/src/referrals/referrals.types.ts @@ -0,0 +1,16 @@ +export enum ReferralEntityType { + EXTERNAL = 'external', + INTERNAL = 'internal', +} + +export enum ReferralEntitySubType { + ORGANISATION = 'organisation', + UNIVERSITY = 'university', + ALUMNI = 'alumni', +} + +export enum ReferralEntityStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', +} + diff --git a/src/referrals/utils/referral-slug.util.ts b/src/referrals/utils/referral-slug.util.ts new file mode 100644 index 00000000..61336274 --- /dev/null +++ b/src/referrals/utils/referral-slug.util.ts @@ -0,0 +1,70 @@ +import { randomBytes } from 'node:crypto'; +import { ReferralEntitySubType, ReferralEntityType } from '../referrals.types'; + +export const DEFAULT_REFERRAL_BASE_URL = () => { + const frontendUrl = (process.env.FRONTEND_URL ?? '').replace(/\/$/, ''); + return `${frontendUrl}/registration`; +}; + +export function buildReferLink(slug: string, baseUrl?: string): string { + const base = baseUrl ?? DEFAULT_REFERRAL_BASE_URL(); + const s = String(slug || '').trim(); + if (!s) return base; + const sep = base.includes('?') ? '&' : '?'; + return `${base}${sep}refer=${encodeURIComponent(s)}`; +} + +function randomBase36(length: number): string { + // Generate more entropy than needed, then slice. + // Base36 chars: 0-9a-z + const raw = randomBytes(Math.ceil((length * 5) / 2)).toString('hex'); // hex + const asBase36 = BigInt('0x' + raw).toString(36); + return asBase36.padStart(length, '0').slice(0, length); +} + +export function standardizeSlugInput(input: string): string { + // Lowercase, remove accents, keep [a-z0-9_], collapse spaces to nothing. + // Use NFKD so accents become combining marks; then strip them. + const s = String(input ?? '') + .trim() + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') // diacritics + .replace(/\s+/g, '') // remove spaces + .replace(/[^a-z0-9_]/g, ''); // remove special chars + return s; +} + +export function standardizeNameForSlug(firstName: string, lastName?: string) { + const a = standardizeSlugInput(firstName); + const b = lastName ? standardizeSlugInput(lastName) : ''; + if (a && b) return `${a}_${b}`; + return a || b; +} + +export function generateReferralSlug(params: { + type: ReferralEntityType; + subType: ReferralEntitySubType; + firstName: string; + lastName?: string | null; +}): string { + const { type, subType, firstName, lastName } = params; + + if (type === ReferralEntityType.INTERNAL && subType === ReferralEntitySubType.ALUMNI) { + // Internal alumni: random, non-identifiable + return randomBase36(8); + } + + // External: human-readable name + random suffix + const base = standardizeNameForSlug(firstName, lastName ?? undefined); + const suffix = randomBase36(6); + return `${base}_${suffix}`; +} + +export function isValidStandardSlug(slug: string): boolean { + const s = String(slug || '').trim(); + if (!s) return false; + if (s !== s.toLowerCase()) return false; + return /^[a-z0-9_]+$/.test(s); +} + diff --git a/src/user/dto/user-create.dto.ts b/src/user/dto/user-create.dto.ts index 8192f42d..aa2567c1 100644 --- a/src/user/dto/user-create.dto.ts +++ b/src/user/dto/user-create.dto.ts @@ -262,4 +262,15 @@ export class UserCreateDto { @Expose() @IsOptional() recaptchaToken?: string; + + @ApiPropertyOptional({ + type: String, + description: + 'Referral slug from /apply?refer={slug} (stored as first-touch attribution)', + }) + @Expose() + @IsOptional() + @IsString() + @MaxLength(100) + referralSlug?: string; }