From 81923960852ba7af7b699d657cfe8402c5134ca4 Mon Sep 17 00:00:00 2001 From: shancheas Date: Tue, 10 Jun 2025 10:26:36 +0700 Subject: [PATCH] feat: implement reschedule verification process with OTP functionality --- src/app.module.ts | 2 + .../1749524993295-reschedule-otp.ts | 28 +++++ .../booking-online/helpers/generate-otp.ts | 9 ++ .../models/reschedule-verification.model.ts | 36 ++++++ .../reschedule-verification.entity.ts | 16 +++ .../reschedule-verification.manager.ts | 105 ++++++++++++++++++ .../infrastructure/dto/reschedule.dto.ts | 47 ++++++++ .../order/infrastructure/order.controller.ts | 27 +++++ .../booking-online/order/order.module.ts | 9 +- src/services/whatsapp/whatsapp.constant.ts | 2 +- 10 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 src/database/migrations/1749524993295-reschedule-otp.ts create mode 100644 src/modules/booking-online/helpers/generate-otp.ts create mode 100644 src/modules/booking-online/order/data/models/reschedule-verification.model.ts create mode 100644 src/modules/booking-online/order/domain/entities/reschedule-verification.entity.ts create mode 100644 src/modules/booking-online/order/domain/usecases/managers/reschedule-verification.manager.ts create mode 100644 src/modules/booking-online/order/infrastructure/dto/reschedule.dto.ts diff --git a/src/app.module.ts b/src/app.module.ts index 75ab21a..7a8461f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -105,6 +105,7 @@ import { TimeGroupModel } from './modules/item-related/time-group/data/models/ti 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'; +import { RescheduleVerificationModel } from './modules/booking-online/order/data/models/reschedule-verification.model'; @Module({ imports: [ @@ -170,6 +171,7 @@ import { OtpVerifierModel } from './modules/configuration/otp-verification/data/ // Booking Online VerificationModel, + RescheduleVerificationModel, OtpVerificationModel, OtpVerifierModel, diff --git a/src/database/migrations/1749524993295-reschedule-otp.ts b/src/database/migrations/1749524993295-reschedule-otp.ts new file mode 100644 index 0000000..b9051ff --- /dev/null +++ b/src/database/migrations/1749524993295-reschedule-otp.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RescheduleOtp1749524993295 implements MigrationInterface { + name = 'RescheduleOtp1749524993295'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "reschedule_verification" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "phone_number" character varying NOT NULL, "booking_id" character varying NOT NULL, "reschedule_date" character varying NOT NULL, "code" integer NOT NULL, "tried" integer NOT NULL DEFAULT '0', "created_at" bigint NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000, "updated_at" bigint NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000, CONSTRAINT "PK_d4df453337ca12771eb223323d8" PRIMARY KEY ("id"))`, + ); + + await queryRunner.query( + `ALTER TABLE "booking_verification" ALTER COLUMN "created_at" SET DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000`, + ); + await queryRunner.query( + `ALTER TABLE "booking_verification" ALTER COLUMN "updated_at" SET DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "booking_verification" ALTER COLUMN "updated_at" SET DEFAULT (EXTRACT(epoch FROM now()) * (1000))`, + ); + await queryRunner.query( + `ALTER TABLE "booking_verification" ALTER COLUMN "created_at" SET DEFAULT (EXTRACT(epoch FROM now()) * (1000))`, + ); + await queryRunner.query(`DROP TABLE "reschedule_verification"`); + } +} diff --git a/src/modules/booking-online/helpers/generate-otp.ts b/src/modules/booking-online/helpers/generate-otp.ts new file mode 100644 index 0000000..5b737cf --- /dev/null +++ b/src/modules/booking-online/helpers/generate-otp.ts @@ -0,0 +1,9 @@ +export function generateOtp(digits = 4): number { + if (digits < 1) { + throw new Error('OTP digits must be at least 1'); + } + const min = Math.pow(10, digits - 1); + const max = Math.pow(10, digits) - 1; + const otp = Math.floor(Math.random() * (max - min + 1)) + min; + return otp; +} diff --git a/src/modules/booking-online/order/data/models/reschedule-verification.model.ts b/src/modules/booking-online/order/data/models/reschedule-verification.model.ts new file mode 100644 index 0000000..a3935fc --- /dev/null +++ b/src/modules/booking-online/order/data/models/reschedule-verification.model.ts @@ -0,0 +1,36 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { RescheduleVerification } from '../../domain/entities/reschedule-verification.entity'; + +@Entity('reschedule_verification') +export class RescheduleVerificationModel implements RescheduleVerification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column() + phone_number: string; + + @Column() + booking_id: string; + + @Column() + reschedule_date: string; + + @Column() + code: number; + + @Column({ default: 0 }) + tried: number; + + @Column({ type: 'bigint', default: () => 'EXTRACT(EPOCH FROM NOW()) * 1000' }) + created_at: number; + + @Column({ + type: 'bigint', + default: () => 'EXTRACT(EPOCH FROM NOW()) * 1000', + onUpdate: 'EXTRACT(EPOCH FROM NOW()) * 1000', + }) + updated_at: number; +} diff --git a/src/modules/booking-online/order/domain/entities/reschedule-verification.entity.ts b/src/modules/booking-online/order/domain/entities/reschedule-verification.entity.ts new file mode 100644 index 0000000..482d4ba --- /dev/null +++ b/src/modules/booking-online/order/domain/entities/reschedule-verification.entity.ts @@ -0,0 +1,16 @@ +export interface RescheduleVerification { + id: string; + name: string; + phone_number: string; + booking_id: string; + reschedule_date: string; + code: number; + tried?: number; + created_at?: number; + updated_at?: number; +} + +export interface RescheduleRequest { + booking_id: string; + reschedule_date: string; +} diff --git a/src/modules/booking-online/order/domain/usecases/managers/reschedule-verification.manager.ts b/src/modules/booking-online/order/domain/usecases/managers/reschedule-verification.manager.ts new file mode 100644 index 0000000..168b6f6 --- /dev/null +++ b/src/modules/booking-online/order/domain/usecases/managers/reschedule-verification.manager.ts @@ -0,0 +1,105 @@ +import { Injectable, UnprocessableEntityException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RescheduleVerificationModel } from '../../../data/models/reschedule-verification.model'; +import { + RescheduleRequest, + RescheduleVerification, +} from '../../entities/reschedule-verification.entity'; +import { generateOtp } from 'src/modules/booking-online/helpers/generate-otp'; +import { TransactionReadService } from 'src/modules/transaction/transaction/data/services/transaction-read.service'; +import { TransactionEntity } from 'src/modules/transaction/transaction/domain/entities/transaction.entity'; +import { WhatsappService } from 'src/services/whatsapp/whatsapp.service'; + +@Injectable() +export class RescheduleVerificationManager { + constructor( + @InjectRepository(RescheduleVerificationModel) + private readonly rescheduleVerificationRepository: Repository, + private readonly transactionService: TransactionReadService, + ) {} + + async saveVerification( + request: RescheduleRequest, + ): Promise { + try { + const otp = generateOtp(); + const transaction = await this.findDetailByBookingId(request.booking_id); + + if (!transaction) { + throw new Error('Transaction not found for the provided booking id'); + } + + const data: Partial = { + code: otp, + booking_id: transaction.id, + name: transaction.customer_name, + phone_number: transaction.customer_phone, + reschedule_date: request.reschedule_date, + }; + + const existTransaction = + await this.rescheduleVerificationRepository.findOne({ + where: { + booking_id: transaction.id, + }, + }); + + const verification = + existTransaction ?? this.rescheduleVerificationRepository.create(data); + const result = await this.rescheduleVerificationRepository.save({ + ...verification, + code: otp, + }); + + const whatsapp = new WhatsappService(); + whatsapp.sendOtpNotification({ + phone: transaction.customer_phone, + code: otp.toString(), + }); + return result; + } catch (error) { + // You can customize the error handling as needed, e.g., throw HttpException for NestJS + throw new UnprocessableEntityException( + `Failed to save reschedule verification: ${error.message}`, + ); + } + } + + async verifyOtp( + booking_id: string, + code: number, + ): Promise<{ success: boolean; message: string }> { + const verification = await this.rescheduleVerificationRepository.findOne({ + where: { booking_id, code }, + order: { created_at: 'DESC' }, + }); + + if (!verification) { + return { + success: false, + message: 'No verification code found for this booking.', + }; + } + + // Optionally, you can implement OTP expiration logic here + + if (verification.code !== code) { + // Increment tried count + verification.tried = (verification.tried || 0) + 1; + await this.rescheduleVerificationRepository.save(verification); + return { success: false, message: 'Invalid verification code.' }; + } + + // Optionally, you can mark the verification as used or verified here + + return { success: true, message: 'Verification successful.' }; + } + + async findDetailByBookingId(bookingId: string): Promise { + const transaction = await this.transactionService.getOneByOptions({ + where: { id: bookingId }, + }); + return transaction; + } +} diff --git a/src/modules/booking-online/order/infrastructure/dto/reschedule.dto.ts b/src/modules/booking-online/order/infrastructure/dto/reschedule.dto.ts new file mode 100644 index 0000000..21bb027 --- /dev/null +++ b/src/modules/booking-online/order/infrastructure/dto/reschedule.dto.ts @@ -0,0 +1,47 @@ +import { IsString, Matches } from 'class-validator'; +import { RescheduleRequest } from 'src/modules/booking-online/order/domain/entities/reschedule-verification.entity'; + +import { ApiProperty } from '@nestjs/swagger'; + +export class RescheduleRequestDTO implements RescheduleRequest { + @ApiProperty({ + type: String, + required: true, + example: '123e4567-e89b-12d3-a456-426614174000', + description: 'The unique identifier of the booking', + }) + @IsString() + booking_id: string; + + @ApiProperty({ + type: String, + required: true, + example: '25-12-2024', + description: 'The new date for rescheduling in the format DD-MM-YYYY', + }) + @IsString() + @Matches(/^(0[1-9]|[12][0-9]|3[01])-(0[1-9]|1[0-2])-\d{4}$/, { + message: 'reschedule_date must be in the format DD-MM-YYYY', + }) + reschedule_date: string; +} + +export class RescheduleVerificationOTP { + @ApiProperty({ + type: String, + required: true, + example: '123e4567-e89b-12d3-a456-426614174000', + description: 'The unique identifier of the booking', + }) + @IsString() + booking_id: string; + + @ApiProperty({ + type: String, + required: true, + example: '123456', + description: 'The OTP code sent for verification', + }) + @IsString() + code: string; +} diff --git a/src/modules/booking-online/order/infrastructure/order.controller.ts b/src/modules/booking-online/order/infrastructure/order.controller.ts index 6b87ca2..6e423d0 100644 --- a/src/modules/booking-online/order/infrastructure/order.controller.ts +++ b/src/modules/booking-online/order/infrastructure/order.controller.ts @@ -10,6 +10,11 @@ import { CreateBookingManager } from '../domain/usecases/managers/create-booking import * as QRCode from 'qrcode'; import { Gate } from 'src/core/response/domain/decorators/pagination.response'; import { Response } from 'express'; +import { + RescheduleRequestDTO, + RescheduleVerificationOTP, +} from './dto/reschedule.dto'; +import { RescheduleVerificationManager } from '../domain/usecases/managers/reschedule-verification.manager'; @ApiTags('Booking Order') @Controller('v1/booking') @@ -19,6 +24,7 @@ export class BookingOrderController { private createBooking: CreateBookingManager, private serviceData: TransactionDataService, private midtransService: MidtransService, + private rescheduleVerification: RescheduleVerificationManager, ) {} @Post() @@ -50,6 +56,27 @@ export class BookingOrderController { }; } + @Post('reschedule') + async reschedule(@Body() data: RescheduleRequestDTO) { + const result = await this.rescheduleVerification.saveVerification(data); + const maskedPhoneNumber = result.phone_number.replace(/.(?=.{4})/g, '*'); + result.phone_number = maskedPhoneNumber; + + return `Verification code sent to ${maskedPhoneNumber}`; + } + + @Post('reschedule/verification') + async verificationReschedule(@Body() data: RescheduleVerificationOTP) { + const result = await this.rescheduleVerification.verifyOtp( + data.booking_id, + +data.code, + ); + + const transaction = await this.get(data.booking_id); + + return { ...result, transaction }; + } + @Get(':id') async get(@Param('id') transactionId: string) { const data = await this.serviceData.getOneByOptions({ diff --git a/src/modules/booking-online/order/order.module.ts b/src/modules/booking-online/order/order.module.ts index 6a5c4b4..84c8626 100644 --- a/src/modules/booking-online/order/order.module.ts +++ b/src/modules/booking-online/order/order.module.ts @@ -11,16 +11,21 @@ import { BookingOrderController } from './infrastructure/order.controller'; import { CreateBookingManager } from './domain/usecases/managers/create-booking.manager'; import { MidtransModule } from 'src/modules/configuration/midtrans/midtrans.module'; import { CqrsModule } from '@nestjs/cqrs'; +import { RescheduleVerificationModel } from './data/models/reschedule-verification.model'; +import { RescheduleVerificationManager } from './domain/usecases/managers/reschedule-verification.manager'; @Module({ imports: [ ConfigModule.forRoot(), - TypeOrmModule.forFeature([ItemModel], CONNECTION_NAME.DEFAULT), + TypeOrmModule.forFeature( + [ItemModel, RescheduleVerificationModel], + CONNECTION_NAME.DEFAULT, + ), ItemModule, TransactionModule, MidtransModule, CqrsModule, ], controllers: [ItemController, BookingOrderController], - providers: [CreateBookingManager], + providers: [CreateBookingManager, RescheduleVerificationManager], }) export class BookingOrderModule {} diff --git a/src/services/whatsapp/whatsapp.constant.ts b/src/services/whatsapp/whatsapp.constant.ts index 2b8f472..07d8b76 100644 --- a/src/services/whatsapp/whatsapp.constant.ts +++ b/src/services/whatsapp/whatsapp.constant.ts @@ -7,7 +7,7 @@ export const BOOKING_QR_URL = export const BOOKING_TICKET_URL = process.env.BOOKING_TICKET_URL ?? - 'https://booking.sky.eigen.co.id/app/ticket/'; + 'https://booking.sky.eigen.co.id/public/ticket/'; export const WHATSAPP_BUSINESS_VERSION = process.env.WHATSAPP_BUSINESS_VERSION ?? 'v22.0';