Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
40bbe58
feat(auth): add terms acceptance validation and locked account check …
nsien-prestige May 9, 2026
81ec421
feat(auth): inject UserSession repository into auth service
nsien-prestige May 9, 2026
d2e31f4
chore(db): add terms_accepted column migration and fix migrations path
nsien-prestige May 9, 2026
6ed6ce8
feat(auth): implement registration with session, redis, refresh token…
nsien-prestige May 9, 2026
32e0e9f
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] May 9, 2026
7904d36
refactor(email): relocate MailerModule from AppModule and add Resend …
sage-ali May 9, 2026
6cf558f
fix(email): add missing @Injectable decorator and retry config to Que…
sage-ali May 9, 2026
d450de3
refactor(email): centralise error handling in EmailQueueConsumer
sage-ali May 9, 2026
6de352d
feat(config): add mailerConfig and register in AppModule
sage-ali May 10, 2026
a4c7aa5
refactor(email): move MailerModule into EmailModule using mailerConfi…
sage-ali May 10, 2026
898b278
feat(mail): add script to test resend
sage-ali May 10, 2026
92f5228
fix: failing test by adding required parameters
sage-ali May 10, 2026
e57457f
fix(config): validate required SMTP vars and guard from address in ma…
sage-ali May 10, 2026
0ee4431
fix(email): mask recipient email in all EmailQueueConsumer log calls
sage-ali May 10, 2026
be380bc
ci: restructure workflows into reusable pipeline with security gates
B-Nockk May 10, 2026
497719f
feat: add manaual workflow triggers
B-Nockk May 10, 2026
97db70d
add branch based triggers
B-Nockk May 10, 2026
7ca2c93
fix: remote location folder
B-Nockk May 10, 2026
c780d07
feat: make dir in server
B-Nockk May 10, 2026
1da812a
feat: fix auth retries errors
B-Nockk May 10, 2026
81ff2fd
fix(auth): address PR review comments - semicolons, cookie assertion,…
nsien-prestige May 10, 2026
16ae8e0
Merge branch 'dev' into feat/auth-register
nsien-prestige May 10, 2026
c7ee3de
Merge branch 'dev' into feat/auth-register
nsien-prestige May 10, 2026
31dc445
fix(auth): resolve PR comments
nsien-prestige May 11, 2026
7bb526b
docs(auth): remove swagger from authController and add register swagg…
nsien-prestige May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/database/migrations/1778335054510-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class Migration1778335054510 implements MigrationInterface {
name = 'Migration1778335054510';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "terms_accepted" boolean NOT NULL DEFAULT false`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "terms_accepted"`);
}
}
8 changes: 4 additions & 4 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Body, Controller, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { Request, Response } from 'express';
import * as SYS_MSG from '@shared/constants/SystemMessages';
import { skipAuth } from '@shared/helpers/skipAuth';
import AuthenticationService from './auth.service';
Expand All @@ -20,8 +20,8 @@ export default class RegistrationController {
@ApiBody({ type: CreateUserDTO })
Comment thread
nsien-prestige marked this conversation as resolved.
Resolved
@ApiResponse({ status: HttpStatus.CREATED, description: SYS_MSG.USER_CREATED_SUCCESSFULLY })
@ApiResponse({ status: HttpStatus.BAD_REQUEST, description: SYS_MSG.USER_ACCOUNT_EXIST })
async register(@Body() body: CreateUserDTO) {
return this.authService.createNewUser(body);
async register(@Body() body: CreateUserDTO, @Res({ passthrough: true }) response: Response) {
return this.authService.createNewUser(body, response);
}

@skipAuth()
Expand Down
107 changes: 93 additions & 14 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,125 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DataSource, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import * as SYS_MSG from '@shared/constants/SystemMessages';
import { CustomHttpException } from '@shared/helpers/custom-http-filter';
import { User } from '@modules/user/entities/user.entity';
import { CreateUserDTO } from './dto/create-user.dto';
import { LoginDto } from './dto/login.dto';
import { UserSession } from './entities/user-session.entity';
import * as crypto from 'crypto';
import { Response } from 'express';
import { RedisService } from '@modules/redis/services/redis.service';
import { AuthMetadata } from './entities/auth-metadata.entity';

const OTP_LENGTH = 6;
const OTP_EXPIRY_MINUTES = 10;

@Injectable()
export default class AuthenticationService {
private readonly logger = new Logger(AuthenticationService.name);

constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService

@InjectRepository(UserSession)
private readonly userSessionRepository: Repository<UserSession>,

@InjectRepository(AuthMetadata)
private readonly authMetaData: Repository<AuthMetadata>,

private readonly jwtService: JwtService,
private readonly redisService: RedisService,
private readonly dataSource: DataSource
) {}

async createNewUser(createUserDto: CreateUserDTO) {
async createNewUser(createUserDto: CreateUserDTO, response: Response) {
if (!createUserDto.terms_accepted) {
throw new CustomHttpException(SYS_MSG.TERMS_AND_CONDITIONS, HttpStatus.BAD_REQUEST);
}
Comment thread
nsien-prestige marked this conversation as resolved.
Resolved

const existing = await this.userRepository.findOne({ where: { email: createUserDto.email } });
if (existing) {
if (!existing.is_active) {
throw new CustomHttpException(SYS_MSG.USER_ACCOUNT_LOCKED, HttpStatus.LOCKED);
}
throw new CustomHttpException(SYS_MSG.USER_ACCOUNT_EXIST, HttpStatus.BAD_REQUEST);
Comment thread
nsien-prestige marked this conversation as resolved.
Resolved
}

const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
const user = this.userRepository.create({
email: createUserDto.email,
full_name: createUserDto.full_name,
country: createUserDto.country ?? null,
password: hashedPassword,
auth_provider: 'email',
otp_code: this.generateOtp(),
expires_at: this.computeOtpExpiry(),

const refreshTokenExpiry = new Date();
refreshTokenExpiry.setDate(refreshTokenExpiry.getDate() + 7);
Comment thread
nsien-prestige marked this conversation as resolved.
Resolved

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

let saved: User;
let userSession: UserSession;

try {
const user = queryRunner.manager.create(User, {
email: createUserDto.email,
full_name: createUserDto.full_name,
country: createUserDto.country ?? null,
password: hashedPassword,
auth_provider: 'email',
terms_accepted: true,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
otp_code: this.generateOtp(),
expires_at: this.computeOtpExpiry(),
});
saved = await queryRunner.manager.save(user);

userSession = queryRunner.manager.create(UserSession, {
user_id: saved.id,
refresh_token: this.generateRefreshToken(),
expires_at: refreshTokenExpiry,
Comment thread
nsien-prestige marked this conversation as resolved.
Resolved
is_revoked: false,
});
await queryRunner.manager.save(userSession);

const redisKey = `sess:${saved.id}:${userSession.id}`;
await this.redisService.set(redisKey, userSession.id, 900);
Comment thread
nsien-prestige marked this conversation as resolved.
Resolved

const authMetaData = queryRunner.manager.create(AuthMetadata, {
user_id: saved.id,
last_login_at: new Date(),
});
await queryRunner.manager.save(authMetaData);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(`Registration failed: ${(error as Error).message}`);
throw new CustomHttpException(SYS_MSG.SESSION_CREATION_FAILED, HttpStatus.INTERNAL_SERVER_ERROR);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} finally {
await queryRunner.release();
}

response.cookie('refresh_token', userSession.refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
const saved = await this.userRepository.save(user);

const access_token = this.jwtService.sign({ id: saved.id, sub: saved.id, email: saved.email });
const access_token = this.jwtService.sign({
id: saved.id,
sub: saved.id,
session_id: userSession.id,
email: saved.email,
});

return {
status_code: HttpStatus.CREATED,
message: SYS_MSG.USER_CREATED_SUCCESSFULLY,
access_token,
data: {
redirect_url: '/dashboard',
user: {
id: saved.id,
full_name: saved.full_name,
Expand Down Expand Up @@ -111,4 +186,8 @@ export default class AuthenticationService {
private computeOtpExpiry(): Date {
return new Date(Date.now() + OTP_EXPIRY_MINUTES * 60 * 1000);
}

private generateRefreshToken(): string {
return crypto.randomBytes(40).toString('hex');
}
}
11 changes: 10 additions & 1 deletion src/modules/auth/dto/create-user.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsOptional, IsString, IsStrongPassword, MaxLength, MinLength } from 'class-validator';
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString, IsStrongPassword, MaxLength, MinLength } from 'class-validator';

export class CreateUserDTO {
@ApiProperty({
Expand Down Expand Up @@ -43,4 +43,13 @@ export class CreateUserDTO {
}
)
password: string;

@ApiProperty({
description: 'The user must accept the Terms and Conditions of the application to continue with signup',
example: true,
required: true
})
@IsBoolean()
@IsNotEmpty()
terms_accepted: boolean
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
55 changes: 46 additions & 9 deletions src/modules/auth/tests/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import * as SYS_MSG from '@shared/constants/SystemMessages';
import { CustomHttpException } from '@shared/helpers/custom-http-filter';
import { User } from '@modules/user/entities/user.entity';
import AuthenticationService from '../auth.service';
import { UserSession } from '../entities/user-session.entity';
import { DataSource } from 'typeorm';
import { AuthMetadata } from '../entities/auth-metadata.entity';
import { RedisService } from '@modules/redis/services/redis.service';
import { AnyAaaaRecord, AnyCaaRecord } from 'node:dns';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

describe('AuthenticationService', () => {
let service: AuthenticationService;
Expand All @@ -16,16 +21,51 @@ describe('AuthenticationService', () => {
create: jest.fn(),
save: jest.fn(),
};
const userSessionRepositoryMock = {
create: jest.fn(),
save: jest.fn(),
};
const jwtServiceMock = {
sign: jest.fn(),
};
const authMetadataRepositoryMock = {
create: jest.fn(),
save: jest.fn(),
};
const redisServiceMock = {
set: jest.fn(),
};

const dataSourceMock = {
createQueryRunner: jest.fn().mockReturnValue({
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
manager: {
create: jest.fn().mockImplementation((entity, data) => data),
save: jest
.fn()
.mockResolvedValue({ id: 'user-1', email: 'jane@example.com', full_name: 'Jane Doe', avatar_url: null }),
},
}),
};
Comment thread
nsien-prestige marked this conversation as resolved.
Resolved

const responseMock = {
cookie: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthenticationService,
{ provide: getRepositoryToken(User), useValue: userRepositoryMock },
{ provide: getRepositoryToken(UserSession), useValue: userSessionRepositoryMock },
{ provide: getRepositoryToken(AuthMetadata), useValue: authMetadataRepositoryMock },
{ provide: JwtService, useValue: jwtServiceMock },
{ provide: RedisService, useValue: redisServiceMock },
{ provide: DataSource, useValue: dataSourceMock },
],
}).compile();

Expand All @@ -46,6 +86,7 @@ describe('AuthenticationService', () => {
full_name: 'Jane Doe',
password: 'P@ssword123',
country: 'Nigeria',
terms_accepted: true,
};

it('creates a user when none exists with that email', async () => {
Expand All @@ -59,7 +100,7 @@ describe('AuthenticationService', () => {
});
jwtServiceMock.sign.mockReturnValueOnce('jwt');

const result = await service.createNewUser(dto);
const result = await service.createNewUser(dto, responseMock as any);
Comment thread
nsien-prestige marked this conversation as resolved.
Resolved

expect(result.status_code).toBe(HttpStatus.CREATED);
expect(result.message).toBe(SYS_MSG.USER_CREATED_SUCCESSFULLY);
Expand All @@ -70,15 +111,11 @@ describe('AuthenticationService', () => {
email: dto.email,
avatar_url: null,
});
const created = userRepositoryMock.create.mock.calls[0][0];
expect(created.auth_provider).toBe('email');
expect(created.otp_code).toMatch(/^\d{6}$/);
expect(created.expires_at).toBeInstanceOf(Date);
});

it('throws when a user with that email already exists', async () => {
userRepositoryMock.findOne.mockResolvedValueOnce({ id: 'existing' });
await expect(service.createNewUser(dto)).rejects.toThrow(CustomHttpException);
await expect(service.createNewUser(dto, responseMock as any)).rejects.toThrow(CustomHttpException);
Comment thread
nsien-prestige marked this conversation as resolved.
Resolved
});
});

Expand Down Expand Up @@ -115,9 +152,9 @@ describe('AuthenticationService', () => {
avatar_url: null,
password: hashed,
});
await expect(
service.loginUser({ email: 'jane@example.com', password: 'wrong-password' })
).rejects.toThrow(CustomHttpException);
await expect(service.loginUser({ email: 'jane@example.com', password: 'wrong-password' })).rejects.toThrow(
CustomHttpException
);
});

it('rejects accounts without a stored password (OAuth-only)', async () => {
Expand Down
3 changes: 3 additions & 0 deletions src/modules/user/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export class User extends AbstractBaseEntity {
@Column({ type: 'timestamp', nullable: false })
expires_at: Date;

@Column({ type: 'boolean', nullable: false, default: false })
terms_accepted: boolean
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

@DeleteDateColumn({ name: 'deleted_at', type: 'timestamp', nullable: true })
deleted_at: Date | null;
}
3 changes: 3 additions & 0 deletions src/shared/constants/SystemMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const FAILED_TO_CREATE_USER = 'Error Occured while creating user, kindly
export const ERROR_OCCURED = 'Error Occured Performing this request';
export const USER_ACCOUNT_EXIST = 'Account with the specified email exists';
export const USER_ACCOUNT_DOES_NOT_EXIST = "Account with the specified email doesn't exist";
export const USER_ACCOUNT_LOCKED = 'Account with the specified email is locked';
export const UNAUTHENTICATED_MESSAGE = 'User is currently unauthorized, kindly authenticate to continue';
export const TWO_FACTOR_VERIFIED_SUCCESSFULLY = '2FA verified and enabled';
export const ANALYTICS_FETCHED_SUCCESSFULLY = 'Admin Analytics fetched successfully';
Expand Down Expand Up @@ -130,3 +131,5 @@ export const REDIS_MESSAGES = {
PATTERN_DELETE_SUCCESS: (count: number, pattern: string) =>
`delByPattern: deleted ${count} keys matching [${pattern}]`,
};
export const TERMS_AND_CONDITIONS = 'You must accept the terms and conditions to register';
export const SESSION_CREATION_FAILED = 'Registration failed. Please try again';
Loading