get subscription in admin

This commit is contained in:
2026-07-08 22:23:14 +03:30
parent 3250a6456b
commit 1dd2813a01
34 changed files with 188 additions and 119 deletions
+4 -4
View File
@@ -102,7 +102,9 @@ export class AuthService {
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);
return this.buildOtpRequestResponse(code);
}
@@ -114,9 +116,7 @@ export class AuthService {
return this.requestOtp(mobile);
}
async checkSalonMobile(
mobile: string,
): Promise<{ isRegistered: boolean }> {
async checkSalonMobile(mobile: string): Promise<{ isRegistered: boolean }> {
await this.ensureSalonMobileCanProceed(mobile);
const isRegistered = await this.isSalonOwnerRegistered(mobile);
return { isRegistered };
+2 -1
View File
@@ -5,7 +5,8 @@ export class RequestOtpResponseDto {
message: string;
@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',
})
code?: string;
@@ -16,7 +16,8 @@ export class RequestSalonOtpResponseDto {
expiresInSeconds: number;
@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',
})
code?: string;
+1 -7
View File
@@ -1,10 +1,4 @@
import {
IsOptional,
IsString,
IsUUID,
Length,
Matches,
} from 'class-validator';
import { IsOptional, IsString, IsUUID, Length, Matches } from 'class-validator';
export class VerifySalonOtpDto {
@Matches(/^09\d{9}$/)
+4 -1
View File
@@ -10,7 +10,10 @@ import { AuthorizationService } from '../authorization.service';
export class ReservePolicy {
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)) {
return true;
}
+1 -3
View File
@@ -20,9 +20,7 @@ export class StylistPolicy {
if (this.authorizationService.isAdmin(user)) {
return true;
}
return (
user.role === UserRole.SALON && stylist.salon.ownerId === user.id
);
return user.role === UserRole.SALON && stylist.salon.ownerId === user.id;
}
canDelete(user: AuthUser, stylist: Stylist): boolean {
+4 -3
View File
@@ -20,7 +20,9 @@ export class ApiExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
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) {
response.status(exception.getStatus()).json(exception.getResponse());
return;
@@ -67,8 +69,7 @@ export class ApiExceptionFilter implements ExceptionFilter {
errors = responseMessage.filter(
(item): item is string => typeof item === 'string',
);
message =
errors.length > 0 ? 'Validation failed' : 'Bad Request';
message = errors.length > 0 ? 'Validation failed' : 'Bad Request';
} else if (typeof responseMessage === 'string') {
message = responseMessage;
} else if (typeof responseBody.error === 'string') {
@@ -33,29 +33,30 @@ function isPaginatedResult<T>(value: unknown): value is PaginatedResult<T> {
);
}
function isMessageOnlyResponse(
value: unknown,
): value is { message: string } {
function isMessageOnlyResponse(value: unknown): value is { message: string } {
return (
typeof value === 'object' &&
value !== null &&
'message' in value &&
typeof (value as { message: unknown }).message === 'string' &&
typeof value.message === 'string' &&
Object.keys(value).length === 1
);
}
@Injectable()
export class TransformResponseInterceptor<T>
implements NestInterceptor<T, ApiSuccessResponse<T>>
{
export class TransformResponseInterceptor<T> implements NestInterceptor<
T,
ApiSuccessResponse<T>
> {
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiSuccessResponse<T>> {
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>>;
}
+1 -2
View File
@@ -349,8 +349,7 @@ export class CustomersService {
!search ||
fullName.includes(search) ||
normalizedMobile.includes(search);
const matchesStatus =
!query.status || customer.status === query.status;
const matchesStatus = !query.status || customer.status === query.status;
return matchesSearch && matchesStatus;
});
+1 -5
View File
@@ -1,8 +1,4 @@
import {
Controller,
Get,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Permissions } from '../auth/decorators/permissions.decorator';
+12 -3
View File
@@ -1,4 +1,8 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
@@ -272,11 +276,16 @@ export class DashboardService {
}, 0);
}
private buildRevenueChart(reserves: Reserve[], referenceDate: Date): number[] {
private buildRevenueChart(
reserves: Reserve[],
referenceDate: Date,
): number[] {
const chart: number[] = [];
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);
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 {
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 {
name = 'AddStylists1719700000000';
@@ -17,7 +22,12 @@ export class AddStylists1719700000000 implements MigrationInterface {
},
{ name: 'salonId', type: 'uuid' },
{ 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: 'createdAt', 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 {
name = 'AddServices1719800000000';
@@ -17,7 +22,12 @@ export class AddServices1719800000000 implements MigrationInterface {
},
{ name: 'title', type: 'varchar', length: '200' },
{ 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: 'updatedAt', type: 'timestamp', default: 'now()' },
],
@@ -1,9 +1,4 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
} from 'typeorm';
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class AddSmsTemplates1720100000000 implements MigrationInterface {
name = 'AddSmsTemplates1720100000000';
@@ -1,8 +1,11 @@
import { MigrationInterface, QueryRunner, TableForeignKey, TableIndex } from 'typeorm';
import {
MigrationInterface,
QueryRunner,
TableForeignKey,
TableIndex,
} from 'typeorm';
export class ExtendPaymentsForSubscriptions1720700000000
implements MigrationInterface
{
export class ExtendPaymentsForSubscriptions1720700000000 implements MigrationInterface {
name = 'ExtendPaymentsForSubscriptions1720700000000';
public async up(queryRunner: QueryRunner): Promise<void> {
@@ -7,9 +7,7 @@ import {
TableUnique,
} from 'typeorm';
export class MoveSuggestionsToServiceSkill1720900000000
implements MigrationInterface
{
export class MoveSuggestionsToServiceSkill1720900000000 implements MigrationInterface {
name = 'MoveSuggestionsToServiceSkill1720900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
@@ -26,7 +24,10 @@ export class MoveSuggestionsToServiceSkill1720900000000
fk.columnNames.includes('suggestedServiceId'),
);
if (suggestedServiceFk) {
await queryRunner.dropForeignKey('service_suggestions', suggestedServiceFk);
await queryRunner.dropForeignKey(
'service_suggestions',
suggestedServiceFk,
);
}
const uniqueConstraints = table?.uniques ?? [];
@@ -36,7 +37,10 @@ export class MoveSuggestionsToServiceSkill1720900000000
const indexes = table?.indices ?? [];
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);
}
}
@@ -128,7 +132,10 @@ export class MoveSuggestionsToServiceSkill1720900000000
'IDX_service_suggestions_serviceSkillId_isActive_displayPriority',
);
await queryRunner.dropColumn('service_suggestions', 'serviceSkillId');
await queryRunner.dropColumn('service_suggestions', 'suggestedServiceSkillId');
await queryRunner.dropColumn(
'service_suggestions',
'suggestedServiceSkillId',
);
await queryRunner.addColumn(
'service_suggestions',
+2 -8
View File
@@ -202,10 +202,7 @@ export class PaymentsService {
});
}
async findByReserveId(
reserveId: string,
user: AuthUser,
): Promise<Payment[]> {
async findByReserveId(reserveId: string, user: AuthUser): Promise<Payment[]> {
await this.reservesService.findOne(reserveId, user);
return this.paymentsRepository.find({
@@ -313,10 +310,7 @@ export class PaymentsService {
throw new ForbiddenException();
}
private buildSuccessReturnUrl(
returnUrl: string,
payment: Payment,
): string {
private buildSuccessReturnUrl(returnUrl: string, payment: Payment): string {
return this.buildReturnUrl(returnUrl, {
success: 'true',
type: payment.type,
+1 -4
View File
@@ -53,10 +53,7 @@ export class ReservesController {
@Permissions(PermissionCode.RESERVES_READ)
@ApiStandardOkResponse(Reserve, { isArray: true })
@Get()
findAll(
@Query() query: FindReservesQueryDto,
@CurrentUser() user: AuthUser,
) {
findAll(@Query() query: FindReservesQueryDto, @CurrentUser() user: AuthUser) {
return this.reservesService.findAll(user, query);
}
+6 -7
View File
@@ -65,7 +65,10 @@ export class ReservesService {
throw new ForbiddenException();
}
this.validateTimeRange(createReserveDto.startTime, createReserveDto.endTime);
this.validateTimeRange(
createReserveDto.startTime,
createReserveDto.endTime,
);
this.validateAmounts(
createReserveDto.totalAmount,
createReserveDto.depositAmount,
@@ -319,9 +322,7 @@ export class ReservesService {
private validateAmounts(totalAmount: number, depositAmount: number): void {
if (depositAmount > totalAmount) {
throw new BadRequestException(
'depositAmount cannot exceed totalAmount',
);
throw new BadRequestException('depositAmount cannot exceed totalAmount');
}
}
@@ -329,9 +330,7 @@ export class ReservesService {
return time.length === 5 ? `${time}:00` : time;
}
private async validateItems(
items: CreateReserveDto['items'],
): Promise<void> {
private async validateItems(items: CreateReserveDto['items']): Promise<void> {
for (const item of items) {
const serviceExists = await this.servicesRepository.existsBy({
id: item.serviceId,
+7 -1
View File
@@ -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 {
@IsString()
@@ -17,7 +17,9 @@ export class ServiceSkill {
@Column()
serviceId: string;
@ManyToOne(() => Service, (service) => service.skills, { onDelete: 'CASCADE' })
@ManyToOne(() => Service, (service) => service.skills, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'serviceId' })
service: Service;
+3 -1
View File
@@ -27,7 +27,9 @@ export class ServicesService {
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));
return this.servicesRepository
.save(service)
.then((saved) => this.findOne(saved.id));
}
findAll(): Promise<Service[]> {
+1 -4
View File
@@ -69,10 +69,7 @@ export class SmsTemplatesService {
return extractPlaceholders(template.body);
}
render(
template: SmsTemplate,
variables: Record<string, string>,
): string {
render(template: SmsTemplate, variables: Record<string, string>): string {
return renderSmsTemplate(template.body, variables);
}
@@ -15,7 +15,8 @@ export function renderSmsTemplate(
body: string,
variables: Record<string, string>,
): string {
return body.replace(PLACEHOLDER_PATTERN, (_, key: string) =>
variables[key] ?? `{${key}}`,
return body.replace(
PLACEHOLDER_PATTERN,
(_, key: string) => variables[key] ?? `{${key}}`,
);
}
+4 -1
View File
@@ -65,7 +65,10 @@ export class UpdateSalonStylistDto {
@Min(0)
experienceYears?: number;
@ApiPropertyOptional({ example: 'https://example.com/avatar.jpg', nullable: true })
@ApiPropertyOptional({
example: 'https://example.com/avatar.jpg',
nullable: true,
})
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsString()
+3 -1
View File
@@ -117,7 +117,9 @@ export class StylistsController {
@ApiQuery({ name: 'salonId', required: false, type: String, format: 'uuid' })
@ApiStandardOkResponse(Stylist, { isArray: true })
@Get()
findAll(@Query('salonId', new ParseUUIDPipe({ optional: true })) salonId?: string) {
findAll(
@Query('salonId', new ParseUUIDPipe({ optional: true })) salonId?: string,
) {
return this.stylistsService.findAll(salonId);
}
+2 -6
View File
@@ -114,9 +114,7 @@ export class StylistsService {
return stylists.filter((stylist) => {
const fullName =
`${stylist.user.firstName} ${stylist.user.lastName}`.toLowerCase();
return (
fullName.includes(search) || stylist.user.mobile.includes(search)
);
return fullName.includes(search) || stylist.user.mobile.includes(search);
});
}
@@ -214,9 +212,7 @@ export class StylistsService {
}
if (existingUser.role === UserRole.ADMIN) {
throw new ConflictException(
'Admins cannot be registered as stylists',
);
throw new ConflictException('Admins cannot be registered as stylists');
}
const existingStylist = await this.stylistsRepository.findOne({
@@ -65,6 +65,15 @@ export class SalonSubscriptionsController {
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)
@Permissions(PermissionCode.SUBSCRIPTIONS_READ)
@AllowWithoutSubscription()
@@ -9,6 +9,7 @@ import { Repository } from 'typeorm';
import { AuthUser } from '../auth/interfaces/auth-user.interface';
import { SubscriptionPolicy } from '../authorization/policies/subscription.policy';
import { SalonsService } from '../salons/salons.service';
import { UserRole } from '../users/enums/user-role.enum';
import { SMS_ROLLOVER_GRACE_DAYS } from './constants/subscription.constants';
import { PurchaseSmsPackageDto } from './dto/purchase-sms-package.dto';
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(
salonId: string,
user: AuthUser,
@@ -138,7 +156,10 @@ export class SalonSubscriptionsService {
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);
if (!this.subscriptionPolicy.canRead(user, salon)) {
throw new ForbiddenException();
@@ -206,7 +227,9 @@ export class SalonSubscriptionsService {
const hasActive = await this.hasActiveSubscription(salonId);
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;
@@ -303,7 +326,10 @@ export class SalonSubscriptionsService {
order: { endDate: 'DESC' },
});
if (!lastSubscription || lastSubscription.status === SalonSubscriptionStatus.ACTIVE) {
if (
!lastSubscription ||
lastSubscription.status === SalonSubscriptionStatus.ACTIVE
) {
return 0;
}
+1 -4
View File
@@ -1,7 +1,4 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateSmsPackageDto } from './dto/create-sms-package.dto';
@@ -1,7 +1,4 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
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.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.isActive !== undefined) plan.isActive = dto.isActive;
return this.plansRepository.save(plan);
@@ -1,10 +1,5 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsUUID,
ValidateNested,
} from 'class-validator';
import { ArrayMinSize, IsArray, IsUUID, ValidateNested } from 'class-validator';
import { CreateBulkServiceSuggestionItemDto } from './create-bulk-service-suggestion-item.dto';
export class CreateBulkServiceSuggestionsDto {
+18 -6
View File
@@ -124,11 +124,15 @@ export class SuggestionsService {
): Promise<ServiceSuggestion> {
this.ensureCanManage(user);
const suggestion = await this.findOne(id);
const serviceSkillId = updateDto.serviceSkillId ?? suggestion.serviceSkillId;
const serviceSkillId =
updateDto.serviceSkillId ?? suggestion.serviceSkillId;
const suggestedServiceSkillId =
updateDto.suggestedServiceSkillId ?? suggestion.suggestedServiceSkillId;
await this.validateSuggestionTargets(serviceSkillId, suggestedServiceSkillId);
await this.validateSuggestionTargets(
serviceSkillId,
suggestedServiceSkillId,
);
Object.assign(suggestion, updateDto);
await this.suggestionsRepository.save(suggestion);
return this.findOne(id);
@@ -175,14 +179,20 @@ export class SuggestionsService {
]);
}
private async validateBaseServiceSkill(serviceSkillId: string): Promise<void> {
const exists = await this.serviceSkillsRepository.existsBy({ id: serviceSkillId });
private async validateBaseServiceSkill(
serviceSkillId: string,
): Promise<void> {
const exists = await this.serviceSkillsRepository.existsBy({
id: serviceSkillId,
});
if (!exists) {
throw new NotFoundException(`Service skill #${serviceSkillId} not found`);
}
}
private ensureUniqueSuggestedSkills(suggestedServiceSkillIds: string[]): void {
private ensureUniqueSuggestedSkills(
suggestedServiceSkillIds: string[],
): void {
const uniqueIds = new Set(suggestedServiceSkillIds);
if (uniqueIds.size !== suggestedServiceSkillIds.length) {
throw new BadRequestException('Duplicate suggested skills in batch');
@@ -204,7 +214,9 @@ export class SuggestionsService {
if (suggestedSkills.length !== suggestedServiceSkillIds.length) {
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(
`Suggested service skill #${missingId} not found`,
);