tipsEnabled

This commit is contained in:
2026-06-29 19:31:26 +03:30
parent 2ba959b3ae
commit 3844594506
14 changed files with 479 additions and 2 deletions
+2
View File
@@ -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"
`);
}
}
+53
View File
@@ -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;
}
+6
View File
@@ -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;
+4
View File
@@ -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)
+2
View File
@@ -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,
+223 -2
View File
@@ -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;
}