diff --git a/src/app.module.ts b/src/app.module.ts index 462276d..5266aa2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -96,7 +96,9 @@ import { import { ItemQueueModule } from './modules/item-related/item-queue/item-queue.module'; import { ItemQueueModel } from './modules/item-related/item-queue/data/models/item-queue.model'; import { QueueBucketModel } from './modules/queue/data/models/queue-bucket.model'; - +import { VerificationModel } from './modules/booking-online/authentication/data/models/verification.model'; +import { BookingOnlineAuthModule } from './modules/booking-online/authentication/auth.module'; +import { BookingOrderModule } from './modules/booking-online/order/order.module'; @Module({ imports: [ ApmModule.register(), @@ -157,6 +159,9 @@ import { QueueBucketModel } from './modules/queue/data/models/queue-bucket.model QueueItemModel, QueueModel, QueueBucketModel, + + // Booking Online + VerificationModel, ], synchronize: false, }), @@ -218,6 +223,9 @@ import { QueueBucketModel } from './modules/queue/data/models/queue-bucket.model GateScanModule, QueueModule, + + BookingOnlineAuthModule, + BookingOrderModule, ], controllers: [], providers: [ diff --git a/src/database/migrations/1748313715598-booking-authentication.ts b/src/database/migrations/1748313715598-booking-authentication.ts new file mode 100644 index 0000000..e67e35c --- /dev/null +++ b/src/database/migrations/1748313715598-booking-authentication.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BookingAuthentication1748313715598 implements MigrationInterface { + name = 'BookingAuthentication1748313715598'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "booking_verification" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "phone_number" character varying NOT NULL, "code" character varying, "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_046e9288c7dd05c7259275d9fc0" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "booking_verification"`); + } +} diff --git a/src/modules/booking-online/authentication/auth.module.ts b/src/modules/booking-online/authentication/auth.module.ts new file mode 100644 index 0000000..6ad3279 --- /dev/null +++ b/src/modules/booking-online/authentication/auth.module.ts @@ -0,0 +1,25 @@ +import { CONNECTION_NAME } from 'src/core/strings/constants/base.constants'; + +import { ConfigModule } from '@nestjs/config'; +import { Module } from '@nestjs/common'; +import { VerificationModel } from './data/models/verification.model'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingAuthenticationController } from './infrastructure/controllers/booking-authentication.controller'; +import { VerificationService } from './data/services/verification.service'; +import { JwtModule } from '@nestjs/jwt'; +import { JWT_EXPIRED } from 'src/core/sessions/constants'; +import { JWT_SECRET } from 'src/core/sessions/constants'; +@Module({ + imports: [ + ConfigModule.forRoot(), + TypeOrmModule.forFeature([VerificationModel], CONNECTION_NAME.DEFAULT), + + JwtModule.register({ + secret: JWT_SECRET, + signOptions: { expiresIn: JWT_EXPIRED }, + }), + ], + controllers: [BookingAuthenticationController], + providers: [VerificationService], +}) +export class BookingOnlineAuthModule {} diff --git a/src/modules/booking-online/authentication/data/models/verification.model.ts b/src/modules/booking-online/authentication/data/models/verification.model.ts new file mode 100644 index 0000000..58de7dd --- /dev/null +++ b/src/modules/booking-online/authentication/data/models/verification.model.ts @@ -0,0 +1,29 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { BookingVerification } from '../../domain/entities/booking-verification.entity'; +@Entity('booking_verification') +export class VerificationModel implements BookingVerification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column() + phone_number: string; + + @Column({ nullable: true }) + code?: string; + + @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/authentication/data/services/verification.service.ts b/src/modules/booking-online/authentication/data/services/verification.service.ts new file mode 100644 index 0000000..85ce140 --- /dev/null +++ b/src/modules/booking-online/authentication/data/services/verification.service.ts @@ -0,0 +1,95 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { VerificationModel } from '../models/verification.model'; +import { BookingVerification } from '../../domain/entities/booking-verification.entity'; +import { UnprocessableEntityException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +export class VerificationService { + constructor( + @InjectRepository(VerificationModel) + private readonly verificationRepository: Repository, + private readonly jwtService: JwtService, + ) {} + + maxAttempts = 3; + expiredTime = 5 * 60 * 1000; + expiredTimeRegister = 1 * 60 * 1000; + + async generateToken(payload: BookingVerification) { + return this.jwtService.sign({ + phone_number: payload.phone_number, + name: payload.name, + created_at: payload.created_at, + }); + } + + async register(data: BookingVerification) { + const currentTime = Math.floor(Date.now()); // current time in seconds + if ( + data.created_at && + currentTime - data.created_at > this.expiredTimeRegister + ) { + throw new UnprocessableEntityException('Please try again in 1 minute'); + } + // Generate a 4 digit OTP code + data.code = Math.floor(1000 + Math.random() * 9000).toString(); + data.tried = 0; + data.updated_at = currentTime; + + let verification = await this.verificationRepository.findOne({ + where: { phone_number: data.phone_number }, + }); + + if (verification) { + // Update existing record + verification = this.verificationRepository.merge(verification, data); + } else { + // Create new record + verification = this.verificationRepository.create(data); + } + return this.verificationRepository.save(verification); + } + + async findByPhoneNumber(phoneNumber: string) { + return this.verificationRepository.findOne({ + where: { phone_number: phoneNumber }, + }); + } + + async verify(data: BookingVerification): Promise { + const verification = await this.findByPhoneNumber(data.phone_number); + if (!verification) { + throw new UnprocessableEntityException('Phone number not found'); + } + + if (verification.tried >= this.maxAttempts) { + throw new UnprocessableEntityException( + 'Too many attempts, please resend OTP Code', + ); + } + + if (verification.code != data.code) { + verification.tried++; + await this.verificationRepository.save(verification); + throw new UnprocessableEntityException('Invalid verification code'); + } + + const currentTime = Math.floor(Date.now()); + if ( + verification.updated_at && + currentTime - verification.updated_at > this.expiredTime + ) { + throw new UnprocessableEntityException('Verification code expired'); + } + + return verification; + } + + async update(id: string, data: BookingVerification) { + return this.verificationRepository.update(id, data); + } + + async delete(id: string) { + return this.verificationRepository.delete(id); + } +} diff --git a/src/modules/booking-online/authentication/domain/entities/booking-verification.entity.ts b/src/modules/booking-online/authentication/domain/entities/booking-verification.entity.ts new file mode 100644 index 0000000..e446511 --- /dev/null +++ b/src/modules/booking-online/authentication/domain/entities/booking-verification.entity.ts @@ -0,0 +1,9 @@ +export interface BookingVerification { + id: string; + name: string; + phone_number: string; + code?: string; + tried?: number; + created_at?: number; + updated_at?: number; +} diff --git a/src/modules/booking-online/authentication/infrastructure/controllers/booking-authentication.controller.ts b/src/modules/booking-online/authentication/infrastructure/controllers/booking-authentication.controller.ts new file mode 100644 index 0000000..6ae67c6 --- /dev/null +++ b/src/modules/booking-online/authentication/infrastructure/controllers/booking-authentication.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Post, Body, Get, Param } from '@nestjs/common'; + +import { + BookingVerificationDto, + VerificationCodeDto, +} from '../dto/booking-verification.dto'; +import { VerificationService } from '../../data/services/verification.service'; +import { Public } from 'src/core/guards/domain/decorators/unprotected.guard'; +import { ApiTags } from '@nestjs/swagger'; + +@ApiTags('Booking Authentication') +@Public() +@Controller('booking-authentication') +export class BookingAuthenticationController { + constructor( + private readonly bookingAuthenticationService: VerificationService, + ) {} + + @Post('verify') + async verify(@Body() body: VerificationCodeDto) { + const verification = await this.bookingAuthenticationService.verify(body); + const token = await this.bookingAuthenticationService.generateToken( + verification, + ); + return { + message: `Verification successful for ${verification.phone_number}`, + token, + }; + } + + @Post('register') + async register(@Body() body: BookingVerificationDto) { + const verification = await this.bookingAuthenticationService.register(body); + return { + message: `Verification code sent to ${verification.phone_number}`, + }; + } + + @Get('get-by-phone/:phone_number') + async getByPhoneNumber(@Param('phone_number') phone_number: string) { + return this.bookingAuthenticationService.findByPhoneNumber(phone_number); + } +} diff --git a/src/modules/booking-online/authentication/infrastructure/dto/booking-verification.dto.ts b/src/modules/booking-online/authentication/infrastructure/dto/booking-verification.dto.ts new file mode 100644 index 0000000..f3e371e --- /dev/null +++ b/src/modules/booking-online/authentication/infrastructure/dto/booking-verification.dto.ts @@ -0,0 +1,67 @@ +import { BookingVerification } from '../../domain/entities/booking-verification.entity'; +import { IsString, IsNotEmpty, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class BookingVerificationDto implements BookingVerification { + id: string; + + @ApiProperty({ + type: String, + required: true, + example: 'John Doe', + description: 'Name of the person booking', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + type: String, + required: true, + example: '628123456789', + description: 'Phone number containing only numeric characters', + }) + @IsString() + @IsNotEmpty() + @Matches(/^\d+$/, { + message: 'phone_number must contain only numeric characters', + }) + phone_number: string; +} + +export class VerificationCodeDto implements BookingVerification { + id: string; + + @ApiProperty({ + type: String, + required: true, + example: 'John Doe', + description: 'Name of the person booking', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + type: String, + required: true, + example: '628123456789', + description: 'Phone number containing only numeric characters', + }) + @IsString() + @IsNotEmpty() + @Matches(/^\d+$/, { + message: 'phone_number must contain only numeric characters', + }) + phone_number: string; + + @ApiProperty({ + type: String, + required: true, + example: '1234', + description: 'Verification code', + }) + @IsString() + @IsNotEmpty() + code?: string; +} diff --git a/src/modules/booking-online/order/infrastructure/item.controller.ts b/src/modules/booking-online/order/infrastructure/item.controller.ts new file mode 100644 index 0000000..e6b8efe --- /dev/null +++ b/src/modules/booking-online/order/infrastructure/item.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Public } from 'src/core/guards'; +import { PaginationResponse } from 'src/core/response/domain/ok-response.interface'; +import { TABLE_NAME } from 'src/core/strings/constants/table.constants'; +import { ItemReadService } from 'src/modules/item-related/item/data/services/item-read.service'; +import { ItemEntity } from 'src/modules/item-related/item/domain/entities/item.entity'; +import { IndexItemManager } from 'src/modules/item-related/item/domain/usecases/managers/index-item.manager'; +import { FilterItemDto } from 'src/modules/item-related/item/infrastructure/dto/filter-item.dto'; + +@ApiTags('Booking Item') +@Controller('booking-item') +@Public(true) +export class ItemController { + constructor( + private indexManager: IndexItemManager, + private serviceData: ItemReadService, + ) {} + + @Get() + async index( + @Query() params: FilterItemDto, + ): Promise> { + try { + params.show_to_booking = true; + this.indexManager.setFilterParam(params); + this.indexManager.setService(this.serviceData, TABLE_NAME.ITEM); + await this.indexManager.execute(); + return this.indexManager.getResult(); + } catch (error) { + console.log(error); + throw error; + } + } +} diff --git a/src/modules/booking-online/order/order.module.ts b/src/modules/booking-online/order/order.module.ts new file mode 100644 index 0000000..7a2203b --- /dev/null +++ b/src/modules/booking-online/order/order.module.ts @@ -0,0 +1,18 @@ +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 { ItemModel } from 'src/modules/item-related/item/data/models/item.model'; +import { ItemModule } from 'src/modules/item-related/item/item.module'; +import { ItemController } from './infrastructure/item.controller'; +@Module({ + imports: [ + ConfigModule.forRoot(), + TypeOrmModule.forFeature([ItemModel], CONNECTION_NAME.DEFAULT), + ItemModule, + ], + controllers: [ItemController], + providers: [], +}) +export class BookingOrderModule {} diff --git a/src/modules/item-related/item/domain/usecases/managers/index-item.manager.ts b/src/modules/item-related/item/domain/usecases/managers/index-item.manager.ts index 3c51840..b34b1ca 100644 --- a/src/modules/item-related/item/domain/usecases/managers/index-item.manager.ts +++ b/src/modules/item-related/item/domain/usecases/managers/index-item.manager.ts @@ -98,6 +98,10 @@ export class IndexItemManager extends BaseIndexManager { queryBuilder.andWhere(`${this.tableName}.tenant_id Is Null`); } + if (this.filterParam.show_to_booking) { + queryBuilder.andWhere(`${this.tableName}.show_to_booking = true`); + } + return queryBuilder; } } diff --git a/src/modules/item-related/item/infrastructure/dto/filter-item.dto.ts b/src/modules/item-related/item/infrastructure/dto/filter-item.dto.ts index 07b9845..b87dc33 100644 --- a/src/modules/item-related/item/infrastructure/dto/filter-item.dto.ts +++ b/src/modules/item-related/item/infrastructure/dto/filter-item.dto.ts @@ -40,4 +40,6 @@ export class FilterItemDto extends BaseFilterDto implements FilterItemEntity { }) @Transform((body) => body.value == 'true') all_item: boolean; + + show_to_booking: boolean; }