From 538abb122f8c37476dff43c8f805b0d3d9e3e4f3 Mon Sep 17 00:00:00 2001 From: Firman Ramdhani <33869609+firmanramdhani@users.noreply.github.com> Date: Wed, 4 Jun 2025 20:01:23 +0700 Subject: [PATCH] feat: implement module otp verification --- src/app.module.ts | 6 + src/core/helpers/otp/otp-service.ts | 57 +++++++ ...28279580-create-table-otp-cerifications.ts | 29 ++++ ...add_column_is_replaced_otp_verification.ts | 19 +++ .../data/models/otp-verification.model.ts | 6 + .../services/otp-verification-data.service.ts | 0 .../services/otp-verification-read.service.ts | 0 .../data/services/otp-verification.service.ts | 158 ++++++++++++++++++ .../entities/otp-verification.entity.ts | 12 ++ .../otp-verification-data.orchestrator.ts | 0 .../otp-verification-read.orchestrator.ts | 0 .../dto/otp-verification.dto.ts | 44 +++++ .../otp-verification-data.controller.ts | 30 ++++ .../otp-verification-read.controller.ts | 0 .../otp-verification.module.ts | 17 ++ 15 files changed, 378 insertions(+) create mode 100644 src/core/helpers/otp/otp-service.ts create mode 100644 src/database/migrations/1749028279580-create-table-otp-cerifications.ts create mode 100644 src/database/migrations/1749030419440-add_column_is_replaced_otp_verification.ts delete mode 100644 src/modules/configuration/otp-verification/data/services/otp-verification-data.service.ts delete mode 100644 src/modules/configuration/otp-verification/data/services/otp-verification-read.service.ts create mode 100644 src/modules/configuration/otp-verification/data/services/otp-verification.service.ts delete mode 100644 src/modules/configuration/otp-verification/domain/usecases/otp-verification-data.orchestrator.ts delete mode 100644 src/modules/configuration/otp-verification/domain/usecases/otp-verification-read.orchestrator.ts delete mode 100644 src/modules/configuration/otp-verification/infrastructure/otp-verification-read.controller.ts diff --git a/src/app.module.ts b/src/app.module.ts index 6bb3641..e8f891d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -101,6 +101,9 @@ import { BookingOnlineAuthModule } from './modules/booking-online/authentication import { BookingOrderModule } from './modules/booking-online/order/order.module'; import { TimeGroupModule } from './modules/item-related/time-group/time-group.module'; import { TimeGroupModel } from './modules/item-related/time-group/data/models/time-group.model'; + +import { OtpVerificationModule } from './modules/configuration/otp-verification/otp-verification.module'; +import { OtpVerificationModel } from './modules/configuration/otp-verification/data/models/otp-verification.model'; @Module({ imports: [ ApmModule.register(), @@ -165,6 +168,7 @@ import { TimeGroupModel } from './modules/item-related/time-group/data/models/ti // Booking Online VerificationModel, + OtpVerificationModel, ], synchronize: false, }), @@ -230,6 +234,8 @@ import { TimeGroupModel } from './modules/item-related/time-group/data/models/ti BookingOnlineAuthModule, BookingOrderModule, + + OtpVerificationModule, ], controllers: [], providers: [ diff --git a/src/core/helpers/otp/otp-service.ts b/src/core/helpers/otp/otp-service.ts new file mode 100644 index 0000000..e5f9433 --- /dev/null +++ b/src/core/helpers/otp/otp-service.ts @@ -0,0 +1,57 @@ +interface OtpServiceEntity { + length?: number; +} + +export class OtpService { + private readonly otpLength: number; + + constructor({ length = 4 }: OtpServiceEntity) { + this.otpLength = Math.max(length, 4); // Minimum of 4 digits + } + + private hasSequentialDigits(str: string): boolean { + for (let i = 0; i < str.length - 1; i++) { + const current = parseInt(str[i], 10); + const next = parseInt(str[i + 1], 10); + if (next === current + 1 || next === current - 1) { + return true; + } + } + return false; + } + + private hasRepeatedDigits(str: string): boolean { + return str.split('').every((char) => char === str[0]); + } + + private isPalindrome(str: string): boolean { + return str === str.split('').reverse().join(''); + } + + private hasPartiallyRepeatedDigits(str: string): boolean { + const counts: Record = {}; + for (const char of str) { + counts[char] = (counts[char] || 0) + 1; + } + + // Reject if any digit appears more than twice + return Object.values(counts).some((count) => count > 2); + } + + public generateSecureOTP(): string { + let otp: string; + + do { + otp = Array.from({ length: this.otpLength }, () => + Math.floor(Math.random() * 10).toString(), + ).join(''); + } while ( + this.hasSequentialDigits(otp) || + this.hasRepeatedDigits(otp) || + this.isPalindrome(otp) || + this.hasPartiallyRepeatedDigits(otp) + ); + + return otp; + } +} diff --git a/src/database/migrations/1749028279580-create-table-otp-cerifications.ts b/src/database/migrations/1749028279580-create-table-otp-cerifications.ts new file mode 100644 index 0000000..a1d2800 --- /dev/null +++ b/src/database/migrations/1749028279580-create-table-otp-cerifications.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateTableOtpCerifications1749028279580 + implements MigrationInterface +{ + name = 'CreateTableOtpCerifications1749028279580'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."otp_verifications_action_type_enum" AS ENUM('CREATE_DISCOUNT', 'CANCEL_TRANSACTION')`, + ); + await queryRunner.query( + `CREATE TYPE "public"."otp_verifications_source_enum" AS ENUM('POS', 'WEB')`, + ); + await queryRunner.query( + `CREATE TABLE "otp_verifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "creator_id" character varying(36), "creator_name" character varying(125), "editor_id" character varying(36), "editor_name" character varying(125), "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL, "otp_code" character varying NOT NULL, "action_type" "public"."otp_verifications_action_type_enum" NOT NULL, "target_id" character varying, "reference" character varying, "source" "public"."otp_verifications_source_enum" NOT NULL, "is_used" boolean NOT NULL DEFAULT false, "expired_at" bigint NOT NULL, "verified_at" bigint, CONSTRAINT "PK_91d17e75ac3182dba6701869b39" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "otp_verifications"`); + await queryRunner.query( + `DROP TYPE "public"."otp_verifications_source_enum"`, + ); + await queryRunner.query( + `DROP TYPE "public"."otp_verifications_action_type_enum"`, + ); + } +} diff --git a/src/database/migrations/1749030419440-add_column_is_replaced_otp_verification.ts b/src/database/migrations/1749030419440-add_column_is_replaced_otp_verification.ts new file mode 100644 index 0000000..befa570 --- /dev/null +++ b/src/database/migrations/1749030419440-add_column_is_replaced_otp_verification.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddColumnIsReplacedOtpVerification1749030419440 + implements MigrationInterface +{ + name = 'AddColumnIsReplacedOtpVerification1749030419440'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "otp_verifications" ADD "is_replaced" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "otp_verifications" DROP COLUMN "is_replaced"`, + ); + } +} diff --git a/src/modules/configuration/otp-verification/data/models/otp-verification.model.ts b/src/modules/configuration/otp-verification/data/models/otp-verification.model.ts index 4db9191..22032aa 100644 --- a/src/modules/configuration/otp-verification/data/models/otp-verification.model.ts +++ b/src/modules/configuration/otp-verification/data/models/otp-verification.model.ts @@ -21,12 +21,18 @@ export class OtpVerificationModel @Column({ type: 'varchar', nullable: true }) target_id: string; + @Column({ type: 'varchar', nullable: true }) + reference: string; + @Column({ type: 'enum', enum: OTP_SOURCE }) source: OTP_SOURCE; @Column({ default: false }) is_used: boolean; + @Column({ default: false }) + is_replaced: boolean; + @Column({ type: 'bigint', nullable: false }) expired_at: number; // UNIX timestamp diff --git a/src/modules/configuration/otp-verification/data/services/otp-verification-data.service.ts b/src/modules/configuration/otp-verification/data/services/otp-verification-data.service.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/configuration/otp-verification/data/services/otp-verification-read.service.ts b/src/modules/configuration/otp-verification/data/services/otp-verification-read.service.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/configuration/otp-verification/data/services/otp-verification.service.ts b/src/modules/configuration/otp-verification/data/services/otp-verification.service.ts new file mode 100644 index 0000000..12c5c09 --- /dev/null +++ b/src/modules/configuration/otp-verification/data/services/otp-verification.service.ts @@ -0,0 +1,158 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OtpVerificationModel } from '../models/otp-verification.model'; +import { + OTP_SOURCE, + OtpRequestEntity, + OtpVerificationEntity, + OtpVerifyEntity, +} from '../../domain/entities/otp-verification.entity'; +import * as moment from 'moment'; +import { OtpService } from 'src/core/helpers/otp/otp-service'; +import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; +@Injectable() +export class OtpVerificationService { + constructor( + @InjectRepository(OtpVerificationModel) + private readonly otpVerificationRepo: Repository, + ) {} + + private generateOtpExpiration(minutes = 5): number { + return moment().add(minutes, 'minutes').valueOf(); // epoch millis expired time + } + + private generateResendAvailableAt(seconds = 90): number { + return moment().add(seconds, 'seconds').valueOf(); // epoch millis + } + + private generateTimestamp(): number { + return moment().valueOf(); // epoch millis verification time (now) + } + + async requestOTP(payload: OtpRequestEntity) { + const otpService = new OtpService({ length: 4 }); + const otpCode = otpService.generateSecureOTP(); + const dateNow = this.generateTimestamp(); + const expiredAt = this.generateOtpExpiration(); + const source = OTP_SOURCE.WEB; + const creator = { + id: 'c59f811e-873c-4472-bd58-21c111902114', + name: 'dev', + }; + + const newOtp: OtpVerificationEntity = { + otp_code: otpCode, + action_type: payload.action_type, + target_id: payload.target_id, + reference: payload.reference, + source: source, + is_used: false, + is_replaced: false, + expired_at: expiredAt, + + creator_id: creator.id, + creator_name: creator.name, + created_at: dateNow, + verified_at: null, + + editor_id: creator.id, + editor_name: creator.name, + updated_at: dateNow, + }; + + const activeOTP = await this.getActiveOtp( + payload.target_id ?? payload.reference, + ); + + if (activeOTP) { + const createdAtMoment = moment(Number(activeOTP.created_at)); + const nowMoment = moment(Number(dateNow)); + const diffSeconds = nowMoment.diff(createdAtMoment, 'seconds'); + if (diffSeconds < 90) { + throw new BadRequestException( + 'An active OTP request was made recently. Please try again later.', + ); + } else { + // Update data is_replaced on database + this.otpVerificationRepo.save({ + ...activeOTP, + is_replaced: true, + }); + } + } + + // save otp to database + await this.otpVerificationRepo.save(newOtp); + + return { + message: `OTP has been sent to the admin's WhatsApp.`, + updated_at: expiredAt, + resend_available_at: this.generateResendAvailableAt(), + }; + } + + async verifyOTP(payload: OtpVerifyEntity) { + const { otp_code, action_type, target_id, reference } = payload; + const dateNow = this.generateTimestamp(); + + if (!target_id && !reference) { + throw new BadRequestException( + 'Either target_id or reference must be provided.', + ); + } + + // Build a where condition with OR between target_id and reference + const otp = await this.otpVerificationRepo.findOne({ + where: [ + { + otp_code, + action_type, + target_id, + is_used: false, + is_replaced: false, + }, + { + otp_code, + action_type, + reference, + is_used: false, + is_replaced: false, + }, + ], + }); + + if (!otp) { + throw new BadRequestException('Invalid or expired OTP.'); + } else if (otp.expired_at <= dateNow) { + throw new BadRequestException('OTP has expired.'); + } + + otp.is_used = true; + otp.verified_at = dateNow; + + // update otp to database + await this.otpVerificationRepo.save(otp); + return { message: 'OTP verified successfully.' }; + } + + async getActiveOtp(payload: string) { + const now = this.generateTimestamp(); + const tableName = TABLE_NAME.OTP_VERIFICATIONS; + + return this.otpVerificationRepo + .createQueryBuilder(tableName) + .where( + `(${tableName}.target_id = :payload OR ${tableName}.reference = :payload) + AND ${tableName}.is_used = false + AND ${tableName}.is_replaced = false + AND ${tableName}.expired_at > :now`, + { payload, now }, + ) + .orderBy( + `CASE WHEN ${tableName}.target_id = :payload THEN 0 ELSE 1 END`, + 'ASC', + ) + .getOne(); + } +} diff --git a/src/modules/configuration/otp-verification/domain/entities/otp-verification.entity.ts b/src/modules/configuration/otp-verification/domain/entities/otp-verification.entity.ts index 88e0c9f..78e8c68 100644 --- a/src/modules/configuration/otp-verification/domain/entities/otp-verification.entity.ts +++ b/src/modules/configuration/otp-verification/domain/entities/otp-verification.entity.ts @@ -14,8 +14,20 @@ export interface OtpVerificationEntity extends BaseEntity { otp_code: string; action_type: OPT_ACTION_TYPE; target_id: string; + reference: string; source: OTP_SOURCE; is_used: boolean; + is_replaced: boolean; expired_at: number; verified_at: number; } + +export interface OtpRequestEntity { + action_type: OPT_ACTION_TYPE; + target_id: string; + reference: string; +} + +export interface OtpVerifyEntity extends OtpRequestEntity { + otp_code: string; +} diff --git a/src/modules/configuration/otp-verification/domain/usecases/otp-verification-data.orchestrator.ts b/src/modules/configuration/otp-verification/domain/usecases/otp-verification-data.orchestrator.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/configuration/otp-verification/domain/usecases/otp-verification-read.orchestrator.ts b/src/modules/configuration/otp-verification/domain/usecases/otp-verification-read.orchestrator.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/configuration/otp-verification/infrastructure/dto/otp-verification.dto.ts b/src/modules/configuration/otp-verification/infrastructure/dto/otp-verification.dto.ts index e69de29..34786d2 100644 --- a/src/modules/configuration/otp-verification/infrastructure/dto/otp-verification.dto.ts +++ b/src/modules/configuration/otp-verification/infrastructure/dto/otp-verification.dto.ts @@ -0,0 +1,44 @@ +import { IsNotEmpty, IsString, ValidateIf } from 'class-validator'; +import { + OPT_ACTION_TYPE, + OtpRequestEntity, + OtpVerifyEntity, +} from '../../domain/entities/otp-verification.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class OtpRequestDto implements OtpRequestEntity { + @ApiProperty({ + type: String, + required: true, + example: OPT_ACTION_TYPE.CANCEL_TRANSACTION, + description: 'CANCEL_TRANSACTION || CREATE_DISCOUNT', + }) + @IsString() + @IsNotEmpty() + action_type: OPT_ACTION_TYPE; + + @ApiProperty({ + name: 'target_id', + example: 'bccc0c6a-51a0-437f-abc8-dc18851604ee', + }) + @IsString() + @ValidateIf((body) => body.target_id) + target_id: string; + + @ApiProperty({ name: 'reference', example: '0625N21' }) + @IsString() + @ValidateIf((body) => body.reference) + reference: string; +} + +export class OtpVerifyDto extends OtpRequestDto implements OtpVerifyEntity { + @ApiProperty({ + name: 'otp_code', + type: String, + required: true, + example: '2345', + }) + @IsString() + @IsNotEmpty() + otp_code: string; +} diff --git a/src/modules/configuration/otp-verification/infrastructure/otp-verification-data.controller.ts b/src/modules/configuration/otp-verification/infrastructure/otp-verification-data.controller.ts index e69de29..0eb5957 100644 --- a/src/modules/configuration/otp-verification/infrastructure/otp-verification-data.controller.ts +++ b/src/modules/configuration/otp-verification/infrastructure/otp-verification-data.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Public } from 'src/core/guards'; +import { MODULE_NAME } from 'src/core/strings/constants/module.constants'; +import { OtpVerificationService } from '../data/services/otp-verification.service'; +import { OtpRequestDto, OtpVerifyDto } from './dto/otp-verification.dto'; + +@ApiTags(`${MODULE_NAME.OTP_VERIFICATIONS.split('-').join(' ')} - data`) +@Controller(`v1/${MODULE_NAME.OTP_VERIFICATIONS}`) +@Public() +export class OtpVerificationController { + constructor( + private readonly otpVerificationService: OtpVerificationService, + ) {} + + @Post('request') + async request(@Body() body: OtpRequestDto) { + return await this.otpVerificationService.requestOTP(body); + } + + @Post('verify') + async verify(@Body() body: OtpVerifyDto) { + return await this.otpVerificationService.verifyOTP(body); + } + + @Get(':ref_or_target_id') + async getByPhoneNumber(@Param('ref_or_target_id') ref_or_target_id: string) { + return this.otpVerificationService.getActiveOtp(ref_or_target_id); + } +} diff --git a/src/modules/configuration/otp-verification/infrastructure/otp-verification-read.controller.ts b/src/modules/configuration/otp-verification/infrastructure/otp-verification-read.controller.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/configuration/otp-verification/otp-verification.module.ts b/src/modules/configuration/otp-verification/otp-verification.module.ts index e69de29..1fd4aeb 100644 --- a/src/modules/configuration/otp-verification/otp-verification.module.ts +++ b/src/modules/configuration/otp-verification/otp-verification.module.ts @@ -0,0 +1,17 @@ +import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants'; + +import { ConfigModule } from '@nestjs/config'; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OtpVerificationModel } from './data/models/otp-verification.model'; +import { OtpVerificationController } from './infrastructure/otp-verification-data.controller'; +import { OtpVerificationService } from './data/services/otp-verification.service'; +@Module({ + imports: [ + ConfigModule.forRoot(), + TypeOrmModule.forFeature([OtpVerificationModel], CONNECTION_NAME.DEFAULT), + ], + controllers: [OtpVerificationController], + providers: [OtpVerificationService], +}) +export class OtpVerificationModule {}