tipsEnabled
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingReviews1789047000000 implements MigrationInterface {
|
||||
name = 'AddBookingReviews1789047000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBusinessTipsEnabled1789047100000 implements MigrationInterface {
|
||||
name = 'AddBusinessTipsEnabled1789047100000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "tipsEnabled" boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses" DROP COLUMN IF EXISTS "tipsEnabled"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BusinessServiceClassEntity>,
|
||||
@InjectRepository(BusinessServiceClassPriceEntity)
|
||||
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
|
||||
@InjectRepository(BookingReviewEntity)
|
||||
private readonly reviewRepo: Repository<BookingReviewEntity>,
|
||||
) {
|
||||
const raw = this.config.get<string | undefined>('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<BookingEntity> {
|
||||
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<string>();
|
||||
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<BookingReviewEntity> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BusinessImageEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly userRepo: Repository<UserEntity>,
|
||||
@InjectRepository(BookingReviewEntity)
|
||||
private readonly reviewRepo: Repository<BookingReviewEntity>,
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user