get subscription in admin
This commit is contained in:
@@ -102,7 +102,9 @@ export class AuthService {
|
|||||||
this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') ?? '7d';
|
this.configService.get<string>('JWT_REFRESH_EXPIRES_IN') ?? '7d';
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestOtp(mobile: string): Promise<{ message: string; code?: string }> {
|
async requestOtp(
|
||||||
|
mobile: string,
|
||||||
|
): Promise<{ message: string; code?: string }> {
|
||||||
const code = await this.sendOtp(mobile, this.otpExpiresInMinutes);
|
const code = await this.sendOtp(mobile, this.otpExpiresInMinutes);
|
||||||
return this.buildOtpRequestResponse(code);
|
return this.buildOtpRequestResponse(code);
|
||||||
}
|
}
|
||||||
@@ -114,9 +116,7 @@ export class AuthService {
|
|||||||
return this.requestOtp(mobile);
|
return this.requestOtp(mobile);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkSalonMobile(
|
async checkSalonMobile(mobile: string): Promise<{ isRegistered: boolean }> {
|
||||||
mobile: string,
|
|
||||||
): Promise<{ isRegistered: boolean }> {
|
|
||||||
await this.ensureSalonMobileCanProceed(mobile);
|
await this.ensureSalonMobileCanProceed(mobile);
|
||||||
const isRegistered = await this.isSalonOwnerRegistered(mobile);
|
const isRegistered = await this.isSalonOwnerRegistered(mobile);
|
||||||
return { isRegistered };
|
return { isRegistered };
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export class RequestOtpResponseDto {
|
|||||||
message: string;
|
message: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Only returned in non-production environments when SMS is unavailable',
|
description:
|
||||||
|
'Only returned in non-production environments when SMS is unavailable',
|
||||||
example: '12345',
|
example: '12345',
|
||||||
})
|
})
|
||||||
code?: string;
|
code?: string;
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export class RequestSalonOtpResponseDto {
|
|||||||
expiresInSeconds: number;
|
expiresInSeconds: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Only returned in non-production environments when SMS is unavailable',
|
description:
|
||||||
|
'Only returned in non-production environments when SMS is unavailable',
|
||||||
example: '12345',
|
example: '12345',
|
||||||
})
|
})
|
||||||
code?: string;
|
code?: string;
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { IsOptional, IsString, IsUUID, Length, Matches } from 'class-validator';
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
IsUUID,
|
|
||||||
Length,
|
|
||||||
Matches,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class VerifySalonOtpDto {
|
export class VerifySalonOtpDto {
|
||||||
@Matches(/^09\d{9}$/)
|
@Matches(/^09\d{9}$/)
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import { AuthorizationService } from '../authorization.service';
|
|||||||
export class ReservePolicy {
|
export class ReservePolicy {
|
||||||
constructor(private readonly authorizationService: AuthorizationService) {}
|
constructor(private readonly authorizationService: AuthorizationService) {}
|
||||||
|
|
||||||
canCreate(user: AuthUser, reserve: Pick<Reserve, 'salon' | 'customer'>): boolean {
|
canCreate(
|
||||||
|
user: AuthUser,
|
||||||
|
reserve: Pick<Reserve, 'salon' | 'customer'>,
|
||||||
|
): boolean {
|
||||||
if (this.authorizationService.isAdmin(user)) {
|
if (this.authorizationService.isAdmin(user)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ export class StylistPolicy {
|
|||||||
if (this.authorizationService.isAdmin(user)) {
|
if (this.authorizationService.isAdmin(user)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return (
|
return user.role === UserRole.SALON && stylist.salon.ownerId === user.id;
|
||||||
user.role === UserRole.SALON && stylist.salon.ownerId === user.id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canDelete(user: AuthUser, stylist: Stylist): boolean {
|
canDelete(user: AuthUser, stylist: Stylist): boolean {
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
|||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
if (SWAGGER_PATH_PREFIXES.some((prefix) => request.url.startsWith(prefix))) {
|
if (
|
||||||
|
SWAGGER_PATH_PREFIXES.some((prefix) => request.url.startsWith(prefix))
|
||||||
|
) {
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
response.status(exception.getStatus()).json(exception.getResponse());
|
response.status(exception.getStatus()).json(exception.getResponse());
|
||||||
return;
|
return;
|
||||||
@@ -67,8 +69,7 @@ export class ApiExceptionFilter implements ExceptionFilter {
|
|||||||
errors = responseMessage.filter(
|
errors = responseMessage.filter(
|
||||||
(item): item is string => typeof item === 'string',
|
(item): item is string => typeof item === 'string',
|
||||||
);
|
);
|
||||||
message =
|
message = errors.length > 0 ? 'Validation failed' : 'Bad Request';
|
||||||
errors.length > 0 ? 'Validation failed' : 'Bad Request';
|
|
||||||
} else if (typeof responseMessage === 'string') {
|
} else if (typeof responseMessage === 'string') {
|
||||||
message = responseMessage;
|
message = responseMessage;
|
||||||
} else if (typeof responseBody.error === 'string') {
|
} else if (typeof responseBody.error === 'string') {
|
||||||
|
|||||||
@@ -33,29 +33,30 @@ function isPaginatedResult<T>(value: unknown): value is PaginatedResult<T> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMessageOnlyResponse(
|
function isMessageOnlyResponse(value: unknown): value is { message: string } {
|
||||||
value: unknown,
|
|
||||||
): value is { message: string } {
|
|
||||||
return (
|
return (
|
||||||
typeof value === 'object' &&
|
typeof value === 'object' &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
'message' in value &&
|
'message' in value &&
|
||||||
typeof (value as { message: unknown }).message === 'string' &&
|
typeof value.message === 'string' &&
|
||||||
Object.keys(value).length === 1
|
Object.keys(value).length === 1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TransformResponseInterceptor<T>
|
export class TransformResponseInterceptor<T> implements NestInterceptor<
|
||||||
implements NestInterceptor<T, ApiSuccessResponse<T>>
|
T,
|
||||||
{
|
ApiSuccessResponse<T>
|
||||||
|
> {
|
||||||
intercept(
|
intercept(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
next: CallHandler<T>,
|
next: CallHandler<T>,
|
||||||
): Observable<ApiSuccessResponse<T>> {
|
): Observable<ApiSuccessResponse<T>> {
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
|
||||||
if (SWAGGER_PATH_PREFIXES.some((prefix) => request.url.startsWith(prefix))) {
|
if (
|
||||||
|
SWAGGER_PATH_PREFIXES.some((prefix) => request.url.startsWith(prefix))
|
||||||
|
) {
|
||||||
return next.handle() as Observable<ApiSuccessResponse<T>>;
|
return next.handle() as Observable<ApiSuccessResponse<T>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -349,8 +349,7 @@ export class CustomersService {
|
|||||||
!search ||
|
!search ||
|
||||||
fullName.includes(search) ||
|
fullName.includes(search) ||
|
||||||
normalizedMobile.includes(search);
|
normalizedMobile.includes(search);
|
||||||
const matchesStatus =
|
const matchesStatus = !query.status || customer.status === query.status;
|
||||||
!query.status || customer.status === query.status;
|
|
||||||
|
|
||||||
return matchesSearch && matchesStatus;
|
return matchesSearch && matchesStatus;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { Permissions } from '../auth/decorators/permissions.decorator';
|
import { Permissions } from '../auth/decorators/permissions.decorator';
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
||||||
@@ -272,11 +276,16 @@ export class DashboardService {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRevenueChart(reserves: Reserve[], referenceDate: Date): number[] {
|
private buildRevenueChart(
|
||||||
|
reserves: Reserve[],
|
||||||
|
referenceDate: Date,
|
||||||
|
): number[] {
|
||||||
const chart: number[] = [];
|
const chart: number[] = [];
|
||||||
|
|
||||||
for (let offset = 11; offset >= 0; offset -= 1) {
|
for (let offset = 11; offset >= 0; offset -= 1) {
|
||||||
const monthDate = this.startOfMonth(this.addMonths(referenceDate, -offset));
|
const monthDate = this.startOfMonth(
|
||||||
|
this.addMonths(referenceDate, -offset),
|
||||||
|
);
|
||||||
const nextMonth = this.startOfNextMonth(monthDate);
|
const nextMonth = this.startOfNextMonth(monthDate);
|
||||||
chart.push(this.sumIncomeInRange(reserves, monthDate, nextMonth));
|
chart.push(this.sumIncomeInRange(reserves, monthDate, nextMonth));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
|
import {
|
||||||
|
MigrationInterface,
|
||||||
|
QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableForeignKey,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
export class InitialSchema1719600000000 implements MigrationInterface {
|
export class InitialSchema1719600000000 implements MigrationInterface {
|
||||||
name = 'InitialSchema1719600000000';
|
name = 'InitialSchema1719600000000';
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
|
import {
|
||||||
|
MigrationInterface,
|
||||||
|
QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableForeignKey,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
export class AddStylists1719700000000 implements MigrationInterface {
|
export class AddStylists1719700000000 implements MigrationInterface {
|
||||||
name = 'AddStylists1719700000000';
|
name = 'AddStylists1719700000000';
|
||||||
@@ -17,7 +22,12 @@ export class AddStylists1719700000000 implements MigrationInterface {
|
|||||||
},
|
},
|
||||||
{ name: 'salonId', type: 'uuid' },
|
{ name: 'salonId', type: 'uuid' },
|
||||||
{ name: 'userId', type: 'uuid', isUnique: true },
|
{ name: 'userId', type: 'uuid', isUnique: true },
|
||||||
{ name: 'avatarUrl', type: 'varchar', length: '500', isNullable: true },
|
{
|
||||||
|
name: 'avatarUrl',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '500',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
{ name: 'experienceYears', type: 'int', default: 0 },
|
{ name: 'experienceYears', type: 'int', default: 0 },
|
||||||
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
|
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
|
||||||
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
|
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
|
import {
|
||||||
|
MigrationInterface,
|
||||||
|
QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableForeignKey,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
export class AddServices1719800000000 implements MigrationInterface {
|
export class AddServices1719800000000 implements MigrationInterface {
|
||||||
name = 'AddServices1719800000000';
|
name = 'AddServices1719800000000';
|
||||||
@@ -17,7 +22,12 @@ export class AddServices1719800000000 implements MigrationInterface {
|
|||||||
},
|
},
|
||||||
{ name: 'title', type: 'varchar', length: '200' },
|
{ name: 'title', type: 'varchar', length: '200' },
|
||||||
{ name: 'iconUrl', type: 'varchar', length: '500', isNullable: true },
|
{ name: 'iconUrl', type: 'varchar', length: '500', isNullable: true },
|
||||||
{ name: 'coverUrl', type: 'varchar', length: '500', isNullable: true },
|
{
|
||||||
|
name: 'coverUrl',
|
||||||
|
type: 'varchar',
|
||||||
|
length: '500',
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
|
{ name: 'createdAt', type: 'timestamp', default: 'now()' },
|
||||||
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
|
{ name: 'updatedAt', type: 'timestamp', default: 'now()' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
MigrationInterface,
|
|
||||||
QueryRunner,
|
|
||||||
Table,
|
|
||||||
TableIndex,
|
|
||||||
} from 'typeorm';
|
|
||||||
|
|
||||||
export class AddSmsTemplates1720100000000 implements MigrationInterface {
|
export class AddSmsTemplates1720100000000 implements MigrationInterface {
|
||||||
name = 'AddSmsTemplates1720100000000';
|
name = 'AddSmsTemplates1720100000000';
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { MigrationInterface, QueryRunner, TableForeignKey, TableIndex } from 'typeorm';
|
import {
|
||||||
|
MigrationInterface,
|
||||||
|
QueryRunner,
|
||||||
|
TableForeignKey,
|
||||||
|
TableIndex,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
export class ExtendPaymentsForSubscriptions1720700000000
|
export class ExtendPaymentsForSubscriptions1720700000000 implements MigrationInterface {
|
||||||
implements MigrationInterface
|
|
||||||
{
|
|
||||||
name = 'ExtendPaymentsForSubscriptions1720700000000';
|
name = 'ExtendPaymentsForSubscriptions1720700000000';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import {
|
|||||||
TableUnique,
|
TableUnique,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
export class MoveSuggestionsToServiceSkill1720900000000
|
export class MoveSuggestionsToServiceSkill1720900000000 implements MigrationInterface {
|
||||||
implements MigrationInterface
|
|
||||||
{
|
|
||||||
name = 'MoveSuggestionsToServiceSkill1720900000000';
|
name = 'MoveSuggestionsToServiceSkill1720900000000';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
@@ -26,7 +24,10 @@ export class MoveSuggestionsToServiceSkill1720900000000
|
|||||||
fk.columnNames.includes('suggestedServiceId'),
|
fk.columnNames.includes('suggestedServiceId'),
|
||||||
);
|
);
|
||||||
if (suggestedServiceFk) {
|
if (suggestedServiceFk) {
|
||||||
await queryRunner.dropForeignKey('service_suggestions', suggestedServiceFk);
|
await queryRunner.dropForeignKey(
|
||||||
|
'service_suggestions',
|
||||||
|
suggestedServiceFk,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueConstraints = table?.uniques ?? [];
|
const uniqueConstraints = table?.uniques ?? [];
|
||||||
@@ -36,7 +37,10 @@ export class MoveSuggestionsToServiceSkill1720900000000
|
|||||||
|
|
||||||
const indexes = table?.indices ?? [];
|
const indexes = table?.indices ?? [];
|
||||||
for (const index of indexes) {
|
for (const index of indexes) {
|
||||||
if (index.columnNames.includes('serviceId') || index.columnNames.includes('serviceSkillId')) {
|
if (
|
||||||
|
index.columnNames.includes('serviceId') ||
|
||||||
|
index.columnNames.includes('serviceSkillId')
|
||||||
|
) {
|
||||||
await queryRunner.dropIndex('service_suggestions', index);
|
await queryRunner.dropIndex('service_suggestions', index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +132,10 @@ export class MoveSuggestionsToServiceSkill1720900000000
|
|||||||
'IDX_service_suggestions_serviceSkillId_isActive_displayPriority',
|
'IDX_service_suggestions_serviceSkillId_isActive_displayPriority',
|
||||||
);
|
);
|
||||||
await queryRunner.dropColumn('service_suggestions', 'serviceSkillId');
|
await queryRunner.dropColumn('service_suggestions', 'serviceSkillId');
|
||||||
await queryRunner.dropColumn('service_suggestions', 'suggestedServiceSkillId');
|
await queryRunner.dropColumn(
|
||||||
|
'service_suggestions',
|
||||||
|
'suggestedServiceSkillId',
|
||||||
|
);
|
||||||
|
|
||||||
await queryRunner.addColumn(
|
await queryRunner.addColumn(
|
||||||
'service_suggestions',
|
'service_suggestions',
|
||||||
|
|||||||
@@ -202,10 +202,7 @@ export class PaymentsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByReserveId(
|
async findByReserveId(reserveId: string, user: AuthUser): Promise<Payment[]> {
|
||||||
reserveId: string,
|
|
||||||
user: AuthUser,
|
|
||||||
): Promise<Payment[]> {
|
|
||||||
await this.reservesService.findOne(reserveId, user);
|
await this.reservesService.findOne(reserveId, user);
|
||||||
|
|
||||||
return this.paymentsRepository.find({
|
return this.paymentsRepository.find({
|
||||||
@@ -313,10 +310,7 @@ export class PaymentsService {
|
|||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSuccessReturnUrl(
|
private buildSuccessReturnUrl(returnUrl: string, payment: Payment): string {
|
||||||
returnUrl: string,
|
|
||||||
payment: Payment,
|
|
||||||
): string {
|
|
||||||
return this.buildReturnUrl(returnUrl, {
|
return this.buildReturnUrl(returnUrl, {
|
||||||
success: 'true',
|
success: 'true',
|
||||||
type: payment.type,
|
type: payment.type,
|
||||||
|
|||||||
@@ -53,10 +53,7 @@ export class ReservesController {
|
|||||||
@Permissions(PermissionCode.RESERVES_READ)
|
@Permissions(PermissionCode.RESERVES_READ)
|
||||||
@ApiStandardOkResponse(Reserve, { isArray: true })
|
@ApiStandardOkResponse(Reserve, { isArray: true })
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
findAll(@Query() query: FindReservesQueryDto, @CurrentUser() user: AuthUser) {
|
||||||
@Query() query: FindReservesQueryDto,
|
|
||||||
@CurrentUser() user: AuthUser,
|
|
||||||
) {
|
|
||||||
return this.reservesService.findAll(user, query);
|
return this.reservesService.findAll(user, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ export class ReservesService {
|
|||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.validateTimeRange(createReserveDto.startTime, createReserveDto.endTime);
|
this.validateTimeRange(
|
||||||
|
createReserveDto.startTime,
|
||||||
|
createReserveDto.endTime,
|
||||||
|
);
|
||||||
this.validateAmounts(
|
this.validateAmounts(
|
||||||
createReserveDto.totalAmount,
|
createReserveDto.totalAmount,
|
||||||
createReserveDto.depositAmount,
|
createReserveDto.depositAmount,
|
||||||
@@ -319,9 +322,7 @@ export class ReservesService {
|
|||||||
|
|
||||||
private validateAmounts(totalAmount: number, depositAmount: number): void {
|
private validateAmounts(totalAmount: number, depositAmount: number): void {
|
||||||
if (depositAmount > totalAmount) {
|
if (depositAmount > totalAmount) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException('depositAmount cannot exceed totalAmount');
|
||||||
'depositAmount cannot exceed totalAmount',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,9 +330,7 @@ export class ReservesService {
|
|||||||
return time.length === 5 ? `${time}:00` : time;
|
return time.length === 5 ? `${time}:00` : time;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateItems(
|
private async validateItems(items: CreateReserveDto['items']): Promise<void> {
|
||||||
items: CreateReserveDto['items'],
|
|
||||||
): Promise<void> {
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const serviceExists = await this.servicesRepository.existsBy({
|
const serviceExists = await this.servicesRepository.existsBy({
|
||||||
id: item.serviceId,
|
id: item.serviceId,
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUrl,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateServiceDto {
|
export class CreateServiceDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export class ServiceSkill {
|
|||||||
@Column()
|
@Column()
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
|
|
||||||
@ManyToOne(() => Service, (service) => service.skills, { onDelete: 'CASCADE' })
|
@ManyToOne(() => Service, (service) => service.skills, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
@JoinColumn({ name: 'serviceId' })
|
@JoinColumn({ name: 'serviceId' })
|
||||||
service: Service;
|
service: Service;
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export class ServicesService {
|
|||||||
create(createServiceDto: CreateServiceDto, user: AuthUser): Promise<Service> {
|
create(createServiceDto: CreateServiceDto, user: AuthUser): Promise<Service> {
|
||||||
this.ensureCanManage(user);
|
this.ensureCanManage(user);
|
||||||
const service = this.servicesRepository.create(createServiceDto);
|
const service = this.servicesRepository.create(createServiceDto);
|
||||||
return this.servicesRepository.save(service).then((saved) => this.findOne(saved.id));
|
return this.servicesRepository
|
||||||
|
.save(service)
|
||||||
|
.then((saved) => this.findOne(saved.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
findAll(): Promise<Service[]> {
|
findAll(): Promise<Service[]> {
|
||||||
|
|||||||
@@ -69,10 +69,7 @@ export class SmsTemplatesService {
|
|||||||
return extractPlaceholders(template.body);
|
return extractPlaceholders(template.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
render(
|
render(template: SmsTemplate, variables: Record<string, string>): string {
|
||||||
template: SmsTemplate,
|
|
||||||
variables: Record<string, string>,
|
|
||||||
): string {
|
|
||||||
return renderSmsTemplate(template.body, variables);
|
return renderSmsTemplate(template.body, variables);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export function renderSmsTemplate(
|
|||||||
body: string,
|
body: string,
|
||||||
variables: Record<string, string>,
|
variables: Record<string, string>,
|
||||||
): string {
|
): string {
|
||||||
return body.replace(PLACEHOLDER_PATTERN, (_, key: string) =>
|
return body.replace(
|
||||||
variables[key] ?? `{${key}}`,
|
PLACEHOLDER_PATTERN,
|
||||||
|
(_, key: string) => variables[key] ?? `{${key}}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ export class UpdateSalonStylistDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
experienceYears?: number;
|
experienceYears?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 'https://example.com/avatar.jpg', nullable: true })
|
@ApiPropertyOptional({
|
||||||
|
example: 'https://example.com/avatar.jpg',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ValidateIf((_, value) => value !== null)
|
@ValidateIf((_, value) => value !== null)
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ export class StylistsController {
|
|||||||
@ApiQuery({ name: 'salonId', required: false, type: String, format: 'uuid' })
|
@ApiQuery({ name: 'salonId', required: false, type: String, format: 'uuid' })
|
||||||
@ApiStandardOkResponse(Stylist, { isArray: true })
|
@ApiStandardOkResponse(Stylist, { isArray: true })
|
||||||
@Get()
|
@Get()
|
||||||
findAll(@Query('salonId', new ParseUUIDPipe({ optional: true })) salonId?: string) {
|
findAll(
|
||||||
|
@Query('salonId', new ParseUUIDPipe({ optional: true })) salonId?: string,
|
||||||
|
) {
|
||||||
return this.stylistsService.findAll(salonId);
|
return this.stylistsService.findAll(salonId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,9 +114,7 @@ export class StylistsService {
|
|||||||
return stylists.filter((stylist) => {
|
return stylists.filter((stylist) => {
|
||||||
const fullName =
|
const fullName =
|
||||||
`${stylist.user.firstName} ${stylist.user.lastName}`.toLowerCase();
|
`${stylist.user.firstName} ${stylist.user.lastName}`.toLowerCase();
|
||||||
return (
|
return fullName.includes(search) || stylist.user.mobile.includes(search);
|
||||||
fullName.includes(search) || stylist.user.mobile.includes(search)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,9 +212,7 @@ export class StylistsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existingUser.role === UserRole.ADMIN) {
|
if (existingUser.role === UserRole.ADMIN) {
|
||||||
throw new ConflictException(
|
throw new ConflictException('Admins cannot be registered as stylists');
|
||||||
'Admins cannot be registered as stylists',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingStylist = await this.stylistsRepository.findOne({
|
const existingStylist = await this.stylistsRepository.findOne({
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ export class SalonSubscriptionsController {
|
|||||||
return this.salonSubscriptionsService.purchaseSmsPackage(dto, user);
|
return this.salonSubscriptionsService.purchaseSmsPackage(dto, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(UserRole.ADMIN, UserRole.SALON)
|
||||||
|
@Permissions(PermissionCode.SUBSCRIPTIONS_READ)
|
||||||
|
@AllowWithoutSubscription()
|
||||||
|
@ApiStandardOkResponse(SalonSubscription, { isArray: true })
|
||||||
|
@Get()
|
||||||
|
findAll(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.salonSubscriptionsService.findAll(user);
|
||||||
|
}
|
||||||
|
|
||||||
@Roles(UserRole.ADMIN, UserRole.SALON)
|
@Roles(UserRole.ADMIN, UserRole.SALON)
|
||||||
@Permissions(PermissionCode.SUBSCRIPTIONS_READ)
|
@Permissions(PermissionCode.SUBSCRIPTIONS_READ)
|
||||||
@AllowWithoutSubscription()
|
@AllowWithoutSubscription()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Repository } from 'typeorm';
|
|||||||
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
||||||
import { SubscriptionPolicy } from '../authorization/policies/subscription.policy';
|
import { SubscriptionPolicy } from '../authorization/policies/subscription.policy';
|
||||||
import { SalonsService } from '../salons/salons.service';
|
import { SalonsService } from '../salons/salons.service';
|
||||||
|
import { UserRole } from '../users/enums/user-role.enum';
|
||||||
import { SMS_ROLLOVER_GRACE_DAYS } from './constants/subscription.constants';
|
import { SMS_ROLLOVER_GRACE_DAYS } from './constants/subscription.constants';
|
||||||
import { PurchaseSmsPackageDto } from './dto/purchase-sms-package.dto';
|
import { PurchaseSmsPackageDto } from './dto/purchase-sms-package.dto';
|
||||||
import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity';
|
import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity';
|
||||||
@@ -126,6 +127,23 @@ export class SalonSubscriptionsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAll(user: AuthUser): Promise<SalonSubscription[]> {
|
||||||
|
if (user.role === UserRole.SALON) {
|
||||||
|
return this.subscriptionsRepository
|
||||||
|
.createQueryBuilder('subscription')
|
||||||
|
.innerJoinAndSelect('subscription.salon', 'salon')
|
||||||
|
.innerJoinAndSelect('subscription.plan', 'plan')
|
||||||
|
.where('salon.ownerId = :ownerId', { ownerId: user.id })
|
||||||
|
.orderBy('subscription.createdAt', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.subscriptionsRepository.find({
|
||||||
|
relations: { plan: true, salon: true },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findActiveForSalon(
|
async findActiveForSalon(
|
||||||
salonId: string,
|
salonId: string,
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
@@ -138,7 +156,10 @@ export class SalonSubscriptionsService {
|
|||||||
return this.findActiveSubscription(salonId);
|
return this.findActiveSubscription(salonId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSmsBalance(salonId: string, user: AuthUser): Promise<SalonSmsBalance> {
|
async getSmsBalance(
|
||||||
|
salonId: string,
|
||||||
|
user: AuthUser,
|
||||||
|
): Promise<SalonSmsBalance> {
|
||||||
const salon = await this.salonsService.findOne(salonId);
|
const salon = await this.salonsService.findOne(salonId);
|
||||||
if (!this.subscriptionPolicy.canRead(user, salon)) {
|
if (!this.subscriptionPolicy.canRead(user, salon)) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
@@ -206,7 +227,9 @@ export class SalonSubscriptionsService {
|
|||||||
|
|
||||||
const hasActive = await this.hasActiveSubscription(salonId);
|
const hasActive = await this.hasActiveSubscription(salonId);
|
||||||
if (!hasActive) {
|
if (!hasActive) {
|
||||||
throw new BadRequestException('Salon does not have an active subscription');
|
throw new BadRequestException(
|
||||||
|
'Salon does not have an active subscription',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let remaining = count;
|
let remaining = count;
|
||||||
@@ -303,7 +326,10 @@ export class SalonSubscriptionsService {
|
|||||||
order: { endDate: 'DESC' },
|
order: { endDate: 'DESC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!lastSubscription || lastSubscription.status === SalonSubscriptionStatus.ACTIVE) {
|
if (
|
||||||
|
!lastSubscription ||
|
||||||
|
lastSubscription.status === SalonSubscriptionStatus.ACTIVE
|
||||||
|
) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { CreateSmsPackageDto } from './dto/create-sms-package.dto';
|
import { CreateSmsPackageDto } from './dto/create-sms-package.dto';
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { CreateSubscriptionPlanDto } from './dto/create-subscription-plan.dto';
|
import { CreateSubscriptionPlanDto } from './dto/create-subscription-plan.dto';
|
||||||
@@ -56,7 +53,8 @@ export class SubscriptionPlansService {
|
|||||||
}
|
}
|
||||||
if (dto.name !== undefined) plan.name = dto.name;
|
if (dto.name !== undefined) plan.name = dto.name;
|
||||||
if (dto.code !== undefined) plan.code = dto.code;
|
if (dto.code !== undefined) plan.code = dto.code;
|
||||||
if (dto.durationMonths !== undefined) plan.durationMonths = dto.durationMonths;
|
if (dto.durationMonths !== undefined)
|
||||||
|
plan.durationMonths = dto.durationMonths;
|
||||||
if (dto.freeSmsCount !== undefined) plan.freeSmsCount = dto.freeSmsCount;
|
if (dto.freeSmsCount !== undefined) plan.freeSmsCount = dto.freeSmsCount;
|
||||||
if (dto.isActive !== undefined) plan.isActive = dto.isActive;
|
if (dto.isActive !== undefined) plan.isActive = dto.isActive;
|
||||||
return this.plansRepository.save(plan);
|
return this.plansRepository.save(plan);
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import { ArrayMinSize, IsArray, IsUUID, ValidateNested } from 'class-validator';
|
||||||
ArrayMinSize,
|
|
||||||
IsArray,
|
|
||||||
IsUUID,
|
|
||||||
ValidateNested,
|
|
||||||
} from 'class-validator';
|
|
||||||
import { CreateBulkServiceSuggestionItemDto } from './create-bulk-service-suggestion-item.dto';
|
import { CreateBulkServiceSuggestionItemDto } from './create-bulk-service-suggestion-item.dto';
|
||||||
|
|
||||||
export class CreateBulkServiceSuggestionsDto {
|
export class CreateBulkServiceSuggestionsDto {
|
||||||
|
|||||||
@@ -124,11 +124,15 @@ export class SuggestionsService {
|
|||||||
): Promise<ServiceSuggestion> {
|
): Promise<ServiceSuggestion> {
|
||||||
this.ensureCanManage(user);
|
this.ensureCanManage(user);
|
||||||
const suggestion = await this.findOne(id);
|
const suggestion = await this.findOne(id);
|
||||||
const serviceSkillId = updateDto.serviceSkillId ?? suggestion.serviceSkillId;
|
const serviceSkillId =
|
||||||
|
updateDto.serviceSkillId ?? suggestion.serviceSkillId;
|
||||||
const suggestedServiceSkillId =
|
const suggestedServiceSkillId =
|
||||||
updateDto.suggestedServiceSkillId ?? suggestion.suggestedServiceSkillId;
|
updateDto.suggestedServiceSkillId ?? suggestion.suggestedServiceSkillId;
|
||||||
|
|
||||||
await this.validateSuggestionTargets(serviceSkillId, suggestedServiceSkillId);
|
await this.validateSuggestionTargets(
|
||||||
|
serviceSkillId,
|
||||||
|
suggestedServiceSkillId,
|
||||||
|
);
|
||||||
Object.assign(suggestion, updateDto);
|
Object.assign(suggestion, updateDto);
|
||||||
await this.suggestionsRepository.save(suggestion);
|
await this.suggestionsRepository.save(suggestion);
|
||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
@@ -175,14 +179,20 @@ export class SuggestionsService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateBaseServiceSkill(serviceSkillId: string): Promise<void> {
|
private async validateBaseServiceSkill(
|
||||||
const exists = await this.serviceSkillsRepository.existsBy({ id: serviceSkillId });
|
serviceSkillId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const exists = await this.serviceSkillsRepository.existsBy({
|
||||||
|
id: serviceSkillId,
|
||||||
|
});
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
throw new NotFoundException(`Service skill #${serviceSkillId} not found`);
|
throw new NotFoundException(`Service skill #${serviceSkillId} not found`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureUniqueSuggestedSkills(suggestedServiceSkillIds: string[]): void {
|
private ensureUniqueSuggestedSkills(
|
||||||
|
suggestedServiceSkillIds: string[],
|
||||||
|
): void {
|
||||||
const uniqueIds = new Set(suggestedServiceSkillIds);
|
const uniqueIds = new Set(suggestedServiceSkillIds);
|
||||||
if (uniqueIds.size !== suggestedServiceSkillIds.length) {
|
if (uniqueIds.size !== suggestedServiceSkillIds.length) {
|
||||||
throw new BadRequestException('Duplicate suggested skills in batch');
|
throw new BadRequestException('Duplicate suggested skills in batch');
|
||||||
@@ -204,7 +214,9 @@ export class SuggestionsService {
|
|||||||
|
|
||||||
if (suggestedSkills.length !== suggestedServiceSkillIds.length) {
|
if (suggestedSkills.length !== suggestedServiceSkillIds.length) {
|
||||||
const foundIds = new Set(suggestedSkills.map((skill) => skill.id));
|
const foundIds = new Set(suggestedSkills.map((skill) => skill.id));
|
||||||
const missingId = suggestedServiceSkillIds.find((id) => !foundIds.has(id));
|
const missingId = suggestedServiceSkillIds.find(
|
||||||
|
(id) => !foundIds.has(id),
|
||||||
|
);
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
`Suggested service skill #${missingId} not found`,
|
`Suggested service skill #${missingId} not found`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user