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..f63057b --- /dev/null +++ b/src/core/helpers/otp/otp-service.ts @@ -0,0 +1,58 @@ +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) || + otp?.split('')?.length < this.otpLength + ); + + 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..11420d2 --- /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 = 60): 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.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 < 60) { + 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/index-public-time-group.manager.ts b/src/modules/item-related/time-group/domain/usecases/managers/index-public-time-group.manager.ts new file mode 100644 index 0000000..c626f1d --- /dev/null +++ b/src/modules/item-related/time-group/domain/usecases/managers/index-public-time-group.manager.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { BaseIndexManager } from 'src/core/modules/domain/usecase/managers/base-index.manager'; +import { TimeGroupEntity } from '../../entities/time-group.entity'; +import { SelectQueryBuilder } from 'typeorm'; +import { + Param, + RelationParam, +} from 'src/core/modules/domain/entities/base-filter.entity'; + +// TODO: +// Implementasikan filter by start_time, end_timen, dan max_usage_time + +@Injectable() +export class IndexPublicTimeGroupManager extends BaseIndexManager { + async prepareData(): Promise { + return; + } + + async beforeProcess(): Promise { + return; + } + + async afterProcess(): Promise { + return; + } + + get relations(): RelationParam { + return { + joinRelations: ['items'], + selectRelations: [], + countRelations: ['items'], + }; + } + + get selects(): string[] { + return [ + `${this.tableName}.id`, + `${this.tableName}.status`, + `${this.tableName}.name`, + `${this.tableName}.start_time`, + `${this.tableName}.end_time`, + `${this.tableName}.max_usage_time`, + `${this.tableName}.created_at`, + `${this.tableName}.creator_name`, + `${this.tableName}.updated_at`, + `${this.tableName}.editor_name`, + ]; + } + + get specificFilter(): Param[] { + return [ + { + cols: `${this.tableName}.name`, + data: this.filterParam.names, + }, + ]; + } + + setQueryFilter( + queryBuilder: SelectQueryBuilder, + ): SelectQueryBuilder { + queryBuilder.andWhere(`items.id is not null`); + return queryBuilder; + } +} 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/item-related/time-group/domain/usecases/time-group-read.orchestrator.ts b/src/modules/item-related/time-group/domain/usecases/time-group-read.orchestrator.ts index aa6b6e3..b0ef50f 100644 --- a/src/modules/item-related/time-group/domain/usecases/time-group-read.orchestrator.ts +++ b/src/modules/item-related/time-group/domain/usecases/time-group-read.orchestrator.ts @@ -6,11 +6,13 @@ import { PaginationResponse } from 'src/core/response/domain/ok-response.interfa import { BaseReadOrchestrator } from 'src/core/modules/domain/usecase/orchestrators/base-read.orchestrator'; import { DetailTimeGroupManager } from './managers/detail-time-group.manager'; import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; +import { IndexPublicTimeGroupManager } from './managers/index-public-time-group.manager'; @Injectable() export class TimeGroupReadOrchestrator extends BaseReadOrchestrator { constructor( private indexManager: IndexTimeGroupManager, + private indexPublicManager: IndexPublicTimeGroupManager, private detailManager: DetailTimeGroupManager, private serviceData: TimeGroupReadService, ) { @@ -24,6 +26,16 @@ export class TimeGroupReadOrchestrator extends BaseReadOrchestrator> { + this.indexPublicManager.setFilterParam(params); + this.indexPublicManager.setService( + this.serviceData, + TABLE_NAME.TIME_GROUPS, + ); + await this.indexPublicManager.execute(); + return this.indexPublicManager.getResult(); + } + async detail(dataId: string): Promise { this.detailManager.setData(dataId); this.detailManager.setService(this.serviceData, TABLE_NAME.TIME_GROUPS); diff --git a/src/modules/item-related/time-group/infrastructure/time-group-read.controller.ts b/src/modules/item-related/time-group/infrastructure/time-group-read.controller.ts index 212558d..73b3a14 100644 --- a/src/modules/item-related/time-group/infrastructure/time-group-read.controller.ts +++ b/src/modules/item-related/time-group/infrastructure/time-group-read.controller.ts @@ -28,3 +28,19 @@ export class TimeGroupReadController { return await this.orchestrator.detail(id); } } + +@ApiTags(`${MODULE_NAME.TIME_GROUPS.split('-').join(' ')} List- read`) +// @Controller(`v1/${MODULE_NAME.TIME_GROUPS}-list`) +@Controller(``) +@Public() +export class TimeGroupPublicReadController { + constructor(private orchestrator: TimeGroupReadOrchestrator) {} + + @Get('v1/time-group-list-by-items') + @Pagination() + async indexPublic( + @Query() params: FilterTimeGroupDto, + ): Promise> { + return await this.orchestrator.indexPublic(params); + } +} diff --git a/src/modules/item-related/time-group/time-group.module.ts b/src/modules/item-related/time-group/time-group.module.ts index 45455c0..87009a2 100644 --- a/src/modules/item-related/time-group/time-group.module.ts +++ b/src/modules/item-related/time-group/time-group.module.ts @@ -4,7 +4,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants'; import { TimeGroupDataService } from './data/services/time-group-data.service'; import { TimeGroupReadService } from './data/services/time-group-read.service'; -import { TimeGroupReadController } from './infrastructure/time-group-read.controller'; +import { + TimeGroupPublicReadController, + TimeGroupReadController, +} from './infrastructure/time-group-read.controller'; import { TimeGroupReadOrchestrator } from './domain/usecases/time-group-read.orchestrator'; import { TimeGroupDataController } from './infrastructure/time-group-data.controller'; import { TimeGroupDataOrchestrator } from './domain/usecases/time-group-data.orchestrator'; @@ -22,6 +25,7 @@ import { BatchActiveTimeGroupManager } from './domain/usecases/managers/batch-ac import { BatchConfirmTimeGroupManager } from './domain/usecases/managers/batch-confirm-time-group.manager'; import { BatchInactiveTimeGroupManager } from './domain/usecases/managers/batch-inactive-time-group.manager'; import { TimeGroupModel } from './data/models/time-group.model'; +import { IndexPublicTimeGroupManager } from './domain/usecases/managers/index-public-time-group.manager'; @Module({ imports: [ @@ -29,8 +33,13 @@ import { TimeGroupModel } from './data/models/time-group.model'; TypeOrmModule.forFeature([TimeGroupModel], CONNECTION_NAME.DEFAULT), CqrsModule, ], - controllers: [TimeGroupDataController, TimeGroupReadController], + controllers: [ + TimeGroupDataController, + TimeGroupReadController, + TimeGroupPublicReadController, + ], providers: [ + IndexPublicTimeGroupManager, IndexTimeGroupManager, DetailTimeGroupManager, CreateTimeGroupManager, 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) { diff --git a/src/modules/transaction/vip-code/infrastructure/dto/vip-code.dto.ts b/src/modules/transaction/vip-code/infrastructure/dto/vip-code.dto.ts index 1666c7f..be762ae 100644 --- a/src/modules/transaction/vip-code/infrastructure/dto/vip-code.dto.ts +++ b/src/modules/transaction/vip-code/infrastructure/dto/vip-code.dto.ts @@ -1,6 +1,6 @@ import { BaseDto } from 'src/core/modules/infrastructure/dto/base.dto'; import { VipCodeEntity } from '../../domain/entities/vip-code.entity'; -import { IsNumber, IsObject, IsString } from 'class-validator'; +import { IsNumber, IsObject, IsString, ValidateIf } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class VipCodeDto extends BaseDto implements VipCodeEntity { @@ -29,6 +29,7 @@ export class VipCodeDto extends BaseDto implements VipCodeEntity { example: 25000, }) @IsNumber() + @ValidateIf((v) => v.discount_value) discount_value: number; @ApiProperty({