diff --git a/src/app.module.ts b/src/app.module.ts index 6bb3641..75ab21a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -101,6 +101,11 @@ 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'; +import { OtpVerifierModel } from './modules/configuration/otp-verification/data/models/otp-verifier.model'; + @Module({ imports: [ ApmModule.register(), @@ -165,6 +170,9 @@ import { TimeGroupModel } from './modules/item-related/time-group/data/models/ti // Booking Online VerificationModel, + + OtpVerificationModel, + OtpVerifierModel, ], synchronize: false, }), @@ -230,6 +238,7 @@ 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/core/strings/constants/module.constants.ts b/src/core/strings/constants/module.constants.ts index 032dace..a7b4aa0 100644 --- a/src/core/strings/constants/module.constants.ts +++ b/src/core/strings/constants/module.constants.ts @@ -30,4 +30,5 @@ export enum MODULE_NAME { QUEUE = 'queue', TIME_GROUPS = 'time-groups', + OTP_VERIFICATIONS = 'otp-verification', } diff --git a/src/core/strings/constants/table.constants.ts b/src/core/strings/constants/table.constants.ts index dcdc1a3..2139ba6 100644 --- a/src/core/strings/constants/table.constants.ts +++ b/src/core/strings/constants/table.constants.ts @@ -45,4 +45,6 @@ export enum TABLE_NAME { QUEUE_BUCKET = 'queue_bucket', TIME_GROUPS = 'time_groups', + OTP_VERIFICATIONS = 'otp_verifications', + OTP_VERIFIER = 'otp_verifier', } diff --git a/src/database/migrations/1748935417155-add_column_otp_code.ts b/src/database/migrations/1748935417155-add_column_otp_code.ts new file mode 100644 index 0000000..a7347fd --- /dev/null +++ b/src/database/migrations/1748935417155-add_column_otp_code.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddColumnOtpCode1748935417155 implements MigrationInterface { + name = 'AddColumnOtpCode1748935417155'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "vip_codes" ADD "otp_code" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "transactions" ADD "otp_code" character varying`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "transactions" DROP COLUMN "otp_code"`, + ); + await queryRunner.query(`ALTER TABLE "vip_codes" DROP COLUMN "otp_code"`); + } +} 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/database/migrations/1749043616622-add_table_otp_verifier.ts b/src/database/migrations/1749043616622-add_table_otp_verifier.ts new file mode 100644 index 0000000..b2085c4 --- /dev/null +++ b/src/database/migrations/1749043616622-add_table_otp_verifier.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTableOtpVerifier1749043616622 implements MigrationInterface { + name = 'AddTableOtpVerifier1749043616622'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "otp_verifier" ("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, "name" character varying, "phone_number" character varying NOT NULL, CONSTRAINT "PK_884e2d0873fc589a1bdc477b2ea" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "otp_verifier"`); + } +} diff --git a/src/database/migrations/1749046285398-update_enum_otp_action_type.ts b/src/database/migrations/1749046285398-update_enum_otp_action_type.ts new file mode 100644 index 0000000..e107607 --- /dev/null +++ b/src/database/migrations/1749046285398-update_enum_otp_action_type.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateEnumOtpActionType1749046285398 + implements MigrationInterface +{ + name = 'UpdateEnumOtpActionType1749046285398'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "public"."otp_verifications_action_type_enum" RENAME TO "otp_verifications_action_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."otp_verifications_action_type_enum" AS ENUM('CREATE_DISCOUNT', 'CANCEL_TRANSACTION', 'REJECT_RECONCILIATION')`, + ); + await queryRunner.query( + `ALTER TABLE "otp_verifications" ALTER COLUMN "action_type" TYPE "public"."otp_verifications_action_type_enum" USING "action_type"::"text"::"public"."otp_verifications_action_type_enum"`, + ); + await queryRunner.query( + `DROP TYPE "public"."otp_verifications_action_type_enum_old"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."otp_verifications_action_type_enum_old" AS ENUM('CREATE_DISCOUNT', 'CANCEL_TRANSACTION')`, + ); + await queryRunner.query( + `ALTER TABLE "otp_verifications" ALTER COLUMN "action_type" TYPE "public"."otp_verifications_action_type_enum_old" USING "action_type"::"text"::"public"."otp_verifications_action_type_enum_old"`, + ); + await queryRunner.query( + `DROP TYPE "public"."otp_verifications_action_type_enum"`, + ); + await queryRunner.query( + `ALTER TYPE "public"."otp_verifications_action_type_enum_old" RENAME TO "otp_verifications_action_type_enum"`, + ); + } +} 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 new file mode 100644 index 0000000..eec21fb --- /dev/null +++ b/src/modules/configuration/otp-verification/data/models/otp-verification.model.ts @@ -0,0 +1,41 @@ +import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; +import { + OTP_ACTION_TYPE, + OTP_SOURCE, + OtpVerificationEntity, +} from '../../domain/entities/otp-verification.entity'; +import { Column, Entity } from 'typeorm'; +import { BaseModel } from 'src/core/modules/data/model/base.model'; + +@Entity(TABLE_NAME.OTP_VERIFICATIONS) +export class OtpVerificationModel + extends BaseModel + implements OtpVerificationEntity +{ + @Column({ type: 'varchar', nullable: false }) + otp_code: string; + + @Column({ type: 'enum', enum: OTP_ACTION_TYPE }) + action_type: OTP_ACTION_TYPE; + + @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 + + @Column({ type: 'bigint', nullable: true }) + verified_at: number; // UNIX timestamp or null +} diff --git a/src/modules/configuration/otp-verification/data/models/otp-verifier.model.ts b/src/modules/configuration/otp-verification/data/models/otp-verifier.model.ts new file mode 100644 index 0000000..cbb7f3d --- /dev/null +++ b/src/modules/configuration/otp-verification/data/models/otp-verifier.model.ts @@ -0,0 +1,16 @@ +import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; +import { OtpVerifierEntity } from '../../domain/entities/otp-verification.entity'; +import { Column, Entity } from 'typeorm'; +import { BaseModel } from 'src/core/modules/data/model/base.model'; + +@Entity(TABLE_NAME.OTP_VERIFIER) +export class OtpVerifierModel + extends BaseModel + implements OtpVerifierEntity +{ + @Column({ type: 'varchar', nullable: true }) + name: string; + + @Column({ type: 'varchar', nullable: false }) + phone_number: string; +} 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..7905606 --- /dev/null +++ b/src/modules/configuration/otp-verification/data/services/otp-verification.service.ts @@ -0,0 +1,174 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OtpVerificationModel } from '../models/otp-verification.model'; +import { + OtpRequestEntity, + OtpVerificationEntity, + OtpVerifierEntity, + // OtpVerifierEntity, + 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'; +import { WhatsappService } from 'src/services/whatsapp/whatsapp.service'; +import { OtpVerifierModel } from '../models/otp-verifier.model'; + +@Injectable() +export class OtpVerificationService { + constructor( + @InjectRepository(OtpVerificationModel) + private readonly otpVerificationRepo: Repository, + + @InjectRepository(OtpVerifierModel) + private readonly otpVerifierRepo: 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(); + + //TODO implementation from auth + const creator = { id: null, name: null }; + + const newOtp: OtpVerificationEntity = { + otp_code: otpCode, + action_type: payload.action_type, + target_id: payload.target_id, + reference: payload.reference, + source: payload.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); + const verifiers: OtpVerifierEntity[] = await this.otpVerifierRepo.find(); + const notificationService = new WhatsappService(); + + verifiers.map((v) => { + notificationService.sendOtpNotification({ + phone: v.phone_number, + code: otpCode, + }); + }); + + 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, source } = 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, + source, + is_used: false, + is_replaced: false, + }, + { + otp_code, + action_type, + reference, + source, + 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 new file mode 100644 index 0000000..951b4a9 --- /dev/null +++ b/src/modules/configuration/otp-verification/domain/entities/otp-verification.entity.ts @@ -0,0 +1,40 @@ +import { BaseEntity } from 'src/core/modules/domain/entities//base.entity'; + +export enum OTP_ACTION_TYPE { + CREATE_DISCOUNT = 'CREATE_DISCOUNT', + CANCEL_TRANSACTION = 'CANCEL_TRANSACTION', + REJECT_RECONCILIATION = 'REJECT_RECONCILIATION', +} + +export enum OTP_SOURCE { + POS = 'POS', + WEB = 'WEB', +} + +export interface OtpVerificationEntity extends BaseEntity { + otp_code: string; + action_type: OTP_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: OTP_ACTION_TYPE; + source: OTP_SOURCE; + target_id: string; + reference: string; +} + +export interface OtpVerifyEntity extends OtpRequestEntity { + otp_code: string; +} + +export interface OtpVerifierEntity { + name: string; + phone_number: string; +} 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 new file mode 100644 index 0000000..cfd0097 --- /dev/null +++ b/src/modules/configuration/otp-verification/infrastructure/dto/otp-verification.dto.ts @@ -0,0 +1,55 @@ +import { IsNotEmpty, IsString, ValidateIf } from 'class-validator'; +import { + OTP_ACTION_TYPE, + OTP_SOURCE, + OtpRequestEntity, + OtpVerifyEntity, +} from '../../domain/entities/otp-verification.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class OtpRequestDto implements OtpRequestEntity { + @ApiProperty({ + type: String, + required: true, + example: OTP_ACTION_TYPE.CANCEL_TRANSACTION, + description: 'CANCEL_TRANSACTION || CREATE_DISCOUNT', + }) + @IsString() + @IsNotEmpty() + action_type: OTP_ACTION_TYPE; + + @ApiProperty({ + type: String, + required: true, + example: OTP_SOURCE.POS, + description: 'POS || WEB', + }) + @IsString() + @IsNotEmpty() + source: OTP_SOURCE; + + @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 new file mode 100644 index 0000000..783f109 --- /dev/null +++ b/src/modules/configuration/otp-verification/infrastructure/otp-verification-data.controller.ts @@ -0,0 +1,31 @@ +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'; + +//TODO implementation auth +@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/otp-verification.module.ts b/src/modules/configuration/otp-verification/otp-verification.module.ts new file mode 100644 index 0000000..6e1f02d --- /dev/null +++ b/src/modules/configuration/otp-verification/otp-verification.module.ts @@ -0,0 +1,21 @@ +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'; +import { OtpVerifierModel } from './data/models/otp-verifier.model'; +@Module({ + imports: [ + ConfigModule.forRoot(), + TypeOrmModule.forFeature( + [OtpVerificationModel, OtpVerifierModel], + CONNECTION_NAME.DEFAULT, + ), + ], + controllers: [OtpVerificationController], + providers: [OtpVerificationService], +}) +export class OtpVerificationModule {} diff --git a/src/modules/item-related/item/domain/usecases/managers/detail-item.manager.ts b/src/modules/item-related/item/domain/usecases/managers/detail-item.manager.ts index b091df9..21e3e4b 100644 --- a/src/modules/item-related/item/domain/usecases/managers/detail-item.manager.ts +++ b/src/modules/item-related/item/domain/usecases/managers/detail-item.manager.ts @@ -26,6 +26,7 @@ export class DetailItemManager extends BaseDetailManager { selectRelations: [ 'item_category', 'bundling_items', + 'bundling_items.time_group bundling_time_groups', 'tenant', 'time_group', ], @@ -64,6 +65,9 @@ export class DetailItemManager extends BaseDetailManager { 'bundling_items.hpp', 'bundling_items.base_price', + 'bundling_time_groups.id', + 'bundling_time_groups.name', + 'tenant.id', 'tenant.name', diff --git a/src/modules/item-related/time-group/domain/usecases/managers/create-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/create-time-group.manager.ts index 8dc54a5..ce07b64 100644 --- a/src/modules/item-related/time-group/domain/usecases/managers/create-time-group.manager.ts +++ b/src/modules/item-related/time-group/domain/usecases/managers/create-time-group.manager.ts @@ -37,7 +37,7 @@ export class CreateTimeGroupManager extends BaseCreateManager { if (max_usage_time.isBefore(end_time)) { throw new Error( - 'Waktu maksimum penggunaan harus lebih kecil dari waktu selesai.', + 'Waktu maksimum penggunaan harus lebih besar dari waktu selesai.', ); } return; diff --git a/src/modules/item-related/time-group/domain/usecases/managers/update-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/update-time-group.manager.ts index 904bfb4..f003866 100644 --- a/src/modules/item-related/time-group/domain/usecases/managers/update-time-group.manager.ts +++ b/src/modules/item-related/time-group/domain/usecases/managers/update-time-group.manager.ts @@ -38,7 +38,7 @@ export class UpdateTimeGroupManager extends BaseUpdateManager { if (max_usage_time.isBefore(end_time)) { throw new Error( - 'Waktu maksimum penggunaan harus lebih kecil dari waktu selesai.', + 'Waktu maksimum penggunaan harus lebih besar dari waktu selesai.', ); } return; diff --git a/src/modules/reports/shared/configs/transaction-report/configs/cancel-transaction.ts b/src/modules/reports/shared/configs/transaction-report/configs/cancel-transaction.ts index 03467f4..f1d195c 100644 --- a/src/modules/reports/shared/configs/transaction-report/configs/cancel-transaction.ts +++ b/src/modules/reports/shared/configs/transaction-report/configs/cancel-transaction.ts @@ -84,6 +84,13 @@ export default { type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, }, + { + column: 'main__otp_code', + query: 'main.otp_code', + label: 'Kode OTP', + type: DATA_TYPE.DIMENSION, + format: DATA_FORMAT.TEXT, + }, { column: 'main__payment_code', query: `CASE WHEN main.type = 'counter' THEN main.invoice_code ELSE main.payment_code END`, diff --git a/src/modules/reports/shared/configs/transaction-report/configs/giving-discounts.ts b/src/modules/reports/shared/configs/transaction-report/configs/giving-discounts.ts index 99a71ee..e46ec43 100644 --- a/src/modules/reports/shared/configs/transaction-report/configs/giving-discounts.ts +++ b/src/modules/reports/shared/configs/transaction-report/configs/giving-discounts.ts @@ -119,6 +119,14 @@ export default { type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, }, + { + column: 'vip__otp_code', + query: 'vip.otp_code', + label: 'Kode OTP', + type: DATA_TYPE.DIMENSION, + format: DATA_FORMAT.TEXT, + }, + { column: 'privilege__name', query: 'privilege.name', diff --git a/src/modules/reports/shared/configs/transaction-report/configs/reconciliation.ts b/src/modules/reports/shared/configs/transaction-report/configs/reconciliation.ts index 8063ad3..acee33b 100644 --- a/src/modules/reports/shared/configs/transaction-report/configs/reconciliation.ts +++ b/src/modules/reports/shared/configs/transaction-report/configs/reconciliation.ts @@ -50,6 +50,13 @@ export default { type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, }, + { + column: 'main__otp_code', + query: 'main.otp_code', + label: 'Kode OTP Reject', + type: DATA_TYPE.DIMENSION, + format: DATA_FORMAT.TEXT, + }, { column: 'main__payment_date', query: `CASE WHEN main.payment_date is not null THEN to_char(main.payment_date, 'DD-MM-YYYY') ELSE null END`, diff --git a/src/modules/reports/shared/configs/transaction-report/configs/vip_code.ts b/src/modules/reports/shared/configs/transaction-report/configs/vip_code.ts index a23ece7..a8a4d5b 100644 --- a/src/modules/reports/shared/configs/transaction-report/configs/vip_code.ts +++ b/src/modules/reports/shared/configs/transaction-report/configs/vip_code.ts @@ -35,6 +35,13 @@ export default { type: DATA_TYPE.DIMENSION, format: DATA_FORMAT.TEXT, }, + { + column: 'main__otp_code', + query: 'main.otp_code', + label: 'Kode OTP', + type: DATA_TYPE.DIMENSION, + format: DATA_FORMAT.TEXT, + }, { column: 'main__discount', query: 'CASE WHEN main.discount > 0 THEN main.discount ELSE null END', diff --git a/src/modules/transaction/transaction/data/models/transaction.model.ts b/src/modules/transaction/transaction/data/models/transaction.model.ts index 053503c..83c4416 100644 --- a/src/modules/transaction/transaction/data/models/transaction.model.ts +++ b/src/modules/transaction/transaction/data/models/transaction.model.ts @@ -274,4 +274,7 @@ export class TransactionModel onUpdate: 'CASCADE', }) refunds: RefundModel[]; + + @Column('varchar', { name: 'otp_code', nullable: true }) + otp_code: string; } diff --git a/src/modules/transaction/vip-code/data/models/vip-code.model.ts b/src/modules/transaction/vip-code/data/models/vip-code.model.ts index 314b047..57e45e0 100644 --- a/src/modules/transaction/vip-code/data/models/vip-code.model.ts +++ b/src/modules/transaction/vip-code/data/models/vip-code.model.ts @@ -27,4 +27,7 @@ export class VipCodeModel }) @JoinColumn({ name: 'vip_category_id' }) vip_category: VipCategoryModel; + + @Column('varchar', { name: 'otp_code', nullable: true }) + otp_code: string; } diff --git a/src/modules/transaction/vip-code/domain/usecases/handlers/create-vip-code.handler.ts b/src/modules/transaction/vip-code/domain/usecases/handlers/create-vip-code.handler.ts index 2c07dcb..006a81b 100644 --- a/src/modules/transaction/vip-code/domain/usecases/handlers/create-vip-code.handler.ts +++ b/src/modules/transaction/vip-code/domain/usecases/handlers/create-vip-code.handler.ts @@ -29,7 +29,7 @@ export class CreateVipCodeHandler implements IEventHandler { id: data._id ?? data.id, vip_category_id: data.vip_category?._id ?? data.vip_category?.id, }; - + console.log({ dataMapped }); try { await this.dataService.create(queryRunner, VipCodeModel, dataMapped); } catch (error) {