landing desktop

This commit is contained in:
2026-06-12 20:17:59 +03:30
parent d3d61f5e5a
commit d9b18ba0ea
8 changed files with 9 additions and 1044 deletions
+2 -41
View File
@@ -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,
@@ -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;
+1 -106
View File
@@ -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;
@@ -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<BusinessRegistrationRequestEntity>,
@InjectRepository(BusinessEntity)
private readonly businessRepo: Repository<BusinessEntity>,
@InjectRepository(BusinessCategoryEntity)
private readonly categoryRepo: Repository<BusinessCategoryEntity>,
@InjectRepository(UserEntity)
private readonly userRepo: Repository<UserEntity>,
private readonly sms: SmsService,
private readonly config: ConfigService,
) {}
async create(
applicantId: string,
dto: CreateBusinessRegistrationDto,
): Promise<BusinessRegistrationRequestEntity> {
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<BusinessRegistrationRequestEntity[]> {
return this.repo.find({
where: { applicantId },
relations: { category: true, business: true },
order: { createdAt: 'DESC' },
});
}
findAll(filters: ListFilters): Promise<BusinessRegistrationRequestEntity[]> {
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<BusinessRegistrationRequestEntity> {
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<BusinessRegistrationRequestEntity> {
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<BusinessRegistrationRequestEntity> {
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<void> {
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<void> {
await this.sms.sendSms(
applicant.mobile,
`درخواست ثبت «${request.title}» در منتایم ثبت شد و در انتظار تأیید است.`,
);
const adminMobile = this.config.get<string>('SUPER_ADMIN_MOBILE');
if (adminMobile) {
await this.sms.sendSms(
adminMobile,
`درخواست کسب‌وکار جدید: ${request.title} — ${applicant.mobile}`,
);
}
}
private async notifyApproved(
request: BusinessRegistrationRequestEntity,
applicant: UserEntity,
): Promise<void> {
await this.sms.sendSms(
applicant.mobile,
`کسب‌وکار «${request.title}» در منتایم تأیید شد. پنل مدیریت در اپ فعال است.`,
);
}
private async notifyRejected(
request: BusinessRegistrationRequestEntity,
applicant: UserEntity,
): Promise<void> {
const note = request.adminNote ? ` ${request.adminNote}` : '';
await this.sms.sendSms(
applicant.mobile,
`درخواست «${request.title}» در منتایم رد شد.${note}`,
);
}
}
subtitle: request.subtitle,
@@ -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;
+1 -305
View File
@@ -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<BusinessEntity>,
@InjectRepository(BusinessAvailabilitySlotEntity)
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
@InjectRepository(BusinessCategoryEntity)
private readonly categoryRepo: Repository<BusinessCategoryEntity>,
@InjectRepository(UserEntity)
private readonly userRepo: Repository<UserEntity>,
private readonly dataSource: DataSource,
private readonly images: BusinessImagesService,
) {}
async findAll(filters: FindAllFilters): Promise<BusinessEntity[]> {
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<BusinessEntity> {
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<BusinessEntity[]> {
return this.repo.find({
where: { ownerId },
relations: { category: true, images: true },
order: { createdAt: 'DESC' },
});
}
async create(payload: CreateBusinessDto): Promise<BusinessEntity> {
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<BusinessEntity> {
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<void> {
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<BusinessEntity> {
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<void> {
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,
@@ -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;
@@ -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;