From 384459450675f99efb972053f89f4d10f9aa4666 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 29 Jun 2026 19:31:26 +0330 Subject: [PATCH] tipsEnabled --- .env.example | 2 + .../1789047000000-AddBookingReviews.ts | 54 +++++ .../1789047100000-AddBusinessTipsEnabled.ts | 18 ++ src/entities/booking-review.entity.ts | 53 +++++ src/entities/booking.entity.ts | 6 + src/entities/business.entity.ts | 4 + src/modules/bookings/bookings.controller.ts | 44 ++++ src/modules/bookings/bookings.module.ts | 2 + src/modules/bookings/bookings.service.ts | 225 +++++++++++++++++- .../bookings/dto/initiate-booking-tip.dto.ts | 9 + .../bookings/dto/submit-booking-review.dto.ts | 10 + src/modules/businesses/businesses.module.ts | 2 + src/modules/businesses/businesses.service.ts | 43 ++++ .../businesses/dto/update-business.dto.ts | 9 + 14 files changed, 479 insertions(+), 2 deletions(-) create mode 100644 src/database/migrations/1789047000000-AddBookingReviews.ts create mode 100644 src/database/migrations/1789047100000-AddBusinessTipsEnabled.ts create mode 100644 src/entities/booking-review.entity.ts create mode 100644 src/modules/bookings/dto/initiate-booking-tip.dto.ts create mode 100644 src/modules/bookings/dto/submit-booking-review.dto.ts diff --git a/.env.example b/.env.example index 70c87f1..daba6f1 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,8 @@ SMS_SENDER= WEB_APP_URL=https://montime.ir API_PUBLIC_URL=https://api.montime.ir +# BOOKING_REVIEW_DELAY_MINUTES=5 + # Online payment (Zarinpal). بدون ZARINPAL_MERCHANT_ID پرداخت سوری فعال است. # ZARINPAL_MERCHANT_ID= # ZARINPAL_SANDBOX=true diff --git a/src/database/migrations/1789047000000-AddBookingReviews.ts b/src/database/migrations/1789047000000-AddBookingReviews.ts new file mode 100644 index 0000000..bbe6724 --- /dev/null +++ b/src/database/migrations/1789047000000-AddBookingReviews.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingReviews1789047000000 implements MigrationInterface { + name = 'AddBookingReviews1789047000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "bookings" + ADD COLUMN IF NOT EXISTS "completedAt" TIMESTAMPTZ + `); + await queryRunner.query(` + ALTER TABLE "bookings" + ADD COLUMN IF NOT EXISTS "reviewPromptedAt" TIMESTAMPTZ + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "booking_reviews" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "bookingId" uuid NOT NULL, + "customerId" uuid NOT NULL, + "businessId" uuid NOT NULL, + "rating" integer NOT NULL, + "tipAmount" numeric(12,0), + "tipPaid" boolean NOT NULL DEFAULT false, + "tipAuthority" varchar(64), + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_booking_reviews" PRIMARY KEY ("id"), + CONSTRAINT "UQ_booking_reviews_booking" UNIQUE ("bookingId"), + CONSTRAINT "FK_booking_reviews_booking" FOREIGN KEY ("bookingId") + REFERENCES "bookings"("id") ON DELETE CASCADE, + CONSTRAINT "FK_booking_reviews_customer" FOREIGN KEY ("customerId") + REFERENCES "users"("id") ON DELETE RESTRICT, + CONSTRAINT "FK_booking_reviews_business" FOREIGN KEY ("businessId") + REFERENCES "businesses"("id") ON DELETE CASCADE, + CONSTRAINT "CHK_booking_reviews_rating" CHECK ("rating" >= 1 AND "rating" <= 5) + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_booking_reviews_business" + ON "booking_reviews" ("businessId") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "booking_reviews"`); + await queryRunner.query(` + ALTER TABLE "bookings" DROP COLUMN IF EXISTS "reviewPromptedAt" + `); + await queryRunner.query(` + ALTER TABLE "bookings" DROP COLUMN IF EXISTS "completedAt" + `); + } +} diff --git a/src/database/migrations/1789047100000-AddBusinessTipsEnabled.ts b/src/database/migrations/1789047100000-AddBusinessTipsEnabled.ts new file mode 100644 index 0000000..7e94b6a --- /dev/null +++ b/src/database/migrations/1789047100000-AddBusinessTipsEnabled.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBusinessTipsEnabled1789047100000 implements MigrationInterface { + name = 'AddBusinessTipsEnabled1789047100000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "businesses" + ADD COLUMN IF NOT EXISTS "tipsEnabled" boolean NOT NULL DEFAULT false + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "businesses" DROP COLUMN IF EXISTS "tipsEnabled" + `); + } +} diff --git a/src/entities/booking-review.entity.ts b/src/entities/booking-review.entity.ts new file mode 100644 index 0000000..44c6828 --- /dev/null +++ b/src/entities/booking-review.entity.ts @@ -0,0 +1,53 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { BookingEntity } from './booking.entity'; +import { BusinessEntity } from './business.entity'; +import { UserEntity } from './user.entity'; + +@Entity('booking_reviews') +export class BookingReviewEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => BookingEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bookingId' }) + booking!: BookingEntity; + + @Column({ type: 'uuid', unique: true }) + bookingId!: string; + + @ManyToOne(() => UserEntity, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'customerId' }) + customer!: UserEntity; + + @Column('uuid') + customerId!: string; + + @ManyToOne(() => BusinessEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'businessId' }) + business!: BusinessEntity; + + @Column('uuid') + businessId!: string; + + @Column({ type: 'int' }) + rating!: number; + + @Column({ type: 'decimal', precision: 12, scale: 0, nullable: true }) + tipAmount!: string | null; + + @Column({ default: false }) + tipPaid!: boolean; + + @Column({ type: 'varchar', nullable: true, length: 64 }) + tipAuthority!: string | null; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/src/entities/booking.entity.ts b/src/entities/booking.entity.ts index db9c31e..33797c6 100644 --- a/src/entities/booking.entity.ts +++ b/src/entities/booking.entity.ts @@ -80,6 +80,12 @@ export class BookingEntity { @Column({ type: 'varchar', nullable: true, length: 64 }) paymentAuthority!: string | null; + @Column({ type: 'timestamptz', nullable: true }) + completedAt!: Date | null; + + @Column({ type: 'timestamptz', nullable: true }) + reviewPromptedAt!: Date | null; + @CreateDateColumn() createdAt!: Date; diff --git a/src/entities/business.entity.ts b/src/entities/business.entity.ts index 28201b7..c6c5f24 100644 --- a/src/entities/business.entity.ts +++ b/src/entities/business.entity.ts @@ -62,6 +62,10 @@ export class BusinessEntity { @Column({ default: false }) autoApproveBookings!: boolean; + /** When true, customers can leave an optional gratuity after rating a completed booking. */ + @Column({ default: false }) + tipsEnabled!: boolean; + @ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, { onDelete: 'RESTRICT', }) diff --git a/src/modules/bookings/bookings.controller.ts b/src/modules/bookings/bookings.controller.ts index f1bcc99..80faeec 100644 --- a/src/modules/bookings/bookings.controller.ts +++ b/src/modules/bookings/bookings.controller.ts @@ -23,6 +23,8 @@ import { CreateBookingDto } from './dto/create-booking.dto'; import { CreateBookingBatchDto } from './dto/create-booking-batch.dto'; import { CancelBookingDto } from './dto/cancel-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; +import { SubmitBookingReviewDto } from './dto/submit-booking-review.dto'; +import { InitiateBookingTipDto } from './dto/initiate-booking-tip.dto'; @Controller('bookings') export class BookingsController { @@ -80,6 +82,26 @@ export class BookingsController { return this.service.initiatePaymentByToken(token, user); } + @Get('pending-reviews') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.CUSTOMER) + getPendingReviews(@CurrentUser() user: AuthUser) { + return this.service.getPendingReviews(user); + } + + @Get('tip/callback') + @Redirect() + async tipCallback( + @Query('Authority') authority: string, + @Query('Status') status: string, + ): Promise<{ url: string; statusCode: number }> { + const { redirectUrl } = await this.service.handleTipCallback({ + authority, + status, + }); + return { url: redirectUrl, statusCode: 302 }; + } + @Get() @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER) @@ -165,6 +187,28 @@ export class BookingsController { return this.service.complete(id, user); } + @Post(':id/review') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.CUSTOMER) + submitReview( + @Param('id') id: string, + @Body() payload: SubmitBookingReviewDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.submitReview(id, payload.rating, user); + } + + @Post(':id/tip/init') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.CUSTOMER) + initiateTip( + @Param('id') id: string, + @Body() payload: InitiateBookingTipDto, + @CurrentUser() user: AuthUser, + ) { + return this.service.initiateTip(id, payload.amountToman, user); + } + @Post(':id/cancel') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER) diff --git a/src/modules/bookings/bookings.module.ts b/src/modules/bookings/bookings.module.ts index 1c18d1e..77f18f9 100644 --- a/src/modules/bookings/bookings.module.ts +++ b/src/modules/bookings/bookings.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingEntity } from '../../entities/booking.entity'; +import { BookingReviewEntity } from '../../entities/booking-review.entity'; import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity'; import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; @@ -17,6 +18,7 @@ import { BookingsService } from './bookings.service'; imports: [ TypeOrmModule.forFeature([ BookingEntity, + BookingReviewEntity, UserEntity, BusinessEntity, BusinessServiceEntity, diff --git a/src/modules/bookings/bookings.service.ts b/src/modules/bookings/bookings.service.ts index 2a2c9d1..fff435e 100644 --- a/src/modules/bookings/bookings.service.ts +++ b/src/modules/bookings/bookings.service.ts @@ -18,6 +18,7 @@ import { type BusyInterval, } from '../../common/utils/booking-schedule.util'; import { BookingEntity, BookingStatus } from '../../entities/booking.entity'; +import { BookingReviewEntity } from '../../entities/booking-review.entity'; import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity'; import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; @@ -63,6 +64,8 @@ export class BookingsService { private readonly serviceClassRepo: Repository, @InjectRepository(BusinessServiceClassPriceEntity) private readonly classPriceRepo: Repository, + @InjectRepository(BookingReviewEntity) + private readonly reviewRepo: Repository, ) { const raw = this.config.get('BOOKING_MIN_LEAD_MINUTES'); const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN; @@ -341,10 +344,22 @@ export class BookingsService { } async complete(id: string, actor?: AuthUser): Promise { - const row = await this.findOne(id); + const row = await this.findOne(id, actor); this.assertCanManageBooking(row, actor); this.applyStatusTransition(row, BookingStatus.COMPLETED, actor); - return this.repo.save(row); + const now = new Date(); + row.completedAt = now; + if (row.bookingGroupId) { + await this.repo.update( + { bookingGroupId: row.bookingGroupId }, + { status: BookingStatus.COMPLETED, completedAt: now }, + ); + } else { + await this.repo.save(row); + } + const completed = await this.findOne(id, actor); + await this.promptReviewIfNeeded(completed); + return completed; } async cancel( @@ -1126,4 +1141,210 @@ export class BookingsService { `رزرو شما در «${businessTitle}» تأیید شد. مبلغ ${amount.toLocaleString('fa-IR')} تومان — پرداخت: ${payUrl}`, ); } + + async getPendingReviews(actor: AuthUser): Promise< + Array<{ + bookingId: string; + businessId: string; + businessTitle: string; + serviceTitle: string; + scheduledAt: string; + tipsEnabled: boolean; + }> + > { + const bookings = await this.repo.find({ + where: { customerId: actor.id, status: BookingStatus.COMPLETED }, + relations: { + business: true, + businessService: { serviceTemplate: true }, + }, + order: { completedAt: 'DESC' }, + }); + + const seenGroupKeys = new Set(); + const pending: Array<{ + bookingId: string; + businessId: string; + businessTitle: string; + serviceTitle: string; + scheduledAt: string; + tipsEnabled: boolean; + }> = []; + + for (const booking of bookings) { + const groupKey = booking.bookingGroupId ?? booking.id; + if (seenGroupKeys.has(groupKey)) continue; + seenGroupKeys.add(groupKey); + + if (!booking.completedAt) continue; + + const reviewBookingId = await this.resolveReviewBookingId(booking); + const existing = await this.reviewRepo.findOne({ + where: { bookingId: reviewBookingId }, + }); + if (existing) continue; + + await this.promptReviewIfNeeded(booking); + pending.push({ + bookingId: reviewBookingId, + businessId: booking.businessId, + businessTitle: booking.business?.title ?? 'کسب‌وکار', + serviceTitle: + booking.businessService?.serviceTemplate?.title ?? 'خدمت', + scheduledAt: booking.scheduledAt.toISOString(), + tipsEnabled: booking.business?.tipsEnabled === true, + }); + } + + return pending; + } + + async submitReview( + bookingId: string, + rating: number, + actor: AuthUser, + ): Promise { + const row = await this.findOne(bookingId, actor); + if (row.customerId !== actor.id && actor.role !== UserRole.ADMIN) { + throw new ForbiddenException('Only the customer can review this booking'); + } + if (row.status !== BookingStatus.COMPLETED) { + throw new BadRequestException('Only completed bookings can be reviewed'); + } + if (!row.business?.tipsEnabled) { + throw new BadRequestException('Tips are not enabled for this business'); + } + + const reviewBookingId = await this.resolveReviewBookingId(row); + const existing = await this.reviewRepo.findOne({ + where: { bookingId: reviewBookingId }, + }); + if (existing) { + throw new BadRequestException('This booking has already been reviewed'); + } + + const review = this.reviewRepo.create({ + bookingId: reviewBookingId, + customerId: row.customerId, + businessId: row.businessId, + rating, + }); + return this.reviewRepo.save(review); + } + + async initiateTip( + bookingId: string, + amountToman: number, + actor: AuthUser, + ): Promise<{ startUrl: string; amountToman: number; mock?: boolean }> { + const row = await this.findOne(bookingId, actor); + if (row.customerId !== actor.id && actor.role !== UserRole.ADMIN) { + throw new ForbiddenException('Only the customer can tip for this booking'); + } + if (!row.business?.tipsEnabled) { + throw new BadRequestException('Tips are not enabled for this business'); + } + + const reviewBookingId = await this.resolveReviewBookingId(row); + const review = await this.reviewRepo.findOne({ + where: { bookingId: reviewBookingId }, + }); + if (!review) { + throw new BadRequestException('Submit a rating before tipping'); + } + if (review.tipPaid) { + throw new BadRequestException('Tip has already been paid'); + } + + const businessTitle = row.business?.title ?? 'کسب‌وکار'; + const payment = await this.zarinpal.requestPayment({ + amountToman, + description: `حسن انجام کار — ${businessTitle}`, + callbackUrl: `${this.apiPublicUrl}/bookings/tip/callback`, + mobile: row.customer?.mobile, + }); + + review.tipAmount = String(amountToman); + review.tipAuthority = payment.authority; + + if (payment.mock) { + review.tipPaid = true; + await this.reviewRepo.save(review); + return { startUrl: '', amountToman, mock: true }; + } + + await this.reviewRepo.save(review); + return { startUrl: payment.startUrl, amountToman }; + } + + async handleTipCallback(params: { + authority: string; + status: string; + }): Promise<{ redirectUrl: string }> { + if (params.status !== 'OK') { + return { redirectUrl: `${this.webAppUrl}/app/bookings?tip=failed` }; + } + + const review = await this.reviewRepo.findOne({ + where: { tipAuthority: params.authority }, + }); + if (!review || !review.tipAmount) { + return { redirectUrl: `${this.webAppUrl}/app/bookings?tip=failed` }; + } + + const amountToman = Number.parseInt(review.tipAmount, 10); + try { + await this.zarinpal.verifyPayment({ + authority: params.authority, + amountToman, + }); + } catch (error) { + this.logger.warn(`Tip verify failed: ${String(error)}`); + return { redirectUrl: `${this.webAppUrl}/app/bookings?tip=failed` }; + } + + review.tipPaid = true; + await this.reviewRepo.save(review); + return { redirectUrl: `${this.webAppUrl}/app/bookings?tip=success` }; + } + + private async resolveReviewBookingId(booking: BookingEntity): Promise { + if (!booking.bookingGroupId) { + return booking.id; + } + const leader = await this.repo.findOne({ + where: { bookingGroupId: booking.bookingGroupId }, + order: { createdAt: 'ASC' }, + select: ['id'], + }); + return leader?.id ?? booking.id; + } + + private async promptReviewIfNeeded(booking: BookingEntity): Promise { + if (booking.reviewPromptedAt) return; + + const reviewBookingId = await this.resolveReviewBookingId(booking); + const row = await this.repo.findOne({ + where: { id: reviewBookingId }, + relations: { customer: true, business: true }, + }); + if (!row?.customer?.mobile) return; + + const businessTitle = row.business?.title ?? 'کسب‌وکار'; + await this.sms.sendSms( + row.customer.mobile, + `خدمت «${businessTitle}» انجام شد. لطفاً در اپ منتایم به خدمات امتیاز دهید: ${this.webAppUrl}/app`, + ); + + const now = new Date(); + if (row.bookingGroupId) { + await this.repo.update( + { bookingGroupId: row.bookingGroupId }, + { reviewPromptedAt: now }, + ); + } else { + row.reviewPromptedAt = now; + await this.repo.save(row); + } + } } diff --git a/src/modules/bookings/dto/initiate-booking-tip.dto.ts b/src/modules/bookings/dto/initiate-booking-tip.dto.ts new file mode 100644 index 0000000..c3e833e --- /dev/null +++ b/src/modules/bookings/dto/initiate-booking-tip.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, Min } from 'class-validator'; + +export class InitiateBookingTipDto { + @ApiProperty({ minimum: 1000, example: 50000 }) + @IsInt() + @Min(1000) + amountToman!: number; +} diff --git a/src/modules/bookings/dto/submit-booking-review.dto.ts b/src/modules/bookings/dto/submit-booking-review.dto.ts new file mode 100644 index 0000000..45c59fe --- /dev/null +++ b/src/modules/bookings/dto/submit-booking-review.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, Max, Min } from 'class-validator'; + +export class SubmitBookingReviewDto { + @ApiProperty({ minimum: 1, maximum: 5, example: 5 }) + @IsInt() + @Min(1) + @Max(5) + rating!: number; +} diff --git a/src/modules/businesses/businesses.module.ts b/src/modules/businesses/businesses.module.ts index 5f1a12d..da5139f 100644 --- a/src/modules/businesses/businesses.module.ts +++ b/src/modules/businesses/businesses.module.ts @@ -4,6 +4,7 @@ import { BusinessCategoryEntity } from '../../entities/business-category.entity' import { BusinessImageEntity } from '../../entities/business-image.entity'; import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; import { BusinessEntity } from '../../entities/business.entity'; +import { BookingReviewEntity } from '../../entities/booking-review.entity'; import { UserEntity } from '../../entities/user.entity'; import { AuthModule } from '../auth/auth.module'; import { BusinessImagesService } from './business-images.service'; @@ -14,6 +15,7 @@ import { BusinessesService } from './businesses.service'; imports: [ TypeOrmModule.forFeature([ BusinessEntity, + BookingReviewEntity, BusinessCategoryEntity, UserEntity, BusinessImageEntity, diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index e322160..7a79fb4 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -13,6 +13,7 @@ import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.u import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; import { BusinessEntity } from '../../entities/business.entity'; +import { BookingReviewEntity } from '../../entities/booking-review.entity'; import { BusinessImageEntity } from '../../entities/business-image.entity'; import { UserEntity, UserRole } from '../../entities/user.entity'; import { AuthUser } from '../auth/auth.types'; @@ -40,6 +41,8 @@ export class BusinessesService { private readonly imageRepo: Repository, @InjectRepository(UserEntity) private readonly userRepo: Repository, + @InjectRepository(BookingReviewEntity) + private readonly reviewRepo: Repository, private readonly dataSource: DataSource, private readonly images: BusinessImagesService, ) {} @@ -64,6 +67,7 @@ export class BusinessesService { const businesses = await query.getMany(); await this.attachCoverImages(businesses); + await this.attachReviewStats(businesses); return businesses; } @@ -88,6 +92,7 @@ export class BusinessesService { throw new NotFoundException('Business not found'); } business.images = this.sortImages(business.images ?? []); + await this.attachReviewStats([business]); return business; } @@ -398,6 +403,44 @@ export class BusinessesService { return normalized; } + private async attachReviewStats(businesses: BusinessEntity[]): Promise { + if (!businesses.length) return; + + const ids = businesses.map((business) => business.id); + const rows = await this.reviewRepo + .createQueryBuilder('review') + .select('review.businessId', 'businessId') + .addSelect('AVG(review.rating)', 'averageRating') + .addSelect('COUNT(review.id)', 'reviewCount') + .where('review.businessId IN (:...ids)', { ids }) + .groupBy('review.businessId') + .getRawMany<{ + businessId: string; + averageRating: string; + reviewCount: string; + }>(); + + const statsById = new Map( + rows.map((row) => [ + row.businessId, + { + averageRating: Number.parseFloat(row.averageRating), + reviewCount: Number.parseInt(row.reviewCount, 10), + }, + ]), + ); + + for (const business of businesses) { + const stats = statsById.get(business.id); + (business as BusinessEntity & { + averageRating?: number | null; + reviewCount?: number; + }).averageRating = stats?.averageRating ?? null; + (business as BusinessEntity & { reviewCount?: number }).reviewCount = + stats?.reviewCount ?? 0; + } + } + private assertCanUpdateBusiness( business: BusinessEntity, payload: UpdateBusinessDto, diff --git a/src/modules/businesses/dto/update-business.dto.ts b/src/modules/businesses/dto/update-business.dto.ts index 4801b3c..d16da08 100644 --- a/src/modules/businesses/dto/update-business.dto.ts +++ b/src/modules/businesses/dto/update-business.dto.ts @@ -118,4 +118,13 @@ export class UpdateBusinessDto { @IsOptional() @IsBoolean() autoApproveBookings?: boolean; + + @ApiPropertyOptional({ + example: false, + description: + 'When true, customers see an optional gratuity (tip) step after rating a completed booking', + }) + @IsOptional() + @IsBoolean() + tipsEnabled?: boolean; }