update rezerv

This commit is contained in:
2026-05-31 21:36:19 +03:30
parent 7affab06f1
commit 150b189be5
25 changed files with 1134 additions and 49 deletions
+2
View File
@@ -13,6 +13,7 @@ import { BusinessAttributeValuesModule } from './modules/business-attribute-valu
import { BookingsModule } from './modules/bookings/bookings.module';
import { BusinessRegistrationRequestsModule } from './modules/business-registration-requests/business-registration-requests.module';
import { BusinessServiceRequestsModule } from './modules/business-service-requests/business-service-requests.module';
import { BusinessServiceClassesModule } from './modules/business-service-classes/business-service-classes.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
@@ -29,6 +30,7 @@ import { UsersModule } from './modules/users/users.module';
BookingsModule,
BusinessRegistrationRequestsModule,
BusinessServiceRequestsModule,
BusinessServiceClassesModule,
AuthModule,
UsersModule,
],
@@ -6,6 +6,7 @@ import { BusinessAttributeValueEntity } from '../../entities/business-attribute-
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
import { BusinessServiceRequestEntity } from '../../entities/business-service-request.entity';
import { BusinessServiceEntity } from '../../entities/business-service.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { BusinessImageEntity } from '../../entities/business-image.entity';
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
import { BusinessEntity } from '../../entities/business.entity';
@@ -20,6 +21,7 @@ export async function deleteBusinessCascade(
await manager.delete(BusinessServiceRequestEntity, { businessId });
await manager.delete(BusinessAvailabilitySlotEntity, { businessId });
await manager.delete(BusinessServiceEntity, { businessId });
await manager.delete(BusinessServiceClassEntity, { businessId });
await manager.delete(BusinessAttributeValueEntity, { businessId });
await manager.delete(BusinessImageEntity, { businessId });
@@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateBusinessServiceClasses1789046000000
implements MigrationInterface
{
name = 'CreateBusinessServiceClasses1789046000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "business_service_classes" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"businessId" uuid NOT NULL,
"title" varchar(120) NOT NULL,
"description" varchar(500),
"sortOrder" integer NOT NULL DEFAULT 0,
"isActive" boolean NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_business_service_classes" PRIMARY KEY ("id"),
CONSTRAINT "UQ_business_service_class_title" UNIQUE ("businessId", "title"),
CONSTRAINT "FK_service_class_business" FOREIGN KEY ("businessId")
REFERENCES "businesses"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "business_service_class_prices" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"businessServiceId" uuid NOT NULL,
"classId" uuid NOT NULL,
"price" numeric(12,2) NOT NULL,
"durationMinutes" integer,
"isActive" boolean NOT NULL DEFAULT true,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_business_service_class_prices" PRIMARY KEY ("id"),
CONSTRAINT "UQ_business_service_class_price" UNIQUE ("businessServiceId", "classId"),
CONSTRAINT "FK_class_price_service" FOREIGN KEY ("businessServiceId")
REFERENCES "business_services"("id") ON DELETE CASCADE,
CONSTRAINT "FK_class_price_class" FOREIGN KEY ("classId")
REFERENCES "business_service_classes"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
ALTER TABLE "bookings"
ADD COLUMN IF NOT EXISTS "serviceClassId" uuid
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_booking_service_class'
) THEN
ALTER TABLE "bookings"
ADD CONSTRAINT "FK_booking_service_class"
FOREIGN KEY ("serviceClassId")
REFERENCES "business_service_classes"("id")
ON DELETE RESTRICT;
END IF;
END $$;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "bookings" DROP CONSTRAINT IF EXISTS "FK_booking_service_class"
`);
await queryRunner.query(`
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "serviceClassId"
`);
await queryRunner.query(`DROP TABLE IF EXISTS "business_service_class_prices"`);
await queryRunner.query(`DROP TABLE IF EXISTS "business_service_classes"`);
}
}
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBusinessServiceClassesEnabled1789046100000
implements MigrationInterface
{
name = 'AddBusinessServiceClassesEnabled1789046100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "businesses"
ADD COLUMN IF NOT EXISTS "serviceClassesEnabled" boolean NOT NULL DEFAULT false
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "businesses" DROP COLUMN IF EXISTS "serviceClassesEnabled"
`);
}
}
@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingGroupId1789046200000 implements MigrationInterface {
name = 'AddBookingGroupId1789046200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "bookings"
ADD COLUMN IF NOT EXISTS "bookingGroupId" uuid
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_booking_group"
ON "bookings" ("bookingGroupId")
WHERE "bookingGroupId" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_booking_group"`);
await queryRunner.query(`
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "bookingGroupId"
`);
}
}
+11
View File
@@ -9,6 +9,7 @@ import {
} from 'typeorm';
import { BusinessEntity } from './business.entity';
import { BusinessServiceEntity } from './business-service.entity';
import { BusinessServiceClassEntity } from './business-service-class.entity';
import { UserEntity } from './user.entity';
export enum BookingStatus {
@@ -46,6 +47,16 @@ export class BookingEntity {
@Column('uuid')
businessServiceId!: string;
@ManyToOne(() => BusinessServiceClassEntity, { nullable: true, onDelete: 'RESTRICT' })
@JoinColumn({ name: 'serviceClassId' })
serviceClass!: BusinessServiceClassEntity | null;
@Column({ type: 'uuid', nullable: true })
serviceClassId!: string | null;
@Column({ type: 'uuid', nullable: true })
bookingGroupId!: string | null;
@Column({ type: 'timestamptz' })
scheduledAt!: Date;
@@ -0,0 +1,52 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { BusinessServiceClassEntity } from './business-service-class.entity';
import { BusinessServiceEntity } from './business-service.entity';
@Entity('business_service_class_prices')
@Unique('UQ_business_service_class_price', ['businessServiceId', 'classId'])
export class BusinessServiceClassPriceEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => BusinessServiceEntity, (service) => service.classPrices, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'businessServiceId' })
businessService!: BusinessServiceEntity;
@Column('uuid')
businessServiceId!: string;
@ManyToOne(() => BusinessServiceClassEntity, (serviceClass) => serviceClass.classPrices, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'classId' })
serviceClass!: BusinessServiceClassEntity;
@Column('uuid')
classId!: string;
@Column({ type: 'numeric', precision: 12, scale: 2 })
price!: string;
@Column({ type: 'int', nullable: true })
durationMinutes!: number | null;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,50 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { BusinessEntity } from './business.entity';
import { BusinessServiceClassPriceEntity } from './business-service-class-price.entity';
@Entity('business_service_classes')
@Unique('UQ_business_service_class_title', ['businessId', 'title'])
export class BusinessServiceClassEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => BusinessEntity, (business) => business.serviceClasses, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'businessId' })
business!: BusinessEntity;
@Column('uuid')
businessId!: string;
@Column({ length: 120 })
title!: string;
@Column({ type: 'varchar', nullable: true, length: 500 })
description!: string | null;
@Column({ type: 'int', default: 0 })
sortOrder!: number;
@Column({ default: true })
isActive!: boolean;
@OneToMany(() => BusinessServiceClassPriceEntity, (price) => price.serviceClass)
classPrices!: BusinessServiceClassPriceEntity[];
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
+4
View File
@@ -10,6 +10,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { BookingEntity } from './booking.entity';
import { BusinessServiceClassPriceEntity } from './business-service-class-price.entity';
import { BusinessEntity } from './business.entity';
import { ServiceTemplateEntity } from './service-template.entity';
@@ -49,6 +50,9 @@ export class BusinessServiceEntity {
@OneToMany(() => BookingEntity, (booking) => booking.businessService)
bookings!: BookingEntity[];
@OneToMany(() => BusinessServiceClassPriceEntity, (classPrice) => classPrice.businessService)
classPrices!: BusinessServiceClassPriceEntity[];
@CreateDateColumn()
createdAt!: Date;
+7
View File
@@ -14,6 +14,7 @@ import { BusinessCategoryEntity } from './business-category.entity';
import { BusinessImageEntity } from './business-image.entity';
import { BusinessAvailabilitySlotEntity } from './business-availability-slot.entity';
import { BusinessServiceEntity } from './business-service.entity';
import { BusinessServiceClassEntity } from './business-service-class.entity';
import { UserEntity } from './user.entity';
@Entity('businesses')
@@ -45,6 +46,9 @@ export class BusinessEntity {
@Column({ default: true })
isActive!: boolean;
@Column({ default: false })
serviceClassesEnabled!: boolean;
@ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, {
onDelete: 'RESTRICT',
})
@@ -72,6 +76,9 @@ export class BusinessEntity {
@OneToMany(() => BusinessServiceEntity, (service) => service.business)
services!: BusinessServiceEntity[];
@OneToMany(() => BusinessServiceClassEntity, (serviceClass) => serviceClass.business)
serviceClasses!: BusinessServiceClassEntity[];
@OneToMany(() => BusinessAttributeValueEntity, (attr) => attr.business)
attributes!: BusinessAttributeValueEntity[];
+23 -2
View File
@@ -19,6 +19,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { BookingsService } from './bookings.service';
import { CreateBookingDto } from './dto/create-booking.dto';
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
@Controller('bookings')
@@ -28,10 +29,20 @@ export class BookingsController {
@Get('available-slots')
getAvailableSlots(
@Query('businessId') businessId: string,
@Query('businessServiceId') businessServiceId: string,
@Query('date') date: string,
@Query('businessServiceId') businessServiceId?: string,
@Query('businessServiceIds') businessServiceIds?: string,
@Query('serviceClassId') serviceClassId?: string,
) {
return this.service.getAvailableSlots(businessId, businessServiceId, date);
const ids = businessServiceIds
? businessServiceIds.split(',').map((id) => id.trim()).filter(Boolean)
: undefined;
return this.service.getAvailableSlots(businessId, date, {
businessServiceId,
businessServiceIds: ids,
serviceClassId,
});
}
@Get()
@@ -71,6 +82,16 @@ export class BookingsController {
return this.service.create(payload, user);
}
@Post('batch')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
createBatch(
@Body() payload: CreateBookingBatchDto,
@CurrentUser() user: AuthUser,
): Promise<BookingEntity[]> {
return this.service.createBatch(payload, user);
}
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
+4
View File
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingEntity } from '../../entities/booking.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';
import { BusinessServiceEntity } from '../../entities/business-service.entity';
import { BusinessEntity } from '../../entities/business.entity';
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
@@ -18,6 +20,8 @@ import { BookingsService } from './bookings.service';
BusinessEntity,
BusinessServiceEntity,
BusinessAvailabilitySlotEntity,
BusinessServiceClassEntity,
BusinessServiceClassPriceEntity,
ServiceTemplateEntity,
]),
AuthModule,
+342 -45
View File
@@ -6,7 +6,8 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { randomUUID } from 'crypto';
import { In, Repository } from 'typeorm';
import {
generateAvailableSlotStarts,
parseGregorianYmd,
@@ -17,11 +18,14 @@ import {
} from '../../common/utils/booking-schedule.util';
import { BookingEntity, BookingStatus } from '../../entities/booking.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';
import { BusinessServiceEntity } from '../../entities/business-service.entity';
import { BusinessEntity } from '../../entities/business.entity';
import { UserEntity, UserRole } from '../../entities/user.entity';
import { AuthUser } from '../auth/auth.types';
import { CreateBookingDto } from './dto/create-booking.dto';
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
type FindAllFilters = {
@@ -47,6 +51,10 @@ export class BookingsService {
private readonly businessServiceRepo: Repository<BusinessServiceEntity>,
@InjectRepository(BusinessAvailabilitySlotEntity)
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
@InjectRepository(BusinessServiceClassEntity)
private readonly serviceClassRepo: Repository<BusinessServiceClassEntity>,
@InjectRepository(BusinessServiceClassPriceEntity)
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
) {
const raw = this.config.get<string | undefined>('BOOKING_MIN_LEAD_MINUTES');
const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
@@ -63,6 +71,7 @@ export class BookingsService {
.leftJoinAndSelect('b.business', 'business')
.leftJoinAndSelect('b.businessService', 'businessService')
.leftJoinAndSelect('businessService.serviceTemplate', 'template')
.leftJoinAndSelect('b.serviceClass', 'serviceClass')
.orderBy('b.scheduledAt', 'ASC');
if (actor?.role === UserRole.CUSTOMER) {
@@ -108,6 +117,7 @@ export class BookingsService {
customer: true,
business: true,
businessService: { serviceTemplate: true },
serviceClass: true,
},
});
if (!row) {
@@ -151,21 +161,21 @@ export class BookingsService {
}
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
const durationMinutes = resolveServiceDurationMinutes(
businessService.durationMinutes,
businessService.serviceTemplate?.defaultDurationMinutes,
const pricing = await this.resolveBookingPricing(
businessService,
dto.serviceClassId ?? null,
);
await this.assertSchedulableSlot({
businessId: dto.businessId,
businessServiceId: dto.businessServiceId,
scheduledAt,
durationMinutes,
durationMinutes: pricing.durationMinutes,
});
const entity = this.repo.create({
customerId: dto.customerId,
businessId: dto.businessId,
businessServiceId: dto.businessServiceId,
serviceClassId: pricing.serviceClassId,
scheduledAt,
note: dto.note ?? null,
status: BookingStatus.PENDING,
@@ -174,6 +184,93 @@ export class BookingsService {
return this.repo.save(entity);
}
async createBatch(
dto: CreateBookingBatchDto,
actor?: AuthUser,
): Promise<BookingEntity[]> {
if (actor && dto.customerId !== actor.id) {
throw new ForbiddenException('You can only book for yourself');
}
const customer = await this.userRepo.findOne({
where: { id: dto.customerId },
});
if (!customer) {
throw new NotFoundException('User not found');
}
const business = await this.businessRepo.findOne({
where: { id: dto.businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
const serviceIds = [
...new Set(dto.items.map((item) => item.businessServiceId)),
];
if (serviceIds.length !== dto.items.length) {
throw new BadRequestException('Duplicate services in booking batch');
}
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
const serviceClassId = dto.serviceClassId ?? null;
const resolvedItems: Array<{
businessService: BusinessServiceEntity;
serviceClassId: string | null;
durationMinutes: number;
}> = [];
let totalDurationMinutes = 0;
for (const serviceId of serviceIds) {
const businessService = await this.businessServiceRepo.findOne({
where: { id: serviceId, businessId: dto.businessId, isActive: true },
relations: { serviceTemplate: true },
});
if (!businessService) {
throw new NotFoundException(
`Business service ${serviceId} not found or inactive`,
);
}
const pricing = await this.resolveBookingPricing(
businessService,
serviceClassId,
);
resolvedItems.push({
businessService,
serviceClassId: pricing.serviceClassId,
durationMinutes: pricing.durationMinutes,
});
totalDurationMinutes += pricing.durationMinutes;
}
await this.assertSchedulableSlot({
businessId: dto.businessId,
scheduledAt,
durationMinutes: totalDurationMinutes,
});
const bookingGroupId = serviceIds.length > 1 ? randomUUID() : null;
const note = dto.note ?? null;
const entities = resolvedItems.map(({ businessService, serviceClassId: classId }) =>
this.repo.create({
customerId: dto.customerId,
businessId: dto.businessId,
businessServiceId: businessService.id,
serviceClassId: classId,
bookingGroupId,
scheduledAt,
note,
status: BookingStatus.PENDING,
}),
);
return this.repo.save(entities);
}
async update(
id: string,
dto: UpdateBookingDto,
@@ -185,18 +282,21 @@ export class BookingsService {
if (dto.scheduledAt !== undefined) {
const scheduledAt = this.parseScheduledAt(dto.scheduledAt);
const durationMinutes = resolveServiceDurationMinutes(
row.businessService?.durationMinutes,
row.businessService?.serviceTemplate?.defaultDurationMinutes,
);
const durationMinutes = await this.resolveGroupDuration(row);
await this.assertSchedulableSlot({
businessId: row.businessId,
businessServiceId: row.businessServiceId,
scheduledAt,
durationMinutes,
excludeBookingId: row.id,
excludeBookingGroupId: row.bookingGroupId ?? undefined,
});
row.scheduledAt = scheduledAt;
if (row.bookingGroupId) {
await this.repo.update(
{ bookingGroupId: row.bookingGroupId },
{ scheduledAt },
);
}
}
if (dto.status !== undefined) {
@@ -240,9 +340,13 @@ export class BookingsService {
async getAvailableSlots(
businessId: string,
businessServiceId: string,
dateYmd: string,
): Promise<{ date: string; slots: string[] }> {
options: {
businessServiceId?: string;
businessServiceIds?: string[];
serviceClassId?: string;
},
): Promise<{ date: string; slots: string[]; totalDurationMinutes: number }> {
parseGregorianYmd(dateYmd);
const business = await this.businessRepo.findOne({
@@ -252,32 +356,49 @@ export class BookingsService {
throw new NotFoundException('Business not found or inactive');
}
const businessService = await this.businessServiceRepo.findOne({
where: { id: businessServiceId, businessId, isActive: true },
relations: { serviceTemplate: true },
});
if (!businessService) {
throw new NotFoundException('Business service not found or inactive');
const serviceIds =
options.businessServiceIds?.length
? options.businessServiceIds
: options.businessServiceId
? [options.businessServiceId]
: [];
if (!serviceIds.length) {
throw new BadRequestException('At least one business service is required');
}
const durationMinutes = resolveServiceDurationMinutes(
businessService.durationMinutes,
businessService.serviceTemplate?.defaultDurationMinutes,
);
const serviceClassId = options.serviceClassId ?? null;
let totalDurationMinutes = 0;
for (const serviceId of serviceIds) {
const businessService = await this.businessServiceRepo.findOne({
where: { id: serviceId, businessId, isActive: true },
relations: { serviceTemplate: true },
});
if (!businessService) {
throw new NotFoundException('Business service not found or inactive');
}
const pricing = await this.resolveBookingPricing(
businessService,
serviceClassId,
);
totalDurationMinutes += pricing.durationMinutes;
}
const windows = await this.loadAvailabilityWindows(businessId);
const busy = await this.loadBusyIntervals(businessId, dateYmd);
const slots = generateAvailableSlotStarts({
dateYmd,
durationMinutes,
durationMinutes: totalDurationMinutes,
leadMinutes: this.minLeadMinutes,
slotStepMinutes: 15,
windows,
busy,
});
return { date: dateYmd, slots };
return { date: dateYmd, slots, totalDurationMinutes };
}
private async loadAvailabilityWindows(
@@ -294,11 +415,20 @@ export class BookingsService {
private async loadBusyIntervals(
businessId: string,
dateYmd: string,
excludeBookingId?: string,
options?: { excludeBookingId?: string; excludeBookingGroupId?: string },
): Promise<BusyInterval[]> {
const dayStart = new Date(`${dateYmd}T00:00:00+03:30`);
const dayEnd = new Date(`${dateYmd}T23:59:59+03:30`);
let excludeGroupId = options?.excludeBookingGroupId ?? null;
if (options?.excludeBookingId && !excludeGroupId) {
const excluded = await this.repo.findOne({
where: { id: options.excludeBookingId },
select: ['id', 'bookingGroupId'],
});
excludeGroupId = excluded?.bookingGroupId ?? null;
}
const qb = this.repo
.createQueryBuilder('b')
.leftJoinAndSelect('b.businessService', 'bs')
@@ -310,34 +440,202 @@ export class BookingsService {
.andWhere('b.scheduledAt >= :dayStart', { dayStart })
.andWhere('b.scheduledAt <= :dayEnd', { dayEnd });
if (excludeBookingId) {
qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
if (options?.excludeBookingId && !excludeGroupId) {
qb.andWhere('b.id != :excludeBookingId', {
excludeBookingId: options.excludeBookingId,
});
}
if (excludeGroupId) {
qb.andWhere('b.bookingGroupId IS NULL OR b.bookingGroupId != :excludeGroupId', {
excludeGroupId,
});
}
const bookings = await qb.getMany();
const durations = await Promise.all(
bookings.map((b) => this.resolveBookingDuration(b)),
);
return bookings.map((b) => {
const duration = resolveServiceDurationMinutes(
b.businessService?.durationMinutes,
b.businessService?.serviceTemplate?.defaultDurationMinutes,
);
const startMs = b.scheduledAt.getTime();
return {
startMs,
endMs: startMs + duration * 60 * 1000,
};
const groupTotals = new Map<
string,
{ startMs: number; totalDuration: number }
>();
const singles: BusyInterval[] = [];
for (const [index, booking] of bookings.entries()) {
const duration = durations[index] ?? 30;
const startMs = booking.scheduledAt.getTime();
if (booking.bookingGroupId) {
const key = `${booking.bookingGroupId}:${startMs}`;
const existing = groupTotals.get(key);
if (existing) {
existing.totalDuration += duration;
} else {
groupTotals.set(key, { startMs, totalDuration: duration });
}
} else {
singles.push({
startMs,
endMs: startMs + duration * 60 * 1000,
});
}
}
return [
...singles,
...Array.from(groupTotals.values()).map((group) => ({
startMs: group.startMs,
endMs: group.startMs + group.totalDuration * 60 * 1000,
})),
];
}
private async resolveBookingPricing(
businessService: BusinessServiceEntity,
serviceClassId: string | null,
): Promise<{ serviceClassId: string | null; durationMinutes: number }> {
const business = await this.businessRepo.findOne({
where: { id: businessService.businessId },
});
const activeClassCount = await this.serviceClassRepo.count({
where: { businessId: businessService.businessId, isActive: true },
});
const usesClasses =
business?.serviceClassesEnabled === true && activeClassCount > 0;
if (!usesClasses) {
if (serviceClassId) {
throw new BadRequestException(
'This business does not use service classes',
);
}
return {
serviceClassId: null,
durationMinutes: resolveServiceDurationMinutes(
businessService.durationMinutes,
businessService.serviceTemplate?.defaultDurationMinutes,
),
};
}
if (!serviceClassId) {
throw new BadRequestException('serviceClassId is required for this business');
}
const serviceClass = await this.serviceClassRepo.findOne({
where: {
id: serviceClassId,
businessId: businessService.businessId,
isActive: true,
},
});
if (!serviceClass) {
throw new BadRequestException('Service class not found or inactive');
}
const classPrice = await this.classPriceRepo.findOne({
where: {
businessServiceId: businessService.id,
classId: serviceClassId,
isActive: true,
},
});
if (!classPrice) {
throw new BadRequestException(
'This service is not available for the selected class',
);
}
return {
serviceClassId,
durationMinutes: resolveServiceDurationMinutes(
classPrice.durationMinutes ?? businessService.durationMinutes,
businessService.serviceTemplate?.defaultDurationMinutes,
),
};
}
private async resolveGroupDuration(booking: BookingEntity): Promise<number> {
if (!booking.bookingGroupId) {
return this.resolveBookingDuration(booking);
}
const siblings = await this.repo.find({
where: {
bookingGroupId: booking.bookingGroupId,
status: In([BookingStatus.PENDING, BookingStatus.CONFIRMED]),
},
relations: { businessService: { serviceTemplate: true } },
});
const rows = siblings.length
? siblings
: [
{
...booking,
businessService:
booking.businessService ??
(await this.businessServiceRepo.findOne({
where: { id: booking.businessServiceId },
relations: { serviceTemplate: true },
})),
} as BookingEntity,
];
let total = 0;
for (const row of rows) {
total += await this.resolveBookingDuration(row);
}
return total;
}
private async resolveBookingDuration(booking: BookingEntity): Promise<number> {
if (!booking.businessService) {
const businessService = await this.businessServiceRepo.findOne({
where: { id: booking.businessServiceId },
relations: { serviceTemplate: true },
});
if (!businessService) {
return 30;
}
booking.businessService = businessService;
}
if (booking.serviceClassId) {
const classPrice = await this.classPriceRepo.findOne({
where: {
businessServiceId: booking.businessServiceId,
classId: booking.serviceClassId,
},
});
return resolveServiceDurationMinutes(
classPrice?.durationMinutes ?? booking.businessService.durationMinutes,
booking.businessService.serviceTemplate?.defaultDurationMinutes,
);
}
return resolveServiceDurationMinutes(
booking.businessService.durationMinutes,
booking.businessService.serviceTemplate?.defaultDurationMinutes,
);
}
private async assertSchedulableSlot(params: {
businessId: string;
businessServiceId: string;
scheduledAt: Date;
durationMinutes: number;
excludeBookingId?: string;
excludeBookingGroupId?: string;
}): Promise<void> {
const { businessId, scheduledAt, durationMinutes, excludeBookingId } =
params;
const {
businessId,
scheduledAt,
durationMinutes,
excludeBookingId,
excludeBookingGroupId,
} = params;
this.assertLeadTime(scheduledAt);
@@ -355,11 +653,10 @@ export class BookingsService {
leadMinutes: this.minLeadMinutes,
slotStepMinutes: 15,
windows,
busy: await this.loadBusyIntervals(
businessId,
dateYmd,
busy: await this.loadBusyIntervals(businessId, dateYmd, {
excludeBookingId,
),
excludeBookingGroupId,
}),
});
const scheduledMs = scheduledAt.getTime();
@@ -0,0 +1,50 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsISO8601,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from 'class-validator';
export class CreateBookingBatchItemDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
businessServiceId!: string;
}
export class CreateBookingBatchDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
customerId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
businessId!: string;
@ApiProperty({ type: [CreateBookingBatchItemDto], minItems: 1 })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateBookingBatchItemDto)
items!: CreateBookingBatchItemDto[];
@ApiProperty({ example: '2026-05-13T10:00:00.000Z' })
@IsISO8601()
scheduledAt!: string;
@ApiPropertyOptional({ format: 'uuid', nullable: true })
@IsOptional()
@IsUUID()
serviceClassId?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
note?: string | null;
}
@@ -14,6 +14,11 @@ export class CreateBookingDto {
@IsUUID()
businessServiceId!: string;
@ApiPropertyOptional({ format: 'uuid', nullable: true })
@IsOptional()
@IsUUID()
serviceClassId?: string | null;
/** ISO 8601 datetime string */
@ApiProperty({ example: '2026-05-13T10:00:00.000Z' })
@IsISO8601()
@@ -0,0 +1,74 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { UserRole } from '../../entities/user.entity';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { BusinessServiceClassesService } from './business-service-classes.service';
import { CreateBusinessServiceClassDto } from './dto/create-business-service-class.dto';
import { UpdateBusinessServiceClassDto } from './dto/update-business-service-class.dto';
@Controller('business-service-classes')
export class BusinessServiceClassesController {
constructor(private readonly service: BusinessServiceClassesService) {}
@Get()
findAll(
@Query('businessId') businessId?: string,
@Query('isActive') isActive?: string,
): Promise<BusinessServiceClassEntity[]> {
const parsedIsActive =
typeof isActive === 'string' ? isActive === 'true' : undefined;
return this.service.findAll({ businessId, isActive: parsedIsActive });
}
@Get(':id')
findOne(@Param('id') id: string): Promise<BusinessServiceClassEntity> {
return this.service.findOne(id);
}
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
create(
@Body() payload: CreateBusinessServiceClassDto,
@CurrentUser() user: AuthUser,
): Promise<BusinessServiceClassEntity> {
return this.service.create(payload, user);
}
@Patch(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
update(
@Param('id') id: string,
@Body() payload: UpdateBusinessServiceClassDto,
@CurrentUser() user: AuthUser,
): Promise<BusinessServiceClassEntity> {
return this.service.update(id, payload, user);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
async remove(
@Param('id') id: string,
@CurrentUser() user: AuthUser,
): Promise<{ success: true }> {
await this.service.remove(id, user);
return { success: true };
}
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BusinessEntity } from '../../entities/business.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { AuthModule } from '../auth/auth.module';
import { BusinessServiceClassesController } from './business-service-classes.controller';
import { BusinessServiceClassesService } from './business-service-classes.service';
@Module({
imports: [
TypeOrmModule.forFeature([BusinessServiceClassEntity, BusinessEntity]),
AuthModule,
],
controllers: [BusinessServiceClassesController],
providers: [BusinessServiceClassesService],
exports: [BusinessServiceClassesService],
})
export class BusinessServiceClassesModule {}
@@ -0,0 +1,168 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BusinessEntity } from '../../entities/business.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { UserRole } from '../../entities/user.entity';
import { AuthUser } from '../auth/auth.types';
import { CreateBusinessServiceClassDto } from './dto/create-business-service-class.dto';
import { UpdateBusinessServiceClassDto } from './dto/update-business-service-class.dto';
type FindAllFilters = {
businessId?: string;
isActive?: boolean;
};
@Injectable()
export class BusinessServiceClassesService {
constructor(
@InjectRepository(BusinessServiceClassEntity)
private readonly repo: Repository<BusinessServiceClassEntity>,
@InjectRepository(BusinessEntity)
private readonly businessRepo: Repository<BusinessEntity>,
) {}
async findAll(filters: FindAllFilters): Promise<BusinessServiceClassEntity[]> {
const query = this.repo
.createQueryBuilder('c')
.orderBy('c.sortOrder', 'ASC')
.addOrderBy('c.createdAt', 'ASC');
if (filters.businessId) {
query.andWhere('c.businessId = :businessId', {
businessId: filters.businessId,
});
}
if (typeof filters.isActive === 'boolean') {
query.andWhere('c.isActive = :isActive', { isActive: filters.isActive });
}
return query.getMany();
}
async findOne(id: string): Promise<BusinessServiceClassEntity> {
const row = await this.repo.findOne({ where: { id } });
if (!row) {
throw new NotFoundException('Service class not found');
}
return row;
}
async create(
dto: CreateBusinessServiceClassDto,
actor?: AuthUser,
): Promise<BusinessServiceClassEntity> {
const business = await this.businessRepo.findOne({
where: { id: dto.businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
this.assertCanManageBusiness(business, actor);
const dup = await this.repo.findOne({
where: { businessId: dto.businessId, title: dto.title.trim() },
});
if (dup) {
throw new ConflictException('A class with this title already exists');
}
let sortOrder = dto.sortOrder;
if (sortOrder === undefined) {
const max = await this.repo
.createQueryBuilder('c')
.select('MAX(c.sortOrder)', 'max')
.where('c.businessId = :businessId', { businessId: dto.businessId })
.getRawOne<{ max: string | null }>();
sortOrder = (max?.max ? Number.parseInt(max.max, 10) : -1) + 1;
}
const entity = this.repo.create({
businessId: dto.businessId,
title: dto.title.trim(),
description: dto.description?.trim() || null,
sortOrder,
isActive: dto.isActive ?? true,
});
return this.repo.save(entity);
}
async update(
id: string,
dto: UpdateBusinessServiceClassDto,
actor?: AuthUser,
): Promise<BusinessServiceClassEntity> {
const row = await this.findOne(id);
const business = await this.businessRepo.findOne({
where: { id: row.businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
this.assertCanManageBusiness(business, actor);
if (dto.title !== undefined) {
const title = dto.title.trim();
if (!title) {
throw new BadRequestException('title cannot be empty');
}
const dup = await this.repo.findOne({
where: { businessId: row.businessId, title },
});
if (dup && dup.id !== row.id) {
throw new ConflictException('A class with this title already exists');
}
row.title = title;
}
if (dto.description !== undefined) {
row.description = dto.description?.trim() || null;
}
if (dto.sortOrder !== undefined) {
row.sortOrder = dto.sortOrder;
}
if (dto.isActive !== undefined) {
row.isActive = dto.isActive;
}
return this.repo.save(row);
}
async remove(id: string, actor?: AuthUser): Promise<void> {
const row = await this.findOne(id);
const business = await this.businessRepo.findOne({
where: { id: row.businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
this.assertCanManageBusiness(business, actor);
const result = await this.repo.delete(id);
if (!result.affected) {
throw new NotFoundException('Service class not found');
}
}
private assertCanManageBusiness(
business: BusinessEntity,
actor?: AuthUser,
): void {
if (!actor || actor.role === UserRole.ADMIN) {
return;
}
if (
actor.role !== UserRole.BUSINESS_OWNER ||
business.ownerId !== actor.id
) {
throw new ForbiddenException('You can only manage your own business');
}
}
}
@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateBusinessServiceClassDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
businessId!: string;
@ApiProperty({ example: 'کلاس ۱ — مینی و کوچک' })
@IsString()
@MaxLength(120)
title!: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
description?: string | null;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -0,0 +1,27 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class UpdateBusinessServiceClassDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(120)
title?: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
description?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
@@ -6,6 +6,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
@@ -19,6 +20,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
import { BusinessServicesService } from './business-services.service';
import { CreateBusinessServiceDto } from './dto/create-business-service.dto';
import { UpdateBusinessServiceDto } from './dto/update-business-service.dto';
import { SetBusinessServiceClassPricesDto } from './dto/set-business-service-class-prices.dto';
@Controller('business-services')
export class BusinessServicesController {
@@ -66,6 +68,17 @@ export class BusinessServicesController {
return this.service.update(id, payload, user);
}
@Put(':id/class-prices')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
setClassPrices(
@Param('id') id: string,
@Body() payload: SetBusinessServiceClassPricesDto,
@CurrentUser() user: AuthUser,
): Promise<BusinessServiceEntity> {
return this.service.setClassPrices(id, payload, user);
}
@Delete(':id')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BusinessEntity } from '../../entities/business.entity';
import { BusinessServiceEntity } from '../../entities/business-service.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
import { AuthModule } from '../auth/auth.module';
import { BusinessServicesController } from './business-services.controller';
@@ -13,6 +15,8 @@ import { BusinessServicesService } from './business-services.service';
BusinessServiceEntity,
BusinessEntity,
ServiceTemplateEntity,
BusinessServiceClassEntity,
BusinessServiceClassPriceEntity,
]),
AuthModule,
],
@@ -6,14 +6,17 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { BusinessEntity } from '../../entities/business.entity';
import { BusinessServiceEntity } from '../../entities/business-service.entity';
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
import { UserRole } from '../../entities/user.entity';
import { AuthUser } from '../auth/auth.types';
import { CreateBusinessServiceDto } from './dto/create-business-service.dto';
import { UpdateBusinessServiceDto } from './dto/update-business-service.dto';
import { SetBusinessServiceClassPricesDto } from './dto/set-business-service-class-prices.dto';
type FindAllFilters = {
businessId?: string;
@@ -37,6 +40,10 @@ export class BusinessServicesService {
private readonly businessRepo: Repository<BusinessEntity>,
@InjectRepository(ServiceTemplateEntity)
private readonly templateRepo: Repository<ServiceTemplateEntity>,
@InjectRepository(BusinessServiceClassEntity)
private readonly classRepo: Repository<BusinessServiceClassEntity>,
@InjectRepository(BusinessServiceClassPriceEntity)
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
) {}
async findAll(filters: FindAllFilters): Promise<BusinessServiceEntity[]> {
@@ -44,6 +51,8 @@ export class BusinessServicesService {
.createQueryBuilder('bs')
.leftJoinAndSelect('bs.business', 'business')
.leftJoinAndSelect('bs.serviceTemplate', 'template')
.leftJoinAndSelect('bs.classPrices', 'classPrices')
.leftJoinAndSelect('classPrices.serviceClass', 'serviceClass')
.orderBy('bs.createdAt', 'DESC');
if (filters.businessId) {
@@ -68,7 +77,11 @@ export class BusinessServicesService {
async findOne(id: string): Promise<BusinessServiceEntity> {
const row = await this.repo.findOne({
where: { id },
relations: { business: true, serviceTemplate: true },
relations: {
business: true,
serviceTemplate: true,
classPrices: { serviceClass: true },
},
});
if (!row) {
throw new NotFoundException('Business service not found');
@@ -193,6 +206,67 @@ export class BusinessServicesService {
return this.repo.save(row);
}
async setClassPrices(
id: string,
dto: SetBusinessServiceClassPricesDto,
actor?: AuthUser,
): Promise<BusinessServiceEntity> {
const row = await this.findOne(id);
const business = await this.businessRepo.findOne({
where: { id: row.businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
this.assertCanManageBusiness(business, actor);
const classIds = [...new Set(dto.prices.map((p) => p.classId))];
const classes = await this.classRepo.find({
where: { businessId: row.businessId, id: In(classIds) },
});
if (classes.length !== classIds.length) {
throw new BadRequestException(
'One or more service classes do not belong to this business',
);
}
for (const item of dto.prices) {
const existing = await this.classPriceRepo.findOne({
where: { businessServiceId: id, classId: item.classId },
});
const payload = {
price: normalizePrice(item.price),
durationMinutes: item.durationMinutes ?? null,
isActive: item.isActive ?? true,
};
if (existing) {
Object.assign(existing, payload);
await this.classPriceRepo.save(existing);
} else {
await this.classPriceRepo.save(
this.classPriceRepo.create({
businessServiceId: id,
classId: item.classId,
...payload,
}),
);
}
}
const activePrices = await this.classPriceRepo.find({
where: { businessServiceId: id, isActive: true },
});
if (activePrices.length) {
const minPrice = activePrices.reduce((min, p) =>
Number.parseFloat(p.price) < Number.parseFloat(min.price) ? p : min,
);
row.price = minPrice.price;
await this.repo.save(row);
}
return this.findOne(id);
}
async remove(id: string, actor?: AuthUser): Promise<void> {
const row = await this.findOne(id);
const business = await this.businessRepo.findOne({
@@ -0,0 +1,44 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsOptional,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
export class BusinessServiceClassPriceItemDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
classId!: string;
@ApiProperty({ example: 100000 })
@IsNumber()
@Min(0)
price!: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsInt()
@Min(1)
durationMinutes?: number | null;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class SetBusinessServiceClassPricesDto {
@ApiProperty({ type: [BusinessServiceClassPriceItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BusinessServiceClassPriceItemDto)
prices!: BusinessServiceClassPriceItemDto[];
}
@@ -68,4 +68,12 @@ export class UpdateBusinessDto {
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
example: false,
description: 'When true, services are priced per owner-defined class (e.g. car size)',
})
@IsOptional()
@IsBoolean()
serviceClassesEnabled?: boolean;
}