From 4e33d3f8e107356eec06612f22b188801e6c42e7 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Fri, 12 Jun 2026 20:37:33 +0330 Subject: [PATCH] landing desktop1 --- src/app.module.ts | 43 ++- .../business-registration-request.entity.ts | 93 ++++- src/entities/business.entity.ts | 105 +++++- .../business-registration-requests.service.ts | 268 +++++++++++++- .../dto/create-business-registration.dto.ts | 59 ++- src/modules/businesses/businesses.service.ts | 341 +++++++++++++++++- .../businesses/dto/create-business.dto.ts | 70 +++- .../businesses/dto/update-business.dto.ts | 107 +++++- 8 files changed, 1077 insertions(+), 9 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index 8f952dc..f2df583 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,3 +1,42 @@ - +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 { AttributeDefinitionsModule } from './modules/attribute-definitions/attribute-definitions.module'; +import { AuthModule } from './modules/auth/auth.module'; +import { BookingsModule } from './modules/bookings/bookings.module'; +import { BusinessAttributeValuesModule } from './modules/business-attribute-values/business-attribute-values.module'; +import { BusinessCategoriesModule } from './modules/business-categories/business-categories.module'; +import { BusinessRegistrationRequestsModule } from './modules/business-registration-requests/business-registration-requests.module'; +import { BusinessServiceClassesModule } from './modules/business-service-classes/business-service-classes.module'; +import { BusinessServiceRequestsModule } from './modules/business-service-requests/business-service-requests.module'; +import { BusinessServicesModule } from './modules/business-services/business-services.module'; +import { BusinessesModule } from './modules/businesses/businesses.module'; import { PlatformSettingsModule } from './modules/platform-settings/platform-settings.module'; - PlatformSettingsModule, \ No newline at end of file +import { ServiceTemplatesModule } from './modules/service-templates/service-templates.module'; +import { UsersModule } from './modules/users/users.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 {} diff --git a/src/entities/business-registration-request.entity.ts b/src/entities/business-registration-request.entity.ts index bc1cdca..7c20118 100644 --- a/src/entities/business-registration-request.entity.ts +++ b/src/entities/business-registration-request.entity.ts @@ -1,4 +1,95 @@ +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; \ No newline at end of file + 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; +} diff --git a/src/entities/business.entity.ts b/src/entities/business.entity.ts index bc1cdca..4dbc344 100644 --- a/src/entities/business.entity.ts +++ b/src/entities/business.entity.ts @@ -1,4 +1,107 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { BookingEntity } from './booking.entity'; +import { BusinessAttributeValueEntity } from './business-attribute-value.entity'; +import { BusinessAvailabilitySlotEntity } from './business-availability-slot.entity'; +import { BusinessCategoryEntity } from './business-category.entity'; +import { BusinessImageEntity } from './business-image.entity'; +import { BusinessServiceClassEntity } from './business-service-class.entity'; +import { BusinessServiceEntity } from './business-service.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; \ No newline at end of file + 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; + + @Column({ type: 'int', default: 1 }) + concurrentCapacity!: number; + + @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; +} 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 3a553a6..0c79de0 100644 --- a/src/modules/business-registration-requests/business-registration-requests.service.ts +++ b/src/modules/business-registration-requests/business-registration-requests.service.ts @@ -1,3 +1,269 @@ +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, - subtitle: request.subtitle, \ No newline at end of file + 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}`, + ); + } +} 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 22c0649..7f2d1ce 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,7 +1,64 @@ +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; \ No newline at end of file + 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; +} diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index 7a55f98..e31fcb8 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -1,2 +1,341 @@ +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 { parseHmToMinute } from '../../common/utils/booking-schedule.util'; +import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util'; +import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; +import { BusinessCategoryEntity } from '../../entities/business-category.entity'; +import { BusinessEntity } from '../../entities/business.entity'; +import { BusinessImageEntity } from '../../entities/business-image.entity'; +import { UserEntity, UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; +import { BusinessImagesService } from './business-images.service'; +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'; - subtitle: payload.subtitle ?? null, \ No newline at end of file +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(BusinessImageEntity) + private readonly imageRepo: 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') + .orderBy('business.createdAt', 'DESC'); + + if (filters.categoryId) { + query.andWhere('business.categoryId = :categoryId', { + categoryId: filters.categoryId, + }); + } + + if (typeof filters.isActive === 'boolean') { + query.andWhere('business.isActive = :isActive', { + isActive: filters.isActive, + }); + } + + const businesses = await query.getMany(); + await this.attachCoverImages(businesses); + return businesses; + } + + async findMine(ownerId: string): Promise { + const businesses = await this.repo.find({ + where: { ownerId }, + relations: { category: true }, + order: { createdAt: 'DESC' }, + }); + await this.attachCoverImages(businesses); + return businesses; + } + + 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 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, + subtitle: payload.subtitle ?? null, + categoryId: payload.categoryId, + 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 startMinute = parseHmToMinute(slot.startTime); + const endMinute = parseHmToMinute(slot.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 attachCoverImages(businesses: BusinessEntity[]): Promise { + if (!businesses.length) { + return; + } + + const ids = businesses.map((b) => b.id); + const rows = (await this.imageRepo.query( + `SELECT DISTINCT ON ("businessId") id, "businessId", url, "originalName", "sortOrder", "isCover", "createdAt" + FROM business_images + WHERE "businessId" = ANY($1::uuid[]) + ORDER BY "businessId", "isCover" DESC, "sortOrder" ASC`, + [ids], + )) as Array<{ + id: string; + businessId: string; + url: string; + originalName: string | null; + sortOrder: number; + isCover: boolean; + createdAt: Date; + }>; + + const coverByBusiness = new Map(); + for (const row of rows) { + coverByBusiness.set(row.businessId, [ + this.imageRepo.create({ + id: row.id, + businessId: row.businessId, + url: row.url, + originalName: row.originalName, + sortOrder: row.sortOrder, + isCover: row.isCover, + createdAt: row.createdAt, + }), + ]); + } + + for (const business of businesses) { + business.images = coverByBusiness.get(business.id) ?? []; + } + } + + 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 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 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', + ); + } + } +} diff --git a/src/modules/businesses/dto/create-business.dto.ts b/src/modules/businesses/dto/create-business.dto.ts index 22c0649..32b45f0 100644 --- a/src/modules/businesses/dto/create-business.dto.ts +++ b/src/modules/businesses/dto/create-business.dto.ts @@ -1,7 +1,75 @@ +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; \ No newline at end of file + 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; +} diff --git a/src/modules/businesses/dto/update-business.dto.ts b/src/modules/businesses/dto/update-business.dto.ts index 1dedbeb..fe032f6 100644 --- a/src/modules/businesses/dto/update-business.dto.ts +++ b/src/modules/businesses/dto/update-business.dto.ts @@ -1,7 +1,112 @@ +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; \ No newline at end of file + 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; +}