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 all 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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ JWT_EXPIRY_TIMEFRAME=3600

ADMIN_SECRET_KEY=sometext

REFRESH_TOKEN_EXPIRY=7

GOOGLE_CLIENT_SECRET=
GOOGLE_CLIENT_ID=

Expand Down
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,3 @@ package-lock.json
docker-compose.yml
data/
.dev.env

temp_auto_push.bat
temp_interactive_push.bat
2 changes: 0 additions & 2 deletions .husky/pre-commit

This file was deleted.

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"`);
}
}
10 changes: 3 additions & 7 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Body, Controller, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import * as SYS_MSG from '@shared/constants/SystemMessages';
import { skipAuth } from '@shared/helpers/skipAuth';
Expand All @@ -9,20 +9,16 @@ import { LoginDto } from './dto/login.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { SendOtpDto } from './dto/send-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { SendOtpDocs, VerifyOtpDocs, ResendOtpDocs, LoginDocs, ChangePasswordDocs } from './docs/auth-swagger.doc';
import { SendOtpDocs, VerifyOtpDocs, ResendOtpDocs, LoginDocs, ChangePasswordDocs, RegisterDocs } from './docs/auth-swagger.doc';

@ApiTags('Authentication')
@Controller('auth')
export default class RegistrationController {
constructor(private readonly authService: AuthenticationService) { }

@skipAuth()
@RegisterDocs()
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new user' })
@ApiBody({ type: CreateUserDTO })
@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);
}
Expand Down
98 changes: 82 additions & 16 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 { randomInt } from 'crypto';
import authConfig from '@config/auth.config';
Expand All @@ -10,7 +10,9 @@ 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 { RedisService } from '@modules/redis/services/redis.service';
import { AuthMetadata } from './entities/auth-metadata.entity';
import QueueService from '@modules/email/queue.service';
import { LockoutService } from './lockout.service';
import { SessionService } from './session.service';
Expand All @@ -22,14 +24,24 @@ const MAX_OTP_ATTEMPTS = 5;

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

constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService,
private readonly redisService: RedisService,
private readonly queueService: QueueService,
private readonly lockoutService: LockoutService,
private readonly sessionService: SessionService
private readonly sessionService: SessionService,

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

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

private readonly dataSource: DataSource,
) { }

async createNewUser(createUserDto: CreateUserDTO) {
Expand All @@ -39,25 +51,65 @@ export default class AuthenticationService {
}

const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
const saved = await this.userRepository.save(
this.userRepository.create({

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

let saved: User;

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: createUserDto.terms_accepted,
});

saved = await queryRunner.manager.save(user);

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

await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();

Comment thread
nsien-prestige marked this conversation as resolved.
const err = error as Error;
this.logger.error(`Registration failed: ${err.message}`, err.stack);

await this.issueOtp(saved.email);
let errorMessage = SYS_MSG.SESSION_CREATION_FAILED;
const statusCode = HttpStatus.INTERNAL_SERVER_ERROR;

const access_token = this.jwtService.sign({ id: saved.id, sub: saved.id, email: saved.email });
if (err.name === 'QueryFailedError') {
errorMessage = 'Database error occurred during registration';
this.logger.error('DB_ERROR during registration', err);
Comment thread
nsien-prestige marked this conversation as resolved.
}

throw new CustomHttpException(errorMessage, statusCode);
} finally {
await queryRunner.release();
}

// Issued after commit so a DB rollback does not leave a dangling OTP in Redis or send a spurious email.
// Failure here is non-fatal — the user was created and can request a resend via /resend-otp.
try {
await this.issueOtp(createUserDto.email);
} catch (otpError) {
const err = otpError as Error;
this.logger.error(`OTP dispatch failed after registration: ${err.message}`, err.stack);
}

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 @@ -170,24 +222,38 @@ export default class AuthenticationService {
throw new CustomHttpException(SYS_MSG.INVALID_OTP, HttpStatus.BAD_REQUEST);
}

user.is_verified = true;
user.otp_code = null;
user.expires_at = null;
await this.userRepository.save(user);
try {
user.is_verified = true;
user.otp_code = null;
user.expires_at = null;
await this.userRepository.save(user);
} catch (error) {
const err = error as Error;
this.logger.error(`OTP verification failed: ${err.message}`, err.stack);
throw new CustomHttpException(SYS_MSG.SESSION_CREATION_FAILED, HttpStatus.INTERNAL_SERVER_ERROR);
}

await Promise.all([
this.redisService.del(`otp:${email}`),
this.redisService.del(`attempts:${email}`),
this.redisService.del(`limit:${email}`),
]);

const access_token = this.jwtService.sign({ id: user.id, sub: user.id, email: user.email });
const { rawToken, sessionId } = await this.sessionService.create(user);

const access_token = this.jwtService.sign({
id: user.id,
sub: user.id,
sid: sessionId,
email: user.email,
});

return {
status_code: HttpStatus.OK,
message: SYS_MSG.EMAIL_VERIFIED,
access_token,
data: {
access_token,
refresh_token: rawToken,
user: {
id: user.id,
full_name: user.full_name,
Expand Down
11 changes: 11 additions & 0 deletions src/modules/auth/docs/auth-swagger.doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SendOtpDto } from '../dto/send-otp.dto';
import { VerifyOtpDto } from '../dto/verify-otp.dto';
import { LoginDto } from '../dto/login.dto';
import { ChangePasswordDto } from '../dto/change-password.dto';
import { CreateUserDTO } from '../dto/create-user.dto';

export function SendOtpDocs() {
return applyDecorators(
Expand Down Expand Up @@ -53,6 +54,16 @@ export function LoginDocs() {
);
}

export function RegisterDocs() {
return applyDecorators(
HttpCode(HttpStatus.CREATED),
ApiOperation({ summary: 'Register a new user' }),
ApiBody({ type: CreateUserDTO }),
ApiResponse({ status: HttpStatus.CREATED, description: 'User Created Successfully' }),
ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Account with the specified email exists' })
);
}

export function ChangePasswordDocs() {
return applyDecorators(
ApiBearerAuth(),
Expand Down
20 changes: 19 additions & 1 deletion src/modules/auth/dto/create-user.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
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 +52,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;
}
5 changes: 2 additions & 3 deletions src/modules/auth/session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@ import { randomBytes, createHash } from 'crypto';
import { User } from '@modules/user/entities/user.entity';
import { UserSession } from './entities/user-session.entity';

const REFRESH_TOKEN_EXPIRY_DAYS = 7;

const REFRESH_TOKEN_EXPIRY_DAYS = +process.env.REFRESH_TOKEN_EXPIRY
@Injectable()
export class SessionService {
constructor(
@InjectRepository(UserSession)
private readonly repo: Repository<UserSession>
) {}
) { }

async create(user: User): Promise<{ rawToken: string; sessionId: string }> {
const rawToken = randomBytes(32).toString('hex');
Expand Down
Loading
Loading