diff --git a/src/app.module.ts b/src/app.module.ts index 3a90e54..8f952dc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,42 +1,3 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { dataSourceOptions } from './config/typeorm.config'; -import { BusinessCategoriesModule } from './modules/business-categories/business-categories.module'; -import { BusinessesModule } from './modules/businesses/businesses.module'; -import { ServiceTemplatesModule } from './modules/service-templates/service-templates.module'; -import { BusinessServicesModule } from './modules/business-services/business-services.module'; -import { AttributeDefinitionsModule } from './modules/attribute-definitions/attribute-definitions.module'; -import { BusinessAttributeValuesModule } from './modules/business-attribute-values/business-attribute-values.module'; -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'; -import { PlatformSettingsModule } from './modules/platform-settings/platform-settings.module'; -@Module({ - imports: [ - ConfigModule.forRoot({ isGlobal: true }), - TypeOrmModule.forRoot(dataSourceOptions), - BusinessCategoriesModule, - BusinessesModule, - ServiceTemplatesModule, - BusinessServicesModule, - AttributeDefinitionsModule, - BusinessAttributeValuesModule, - BookingsModule, - BusinessRegistrationRequestsModule, - BusinessServiceRequestsModule, - BusinessServiceClassesModule, - AuthModule, - UsersModule, - PlatformSettingsModule, - ], - controllers: [AppController], - providers: [AppService], -}) -export class AppModule {} +import { PlatformSettingsModule } from './modules/platform-settings/platform-settings.module'; + PlatformSettingsModule, \ No newline at end of file diff --git a/src/entities/business-registration-request.entity.ts b/src/entities/business-registration-request.entity.ts index 7c20118..bc1cdca 100644 --- a/src/entities/business-registration-request.entity.ts +++ b/src/entities/business-registration-request.entity.ts @@ -1,95 +1,4 @@ -import { - Column, - CreateDateColumn, - Entity, - JoinColumn, - ManyToOne, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; -import { BusinessCategoryEntity } from './business-category.entity'; -import { BusinessEntity } from './business.entity'; -import { UserEntity } from './user.entity'; -export enum BusinessRegistrationStatus { - PENDING = 'pending', - APPROVED = 'approved', - REJECTED = 'rejected', -} - -@Entity('business_registration_requests') -export class BusinessRegistrationRequestEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'applicantId' }) - applicant!: UserEntity; - - @Column('uuid') - applicantId!: string; - - @Column({ - type: 'varchar', - length: 20, - default: BusinessRegistrationStatus.PENDING, - }) - status!: BusinessRegistrationStatus; - - @Column({ length: 150 }) - title!: string; - - @Column({ length: 120 }) - slug!: string; - - @ManyToOne(() => BusinessCategoryEntity, { onDelete: 'RESTRICT' }) - @JoinColumn({ name: 'categoryId' }) - category!: BusinessCategoryEntity; - - @Column('uuid') - categoryId!: string; @Column({ type: 'varchar', nullable: true, length: 150 }) - subtitle!: string | null; - - @Column({ type: 'varchar', nullable: true, length: 500 }) - description!: string | null; - - @Column({ type: 'varchar', nullable: true, length: 20 }) - contactPhone!: string | null; - - @Column({ type: 'varchar', nullable: true, length: 500 }) - address!: string | null; - - @Column({ type: 'double precision', nullable: true }) - latitude!: number | null; - - @Column({ type: 'double precision', nullable: true }) - longitude!: number | null; - - @Column({ type: 'varchar', nullable: true, length: 500 }) - adminNote!: string | null; - - @ManyToOne(() => UserEntity, { nullable: true, onDelete: 'SET NULL' }) - @JoinColumn({ name: 'reviewedById' }) - reviewedBy!: UserEntity | null; - - @Column({ type: 'uuid', nullable: true }) - reviewedById!: string | null; - - @Column({ type: 'timestamptz', nullable: true }) - reviewedAt!: Date | null; - - @ManyToOne(() => BusinessEntity, { nullable: true, onDelete: 'SET NULL' }) - @JoinColumn({ name: 'businessId' }) - business!: BusinessEntity | null; - - @Column({ type: 'uuid', nullable: true }) - businessId!: string | null; - - @CreateDateColumn() - createdAt!: Date; - - @UpdateDateColumn() - updatedAt!: Date; -} + subtitle!: string | null; \ No newline at end of file diff --git a/src/entities/business.entity.ts b/src/entities/business.entity.ts index 0269ed3..bc1cdca 100644 --- a/src/entities/business.entity.ts +++ b/src/entities/business.entity.ts @@ -1,109 +1,4 @@ -import { - Column, - CreateDateColumn, - Entity, - JoinColumn, - ManyToOne, - OneToMany, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; -import { BookingEntity } from './booking.entity'; -import { BusinessAttributeValueEntity } from './business-attribute-value.entity'; -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') -export class BusinessEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ length: 150 }) - title!: string; - - @Column({ unique: true, length: 120 }) - slug!: string; @Column({ type: 'varchar', nullable: true, length: 150 }) - subtitle!: string | null; - - @Column({ type: 'varchar', nullable: true, length: 500 }) - description!: string | null; - - @Column({ type: 'varchar', nullable: true, length: 20 }) - contactPhone!: string | null; - - @Column({ type: 'varchar', nullable: true, length: 500 }) - address!: string | null; - - @Column({ type: 'double precision', nullable: true }) - latitude!: number | null; - - @Column({ type: 'double precision', nullable: true }) - longitude!: number | null; - - @Column({ default: true }) - isActive!: boolean; - - @Column({ default: false }) - serviceClassesEnabled!: boolean; - - /** Max simultaneous bookings per overlapping time window */ - @Column({ type: 'int', default: 1 }) - concurrentCapacity!: number; - - /** Minutes between bookable slot start times (e.g. 30 → 10:00, 10:30, 11:00) */ - @Column({ type: 'int', default: 30 }) - slotStepMinutes!: number; - - @ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, { - onDelete: 'RESTRICT', - }) - @JoinColumn({ name: 'categoryId' }) - category!: BusinessCategoryEntity; - - @Column('uuid') - categoryId!: string; - - @ManyToOne(() => UserEntity, (user) => user.ownedBusinesses, { - nullable: true, - onDelete: 'SET NULL', - }) - @JoinColumn({ name: 'ownerId' }) - owner!: UserEntity | null; - - @Column({ type: 'uuid', nullable: true }) - ownerId!: string | null; - - @OneToMany(() => BusinessImageEntity, (image) => image.business, { - cascade: true, - }) - images!: BusinessImageEntity[]; - - @OneToMany(() => BusinessServiceEntity, (service) => service.business) - services!: BusinessServiceEntity[]; - - @OneToMany(() => BusinessServiceClassEntity, (serviceClass) => serviceClass.business) - serviceClasses!: BusinessServiceClassEntity[]; - - @OneToMany(() => BusinessAttributeValueEntity, (attr) => attr.business) - attributes!: BusinessAttributeValueEntity[]; - - @OneToMany(() => BookingEntity, (booking) => booking.business) - bookings!: BookingEntity[]; - - @OneToMany(() => BusinessAvailabilitySlotEntity, (slot) => slot.business, { - cascade: true, - }) - availabilitySlots!: BusinessAvailabilitySlotEntity[]; - - @CreateDateColumn() - createdAt!: Date; - - @UpdateDateColumn() - updatedAt!: Date; -} + subtitle!: string | null; \ No newline at end of file diff --git a/src/modules/business-registration-requests/business-registration-requests.service.ts b/src/modules/business-registration-requests/business-registration-requests.service.ts index 0c79de0..3a553a6 100644 --- a/src/modules/business-registration-requests/business-registration-requests.service.ts +++ b/src/modules/business-registration-requests/business-registration-requests.service.ts @@ -1,269 +1,3 @@ -import { - BadRequestException, - ConflictException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util'; -import { - BusinessRegistrationRequestEntity, - BusinessRegistrationStatus, -} from '../../entities/business-registration-request.entity'; -import { BusinessCategoryEntity } from '../../entities/business-category.entity'; -import { BusinessEntity } from '../../entities/business.entity'; -import { UserEntity, UserRole } from '../../entities/user.entity'; -import { AuthUser } from '../auth/auth.types'; -import { SmsService } from '../auth/sms.service'; -import { CreateBusinessRegistrationDto } from './dto/create-business-registration.dto'; -import { RejectBusinessRegistrationDto } from './dto/reject-business-registration.dto'; -type ListFilters = { - status?: BusinessRegistrationStatus; -}; - -@Injectable() -export class BusinessRegistrationRequestsService { - constructor( - @InjectRepository(BusinessRegistrationRequestEntity) - private readonly repo: Repository, - @InjectRepository(BusinessEntity) - private readonly businessRepo: Repository, - @InjectRepository(BusinessCategoryEntity) - private readonly categoryRepo: Repository, - @InjectRepository(UserEntity) - private readonly userRepo: Repository, - private readonly sms: SmsService, - private readonly config: ConfigService, - ) {} - - async create( - applicantId: string, - dto: CreateBusinessRegistrationDto, - ): Promise { - const applicant = await this.userRepo.findOne({ where: { id: applicantId } }); - if (!applicant) { - throw new NotFoundException('User not found'); - } - if (applicant.role === UserRole.ADMIN) { - throw new ForbiddenException('Admin cannot submit business registration'); - } - if (applicant.role === UserRole.BUSINESS_OWNER) { - throw new BadRequestException('You already manage a business'); - } - - const owned = await this.businessRepo.findOne({ where: { ownerId: applicantId } }); - if (owned) { - throw new BadRequestException('You already have a registered business'); - } - - const pending = await this.repo.findOne({ - where: { - applicantId, - status: BusinessRegistrationStatus.PENDING, - }, - }); - if (pending) { - throw new ConflictException('You already have a pending registration request'); - } - - await this.ensureCategoryExists(dto.categoryId); - - const slugExists = async (candidate: string) => { - const inBusiness = await this.businessRepo.findOne({ where: { slug: candidate } }); - if (inBusiness) return true; - const inRequest = await this.repo.findOne({ where: { slug: candidate } }); - return Boolean(inRequest); - }; - - const explicitSlug = normalizeSlugInput(dto.slug); - if (explicitSlug) { - if (await slugExists(explicitSlug)) { - throw new ConflictException('Slug already exists'); - } - } - - const slug = await resolveUniqueSlug( - dto.title, - slugExists, - dto.slug, - 'business', - ); - - const entity = this.repo.create({ - applicantId, - status: BusinessRegistrationStatus.PENDING, - title: dto.title.trim(), - slug, - categoryId: dto.categoryId, subtitle: dto.subtitle?.trim() ?? null, - description: dto.description?.trim() ?? null, - contactPhone: dto.contactPhone?.trim() ?? null, - address: dto.address?.trim() ?? null, - latitude: dto.latitude ?? null, - longitude: dto.longitude ?? null, - }); - - const saved = await this.repo.save(entity); - const withRelations = await this.findOne(saved.id); - - await this.notifySubmitted(withRelations, applicant); - return withRelations; - } - - findMine(applicantId: string): Promise { - return this.repo.find({ - where: { applicantId }, - relations: { category: true, business: true }, - order: { createdAt: 'DESC' }, - }); - } - - findAll(filters: ListFilters): Promise { - const qb = this.repo - .createQueryBuilder('req') - .leftJoinAndSelect('req.applicant', 'applicant') - .leftJoinAndSelect('req.category', 'category') - .leftJoinAndSelect('req.business', 'business') - .orderBy('req.createdAt', 'DESC'); - - if (filters.status) { - qb.andWhere('req.status = :status', { status: filters.status }); - } - - return qb.getMany(); - } - - async findOne(id: string): Promise { - const entity = await this.repo.findOne({ - where: { id }, - relations: { applicant: true, category: true, business: true, reviewedBy: true }, - }); - if (!entity) { - throw new NotFoundException('Registration request not found'); - } - return entity; - } - - async approve(id: string, admin: AuthUser): Promise { - const request = await this.findOne(id); - if (request.status !== BusinessRegistrationStatus.PENDING) { - throw new BadRequestException('Only pending requests can be approved'); - } - - const slugTaken = await this.businessRepo.findOne({ where: { slug: request.slug } }); - if (slugTaken) { - throw new ConflictException('Business slug already exists'); - } - - const applicant = await this.userRepo.findOne({ where: { id: request.applicantId } }); - if (!applicant) { - throw new NotFoundException('Applicant not found'); - } - - const business = this.businessRepo.create({ - title: request.title, - slug: request.slug, - categoryId: request.categoryId, - subtitle: request.subtitle, - description: request.description, - contactPhone: request.contactPhone, - address: request.address, - latitude: request.latitude, - longitude: request.longitude, - ownerId: applicant.id, - isActive: true, - }); - const savedBusiness = await this.businessRepo.save(business); - - if (applicant.role !== UserRole.ADMIN) { - applicant.role = UserRole.BUSINESS_OWNER; - } - applicant.isActive = true; - await this.userRepo.save(applicant); - - request.status = BusinessRegistrationStatus.APPROVED; - request.businessId = savedBusiness.id; - request.reviewedById = admin.id; - request.reviewedAt = new Date(); - request.adminNote = null; - const savedRequest = await this.repo.save(request); - - const result = await this.findOne(savedRequest.id); - await this.notifyApproved(result, applicant); - return result; - } - - async reject( - id: string, - admin: AuthUser, - dto: RejectBusinessRegistrationDto, - ): Promise { - const request = await this.findOne(id); - if (request.status !== BusinessRegistrationStatus.PENDING) { - throw new BadRequestException('Only pending requests can be rejected'); - } - - request.status = BusinessRegistrationStatus.REJECTED; - request.adminNote = dto.adminNote?.trim() ?? null; - request.reviewedById = admin.id; - request.reviewedAt = new Date(); - const saved = await this.repo.save(request); - - const result = await this.findOne(saved.id); - const applicant = result.applicant; - if (applicant) { - await this.notifyRejected(result, applicant); - } - return result; - } - - private async ensureCategoryExists(categoryId: string): Promise { - const category = await this.categoryRepo.findOne({ where: { id: categoryId } }); - if (!category) { - throw new NotFoundException('Business category not found'); - } - } - - private async notifySubmitted( - request: BusinessRegistrationRequestEntity, - applicant: UserEntity, - ): Promise { - await this.sms.sendSms( - applicant.mobile, - `درخواست ثبت «${request.title}» در منتایم ثبت شد و در انتظار تأیید است.`, - ); - - const adminMobile = this.config.get('SUPER_ADMIN_MOBILE'); - if (adminMobile) { - await this.sms.sendSms( - adminMobile, - `درخواست کسب‌وکار جدید: ${request.title} — ${applicant.mobile}`, - ); - } - } - - private async notifyApproved( - request: BusinessRegistrationRequestEntity, - applicant: UserEntity, - ): Promise { - await this.sms.sendSms( - applicant.mobile, - `کسب‌وکار «${request.title}» در منتایم تأیید شد. پنل مدیریت در اپ فعال است.`, - ); - } - - private async notifyRejected( - request: BusinessRegistrationRequestEntity, - applicant: UserEntity, - ): Promise { - const note = request.adminNote ? ` ${request.adminNote}` : ''; - await this.sms.sendSms( - applicant.mobile, - `درخواست «${request.title}» در منتایم رد شد.${note}`, - ); - } -} + subtitle: request.subtitle, \ No newline at end of file diff --git a/src/modules/business-registration-requests/dto/create-business-registration.dto.ts b/src/modules/business-registration-requests/dto/create-business-registration.dto.ts index 7f2d1ce..22c0649 100644 --- a/src/modules/business-registration-requests/dto/create-business-registration.dto.ts +++ b/src/modules/business-registration-requests/dto/create-business-registration.dto.ts @@ -1,64 +1,7 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsLatitude, - IsLongitude, - IsOptional, - IsString, - IsUUID, - Matches, - MaxLength, -} from 'class-validator'; -export class CreateBusinessRegistrationDto { - @ApiProperty({ example: 'کارواش محمد' }) - @IsString() - @MaxLength(150) - title!: string; - - @ApiProperty({ format: 'uuid' }) - @IsUUID() - categoryId!: string; - - @ApiPropertyOptional({ example: 'mohammad-carwash' }) - @IsOptional() - @IsString() - @MaxLength(120) - @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { - message: 'slug must be lowercase words separated by hyphens', - }) - slug?: string; @ApiPropertyOptional({ example: 'تمیزی که می‌درخشد' }) @IsOptional() @IsString() @MaxLength(150) - subtitle?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - @MaxLength(500) - description?: string; - - @ApiPropertyOptional({ example: '09121234567' }) - @IsOptional() - @IsString() - @MaxLength(20) - contactPhone?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsString() - @MaxLength(500) - address?: string; - - @ApiPropertyOptional() - @IsOptional() - @IsLatitude() - latitude?: number; - - @ApiPropertyOptional() - @IsOptional() - @IsLongitude() - longitude?: number; -} + subtitle?: string; \ No newline at end of file diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index 70d656d..7a55f98 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -1,306 +1,2 @@ -import { - BadRequestException, - ConflictException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; -import { deleteBusinessCascade } from '../../common/utils/business-cascade.util'; -import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util'; -import { BusinessImagesService } from './business-images.service'; -import { BusinessCategoryEntity } from '../../entities/business-category.entity'; -import { - normalizeHmTimeInput, - parseHmToMinute, -} from '../../common/utils/booking-schedule.util'; -import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; -import { BusinessEntity } from '../../entities/business.entity'; -import { UserEntity, UserRole } from '../../entities/user.entity'; -import { AuthUser } from '../auth/auth.types'; -import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto'; -import { CreateBusinessDto } from './dto/create-business.dto'; -import { SetAvailabilityDto } from './dto/set-availability.dto'; -import { UpdateBusinessDto } from './dto/update-business.dto'; -type FindAllFilters = { - categoryId?: string; - isActive?: boolean; -}; - -@Injectable() -export class BusinessesService { - constructor( - @InjectRepository(BusinessEntity) - private readonly repo: Repository, - @InjectRepository(BusinessAvailabilitySlotEntity) - private readonly availabilityRepo: Repository, - @InjectRepository(BusinessCategoryEntity) - private readonly categoryRepo: Repository, - @InjectRepository(UserEntity) - private readonly userRepo: Repository, - private readonly dataSource: DataSource, - private readonly images: BusinessImagesService, - ) {} - - async findAll(filters: FindAllFilters): Promise { - const query = this.repo - .createQueryBuilder('business') - .leftJoinAndSelect('business.category', 'category') - .leftJoinAndSelect('business.images', 'images') - .orderBy('business.createdAt', 'DESC') - .addOrderBy('images.isCover', 'DESC') - .addOrderBy('images.sortOrder', 'ASC'); - - if (filters.categoryId) { - query.andWhere('business.categoryId = :categoryId', { - categoryId: filters.categoryId, - }); - } - - if (typeof filters.isActive === 'boolean') { - query.andWhere('business.isActive = :isActive', { - isActive: filters.isActive, - }); - } - - return query.getMany(); - } - - async findOne(id: string): Promise { - const business = await this.repo.findOne({ - where: { id }, - relations: { category: true, images: true }, - }); - if (!business) { - throw new NotFoundException('Business not found'); - } - return business; - } - - async findMine(ownerId: string): Promise { - return this.repo.find({ - where: { ownerId }, - relations: { category: true, images: true }, - order: { createdAt: 'DESC' }, - }); - } - - async create(payload: CreateBusinessDto): Promise { - const explicitSlug = normalizeSlugInput(payload.slug); - if (explicitSlug) { - const taken = await this.repo.findOne({ where: { slug: explicitSlug } }); - if (taken) { - throw new ConflictException('Slug already exists'); - } - } - - const slug = await resolveUniqueSlug( - payload.title, - async (candidate) => - Boolean(await this.repo.findOne({ where: { slug: candidate } })), - payload.slug, - 'business', - ); - - await this.ensureCategoryExists(payload.categoryId); - - const entity = this.repo.create({ - title: payload.title, - slug, - categoryId: payload.categoryId, - subtitle: payload.subtitle ?? null, - description: payload.description ?? null, - contactPhone: payload.contactPhone ?? null, - address: payload.address ?? null, - latitude: payload.latitude ?? null, - longitude: payload.longitude ?? null, - ownerId: payload.ownerId ?? null, - isActive: payload.isActive ?? true, - }); - - return this.repo.save(entity); - } - - async update( - id: string, - payload: UpdateBusinessDto, - actor?: AuthUser, - ): Promise { - const business = await this.findOne(id); - this.assertCanUpdateBusiness(business, payload, actor); - - if (payload.slug && payload.slug !== business.slug) { - const slugTaken = await this.repo.findOne({ where: { slug: payload.slug } }); - if (slugTaken) { - throw new ConflictException('Slug already exists'); - } - } - - if (payload.categoryId && payload.categoryId !== business.categoryId) { - await this.ensureCategoryExists(payload.categoryId); - } - - Object.assign(business, payload); - return this.repo.save(business); - } - - async remove(id: string): Promise { - await this.findOne(id); - - const imageUrls = await this.images.listUrlsForBusiness(id); - - await this.dataSource.transaction(async (manager) => { - await deleteBusinessCascade(manager, id); - }); - - this.images.deleteFiles(imageUrls); - } - - async getAvailability(businessId: string) { - await this.findOne(businessId); - const rows = await this.availabilityRepo.find({ - where: { businessId }, - order: { dayOfWeek: 'ASC', startMinute: 'ASC' }, - }); - return rows.map((row) => ({ - id: row.id, - dayOfWeek: row.dayOfWeek, - startTime: this.minuteToHm(row.startMinute), - endTime: this.minuteToHm(row.endMinute), - })); - } - - async setAvailability( - businessId: string, - dto: SetAvailabilityDto, - actor?: AuthUser, - ) { - const business = await this.findOne(businessId); - this.assertCanManageAvailability(business, actor); - - const entities: BusinessAvailabilitySlotEntity[] = []; - for (const slot of dto.slots) { - const startTime = normalizeHmTimeInput(slot.startTime); - const endTime = normalizeHmTimeInput(slot.endTime); - if (!startTime || !endTime) { - throw new BadRequestException( - 'ساعت باید به صورت HH:mm باشد (مثلاً 09:00)', - ); - } - const startMinute = parseHmToMinute(startTime); - const endMinute = parseHmToMinute(endTime); - if (endMinute <= startMinute) { - throw new BadRequestException('endTime must be after startTime'); - } - entities.push( - this.availabilityRepo.create({ - businessId, - dayOfWeek: slot.dayOfWeek, - startMinute, - endMinute, - }), - ); - } - - await this.availabilityRepo.delete({ businessId }); - if (entities.length) { - await this.availabilityRepo.save(entities); - } - return this.getAvailability(businessId); - } - - async assignOwner( - id: string, - dto: AssignBusinessOwnerDto, - ): Promise { - const business = await this.findOne(id); - const mobile = this.normalizeMobile(dto.mobile); - - let user = await this.userRepo.findOne({ where: { mobile } }); - if (!user) { - user = this.userRepo.create({ - mobile, - fullName: dto.fullName ?? null, - role: UserRole.BUSINESS_OWNER, - isActive: true, - }); - } else { - if (user.role !== UserRole.ADMIN) { - user.role = UserRole.BUSINESS_OWNER; - } - if (dto.fullName !== undefined) { - user.fullName = dto.fullName; - } - user.isActive = true; - } - - const savedUser = await this.userRepo.save(user); - business.ownerId = savedUser.id; - return this.repo.save(business); - } - - private async ensureCategoryExists(categoryId: string): Promise { - const category = await this.categoryRepo.findOne({ where: { id: categoryId } }); - if (!category) { - throw new NotFoundException('Business category not found'); - } - } - - private normalizeMobile(mobile: string): string { - const normalized = String(mobile ?? '').trim(); - if (!/^(\+?\d{10,15}|09\d{9})$/.test(normalized)) { - throw new BadRequestException('Invalid mobile number'); - } - return normalized; - } - - private minuteToHm(minute: number): string { - const h = Math.floor(minute / 60); - const m = minute % 60; - return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; - } - - private assertCanManageAvailability( - 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'); - } - } - - private assertCanUpdateBusiness( - business: BusinessEntity, - payload: UpdateBusinessDto, - 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'); - } - - if ( - payload.ownerId !== undefined || - payload.categoryId !== undefined || - payload.isActive !== undefined - ) { - throw new ForbiddenException( - 'Only the super admin can change owner, category, or active status', - ); - } - } -} + subtitle: payload.subtitle ?? null, \ No newline at end of file diff --git a/src/modules/businesses/dto/create-business.dto.ts b/src/modules/businesses/dto/create-business.dto.ts index 32b45f0..22c0649 100644 --- a/src/modules/businesses/dto/create-business.dto.ts +++ b/src/modules/businesses/dto/create-business.dto.ts @@ -1,75 +1,7 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsBoolean, - IsLatitude, - IsLongitude, - IsOptional, - IsString, - IsUUID, - Matches, - MaxLength, -} from 'class-validator'; -export class CreateBusinessDto { - @ApiProperty({ example: 'Shine Carwash' }) - @IsString() - @MaxLength(150) - title!: string; - - @ApiPropertyOptional({ example: 'shine-carwash' }) - @IsOptional() - @IsString() - @MaxLength(120) - @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { - message: 'slug must be lowercase words separated by hyphens', - }) - slug?: string; - - @ApiProperty({ format: 'uuid' }) - @IsUUID() - categoryId!: string; @ApiPropertyOptional({ example: 'تمیزی که می‌درخشد' }) @IsOptional() @IsString() @MaxLength(150) - subtitle?: string; - - @ApiPropertyOptional({ example: 'Premium carwash service' }) - @IsOptional() - @IsString() - @MaxLength(500) - description?: string; - - @ApiPropertyOptional({ example: '09123456789' }) - @IsOptional() - @IsString() - @MaxLength(20) - contactPhone?: string; - - @ApiPropertyOptional({ example: 'Main street' }) - @IsOptional() - @IsString() - @MaxLength(500) - address?: string; - - @ApiPropertyOptional({ example: 35.6892 }) - @IsOptional() - @IsLatitude() - latitude?: number; - - @ApiPropertyOptional({ example: 51.389 }) - @IsOptional() - @IsLongitude() - longitude?: number; - - @ApiPropertyOptional({ format: 'uuid' }) - @IsOptional() - @IsUUID() - ownerId?: string; - - @ApiPropertyOptional({ example: true }) - @IsOptional() - @IsBoolean() - isActive?: boolean; -} + subtitle?: string; \ No newline at end of file diff --git a/src/modules/businesses/dto/update-business.dto.ts b/src/modules/businesses/dto/update-business.dto.ts index fe032f6..1dedbeb 100644 --- a/src/modules/businesses/dto/update-business.dto.ts +++ b/src/modules/businesses/dto/update-business.dto.ts @@ -1,112 +1,7 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { - IsBoolean, - IsInt, - IsLatitude, - IsLongitude, - IsOptional, - IsString, - IsUUID, - Matches, - Max, - MaxLength, - Min, -} from 'class-validator'; -export class UpdateBusinessDto { - @ApiPropertyOptional({ example: 'Shine Carwash' }) - @IsOptional() - @IsString() - @MaxLength(150) - title?: string; - - @ApiPropertyOptional({ example: 'shine-carwash' }) - @IsOptional() - @IsString() - @MaxLength(120) - @Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { - message: 'slug must be lowercase words separated by hyphens', - }) - slug?: string; - - @ApiPropertyOptional({ format: 'uuid' }) - @IsOptional() - @IsUUID() - categoryId?: string; @ApiPropertyOptional({ example: 'تمیزی که می‌درخشد', nullable: true }) @IsOptional() @IsString() @MaxLength(150) - subtitle?: string | null; - - @ApiPropertyOptional({ example: 'Premium carwash service', nullable: true }) - @IsOptional() - @IsString() - @MaxLength(500) - description?: string | null; - - @ApiPropertyOptional({ example: '09123456789', nullable: true }) - @IsOptional() - @IsString() - @MaxLength(20) - contactPhone?: string | null; - - @ApiPropertyOptional({ example: 'Main street', nullable: true }) - @IsOptional() - @IsString() - @MaxLength(500) - address?: string | null; - - @ApiPropertyOptional({ example: 35.6892, nullable: true }) - @IsOptional() - @IsLatitude() - latitude?: number | null; - - @ApiPropertyOptional({ example: 51.389, nullable: true }) - @IsOptional() - @IsLongitude() - longitude?: number | null; - - @ApiPropertyOptional({ format: 'uuid', nullable: true }) - @IsOptional() - @IsUUID() - ownerId?: string | null; - - @ApiPropertyOptional({ example: true }) - @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; - - @ApiPropertyOptional({ - example: 1, - description: 'How many customers can be served at the same time', - minimum: 1, - maximum: 20, - }) - @IsOptional() - @IsInt() - @Min(1) - @Max(20) - concurrentCapacity?: number; - - @ApiPropertyOptional({ - example: 30, - description: 'Minutes between available booking slot start times', - minimum: 5, - maximum: 120, - }) - @IsOptional() - @IsInt() - @Min(5) - @Max(120) - slotStepMinutes?: number; -} + subtitle?: string | null; \ No newline at end of file