reserves module
This commit is contained in:
@@ -5,6 +5,7 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { CustomersModule } from './customers/customers.module';
|
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 { ReservesModule } from './reserves/reserves.module';
|
||||||
import { SalonsModule } from './salons/salons.module';
|
import { SalonsModule } from './salons/salons.module';
|
||||||
import { ServicesModule } from './services/services.module';
|
import { ServicesModule } from './services/services.module';
|
||||||
import { StylistsModule } from './stylists/stylists.module';
|
import { StylistsModule } from './stylists/stylists.module';
|
||||||
@@ -23,6 +24,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
StylistsModule,
|
StylistsModule,
|
||||||
ServicesModule,
|
ServicesModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
|
ReservesModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AuthorizationService } from './authorization.service';
|
|||||||
import { Permission } from './entities/permission.entity';
|
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 { ReservePolicy } from './policies/reserve.policy';
|
||||||
import { SalonPolicy } from './policies/salon.policy';
|
import { SalonPolicy } from './policies/salon.policy';
|
||||||
import { ServicePolicy } from './policies/service.policy';
|
import { ServicePolicy } from './policies/service.policy';
|
||||||
import { StylistPolicy } from './policies/stylist.policy';
|
import { StylistPolicy } from './policies/stylist.policy';
|
||||||
@@ -16,6 +17,7 @@ import { StylistPolicy } from './policies/stylist.policy';
|
|||||||
ServicePolicy,
|
ServicePolicy,
|
||||||
CustomerPolicy,
|
CustomerPolicy,
|
||||||
StylistPolicy,
|
StylistPolicy,
|
||||||
|
ReservePolicy,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthorizationService,
|
AuthorizationService,
|
||||||
@@ -23,6 +25,7 @@ import { StylistPolicy } from './policies/stylist.policy';
|
|||||||
ServicePolicy,
|
ServicePolicy,
|
||||||
CustomerPolicy,
|
CustomerPolicy,
|
||||||
StylistPolicy,
|
StylistPolicy,
|
||||||
|
ReservePolicy,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AuthorizationModule {}
|
export class AuthorizationModule {}
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ export enum PermissionCode {
|
|||||||
SERVICES_READ = 'services:read',
|
SERVICES_READ = 'services:read',
|
||||||
SERVICES_WRITE = 'services:write',
|
SERVICES_WRITE = 'services:write',
|
||||||
SERVICES_DELETE = 'services:delete',
|
SERVICES_DELETE = 'services:delete',
|
||||||
|
RESERVES_READ = 'reserves:read',
|
||||||
|
RESERVES_WRITE = 'reserves:write',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthUser } from '../../auth/interfaces/auth-user.interface';
|
||||||
|
import { UserRole } from '../../users/enums/user-role.enum';
|
||||||
|
import { Reserve } from '../../reserves/entities/reserve.entity';
|
||||||
|
import { AuthorizationService } from '../authorization.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReservePolicy {
|
||||||
|
constructor(private readonly authorizationService: AuthorizationService) {}
|
||||||
|
|
||||||
|
canCreate(user: AuthUser, reserve: Pick<Reserve, 'salon' | 'customer'>): boolean {
|
||||||
|
if (this.authorizationService.isAdmin(user)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (user.role === UserRole.SALON) {
|
||||||
|
return reserve.salon.ownerId === user.id;
|
||||||
|
}
|
||||||
|
if (user.role === UserRole.CUSTOMER) {
|
||||||
|
return reserve.customer.userId === user.id;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
canRead(user: AuthUser, reserve: Reserve): boolean {
|
||||||
|
if (this.authorizationService.isAdmin(user)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (user.role === UserRole.SALON) {
|
||||||
|
return reserve.salon.ownerId === user.id;
|
||||||
|
}
|
||||||
|
if (user.role === UserRole.CUSTOMER) {
|
||||||
|
return reserve.customer.userId === user.id;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
canUpdate(user: AuthUser, reserve: Reserve): boolean {
|
||||||
|
return this.canRead(user, reserve);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import {
|
||||||
|
MigrationInterface,
|
||||||
|
QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export class AddReserves1719900000000 implements MigrationInterface {
|
||||||
|
name = 'AddReserves1719900000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TYPE "reserves_status_enum" AS ENUM(
|
||||||
|
'pending',
|
||||||
|
'confirmed',
|
||||||
|
'completed',
|
||||||
|
'cancelled',
|
||||||
|
'no_show'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'reserves',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'uuid',
|
||||||
|
isPrimary: true,
|
||||||
|
generationStrategy: 'uuid',
|
||||||
|
default: 'gen_random_uuid()',
|
||||||
|
},
|
||||||
|
{ name: 'salonId', type: 'uuid' },
|
||||||
|
{ name: 'customerId', type: 'uuid' },
|
||||||
|
{ name: 'stylistId', type: 'uuid' },
|
||||||
|
{ name: 'appointmentDate', type: 'date' },
|
||||||
|
{ name: 'startTime', type: 'time' },
|
||||||
|
{ name: 'endTime', type: 'time' },
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'reserves_status_enum',
|
||||||
|
default: `'pending'`,
|
||||||
|
},
|
||||||
|
{ name: 'totalAmount', type: 'decimal', precision: 12, scale: 2 },
|
||||||
|
{ name: 'depositAmount', type: 'decimal', precision: 12, scale: 2 },
|
||||||
|
{ name: 'remainingAmount', type: 'decimal', precision: 12, scale: 2 },
|
||||||
|
{ name: 'cancellationReason', type: 'text', isNullable: true },
|
||||||
|
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
|
||||||
|
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: 'reserve_items',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'uuid',
|
||||||
|
isPrimary: true,
|
||||||
|
generationStrategy: 'uuid',
|
||||||
|
default: 'gen_random_uuid()',
|
||||||
|
},
|
||||||
|
{ name: 'reserveId', type: 'uuid' },
|
||||||
|
{ name: 'serviceId', type: 'uuid' },
|
||||||
|
{ name: 'serviceSkillId', type: 'uuid' },
|
||||||
|
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
|
||||||
|
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'reserves',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['salonId'],
|
||||||
|
referencedTableName: 'salons',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'reserves',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['customerId'],
|
||||||
|
referencedTableName: 'customers',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'reserves',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['stylistId'],
|
||||||
|
referencedTableName: 'stylists',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'reserve_items',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['reserveId'],
|
||||||
|
referencedTableName: 'reserves',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'reserve_items',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['serviceId'],
|
||||||
|
referencedTableName: 'services',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'RESTRICT',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createForeignKey(
|
||||||
|
'reserve_items',
|
||||||
|
new TableForeignKey({
|
||||||
|
columnNames: ['serviceSkillId'],
|
||||||
|
referencedTableName: 'service_skills',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
onDelete: 'RESTRICT',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'reserves',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'IDX_reserves_salonId_appointmentDate',
|
||||||
|
columnNames: ['salonId', 'appointmentDate'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'reserves',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'IDX_reserves_stylistId_appointmentDate',
|
||||||
|
columnNames: ['stylistId', 'appointmentDate'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'reserves',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'IDX_reserves_customerId',
|
||||||
|
columnNames: ['customerId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'reserves',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'IDX_reserves_status',
|
||||||
|
columnNames: ['status'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex(
|
||||||
|
'reserve_items',
|
||||||
|
new TableIndex({
|
||||||
|
name: 'IDX_reserve_items_reserveId',
|
||||||
|
columnNames: ['reserveId'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('reserve_items', true);
|
||||||
|
await queryRunner.dropTable('reserves', true);
|
||||||
|
await queryRunner.query(`DROP TYPE "reserves_status_enum"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateReserveItemDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
serviceId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
serviceSkillId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { CreateReserveItemDto } from './create-reserve-item.dto';
|
||||||
|
|
||||||
|
export class CreateReserveDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
salonId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
customerId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
stylistId: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
appointmentDate: string;
|
||||||
|
|
||||||
|
@Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/)
|
||||||
|
startTime: string;
|
||||||
|
|
||||||
|
@Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/)
|
||||||
|
endTime: string;
|
||||||
|
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
totalAmount: number;
|
||||||
|
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
depositAmount: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateReserveItemDto)
|
||||||
|
items: CreateReserveItemDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsDateString, IsEnum, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
import { ReserveStatus } from '../enums/reserve-status.enum';
|
||||||
|
|
||||||
|
export class FindReservesQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
salonId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
customerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
stylistId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ReserveStatus)
|
||||||
|
status?: ReserveStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
appointmentDate?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ReserveStatus } from '../enums/reserve-status.enum';
|
||||||
|
|
||||||
|
export class UpdateReserveDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
appointmentDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/)
|
||||||
|
startTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/)
|
||||||
|
endTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ReserveStatus)
|
||||||
|
status?: ReserveStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
totalAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
depositAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(1000)
|
||||||
|
cancellationReason?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ServiceSkill } from '../../services/entities/service-skill.entity';
|
||||||
|
import { Service } from '../../services/entities/service.entity';
|
||||||
|
import type { Reserve } from './reserve.entity.js';
|
||||||
|
|
||||||
|
@Entity('reserve_items')
|
||||||
|
@Index(['reserveId'])
|
||||||
|
export class ReserveItem {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
reserveId: string;
|
||||||
|
|
||||||
|
@ManyToOne('Reserve', 'items', { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'reserveId' })
|
||||||
|
reserve: Reserve;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
serviceId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Service, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'serviceId' })
|
||||||
|
service: Service;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
serviceSkillId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => ServiceSkill, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'serviceSkillId' })
|
||||||
|
serviceSkill: ServiceSkill;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
OneToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Customer } from '../../customers/entities/customer.entity';
|
||||||
|
import { Salon } from '../../salons/entities/salon.entity';
|
||||||
|
import { Stylist } from '../../stylists/entities/stylist.entity';
|
||||||
|
import { ReserveStatus } from '../enums/reserve-status.enum';
|
||||||
|
import type { ReserveItem } from './reserve-item.entity.js';
|
||||||
|
|
||||||
|
const decimalTransformer = {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string) => parseFloat(value),
|
||||||
|
};
|
||||||
|
|
||||||
|
@Entity('reserves')
|
||||||
|
@Index(['salonId', 'appointmentDate'])
|
||||||
|
@Index(['stylistId', 'appointmentDate'])
|
||||||
|
@Index(['customerId'])
|
||||||
|
@Index(['status'])
|
||||||
|
export class Reserve {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
salonId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Salon, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'salonId' })
|
||||||
|
salon: Salon;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
customerId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Customer, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'customerId' })
|
||||||
|
customer: Customer;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
stylistId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Stylist, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'stylistId' })
|
||||||
|
stylist: Stylist;
|
||||||
|
|
||||||
|
@Column({ type: 'date' })
|
||||||
|
appointmentDate: string;
|
||||||
|
|
||||||
|
@Column({ type: 'time' })
|
||||||
|
startTime: string;
|
||||||
|
|
||||||
|
@Column({ type: 'time' })
|
||||||
|
endTime: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ReserveStatus,
|
||||||
|
enumName: 'reserves_status_enum',
|
||||||
|
default: ReserveStatus.PENDING,
|
||||||
|
})
|
||||||
|
status: ReserveStatus;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 2,
|
||||||
|
transformer: decimalTransformer,
|
||||||
|
})
|
||||||
|
totalAmount: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 2,
|
||||||
|
transformer: decimalTransformer,
|
||||||
|
})
|
||||||
|
depositAmount: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 2,
|
||||||
|
transformer: decimalTransformer,
|
||||||
|
})
|
||||||
|
remainingAmount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
cancellationReason: string | null;
|
||||||
|
|
||||||
|
@OneToMany('ReserveItem', 'reserve', { cascade: true })
|
||||||
|
items: ReserveItem[];
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export enum ReserveStatus {
|
||||||
|
PENDING = 'pending',
|
||||||
|
CONFIRMED = 'confirmed',
|
||||||
|
COMPLETED = 'completed',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
NO_SHOW = 'no_show',
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.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 { CreateReserveDto } from './dto/create-reserve.dto';
|
||||||
|
import { FindReservesQueryDto } from './dto/find-reserves-query.dto';
|
||||||
|
import { UpdateReserveDto } from './dto/update-reserve.dto';
|
||||||
|
import { ReservesService } from './reserves.service';
|
||||||
|
|
||||||
|
@Controller('reserves')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
export class ReservesController {
|
||||||
|
constructor(private readonly reservesService: ReservesService) {}
|
||||||
|
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER)
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@Body() createReserveDto: CreateReserveDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.reservesService.create(createReserveDto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER)
|
||||||
|
@Get()
|
||||||
|
findAll(
|
||||||
|
@Query() query: FindReservesQueryDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.reservesService.findAll(user, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER)
|
||||||
|
@Get(':id')
|
||||||
|
findOne(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.reservesService.findOne(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER)
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() updateReserveDto: UpdateReserveDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.reservesService.update(id, updateReserveDto, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AuthGuardsModule } from '../auth/auth-guards.module';
|
||||||
|
import { AuthorizationModule } from '../authorization/authorization.module';
|
||||||
|
import { CustomersModule } from '../customers/customers.module';
|
||||||
|
import { SalonsModule } from '../salons/salons.module';
|
||||||
|
import { ServiceSkill } from '../services/entities/service-skill.entity';
|
||||||
|
import { Service } from '../services/entities/service.entity';
|
||||||
|
import { StylistsModule } from '../stylists/stylists.module';
|
||||||
|
import { ReserveItem } from './entities/reserve-item.entity';
|
||||||
|
import { Reserve } from './entities/reserve.entity';
|
||||||
|
import { ReservesController } from './reserves.controller';
|
||||||
|
import { ReservesService } from './reserves.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Reserve, ReserveItem, Service, ServiceSkill]),
|
||||||
|
SalonsModule,
|
||||||
|
CustomersModule,
|
||||||
|
StylistsModule,
|
||||||
|
AuthorizationModule,
|
||||||
|
AuthGuardsModule,
|
||||||
|
],
|
||||||
|
controllers: [ReservesController],
|
||||||
|
providers: [ReservesService],
|
||||||
|
exports: [ReservesService],
|
||||||
|
})
|
||||||
|
export class ReservesModule {}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuthorizationService } from '../authorization/authorization.service';
|
||||||
|
import { ReservePolicy } from '../authorization/policies/reserve.policy';
|
||||||
|
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
||||||
|
import { CustomersService } from '../customers/customers.service';
|
||||||
|
import { SalonsService } from '../salons/salons.service';
|
||||||
|
import { ServiceSkill } from '../services/entities/service-skill.entity';
|
||||||
|
import { Service } from '../services/entities/service.entity';
|
||||||
|
import { StylistsService } from '../stylists/stylists.service';
|
||||||
|
import { UserRole } from '../users/enums/user-role.enum';
|
||||||
|
import { CreateReserveDto } from './dto/create-reserve.dto';
|
||||||
|
import { FindReservesQueryDto } from './dto/find-reserves-query.dto';
|
||||||
|
import { UpdateReserveDto } from './dto/update-reserve.dto';
|
||||||
|
import { ReserveItem } from './entities/reserve-item.entity';
|
||||||
|
import { Reserve } from './entities/reserve.entity';
|
||||||
|
import { ReserveStatus } from './enums/reserve-status.enum';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReservesService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Reserve)
|
||||||
|
private readonly reservesRepository: Repository<Reserve>,
|
||||||
|
@InjectRepository(ReserveItem)
|
||||||
|
private readonly reserveItemsRepository: Repository<ReserveItem>,
|
||||||
|
@InjectRepository(Service)
|
||||||
|
private readonly servicesRepository: Repository<Service>,
|
||||||
|
@InjectRepository(ServiceSkill)
|
||||||
|
private readonly serviceSkillsRepository: Repository<ServiceSkill>,
|
||||||
|
private readonly salonsService: SalonsService,
|
||||||
|
private readonly customersService: CustomersService,
|
||||||
|
private readonly stylistsService: StylistsService,
|
||||||
|
private readonly authorizationService: AuthorizationService,
|
||||||
|
private readonly reservePolicy: ReservePolicy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
createReserveDto: CreateReserveDto,
|
||||||
|
user: AuthUser,
|
||||||
|
): Promise<Reserve> {
|
||||||
|
const salon = await this.salonsService.findOne(createReserveDto.salonId);
|
||||||
|
const customer = await this.customersService.findOne(
|
||||||
|
createReserveDto.customerId,
|
||||||
|
);
|
||||||
|
const stylist = await this.stylistsService.findOne(
|
||||||
|
createReserveDto.stylistId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stylist.salonId !== salon.id) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Stylist does not belong to the selected salon',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.reservePolicy.canCreate(user, { salon, customer })) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validateTimeRange(createReserveDto.startTime, createReserveDto.endTime);
|
||||||
|
this.validateAmounts(
|
||||||
|
createReserveDto.totalAmount,
|
||||||
|
createReserveDto.depositAmount,
|
||||||
|
);
|
||||||
|
await this.validateItems(createReserveDto.items);
|
||||||
|
|
||||||
|
const reserve = this.reservesRepository.create({
|
||||||
|
salonId: createReserveDto.salonId,
|
||||||
|
customerId: createReserveDto.customerId,
|
||||||
|
stylistId: createReserveDto.stylistId,
|
||||||
|
appointmentDate: createReserveDto.appointmentDate,
|
||||||
|
startTime: this.normalizeTime(createReserveDto.startTime),
|
||||||
|
endTime: this.normalizeTime(createReserveDto.endTime),
|
||||||
|
totalAmount: createReserveDto.totalAmount,
|
||||||
|
depositAmount: createReserveDto.depositAmount,
|
||||||
|
remainingAmount:
|
||||||
|
createReserveDto.totalAmount - createReserveDto.depositAmount,
|
||||||
|
items: createReserveDto.items.map((item) =>
|
||||||
|
this.reserveItemsRepository.create(item),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.reservesRepository.save(reserve);
|
||||||
|
return this.findOne(saved.id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(
|
||||||
|
user: AuthUser,
|
||||||
|
query: FindReservesQueryDto,
|
||||||
|
): Promise<Reserve[]> {
|
||||||
|
const qb = this.reservesRepository
|
||||||
|
.createQueryBuilder('reserve')
|
||||||
|
.leftJoinAndSelect('reserve.salon', 'salon')
|
||||||
|
.leftJoinAndSelect('reserve.customer', 'customer')
|
||||||
|
.leftJoinAndSelect('customer.user', 'customerUser')
|
||||||
|
.leftJoinAndSelect('reserve.stylist', 'stylist')
|
||||||
|
.leftJoinAndSelect('stylist.user', 'stylistUser')
|
||||||
|
.leftJoinAndSelect('reserve.items', 'items')
|
||||||
|
.leftJoinAndSelect('items.service', 'service')
|
||||||
|
.leftJoinAndSelect('items.serviceSkill', 'serviceSkill')
|
||||||
|
.orderBy('reserve.appointmentDate', 'ASC')
|
||||||
|
.addOrderBy('reserve.startTime', 'ASC');
|
||||||
|
|
||||||
|
if (query.salonId) {
|
||||||
|
qb.andWhere('reserve.salonId = :salonId', { salonId: query.salonId });
|
||||||
|
}
|
||||||
|
if (query.customerId) {
|
||||||
|
qb.andWhere('reserve.customerId = :customerId', {
|
||||||
|
customerId: query.customerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.stylistId) {
|
||||||
|
qb.andWhere('reserve.stylistId = :stylistId', {
|
||||||
|
stylistId: query.stylistId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.status) {
|
||||||
|
qb.andWhere('reserve.status = :status', { status: query.status });
|
||||||
|
}
|
||||||
|
if (query.appointmentDate) {
|
||||||
|
qb.andWhere('reserve.appointmentDate = :appointmentDate', {
|
||||||
|
appointmentDate: query.appointmentDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.authorizationService.isAdmin(user)) {
|
||||||
|
if (user.role === UserRole.SALON) {
|
||||||
|
qb.andWhere('salon.ownerId = :ownerId', { ownerId: user.id });
|
||||||
|
} else if (user.role === UserRole.CUSTOMER) {
|
||||||
|
qb.andWhere('customer.userId = :userId', { userId: user.id });
|
||||||
|
} else {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string, user: AuthUser): Promise<Reserve> {
|
||||||
|
const reserve = await this.reservesRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: this.defaultRelations,
|
||||||
|
});
|
||||||
|
if (!reserve) {
|
||||||
|
throw new NotFoundException(`Reserve #${id} not found`);
|
||||||
|
}
|
||||||
|
if (!this.reservePolicy.canRead(user, reserve)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
return reserve;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
updateReserveDto: UpdateReserveDto,
|
||||||
|
user: AuthUser,
|
||||||
|
): Promise<Reserve> {
|
||||||
|
const reserve = await this.findOne(id, user);
|
||||||
|
if (!this.reservePolicy.canUpdate(user, reserve)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
appointmentDate,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
status,
|
||||||
|
totalAmount,
|
||||||
|
depositAmount,
|
||||||
|
cancellationReason,
|
||||||
|
} = updateReserveDto;
|
||||||
|
|
||||||
|
if (appointmentDate !== undefined) {
|
||||||
|
reserve.appointmentDate = appointmentDate;
|
||||||
|
}
|
||||||
|
if (startTime !== undefined) {
|
||||||
|
reserve.startTime = this.normalizeTime(startTime);
|
||||||
|
}
|
||||||
|
if (endTime !== undefined) {
|
||||||
|
reserve.endTime = this.normalizeTime(endTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validateTimeRange(reserve.startTime, reserve.endTime);
|
||||||
|
|
||||||
|
if (totalAmount !== undefined) {
|
||||||
|
reserve.totalAmount = totalAmount;
|
||||||
|
}
|
||||||
|
if (depositAmount !== undefined) {
|
||||||
|
reserve.depositAmount = depositAmount;
|
||||||
|
}
|
||||||
|
if (totalAmount !== undefined || depositAmount !== undefined) {
|
||||||
|
this.validateAmounts(reserve.totalAmount, reserve.depositAmount);
|
||||||
|
reserve.remainingAmount = reserve.totalAmount - reserve.depositAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== undefined) {
|
||||||
|
reserve.status = status;
|
||||||
|
if (status === ReserveStatus.CANCELLED) {
|
||||||
|
reserve.cancellationReason = cancellationReason ?? null;
|
||||||
|
} else if (cancellationReason !== undefined) {
|
||||||
|
reserve.cancellationReason = cancellationReason;
|
||||||
|
}
|
||||||
|
} else if (cancellationReason !== undefined) {
|
||||||
|
reserve.cancellationReason = cancellationReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.reservesRepository.save(reserve);
|
||||||
|
return this.findOne(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly defaultRelations = {
|
||||||
|
salon: true,
|
||||||
|
customer: { user: true },
|
||||||
|
stylist: { user: true },
|
||||||
|
items: { service: true, serviceSkill: true },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
private validateTimeRange(startTime: string, endTime: string): void {
|
||||||
|
if (startTime >= endTime) {
|
||||||
|
throw new BadRequestException('endTime must be after startTime');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateAmounts(totalAmount: number, depositAmount: number): void {
|
||||||
|
if (depositAmount > totalAmount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'depositAmount cannot exceed totalAmount',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeTime(time: string): string {
|
||||||
|
return time.length === 5 ? `${time}:00` : time;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateItems(
|
||||||
|
items: CreateReserveDto['items'],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const item of items) {
|
||||||
|
const serviceExists = await this.servicesRepository.existsBy({
|
||||||
|
id: item.serviceId,
|
||||||
|
});
|
||||||
|
if (!serviceExists) {
|
||||||
|
throw new BadRequestException(`Service #${item.serviceId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skill = await this.serviceSkillsRepository.findOne({
|
||||||
|
where: { id: item.serviceSkillId, serviceId: item.serviceId },
|
||||||
|
});
|
||||||
|
if (!skill) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Service skill #${item.serviceSkillId} not found for service #${item.serviceId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user