salon's services
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
MigrationInterface,
|
||||||
|
QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableForeignKey,
|
||||||
|
TableUnique,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class AddSalonServices1721400000000 implements MigrationInterface {
|
||||||
|
name = 'AddSalonServices1721400000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'salon_services',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'uuid',
|
||||||
|
isPrimary: true,
|
||||||
|
generationStrategy: 'uuid',
|
||||||
|
default: 'gen_random_uuid()',
|
||||||
|
},
|
||||||
|
{ name: 'salonId', type: 'uuid' },
|
||||||
|
{ name: 'serviceId', type: 'uuid' },
|
||||||
|
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
|
||||||
|
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createUniqueConstraint(
|
||||||
|
'salon_services',
|
||||||
|
new TableUnique({
|
||||||
|
name: 'UQ_salon_services_salon_service',
|
||||||
|
columnNames: ['salonId', 'serviceId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'salon_services',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['salonId'],
|
||||||
|
referencedTableName: 'salons',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'salon_services',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['serviceId'],
|
||||||
|
referencedTableName: 'services',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('salon_services', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { CustomersModule } from '../customers/customers.module';
|
|||||||
import { SalonsModule } from '../salons/salons.module';
|
import { SalonsModule } from '../salons/salons.module';
|
||||||
import { ServiceSkill } from '../services/entities/service-skill.entity';
|
import { ServiceSkill } from '../services/entities/service-skill.entity';
|
||||||
import { Service } from '../services/entities/service.entity';
|
import { Service } from '../services/entities/service.entity';
|
||||||
|
import { ServicesModule } from '../services/services.module';
|
||||||
import { SmsSettingsModule } from '../sms-settings/sms-settings.module';
|
import { SmsSettingsModule } from '../sms-settings/sms-settings.module';
|
||||||
import { StylistsModule } from '../stylists/stylists.module';
|
import { StylistsModule } from '../stylists/stylists.module';
|
||||||
import { ReserveItem } from './entities/reserve-item.entity';
|
import { ReserveItem } from './entities/reserve-item.entity';
|
||||||
@@ -26,6 +27,7 @@ import { ReservesService } from './reserves.service';
|
|||||||
SalonsModule,
|
SalonsModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
StylistsModule,
|
StylistsModule,
|
||||||
|
ServicesModule,
|
||||||
AuthorizationModule,
|
AuthorizationModule,
|
||||||
AuthGuardsModule,
|
AuthGuardsModule,
|
||||||
SmsSettingsModule,
|
SmsSettingsModule,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { CustomersService } from '../customers/customers.service';
|
|||||||
import { SalonsService } from '../salons/salons.service';
|
import { SalonsService } from '../salons/salons.service';
|
||||||
import { ServiceSkill } from '../services/entities/service-skill.entity';
|
import { ServiceSkill } from '../services/entities/service-skill.entity';
|
||||||
import { Service } from '../services/entities/service.entity';
|
import { Service } from '../services/entities/service.entity';
|
||||||
|
import { ServicesService } from '../services/services.service';
|
||||||
import { StylistsService } from '../stylists/stylists.service';
|
import { StylistsService } from '../stylists/stylists.service';
|
||||||
import { UserRole } from '../users/enums/user-role.enum';
|
import { UserRole } from '../users/enums/user-role.enum';
|
||||||
import { CreateReserveDto } from './dto/create-reserve.dto';
|
import { CreateReserveDto } from './dto/create-reserve.dto';
|
||||||
@@ -43,6 +44,7 @@ export class ReservesService {
|
|||||||
private readonly authorizationService: AuthorizationService,
|
private readonly authorizationService: AuthorizationService,
|
||||||
private readonly reservePolicy: ReservePolicy,
|
private readonly reservePolicy: ReservePolicy,
|
||||||
private readonly reserveSmsService: ReserveSmsService,
|
private readonly reserveSmsService: ReserveSmsService,
|
||||||
|
private readonly servicesService: ServicesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
@@ -76,6 +78,10 @@ export class ReservesService {
|
|||||||
createReserveDto.depositAmount,
|
createReserveDto.depositAmount,
|
||||||
);
|
);
|
||||||
await this.validateItems(createReserveDto.items);
|
await this.validateItems(createReserveDto.items);
|
||||||
|
await this.servicesService.ensureSalonOffersServices(
|
||||||
|
createReserveDto.salonId,
|
||||||
|
createReserveDto.items.map((item) => item.serviceId),
|
||||||
|
);
|
||||||
|
|
||||||
const reserve = this.reservesRepository.create({
|
const reserve = this.reservesRepository.create({
|
||||||
salonId: createReserveDto.salonId,
|
salonId: createReserveDto.salonId,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateSalonServiceDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
serviceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FindSalonServicesQueryDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Unique,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Salon } from '../../salons/entities/salon.entity';
|
||||||
|
import { Service } from './service.entity';
|
||||||
|
|
||||||
|
@Entity('salon_services')
|
||||||
|
@Unique(['salonId', 'serviceId'])
|
||||||
|
export class SalonServiceOffering {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
salonId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
serviceId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Salon, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'salonId' })
|
||||||
|
salon: Salon;
|
||||||
|
|
||||||
|
@ManyToOne(() => Service, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'serviceId' })
|
||||||
|
service: Service;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -21,6 +22,10 @@ import { PermissionCode } from '../authorization/enums/permission-code.enum';
|
|||||||
import { UserRole } from '../users/enums/user-role.enum';
|
import { UserRole } from '../users/enums/user-role.enum';
|
||||||
import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
|
import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
|
||||||
import { CreateServiceDto } from './dto/create-service.dto';
|
import { CreateServiceDto } from './dto/create-service.dto';
|
||||||
|
import {
|
||||||
|
CreateSalonServiceDto,
|
||||||
|
FindSalonServicesQueryDto,
|
||||||
|
} from './dto/salon-service.dto';
|
||||||
import { UpdateServiceSkillDto } from './dto/update-service-skill.dto';
|
import { UpdateServiceSkillDto } from './dto/update-service-skill.dto';
|
||||||
import { UpdateServiceDto } from './dto/update-service.dto';
|
import { UpdateServiceDto } from './dto/update-service.dto';
|
||||||
import { ServicesService } from './services.service';
|
import { ServicesService } from './services.service';
|
||||||
@@ -61,6 +66,39 @@ export class ServicesController {
|
|||||||
return this.servicesService.findAll();
|
return this.servicesService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(UserRole.SALON)
|
||||||
|
@Permissions(PermissionCode.SERVICES_READ)
|
||||||
|
@ApiStandardOkResponse(Service, { isArray: true })
|
||||||
|
@Get('salon')
|
||||||
|
findAllForSalon(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query() query: FindSalonServicesQueryDto,
|
||||||
|
) {
|
||||||
|
return this.servicesService.findAllForSalon(user, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(UserRole.SALON)
|
||||||
|
@Permissions(PermissionCode.SERVICES_WRITE)
|
||||||
|
@ApiStandardOkResponse(Service)
|
||||||
|
@Post('salon')
|
||||||
|
addServiceForSalon(
|
||||||
|
@Body() createSalonServiceDto: CreateSalonServiceDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.servicesService.addServiceForSalon(createSalonServiceDto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(UserRole.SALON)
|
||||||
|
@Permissions(PermissionCode.SERVICES_DELETE)
|
||||||
|
@ApiStandardEmptyResponse()
|
||||||
|
@Delete('salon/:serviceId')
|
||||||
|
removeServiceForSalon(
|
||||||
|
@Param('serviceId', ParseUUIDPipe) serviceId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.servicesService.removeServiceForSalon(serviceId, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@ApiPublicEndpoint('Get service by ID')
|
@ApiPublicEndpoint('Get service by ID')
|
||||||
@ApiStandardOkResponse(Service)
|
@ApiStandardOkResponse(Service)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AuthGuardsModule } from '../auth/auth-guards.module';
|
import { AuthGuardsModule } from '../auth/auth-guards.module';
|
||||||
import { AuthorizationModule } from '../authorization/authorization.module';
|
import { AuthorizationModule } from '../authorization/authorization.module';
|
||||||
|
import { Salon } from '../salons/entities/salon.entity';
|
||||||
|
import { SalonServiceOffering } from './entities/salon-service-offering.entity';
|
||||||
import { ServiceSkill } from './entities/service-skill.entity';
|
import { ServiceSkill } from './entities/service-skill.entity';
|
||||||
import { Service } from './entities/service.entity';
|
import { Service } from './entities/service.entity';
|
||||||
import { ServicesController } from './services.controller';
|
import { ServicesController } from './services.controller';
|
||||||
@@ -9,7 +11,7 @@ import { ServicesService } from './services.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Service, ServiceSkill]),
|
TypeOrmModule.forFeature([Service, ServiceSkill, SalonServiceOffering, Salon]),
|
||||||
AuthorizationModule,
|
AuthorizationModule,
|
||||||
AuthGuardsModule,
|
AuthGuardsModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { In, Repository } from 'typeorm';
|
||||||
import { ServicePolicy } from '../authorization/policies/service.policy';
|
import { ServicePolicy } from '../authorization/policies/service.policy';
|
||||||
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
||||||
|
import { Salon } from '../salons/entities/salon.entity';
|
||||||
|
import { UserRole } from '../users/enums/user-role.enum';
|
||||||
import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
|
import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
|
||||||
import { CreateServiceDto } from './dto/create-service.dto';
|
import { CreateServiceDto } from './dto/create-service.dto';
|
||||||
|
import {
|
||||||
|
CreateSalonServiceDto,
|
||||||
|
FindSalonServicesQueryDto,
|
||||||
|
} from './dto/salon-service.dto';
|
||||||
import { UpdateServiceSkillDto } from './dto/update-service-skill.dto';
|
import { UpdateServiceSkillDto } from './dto/update-service-skill.dto';
|
||||||
import { UpdateServiceDto } from './dto/update-service.dto';
|
import { UpdateServiceDto } from './dto/update-service.dto';
|
||||||
|
import { SalonServiceOffering } from './entities/salon-service-offering.entity';
|
||||||
import { ServiceSkill } from './entities/service-skill.entity';
|
import { ServiceSkill } from './entities/service-skill.entity';
|
||||||
import { Service } from './entities/service.entity';
|
import { Service } from './entities/service.entity';
|
||||||
|
|
||||||
@@ -21,6 +30,10 @@ export class ServicesService {
|
|||||||
private readonly servicesRepository: Repository<Service>,
|
private readonly servicesRepository: Repository<Service>,
|
||||||
@InjectRepository(ServiceSkill)
|
@InjectRepository(ServiceSkill)
|
||||||
private readonly serviceSkillsRepository: Repository<ServiceSkill>,
|
private readonly serviceSkillsRepository: Repository<ServiceSkill>,
|
||||||
|
@InjectRepository(SalonServiceOffering)
|
||||||
|
private readonly salonServicesRepository: Repository<SalonServiceOffering>,
|
||||||
|
@InjectRepository(Salon)
|
||||||
|
private readonly salonsRepository: Repository<Salon>,
|
||||||
private readonly servicePolicy: ServicePolicy,
|
private readonly servicePolicy: ServicePolicy,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -127,6 +140,117 @@ export class ServicesService {
|
|||||||
await this.serviceSkillsRepository.remove(skill);
|
await this.serviceSkillsRepository.remove(skill);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAllForSalon(
|
||||||
|
user: AuthUser,
|
||||||
|
query: FindSalonServicesQueryDto,
|
||||||
|
): Promise<Service[]> {
|
||||||
|
const salon = await this.resolveSalonForUser(user);
|
||||||
|
const offerings = await this.salonServicesRepository.find({
|
||||||
|
where: { salonId: salon.id },
|
||||||
|
relations: { service: { skills: true } },
|
||||||
|
order: { createdAt: 'ASC', service: { skills: { createdAt: 'ASC' } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
let services = offerings.map((offering) => offering.service);
|
||||||
|
|
||||||
|
if (query.search?.trim()) {
|
||||||
|
const search = query.search.trim().toLowerCase();
|
||||||
|
services = services.filter((service) =>
|
||||||
|
service.title.toLowerCase().includes(search),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addServiceForSalon(
|
||||||
|
dto: CreateSalonServiceDto,
|
||||||
|
user: AuthUser,
|
||||||
|
): Promise<Service> {
|
||||||
|
const salon = await this.resolveSalonForUser(user);
|
||||||
|
await this.findOne(dto.serviceId);
|
||||||
|
|
||||||
|
const existing = await this.salonServicesRepository.findOne({
|
||||||
|
where: { salonId: salon.id, serviceId: dto.serviceId },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('This service is already added to the salon');
|
||||||
|
}
|
||||||
|
|
||||||
|
const offering = this.salonServicesRepository.create({
|
||||||
|
salonId: salon.id,
|
||||||
|
serviceId: dto.serviceId,
|
||||||
|
});
|
||||||
|
await this.salonServicesRepository.save(offering);
|
||||||
|
return this.findOne(dto.serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeServiceForSalon(
|
||||||
|
serviceId: string,
|
||||||
|
user: AuthUser,
|
||||||
|
): Promise<void> {
|
||||||
|
const salon = await this.resolveSalonForUser(user);
|
||||||
|
const offering = await this.salonServicesRepository.findOne({
|
||||||
|
where: { salonId: salon.id, serviceId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!offering) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Service #${serviceId} is not offered by this salon`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.salonServicesRepository.remove(offering);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureSalonOffersServices(
|
||||||
|
salonId: string,
|
||||||
|
serviceIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const uniqueServiceIds = [...new Set(serviceIds)];
|
||||||
|
if (uniqueServiceIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const salonOfferingCount = await this.salonServicesRepository.count({
|
||||||
|
where: { salonId },
|
||||||
|
});
|
||||||
|
if (salonOfferingCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offerings = await this.salonServicesRepository.find({
|
||||||
|
where: { salonId, serviceId: In(uniqueServiceIds) },
|
||||||
|
select: { serviceId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const offeredIds = new Set(offerings.map((offering) => offering.serviceId));
|
||||||
|
const missing = uniqueServiceIds.filter((id) => !offeredIds.has(id));
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Salon does not offer service(s): ${missing.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSalonForUser(user: AuthUser): Promise<Salon> {
|
||||||
|
if (user.role !== UserRole.SALON) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const salon = await this.salonsRepository.findOne({
|
||||||
|
where: { ownerId: user.id },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!salon) {
|
||||||
|
throw new NotFoundException('Salon not found for this user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return salon;
|
||||||
|
}
|
||||||
|
|
||||||
private ensureCanManage(user: AuthUser): void {
|
private ensureCanManage(user: AuthUser): void {
|
||||||
if (!this.servicePolicy.canManage(user)) {
|
if (!this.servicePolicy.canManage(user)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
|
|||||||
Reference in New Issue
Block a user