This commit is contained in:
2026-06-28 22:43:08 +03:30
parent 322c9018da
commit 127c1eaf78
14 changed files with 500 additions and 0 deletions
+2
View File
@@ -6,6 +6,7 @@ import { CustomersModule } from './customers/customers.module';
import configuration from './config/configuration'; import configuration from './config/configuration';
import { getTypeOrmOptions } from './database/typeorm-options'; import { getTypeOrmOptions } from './database/typeorm-options';
import { SalonsModule } from './salons/salons.module'; import { SalonsModule } from './salons/salons.module';
import { ServicesModule } from './services/services.module';
import { StylistsModule } from './stylists/stylists.module'; import { StylistsModule } from './stylists/stylists.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
@@ -20,6 +21,7 @@ import { UsersModule } from './users/users.module';
UsersModule, UsersModule,
SalonsModule, SalonsModule,
StylistsModule, StylistsModule,
ServicesModule,
CustomersModule, CustomersModule,
], ],
controllers: [], controllers: [],
@@ -5,6 +5,7 @@ import { Permission } from './entities/permission.entity';
import { UserPermission } from './entities/user-permission.entity'; import { UserPermission } from './entities/user-permission.entity';
import { CustomerPolicy } from './policies/customer.policy'; import { CustomerPolicy } from './policies/customer.policy';
import { SalonPolicy } from './policies/salon.policy'; import { SalonPolicy } from './policies/salon.policy';
import { ServicePolicy } from './policies/service.policy';
import { StylistPolicy } from './policies/stylist.policy'; import { StylistPolicy } from './policies/stylist.policy';
@Module({ @Module({
@@ -12,12 +13,14 @@ import { StylistPolicy } from './policies/stylist.policy';
providers: [ providers: [
AuthorizationService, AuthorizationService,
SalonPolicy, SalonPolicy,
ServicePolicy,
CustomerPolicy, CustomerPolicy,
StylistPolicy, StylistPolicy,
], ],
exports: [ exports: [
AuthorizationService, AuthorizationService,
SalonPolicy, SalonPolicy,
ServicePolicy,
CustomerPolicy, CustomerPolicy,
StylistPolicy, StylistPolicy,
], ],
@@ -10,4 +10,7 @@ export enum PermissionCode {
STYLISTS_READ = 'stylists:read', STYLISTS_READ = 'stylists:read',
STYLISTS_WRITE = 'stylists:write', STYLISTS_WRITE = 'stylists:write',
STYLISTS_DELETE = 'stylists:delete', STYLISTS_DELETE = 'stylists:delete',
SERVICES_READ = 'services:read',
SERVICES_WRITE = 'services:write',
SERVICES_DELETE = 'services:delete',
} }
@@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common';
import { AuthUser } from '../../auth/interfaces/auth-user.interface';
import { AuthorizationService } from '../authorization.service';
@Injectable()
export class ServicePolicy {
constructor(private readonly authorizationService: AuthorizationService) {}
canManage(user: AuthUser): boolean {
return this.authorizationService.isAdmin(user);
}
}
@@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
export class AddServices1719800000000 implements MigrationInterface {
name = 'AddServices1719800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: 'services',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{ name: 'title', type: 'varchar', length: '200' },
{ name: 'iconUrl', type: 'varchar', length: '500', isNullable: true },
{ name: 'coverUrl', type: 'varchar', length: '500', isNullable: true },
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
],
}),
true,
);
await queryRunner.createTable(
new Table({
name: 'service_skills',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{ name: 'serviceId', type: 'uuid' },
{ name: 'title', type: 'varchar', length: '200' },
{ name: 'renewalDays', type: 'int' },
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
],
}),
true,
);
await queryRunner.createForeignKey(
'service_skills',
new TableForeignKey({
columnNames: ['serviceId'],
referencedTableName: 'services',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('service_skills', true);
await queryRunner.dropTable('services', true);
}
}
@@ -0,0 +1,12 @@
import { IsInt, IsNotEmpty, IsString, MaxLength, Min } from 'class-validator';
export class CreateServiceSkillDto {
@IsString()
@IsNotEmpty()
@MaxLength(200)
title: string;
@IsInt()
@Min(1)
renewalDays: number;
}
+20
View File
@@ -0,0 +1,20 @@
import { IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
export class CreateServiceDto {
@IsString()
@IsNotEmpty()
@MaxLength(200)
title: string;
@IsOptional()
@IsString()
@IsUrl()
@MaxLength(500)
iconUrl?: string;
@IsOptional()
@IsString()
@IsUrl()
@MaxLength(500)
coverUrl?: string;
}
@@ -0,0 +1,13 @@
import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class UpdateServiceSkillDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsInt()
@Min(1)
renewalDays?: number;
}
+20
View File
@@ -0,0 +1,20 @@
import { IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
export class UpdateServiceDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
@IsUrl()
@MaxLength(500)
iconUrl?: string;
@IsOptional()
@IsString()
@IsUrl()
@MaxLength(500)
coverUrl?: string;
}
@@ -0,0 +1,35 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Service } from './service.entity';
@Entity('service_skills')
export class ServiceSkill {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
serviceId: string;
@ManyToOne(() => Service, (service) => service.skills, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'serviceId' })
service: Service;
@Column({ length: 200 })
title: string;
@Column({ type: 'int' })
renewalDays: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
+33
View File
@@ -0,0 +1,33 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { ServiceSkill } from './service-skill.entity';
@Entity('services')
export class Service {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 200 })
title: string;
@Column({ type: 'varchar', length: 500, nullable: true })
iconUrl: string | null;
@Column({ type: 'varchar', length: 500, nullable: true })
coverUrl: string | null;
@OneToMany(() => ServiceSkill, (skill) => skill.service)
skills: ServiceSkill[];
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
+123
View File
@@ -0,0 +1,123 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { UserRole } from '../users/enums/user-role.enum';
import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
import { CreateServiceDto } from './dto/create-service.dto';
import { UpdateServiceSkillDto } from './dto/update-service-skill.dto';
import { UpdateServiceDto } from './dto/update-service.dto';
import { ServicesService } from './services.service';
@Controller('services')
@UseGuards(RolesGuard)
export class ServicesController {
constructor(private readonly servicesService: ServicesService) {}
@Roles(UserRole.ADMIN)
@Post()
create(
@Body() createServiceDto: CreateServiceDto,
@CurrentUser() user: AuthUser,
) {
return this.servicesService.create(createServiceDto, user);
}
@Public()
@Get()
findAll() {
return this.servicesService.findAll();
}
@Public()
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.servicesService.findOne(id);
}
@Roles(UserRole.ADMIN)
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateServiceDto: UpdateServiceDto,
@CurrentUser() user: AuthUser,
) {
return this.servicesService.update(id, updateServiceDto, user);
}
@Roles(UserRole.ADMIN)
@Delete(':id')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUser,
) {
return this.servicesService.remove(id, user);
}
@Roles(UserRole.ADMIN)
@Post(':serviceId/skills')
createSkill(
@Param('serviceId', ParseUUIDPipe) serviceId: string,
@Body() createServiceSkillDto: CreateServiceSkillDto,
@CurrentUser() user: AuthUser,
) {
return this.servicesService.createSkill(
serviceId,
createServiceSkillDto,
user,
);
}
@Public()
@Get(':serviceId/skills')
findSkills(@Param('serviceId', ParseUUIDPipe) serviceId: string) {
return this.servicesService.findSkills(serviceId);
}
@Public()
@Get(':serviceId/skills/:skillId')
findSkill(
@Param('serviceId', ParseUUIDPipe) serviceId: string,
@Param('skillId', ParseUUIDPipe) skillId: string,
) {
return this.servicesService.findSkill(serviceId, skillId);
}
@Roles(UserRole.ADMIN)
@Patch(':serviceId/skills/:skillId')
updateSkill(
@Param('serviceId', ParseUUIDPipe) serviceId: string,
@Param('skillId', ParseUUIDPipe) skillId: string,
@Body() updateServiceSkillDto: UpdateServiceSkillDto,
@CurrentUser() user: AuthUser,
) {
return this.servicesService.updateSkill(
serviceId,
skillId,
updateServiceSkillDto,
user,
);
}
@Roles(UserRole.ADMIN)
@Delete(':serviceId/skills/:skillId')
removeSkill(
@Param('serviceId', ParseUUIDPipe) serviceId: string,
@Param('skillId', ParseUUIDPipe) skillId: string,
@CurrentUser() user: AuthUser,
) {
return this.servicesService.removeSkill(serviceId, skillId, user);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module';
import { ServiceSkill } from './entities/service-skill.entity';
import { Service } from './entities/service.entity';
import { ServicesController } from './services.controller';
import { ServicesService } from './services.service';
@Module({
imports: [
TypeOrmModule.forFeature([Service, ServiceSkill]),
AuthorizationModule,
AuthGuardsModule,
],
controllers: [ServicesController],
providers: [ServicesService],
exports: [ServicesService],
})
export class ServicesModule {}
+140
View File
@@ -0,0 +1,140 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ServicePolicy } from '../authorization/policies/service.policy';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { CreateServiceSkillDto } from './dto/create-service-skill.dto';
import { CreateServiceDto } from './dto/create-service.dto';
import { UpdateServiceSkillDto } from './dto/update-service-skill.dto';
import { UpdateServiceDto } from './dto/update-service.dto';
import { ServiceSkill } from './entities/service-skill.entity';
import { Service } from './entities/service.entity';
@Injectable()
export class ServicesService {
constructor(
@InjectRepository(Service)
private readonly servicesRepository: Repository<Service>,
@InjectRepository(ServiceSkill)
private readonly serviceSkillsRepository: Repository<ServiceSkill>,
private readonly servicePolicy: ServicePolicy,
) {}
create(createServiceDto: CreateServiceDto, user: AuthUser): Promise<Service> {
this.ensureCanManage(user);
const service = this.servicesRepository.create(createServiceDto);
return this.servicesRepository.save(service).then((saved) => this.findOne(saved.id));
}
findAll(): Promise<Service[]> {
return this.servicesRepository.find({
relations: { skills: true },
order: { createdAt: 'ASC', skills: { createdAt: 'ASC' } },
});
}
async findOne(id: string): Promise<Service> {
const service = await this.servicesRepository.findOne({
where: { id },
relations: { skills: true },
order: { skills: { createdAt: 'ASC' } },
});
if (!service) {
throw new NotFoundException(`Service #${id} not found`);
}
return service;
}
async update(
id: string,
updateServiceDto: UpdateServiceDto,
user: AuthUser,
): Promise<Service> {
this.ensureCanManage(user);
const service = await this.findOne(id);
Object.assign(service, updateServiceDto);
await this.servicesRepository.save(service);
return this.findOne(id);
}
async remove(id: string, user: AuthUser): Promise<void> {
this.ensureCanManage(user);
const service = await this.findOne(id);
await this.servicesRepository.remove(service);
}
async createSkill(
serviceId: string,
createServiceSkillDto: CreateServiceSkillDto,
user: AuthUser,
): Promise<ServiceSkill> {
this.ensureCanManage(user);
await this.findOne(serviceId);
const skill = this.serviceSkillsRepository.create({
...createServiceSkillDto,
serviceId,
});
const saved = await this.serviceSkillsRepository.save(skill);
return this.findSkill(serviceId, saved.id);
}
async findSkills(serviceId: string): Promise<ServiceSkill[]> {
await this.ensureServiceExists(serviceId);
return this.serviceSkillsRepository.find({
where: { serviceId },
order: { createdAt: 'ASC' },
});
}
async findSkill(serviceId: string, skillId: string): Promise<ServiceSkill> {
const skill = await this.serviceSkillsRepository.findOne({
where: { id: skillId, serviceId },
});
if (!skill) {
throw new NotFoundException(
`Service skill #${skillId} not found for service #${serviceId}`,
);
}
return skill;
}
async updateSkill(
serviceId: string,
skillId: string,
updateServiceSkillDto: UpdateServiceSkillDto,
user: AuthUser,
): Promise<ServiceSkill> {
this.ensureCanManage(user);
const skill = await this.findSkill(serviceId, skillId);
Object.assign(skill, updateServiceSkillDto);
await this.serviceSkillsRepository.save(skill);
return this.findSkill(serviceId, skillId);
}
async removeSkill(
serviceId: string,
skillId: string,
user: AuthUser,
): Promise<void> {
this.ensureCanManage(user);
const skill = await this.findSkill(serviceId, skillId);
await this.serviceSkillsRepository.remove(skill);
}
private ensureCanManage(user: AuthUser): void {
if (!this.servicePolicy.canManage(user)) {
throw new ForbiddenException();
}
}
private async ensureServiceExists(serviceId: string): Promise<void> {
const exists = await this.servicesRepository.existsBy({ id: serviceId });
if (!exists) {
throw new NotFoundException(`Service #${serviceId} not found`);
}
}
}