feat: implement reschedule verification process with OTP functionality

pull/147/head^2 1.6.9-alpha.1
shancheas 2025-06-10 10:26:36 +07:00
parent 3661d9d171
commit 8192396085
10 changed files with 278 additions and 3 deletions

View File

@ -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,

View File

@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RescheduleOtp1749524993295 implements MigrationInterface {
name = 'RescheduleOtp1749524993295';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<RescheduleVerificationModel>,
private readonly transactionService: TransactionReadService,
) {}
async saveVerification(
request: RescheduleRequest,
): Promise<RescheduleVerificationModel> {
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<RescheduleVerification> = {
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<TransactionEntity> {
const transaction = await this.transactionService.getOneByOptions({
where: { id: bookingId },
});
return transaction;
}
}

View File

@ -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;
}

View File

@ -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({

View File

@ -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 {}

View File

@ -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';