Compare commits

...

2 Commits

Author SHA1 Message Date
root
0f23e4fdce feat: BullMQ очереди, 2FA/TOTP, audit logs, rate limiting, 310 unit-тестов, CRUD-диалоги веб-панелей
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Build All Apps (push) Has been cancelled
Фаза 1: BullMQ + EventEmitter2 — QueueModule (webhook-delivery, push-notification, sync-1c),
webhook delivery processor с HMAC-SHA256 и retry 3 попытки, webhook dispatch service
с @OnEvent для 12 типов событий, эмиссия событий из бизнес-сервисов.

Фаза 2: @nestjs/throttler rate limiting (1000 req/min, Redis), TOTP 2FA для суперадмина
(otplib + qrcode), AuditModule с GET /admin/audit-logs.

Фаза 3: 14 новых тестовых файлов (310 тестов) — auth, clients, schedule, funnel, sales,
stats, notifications, webhooks, totp, metering, guards, middleware.

Фаза 4: web-club-admin — 15 CRUD-диалогов (staff, departments, rooms, catalog, integrations,
license, settings) + подключение к страницам.

Фаза 5: web-platform-admin — create/edit club, issue license, club actions menu, CSV export
audit logs + подключение к страницам.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 07:59:20 +00:00
root
7fcebf2739 fix(infra): снижение resource limits под сервер 4 CPU / 8 GB RAM
Суммарные лимиты контейнеров превышали физические ресурсы сервера
(было 9 CPU / 8.5 GB, стало 5 CPU / 4.3 GB). Также настроен 2 GB swap
и swappiness=10 на уровне ОС.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 06:25:48 +00:00
68 changed files with 8352 additions and 141 deletions

View File

@@ -14,21 +14,27 @@
"prisma:studio": "prisma studio"
},
"dependencies": {
"@nestjs/core": "^11.0.0",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/common": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.0.0",
"bcrypt": "^5.1.1",
"bullmq": "^5.0.0",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.0",
"class-validator": "^0.14.0",
"express": "^5.0.0",
"otplib": "^13.3.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.0"
},
@@ -40,15 +46,16 @@
"@nestjs/testing": "^11.0.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.0",
"@types/node": "^22.0.0",
"@types/passport-jwt": "^4.0.1",
"prisma": "^6.0.0",
"typescript": "^5.5.0",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^6.0.0",
"jest": "^29.7.0",
"@types/jest": "^29.5.0",
"prisma": "^6.0.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.0",
"ts-node": "^10.9.0",
"supertest": "^7.0.0",
"@types/supertest": "^6.0.0"
"typescript": "^5.5.0"
}
}

View File

@@ -1,7 +1,12 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { configuration } from './config';
import { PrismaModule } from './prisma';
import { QueueModule } from './queue';
import { AuthModule } from './modules/auth';
import { UsersModule } from './modules/users';
import { TenantMiddleware } from './common/middleware/tenant.middleware';
@@ -25,6 +30,7 @@ import { CatalogModule } from './modules/catalog';
import { DepartmentsModule } from './modules/departments';
import { RoomsModule } from './modules/rooms';
import { IntegrationModule } from './modules/integration';
import { AuditModule } from './modules/audit/audit.module';
@Module({
imports: [
@@ -32,6 +38,12 @@ import { IntegrationModule } from './modules/integration';
isGlobal: true,
load: [configuration],
}),
EventEmitterModule.forRoot(),
NestScheduleModule.forRoot(),
ThrottlerModule.forRoot({
throttlers: [{ ttl: 60000, limit: 1000 }],
}),
QueueModule,
PrismaModule,
AuthModule,
UsersModule,
@@ -55,6 +67,10 @@ import { IntegrationModule } from './modules/integration';
ReportsModule,
WebhooksModule,
IntegrationModule,
AuditModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
export class AppModule implements NestModule {

View File

@@ -0,0 +1,125 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import { ForbiddenException } from '@nestjs/common';
import { ModuleGuard } from './module.guard';
import { PrismaService } from '../../prisma/prisma.service';
import { REQUIRE_MODULE_KEY } from '../decorators/require-module.decorator';
describe('ModuleGuard', () => {
let guard: ModuleGuard;
let reflector: Reflector;
let prisma: { $queryRawUnsafe: jest.Mock };
const createMockExecutionContext = (user?: { clubId?: string }) => ({
getHandler: jest.fn(),
getClass: jest.fn(),
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue({ user }),
}),
});
beforeEach(async () => {
jest.clearAllMocks();
prisma = {
$queryRawUnsafe: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ModuleGuard,
{
provide: Reflector,
useValue: {
get: jest.fn(),
},
},
{
provide: PrismaService,
useValue: prisma,
},
],
}).compile();
guard = module.get<ModuleGuard>(ModuleGuard);
reflector = module.get<Reflector>(Reflector);
});
it('should return true when no @RequireModule decorator is present', async () => {
(reflector.get as jest.Mock).mockReturnValue(undefined);
const context = createMockExecutionContext({ clubId: 'club-001' });
const result = await guard.canActivate(context as any);
expect(result).toBe(true);
expect(reflector.get).toHaveBeenCalledWith(REQUIRE_MODULE_KEY, context.getHandler());
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled();
});
it('should return true when module is enabled for the club', async () => {
(reflector.get as jest.Mock).mockReturnValue('sip');
prisma.$queryRawUnsafe.mockResolvedValue([{ enabled: true }]);
const context = createMockExecutionContext({ clubId: 'club-001' });
const result = await guard.canActivate(context as any);
expect(result).toBe(true);
expect(prisma.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('SELECT enabled FROM club_modules'),
'club-001',
'sip',
);
});
it('should throw ForbiddenException when no clubId in request', async () => {
(reflector.get as jest.Mock).mockReturnValue('sip');
const context = createMockExecutionContext(undefined);
await expect(guard.canActivate(context as any)).rejects.toThrow(ForbiddenException);
await expect(guard.canActivate(context as any)).rejects.toThrow('Club context is required');
});
it('should throw ForbiddenException when clubId is missing from user object', async () => {
(reflector.get as jest.Mock).mockReturnValue('sip');
const context = createMockExecutionContext({});
await expect(guard.canActivate(context as any)).rejects.toThrow(ForbiddenException);
await expect(guard.canActivate(context as any)).rejects.toThrow('Club context is required');
});
it('should throw ForbiddenException when module is not found in club_modules', async () => {
(reflector.get as jest.Mock).mockReturnValue('sip');
prisma.$queryRawUnsafe.mockResolvedValue([]);
const context = createMockExecutionContext({ clubId: 'club-001' });
await expect(guard.canActivate(context as any)).rejects.toThrow(ForbiddenException);
await expect(guard.canActivate(context as any)).rejects.toThrow(
'Module "sip" is not enabled for this club',
);
});
it('should throw ForbiddenException when module exists but enabled is false', async () => {
(reflector.get as jest.Mock).mockReturnValue('webhooks');
prisma.$queryRawUnsafe.mockResolvedValue([{ enabled: false }]);
const context = createMockExecutionContext({ clubId: 'club-002' });
await expect(guard.canActivate(context as any)).rejects.toThrow(ForbiddenException);
await expect(guard.canActivate(context as any)).rejects.toThrow(
'Module "webhooks" is not enabled for this club',
);
});
it('should query with correct clubId and moduleId parameters', async () => {
(reflector.get as jest.Mock).mockReturnValue('1c_sync');
prisma.$queryRawUnsafe.mockResolvedValue([{ enabled: true }]);
const context = createMockExecutionContext({ clubId: 'club-xyz-123' });
await guard.canActivate(context as any);
expect(prisma.$queryRawUnsafe).toHaveBeenCalledWith(
expect.any(String),
'club-xyz-123',
'1c_sync',
);
});
});

View File

@@ -0,0 +1,97 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TenantMiddleware } from './tenant.middleware';
import { PrismaService } from '../../prisma/prisma.service';
describe('TenantMiddleware', () => {
let middleware: TenantMiddleware;
let mockPrisma: { setTenantId: jest.Mock };
let mockNext: jest.Mock;
let mockRes: Partial<Record<string, unknown>>;
beforeEach(async () => {
jest.clearAllMocks();
mockPrisma = {
setTenantId: jest.fn().mockResolvedValue(undefined),
};
mockNext = jest.fn();
mockRes = {};
const module: TestingModule = await Test.createTestingModule({
providers: [
TenantMiddleware,
{
provide: PrismaService,
useValue: mockPrisma,
},
],
}).compile();
middleware = module.get<TenantMiddleware>(TenantMiddleware);
});
it('should call setTenantId when user has clubId', async () => {
const req = { user: { clubId: 'club-001' } } as any;
await middleware.use(req, mockRes as any, mockNext);
expect(mockPrisma.setTenantId).toHaveBeenCalledTimes(1);
expect(mockPrisma.setTenantId).toHaveBeenCalledWith('club-001');
expect(mockNext).toHaveBeenCalledTimes(1);
});
it('should always call next()', async () => {
const req = { user: { clubId: 'club-001' } } as any;
await middleware.use(req, mockRes as any, mockNext);
expect(mockNext).toHaveBeenCalledTimes(1);
});
it('should not call setTenantId when there is no user on request', async () => {
const req = {} as any;
await middleware.use(req, mockRes as any, mockNext);
expect(mockPrisma.setTenantId).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalledTimes(1);
});
it('should not call setTenantId when user has no clubId', async () => {
const req = { user: { name: 'John' } } as any;
await middleware.use(req, mockRes as any, mockNext);
expect(mockPrisma.setTenantId).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalledTimes(1);
});
it('should not call setTenantId when user is undefined', async () => {
const req = { user: undefined } as any;
await middleware.use(req, mockRes as any, mockNext);
expect(mockPrisma.setTenantId).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalledTimes(1);
});
it('should not call setTenantId when user is null', async () => {
const req = { user: null } as any;
await middleware.use(req, mockRes as any, mockNext);
expect(mockPrisma.setTenantId).not.toHaveBeenCalled();
expect(mockNext).toHaveBeenCalledTimes(1);
});
it('should call next() even after setTenantId succeeds', async () => {
mockPrisma.setTenantId.mockResolvedValue(undefined);
const req = { user: { clubId: 'club-xyz' } } as any;
await middleware.use(req, mockRes as any, mockNext);
expect(mockPrisma.setTenantId).toHaveBeenCalledWith('club-xyz');
expect(mockNext).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,22 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles, UserRole } from '../../common/decorators/roles.decorator';
import { AuditService } from './audit.service';
import { FindAuditLogsDto } from './dto/find-audit-logs.dto';
@ApiTags('Admin - Audit')
@ApiBearerAuth()
@Controller('admin/audit-logs')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.SUPER_ADMIN)
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get()
@ApiOperation({ summary: 'List audit logs with filters and pagination' })
async findAll(@Query() query: FindAuditLogsDto) {
return this.auditService.findAll(query);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuditController } from './audit.controller';
import { AuditService } from './audit.service';
@Module({
controllers: [AuditController],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { FindAuditLogsDto } from './dto/find-audit-logs.dto';
export interface AuditLogRow {
id: string;
userId: string;
clubId: string;
action: string;
resource: string;
resourceId: string | null;
ipAddress: string;
userAgent: string;
statusCode: number;
durationMs: number;
createdAt: Date;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async findAll(params: FindAuditLogsDto) {
const { search, userId, clubId, action, limit = 50, offset = 0 } = params;
const conditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (search) {
conditions.push(
`(resource ILIKE $${paramIndex} OR action ILIKE $${paramIndex})`,
);
values.push(`%${search}%`);
paramIndex++;
}
if (userId) {
conditions.push(`"userId" = $${paramIndex}`);
values.push(userId);
paramIndex++;
}
if (clubId) {
conditions.push(`"clubId" = $${paramIndex}`);
values.push(clubId);
paramIndex++;
}
if (action) {
conditions.push(`action = $${paramIndex}`);
values.push(action);
paramIndex++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const countQuery = `SELECT COUNT(*) as total FROM audit_logs ${whereClause}`;
const countResult = await this.prisma.$queryRawUnsafe<{ total: bigint }[]>(
countQuery,
...values,
);
const total = Number(countResult[0]?.total ?? 0);
const dataQuery = `
SELECT id, "userId", "clubId", action, resource, "resourceId",
"ipAddress", "userAgent", "statusCode", "durationMs", "createdAt"
FROM audit_logs
${whereClause}
ORDER BY "createdAt" DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const data = await this.prisma.$queryRawUnsafe<AuditLogRow[]>(
dataQuery,
...values,
limit,
offset,
);
return { data, total };
}
}

View File

@@ -0,0 +1,40 @@
import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class FindAuditLogsDto {
@ApiPropertyOptional({ description: 'Search by action or resource' })
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ description: 'Filter by user ID' })
@IsOptional()
@IsString()
userId?: string;
@ApiPropertyOptional({ description: 'Filter by club ID' })
@IsOptional()
@IsString()
clubId?: string;
@ApiPropertyOptional({ description: 'Filter by HTTP method (GET, POST, PATCH, DELETE)' })
@IsOptional()
@IsString()
action?: string;
@ApiPropertyOptional({ description: 'Limit results', default: 50 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit?: number;
@ApiPropertyOptional({ description: 'Offset for pagination', default: 0 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number;
}

View File

@@ -1,24 +1,55 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
UseGuards,
Request,
UnauthorizedException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService, AuthTokens } from './auth.service';
import { TotpService } from './totp.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { TotpVerifyDto, TotpValidateDto } from './dto/totp.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { verifySync } from 'otplib';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(
private readonly authService: AuthService,
private readonly totpService: TotpService,
) {}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with phone and password' })
@ApiResponse({
status: 200,
description: 'Returns access and refresh tokens',
})
@ApiResponse({ status: 200, description: 'Returns access and refresh tokens' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(@Body() dto: LoginDto): Promise<AuthTokens> {
async login(
@Body() dto: LoginDto & { totpCode?: string },
): Promise<AuthTokens | { requireTotp: true; userId: string }> {
const user = await this.authService.validateUser(dto.phone, dto.password);
if (user.totpSecret) {
if (!dto.totpCode) {
return { requireTotp: true, userId: user.id };
}
const result = verifySync({
token: dto.totpCode,
secret: user.totpSecret,
});
if (!result.valid) {
throw new UnauthorizedException('Invalid TOTP code');
}
}
return this.authService.login(user);
}
@@ -26,13 +57,40 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token using refresh token' })
@ApiResponse({ status: 200, description: 'Returns new access token' })
@ApiResponse({
status: 401,
description: 'Invalid or expired refresh token',
})
@ApiResponse({ status: 401, description: 'Invalid or expired refresh token' })
async refresh(
@Body() dto: RefreshTokenDto,
): Promise<{ accessToken: string }> {
return this.authService.refreshToken(dto.refreshToken);
}
@Post('totp/setup')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate TOTP secret and QR code for 2FA setup' })
async totpSetup(@Request() req: any) {
return this.totpService.setup(req.user.sub);
}
@Post('totp/verify')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Verify TOTP code and enable 2FA' })
async totpVerify(@Request() req: any, @Body() dto: TotpVerifyDto) {
await this.totpService.verify(req.user.sub, dto.token, dto.secret);
return { enabled: true };
}
@Post('totp/validate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Validate TOTP code during login' })
async totpValidate(@Body() dto: TotpValidateDto & { userId: string }) {
const isValid = await this.totpService.validate(dto.userId, dto.token);
if (!isValid) {
throw new UnauthorizedException('Invalid TOTP code');
}
return { valid: true };
}
}

View File

@@ -4,6 +4,7 @@ import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { TotpService } from './totp.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
@@ -21,7 +22,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
providers: [AuthService, TotpService, JwtStrategy],
exports: [AuthService, TotpService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,217 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService, UserRecord } from './auth.service';
import { PrismaService } from '../../prisma/prisma.service';
jest.mock('bcrypt', () => ({
compare: jest.fn(),
}));
import * as bcrypt from 'bcrypt';
const mockPrisma = {
$queryRawUnsafe: jest.fn(),
$executeRawUnsafe: jest.fn(),
};
const mockJwtService = {
signAsync: jest.fn(),
verifyAsync: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
const CLUB_ID = 'club-uuid-001';
const USER_ID = 'user-uuid-001';
const makeUser = (overrides: Partial<UserRecord> = {}): UserRecord => ({
id: USER_ID,
clubId: CLUB_ID,
phone: '+79001234567',
passwordHash: '$2b$10$hashedpassword',
role: 'trainer',
isActive: true,
firstName: 'Ivan',
lastName: 'Petrov',
totpSecret: null,
...overrides,
});
describe('AuthService', () => {
let service: AuthService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwtService },
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<AuthService>(AuthService);
jest.clearAllMocks();
});
// ---------------------------------------------------------------------------
// validateUser
// ---------------------------------------------------------------------------
describe('validateUser', () => {
it('returns user when credentials are valid', async () => {
const user = makeUser();
mockPrisma.$queryRawUnsafe.mockResolvedValue([user]);
(bcrypt.compare as jest.Mock).mockResolvedValue(true);
const result = await service.validateUser('+79001234567', 'correctPassword');
expect(result).toEqual(user);
expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('SELECT'),
'+79001234567',
);
expect(bcrypt.compare).toHaveBeenCalledWith('correctPassword', user.passwordHash);
});
it('throws UnauthorizedException when phone is not found', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([]);
await expect(
service.validateUser('+79999999999', 'anyPassword'),
).rejects.toThrow(new UnauthorizedException('Invalid credentials'));
expect(bcrypt.compare).not.toHaveBeenCalled();
});
it('throws UnauthorizedException when account is deactivated', async () => {
const user = makeUser({ isActive: false });
mockPrisma.$queryRawUnsafe.mockResolvedValue([user]);
await expect(
service.validateUser('+79001234567', 'correctPassword'),
).rejects.toThrow(new UnauthorizedException('Account is deactivated'));
expect(bcrypt.compare).not.toHaveBeenCalled();
});
it('throws UnauthorizedException when password is wrong', async () => {
const user = makeUser();
mockPrisma.$queryRawUnsafe.mockResolvedValue([user]);
(bcrypt.compare as jest.Mock).mockResolvedValue(false);
await expect(
service.validateUser('+79001234567', 'wrongPassword'),
).rejects.toThrow(new UnauthorizedException('Invalid credentials'));
expect(bcrypt.compare).toHaveBeenCalledWith('wrongPassword', user.passwordHash);
});
});
// ---------------------------------------------------------------------------
// login
// ---------------------------------------------------------------------------
describe('login', () => {
it('generates tokens and stores refresh token', async () => {
const user = makeUser();
mockJwtService.signAsync
.mockResolvedValueOnce('access-token-123')
.mockResolvedValueOnce('refresh-token-456');
mockConfigService.get.mockImplementation((key: string) => {
if (key === 'jwt.refreshSecret') return 'refresh-secret';
if (key === 'jwt.refreshExpiresIn') return '30d';
return undefined;
});
mockPrisma.$executeRawUnsafe.mockResolvedValue(undefined);
const result = await service.login(user);
expect(result).toEqual({
accessToken: 'access-token-123',
refreshToken: 'refresh-token-456',
});
expect(mockJwtService.signAsync).toHaveBeenCalledTimes(2);
expect(mockJwtService.signAsync).toHaveBeenCalledWith({
sub: USER_ID,
clubId: CLUB_ID,
role: 'trainer',
});
expect(mockJwtService.signAsync).toHaveBeenCalledWith(
{ sub: USER_ID, clubId: CLUB_ID, role: 'trainer' },
expect.objectContaining({
secret: 'refresh-secret',
expiresIn: '30d',
}),
);
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO refresh_tokens'),
USER_ID,
'refresh-token-456',
expect.any(String),
);
});
});
// ---------------------------------------------------------------------------
// refreshToken
// ---------------------------------------------------------------------------
describe('refreshToken', () => {
it('returns new accessToken when refresh token is valid', async () => {
const payload = { sub: USER_ID, clubId: CLUB_ID, role: 'trainer' };
mockJwtService.verifyAsync.mockResolvedValue(payload);
mockConfigService.get.mockReturnValue('refresh-secret');
mockPrisma.$queryRawUnsafe.mockResolvedValue([{ id: 'token-uuid-001' }]);
mockJwtService.signAsync.mockResolvedValue('new-access-token');
const result = await service.refreshToken('valid-refresh-token');
expect(result).toEqual({ accessToken: 'new-access-token' });
expect(mockJwtService.verifyAsync).toHaveBeenCalledWith(
'valid-refresh-token',
{ secret: 'refresh-secret' },
);
expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('SELECT id FROM refresh_tokens'),
USER_ID,
'valid-refresh-token',
);
expect(mockJwtService.signAsync).toHaveBeenCalledWith({
sub: USER_ID,
clubId: CLUB_ID,
role: 'trainer',
});
});
it('throws UnauthorizedException when token verification fails', async () => {
mockJwtService.verifyAsync.mockRejectedValue(new Error('jwt expired'));
mockConfigService.get.mockReturnValue('refresh-secret');
await expect(
service.refreshToken('expired-token'),
).rejects.toThrow(new UnauthorizedException('Invalid or expired refresh token'));
expect(mockPrisma.$queryRawUnsafe).not.toHaveBeenCalled();
});
it('throws UnauthorizedException when refresh token is revoked or not found', async () => {
const payload = { sub: USER_ID, clubId: CLUB_ID, role: 'trainer' };
mockJwtService.verifyAsync.mockResolvedValue(payload);
mockConfigService.get.mockReturnValue('refresh-secret');
mockPrisma.$queryRawUnsafe.mockResolvedValue([]);
await expect(
service.refreshToken('revoked-token'),
).rejects.toThrow(
new UnauthorizedException('Refresh token has been revoked or expired'),
);
expect(mockJwtService.signAsync).not.toHaveBeenCalled();
});
});
});

View File

@@ -8,7 +8,7 @@ import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../prisma/prisma.service';
interface UserRecord {
export interface UserRecord {
id: string;
clubId: string;
phone: string;
@@ -17,6 +17,7 @@ interface UserRecord {
isActive: boolean;
firstName: string;
lastName: string;
totpSecret: string | null;
}
export interface AuthTokens {
@@ -45,7 +46,7 @@ export class AuthService {
*/
async validateUser(phone: string, password: string): Promise<UserRecord> {
const users = await this.prisma.$queryRawUnsafe<UserRecord[]>(
`SELECT id, "clubId", phone, "passwordHash", role, "isActive", "firstName", "lastName"
`SELECT id, "clubId", phone, "passwordHash", role, "isActive", "firstName", "lastName", "totpSecret"
FROM users
WHERE phone = $1
LIMIT 1`,

View File

@@ -0,0 +1,40 @@
import { IsString, IsNotEmpty, Length } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class TotpVerifyDto {
@ApiProperty({ description: 'TOTP code from authenticator app', example: '123456' })
@IsString()
@IsNotEmpty()
@Length(6, 6)
token: string;
@ApiProperty({ description: 'TOTP secret from setup step' })
@IsString()
@IsNotEmpty()
secret: string;
}
export class TotpValidateDto {
@ApiProperty({ description: 'TOTP code from authenticator app', example: '123456' })
@IsString()
@IsNotEmpty()
@Length(6, 6)
token: string;
}
export class LoginWithTotpDto {
@ApiProperty({ description: 'Phone number' })
@IsString()
@IsNotEmpty()
phone: string;
@ApiProperty({ description: 'Password' })
@IsString()
@IsNotEmpty()
password: string;
@ApiProperty({ description: 'TOTP code (required if 2FA is enabled)', required: false })
@IsString()
@Length(6, 6)
totpCode?: string;
}

View File

@@ -0,0 +1,148 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Reflector } from '@nestjs/core';
import { ForbiddenException } from '@nestjs/common';
import { RolesGuard } from './roles.guard';
import { ROLES_KEY } from '../../../common/decorators/roles.decorator';
enum UserRole {
TRAINER = 'trainer',
COORDINATOR = 'coordinator',
MANAGER = 'manager',
RECEPTIONIST = 'receptionist',
CLUB_ADMIN = 'club_admin',
SUPER_ADMIN = 'super_admin',
}
describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
const createMockExecutionContext = (user?: { role?: string }) => ({
getHandler: jest.fn(),
getClass: jest.fn(),
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue({ user }),
}),
});
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
RolesGuard,
{
provide: Reflector,
useValue: {
getAllAndOverride: jest.fn(),
},
},
],
}).compile();
guard = module.get<RolesGuard>(RolesGuard);
reflector = module.get<Reflector>(Reflector);
});
it('should return true when no roles are required (no decorator)', () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue(undefined);
const context = createMockExecutionContext({ role: 'trainer' });
const result = guard.canActivate(context as any);
expect(result).toBe(true);
expect(reflector.getAllAndOverride).toHaveBeenCalledWith(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
});
it('should return true when required roles array is empty', () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue([]);
const context = createMockExecutionContext({ role: 'trainer' });
const result = guard.canActivate(context as any);
expect(result).toBe(true);
});
it.each([
[UserRole.TRAINER, 'trainer'],
[UserRole.COORDINATOR, 'coordinator'],
[UserRole.MANAGER, 'manager'],
[UserRole.RECEPTIONIST, 'receptionist'],
[UserRole.CLUB_ADMIN, 'club_admin'],
[UserRole.SUPER_ADMIN, 'super_admin'],
])('should return true when user role matches required role %s', (role, roleString) => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue([role]);
const context = createMockExecutionContext({ role: roleString });
const result = guard.canActivate(context as any);
expect(result).toBe(true);
});
it('should throw ForbiddenException when user role is not in required list', () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue([
UserRole.COORDINATOR,
UserRole.MANAGER,
]);
const context = createMockExecutionContext({ role: 'trainer' });
expect(() => guard.canActivate(context as any)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context as any)).toThrow(
'Access denied. Required roles: coordinator, manager',
);
});
it('should throw ForbiddenException when user has no role property', () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue([UserRole.TRAINER]);
const context = createMockExecutionContext({});
expect(() => guard.canActivate(context as any)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context as any)).toThrow('User role is not defined');
});
it('should throw ForbiddenException when there is no user object', () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue([UserRole.TRAINER]);
const context = createMockExecutionContext(undefined);
expect(() => guard.canActivate(context as any)).toThrow(ForbiddenException);
expect(() => guard.canActivate(context as any)).toThrow('User role is not defined');
});
it('should return true when user has one of multiple required roles', () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue([
UserRole.TRAINER,
UserRole.COORDINATOR,
UserRole.MANAGER,
]);
const context = createMockExecutionContext({ role: 'coordinator' });
const result = guard.canActivate(context as any);
expect(result).toBe(true);
});
it('should return true when super_admin matches in a multi-role requirement', () => {
(reflector.getAllAndOverride as jest.Mock).mockReturnValue([
UserRole.CLUB_ADMIN,
UserRole.SUPER_ADMIN,
]);
const context = createMockExecutionContext({ role: 'super_admin' });
const result = guard.canActivate(context as any);
expect(result).toBe(true);
});
it('should throw ForbiddenException with all required roles listed in message', () => {
const requiredRoles = [UserRole.MANAGER, UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN];
(reflector.getAllAndOverride as jest.Mock).mockReturnValue(requiredRoles);
const context = createMockExecutionContext({ role: 'trainer' });
expect(() => guard.canActivate(context as any)).toThrow(
'Access denied. Required roles: manager, club_admin, super_admin',
);
});
});

View File

@@ -0,0 +1,207 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TotpService } from './totp.service';
import { PrismaService } from '../../prisma/prisma.service';
jest.mock('otplib', () => ({
__esModule: true,
generateSecret: jest.fn().mockReturnValue('MOCK_SECRET_BASE32'),
generateURI: jest.fn().mockReturnValue(
'otpauth://totp/FitCRM:+79001234567?secret=MOCK_SECRET_BASE32&issuer=FitCRM',
),
verifySync: jest.fn(),
}));
jest.mock('qrcode', () => ({
toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,mockQR'),
}));
import { generateSecret, generateURI, verifySync } from 'otplib';
import * as QRCode from 'qrcode';
describe('TotpService', () => {
let service: TotpService;
let prisma: PrismaService;
const mockPrisma = {
$queryRawUnsafe: jest.fn(),
$executeRawUnsafe: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
TotpService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<TotpService>(TotpService);
prisma = module.get<PrismaService>(PrismaService);
});
describe('setup', () => {
it('should return secret and QR code for valid user', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{
id: 'user-1',
phone: '+79001234567',
totpSecret: null,
},
]);
const result = await service.setup('user-1');
expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('SELECT'),
'user-1',
);
expect(generateSecret).toHaveBeenCalled();
expect(result).toEqual(
expect.objectContaining({
secret: 'MOCK_SECRET_BASE32',
qrCodeDataUrl: 'data:image/png;base64,mockQR',
}),
);
});
it('should throw if user not found', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([]);
await expect(service.setup('nonexistent')).rejects.toThrow();
});
it('should throw if 2FA is already enabled', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{
id: 'user-1',
phone: '+79001234567',
totpSecret: 'EXISTING_SECRET',
},
]);
await expect(service.setup('user-1')).rejects.toThrow();
});
});
describe('verify', () => {
it('should save secret when TOTP code is valid', async () => {
(verifySync as jest.Mock).mockReturnValue({ valid: true });
mockPrisma.$executeRawUnsafe.mockResolvedValue(1);
await service.verify('user-1', '123456', 'MOCK_SECRET_BASE32');
expect(verifySync).toHaveBeenCalledWith({
token: '123456',
secret: 'MOCK_SECRET_BASE32',
});
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('UPDATE'),
'MOCK_SECRET_BASE32',
'user-1',
);
});
it('should throw when TOTP code is invalid', async () => {
(verifySync as jest.Mock).mockReturnValue({ valid: false });
await expect(
service.verify('user-1', '000000', 'MOCK_SECRET_BASE32'),
).rejects.toThrow();
expect(mockPrisma.$executeRawUnsafe).not.toHaveBeenCalled();
});
});
describe('validate', () => {
it('should return true when code is valid', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{
totpSecret: 'STORED_SECRET',
},
]);
(verifySync as jest.Mock).mockReturnValue({ valid: true });
const result = await service.validate('user-1', '123456');
expect(result).toBe(true);
expect(verifySync).toHaveBeenCalledWith({
token: '123456',
secret: 'STORED_SECRET',
});
});
it('should return false when code is invalid', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{
totpSecret: 'STORED_SECRET',
},
]);
(verifySync as jest.Mock).mockReturnValue({ valid: false });
const result = await service.validate('user-1', '000000');
expect(result).toBe(false);
});
it('should throw when 2FA is not enabled for user', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{
totpSecret: null,
},
]);
await expect(service.validate('user-1', '123456')).rejects.toThrow();
});
});
describe('disable', () => {
it('should remove totpSecret when valid code is provided', async () => {
// validate() will query for user's totpSecret
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{
totpSecret: 'STORED_SECRET',
},
]);
(verifySync as jest.Mock).mockReturnValue({ valid: true });
mockPrisma.$executeRawUnsafe.mockResolvedValue(1);
await service.disable('user-1', '123456');
expect(verifySync).toHaveBeenCalledWith({
token: '123456',
secret: 'STORED_SECRET',
});
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('UPDATE'),
'user-1',
);
});
it('should throw when invalid code is provided', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{
totpSecret: 'STORED_SECRET',
},
]);
(verifySync as jest.Mock).mockReturnValue({ valid: false });
await expect(service.disable('user-1', '000000')).rejects.toThrow();
expect(mockPrisma.$executeRawUnsafe).not.toHaveBeenCalled();
});
it('should throw when 2FA is not enabled', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([
{
totpSecret: null,
},
]);
await expect(service.disable('user-1', '123456')).rejects.toThrow();
expect(mockPrisma.$executeRawUnsafe).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,90 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { generateSecret, generateURI, verifySync } from 'otplib';
import * as QRCode from 'qrcode';
export interface TotpSetupResult {
secret: string;
otpauthUrl: string;
qrCodeDataUrl: string;
}
@Injectable()
export class TotpService {
constructor(private readonly prisma: PrismaService) {}
async setup(userId: string): Promise<TotpSetupResult> {
const users = await this.prisma.$queryRawUnsafe<
{ id: string; phone: string; totpSecret: string | null }[]
>(
`SELECT id, phone, "totpSecret" FROM users WHERE id = $1 LIMIT 1`,
userId,
);
if (!users.length) {
throw new BadRequestException('User not found');
}
const user = users[0];
if (user.totpSecret) {
throw new BadRequestException('2FA is already enabled');
}
const secret = generateSecret();
const otpauthUrl = generateURI({
issuer: 'FitCRM',
label: user.phone,
secret,
});
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);
return { secret, otpauthUrl, qrCodeDataUrl };
}
async verify(userId: string, token: string, secret: string): Promise<void> {
const result = verifySync({ token, secret });
if (!result.valid) {
throw new BadRequestException('Invalid TOTP code');
}
await this.prisma.$executeRawUnsafe(
`UPDATE users SET "totpSecret" = $1, "updatedAt" = NOW() WHERE id = $2`,
secret,
userId,
);
}
async validate(userId: string, token: string): Promise<boolean> {
const users = await this.prisma.$queryRawUnsafe<
{ totpSecret: string | null }[]
>(
`SELECT "totpSecret" FROM users WHERE id = $1 LIMIT 1`,
userId,
);
if (!users.length || !users[0].totpSecret) {
throw new BadRequestException('2FA is not enabled');
}
const result = verifySync({
token,
secret: users[0].totpSecret,
});
return result.valid;
}
async disable(userId: string, token: string): Promise<void> {
const isValid = await this.validate(userId, token);
if (!isValid) {
throw new BadRequestException('Invalid TOTP code');
}
await this.prisma.$executeRawUnsafe(
`UPDATE users SET "totpSecret" = NULL, "updatedAt" = NOW() WHERE id = $1`,
userId,
);
}
}

View File

@@ -0,0 +1,324 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ClientsService } from './clients.service';
import { PrismaService } from '../../prisma/prisma.service';
const mockEventEmitter = {
emit: jest.fn(),
};
const mockPrisma = {
client: {
findMany: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn().mockResolvedValue(1),
},
clientGoal: {
findMany: jest.fn(),
create: jest.fn(),
},
clientInjury: {
findMany: jest.fn(),
create: jest.fn(),
},
};
const CLUB_ID = 'club-uuid-001';
const CLIENT_ID = 'client-uuid-001';
const TRAINER_ID = 'trainer-uuid-001';
const makeClient = (overrides: Record<string, unknown> = {}) => ({
id: CLIENT_ID,
clubId: CLUB_ID,
firstName: 'Anna',
lastName: 'Ivanova',
phone: '+79001112233',
email: 'anna@example.com',
assignedTrainerId: TRAINER_ID,
lastActivityAt: new Date('2024-06-01'),
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
...overrides,
});
describe('ClientsService', () => {
let service: ClientsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ClientsService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: EventEmitter2, useValue: mockEventEmitter },
],
}).compile();
service = module.get<ClientsService>(ClientsService);
jest.clearAllMocks();
});
// ---------------------------------------------------------------------------
// findAll
// ---------------------------------------------------------------------------
describe('findAll', () => {
it('returns data with hasMore=false when results are within limit', async () => {
const clients = [
makeClient({ id: 'client-001', firstName: 'Anna' }),
makeClient({ id: 'client-002', firstName: 'Boris' }),
];
mockPrisma.client.findMany.mockResolvedValue(clients);
const result = await service.findAll(CLUB_ID, { limit: 5 });
expect(result.data).toHaveLength(2);
expect(result.hasMore).toBe(false);
expect(result.nextCursor).toBeNull();
expect(mockPrisma.client.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ clubId: CLUB_ID }),
take: 6,
}),
);
});
it('returns hasMore=true and nextCursor when more items exist', async () => {
const clients = [
makeClient({ id: 'client-001' }),
makeClient({ id: 'client-002' }),
makeClient({ id: 'client-003' }),
];
mockPrisma.client.findMany.mockResolvedValue(clients);
const result = await service.findAll(CLUB_ID, { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.hasMore).toBe(true);
expect(result.nextCursor).toBe('client-002');
});
it('passes cursor and skip=1 to findMany when cursor is provided', async () => {
mockPrisma.client.findMany.mockResolvedValue([]);
await service.findAll(CLUB_ID, { cursor: 'client-002', limit: 10 });
expect(mockPrisma.client.findMany).toHaveBeenCalledWith(
expect.objectContaining({
cursor: { id: 'client-002' },
skip: 1,
}),
);
});
it('applies search filter when search param is provided', async () => {
mockPrisma.client.findMany.mockResolvedValue([]);
await service.findAll(CLUB_ID, { search: 'Anna', limit: 10 });
expect(mockPrisma.client.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId: CLUB_ID,
OR: expect.arrayContaining([
expect.objectContaining({
firstName: expect.objectContaining({ contains: 'Anna', mode: 'insensitive' }),
}),
]),
}),
}),
);
});
it('applies assignedTrainerId filter when provided', async () => {
mockPrisma.client.findMany.mockResolvedValue([]);
await service.findAll(CLUB_ID, { assignedTrainerId: TRAINER_ID, limit: 10 });
expect(mockPrisma.client.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId: CLUB_ID,
assignedTrainerId: TRAINER_ID,
}),
}),
);
});
});
// ---------------------------------------------------------------------------
// findById
// ---------------------------------------------------------------------------
describe('findById', () => {
it('returns client with relations when found', async () => {
const client = makeClient();
mockPrisma.client.findFirst.mockResolvedValue(client);
const result = await service.findById(CLUB_ID, CLIENT_ID);
expect(result).toEqual(client);
expect(mockPrisma.client.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CLIENT_ID, clubId: CLUB_ID },
}),
);
});
it('throws NotFoundException when client does not exist', async () => {
mockPrisma.client.findFirst.mockResolvedValue(null);
await expect(
service.findById(CLUB_ID, 'nonexistent'),
).rejects.toThrow(NotFoundException);
});
});
// ---------------------------------------------------------------------------
// create
// ---------------------------------------------------------------------------
describe('create', () => {
it('creates a client and emits client.created event', async () => {
const dto = { firstName: 'Anna', lastName: 'Ivanova', phone: '+79001112233' };
const created = makeClient();
mockPrisma.client.create.mockResolvedValue(created);
const result = await service.create(CLUB_ID, dto);
expect(result).toEqual(created);
expect(mockPrisma.client.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
clubId: CLUB_ID,
firstName: dto.firstName,
lastName: dto.lastName,
phone: dto.phone,
}),
}),
);
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'client.created',
expect.objectContaining({ clubId: CLUB_ID }),
);
});
});
// ---------------------------------------------------------------------------
// update
// ---------------------------------------------------------------------------
describe('update', () => {
it('updates a client and emits client.updated event', async () => {
mockPrisma.client.count.mockResolvedValue(1);
const updated = makeClient({ firstName: 'Maria' });
mockPrisma.client.update.mockResolvedValue(updated);
const result = await service.update(CLUB_ID, CLIENT_ID, { firstName: 'Maria' });
expect(result).toEqual(updated);
expect(mockPrisma.client.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CLIENT_ID },
data: expect.objectContaining({ firstName: 'Maria' }),
}),
);
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'client.updated',
expect.objectContaining({ clubId: CLUB_ID }),
);
});
it('throws NotFoundException when client does not exist on update', async () => {
mockPrisma.client.count.mockResolvedValue(0);
await expect(
service.update(CLUB_ID, 'nonexistent', { firstName: 'Ghost' }),
).rejects.toThrow(NotFoundException);
expect(mockPrisma.client.update).not.toHaveBeenCalled();
expect(mockEventEmitter.emit).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// delete
// ---------------------------------------------------------------------------
describe('delete', () => {
it('deletes the client when it exists', async () => {
mockPrisma.client.count.mockResolvedValue(1);
mockPrisma.client.delete.mockResolvedValue(undefined);
await service.delete(CLUB_ID, CLIENT_ID);
expect(mockPrisma.client.delete).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CLIENT_ID },
}),
);
});
it('throws NotFoundException when client does not exist on delete', async () => {
mockPrisma.client.count.mockResolvedValue(0);
await expect(
service.delete(CLUB_ID, 'nonexistent'),
).rejects.toThrow(NotFoundException);
expect(mockPrisma.client.delete).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// findSleeping
// ---------------------------------------------------------------------------
describe('findSleeping', () => {
it('returns sleeping clients with date filter', async () => {
const sleepingClients = [
makeClient({ id: 'client-sleeping-001', lastActivityAt: new Date('2024-01-01') }),
];
mockPrisma.client.findMany.mockResolvedValue(sleepingClients);
const result = await service.findSleeping(CLUB_ID, { limit: 10 });
expect(result.data).toHaveLength(1);
expect(mockPrisma.client.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId: CLUB_ID,
lastActivityAt: expect.objectContaining({
lt: expect.any(Date),
}),
}),
}),
);
});
});
// ---------------------------------------------------------------------------
// assignTrainer
// ---------------------------------------------------------------------------
describe('assignTrainer', () => {
it('updates the assignedTrainerId on the client', async () => {
mockPrisma.client.count.mockResolvedValue(1);
const updated = makeClient({ assignedTrainerId: 'trainer-uuid-002' });
mockPrisma.client.update.mockResolvedValue(updated);
const result = await service.assignTrainer(CLUB_ID, CLIENT_ID, 'trainer-uuid-002');
expect(result).toEqual(updated);
expect(mockPrisma.client.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: CLIENT_ID },
data: expect.objectContaining({ assignedTrainerId: 'trainer-uuid-002' }),
}),
);
});
});
});

View File

@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateClientDto } from './dto/create-client.dto';
import { UpdateClientDto } from './dto/update-client.dto';
@@ -15,7 +16,10 @@ export interface PaginatedResult<T> {
@Injectable()
export class ClientsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* Find all clients with cursor-based pagination, search, and filters.
@@ -92,7 +96,7 @@ export class ClientsService {
* Create a new client within the club.
*/
async create(clubId: string, dto: CreateClientDto) {
return this.prisma.client.create({
const client = await this.prisma.client.create({
data: {
clubId,
firstName: dto.firstName,
@@ -112,6 +116,10 @@ export class ClientsService {
},
},
});
this.eventEmitter.emit('client.created', { clubId, data: client });
return client;
}
/**
@@ -120,7 +128,7 @@ export class ClientsService {
async update(clubId: string, id: string, dto: UpdateClientDto) {
await this.ensureClientExists(clubId, id);
return this.prisma.client.update({
const client = await this.prisma.client.update({
where: { id },
data: {
...(dto.firstName !== undefined ? { firstName: dto.firstName } : {}),
@@ -146,6 +154,10 @@ export class ClientsService {
},
},
});
this.eventEmitter.emit('client.updated', { clubId, data: client });
return client;
}
/**

View File

@@ -0,0 +1,396 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { FunnelService } from './funnel.service';
import { PrismaService } from '../../prisma/prisma.service';
const FunnelStage = {
NEW: 'NEW',
ASSIGNED: 'ASSIGNED',
COMPLETED: 'COMPLETED',
REGULAR: 'REGULAR',
TRANSFERRED: 'TRANSFERRED',
REFUSED: 'REFUSED',
} as const;
describe('FunnelService', () => {
let service: FunnelService;
let prisma: PrismaService;
let eventEmitter: EventEmitter2;
const mockPrisma = {
funnelEntry: {
findMany: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
groupBy: jest.fn(),
count: jest.fn(),
},
client: {
findUnique: jest.fn(),
update: jest.fn(),
count: jest.fn(),
},
$transaction: jest.fn(),
};
const mockEventEmitter = {
emit: jest.fn(),
};
const clubId = 'club-uuid-1';
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
FunnelService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: EventEmitter2, useValue: mockEventEmitter },
],
}).compile();
service = module.get<FunnelService>(FunnelService);
prisma = module.get<PrismaService>(PrismaService);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
// ---------------------------------------------------------------------------
// findByStage
// ---------------------------------------------------------------------------
describe('findByStage', () => {
it('should return paginated entries for a given stage', async () => {
const entries = [
{ id: 'entry-1', stage: FunnelStage.NEW, clientId: 'c1', clubId },
{ id: 'entry-2', stage: FunnelStage.NEW, clientId: 'c2', clubId },
];
mockPrisma.funnelEntry.findMany.mockResolvedValue(entries);
const result = await service.findByStage(clubId, {
stage: FunnelStage.NEW as any,
limit: 10,
});
expect(mockPrisma.funnelEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId,
stage: FunnelStage.NEW,
}),
}),
);
expect(result).toEqual({
data: entries,
nextCursor: null,
hasMore: false,
});
});
it('should pass cursor when provided', async () => {
mockPrisma.funnelEntry.findMany.mockResolvedValue([]);
await service.findByStage(clubId, {
stage: FunnelStage.ASSIGNED as any,
cursor: 'entry-cursor',
limit: 5,
});
expect(mockPrisma.funnelEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
cursor: expect.objectContaining({ id: 'entry-cursor' }),
skip: 1,
}),
);
});
});
// ---------------------------------------------------------------------------
// assignClient
// ---------------------------------------------------------------------------
describe('assignClient', () => {
const dto = { clientId: 'client-1', trainerId: 'trainer-1' };
const assignedById = 'coordinator-1';
it('should create an ASSIGNED entry and update client trainer', async () => {
const createdEntry = {
id: 'entry-new',
stage: FunnelStage.ASSIGNED,
clientId: dto.clientId,
trainerId: dto.trainerId,
clubId,
};
mockPrisma.client.count.mockResolvedValue(1);
mockPrisma.funnelEntry.findFirst.mockResolvedValue(null);
mockPrisma.$transaction.mockResolvedValue([createdEntry, {}]);
const result = await service.assignClient(clubId, dto as any, assignedById);
expect(mockPrisma.$transaction).toHaveBeenCalled();
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'funnel.assigned',
expect.objectContaining({
clubId,
data: createdEntry,
}),
);
});
});
// ---------------------------------------------------------------------------
// moveStage
// ---------------------------------------------------------------------------
describe('moveStage', () => {
const clientId = 'client-1';
it('should create a new entry when moving to a different stage', async () => {
const lastEntry = {
id: 'entry-prev',
stage: FunnelStage.ASSIGNED,
clientId,
clubId,
};
const newEntry = {
id: 'entry-new',
stage: FunnelStage.COMPLETED,
clientId,
clubId,
};
mockPrisma.client.count.mockResolvedValue(1);
mockPrisma.funnelEntry.findFirst.mockResolvedValue(lastEntry);
mockPrisma.funnelEntry.create.mockResolvedValue(newEntry);
const result = await service.moveStage(clubId, clientId, {
stage: FunnelStage.COMPLETED as any,
} as any);
expect(mockPrisma.funnelEntry.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ clubId, clientId }),
orderBy: expect.objectContaining({ createdAt: 'desc' }),
}),
);
expect(mockPrisma.funnelEntry.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
stage: FunnelStage.COMPLETED,
clientId,
clubId,
}),
}),
);
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'funnel.stage_changed',
expect.objectContaining({
clubId,
data: expect.objectContaining({
clientId,
stage: FunnelStage.COMPLETED,
}),
}),
);
});
it('should throw if no funnel history exists for the client', async () => {
mockPrisma.client.count.mockResolvedValue(1);
mockPrisma.funnelEntry.findFirst.mockResolvedValue(null);
await expect(
service.moveStage(clubId, clientId, { stage: FunnelStage.COMPLETED as any } as any),
).rejects.toThrow(BadRequestException);
});
it('should throw if attempting to move to the same stage', async () => {
const lastEntry = {
id: 'entry-prev',
stage: FunnelStage.ASSIGNED,
clientId,
clubId,
};
mockPrisma.client.count.mockResolvedValue(1);
mockPrisma.funnelEntry.findFirst.mockResolvedValue(lastEntry);
await expect(
service.moveStage(clubId, clientId, { stage: FunnelStage.ASSIGNED as any } as any),
).rejects.toThrow(BadRequestException);
});
});
// ---------------------------------------------------------------------------
// getConversionStats
// ---------------------------------------------------------------------------
describe('getConversionStats', () => {
it('should calculate conversion rates between stages', async () => {
// Mock count calls for each stage
mockPrisma.funnelEntry.count
.mockResolvedValueOnce(100) // NEW
.mockResolvedValueOnce(60) // ASSIGNED
.mockResolvedValueOnce(40) // COMPLETED
.mockResolvedValueOnce(25); // REGULAR
const result = await service.getConversionStats(clubId);
expect(result).toEqual(
expect.objectContaining({
newToAssigned: 60, // (60/100)*100 = 60
assignedToCompleted: 66.67, // round((40/60)*10000)/100
completedToRegular: 62.5, // (25/40)*100 = 62.5
}),
);
});
it('should return 0 for conversion rates when denominator is zero', async () => {
mockPrisma.funnelEntry.count
.mockResolvedValueOnce(0) // NEW
.mockResolvedValueOnce(0) // ASSIGNED
.mockResolvedValueOnce(0) // COMPLETED
.mockResolvedValueOnce(0); // REGULAR
const result = await service.getConversionStats(clubId);
expect(result).toEqual(
expect.objectContaining({
newToAssigned: 0,
assignedToCompleted: 0,
completedToRegular: 0,
}),
);
});
it('should accept optional date range filters', async () => {
mockPrisma.funnelEntry.count
.mockResolvedValue(10);
const dateFrom = '2026-01-01';
const dateTo = '2026-01-31';
await service.getConversionStats(clubId, dateFrom, dateTo);
expect(mockPrisma.funnelEntry.count).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId,
createdAt: expect.objectContaining({
gte: expect.any(Date),
lte: expect.any(Date),
}),
}),
}),
);
});
});
// ---------------------------------------------------------------------------
// getDistribution
// ---------------------------------------------------------------------------
describe('getDistribution', () => {
it('should return funnel entries grouped by stage', async () => {
const grouped = [
{ stage: FunnelStage.NEW, _count: { stage: 15 } },
{ stage: FunnelStage.ASSIGNED, _count: { stage: 10 } },
{ stage: FunnelStage.COMPLETED, _count: { stage: 5 } },
];
mockPrisma.funnelEntry.groupBy.mockResolvedValue(grouped);
const result = await service.getDistribution(clubId);
expect(mockPrisma.funnelEntry.groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ['stage'],
where: expect.objectContaining({ clubId }),
_count: expect.anything(),
}),
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({ stage: FunnelStage.NEW, count: 15 }),
expect.objectContaining({ stage: FunnelStage.ASSIGNED, count: 10 }),
expect.objectContaining({ stage: FunnelStage.COMPLETED, count: 5 }),
]),
);
});
});
// ---------------------------------------------------------------------------
// transferClient
// ---------------------------------------------------------------------------
describe('transferClient', () => {
it('should create a TRANSFERRED entry and reset assignedTrainerId', async () => {
const dto = {
clientId: 'client-1',
fromTrainerId: 'trainer-1',
toDepartmentId: 'dept-2',
};
const transferredById = 'coordinator-1';
const created = {
id: 'entry-t',
stage: FunnelStage.TRANSFERRED,
clientId: dto.clientId,
clubId,
};
mockPrisma.client.count.mockResolvedValue(1);
mockPrisma.funnelEntry.findFirst.mockResolvedValue(null);
mockPrisma.funnelEntry.create.mockResolvedValue(created);
mockPrisma.client.update.mockResolvedValue({});
await service.transferClient(clubId, dto as any, transferredById);
expect(mockPrisma.funnelEntry.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
stage: FunnelStage.TRANSFERRED,
clientId: dto.clientId,
clubId,
}),
}),
);
expect(mockPrisma.client.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: dto.clientId }),
data: expect.objectContaining({ assignedTrainerId: null }),
}),
);
});
});
// ---------------------------------------------------------------------------
// refuseClient
// ---------------------------------------------------------------------------
describe('refuseClient', () => {
it('should create a REFUSED entry with comment', async () => {
const clientId = 'client-1';
const dto = { comment: 'Client declined all offers' };
const created = {
id: 'entry-r',
stage: FunnelStage.REFUSED,
clientId,
clubId,
comment: dto.comment,
};
mockPrisma.client.count.mockResolvedValue(1);
mockPrisma.funnelEntry.findFirst.mockResolvedValue(null);
mockPrisma.funnelEntry.create.mockResolvedValue(created);
const result = await service.refuseClient(clubId, clientId, dto as any);
expect(mockPrisma.funnelEntry.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
stage: FunnelStage.REFUSED,
clientId,
clubId,
comment: dto.comment,
}),
}),
);
});
});
});

View File

@@ -1,4 +1,5 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '../../prisma/prisma.service';
import { AssignClientDto } from './dto/assign-client.dto';
import { MoveStageDto } from './dto/move-stage.dto';
@@ -30,7 +31,10 @@ export interface StageDistribution {
@Injectable()
export class FunnelService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* List funnel entries by stage with cursor-based pagination.
@@ -136,6 +140,8 @@ export class FunnelService {
}),
]);
this.eventEmitter.emit('funnel.assigned', { clubId, data: entry });
return entry;
}
@@ -159,7 +165,7 @@ export class FunnelService {
);
}
return this.prisma.funnelEntry.create({
const entry = await this.prisma.funnelEntry.create({
data: {
clubId,
clientId,
@@ -182,6 +188,10 @@ export class FunnelService {
},
},
});
this.eventEmitter.emit('funnel.stage_changed', { clubId, data: entry });
return entry;
}
/**

View File

@@ -0,0 +1,350 @@
import { Test, TestingModule } from '@nestjs/testing';
import { MeteringService } from './metering.service';
import { PrismaService } from '../../prisma/prisma.service';
describe('MeteringService', () => {
let service: MeteringService;
let mockPrisma: {
$queryRawUnsafe: jest.Mock;
$executeRawUnsafe: jest.Mock;
};
const clubId = 'club-001';
beforeEach(async () => {
jest.clearAllMocks();
mockPrisma = {
$queryRawUnsafe: jest.fn(),
$executeRawUnsafe: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
MeteringService,
{
provide: PrismaService,
useValue: mockPrisma,
},
],
}).compile();
service = module.get<MeteringService>(MeteringService);
});
describe('aggregateUsage', () => {
it('should count all resource types and upsert the usage record', async () => {
const mockUsageRecord = {
id: 'usage-1',
clubId,
period: new Date(),
activeUsers: 5,
clients: 100,
callStorageGb: 0,
webhooksSent: 320,
pushSent: 150,
apiRequests: 5000,
createdAt: new Date(),
updatedAt: new Date(),
};
mockPrisma.$queryRawUnsafe.mockImplementation((sql: string) => {
// The INSERT/upsert into usage_records (uses $queryRawUnsafe with RETURNING)
if (sql.includes('INSERT INTO usage_records')) {
return Promise.resolve([mockUsageRecord]);
}
// countActiveUsers: SELECT COUNT(*) FROM users
if (sql.includes('FROM users')) {
return Promise.resolve([{ count: BigInt(5) }]);
}
// countClients: SELECT COUNT(*) FROM clients
if (sql.includes('FROM clients')) {
return Promise.resolve([{ count: BigInt(100) }]);
}
// countWebhooksSent: SELECT COUNT(*) FROM webhook_logs
if (sql.includes('webhook_logs')) {
return Promise.resolve([{ count: BigInt(320) }]);
}
// countPushSent: SELECT COUNT(*) FROM notifications
if (sql.includes('FROM notifications')) {
return Promise.resolve([{ count: BigInt(150) }]);
}
// countApiRequests: SELECT ... FROM usage_records (a SELECT, not INSERT)
if (sql.includes('usage_records') && sql.includes('SELECT')) {
return Promise.resolve([{ apiRequests: 5000 }]);
}
return Promise.resolve([{ count: BigInt(0) }]);
});
await service.aggregateUsage(clubId);
expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalled();
// The upsert uses $queryRawUnsafe (not $executeRawUnsafe) because of RETURNING
const upsertCall = mockPrisma.$queryRawUnsafe.mock.calls.find(
(call: string[]) =>
call[0].includes('INSERT INTO usage_records'),
);
expect(upsertCall).toBeDefined();
});
it('should pass the correct clubId to all count queries', async () => {
const mockUsageRecord = {
id: 'usage-1',
clubId,
period: new Date(),
activeUsers: 0,
clients: 0,
callStorageGb: 0,
webhooksSent: 0,
pushSent: 0,
apiRequests: 0,
createdAt: new Date(),
updatedAt: new Date(),
};
mockPrisma.$queryRawUnsafe.mockImplementation((sql: string) => {
if (sql.includes('INSERT INTO usage_records')) {
return Promise.resolve([mockUsageRecord]);
}
if (sql.includes('usage_records') && sql.includes('SELECT')) {
return Promise.resolve([{ apiRequests: 0 }]);
}
return Promise.resolve([{ count: BigInt(0) }]);
});
await service.aggregateUsage(clubId);
const queryCalls = mockPrisma.$queryRawUnsafe.mock.calls;
for (const call of queryCalls) {
// Every query should have clubId as one of its parameters
// (except the INSERT which also has clubId as $1)
expect(call.slice(1)).toEqual(
expect.arrayContaining([expect.anything()]),
);
}
});
});
describe('getUsage', () => {
it('should return usage record when it exists', async () => {
const usageRecord = {
clubId,
activeUsers: 5,
clients: 100,
callStorageGb: 0,
webhooksSent: 320,
pushSent: 150,
apiRequests: 5000,
period: new Date('2026-02-01'),
};
mockPrisma.$queryRawUnsafe.mockResolvedValue([usageRecord]);
const result = await service.getUsage(clubId);
expect(result).toEqual(usageRecord);
expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('usage_records'),
clubId,
expect.anything(),
);
});
it('should return null when no usage record exists', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([]);
const result = await service.getUsage(clubId);
expect(result).toBeNull();
});
it('should accept an optional period parameter', async () => {
mockPrisma.$queryRawUnsafe.mockResolvedValue([]);
const period = new Date('2026-01-01');
await service.getUsage(clubId, period);
expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalledWith(
expect.any(String),
clubId,
period,
);
});
});
describe('checkLimits', () => {
it('should return exceeded flags when usage exceeds limits', async () => {
mockPrisma.$queryRawUnsafe.mockImplementation((sql: string) => {
// getUsage SELECT from usage_records
if (sql.includes('usage_records') && sql.includes('SELECT') && !sql.includes('INSERT')) {
return Promise.resolve([
{
id: 'usage-1',
clubId,
activeUsers: 15,
clients: 1500,
callStorageGb: 5,
webhooksSent: 12000,
pushSent: 6000,
apiRequests: 60000,
},
]);
}
// licenses query
if (sql.includes('licenses')) {
return Promise.resolve([
{
maxUsers: 10,
maxClients: 1000,
},
]);
}
// club_modules query
if (sql.includes('club_modules')) {
return Promise.resolve([
{
moduleId: 'webhooks',
limitsJson: { max_events: 10000 },
},
{
moduleId: 'push',
limitsJson: { max_messages: 5000 },
},
{
moduleId: 'sip_recording',
limitsJson: { max_storage_gb: 2 },
},
]);
}
return Promise.resolve([]);
});
const result = await service.checkLimits(clubId);
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
const exceededResources = result.filter((check: any) => check.exceeded);
expect(exceededResources.length).toBeGreaterThan(0);
});
it('should return empty array when no license is found', async () => {
mockPrisma.$queryRawUnsafe.mockImplementation((sql: string) => {
if (sql.includes('usage_records') && sql.includes('SELECT') && !sql.includes('INSERT')) {
return Promise.resolve([
{
id: 'usage-1',
clubId,
activeUsers: 5,
clients: 100,
callStorageGb: 0,
webhooksSent: 100,
pushSent: 50,
apiRequests: 1000,
},
]);
}
if (sql.includes('licenses')) {
return Promise.resolve([]);
}
if (sql.includes('club_modules')) {
return Promise.resolve([]);
}
return Promise.resolve([]);
});
const result = await service.checkLimits(clubId);
expect(Array.isArray(result)).toBe(true);
expect(result).toEqual([]);
});
it('should return no exceeded flags when usage is within limits', async () => {
mockPrisma.$queryRawUnsafe.mockImplementation((sql: string) => {
if (sql.includes('usage_records') && sql.includes('SELECT') && !sql.includes('INSERT')) {
return Promise.resolve([
{
id: 'usage-1',
clubId,
activeUsers: 5,
clients: 500,
callStorageGb: 0,
webhooksSent: 3000,
pushSent: 2000,
apiRequests: 20000,
},
]);
}
if (sql.includes('licenses')) {
return Promise.resolve([
{
maxUsers: 10,
maxClients: 1000,
},
]);
}
if (sql.includes('club_modules')) {
return Promise.resolve([
{
moduleId: 'webhooks',
limitsJson: { max_events: 10000 },
},
{
moduleId: 'push',
limitsJson: { max_messages: 5000 },
},
{
moduleId: 'sip_recording',
limitsJson: { max_storage_gb: 0 },
},
]);
}
return Promise.resolve([]);
});
const result = await service.checkLimits(clubId);
const exceededResources = result.filter((check: any) => check.exceeded);
expect(exceededResources).toEqual([]);
});
});
describe('recordApiRequest', () => {
it('should execute an upsert SQL to increment the apiRequests counter', async () => {
mockPrisma.$executeRawUnsafe.mockResolvedValue(1);
await service.recordApiRequest(clubId);
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalledTimes(1);
const sql = mockPrisma.$executeRawUnsafe.mock.calls[0][0];
expect(sql).toMatch(/INSERT|UPDATE|UPSERT|api/i);
expect(mockPrisma.$executeRawUnsafe.mock.calls[0]).toContain(clubId);
});
});
describe('recordWebhookSent', () => {
it('should execute an upsert SQL to increment the webhooksSent counter', async () => {
mockPrisma.$executeRawUnsafe.mockResolvedValue(1);
await service.recordWebhookSent(clubId);
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalledTimes(1);
const sql = mockPrisma.$executeRawUnsafe.mock.calls[0][0];
expect(sql).toMatch(/INSERT|UPDATE|UPSERT|webhook/i);
expect(mockPrisma.$executeRawUnsafe.mock.calls[0]).toContain(clubId);
});
});
describe('recordPushSent', () => {
it('should execute an upsert SQL to increment the pushSent counter', async () => {
mockPrisma.$executeRawUnsafe.mockResolvedValue(1);
await service.recordPushSent(clubId);
expect(mockPrisma.$executeRawUnsafe).toHaveBeenCalledTimes(1);
const sql = mockPrisma.$executeRawUnsafe.mock.calls[0][0];
expect(sql).toMatch(/INSERT|UPDATE|UPSERT|push/i);
expect(mockPrisma.$executeRawUnsafe.mock.calls[0]).toContain(clubId);
});
});
});

View File

@@ -0,0 +1,282 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotificationTriggersService } from './notification-triggers.service';
import { NotificationsService } from './notifications.service';
import { NotificationSenderService } from './notification-sender.service';
import { NotificationType } from '@prisma/client';
describe('NotificationTriggersService', () => {
let service: NotificationTriggersService;
let notificationsService: NotificationsService;
let senderService: NotificationSenderService;
const mockNotificationsService = {
create: jest.fn(),
};
const mockSenderService = {
shouldSend: jest.fn(),
getCustomText: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
NotificationTriggersService,
{ provide: NotificationsService, useValue: mockNotificationsService },
{ provide: NotificationSenderService, useValue: mockSenderService },
],
}).compile();
service = module.get<NotificationTriggersService>(NotificationTriggersService);
notificationsService = module.get<NotificationsService>(NotificationsService);
senderService = module.get<NotificationSenderService>(NotificationSenderService);
});
describe('handleFunnelAssigned', () => {
it('should call onClientAssigned with correct params', async () => {
const event = {
clubId: 'club-1',
data: {
trainer: { id: 'trainer-1' },
client: { id: 'client-1', firstName: 'Иван', lastName: 'Иванов' },
},
};
mockSenderService.shouldSend.mockResolvedValue(true);
mockSenderService.getCustomText.mockResolvedValue(null);
mockNotificationsService.create.mockResolvedValue({});
await service.handleFunnelAssigned(event);
expect(mockSenderService.shouldSend).toHaveBeenCalledWith(
'club-1',
NotificationType.CLIENT_ASSIGNED,
);
expect(mockNotificationsService.create).toHaveBeenCalledWith(
'club-1',
expect.objectContaining({
userId: 'trainer-1',
type: NotificationType.CLIENT_ASSIGNED,
}),
);
});
it('should return early if no trainer in event data', async () => {
const event = {
clubId: 'club-1',
data: {
trainer: null,
client: { id: 'client-1', firstName: 'Иван', lastName: 'Иванов' },
},
};
await service.handleFunnelAssigned(event);
expect(mockSenderService.shouldSend).not.toHaveBeenCalled();
expect(mockNotificationsService.create).not.toHaveBeenCalled();
});
it('should return early if no client in event data', async () => {
const event = {
clubId: 'club-1',
data: {
trainer: { id: 'trainer-1' },
client: null,
},
};
await service.handleFunnelAssigned(event);
expect(mockSenderService.shouldSend).not.toHaveBeenCalled();
expect(mockNotificationsService.create).not.toHaveBeenCalled();
});
});
describe('onClientAssigned', () => {
it('should create notification when sending is enabled', async () => {
mockSenderService.shouldSend.mockResolvedValue(true);
mockSenderService.getCustomText.mockResolvedValue(null);
mockNotificationsService.create.mockResolvedValue({});
await service['onClientAssigned']({
clubId: 'club-1',
trainerId: 'trainer-1',
clientId: 'client-1',
clientName: 'Иван Иванов',
});
expect(mockSenderService.shouldSend).toHaveBeenCalledWith(
'club-1',
NotificationType.CLIENT_ASSIGNED,
);
expect(mockNotificationsService.create).toHaveBeenCalledWith(
'club-1',
expect.objectContaining({
userId: 'trainer-1',
type: NotificationType.CLIENT_ASSIGNED,
}),
);
});
it('should skip notification when sending is disabled', async () => {
mockSenderService.shouldSend.mockResolvedValue(false);
await service['onClientAssigned']({
clubId: 'club-1',
trainerId: 'trainer-1',
clientId: 'client-1',
clientName: 'Иван Иванов',
});
expect(mockNotificationsService.create).not.toHaveBeenCalled();
});
it('should use custom text when available', async () => {
mockSenderService.shouldSend.mockResolvedValue(true);
mockSenderService.getCustomText.mockResolvedValue('Вам назначен новый клиент!');
mockNotificationsService.create.mockResolvedValue({});
await service['onClientAssigned']({
clubId: 'club-1',
trainerId: 'trainer-1',
clientId: 'client-1',
clientName: 'Иван Иванов',
});
expect(mockNotificationsService.create).toHaveBeenCalledWith(
'club-1',
expect.objectContaining({
body: 'Вам назначен новый клиент!',
}),
);
});
});
describe('handleTrainingCreated / onServiceBooked', () => {
it('should create notification with correct service name', async () => {
const event = {
clubId: 'club-1',
data: {
trainer: { id: 'trainer-1' },
client: { id: 'client-1', firstName: 'Иван', lastName: 'Иванов' },
service: { name: 'Персональная тренировка' },
},
};
mockSenderService.shouldSend.mockResolvedValue(true);
mockSenderService.getCustomText.mockResolvedValue(null);
mockNotificationsService.create.mockResolvedValue({});
await service.handleTrainingCreated(event);
expect(mockSenderService.shouldSend).toHaveBeenCalledWith(
'club-1',
NotificationType.SERVICE_BOOKED,
);
expect(mockNotificationsService.create).toHaveBeenCalledWith(
'club-1',
expect.objectContaining({
userId: 'trainer-1',
type: NotificationType.SERVICE_BOOKED,
}),
);
});
});
describe('handleSalePaid', () => {
it('should create notification with amount', async () => {
const event = {
clubId: 'club-1',
data: {
trainer: { id: 'trainer-1' },
client: { id: 'client-1', firstName: 'Иван', lastName: 'Иванов' },
amount: 5000,
},
};
mockSenderService.shouldSend.mockResolvedValue(true);
mockSenderService.getCustomText.mockResolvedValue(null);
mockNotificationsService.create.mockResolvedValue({});
await service.handleSalePaid(event);
expect(mockSenderService.shouldSend).toHaveBeenCalledWith(
'club-1',
NotificationType.SERVICE_PAID,
);
expect(mockNotificationsService.create).toHaveBeenCalledWith(
'club-1',
expect.objectContaining({
type: NotificationType.SERVICE_PAID,
}),
);
});
});
describe('handleTrainingCancelled', () => {
it('should create notification when enabled', async () => {
const event = {
clubId: 'club-1',
data: {
trainer: { id: 'trainer-1' },
client: { id: 'client-1', firstName: 'Иван', lastName: 'Иванов' },
scheduledAt: '2026-02-20T10:00:00Z',
},
};
mockSenderService.shouldSend.mockResolvedValue(true);
mockSenderService.getCustomText.mockResolvedValue(null);
mockNotificationsService.create.mockResolvedValue({});
await service.handleTrainingCancelled(event);
expect(mockSenderService.shouldSend).toHaveBeenCalledWith(
'club-1',
NotificationType.BOOKING_CANCELLED,
);
expect(mockNotificationsService.create).toHaveBeenCalled();
});
});
describe('handleTrainingCompleted', () => {
it('should create notification when enabled', async () => {
const event = {
clubId: 'club-1',
data: {
trainer: { id: 'trainer-1' },
client: { id: 'client-1', firstName: 'Иван', lastName: 'Иванов' },
},
};
mockSenderService.shouldSend.mockResolvedValue(true);
mockSenderService.getCustomText.mockResolvedValue(null);
mockNotificationsService.create.mockResolvedValue({});
await service.handleTrainingCompleted(event);
expect(mockSenderService.shouldSend).toHaveBeenCalledWith(
'club-1',
NotificationType.TRAINING_DEDUCTED,
);
expect(mockNotificationsService.create).toHaveBeenCalled();
});
it('should skip notification when disabled', async () => {
const event = {
clubId: 'club-1',
data: {
trainer: { id: 'trainer-1' },
client: { id: 'client-1', firstName: 'Иван', lastName: 'Иванов' },
},
};
mockSenderService.shouldSend.mockResolvedValue(false);
await service.handleTrainingCompleted(event);
expect(mockNotificationsService.create).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { NotificationType } from '@prisma/client';
import { NotificationsService } from './notifications.service';
import { NotificationSenderService } from './notification-sender.service';
@@ -84,9 +85,18 @@ export class NotificationTriggersService {
private readonly senderService: NotificationSenderService,
) {}
/**
* Trigger: new client assigned to a trainer.
*/
@OnEvent('funnel.assigned')
async handleFunnelAssigned(event: { clubId: string; data: any }): Promise<void> {
const { data } = event;
if (!data.trainer || !data.client) return;
await this.onClientAssigned({
clubId: event.clubId,
trainerId: data.trainer.id,
clientId: data.client.id,
clientName: `${data.client.firstName} ${data.client.lastName}`,
});
}
async onClientAssigned(payload: ClientAssignedPayload): Promise<void> {
await this.createIfEnabled(
payload.clubId,
@@ -98,9 +108,19 @@ export class NotificationTriggersService {
);
}
/**
* Trigger: client booked a service.
*/
@OnEvent('training.created')
async handleTrainingCreated(event: { clubId: string; data: any }): Promise<void> {
const { data } = event;
if (!data.trainer || !data.client || !data.service) return;
await this.onServiceBooked({
clubId: event.clubId,
trainerId: data.trainer.id,
clientId: data.client.id,
clientName: `${data.client.firstName} ${data.client.lastName}`,
serviceName: data.service.name,
});
}
async onServiceBooked(payload: ServiceBookedPayload): Promise<void> {
await this.createIfEnabled(
payload.clubId,
@@ -112,9 +132,19 @@ export class NotificationTriggersService {
);
}
/**
* Trigger: client paid for a service.
*/
@OnEvent('sale.paid')
async handleSalePaid(event: { clubId: string; data: any }): Promise<void> {
const { data } = event;
if (!data.trainer || !data.client) return;
await this.onServicePaid({
clubId: event.clubId,
trainerId: data.trainer.id,
clientId: data.client.id,
clientName: `${data.client.firstName} ${data.client.lastName}`,
amount: Number(data.amount),
});
}
async onServicePaid(payload: ServicePaidPayload): Promise<void> {
await this.createIfEnabled(
payload.clubId,
@@ -140,9 +170,19 @@ export class NotificationTriggersService {
);
}
/**
* Trigger: training/booking cancelled.
*/
@OnEvent('training.cancelled')
async handleTrainingCancelled(event: { clubId: string; data: any }): Promise<void> {
const { data } = event;
if (!data.trainer || !data.client) return;
await this.onTrainingCancelled({
clubId: event.clubId,
trainerId: data.trainer.id,
clientId: data.client.id,
clientName: `${data.client.firstName} ${data.client.lastName}`,
scheduledAt: data.scheduledAt,
});
}
async onTrainingCancelled(
payload: BookingCancelledPayload,
): Promise<void> {
@@ -156,9 +196,18 @@ export class NotificationTriggersService {
);
}
/**
* Trigger: training session deducted/completed.
*/
@OnEvent('training.completed')
async handleTrainingCompleted(event: { clubId: string; data: any }): Promise<void> {
const { data } = event;
if (!data.trainer || !data.client) return;
await this.onTrainingCompleted({
clubId: event.clubId,
trainerId: data.trainer.id,
clientId: data.client.id,
clientName: `${data.client.firstName} ${data.client.lastName}`,
});
}
async onTrainingCompleted(
payload: TrainingDeductedPayload,
): Promise<void> {

View File

@@ -0,0 +1,433 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { SalesService } from './sales.service';
import { PrismaService } from '../../prisma/prisma.service';
const SaleStatus = {
PENDING: 'PENDING',
PAID: 'PAID',
CANCELLED: 'CANCELLED',
REFUNDED: 'REFUNDED',
} as const;
describe('SalesService', () => {
let service: SalesService;
let prisma: PrismaService;
let eventEmitter: EventEmitter2;
const mockPrisma = {
sale: {
findMany: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
aggregate: jest.fn(),
},
client: {
count: jest.fn(),
},
package: {
findFirst: jest.fn(),
},
service: {
findFirst: jest.fn(),
},
$transaction: jest.fn(),
};
const mockEventEmitter = {
emit: jest.fn(),
};
const clubId = 'club-uuid-1';
const trainerId = 'trainer-uuid-1';
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
SalesService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: EventEmitter2, useValue: mockEventEmitter },
],
}).compile();
service = module.get<SalesService>(SalesService);
prisma = module.get<PrismaService>(PrismaService);
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
// ---------------------------------------------------------------------------
// findAll
// ---------------------------------------------------------------------------
describe('findAll', () => {
it('should return paginated sales', async () => {
const sales = [
{ id: 'sale-1', status: SaleStatus.PENDING, clubId, amount: 5000 },
{ id: 'sale-2', status: SaleStatus.PAID, clubId, amount: 3000 },
];
mockPrisma.sale.findMany.mockResolvedValue(sales);
const result = await service.findAll(clubId, { limit: 10 });
expect(mockPrisma.sale.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ clubId }),
}),
);
expect(result).toEqual({
data: sales,
hasMore: false,
nextCursor: null,
});
});
it('should support cursor-based pagination', async () => {
mockPrisma.sale.findMany.mockResolvedValue([]);
await service.findAll(clubId, { cursor: 'sale-cursor', limit: 5 });
expect(mockPrisma.sale.findMany).toHaveBeenCalledWith(
expect.objectContaining({
cursor: expect.objectContaining({ id: 'sale-cursor' }),
skip: 1,
}),
);
});
});
// ---------------------------------------------------------------------------
// findById
// ---------------------------------------------------------------------------
describe('findById', () => {
it('should return the sale when found', async () => {
const sale = { id: 'sale-1', status: SaleStatus.PENDING, clubId, amount: 5000 };
mockPrisma.sale.findFirst.mockResolvedValue(sale);
const result = await service.findById(clubId, 'sale-1');
expect(mockPrisma.sale.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'sale-1', clubId }),
}),
);
expect(result).toEqual(sale);
});
it('should throw NotFoundException when sale is not found', async () => {
mockPrisma.sale.findFirst.mockResolvedValue(null);
await expect(service.findById(clubId, 'nonexistent')).rejects.toThrow(
NotFoundException,
);
});
});
// ---------------------------------------------------------------------------
// create
// ---------------------------------------------------------------------------
describe('create', () => {
const dto = {
clientId: 'client-1',
packageId: 'pkg-1',
amount: 5000,
description: 'Personal training',
};
it('should create a sale and emit sale.created event', async () => {
const created = {
id: 'sale-new',
...dto,
trainerId,
clubId,
status: SaleStatus.PENDING,
};
mockPrisma.client.count.mockResolvedValue(1);
mockPrisma.sale.create.mockResolvedValue(created);
const result = await service.create(clubId, trainerId, dto as any);
expect(mockPrisma.client.count).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: dto.clientId, clubId }),
}),
);
expect(mockPrisma.sale.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
clientId: dto.clientId,
trainerId,
clubId,
amount: 5000,
}),
}),
);
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'sale.created',
expect.objectContaining({ clubId, data: created }),
);
});
it('should auto-fill amount from package price when amount is 0', async () => {
const pkg = { id: 'pkg-1', price: 7500, clubId };
const dtoZero = { ...dto, amount: 0 };
const created = {
id: 'sale-auto',
...dtoZero,
amount: 7500,
trainerId,
clubId,
status: SaleStatus.PENDING,
};
mockPrisma.client.count.mockResolvedValue(1);
mockPrisma.package.findFirst.mockResolvedValue(pkg);
mockPrisma.sale.create.mockResolvedValue(created);
const result = await service.create(clubId, trainerId, dtoZero as any);
expect(mockPrisma.package.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'pkg-1', clubId }),
}),
);
expect(mockPrisma.sale.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ amount: 7500 }),
}),
);
});
it('should throw NotFoundException when client does not exist', async () => {
mockPrisma.client.count.mockResolvedValue(0);
await expect(
service.create(clubId, trainerId, dto as any),
).rejects.toThrow(NotFoundException);
});
});
// ---------------------------------------------------------------------------
// update
// ---------------------------------------------------------------------------
describe('update', () => {
const updateDto = { description: 'Updated description' };
it('should update a PENDING sale', async () => {
const existing = { id: 'sale-1', status: SaleStatus.PENDING, clubId };
const updated = { ...existing, ...updateDto };
mockPrisma.sale.findFirst.mockResolvedValue(existing);
mockPrisma.sale.update.mockResolvedValue(updated);
const result = await service.update(clubId, 'sale-1', updateDto as any);
expect(mockPrisma.sale.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'sale-1' }),
data: expect.objectContaining(updateDto),
}),
);
expect(result).toEqual(updated);
});
it('should throw BadRequestException when sale is not PENDING', async () => {
const existing = { id: 'sale-1', status: SaleStatus.PAID, clubId };
mockPrisma.sale.findFirst.mockResolvedValue(existing);
await expect(
service.update(clubId, 'sale-1', updateDto as any),
).rejects.toThrow(BadRequestException);
});
});
// ---------------------------------------------------------------------------
// markAsPaid
// ---------------------------------------------------------------------------
describe('markAsPaid', () => {
it('should mark a PENDING sale as PAID and set paidAt', async () => {
const existing = { id: 'sale-1', status: SaleStatus.PENDING, clubId };
const paid = {
...existing,
status: SaleStatus.PAID,
paidAt: expect.any(Date),
};
mockPrisma.sale.findFirst.mockResolvedValue(existing);
mockPrisma.sale.update.mockResolvedValue(paid);
const result = await service.markAsPaid(clubId, 'sale-1');
expect(mockPrisma.sale.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'sale-1' }),
data: expect.objectContaining({
status: SaleStatus.PAID,
paidAt: expect.any(Date),
}),
}),
);
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'sale.paid',
expect.objectContaining({ clubId, data: paid }),
);
});
it('should throw BadRequestException when sale is not PENDING', async () => {
const existing = { id: 'sale-1', status: SaleStatus.CANCELLED, clubId };
mockPrisma.sale.findFirst.mockResolvedValue(existing);
await expect(service.markAsPaid(clubId, 'sale-1')).rejects.toThrow(
BadRequestException,
);
});
});
// ---------------------------------------------------------------------------
// cancel
// ---------------------------------------------------------------------------
describe('cancel', () => {
it('should cancel a PENDING sale', async () => {
const existing = { id: 'sale-1', status: SaleStatus.PENDING, clubId };
const cancelled = { ...existing, status: SaleStatus.CANCELLED };
mockPrisma.sale.findFirst.mockResolvedValue(existing);
mockPrisma.sale.update.mockResolvedValue(cancelled);
const result = await service.cancel(clubId, 'sale-1');
expect(mockPrisma.sale.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'sale-1' }),
data: expect.objectContaining({ status: SaleStatus.CANCELLED }),
}),
);
});
it('should throw BadRequestException when sale is not PENDING', async () => {
const existing = { id: 'sale-1', status: SaleStatus.PAID, clubId };
mockPrisma.sale.findFirst.mockResolvedValue(existing);
await expect(service.cancel(clubId, 'sale-1')).rejects.toThrow(
BadRequestException,
);
});
});
// ---------------------------------------------------------------------------
// refund
// ---------------------------------------------------------------------------
describe('refund', () => {
it('should refund a PAID sale', async () => {
const existing = { id: 'sale-1', status: SaleStatus.PAID, clubId };
const refunded = { ...existing, status: SaleStatus.REFUNDED };
mockPrisma.sale.findFirst.mockResolvedValue(existing);
mockPrisma.sale.update.mockResolvedValue(refunded);
const result = await service.refund(clubId, 'sale-1');
expect(mockPrisma.sale.update).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: 'sale-1' }),
data: expect.objectContaining({ status: SaleStatus.REFUNDED }),
}),
);
});
it('should throw BadRequestException when sale is PENDING', async () => {
const existing = { id: 'sale-1', status: SaleStatus.PENDING, clubId };
mockPrisma.sale.findFirst.mockResolvedValue(existing);
await expect(service.refund(clubId, 'sale-1')).rejects.toThrow(
BadRequestException,
);
});
});
// ---------------------------------------------------------------------------
// getDebts
// ---------------------------------------------------------------------------
describe('getDebts', () => {
it('should return only PENDING sales', async () => {
const debts = [
{ id: 'sale-1', status: SaleStatus.PENDING, amount: 5000, clubId },
{ id: 'sale-2', status: SaleStatus.PENDING, amount: 3000, clubId },
];
mockPrisma.sale.findMany.mockResolvedValue(debts);
const result = await service.getDebts(clubId, { limit: 10 });
expect(mockPrisma.sale.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId,
status: SaleStatus.PENDING,
}),
}),
);
expect(result).toEqual({
data: debts,
hasMore: false,
nextCursor: null,
});
});
});
// ---------------------------------------------------------------------------
// getDebtsSummary
// ---------------------------------------------------------------------------
describe('getDebtsSummary', () => {
it('should return totalAmount and count for PENDING sales', async () => {
mockPrisma.sale.aggregate.mockResolvedValue({
_sum: { amount: 25000 },
_count: { id: 8 },
});
const result = await service.getDebtsSummary(clubId);
expect(mockPrisma.sale.aggregate).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId,
status: SaleStatus.PENDING,
}),
_sum: expect.objectContaining({ amount: true }),
_count: expect.objectContaining({ id: true }),
}),
);
expect(result).toEqual(
expect.objectContaining({
totalAmount: 25000,
count: 8,
}),
);
});
it('should filter by trainerId when provided', async () => {
mockPrisma.sale.aggregate.mockResolvedValue({
_sum: { amount: 10000 },
_count: { id: 3 },
});
await service.getDebtsSummary(clubId, trainerId);
expect(mockPrisma.sale.aggregate).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId,
trainerId,
status: SaleStatus.PENDING,
}),
}),
);
});
});
});

View File

@@ -3,6 +3,7 @@ import {
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateSaleDto } from './dto/create-sale.dto';
import { UpdateSaleDto } from './dto/update-sale.dto';
@@ -31,7 +32,10 @@ const SALE_INCLUDE = {
@Injectable()
export class SalesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
/** Find all sales with cursor-based pagination and filters. */
async findAll(
@@ -92,7 +96,7 @@ export class SalesService {
}
}
return this.prisma.sale.create({
const sale = await this.prisma.sale.create({
data: {
clubId,
trainerId,
@@ -104,6 +108,10 @@ export class SalesService {
},
include: SALE_INCLUDE,
});
this.eventEmitter.emit('sale.created', { clubId, data: sale });
return sale;
}
/** Update a PENDING sale only. */
@@ -140,11 +148,15 @@ export class SalesService {
const sale = await this.ensureSaleExists(clubId, id);
this.assertStatus(sale.status, SaleStatus.PENDING, 'mark as paid');
return this.prisma.sale.update({
const paid = await this.prisma.sale.update({
where: { id },
data: { status: SaleStatus.PAID, paidAt: new Date() },
include: SALE_INCLUDE,
});
this.eventEmitter.emit('sale.paid', { clubId, data: paid });
return paid;
}
/** Refund a PAID sale. */

View File

@@ -0,0 +1,415 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ScheduleService } from './schedule.service';
import { PrismaService } from '../../prisma/prisma.service';
const mockEventEmitter = {
emit: jest.fn(),
};
const mockPrisma = {
training: {
findMany: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
client: {
update: jest.fn(),
},
$transaction: jest.fn(),
};
const CLUB_ID = 'club-uuid-001';
const TRAINING_ID = 'training-uuid-001';
const TRAINER_ID = 'trainer-uuid-001';
const CLIENT_ID = 'client-uuid-001';
const makeTraining = (overrides: Record<string, unknown> = {}) => ({
id: TRAINING_ID,
clubId: CLUB_ID,
trainerId: TRAINER_ID,
clientId: CLIENT_ID,
status: 'PLANNED',
scheduledAt: new Date('2024-07-01T10:00:00Z'),
duration: 60,
isRepeating: false,
cancelReason: null,
cancelComment: null,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
...overrides,
});
describe('ScheduleService', () => {
let service: ScheduleService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ScheduleService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: EventEmitter2, useValue: mockEventEmitter },
],
}).compile();
service = module.get<ScheduleService>(ScheduleService);
jest.clearAllMocks();
});
// ---------------------------------------------------------------------------
// findAll
// ---------------------------------------------------------------------------
describe('findAll', () => {
it('returns data with hasMore=false when results are within limit', async () => {
const trainings = [
makeTraining({ id: 'training-001' }),
makeTraining({ id: 'training-002' }),
];
mockPrisma.training.findMany.mockResolvedValue(trainings);
const result = await service.findAll(CLUB_ID, { limit: 5 });
expect(result.data).toHaveLength(2);
expect(result.hasMore).toBe(false);
expect(result.nextCursor).toBeNull();
expect(mockPrisma.training.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ clubId: CLUB_ID }),
take: 6,
}),
);
});
it('returns hasMore=true and nextCursor when more items exist', async () => {
const trainings = [
makeTraining({ id: 'training-001' }),
makeTraining({ id: 'training-002' }),
makeTraining({ id: 'training-003' }),
];
mockPrisma.training.findMany.mockResolvedValue(trainings);
const result = await service.findAll(CLUB_ID, { limit: 2 });
expect(result.data).toHaveLength(2);
expect(result.hasMore).toBe(true);
expect(result.nextCursor).toBe('training-002');
});
it('passes cursor and skip=1 to findMany when cursor is provided', async () => {
mockPrisma.training.findMany.mockResolvedValue([]);
await service.findAll(CLUB_ID, { cursor: 'training-002', limit: 10 });
expect(mockPrisma.training.findMany).toHaveBeenCalledWith(
expect.objectContaining({
cursor: { id: 'training-002' },
skip: 1,
}),
);
});
});
// ---------------------------------------------------------------------------
// findById
// ---------------------------------------------------------------------------
describe('findById', () => {
it('returns training with relations when found', async () => {
const training = makeTraining();
mockPrisma.training.findFirst.mockResolvedValue(training);
const result = await service.findById(CLUB_ID, TRAINING_ID);
expect(result).toEqual(training);
expect(mockPrisma.training.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: TRAINING_ID, clubId: CLUB_ID },
}),
);
});
it('throws NotFoundException when training does not exist', async () => {
mockPrisma.training.findFirst.mockResolvedValue(null);
await expect(
service.findById(CLUB_ID, 'nonexistent'),
).rejects.toThrow(NotFoundException);
});
});
// ---------------------------------------------------------------------------
// create
// ---------------------------------------------------------------------------
describe('create', () => {
it('creates a single training and emits training.created event', async () => {
const dto = {
clientId: CLIENT_ID,
scheduledAt: '2024-07-01T10:00:00Z',
duration: 60,
};
const created = makeTraining();
mockPrisma.training.create.mockResolvedValue(created);
const result = await service.create(CLUB_ID, TRAINER_ID, dto);
expect(result).toEqual(created);
expect(mockPrisma.training.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
clubId: CLUB_ID,
trainerId: TRAINER_ID,
clientId: CLIENT_ID,
}),
}),
);
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'training.created',
expect.objectContaining({ clubId: CLUB_ID }),
);
});
});
// ---------------------------------------------------------------------------
// update
// ---------------------------------------------------------------------------
describe('update', () => {
it('updates training when status is PLANNED', async () => {
const training = makeTraining({ status: 'PLANNED' });
mockPrisma.training.findFirst.mockResolvedValue(training);
const updated = makeTraining({ duration: 90 });
mockPrisma.training.update.mockResolvedValue(updated);
const result = await service.update(CLUB_ID, TRAINING_ID, { duration: 90 });
expect(result).toEqual(updated);
expect(mockPrisma.training.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: TRAINING_ID },
data: expect.objectContaining({ duration: 90 }),
}),
);
});
it('throws BadRequestException when status is not PLANNED', async () => {
const training = makeTraining({ status: 'CONFIRMED' });
mockPrisma.training.findFirst.mockResolvedValue(training);
await expect(
service.update(CLUB_ID, TRAINING_ID, { duration: 90 }),
).rejects.toThrow(BadRequestException);
expect(mockPrisma.training.update).not.toHaveBeenCalled();
});
it('throws NotFoundException when training does not exist on update', async () => {
mockPrisma.training.findFirst.mockResolvedValue(null);
await expect(
service.update(CLUB_ID, 'nonexistent', { duration: 90 }),
).rejects.toThrow(NotFoundException);
expect(mockPrisma.training.update).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// cancel
// ---------------------------------------------------------------------------
describe('cancel', () => {
it('cancels training when status is PLANNED', async () => {
const training = makeTraining({ status: 'PLANNED' });
mockPrisma.training.findFirst.mockResolvedValue(training);
const cancelled = makeTraining({ status: 'CANCELLED', cancelReason: 'CLIENT_CANCEL' });
mockPrisma.training.update.mockResolvedValue(cancelled);
const result = await service.cancel(CLUB_ID, TRAINING_ID, { cancelReason: 'CLIENT_CANCEL' as any });
expect(result).toEqual(cancelled);
expect(mockPrisma.training.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: TRAINING_ID },
data: expect.objectContaining({
status: 'CANCELLED',
cancelReason: 'CLIENT_CANCEL',
}),
}),
);
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'training.cancelled',
expect.objectContaining({ clubId: CLUB_ID }),
);
});
it('cancels training when status is CONFIRMED', async () => {
const training = makeTraining({ status: 'CONFIRMED' });
mockPrisma.training.findFirst.mockResolvedValue(training);
const cancelled = makeTraining({ status: 'CANCELLED' });
mockPrisma.training.update.mockResolvedValue(cancelled);
const result = await service.cancel(CLUB_ID, TRAINING_ID, { cancelReason: 'SICK' as any });
expect(result).toEqual(cancelled);
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'training.cancelled',
expect.any(Object),
);
});
it('throws BadRequestException when cancelling a COMPLETED training', async () => {
const training = makeTraining({ status: 'COMPLETED' });
mockPrisma.training.findFirst.mockResolvedValue(training);
await expect(
service.cancel(CLUB_ID, TRAINING_ID, { cancelReason: 'CLIENT_CANCEL' as any }),
).rejects.toThrow(BadRequestException);
expect(mockPrisma.training.update).not.toHaveBeenCalled();
expect(mockEventEmitter.emit).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// reschedule
// ---------------------------------------------------------------------------
describe('reschedule', () => {
it('reschedules training and stores previous time in cancelComment', async () => {
const training = makeTraining({
status: 'PLANNED',
scheduledAt: new Date('2024-07-01T10:00:00Z'),
});
mockPrisma.training.findFirst.mockResolvedValue(training);
const rescheduled = makeTraining({
scheduledAt: new Date('2024-07-02T14:00:00Z'),
});
mockPrisma.training.update.mockResolvedValue(rescheduled);
const result = await service.reschedule(CLUB_ID, TRAINING_ID, {
scheduledAt: '2024-07-02T14:00:00Z',
});
expect(result).toEqual(rescheduled);
expect(mockPrisma.training.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: TRAINING_ID },
data: expect.objectContaining({
scheduledAt: new Date('2024-07-02T14:00:00Z'),
}),
}),
);
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'training.rescheduled',
expect.objectContaining({ clubId: CLUB_ID }),
);
});
it('throws BadRequestException when rescheduling a CANCELLED training', async () => {
const training = makeTraining({ status: 'CANCELLED' });
mockPrisma.training.findFirst.mockResolvedValue(training);
await expect(
service.reschedule(CLUB_ID, TRAINING_ID, {
scheduledAt: '2024-07-02T14:00:00Z',
}),
).rejects.toThrow(BadRequestException);
expect(mockPrisma.training.update).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// complete
// ---------------------------------------------------------------------------
describe('complete', () => {
it('completes training in transaction and updates client lastActivityAt', async () => {
const training = makeTraining({ status: 'CONFIRMED' });
mockPrisma.training.findFirst.mockResolvedValue(training);
const completed = makeTraining({ status: 'COMPLETED' });
mockPrisma.$transaction.mockImplementation(async (cb: (tx: unknown) => Promise<unknown>) => {
const tx = {
training: {
update: jest.fn().mockResolvedValue(completed),
},
client: {
update: jest.fn().mockResolvedValue(undefined),
},
};
return cb(tx);
});
const result = await service.complete(CLUB_ID, TRAINING_ID);
expect(result).toEqual(completed);
expect(mockPrisma.$transaction).toHaveBeenCalled();
expect(mockEventEmitter.emit).toHaveBeenCalledWith(
'training.completed',
expect.objectContaining({ clubId: CLUB_ID }),
);
});
it('throws BadRequestException when completing a CANCELLED training', async () => {
const training = makeTraining({ status: 'CANCELLED' });
mockPrisma.training.findFirst.mockResolvedValue(training);
await expect(
service.complete(CLUB_ID, TRAINING_ID),
).rejects.toThrow(BadRequestException);
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
expect(mockEventEmitter.emit).not.toHaveBeenCalled();
});
it('throws NotFoundException when training does not exist on complete', async () => {
mockPrisma.training.findFirst.mockResolvedValue(null);
await expect(
service.complete(CLUB_ID, 'nonexistent'),
).rejects.toThrow(NotFoundException);
expect(mockPrisma.$transaction).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// confirm
// ---------------------------------------------------------------------------
describe('confirm', () => {
it('confirms training when status is PLANNED', async () => {
const training = makeTraining({ status: 'PLANNED' });
mockPrisma.training.findFirst.mockResolvedValue(training);
const confirmed = makeTraining({ status: 'CONFIRMED' });
mockPrisma.training.update.mockResolvedValue(confirmed);
const result = await service.confirm(CLUB_ID, TRAINING_ID);
expect(result).toEqual(confirmed);
expect(mockPrisma.training.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: TRAINING_ID },
data: expect.objectContaining({ status: 'CONFIRMED' }),
}),
);
});
it('throws BadRequestException when confirming a non-PLANNED training', async () => {
const training = makeTraining({ status: 'COMPLETED' });
mockPrisma.training.findFirst.mockResolvedValue(training);
await expect(
service.confirm(CLUB_ID, TRAINING_ID),
).rejects.toThrow(BadRequestException);
expect(mockPrisma.training.update).not.toHaveBeenCalled();
});
});
});

View File

@@ -3,6 +3,7 @@ import {
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateTrainingDto } from './dto/create-training.dto';
import { UpdateTrainingDto } from './dto/update-training.dto';
@@ -43,7 +44,10 @@ const TRAINING_INCLUDE = {
@Injectable()
export class ScheduleService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* Find all trainings with cursor-based pagination and filters.
@@ -123,16 +127,18 @@ export class ScheduleService {
const duration = dto.duration ?? 60;
if (dto.isRepeating && dto.repeatCount && dto.repeatCount > 1) {
return this.createRepeatingTrainings(
const result = await this.createRepeatingTrainings(
clubId,
trainerId,
dto,
scheduledAt,
duration,
);
this.eventEmitter.emit('training.created', { clubId, data: result });
return result;
}
return this.prisma.training.create({
const training = await this.prisma.training.create({
data: {
clubId,
trainerId,
@@ -145,6 +151,10 @@ export class ScheduleService {
},
include: TRAINING_INCLUDE,
});
this.eventEmitter.emit('training.created', { clubId, data: training });
return training;
}
/**
@@ -188,7 +198,7 @@ export class ScheduleService {
);
}
return this.prisma.training.update({
const cancelled = await this.prisma.training.update({
where: { id },
data: {
status: 'CANCELLED',
@@ -197,6 +207,10 @@ export class ScheduleService {
},
include: TRAINING_INCLUDE,
});
this.eventEmitter.emit('training.cancelled', { clubId, data: cancelled });
return cancelled;
}
/**
@@ -215,7 +229,7 @@ export class ScheduleService {
const previousTime = training.scheduledAt.toISOString();
const rescheduleNote = `Rescheduled from ${previousTime}`;
return this.prisma.training.update({
const rescheduled = await this.prisma.training.update({
where: { id },
data: {
scheduledAt: new Date(dto.scheduledAt),
@@ -227,6 +241,10 @@ export class ScheduleService {
},
include: TRAINING_INCLUDE,
});
this.eventEmitter.emit('training.rescheduled', { clubId, data: rescheduled });
return rescheduled;
}
/**
@@ -241,7 +259,7 @@ export class ScheduleService {
);
}
return this.prisma.$transaction(async (tx) => {
const completed = await this.prisma.$transaction(async (tx) => {
const updated = await tx.training.update({
where: { id },
data: { status: 'COMPLETED' },
@@ -255,6 +273,10 @@ export class ScheduleService {
return updated;
});
this.eventEmitter.emit('training.completed', { clubId, data: completed });
return completed;
}
/**

View File

@@ -0,0 +1,443 @@
import { Test, TestingModule } from '@nestjs/testing';
import { StatsService } from './stats.service';
import { PrismaService } from '../../prisma/prisma.service';
const TrainingStatus = {
PLANNED: 'PLANNED',
CONFIRMED: 'CONFIRMED',
COMPLETED: 'COMPLETED',
CANCELLED: 'CANCELLED',
NO_SHOW: 'NO_SHOW',
} as const;
const FunnelStage = {
NEW: 'NEW',
ASSIGNED: 'ASSIGNED',
COMPLETED: 'COMPLETED',
REGULAR: 'REGULAR',
TRANSFERRED: 'TRANSFERRED',
REFUSED: 'REFUSED',
} as const;
describe('StatsService', () => {
let service: StatsService;
let prisma: PrismaService;
const mockPrisma = {
training: {
count: jest.fn(),
groupBy: jest.fn(),
},
client: {
count: jest.fn(),
groupBy: jest.fn(),
},
sale: {
count: jest.fn(),
aggregate: jest.fn(),
groupBy: jest.fn(),
},
funnelEntry: {
count: jest.fn(),
groupBy: jest.fn(),
},
user: {
findMany: jest.fn(),
},
ratingPeriod: {
findMany: jest.fn(),
},
$queryRaw: jest.fn(),
};
const clubId = 'club-uuid-1';
const trainerId = 'trainer-uuid-1';
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
StatsService,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
service = module.get<StatsService>(StatsService);
prisma = module.get<PrismaService>(PrismaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
// ---------------------------------------------------------------------------
// getSummary
// ---------------------------------------------------------------------------
describe('getSummary', () => {
it('should return all dashboard KPIs', async () => {
mockPrisma.training.count
.mockResolvedValueOnce(50) // total trainings
.mockResolvedValueOnce(30) // completed trainings
.mockResolvedValueOnce(10) // cancelled trainings
.mockResolvedValueOnce(8); // upcoming trainings
mockPrisma.client.count
.mockResolvedValueOnce(120) // total clients
.mockResolvedValueOnce(15) // new clients
.mockResolvedValueOnce(5); // sleeping clients
mockPrisma.sale.aggregate
.mockResolvedValueOnce({ _sum: { amount: 150000 } }) // total sales amount
.mockResolvedValueOnce({ _sum: { amount: 100000 } }); // paid sales amount
const result = await service.getSummary(clubId);
expect(result).toEqual(
expect.objectContaining({
totalTrainings: 50,
completedTrainings: 30,
cancelledTrainings: 10,
totalClients: 120,
newClients: 15,
totalSalesAmount: 150000,
paidSalesAmount: 100000,
upcomingTrainings: 8,
sleepingClients: 5,
}),
);
});
it('should filter by trainerId when provided', async () => {
mockPrisma.training.count.mockResolvedValue(15);
mockPrisma.client.count.mockResolvedValue(20);
mockPrisma.sale.aggregate.mockResolvedValue({
_sum: { amount: 40000 },
});
await service.getSummary(clubId, trainerId);
expect(mockPrisma.training.count).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ clubId, trainerId }),
}),
);
});
it('should filter by date range when provided', async () => {
mockPrisma.training.count.mockResolvedValue(10);
mockPrisma.client.count.mockResolvedValue(15);
mockPrisma.sale.aggregate.mockResolvedValue({
_sum: { amount: 20000 },
});
const dateFrom = '2026-01-01';
const dateTo = '2026-01-31';
await service.getSummary(clubId, undefined, dateFrom, dateTo);
expect(mockPrisma.training.count).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId,
scheduledAt: expect.objectContaining({
gte: expect.any(Date),
lte: expect.any(Date),
}),
}),
}),
);
});
});
// ---------------------------------------------------------------------------
// getFunnelConversion
// ---------------------------------------------------------------------------
describe('getFunnelConversion', () => {
it('should calculate conversion rates between funnel stages', async () => {
mockPrisma.funnelEntry.count
.mockResolvedValueOnce(200) // NEW
.mockResolvedValueOnce(120) // ASSIGNED
.mockResolvedValueOnce(80) // COMPLETED
.mockResolvedValueOnce(50); // REGULAR
// getPerTrainerFunnelBreakdown is called when no trainerId filter
mockPrisma.funnelEntry.groupBy.mockResolvedValue([]);
const result = await service.getFunnelConversion(clubId);
// safeDiv: round((num/denom)*10000)/100
expect(result).toEqual(
expect.objectContaining({
totalNew: 200,
totalAssigned: 120,
totalCompleted: 80,
totalRegular: 50,
newToAssigned: 60, // (120/200)*100
assignedToCompleted: 66.67, // round((80/120)*10000)/100
completedToRegular: 62.5, // (50/80)*100
perTrainer: [],
}),
);
});
it('should return 0 for all rates when all counts are zero', async () => {
mockPrisma.funnelEntry.count.mockResolvedValue(0);
mockPrisma.funnelEntry.groupBy.mockResolvedValue([]);
const result = await service.getFunnelConversion(clubId);
expect(result).toEqual(
expect.objectContaining({
totalNew: 0,
totalAssigned: 0,
totalCompleted: 0,
totalRegular: 0,
newToAssigned: 0,
assignedToCompleted: 0,
completedToRegular: 0,
perTrainer: [],
}),
);
});
it('should handle partial zero denominators gracefully', async () => {
mockPrisma.funnelEntry.count
.mockResolvedValueOnce(100) // NEW
.mockResolvedValueOnce(0) // ASSIGNED — zero means next rate is 0
.mockResolvedValueOnce(0) // COMPLETED
.mockResolvedValueOnce(0); // REGULAR
mockPrisma.funnelEntry.groupBy.mockResolvedValue([]);
const result = await service.getFunnelConversion(clubId);
expect(result.newToAssigned).toBe(0);
expect(result.assignedToCompleted).toBe(0);
expect(result.completedToRegular).toBe(0);
});
it('should apply trainerId filter when provided', async () => {
mockPrisma.funnelEntry.count.mockResolvedValue(10);
await service.getFunnelConversion(clubId, trainerId);
expect(mockPrisma.funnelEntry.count).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ clubId, trainerId }),
}),
);
});
});
// ---------------------------------------------------------------------------
// getRating
// ---------------------------------------------------------------------------
describe('getRating', () => {
it('should return ratings from ratingPeriod table when available', async () => {
const ratings = [
{
userId: 'tr-1',
user: { id: 'tr-1', firstName: 'Ivan', lastName: 'Petrov' },
score: 95.5,
rank: 1,
totalTrainings: 50,
totalSalesAmount: 100000,
totalClients: 20,
newClients: 5,
conversionNewToAssigned: 80,
conversionAssignedToCompleted: 70,
conversionCompletedToRegular: 60,
},
{
userId: 'tr-2',
user: { id: 'tr-2', firstName: 'Maria', lastName: 'Ivanova' },
score: 88.0,
rank: 2,
totalTrainings: 40,
totalSalesAmount: 80000,
totalClients: 15,
newClients: 3,
conversionNewToAssigned: 75,
conversionAssignedToCompleted: 65,
conversionCompletedToRegular: 55,
},
];
mockPrisma.ratingPeriod.findMany.mockResolvedValue(ratings);
const result = await service.getRating(clubId, '2026-01-01');
expect(mockPrisma.ratingPeriod.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId,
}),
}),
);
expect(result).toHaveLength(2);
expect(result[0].userId).toBe('tr-1');
expect(result[0].score).toBe(95.5);
});
it('should fall back to on-the-fly calculation when no stored ratings exist', async () => {
mockPrisma.ratingPeriod.findMany.mockResolvedValue([]);
// calculateRatingOnTheFly calls user.findMany, training.groupBy, sale.groupBy, client.groupBy
mockPrisma.user.findMany.mockResolvedValue([
{ id: 'tr-1', firstName: 'Ivan', lastName: 'Petrov' },
]);
mockPrisma.training.groupBy.mockResolvedValue([
{ trainerId: 'tr-1', _count: { trainerId: 20 } },
]);
mockPrisma.sale.groupBy.mockResolvedValue([
{ trainerId: 'tr-1', _sum: { amount: 50000 } },
]);
mockPrisma.client.groupBy.mockResolvedValue([
{ assignedTrainerId: 'tr-1', _count: { assignedTrainerId: 10 } },
]);
const result = await service.getRating(clubId);
expect(mockPrisma.ratingPeriod.findMany).toHaveBeenCalled();
expect(mockPrisma.user.findMany).toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0].userId).toBe('tr-1');
expect(result[0].rank).toBe(1);
});
});
// ---------------------------------------------------------------------------
// getTrainerStats
// ---------------------------------------------------------------------------
describe('getTrainerStats', () => {
it('should return training counts grouped by status', async () => {
const groupedTrainings = [
{ status: TrainingStatus.COMPLETED, _count: { status: 25 } },
{ status: TrainingStatus.CANCELLED, _count: { status: 5 } },
{ status: TrainingStatus.PLANNED, _count: { status: 10 } },
{ status: TrainingStatus.NO_SHOW, _count: { status: 3 } },
];
mockPrisma.training.groupBy.mockResolvedValue(groupedTrainings);
mockPrisma.sale.aggregate
.mockResolvedValueOnce({ _sum: { amount: 75000 } }) // total
.mockResolvedValueOnce({ _sum: { amount: 50000 } }) // paid
.mockResolvedValueOnce({ _sum: { amount: 25000 } }); // pending
mockPrisma.sale.count.mockResolvedValue(18);
mockPrisma.funnelEntry.count
.mockResolvedValueOnce(30) // NEW
.mockResolvedValueOnce(25) // ASSIGNED
.mockResolvedValueOnce(20) // COMPLETED (funnel)
.mockResolvedValueOnce(15); // REGULAR
mockPrisma.client.count.mockResolvedValue(12);
const result = await service.getTrainerStats(clubId, trainerId);
expect(mockPrisma.training.groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ['status'],
where: expect.objectContaining({ clubId, trainerId }),
_count: expect.objectContaining({ status: true }),
}),
);
expect(result).toEqual(
expect.objectContaining({
trainings: expect.objectContaining({
completed: 25,
cancelled: 5,
planned: 10,
noShow: 3,
}),
sales: expect.objectContaining({
totalAmount: 75000,
count: 18,
}),
funnel: expect.objectContaining({
totalNew: 30,
totalAssigned: 25,
totalCompleted: 20,
totalRegular: 15,
}),
}),
);
});
it('should aggregate sales amount and count for the trainer', async () => {
mockPrisma.training.groupBy.mockResolvedValue([]);
mockPrisma.sale.aggregate
.mockResolvedValueOnce({ _sum: { amount: 120000 } }) // total
.mockResolvedValueOnce({ _sum: { amount: 80000 } }) // paid
.mockResolvedValueOnce({ _sum: { amount: 40000 } }); // pending
mockPrisma.sale.count.mockResolvedValue(30);
mockPrisma.funnelEntry.count.mockResolvedValue(0);
mockPrisma.client.count.mockResolvedValue(0);
const result = await service.getTrainerStats(clubId, trainerId);
expect(mockPrisma.sale.aggregate).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ clubId, trainerId }),
_sum: expect.objectContaining({ amount: true }),
}),
);
expect(result.sales.totalAmount).toBe(120000);
expect(result.sales.count).toBe(30);
});
it('should return funnel counts for the trainer', async () => {
mockPrisma.training.groupBy.mockResolvedValue([]);
mockPrisma.sale.aggregate.mockResolvedValue({ _sum: { amount: 0 } });
mockPrisma.sale.count.mockResolvedValue(0);
mockPrisma.funnelEntry.count
.mockResolvedValueOnce(40) // NEW
.mockResolvedValueOnce(35) // ASSIGNED
.mockResolvedValueOnce(25) // COMPLETED
.mockResolvedValueOnce(18); // REGULAR
mockPrisma.client.count.mockResolvedValue(0);
const result = await service.getTrainerStats(clubId, trainerId);
expect(mockPrisma.funnelEntry.count).toHaveBeenCalledTimes(4);
expect(result.funnel.totalNew).toBe(40);
expect(result.funnel.totalAssigned).toBe(35);
expect(result.funnel.totalCompleted).toBe(25);
expect(result.funnel.totalRegular).toBe(18);
});
it('should apply date range filters when provided', async () => {
mockPrisma.training.groupBy.mockResolvedValue([]);
mockPrisma.sale.aggregate.mockResolvedValue({ _sum: { amount: 0 } });
mockPrisma.sale.count.mockResolvedValue(0);
mockPrisma.funnelEntry.count.mockResolvedValue(0);
mockPrisma.client.count.mockResolvedValue(0);
const dateFrom = '2026-01-01';
const dateTo = '2026-01-31';
await service.getTrainerStats(clubId, trainerId, dateFrom, dateTo);
expect(mockPrisma.training.groupBy).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId,
trainerId,
scheduledAt: expect.objectContaining({
gte: expect.any(Date),
lte: expect.any(Date),
}),
}),
}),
);
expect(mockPrisma.sale.aggregate).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
clubId,
trainerId,
createdAt: expect.objectContaining({
gte: expect.any(Date),
lte: expect.any(Date),
}),
}),
}),
);
});
});
});

View File

@@ -0,0 +1,171 @@
import { Test, TestingModule } from '@nestjs/testing';
import { createHmac } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
// Mock @nestjs/bullmq before importing the processor
jest.mock('@nestjs/bullmq', () => ({
Processor: () => () => {},
WorkerHost: class {},
}));
import { WebhookDeliveryProcessor } from './webhook-delivery.processor';
describe('WebhookDeliveryProcessor', () => {
let processor: WebhookDeliveryProcessor;
let prisma: PrismaService;
const mockPrisma = {
webhookLog: {
create: jest.fn(),
},
};
beforeEach(async () => {
jest.clearAllMocks();
jest.restoreAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
WebhookDeliveryProcessor,
{ provide: PrismaService, useValue: mockPrisma },
],
}).compile();
processor = module.get<WebhookDeliveryProcessor>(WebhookDeliveryProcessor);
prisma = module.get<PrismaService>(PrismaService);
});
const createJob = (overrides = {}) => ({
data: {
clubId: 'club-1',
subscriptionId: 'sub-1',
event: 'client.created',
payload: { id: 'client-1', firstName: 'Иван' },
secret: 'webhook-secret-123',
url: 'https://example.com/webhook',
...overrides,
},
attemptsMade: 0,
});
describe('process - successful delivery', () => {
it('should deliver webhook and create log with deliveredAt set', async () => {
const job = createJob();
const mockResponse = {
ok: true,
status: 200,
text: jest.fn().mockResolvedValue('OK'),
};
jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse as any);
mockPrisma.webhookLog.create.mockResolvedValue({});
await processor.process(job as any);
expect(global.fetch).toHaveBeenCalledWith(
'https://example.com/webhook',
expect.objectContaining({
method: 'POST',
body: expect.any(String),
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
}),
);
expect(mockPrisma.webhookLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
subscriptionId: 'sub-1',
event: 'client.created',
statusCode: 200,
deliveredAt: expect.any(Date),
}),
});
});
});
describe('process - failed delivery', () => {
it('should create log with deliveredAt null and throw error on 500 response', async () => {
const job = createJob();
const mockResponse = {
ok: false,
status: 500,
text: jest.fn().mockResolvedValue('Internal Server Error'),
};
jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse as any);
mockPrisma.webhookLog.create.mockResolvedValue({});
await expect(processor.process(job as any)).rejects.toThrow();
expect(mockPrisma.webhookLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
subscriptionId: 'sub-1',
event: 'client.created',
statusCode: 500,
deliveredAt: null,
}),
});
});
});
describe('process - network error', () => {
it('should create log and throw error when fetch throws', async () => {
const job = createJob();
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
mockPrisma.webhookLog.create.mockResolvedValue({});
await expect(processor.process(job as any)).rejects.toThrow();
expect(mockPrisma.webhookLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
subscriptionId: 'sub-1',
event: 'client.created',
deliveredAt: null,
}),
});
});
});
describe('HMAC signature', () => {
it('should send correct HMAC-SHA256 signature in header', async () => {
const job = createJob();
const payload = job.data.payload;
const secret = job.data.secret;
const mockResponse = {
ok: true,
status: 200,
text: jest.fn().mockResolvedValue('OK'),
};
let capturedHeaders: Record<string, string> = {};
let capturedBody = '';
jest.spyOn(global, 'fetch').mockImplementation(async (_url, init: any) => {
capturedHeaders = init.headers;
capturedBody = init.body;
return mockResponse as any;
});
mockPrisma.webhookLog.create.mockResolvedValue({});
await processor.process(job as any);
// Verify the signature matches what we'd expect
const expectedSignature = createHmac('sha256', secret)
.update(capturedBody)
.digest('hex');
// The processor should include a signature header
const signatureHeader =
capturedHeaders['X-Webhook-Signature'] ||
capturedHeaders['x-webhook-signature'] ||
capturedHeaders['X-Signature'];
expect(signatureHeader).toBeDefined();
expect(signatureHeader).toContain(expectedSignature);
});
});
});

View File

@@ -0,0 +1,84 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { PrismaService } from '../../prisma/prisma.service';
import { createHmac } from 'crypto';
export interface WebhookJobData {
clubId: string;
subscriptionId: string;
event: string;
payload: Record<string, any>;
secret: string;
url: string;
}
@Processor('webhook-delivery')
export class WebhookDeliveryProcessor extends WorkerHost {
private readonly logger = new Logger(WebhookDeliveryProcessor.name);
constructor(private readonly prisma: PrismaService) {
super();
}
async process(job: Job<WebhookJobData>): Promise<void> {
const { clubId, subscriptionId, event, payload, secret, url } = job.data;
const attempt = job.attemptsMade + 1;
this.logger.log(
`Delivering webhook: event=${event}, url=${url}, attempt=${attempt}`,
);
const body = JSON.stringify(payload);
const signature = createHmac('sha256', secret).update(body).digest('hex');
let statusCode: number | null = null;
let response: string | null = null;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Event': event,
},
body,
signal: AbortSignal.timeout(10000),
});
statusCode = res.status;
response = await res.text();
} catch (err: any) {
response = err.message;
}
const success = statusCode !== null && statusCode >= 200 && statusCode < 300;
await this.prisma.webhookLog.create({
data: {
clubId,
subscriptionId,
event,
payload,
statusCode,
response: response?.slice(0, 1000) ?? null,
attempt,
deliveredAt: success ? new Date() : null,
},
});
if (!success) {
this.logger.warn(
`Webhook delivery failed: event=${event}, url=${url}, attempt=${attempt}, status=${statusCode}`,
);
throw new Error(
`Webhook delivery failed with status ${statusCode}: ${response?.slice(0, 200)}`,
);
}
this.logger.log(
`Webhook delivered: event=${event}, url=${url}, status=${statusCode}`,
);
}
}

View File

@@ -0,0 +1,207 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getQueueToken } from '@nestjs/bullmq';
import { WebhookDispatchService, calculateBackoff } from './webhook-dispatch.service';
import { PrismaService } from '../../prisma/prisma.service';
describe('WebhookDispatchService', () => {
let service: WebhookDispatchService;
let prisma: PrismaService;
let mockQueue: { add: jest.Mock };
const mockPrisma = {
webhookSubscription: {
findMany: jest.fn(),
},
};
beforeEach(async () => {
jest.clearAllMocks();
mockQueue = { add: jest.fn() };
const module: TestingModule = await Test.createTestingModule({
providers: [
WebhookDispatchService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: getQueueToken('webhook-delivery'), useValue: mockQueue },
],
}).compile();
service = module.get<WebhookDispatchService>(WebhookDispatchService);
prisma = module.get<PrismaService>(PrismaService);
});
describe('onClientCreated', () => {
it('should find subscriptions and enqueue jobs', async () => {
const event = {
clubId: 'club-1',
data: {
id: 'client-1',
firstName: 'Иван',
lastName: 'Иванов',
phone: '+79001234567',
},
};
const subscriptions = [
{
id: 'sub-1',
url: 'https://example.com/webhook',
secret: 'secret-1',
},
];
mockPrisma.webhookSubscription.findMany.mockResolvedValue(subscriptions);
await service.onClientCreated(event);
expect(mockPrisma.webhookSubscription.findMany).toHaveBeenCalledWith({
where: {
clubId: 'club-1',
isActive: true,
events: { has: 'client.created' },
},
select: { id: true, url: true, secret: true },
});
expect(mockQueue.add).toHaveBeenCalledWith(
'webhook:client.created',
expect.objectContaining({
clubId: 'club-1',
subscriptionId: 'sub-1',
event: 'client.created',
url: 'https://example.com/webhook',
secret: 'secret-1',
payload: expect.objectContaining({
event: 'client.created',
timestamp: expect.any(String),
club_id: 'club-1',
data: event.data,
}),
}),
expect.objectContaining({
attempts: 3,
}),
);
});
});
describe('dispatch', () => {
it('should skip when no subscriptions found', async () => {
mockPrisma.webhookSubscription.findMany.mockResolvedValue([]);
await service['dispatch']('club-1', 'client.created', { id: 'client-1' });
expect(mockQueue.add).not.toHaveBeenCalled();
});
it('should create jobs with correct data for each subscription', async () => {
const subscriptions = [
{
id: 'sub-1',
url: 'https://example.com/hook1',
secret: 'secret-1',
},
{
id: 'sub-2',
url: 'https://other.com/hook2',
secret: 'secret-2',
},
];
mockPrisma.webhookSubscription.findMany.mockResolvedValue(subscriptions);
const data = { id: 'client-1', firstName: 'Иван' };
await service['dispatch']('club-1', 'client.created', data);
expect(mockQueue.add).toHaveBeenCalledTimes(2);
expect(mockQueue.add).toHaveBeenCalledWith(
'webhook:client.created',
expect.objectContaining({
clubId: 'club-1',
subscriptionId: 'sub-1',
event: 'client.created',
url: 'https://example.com/hook1',
secret: 'secret-1',
payload: expect.objectContaining({
event: 'client.created',
timestamp: expect.any(String),
club_id: 'club-1',
data,
}),
}),
expect.objectContaining({ attempts: 3 }),
);
expect(mockQueue.add).toHaveBeenCalledWith(
'webhook:client.created',
expect.objectContaining({
clubId: 'club-1',
subscriptionId: 'sub-2',
event: 'client.created',
url: 'https://other.com/hook2',
secret: 'secret-2',
payload: expect.objectContaining({
event: 'client.created',
timestamp: expect.any(String),
club_id: 'club-1',
data,
}),
}),
expect.objectContaining({ attempts: 3 }),
);
});
it('should enqueue jobs for training.created event', async () => {
const subscriptions = [
{
id: 'sub-3',
url: 'https://hooks.example.com/training',
secret: 'training-secret',
},
];
mockPrisma.webhookSubscription.findMany.mockResolvedValue(subscriptions);
await service['dispatch']('club-2', 'training.created', { id: 'training-1' });
expect(mockPrisma.webhookSubscription.findMany).toHaveBeenCalledWith({
where: {
clubId: 'club-2',
isActive: true,
events: { has: 'training.created' },
},
select: { id: true, url: true, secret: true },
});
expect(mockQueue.add).toHaveBeenCalledWith(
'webhook:training.created',
expect.objectContaining({
event: 'training.created',
subscriptionId: 'sub-3',
}),
expect.anything(),
);
});
});
describe('calculateBackoff', () => {
it('should return 5000ms for first retry', () => {
expect(calculateBackoff(1)).toBe(5000);
});
it('should return 30000ms for second retry', () => {
expect(calculateBackoff(2)).toBe(30000);
});
it('should return 300000ms for third retry', () => {
expect(calculateBackoff(3)).toBe(300000);
});
it('should return last delay for attempts beyond array length', () => {
expect(calculateBackoff(4)).toBe(300000);
expect(calculateBackoff(10)).toBe(300000);
});
});
});

View File

@@ -0,0 +1,132 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { PrismaService } from '../../prisma/prisma.service';
import { WebhookJobData } from './webhook-delivery.processor';
/** Backoff delays for 3 retry attempts: 5s, 30s, 5min */
const RETRY_DELAYS = [5000, 30000, 300000];
@Injectable()
export class WebhookDispatchService {
private readonly logger = new Logger(WebhookDispatchService.name);
constructor(
@InjectQueue('webhook-delivery')
private readonly webhookQueue: Queue,
private readonly prisma: PrismaService,
) {}
@OnEvent('client.created')
async onClientCreated(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'client.created', payload.data);
}
@OnEvent('client.updated')
async onClientUpdated(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'client.updated', payload.data);
}
@OnEvent('training.created')
async onTrainingCreated(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'training.created', payload.data);
}
@OnEvent('training.cancelled')
async onTrainingCancelled(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'training.cancelled', payload.data);
}
@OnEvent('training.rescheduled')
async onTrainingRescheduled(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'training.rescheduled', payload.data);
}
@OnEvent('training.completed')
async onTrainingCompleted(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'training.completed', payload.data);
}
@OnEvent('sale.created')
async onSaleCreated(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'sale.created', payload.data);
}
@OnEvent('sale.paid')
async onSalePaid(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'sale.paid', payload.data);
}
@OnEvent('funnel.stage_changed')
async onFunnelStageChanged(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'funnel.stage_changed', payload.data);
}
@OnEvent('funnel.assigned')
async onFunnelAssigned(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'funnel.assigned', payload.data);
}
@OnEvent('schedule.updated')
async onScheduleUpdated(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'schedule.updated', payload.data);
}
@OnEvent('membership.expiring')
async onMembershipExpiring(payload: { clubId: string; data: any }) {
await this.dispatch(payload.clubId, 'membership.expiring', payload.data);
}
private async dispatch(
clubId: string,
event: string,
data: any,
): Promise<void> {
const subscriptions = await this.prisma.webhookSubscription.findMany({
where: {
clubId,
isActive: true,
events: { has: event },
},
select: { id: true, url: true, secret: true },
});
if (subscriptions.length === 0) return;
const webhookPayload = {
event,
timestamp: new Date().toISOString(),
club_id: clubId,
data,
};
for (const sub of subscriptions) {
const jobData: WebhookJobData = {
clubId,
subscriptionId: sub.id,
event,
payload: webhookPayload,
secret: sub.secret,
url: sub.url,
};
await this.webhookQueue.add(`webhook:${event}`, jobData, {
attempts: 3,
backoff: {
type: 'custom',
},
removeOnComplete: 100,
removeOnFail: 500,
});
this.logger.debug(
`Enqueued webhook: event=${event}, subscription=${sub.id}, url=${sub.url}`,
);
}
}
}
export function calculateBackoff(attemptsMade: number): number {
return RETRY_DELAYS[Math.min(attemptsMade - 1, RETRY_DELAYS.length - 1)];
}

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { WebhooksController } from './webhooks.controller';
import { WebhooksService } from './webhooks.service';
import { WebhookDeliveryProcessor } from './webhook-delivery.processor';
import { WebhookDispatchService } from './webhook-dispatch.service';
@Module({
controllers: [WebhooksController],
providers: [WebhooksService],
providers: [WebhooksService, WebhookDeliveryProcessor, WebhookDispatchService],
exports: [WebhooksService],
})
export class WebhooksModule {}

View File

@@ -3,6 +3,7 @@ import {
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateWorkScheduleDto } from './dto/create-work-schedule.dto';
import { UpdateWorkScheduleDto } from './dto/update-work-schedule.dto';
@@ -18,7 +19,10 @@ const WORK_SCHEDULE_INCLUDE = {
@Injectable()
export class WorkScheduleService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* Find all work schedule entries filtered by club, trainer, and date range.
@@ -86,7 +90,7 @@ export class WorkScheduleService {
async create(clubId: string, dto: CreateWorkScheduleDto) {
this.validateTimeRange(dto.startTime, dto.endTime);
return this.prisma.workSchedule.create({
const schedule = await this.prisma.workSchedule.create({
data: {
clubId,
trainerId: dto.trainerId!,
@@ -97,6 +101,10 @@ export class WorkScheduleService {
},
include: WORK_SCHEDULE_INCLUDE,
});
this.eventEmitter.emit('schedule.updated', { clubId, data: schedule });
return schedule;
}
/**
@@ -110,7 +118,7 @@ export class WorkScheduleService {
const endTime = dto.endTime ?? existing.endTime;
this.validateTimeRange(startTime, endTime);
return this.prisma.workSchedule.update({
const updated = await this.prisma.workSchedule.update({
where: { id },
data: {
...(dto.startTime !== undefined ? { startTime: dto.startTime } : {}),
@@ -121,6 +129,10 @@ export class WorkScheduleService {
},
include: WORK_SCHEDULE_INCLUDE,
});
this.eventEmitter.emit('schedule.updated', { clubId, data: updated });
return updated;
}
/**

View File

@@ -0,0 +1 @@
export { QueueModule } from './queue.module';

View File

@@ -0,0 +1,27 @@
import { Global, Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ConfigModule, ConfigService } from '@nestjs/config';
@Global()
@Module({
imports: [
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
connection: {
host: config.get<string>('redis.host'),
port: config.get<number>('redis.port'),
password: config.get<string>('redis.password'),
},
}),
}),
BullModule.registerQueue(
{ name: 'webhook-delivery' },
{ name: 'push-notification' },
{ name: 'sync-1c' },
),
],
exports: [BullModule],
})
export class QueueModule {}

View File

@@ -1,8 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Plus, Search, Package, Tag } from 'lucide-react';
import { api } from '@/lib/api';
import { ServiceDialog } from '@/components/catalog/service-dialog';
import { PackageDialog } from '@/components/catalog/package-dialog';
type Service = {
id: string;
@@ -43,7 +45,13 @@ export default function CatalogPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const [showServiceDialog, setShowServiceDialog] = useState(false);
const [editService, setEditService] = useState<Service | null>(null);
const [showPackageDialog, setShowPackageDialog] = useState(false);
const [editPackage, setEditPackage] = useState<ServicePackage | null>(null);
const loadData = useCallback(() => {
setLoading(true);
void Promise.all([
api.get<ServicesResponse>('/catalog/services')
.then((res) => setServices(res.data ?? []))
@@ -58,6 +66,10 @@ export default function CatalogPage() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadData();
}, [loadData]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -72,7 +84,7 @@ export default function CatalogPage() {
<div className="text-center">
<p className="text-error text-sm mb-2">{error}</p>
<button
onClick={() => window.location.reload()}
onClick={loadData}
className="text-sm text-primary hover:underline"
>
Попробовать снова
@@ -87,11 +99,22 @@ export default function CatalogPage() {
s.category.toLowerCase().includes(search.toLowerCase())
);
const handleAddClick = () => {
if (tab === 'services') {
setShowServiceDialog(true);
} else {
setShowPackageDialog(true);
}
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Каталог услуг</h1>
<button className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors">
<button
onClick={handleAddClick}
className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4" />
{tab === 'services' ? 'Добавить услугу' : 'Создать пакет'}
</button>
@@ -149,7 +172,11 @@ export default function CatalogPage() {
</thead>
<tbody>
{filteredServices.map((service) => (
<tr key={service.id} className="border-b border-border last:border-0 hover:bg-gray-50 transition-colors cursor-pointer">
<tr
key={service.id}
onClick={() => setEditService(service)}
className="border-b border-border last:border-0 hover:bg-gray-50 transition-colors cursor-pointer"
>
<td className="px-6 py-4 text-sm font-medium text-gray-900">{service.name}</td>
<td className="px-6 py-4 text-sm text-gray-600">{service.category}</td>
<td className="px-6 py-4 text-sm text-gray-900 font-medium">
@@ -216,7 +243,10 @@ export default function CatalogPage() {
</p>
)}
</div>
<button className="text-sm text-primary font-medium hover:underline">
<button
onClick={() => setEditPackage(pkg)}
className="text-sm text-primary font-medium hover:underline"
>
Редактировать
</button>
</div>
@@ -224,6 +254,32 @@ export default function CatalogPage() {
))}
</div>
)}
<ServiceDialog
open={showServiceDialog}
onClose={() => setShowServiceDialog(false)}
onSaved={loadData}
/>
<ServiceDialog
open={!!editService}
onClose={() => setEditService(null)}
onSaved={loadData}
service={editService}
/>
<PackageDialog
open={showPackageDialog}
onClose={() => setShowPackageDialog(false)}
onSaved={loadData}
/>
<PackageDialog
open={!!editPackage}
onClose={() => setEditPackage(null)}
onSaved={loadData}
pkg={editPackage}
/>
</div>
);
}

View File

@@ -1,8 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Plus, Users, UserCheck, Edit, Trash2 } from 'lucide-react';
import { api } from '@/lib/api';
import { DepartmentDialog } from '@/components/departments/department-dialog';
import { DeleteDepartmentDialog } from '@/components/departments/delete-department-dialog';
type Department = {
id: string;
@@ -24,7 +26,13 @@ export default function DepartmentsPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Dialog state
const [showAddDialog, setShowAddDialog] = useState(false);
const [editDepartment, setEditDepartment] = useState<Department | null>(null);
const [deleteDepartment, setDeleteDepartment] = useState<Department | null>(null);
const loadDepartments = useCallback(() => {
setLoading(true);
api.get<DepartmentsResponse>('/departments')
.then((res) => {
setDepartments(res.data ?? []);
@@ -37,6 +45,10 @@ export default function DepartmentsPage() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadDepartments();
}, [loadDepartments]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -51,7 +63,7 @@ export default function DepartmentsPage() {
<div className="text-center">
<p className="text-error text-sm mb-2">{error}</p>
<button
onClick={() => window.location.reload()}
onClick={loadDepartments}
className="text-sm text-primary hover:underline"
>
Попробовать снова
@@ -65,7 +77,10 @@ export default function DepartmentsPage() {
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Департаменты</h1>
<button className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors">
<button
onClick={() => setShowAddDialog(true)}
className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4" />
Создать департамент
</button>
@@ -86,10 +101,16 @@ export default function DepartmentsPage() {
<div className="flex items-start justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">{dept.name}</h2>
<div className="flex gap-1">
<button className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors">
<button
onClick={() => setEditDepartment(dept)}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors"
>
<Edit className="h-4 w-4 text-muted" />
</button>
<button className="p-1.5 rounded-lg hover:bg-red-50 transition-colors">
<button
onClick={() => setDeleteDepartment(dept)}
className="p-1.5 rounded-lg hover:bg-red-50 transition-colors"
>
<Trash2 className="h-4 w-4 text-error" />
</button>
</div>
@@ -123,6 +144,25 @@ export default function DepartmentsPage() {
</div>
))}
</div>
{/* Add / Edit Department Dialog */}
<DepartmentDialog
open={showAddDialog || !!editDepartment}
onClose={() => {
setShowAddDialog(false);
setEditDepartment(null);
}}
onSaved={loadDepartments}
department={editDepartment}
/>
{/* Delete Department Dialog */}
<DeleteDepartmentDialog
open={!!deleteDepartment}
onClose={() => setDeleteDepartment(null)}
onDeleted={loadDepartments}
department={deleteDepartment}
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
Plug,
Phone,
@@ -14,6 +14,9 @@ import {
RefreshCw,
} from 'lucide-react';
import { api } from '@/lib/api';
import { AlertDialog } from '@/components/ui/alert-dialog';
import { CreateApiKeyDialog } from '@/components/integrations/create-api-key-dialog';
import { ConfigureIntegrationDialog } from '@/components/integrations/configure-integration-dialog';
type IntegrationStatus = 'connected' | 'disconnected' | 'error';
@@ -81,7 +84,13 @@ export default function IntegrationsPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const [showCreateKeyDialog, setShowCreateKeyDialog] = useState(false);
const [configureIntegration, setConfigureIntegration] = useState<{ id: string; name: string } | null>(null);
const [regenerateKeyId, setRegenerateKeyId] = useState<string | null>(null);
const loadData = useCallback(() => {
setLoading(true);
setError(null);
void Promise.all([
api.get<IntegrationConfig>('/integration/config')
.then((config) => {
@@ -140,6 +149,25 @@ export default function IntegrationsPage() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const handleCopyKey = async (key: string) => {
try {
await navigator.clipboard.writeText(key);
} catch {
// Clipboard API not available
}
};
const handleRegenerateKey = async () => {
if (!regenerateKeyId) return;
await api.post(`/auth/api-keys/${regenerateKeyId}/regenerate`);
setRegenerateKeyId(null);
loadData();
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -154,7 +182,7 @@ export default function IntegrationsPage() {
<div className="text-center">
<p className="text-error text-sm mb-2">{error}</p>
<button
onClick={() => window.location.reload()}
onClick={loadData}
className="text-sm text-primary hover:underline"
>
Попробовать снова
@@ -202,7 +230,10 @@ export default function IntegrationsPage() {
<StatusIcon className="h-4 w-4" />
{status.label}
</span>
<button className="px-4 py-2 rounded-lg border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
<button
onClick={() => setConfigureIntegration({ id: integration.id, name: integration.name })}
className="px-4 py-2 rounded-lg border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Настроить
</button>
</div>
@@ -215,7 +246,10 @@ export default function IntegrationsPage() {
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">API-ключи</h2>
<button className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors">
<button
onClick={() => setShowCreateKeyDialog(true)}
className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors"
>
<KeyRound className="h-4 w-4" />
Создать ключ
</button>
@@ -251,12 +285,18 @@ export default function IntegrationsPage() {
<Eye className="h-3.5 w-3.5 text-muted" />
)}
</button>
<button className="p-1 rounded hover:bg-gray-100">
<button
onClick={() => void handleCopyKey(apiKey.key)}
className="p-1 rounded hover:bg-gray-100"
>
<Copy className="h-3.5 w-3.5 text-muted" />
</button>
</div>
</div>
<button className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs font-medium text-gray-700 hover:bg-gray-50">
<button
onClick={() => setRegenerateKeyId(apiKey.id)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs font-medium text-gray-700 hover:bg-gray-50"
>
<RefreshCw className="h-3 w-3" />
Перевыпустить
</button>
@@ -281,6 +321,29 @@ export default function IntegrationsPage() {
))}
</div>
</div>
<CreateApiKeyDialog
open={showCreateKeyDialog}
onClose={() => setShowCreateKeyDialog(false)}
onCreated={loadData}
/>
<ConfigureIntegrationDialog
open={configureIntegration !== null}
onClose={() => setConfigureIntegration(null)}
onSaved={loadData}
integration={configureIntegration}
/>
<AlertDialog
open={regenerateKeyId !== null}
onClose={() => setRegenerateKeyId(null)}
onConfirm={handleRegenerateKey}
title="Перевыпустить API-ключ"
description="Текущий ключ будет отозван и заменён новым. Все интеграции, использующие старый ключ, перестанут работать. Продолжить?"
confirmLabel="Перевыпустить"
variant="warning"
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
KeyRound,
Calendar,
@@ -10,6 +10,7 @@ import {
BarChart3,
} from 'lucide-react';
import { api } from '@/lib/api';
import { RenewDialog } from '@/components/license/renew-dialog';
type Module = {
id: string;
@@ -83,7 +84,11 @@ export default function LicensePage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const [showRenewDialog, setShowRenewDialog] = useState(false);
const loadData = useCallback(() => {
setLoading(true);
setError(null);
void Promise.all([
api.get<LicenseInfo>('/licenses/my')
.then((res) => setLicenseInfo(res))
@@ -101,6 +106,10 @@ export default function LicensePage() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadData();
}, [loadData]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -115,7 +124,7 @@ export default function LicensePage() {
<div className="text-center">
<p className="text-error text-sm mb-2">{error}</p>
<button
onClick={() => window.location.reload()}
onClick={loadData}
className="text-sm text-primary hover:underline"
>
Попробовать снова
@@ -154,7 +163,10 @@ export default function LicensePage() {
</div>
<p className="text-sm text-gray-600">{licenseInfo.clubName}</p>
</div>
<button className="px-4 py-2 rounded-xl bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors">
<button
onClick={() => setShowRenewDialog(true)}
className="px-4 py-2 rounded-xl bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors"
>
Продлить
</button>
</div>
@@ -264,6 +276,13 @@ export default function LicensePage() {
})}
</div>
</div>
<RenewDialog
open={showRenewDialog}
onClose={() => setShowRenewDialog(false)}
onRenewed={loadData}
licenseInfo={{ type: licenseInfo.type, expiresAt: licenseInfo.expiresAt }}
/>
</div>
);
}

View File

@@ -1,8 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Plus, Edit, Trash2, MapPin, Users, Clock } from 'lucide-react';
import { api } from '@/lib/api';
import { RoomDialog } from '@/components/rooms/room-dialog';
import { DeleteRoomDialog } from '@/components/rooms/delete-room-dialog';
type Room = {
id: string;
@@ -32,7 +34,12 @@ export default function RoomsPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const [showAddDialog, setShowAddDialog] = useState(false);
const [editRoom, setEditRoom] = useState<Room | null>(null);
const [deleteRoom, setDeleteRoom] = useState<Room | null>(null);
const loadRooms = useCallback(() => {
setLoading(true);
api.get<RoomsResponse>('/rooms')
.then((res) => {
setRooms(res.data ?? []);
@@ -45,6 +52,10 @@ export default function RoomsPage() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadRooms();
}, [loadRooms]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -59,7 +70,7 @@ export default function RoomsPage() {
<div className="text-center">
<p className="text-error text-sm mb-2">{error}</p>
<button
onClick={() => window.location.reload()}
onClick={loadRooms}
className="text-sm text-primary hover:underline"
>
Попробовать снова
@@ -73,7 +84,10 @@ export default function RoomsPage() {
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Залы и локации</h1>
<button className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors">
<button
onClick={() => setShowAddDialog(true)}
className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4" />
Добавить зал
</button>
@@ -129,11 +143,17 @@ export default function RoomsPage() {
</div>
<div className="flex gap-2 pt-4 border-t border-border">
<button className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<button
onClick={() => setEditRoom(room)}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<Edit className="h-3.5 w-3.5" />
Изменить
</button>
<button className="flex items-center justify-center p-2 rounded-lg border border-border text-error hover:bg-red-50 transition-colors">
<button
onClick={() => setDeleteRoom(room)}
className="flex items-center justify-center p-2 rounded-lg border border-border text-error hover:bg-red-50 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
@@ -141,6 +161,26 @@ export default function RoomsPage() {
);
})}
</div>
<RoomDialog
open={showAddDialog}
onClose={() => setShowAddDialog(false)}
onSaved={loadRooms}
/>
<RoomDialog
open={!!editRoom}
onClose={() => setEditRoom(null)}
onSaved={loadRooms}
room={editRoom}
/>
<DeleteRoomDialog
open={!!deleteRoom}
onClose={() => setDeleteRoom(null)}
onDeleted={loadRooms}
room={deleteRoom}
/>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react';
import { Save, Upload, MapPin, Phone, Clock, Globe } from 'lucide-react';
import { api } from '@/lib/api';
import { DeactivateDialog } from '@/components/settings/deactivate-dialog';
type ClubSettings = {
name: string;
@@ -35,6 +36,9 @@ export default function SettingsPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [showDeactivateDialog, setShowDeactivateDialog] = useState(false);
useEffect(() => {
api.get<ClubSettings>('/clubs/settings')
@@ -54,16 +58,21 @@ export default function SettingsPage() {
const handleSave = async () => {
setSaving(true);
setSaveError(null);
try {
const updated = await api.patch<ClubSettings>('/clubs/settings', settings);
setSettings(updated);
} catch {
// Ignore save errors for now
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Не удалось сохранить настройки');
} finally {
setSaving(false);
}
};
const handleDeactivated = () => {
window.location.reload();
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -95,14 +104,19 @@ export default function SettingsPage() {
<h1 className="text-2xl font-bold text-gray-900">Настройки клуба</h1>
<p className="text-sm text-muted mt-1">Общие настройки вашего фитнес-клуба</p>
</div>
<button
onClick={() => void handleSave()}
disabled={saving}
className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? 'Сохранение...' : 'Сохранить изменения'}
</button>
<div className="flex items-center gap-3">
{saveError && (
<p className="text-sm text-error">{saveError}</p>
)}
<button
onClick={() => void handleSave()}
disabled={saving}
className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? 'Сохранение...' : 'Сохранить изменения'}
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
@@ -262,12 +276,22 @@ export default function SettingsPage() {
<p className="text-sm text-gray-600 mb-4">
Деактивация клуба приведёт к блокировке доступа для всех сотрудников. Данные сохранятся.
</p>
<button className="px-4 py-2 rounded-lg bg-error text-white text-sm font-medium hover:bg-error/90 transition-colors">
<button
onClick={() => setShowDeactivateDialog(true)}
className="px-4 py-2 rounded-lg bg-error text-white text-sm font-medium hover:bg-error/90 transition-colors"
>
Деактивировать клуб
</button>
</div>
</div>
</div>
<DeactivateDialog
open={showDeactivateDialog}
onClose={() => setShowDeactivateDialog(false)}
onConfirmed={handleDeactivated}
clubName={settings.name || 'Клуб'}
/>
</div>
);
}

View File

@@ -1,8 +1,10 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Search, MoreHorizontal, UserCheck, UserX } from 'lucide-react';
import { api } from '@/lib/api';
import { EmployeeDialog } from '@/components/staff/employee-dialog';
import { DeleteEmployeeDialog } from '@/components/staff/delete-employee-dialog';
type Employee = {
id: string;
@@ -33,7 +35,17 @@ export default function StaffPage() {
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
useEffect(() => {
// Dialog state
const [showAddDialog, setShowAddDialog] = useState(false);
const [editEmployee, setEditEmployee] = useState<Employee | null>(null);
const [deleteEmployee, setDeleteEmployee] = useState<Employee | null>(null);
// Dropdown state
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const loadEmployees = useCallback(() => {
setLoading(true);
api.get<UsersResponse>('/users')
.then((res) => {
setEmployees(res.data ?? []);
@@ -46,6 +58,21 @@ export default function StaffPage() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadEmployees();
}, [loadEmployees]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenMenuId(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -60,7 +87,7 @@ export default function StaffPage() {
<div className="text-center">
<p className="text-error text-sm mb-2">{error}</p>
<button
onClick={() => window.location.reload()}
onClick={loadEmployees}
className="text-sm text-primary hover:underline"
>
Попробовать снова
@@ -81,7 +108,10 @@ export default function StaffPage() {
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Сотрудники</h1>
<button className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors">
<button
onClick={() => setShowAddDialog(true)}
className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4" />
Добавить сотрудника
</button>
@@ -140,9 +170,36 @@ export default function StaffPage() {
</td>
<td className="px-6 py-4 text-sm text-gray-600">{employee.hiredAt}</td>
<td className="px-6 py-4 text-right">
<button className="p-1 rounded hover:bg-gray-100">
<MoreHorizontal className="h-4 w-4 text-muted" />
</button>
<div className="relative inline-block" ref={openMenuId === employee.id ? menuRef : undefined}>
<button
onClick={() => setOpenMenuId(openMenuId === employee.id ? null : employee.id)}
className="p-1 rounded hover:bg-gray-100"
>
<MoreHorizontal className="h-4 w-4 text-muted" />
</button>
{openMenuId === employee.id && (
<div className="absolute right-0 top-full mt-1 w-44 bg-white border border-border rounded-xl shadow-lg z-20">
<button
onClick={() => {
setOpenMenuId(null);
setEditEmployee(employee);
}}
className="w-full text-left px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 rounded-t-xl transition-colors"
>
Редактировать
</button>
<button
onClick={() => {
setOpenMenuId(null);
setDeleteEmployee(employee);
}}
className="w-full text-left px-4 py-2.5 text-sm text-error hover:bg-red-50 rounded-b-xl transition-colors"
>
Удалить
</button>
</div>
)}
</div>
</td>
</tr>
))}
@@ -154,6 +211,25 @@ export default function StaffPage() {
Показано {filtered.length} из {employees.length} сотрудников
</div>
</div>
{/* Add / Edit Employee Dialog */}
<EmployeeDialog
open={showAddDialog || !!editEmployee}
onClose={() => {
setShowAddDialog(false);
setEditEmployee(null);
}}
onSaved={loadEmployees}
employee={editEmployee}
/>
{/* Delete Employee Dialog */}
<DeleteEmployeeDialog
open={!!deleteEmployee}
onClose={() => setDeleteEmployee(null)}
onDeleted={loadEmployees}
employee={deleteEmployee}
/>
</div>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import { useEffect, useState } from 'react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { api } from '@/lib/api';
type ServicePackage = {
id: string;
name: string;
services: string[];
totalPrice: number;
discountPercent: number;
validDays: number;
isActive: boolean;
};
type PackageDialogProps = {
open: boolean;
onClose: () => void;
onSaved: () => void;
pkg?: ServicePackage | null;
};
export function PackageDialog({ open, onClose, onSaved, pkg }: PackageDialogProps) {
const [name, setName] = useState('');
const [servicesText, setServicesText] = useState('');
const [totalPrice, setTotalPrice] = useState<number>(0);
const [discountPercent, setDiscountPercent] = useState<number>(0);
const [validDays, setValidDays] = useState<number>(30);
const [isActive, setIsActive] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = !!pkg;
useEffect(() => {
if (open) {
if (pkg) {
setName(pkg.name);
setServicesText(pkg.services.join(', '));
setTotalPrice(pkg.totalPrice);
setDiscountPercent(pkg.discountPercent);
setValidDays(pkg.validDays);
setIsActive(pkg.isActive);
} else {
setName('');
setServicesText('');
setTotalPrice(0);
setDiscountPercent(0);
setValidDays(30);
setIsActive(true);
}
setError(null);
}
}, [open, pkg]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const services = servicesText
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const body = { name, services, totalPrice, discountPercent, validDays, isActive };
try {
if (isEdit) {
await api.patch(`/catalog/packages/${pkg.id}`, body);
} else {
await api.post('/catalog/packages', body);
}
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось сохранить пакет');
} finally {
setLoading(false);
}
};
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
return (
<Dialog open={open} onClose={onClose}>
<DialogHeader
title={isEdit ? 'Редактировать пакет' : 'Создать пакет'}
description={isEdit ? 'Измените данные пакета услуг' : 'Заполните информацию о новом пакете'}
onClose={onClose}
/>
<form onSubmit={(e) => void handleSubmit(e)}>
<DialogBody className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-error text-sm px-3 py-2 rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Название <span className="text-error">*</span>
</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Например: Безлимит 12 месяцев"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Услуги <span className="text-error">*</span>
</label>
<input
type="text"
required
value={servicesText}
onChange={(e) => setServicesText(e.target.value)}
placeholder="Через запятую: Тренажёрный зал, Бассейн, Сауна"
className={inputClass}
/>
<p className="text-xs text-muted mt-1">Перечислите через запятую</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Общая цена, &#8381; <span className="text-error">*</span>
</label>
<input
type="number"
required
min={0}
value={totalPrice}
onChange={(e) => setTotalPrice(Number(e.target.value))}
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Скидка, %
</label>
<input
type="number"
min={0}
max={100}
value={discountPercent}
onChange={(e) => setDiscountPercent(Number(e.target.value))}
className={inputClass}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Срок действия, дней <span className="text-error">*</span>
</label>
<input
type="number"
required
min={1}
value={validDays}
onChange={(e) => setValidDays(Number(e.target.value))}
className={inputClass}
/>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="package-active"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="h-4 w-4 rounded border-border text-primary focus:ring-primary/30"
/>
<label htmlFor="package-active" className="text-sm font-medium text-gray-700">
Пакет активен
</label>
</div>
</DialogBody>
<DialogFooter>
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Сохранение...' : isEdit ? 'Сохранить' : 'Создать'}
</button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useEffect, useState } from 'react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { api } from '@/lib/api';
type Service = {
id: string;
name: string;
category: string;
price: number;
duration: number;
isActive: boolean;
};
type ServiceDialogProps = {
open: boolean;
onClose: () => void;
onSaved: () => void;
service?: Service | null;
};
export function ServiceDialog({ open, onClose, onSaved, service }: ServiceDialogProps) {
const [name, setName] = useState('');
const [category, setCategory] = useState('');
const [price, setPrice] = useState<number>(0);
const [duration, setDuration] = useState<number>(60);
const [isActive, setIsActive] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = !!service;
useEffect(() => {
if (open) {
if (service) {
setName(service.name);
setCategory(service.category);
setPrice(service.price);
setDuration(service.duration);
setIsActive(service.isActive);
} else {
setName('');
setCategory('');
setPrice(0);
setDuration(60);
setIsActive(true);
}
setError(null);
}
}, [open, service]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const body = { name, category, price, duration, isActive };
try {
if (isEdit) {
await api.patch(`/catalog/services/${service.id}`, body);
} else {
await api.post('/catalog/services', body);
}
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось сохранить услугу');
} finally {
setLoading(false);
}
};
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
return (
<Dialog open={open} onClose={onClose}>
<DialogHeader
title={isEdit ? 'Редактировать услугу' : 'Добавить услугу'}
description={isEdit ? 'Измените данные услуги' : 'Заполните информацию о новой услуге'}
onClose={onClose}
/>
<form onSubmit={(e) => void handleSubmit(e)}>
<DialogBody className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-error text-sm px-3 py-2 rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Название <span className="text-error">*</span>
</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Например: Персональная тренировка"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Категория <span className="text-error">*</span>
</label>
<input
type="text"
required
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="Например: Персональные тренировки"
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Цена, &#8381; <span className="text-error">*</span>
</label>
<input
type="number"
required
min={0}
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Длительность, мин <span className="text-error">*</span>
</label>
<input
type="number"
required
min={1}
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
className={inputClass}
/>
</div>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="service-active"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="h-4 w-4 rounded border-border text-primary focus:ring-primary/30"
/>
<label htmlFor="service-active" className="text-sm font-medium text-gray-700">
Услуга активна
</label>
</div>
</DialogBody>
<DialogFooter>
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Сохранение...' : isEdit ? 'Сохранить' : 'Добавить'}
</button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import { AlertDialog } from '@/components/ui/alert-dialog';
import { api } from '@/lib/api';
type Department = {
id: string;
name: string;
head: string;
trainersCount: number;
activeClients: number;
description: string;
};
type DeleteDepartmentDialogProps = {
open: boolean;
onClose: () => void;
onDeleted: () => void;
department: Department | null;
};
export function DeleteDepartmentDialog({
open,
onClose,
onDeleted,
department,
}: DeleteDepartmentDialogProps) {
const handleConfirm = async () => {
if (!department) return;
await api.delete(`/departments/${department.id}`);
onDeleted();
};
return (
<AlertDialog
open={open}
onClose={onClose}
onConfirm={handleConfirm}
title="Удалить департамент"
description={
department
? `Вы уверены, что хотите удалить департамент "${department.name}"? Все связанные данные будут потеряны. Это действие нельзя отменить.`
: ''
}
confirmLabel="Удалить"
cancelLabel="Отмена"
variant="danger"
/>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { useEffect, useState } from 'react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { api } from '@/lib/api';
type Department = {
id: string;
name: string;
head: string;
trainersCount: number;
activeClients: number;
description: string;
};
type DepartmentDialogProps = {
open: boolean;
onClose: () => void;
onSaved: () => void;
department?: Department | null;
};
export function DepartmentDialog({ open, onClose, onSaved, department }: DepartmentDialogProps) {
const isEdit = !!department;
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [headId, setHeadId] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
if (department) {
setName(department.name);
setDescription(department.description);
setHeadId(department.head);
} else {
setName('');
setDescription('');
setHeadId('');
}
setError(null);
}
}, [open, department]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const body = {
name,
description,
headId,
};
if (isEdit && department) {
await api.patch(`/departments/${department.id}`, body);
} else {
await api.post('/departments', body);
}
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка при сохранении');
} finally {
setLoading(false);
}
};
const inputClassName =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
return (
<Dialog open={open} onClose={onClose}>
<DialogHeader
title={isEdit ? 'Редактировать департамент' : 'Создать департамент'}
description={isEdit ? 'Измените данные департамента' : 'Заполните данные нового департамента'}
onClose={onClose}
/>
<form onSubmit={(e) => void handleSubmit(e)}>
<DialogBody className="space-y-4">
{error && (
<div className="bg-red-50 text-error text-sm px-4 py-3 rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Название
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Введите название департамента"
required
className={inputClassName}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Описание
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Описание департамента"
rows={3}
className={inputClassName}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Руководитель
</label>
<input
type="text"
value={headId}
onChange={(e) => setHeadId(e.target.value)}
placeholder="ID руководителя"
className={inputClassName}
/>
</div>
</DialogBody>
<DialogFooter>
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Сохранение...' : isEdit ? 'Сохранить' : 'Создать'}
</button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,301 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { api } from '@/lib/api';
type Integration = {
id: string;
name: string;
};
type ConfigureIntegrationDialogProps = {
open: boolean;
onClose: () => void;
onSaved: () => void;
integration: Integration | null;
};
type OneCForm = {
serverUrl: string;
apiKey: string;
syncInterval: number;
};
type SipForm = {
host: string;
port: number;
username: string;
password: string;
};
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
export function ConfigureIntegrationDialog({
open,
onClose,
onSaved,
integration,
}: ConfigureIntegrationDialogProps) {
const [oneCForm, setOneCForm] = useState<OneCForm>({
serverUrl: '',
apiKey: '',
syncInterval: 15,
});
const [sipForm, setSipForm] = useState<SipForm>({
host: '',
port: 5060,
username: '',
password: '',
});
const [loading, setLoading] = useState(false);
const [loadingConfig, setLoadingConfig] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !integration) return;
setError(null);
if (integration.id === '1c') {
setLoadingConfig(true);
api.get<Record<string, unknown>>('/integration/config')
.then((res) => {
setOneCForm({
serverUrl: (res.serverUrl as string) ?? '',
apiKey: (res.apiKey as string) ?? '',
syncInterval: (res.syncInterval as number) ?? 15,
});
})
.catch(() => {
// Use default empty form
})
.finally(() => setLoadingConfig(false));
} else if (integration.id === 'sip') {
setLoadingConfig(true);
api.get<Record<string, unknown>>('/sip/settings')
.then((res) => {
setSipForm({
host: (res.host as string) ?? '',
port: (res.port as number) ?? 5060,
username: (res.username as string) ?? '',
password: '',
});
})
.catch(() => {
// Use default empty form
})
.finally(() => setLoadingConfig(false));
}
}, [open, integration]);
const handleClose = () => {
setError(null);
setLoading(false);
setLoadingConfig(false);
onClose();
};
const handleSave = async () => {
if (!integration) return;
setLoading(true);
setError(null);
try {
if (integration.id === '1c') {
if (!oneCForm.serverUrl.trim()) {
setError('Введите URL сервера');
setLoading(false);
return;
}
await api.patch('/integration/config', {
serverUrl: oneCForm.serverUrl.trim(),
apiKey: oneCForm.apiKey.trim(),
syncInterval: oneCForm.syncInterval,
});
} else if (integration.id === 'sip') {
if (!sipForm.host.trim()) {
setError('Введите хост SIP-сервера');
setLoading(false);
return;
}
await api.patch('/sip/settings', {
host: sipForm.host.trim(),
port: sipForm.port,
username: sipForm.username.trim(),
password: sipForm.password || undefined,
});
}
onSaved();
handleClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось сохранить настройки');
} finally {
setLoading(false);
}
};
if (!integration) return null;
const title = `Настройка: ${integration.name}`;
return (
<Dialog open={open} onClose={handleClose}>
<DialogHeader title={title} onClose={handleClose} />
<DialogBody>
{loadingConfig ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
) : (
<div className="space-y-4">
{integration.id === '1c' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
URL сервера 1С
</label>
<input
type="text"
value={oneCForm.serverUrl}
onChange={(e) => setOneCForm((prev) => ({ ...prev, serverUrl: e.target.value }))}
placeholder="https://1c.example.com/api"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API-ключ 1С
</label>
<input
type="text"
value={oneCForm.apiKey}
onChange={(e) => setOneCForm((prev) => ({ ...prev, apiKey: e.target.value }))}
placeholder="Ключ авторизации"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Интервал синхронизации (минуты)
</label>
<input
type="number"
min={1}
max={1440}
value={oneCForm.syncInterval}
onChange={(e) =>
setOneCForm((prev) => ({ ...prev, syncInterval: Number(e.target.value) || 15 }))
}
className={inputClass}
/>
</div>
</>
)}
{integration.id === 'sip' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Хост SIP-сервера
</label>
<input
type="text"
value={sipForm.host}
onChange={(e) => setSipForm((prev) => ({ ...prev, host: e.target.value }))}
placeholder="sip.example.com"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Порт
</label>
<input
type="number"
min={1}
max={65535}
value={sipForm.port}
onChange={(e) =>
setSipForm((prev) => ({ ...prev, port: Number(e.target.value) || 5060 }))
}
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Имя пользователя
</label>
<input
type="text"
value={sipForm.username}
onChange={(e) => setSipForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="sip_user"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Пароль
</label>
<input
type="password"
value={sipForm.password}
onChange={(e) => setSipForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Оставьте пустым, чтобы не менять"
className={inputClass}
/>
</div>
</>
)}
{integration.id === 'webhooks' && (
<div className="text-center py-6">
<p className="text-sm text-gray-600">
Управление webhooks доступно через API.
</p>
<p className="text-xs text-muted mt-2">
Используйте API-ключ и эндпоинт <code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs font-mono">/v1/webhooks</code> для создания и настройки webhook-подписок.
</p>
</div>
)}
{error && (
<p className="text-sm text-error">{error}</p>
)}
</div>
)}
</DialogBody>
{integration.id !== 'webhooks' && (
<DialogFooter>
<button
onClick={handleClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Отмена
</button>
<button
onClick={() => void handleSave()}
disabled={loading || loadingConfig}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</DialogFooter>
)}
{integration.id === 'webhooks' && (
<DialogFooter>
<button
onClick={handleClose}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Закрыть
</button>
</DialogFooter>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { api } from '@/lib/api';
const ALL_SCOPES = [
'clients:read',
'clients:write',
'trainings:read',
'trainings:write',
'sales:read',
'sales:write',
'funnel:read',
'funnel:write',
'stats:read',
'webhooks:manage',
] as const;
type CreateApiKeyResponse = {
id: string;
name: string;
key: string;
scopes: string[];
};
type CreateApiKeyDialogProps = {
open: boolean;
onClose: () => void;
onCreated: () => void;
};
export function CreateApiKeyDialog({ open, onClose, onCreated }: CreateApiKeyDialogProps) {
const [step, setStep] = useState<'form' | 'result'>('form');
const [name, setName] = useState('');
const [scopes, setScopes] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createdKey, setCreatedKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const reset = () => {
setStep('form');
setName('');
setScopes([]);
setLoading(false);
setError(null);
setCreatedKey(null);
setCopied(false);
};
const handleClose = () => {
if (step === 'result') {
onCreated();
}
reset();
onClose();
};
const toggleScope = (scope: string) => {
setScopes((prev) =>
prev.includes(scope)
? prev.filter((s) => s !== scope)
: [...prev, scope],
);
};
const handleSubmit = async () => {
if (!name.trim()) {
setError('Введите название ключа');
return;
}
if (scopes.length === 0) {
setError('Выберите хотя бы один scope');
return;
}
setLoading(true);
setError(null);
try {
const res = await api.post<CreateApiKeyResponse>('/auth/api-keys', {
name: name.trim(),
scopes,
});
setCreatedKey(res.key);
setStep('result');
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось создать ключ');
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
if (!createdKey) return;
try {
await navigator.clipboard.writeText(createdKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API not available
}
};
return (
<Dialog open={open} onClose={handleClose}>
{step === 'form' ? (
<>
<DialogHeader
title="Создать API-ключ"
description="Укажите название и права доступа для нового ключа"
onClose={handleClose}
/>
<DialogBody>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Название
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Например: Интеграция с 1С"
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Права доступа (scopes)
</label>
<div className="grid grid-cols-2 gap-2">
{ALL_SCOPES.map((scope) => (
<label
key={scope}
className="flex items-center gap-2 p-2 rounded-lg border border-border hover:bg-gray-50 cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={scopes.includes(scope)}
onChange={() => toggleScope(scope)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary/30"
/>
<span className="text-xs font-mono text-gray-700">{scope}</span>
</label>
))}
</div>
</div>
{error && (
<p className="text-sm text-error">{error}</p>
)}
</div>
</DialogBody>
<DialogFooter>
<button
onClick={handleClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Отмена
</button>
<button
onClick={() => void handleSubmit()}
disabled={loading}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Создание...' : 'Создать ключ'}
</button>
</DialogFooter>
</>
) : (
<>
<DialogHeader
title="Ключ создан"
description="Скопируйте ключ сейчас. Он больше не будет показан."
onClose={handleClose}
/>
<DialogBody>
<div className="space-y-4">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800 font-medium">
Сохраните этот ключ в надёжном месте. После закрытия окна он будет недоступен.
</p>
</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-gray-100 px-3 py-2.5 rounded-lg font-mono text-gray-900 break-all select-all">
{createdKey}
</code>
<button
onClick={() => void handleCopy()}
className="shrink-0 p-2.5 rounded-lg border border-border hover:bg-gray-50 transition-colors"
title="Копировать"
>
{copied ? (
<Check className="h-4 w-4 text-success" />
) : (
<Copy className="h-4 w-4 text-muted" />
)}
</button>
</div>
</div>
</DialogBody>
<DialogFooter>
<button
onClick={handleClose}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors"
>
Закрыть
</button>
</DialogFooter>
</>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { useState } from 'react';
import { CheckCircle } from 'lucide-react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { api } from '@/lib/api';
type RenewDialogProps = {
open: boolean;
onClose: () => void;
onRenewed: () => void;
licenseInfo: { type: string; expiresAt: string } | null;
};
const RENEWAL_PERIODS = [
{ value: 1, label: '1 месяц' },
{ value: 3, label: '3 месяца' },
{ value: 6, label: '6 месяцев' },
{ value: 12, label: '12 месяцев' },
];
export function RenewDialog({ open, onClose, onRenewed, licenseInfo }: RenewDialogProps) {
const [period, setPeriod] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const reset = () => {
setPeriod(1);
setLoading(false);
setError(null);
setSuccess(false);
};
const handleClose = () => {
if (success) {
onRenewed();
}
reset();
onClose();
};
const handleSubmit = async () => {
setLoading(true);
setError(null);
try {
await api.post('/licenses/renew', { period });
setSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось продлить лицензию');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={handleClose} className="max-w-md">
<DialogHeader
title="Продление лицензии"
onClose={handleClose}
/>
<DialogBody>
{success ? (
<div className="text-center py-4">
<div className="bg-green-50 text-success p-3 rounded-full inline-flex mb-4">
<CheckCircle className="h-8 w-8" />
</div>
<h3 className="text-base font-semibold text-gray-900 mb-1">
Лицензия продлена
</h3>
<p className="text-sm text-gray-600">
Лицензия успешно продлена на {RENEWAL_PERIODS.find((p) => p.value === period)?.label}.
</p>
</div>
) : (
<div className="space-y-4">
{licenseInfo && (
<div className="bg-gray-50 rounded-lg p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted">Тип лицензии</span>
<span className="font-medium text-gray-900">{licenseInfo.type}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted">Истекает</span>
<span className="font-medium text-gray-900">{licenseInfo.expiresAt}</span>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Период продления
</label>
<div className="grid grid-cols-2 gap-2">
{RENEWAL_PERIODS.map((option) => (
<button
key={option.value}
onClick={() => setPeriod(option.value)}
className={`px-4 py-3 rounded-xl border text-sm font-medium transition-colors ${
period === option.value
? 'border-primary bg-primary/5 text-primary'
: 'border-border text-gray-700 hover:bg-gray-50'
}`}
>
{option.label}
</button>
))}
</div>
</div>
{error && (
<p className="text-sm text-error">{error}</p>
)}
</div>
)}
</DialogBody>
<DialogFooter>
{success ? (
<button
onClick={handleClose}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors"
>
Закрыть
</button>
) : (
<>
<button
onClick={handleClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Отмена
</button>
<button
onClick={() => void handleSubmit()}
disabled={loading}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Оформление...' : 'Продлить'}
</button>
</>
)}
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { AlertDialog } from '@/components/ui/alert-dialog';
import { api } from '@/lib/api';
type Room = {
id: string;
name: string;
};
type DeleteRoomDialogProps = {
open: boolean;
onClose: () => void;
onDeleted: () => void;
room: Room | null;
};
export function DeleteRoomDialog({ open, onClose, onDeleted, room }: DeleteRoomDialogProps) {
const handleConfirm = async () => {
if (!room) return;
await api.delete(`/rooms/${room.id}`);
onDeleted();
};
return (
<AlertDialog
open={open}
onClose={onClose}
onConfirm={handleConfirm}
title="Удалить зал"
description={`Вы уверены, что хотите удалить зал «${room?.name ?? ''}»? Это действие нельзя отменить.`}
confirmLabel="Удалить"
cancelLabel="Отмена"
variant="danger"
/>
);
}

View File

@@ -0,0 +1,225 @@
'use client';
import { useEffect, useState } from 'react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { api } from '@/lib/api';
type Room = {
id: string;
name: string;
location: string;
capacity: number;
type: string;
equipment: string[];
status: 'available' | 'occupied' | 'maintenance';
workingHours: string;
};
type RoomDialogProps = {
open: boolean;
onClose: () => void;
onSaved: () => void;
room?: Room | null;
};
export function RoomDialog({ open, onClose, onSaved, room }: RoomDialogProps) {
const [name, setName] = useState('');
const [location, setLocation] = useState('');
const [capacity, setCapacity] = useState<number>(0);
const [type, setType] = useState('');
const [workingHours, setWorkingHours] = useState('');
const [status, setStatus] = useState<'available' | 'occupied' | 'maintenance'>('available');
const [equipmentText, setEquipmentText] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = !!room;
useEffect(() => {
if (open) {
if (room) {
setName(room.name);
setLocation(room.location);
setCapacity(room.capacity);
setType(room.type);
setWorkingHours(room.workingHours);
setStatus(room.status);
setEquipmentText(room.equipment.join(', '));
} else {
setName('');
setLocation('');
setCapacity(0);
setType('');
setWorkingHours('');
setStatus('available');
setEquipmentText('');
}
setError(null);
}
}, [open, room]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const equipment = equipmentText
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const body = { name, location, capacity, type, workingHours, status, equipment };
try {
if (isEdit) {
await api.patch(`/rooms/${room.id}`, body);
} else {
await api.post('/rooms', body);
}
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось сохранить зал');
} finally {
setLoading(false);
}
};
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
return (
<Dialog open={open} onClose={onClose}>
<DialogHeader
title={isEdit ? 'Редактировать зал' : 'Добавить зал'}
description={isEdit ? 'Измените данные зала' : 'Заполните информацию о новом зале'}
onClose={onClose}
/>
<form onSubmit={(e) => void handleSubmit(e)}>
<DialogBody className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-error text-sm px-3 py-2 rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Название <span className="text-error">*</span>
</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Например: Зал групповых программ"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Локация <span className="text-error">*</span>
</label>
<input
type="text"
required
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Например: 2 этаж, корпус А"
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Вместимость <span className="text-error">*</span>
</label>
<input
type="number"
required
min={0}
value={capacity}
onChange={(e) => setCapacity(Number(e.target.value))}
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Тип
</label>
<input
type="text"
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="Например: тренажёрный"
className={inputClass}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Часы работы
</label>
<input
type="text"
value={workingHours}
onChange={(e) => setWorkingHours(e.target.value)}
placeholder="Например: 07:00 — 23:00"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Статус
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as 'available' | 'occupied' | 'maintenance')}
className={inputClass}
>
<option value="available">Свободен</option>
<option value="occupied">Занят</option>
<option value="maintenance">Обслуживание</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Оборудование
</label>
<input
type="text"
value={equipmentText}
onChange={(e) => setEquipmentText(e.target.value)}
placeholder="Через запятую: коврики, зеркала, колонки"
className={inputClass}
/>
<p className="text-xs text-muted mt-1">Перечислите через запятую</p>
</div>
</DialogBody>
<DialogFooter>
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Сохранение...' : isEdit ? 'Сохранить' : 'Добавить'}
</button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import { AlertDialog } from '@/components/ui/alert-dialog';
import { api } from '@/lib/api';
type DeactivateDialogProps = {
open: boolean;
onClose: () => void;
onConfirmed: () => void;
clubName: string;
};
export function DeactivateDialog({ open, onClose, onConfirmed, clubName }: DeactivateDialogProps) {
const handleConfirm = async () => {
await api.post('/clubs/deactivate');
onConfirmed();
};
return (
<AlertDialog
open={open}
onClose={onClose}
onConfirm={handleConfirm}
title="Деактивировать клуб"
description={`Вы собираетесь деактивировать клуб "${clubName}". Доступ для всех сотрудников будет заблокирован. Данные сохранятся, но пользоваться системой будет невозможно до повторной активации.`}
confirmLabel="Деактивировать"
variant="danger"
/>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { AlertDialog } from '@/components/ui/alert-dialog';
import { api } from '@/lib/api';
type Employee = {
id: string;
name: string;
role: string;
department: string;
phone: string;
status: 'active' | 'inactive';
hiredAt: string;
};
type DeleteEmployeeDialogProps = {
open: boolean;
onClose: () => void;
onDeleted: () => void;
employee: Employee | null;
};
export function DeleteEmployeeDialog({
open,
onClose,
onDeleted,
employee,
}: DeleteEmployeeDialogProps) {
const handleConfirm = async () => {
if (!employee) return;
await api.delete(`/users/${employee.id}`);
onDeleted();
};
return (
<AlertDialog
open={open}
onClose={onClose}
onConfirm={handleConfirm}
title="Удалить сотрудника"
description={
employee
? `Вы уверены, что хотите удалить сотрудника "${employee.name}"? Это действие нельзя отменить.`
: ''
}
confirmLabel="Удалить"
cancelLabel="Отмена"
variant="danger"
/>
);
}

View File

@@ -0,0 +1,206 @@
'use client';
import { useEffect, useState } from 'react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { api } from '@/lib/api';
type Employee = {
id: string;
name: string;
role: string;
department: string;
phone: string;
status: 'active' | 'inactive';
hiredAt: string;
};
type EmployeeDialogProps = {
open: boolean;
onClose: () => void;
onSaved: () => void;
employee?: Employee | null;
};
const ROLE_OPTIONS = [
{ value: 'trainer', label: 'Тренер' },
{ value: 'coordinator', label: 'Координатор' },
{ value: 'manager', label: 'Фитнес-менеджер' },
{ value: 'receptionist', label: 'Администратор' },
];
export function EmployeeDialog({ open, onClose, onSaved, employee }: EmployeeDialogProps) {
const isEdit = !!employee;
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [role, setRole] = useState('trainer');
const [departmentId, setDepartmentId] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
if (employee) {
setName(employee.name);
setPhone(employee.phone);
setRole(employee.role);
setDepartmentId(employee.department);
setPassword('');
} else {
setName('');
setPhone('');
setRole('trainer');
setDepartmentId('');
setPassword('');
}
setError(null);
}
}, [open, employee]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const body: Record<string, string> = {
name,
phone,
role,
departmentId,
};
if (!isEdit) {
body.password = password;
}
if (isEdit && employee) {
await api.patch(`/users/${employee.id}`, body);
} else {
await api.post('/users', body);
}
onSaved();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Произошла ошибка при сохранении');
} finally {
setLoading(false);
}
};
const inputClassName =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
return (
<Dialog open={open} onClose={onClose}>
<DialogHeader
title={isEdit ? 'Редактировать сотрудника' : 'Добавить сотрудника'}
description={isEdit ? 'Измените данные сотрудника' : 'Заполните данные нового сотрудника'}
onClose={onClose}
/>
<form onSubmit={(e) => void handleSubmit(e)}>
<DialogBody className="space-y-4">
{error && (
<div className="bg-red-50 text-error text-sm px-4 py-3 rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Имя
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Введите имя сотрудника"
required
className={inputClassName}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Телефон
</label>
<input
type="text"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+7 (999) 123-45-67"
required
className={inputClassName}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Роль
</label>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
required
className={inputClassName}
>
{ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Департамент
</label>
<input
type="text"
value={departmentId}
onChange={(e) => setDepartmentId(e.target.value)}
placeholder="ID департамента"
className={inputClassName}
/>
</div>
{!isEdit && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Пароль
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Введите пароль"
required
className={inputClassName}
/>
</div>
)}
</DialogBody>
<DialogFooter>
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Сохранение...' : isEdit ? 'Сохранить' : 'Добавить'}
</button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from './dialog';
type AlertDialogProps = {
open: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'danger' | 'warning';
};
export function AlertDialog({
open,
onClose,
onConfirm,
title,
description,
confirmLabel = 'Удалить',
cancelLabel = 'Отмена',
variant = 'danger',
}: AlertDialogProps) {
const [loading, setLoading] = useState(false);
const handleConfirm = async () => {
setLoading(true);
try {
await onConfirm();
onClose();
} catch {
// Error handled by caller
} finally {
setLoading(false);
}
};
const btnColor =
variant === 'danger'
? 'bg-error text-white hover:bg-error/90'
: 'bg-yellow-500 text-white hover:bg-yellow-600';
return (
<Dialog open={open} onClose={onClose} className="max-w-md">
<DialogHeader title={title} onClose={onClose} />
<DialogBody>
<div className="flex gap-4">
<div className="shrink-0 bg-red-50 text-error p-2.5 rounded-full h-fit">
<AlertTriangle className="h-5 w-5" />
</div>
<p className="text-sm text-gray-600 leading-relaxed">{description}</p>
</div>
</DialogBody>
<DialogFooter>
<button
onClick={onClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
{cancelLabel}
</button>
<button
onClick={() => void handleConfirm()}
disabled={loading}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 ${btnColor}`}
>
{loading ? 'Подождите...' : confirmLabel}
</button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { useEffect, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/cn';
type DialogProps = {
open: boolean;
onClose: () => void;
children: ReactNode;
className?: string;
};
export function Dialog({ open, onClose, children, className }: DialogProps) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = '';
};
}, [open, onClose]);
if (!open) return null;
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={(e) => {
if (e.target === overlayRef.current) onClose();
}}
>
<div className="fixed inset-0 bg-black/50 animate-in fade-in" />
<div
className={cn(
'relative z-10 bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in-95',
className,
)}
>
{children}
</div>
</div>
);
}
type DialogHeaderProps = {
title: string;
description?: string;
onClose: () => void;
};
export function DialogHeader({ title, description, onClose }: DialogHeaderProps) {
return (
<div className="flex items-start justify-between p-6 pb-0">
<div>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
{description && (
<p className="text-sm text-muted mt-1">{description}</p>
)}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors -mt-1 -mr-1"
>
<X className="h-4 w-4 text-muted" />
</button>
</div>
);
}
type DialogBodyProps = {
children: ReactNode;
className?: string;
};
export function DialogBody({ children, className }: DialogBodyProps) {
return <div className={cn('p-6', className)}>{children}</div>;
}
type DialogFooterProps = {
children: ReactNode;
className?: string;
};
export function DialogFooter({ children, className }: DialogFooterProps) {
return (
<div className={cn('flex items-center justify-end gap-3 px-6 pb-6', className)}>
{children}
</div>
);
}

View File

@@ -57,6 +57,32 @@ function mapLog(l: ApiAuditLog): AuditEntry {
};
}
function handleExport(entries: AuditEntry[]) {
const header = 'Время,Уровень,Пользователь,Действие,Ресурс,IP,Описание';
const escapeField = (val: string) => {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return `"${val.replace(/"/g, '""')}"`;
}
return val;
};
const rows = entries.map((e) =>
[e.timestamp, levelLabels[e.level], e.user, e.action, e.resource, e.ip, e.details]
.map(escapeField)
.join(','),
);
const csv = '\uFEFF' + [header, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const date = new Date().toISOString().split('T')[0];
const link = document.createElement('a');
link.href = url;
link.download = `audit-log-${date}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
export default function AuditPage() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [totalCount, setTotalCount] = useState(0);
@@ -108,7 +134,11 @@ export default function AuditPage() {
<button onClick={() => void fetchLogs()} className="flex items-center gap-2 border border-border bg-card text-gray-700 px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-background transition-colors">
<RefreshCw className="h-4 w-4" />Обновить
</button>
<button className="flex items-center gap-2 border border-border bg-card text-gray-700 px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-background transition-colors">
<button
onClick={() => handleExport(filtered)}
disabled={filtered.length === 0}
className="flex items-center gap-2 border border-border bg-card text-gray-700 px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-background transition-colors disabled:opacity-50"
>
<Download className="h-4 w-4" />Экспорт
</button>
</div>

View File

@@ -4,12 +4,15 @@ import { useEffect, useState, useCallback } from 'react';
import {
Search,
Plus,
MoreHorizontal,
Building2,
ChevronDown,
} from 'lucide-react';
import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
import { CreateClubDialog } from '@/components/clubs/create-club-dialog';
import { EditClubDialog } from '@/components/clubs/edit-club-dialog';
import { ClubActionsMenu } from '@/components/clubs/club-actions-menu';
import { AlertDialog } from '@/components/ui/alert-dialog';
interface ApiClub {
id: string;
@@ -147,6 +150,11 @@ export default function ClubsPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
// Dialog states
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editClub, setEditClub] = useState<{ id: string; name: string; city: string } | null>(null);
const [deactivateClub, setDeactivateClub] = useState<{ id: string; name: string; status: string } | null>(null);
const fetchClubs = useCallback(async () => {
setLoading(true);
setError(null);
@@ -183,6 +191,17 @@ export default function ClubsPage() {
return () => clearTimeout(debounce);
}, [fetchClubs]);
const handleDeactivateConfirm = async () => {
if (!deactivateClub) return;
const isBlocked = deactivateClub.status === 'blocked';
if (isBlocked) {
await api.patch(`/admin/clubs/${deactivateClub.id}`, { isActive: true });
} else {
await api.delete(`/admin/clubs/${deactivateClub.id}`);
}
void fetchClubs();
};
const filtered = clubs.filter((club) => {
// API already handles search, but we still filter status client-side
// since API only has isActive filter, not grace/trial distinction
@@ -224,7 +243,10 @@ export default function ClubsPage() {
{totalCount} клубов на платформе
</p>
</div>
<button className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors">
<button
onClick={() => setShowCreateDialog(true)}
className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4" />
Добавить клуб
</button>
@@ -334,9 +356,11 @@ export default function ClubsPage() {
<td className="px-4 py-3 text-muted">{club.createdAt}</td>
<td className="px-4 py-3 text-muted">{club.expiresAt}</td>
<td className="px-4 py-3">
<button className="p-1.5 rounded-lg hover:bg-background transition-colors">
<MoreHorizontal className="h-4 w-4 text-muted" />
</button>
<ClubActionsMenu
club={{ id: club.id, name: club.name, status: club.status }}
onEdit={() => setEditClub({ id: club.id, name: club.name, city: club.city })}
onToggleActive={() => setDeactivateClub({ id: club.id, name: club.name, status: club.status })}
/>
</td>
</tr>
))}
@@ -350,6 +374,39 @@ export default function ClubsPage() {
</p>
</div>
</div>
<CreateClubDialog
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onCreated={() => void fetchClubs()}
/>
<EditClubDialog
open={editClub !== null}
onClose={() => setEditClub(null)}
onSaved={() => void fetchClubs()}
club={editClub}
/>
<AlertDialog
open={deactivateClub !== null}
onClose={() => setDeactivateClub(null)}
onConfirm={handleDeactivateConfirm}
title={
deactivateClub?.status === 'blocked'
? 'Активация клуба'
: 'Деактивация клуба'
}
description={
deactivateClub?.status === 'blocked'
? `Вы уверены, что хотите активировать клуб «${deactivateClub?.name ?? ''}»? Доступ к системе будет восстановлен.`
: `Вы уверены, что хотите деактивировать клуб «${deactivateClub?.name ?? ''}»? Пользователи клуба потеряют доступ к системе.`
}
confirmLabel={
deactivateClub?.status === 'blocked' ? 'Активировать' : 'Деактивировать'
}
variant={deactivateClub?.status === 'blocked' ? 'warning' : 'danger'}
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import {
Plus,
Check,
@@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/cn';
import { api } from '@/lib/api';
import { IssueLicenseDialog } from '@/components/licenses/issue-license-dialog';
const licenseTypes = [
{
@@ -191,8 +192,9 @@ export default function LicensesPage() {
standard: 0,
premium: 0,
});
const [showIssueDialog, setShowIssueDialog] = useState(false);
useEffect(() => {
const loadLicenses = useCallback(() => {
setLoading(true);
setError(null);
api
@@ -225,6 +227,10 @@ export default function LicensesPage() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadLicenses();
}, [loadLicenses]);
// Update licenseTypes with live counts
const enrichedLicenseTypes = licenseTypes.map((type) => ({
...type,
@@ -264,7 +270,10 @@ export default function LicensesPage() {
{licenses.length} лицензий выдано
</p>
</div>
<button className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors">
<button
onClick={() => setShowIssueDialog(true)}
className="flex items-center gap-2 bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="h-4 w-4" />
Выдать лицензию
</button>
@@ -438,6 +447,12 @@ export default function LicensesPage() {
</div>
</div>
)}
<IssueLicenseDialog
open={showIssueDialog}
onClose={() => setShowIssueDialog(false)}
onCreated={loadLicenses}
/>
</div>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { MoreHorizontal, Pencil, Power } from 'lucide-react';
type ClubForActions = {
id: string;
name: string;
status: string;
};
type ClubActionsMenuProps = {
club: ClubForActions;
onEdit: () => void;
onToggleActive: () => void;
};
export function ClubActionsMenu({ club, onEdit, onToggleActive }: ClubActionsMenuProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(e.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
const isBlocked = club.status === 'blocked';
return (
<div className="relative">
<button
ref={buttonRef}
onClick={() => setOpen((prev) => !prev)}
className="p-1.5 rounded-lg hover:bg-background transition-colors"
>
<MoreHorizontal className="h-4 w-4 text-muted" />
</button>
{open && (
<div
ref={menuRef}
className="absolute right-0 top-full mt-1 z-20 w-48 bg-white rounded-xl shadow-lg border border-border py-1 animate-in fade-in zoom-in-95"
>
<button
onClick={() => {
setOpen(false);
onEdit();
}}
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<Pencil className="h-3.5 w-3.5 text-muted" />
Редактировать
</button>
<button
onClick={() => {
setOpen(false);
onToggleActive();
}}
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<Power className="h-3.5 w-3.5 text-muted" />
{isBlocked ? 'Активировать' : 'Деактивировать'}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,265 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { api } from '@/lib/api';
type CreateClubDialogProps = {
open: boolean;
onClose: () => void;
onCreated: () => void;
};
function generateSlug(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-zа-яё0-9\s-]/gi, '')
.replace(/[а-яё]/gi, (ch) => {
const map: Record<string, string> = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'shch',
'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya',
};
return map[ch.toLowerCase()] ?? '';
})
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
const initialForm = {
name: '',
slug: '',
address: '',
phone: '',
email: '',
licenseType: 'STARTER' as 'STARTER' | 'STANDARD' | 'PREMIUM',
maxUsers: 5,
maxClients: 100,
};
export function CreateClubDialog({ open, onClose, onCreated }: CreateClubDialogProps) {
const [form, setForm] = useState(initialForm);
const [slugManual, setSlugManual] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setForm(initialForm);
setSlugManual(false);
setError(null);
}
}, [open]);
useEffect(() => {
if (!slugManual) {
setForm((prev) => ({ ...prev, slug: generateSlug(prev.name) }));
}
}, [form.name, slugManual]);
const handleChange = (field: keyof typeof form, value: string | number) => {
setForm((prev) => ({ ...prev, [field]: value }));
setError(null);
};
const handleSlugChange = (value: string) => {
setSlugManual(true);
setForm((prev) => ({
...prev,
slug: value
.toLowerCase()
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-'),
}));
setError(null);
};
const handleSubmit = async () => {
if (!form.name.trim()) {
setError('Введите название клуба');
return;
}
if (!form.slug.trim()) {
setError('Введите slug клуба');
return;
}
setLoading(true);
setError(null);
try {
const body: Record<string, unknown> = {
name: form.name.trim(),
slug: form.slug.trim(),
licenseType: form.licenseType,
maxUsers: form.maxUsers,
maxClients: form.maxClients,
};
if (form.address.trim()) body.address = form.address.trim();
if (form.phone.trim()) body.phone = form.phone.trim();
if (form.email.trim()) body.email = form.email.trim();
await api.post('/clubs', body);
onCreated();
onClose();
} catch (err) {
const message = err instanceof Error ? err.message : 'Ошибка при создании клуба';
setError(message);
} finally {
setLoading(false);
}
};
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
return (
<Dialog open={open} onClose={onClose}>
<DialogHeader
title="Создание клуба"
description="Заполните данные нового клуба"
onClose={onClose}
/>
<DialogBody className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Название <span className="text-error">*</span>
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Фитнес-клуб «Энергия»"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Slug <span className="text-error">*</span>
</label>
<input
type="text"
value={form.slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="fitness-klub-energiya"
className={inputClass}
/>
<p className="text-xs text-muted mt-1">
Уникальный идентификатор (латиница, цифры, дефис)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Адрес
</label>
<input
type="text"
value={form.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder="г. Москва, ул. Примерная, д. 1"
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Телефон
</label>
<input
type="text"
value={form.phone}
onChange={(e) => handleChange('phone', e.target.value)}
placeholder="+7 (999) 123-45-67"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={form.email}
onChange={(e) => handleChange('email', e.target.value)}
placeholder="info@club.ru"
className={inputClass}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Тип лицензии
</label>
<select
value={form.licenseType}
onChange={(e) => handleChange('licenseType', e.target.value)}
className={inputClass}
>
<option value="STARTER">Стартовая</option>
<option value="STANDARD">Стандартная</option>
<option value="PREMIUM">Премиум</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Макс. пользователей
</label>
<input
type="number"
min={1}
value={form.maxUsers}
onChange={(e) => handleChange('maxUsers', parseInt(e.target.value, 10) || 1)}
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Макс. клиентов
</label>
<input
type="number"
min={1}
value={form.maxClients}
onChange={(e) => handleChange('maxClients', parseInt(e.target.value, 10) || 1)}
className={inputClass}
/>
</div>
</div>
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-sm text-error">
{error}
</div>
)}
</DialogBody>
<DialogFooter>
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Отмена
</button>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={loading}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Создание...' : 'Создать клуб'}
</button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,203 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { api } from '@/lib/api';
type EditClubDialogProps = {
open: boolean;
onClose: () => void;
onSaved: () => void;
club: { id: string; name: string; city: string } | null;
};
interface ClubDetail {
id: string;
name: string;
address: string | null;
phone: string | null;
email: string | null;
}
export function EditClubDialog({ open, onClose, onSaved, club }: EditClubDialogProps) {
const [form, setForm] = useState({
name: '',
address: '',
phone: '',
email: '',
});
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !club) return;
let cancelled = false;
setFetching(true);
setError(null);
api
.get<ClubDetail>(`/admin/clubs/${club.id}`)
.then((data) => {
if (cancelled) return;
setForm({
name: data.name ?? '',
address: data.address ?? '',
phone: data.phone ?? '',
email: data.email ?? '',
});
})
.catch((err) => {
if (cancelled) return;
const message = err instanceof Error ? err.message : 'Ошибка загрузки данных клуба';
setError(message);
})
.finally(() => {
if (!cancelled) setFetching(false);
});
return () => {
cancelled = true;
};
}, [open, club]);
const handleChange = (field: keyof typeof form, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
setError(null);
};
const handleSubmit = async () => {
if (!club) return;
if (!form.name.trim()) {
setError('Введите название клуба');
return;
}
setLoading(true);
setError(null);
try {
const body: Record<string, string> = {
name: form.name.trim(),
};
if (form.address.trim()) body.address = form.address.trim();
else body.address = '';
if (form.phone.trim()) body.phone = form.phone.trim();
else body.phone = '';
if (form.email.trim()) body.email = form.email.trim();
else body.email = '';
await api.patch(`/admin/clubs/${club.id}`, body);
onSaved();
onClose();
} catch (err) {
const message = err instanceof Error ? err.message : 'Ошибка при сохранении';
setError(message);
} finally {
setLoading(false);
}
};
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
return (
<Dialog open={open} onClose={onClose}>
<DialogHeader
title="Редактирование клуба"
description={club?.name ?? ''}
onClose={onClose}
/>
<DialogBody className="space-y-4">
{fetching ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Название <span className="text-error">*</span>
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Название клуба"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Адрес
</label>
<input
type="text"
value={form.address}
onChange={(e) => handleChange('address', e.target.value)}
placeholder="г. Москва, ул. Примерная, д. 1"
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Телефон
</label>
<input
type="text"
value={form.phone}
onChange={(e) => handleChange('phone', e.target.value)}
placeholder="+7 (999) 123-45-67"
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={form.email}
onChange={(e) => handleChange('email', e.target.value)}
placeholder="info@club.ru"
className={inputClass}
/>
</div>
</div>
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-sm text-error">
{error}
</div>
)}
</>
)}
</DialogBody>
{!fetching && (
<DialogFooter>
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
Отмена
</button>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={loading}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{loading ? 'Сохранение...' : 'Сохранить'}
</button>
</DialogFooter>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,272 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@/components/ui/dialog';
import { api } from '@/lib/api';
type LicenseType = 'STARTER' | 'STANDARD' | 'PREMIUM';
interface Club {
id: string;
name: string;
}
interface IssueLicenseDialogProps {
open: boolean;
onClose: () => void;
onCreated: () => void;
}
const typeDefaults: Record<LicenseType, { maxUsers: number; maxClients: number }> = {
STARTER: { maxUsers: 5, maxClients: 500 },
STANDARD: { maxUsers: 10, maxClients: 1000 },
PREMIUM: { maxUsers: 999, maxClients: 99999 },
};
const typeLabels: Record<LicenseType, string> = {
STARTER: 'Стартовая',
STANDARD: 'Стандартная',
PREMIUM: 'Премиум',
};
function toDateInputValue(date: Date): string {
return date.toISOString().split('T')[0] ?? '';
}
export function IssueLicenseDialog({ open, onClose, onCreated }: IssueLicenseDialogProps) {
const [clubs, setClubs] = useState<Club[]>([]);
const [clubsLoading, setClubsLoading] = useState(false);
const [clubsError, setClubsError] = useState<string | null>(null);
const [clubId, setClubId] = useState('');
const [type, setType] = useState<LicenseType>('STARTER');
const [startDate, setStartDate] = useState(() => toDateInputValue(new Date()));
const [endDate, setEndDate] = useState(() => {
const d = new Date();
d.setDate(d.getDate() + 365);
return toDateInputValue(d);
});
const [maxUsers, setMaxUsers] = useState(typeDefaults.STARTER.maxUsers);
const [maxClients, setMaxClients] = useState(typeDefaults.STARTER.maxClients);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const fetchClubs = useCallback(async () => {
setClubsLoading(true);
setClubsError(null);
try {
const res = await api.get<{ data: Club[] }>('/admin/clubs?limit=500');
setClubs(res.data);
} catch (err) {
const message = err instanceof Error ? err.message : 'Не удалось загрузить список клубов';
setClubsError(message);
} finally {
setClubsLoading(false);
}
}, []);
useEffect(() => {
if (open) {
void fetchClubs();
// Reset form on open
setClubId('');
setType('STARTER');
const today = new Date();
setStartDate(toDateInputValue(today));
const end = new Date(today);
end.setDate(end.getDate() + 365);
setEndDate(toDateInputValue(end));
setMaxUsers(typeDefaults.STARTER.maxUsers);
setMaxClients(typeDefaults.STARTER.maxClients);
setSubmitError(null);
}
}, [open, fetchClubs]);
useEffect(() => {
const defaults = typeDefaults[type];
setMaxUsers(defaults.maxUsers);
setMaxClients(defaults.maxClients);
}, [type]);
useEffect(() => {
if (startDate) {
const d = new Date(startDate);
d.setDate(d.getDate() + 365);
setEndDate(toDateInputValue(d));
}
}, [startDate]);
const inputClass =
'w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary';
const handleSubmit = async () => {
if (!clubId) {
setSubmitError('Выберите клуб');
return;
}
if (!startDate || !endDate) {
setSubmitError('Укажите даты начала и окончания');
return;
}
setSubmitting(true);
setSubmitError(null);
try {
await api.post('/licenses', {
clubId,
type,
startDate: new Date(startDate).toISOString(),
endDate: new Date(endDate).toISOString(),
maxUsers,
maxClients,
});
onCreated();
onClose();
} catch (err) {
const message = err instanceof Error ? err.message : 'Не удалось выдать лицензию';
setSubmitError(message);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onClose={onClose}>
<DialogHeader
title="Выдать лицензию"
description="Создание новой лицензии для клуба"
onClose={onClose}
/>
<DialogBody className="space-y-4">
{/* Club select */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Клуб</label>
{clubsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted py-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
Загрузка клубов...
</div>
) : clubsError ? (
<div className="text-sm text-error py-1">
{clubsError}{' '}
<button
type="button"
onClick={() => void fetchClubs()}
className="text-primary hover:underline"
>
Повторить
</button>
</div>
) : (
<select
value={clubId}
onChange={(e) => setClubId(e.target.value)}
className={inputClass}
>
<option value="">-- Выберите клуб --</option>
{clubs.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
</div>
{/* License type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Тип лицензии</label>
<select
value={type}
onChange={(e) => setType(e.target.value as LicenseType)}
className={inputClass}
>
{(Object.keys(typeLabels) as LicenseType[]).map((t) => (
<option key={t} value={t}>
{typeLabels[t]}
</option>
))}
</select>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Дата начала</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Дата окончания</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className={inputClass}
/>
</div>
</div>
{/* Limits */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Макс. пользователей
</label>
<input
type="number"
min={1}
value={maxUsers}
onChange={(e) => setMaxUsers(Number(e.target.value))}
className={inputClass}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Макс. клиентов
</label>
<input
type="number"
min={1}
value={maxClients}
onChange={(e) => setMaxClients(Number(e.target.value))}
className={inputClass}
/>
</div>
</div>
{submitError && (
<div className="px-3 py-2 rounded-lg bg-red-50 border border-red-200 text-error text-sm">
{submitError}
</div>
)}
</DialogBody>
<DialogFooter>
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-4 py-2.5 rounded-xl text-sm font-medium border border-border text-gray-700 hover:bg-background transition-colors disabled:opacity-50"
>
Отмена
</button>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={submitting || clubsLoading}
className="bg-primary text-white px-4 py-2.5 rounded-xl text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{submitting ? 'Создание...' : 'Выдать лицензию'}
</button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { Dialog, DialogHeader, DialogBody, DialogFooter } from './dialog';
type AlertDialogProps = {
open: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'danger' | 'warning';
};
export function AlertDialog({
open,
onClose,
onConfirm,
title,
description,
confirmLabel = 'Удалить',
cancelLabel = 'Отмена',
variant = 'danger',
}: AlertDialogProps) {
const [loading, setLoading] = useState(false);
const handleConfirm = async () => {
setLoading(true);
try {
await onConfirm();
onClose();
} catch {
// Error handled by caller
} finally {
setLoading(false);
}
};
const btnColor =
variant === 'danger'
? 'bg-error text-white hover:bg-error/90'
: 'bg-yellow-500 text-white hover:bg-yellow-600';
return (
<Dialog open={open} onClose={onClose} className="max-w-md">
<DialogHeader title={title} onClose={onClose} />
<DialogBody>
<div className="flex gap-4">
<div className="shrink-0 bg-red-50 text-error p-2.5 rounded-full h-fit">
<AlertTriangle className="h-5 w-5" />
</div>
<p className="text-sm text-gray-600 leading-relaxed">{description}</p>
</div>
</DialogBody>
<DialogFooter>
<button
onClick={onClose}
disabled={loading}
className="px-4 py-2 rounded-xl border border-border text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
>
{cancelLabel}
</button>
<button
onClick={() => void handleConfirm()}
disabled={loading}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 ${btnColor}`}
>
{loading ? 'Подождите...' : confirmLabel}
</button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { useEffect, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/cn';
type DialogProps = {
open: boolean;
onClose: () => void;
children: ReactNode;
className?: string;
};
export function Dialog({ open, onClose, children, className }: DialogProps) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = '';
};
}, [open, onClose]);
if (!open) return null;
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={(e) => {
if (e.target === overlayRef.current) onClose();
}}
>
<div className="fixed inset-0 bg-black/50 animate-in fade-in" />
<div
className={cn(
'relative z-10 bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in-95',
className,
)}
>
{children}
</div>
</div>
);
}
type DialogHeaderProps = {
title: string;
description?: string;
onClose: () => void;
};
export function DialogHeader({ title, description, onClose }: DialogHeaderProps) {
return (
<div className="flex items-start justify-between p-6 pb-0">
<div>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
{description && (
<p className="text-sm text-muted mt-1">{description}</p>
)}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors -mt-1 -mr-1"
>
<X className="h-4 w-4 text-muted" />
</button>
</div>
);
}
type DialogBodyProps = {
children: ReactNode;
className?: string;
};
export function DialogBody({ children, className }: DialogBodyProps) {
return <div className={cn('p-6', className)}>{children}</div>;
}
type DialogFooterProps = {
children: ReactNode;
className?: string;
};
export function DialogFooter({ children, className }: DialogFooterProps) {
return (
<div className={cn('flex items-center justify-end gap-3 px-6 pb-6', className)}>
{children}
</div>
);
}

View File

@@ -7,11 +7,11 @@ services:
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
cpus: "1.0"
memory: 1G
reservations:
cpus: "0.5"
memory: 512M
cpus: "0.25"
memory: 256M
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
@@ -23,22 +23,22 @@ services:
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
cpus: "0.5"
memory: 512M
reservations:
cpus: "0.25"
memory: 256M
cpus: "0.15"
memory: 128M
coturn:
restart: always
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
cpus: "0.5"
memory: 256M
reservations:
cpus: "0.25"
memory: 128M
cpus: "0.1"
memory: 64M
api:
build:
@@ -64,11 +64,11 @@ services:
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
cpus: "1.5"
memory: 1G
reservations:
cpus: "0.5"
memory: 512M
cpus: "0.25"
memory: 256M
networks:
- fitcrm-network
@@ -88,11 +88,11 @@ services:
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
cpus: "0.5"
memory: 512M
reservations:
cpus: "0.25"
memory: 256M
cpus: "0.15"
memory: 128M
networks:
- fitcrm-network
@@ -112,11 +112,11 @@ services:
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
cpus: "0.5"
memory: 512M
reservations:
cpus: "0.25"
memory: 256M
cpus: "0.15"
memory: 128M
networks:
- fitcrm-network
@@ -136,11 +136,11 @@ services:
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
cpus: "0.5"
memory: 512M
reservations:
cpus: "0.25"
memory: 256M
cpus: "0.15"
memory: 128M
networks:
- fitcrm-network

258
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
apps/api:
dependencies:
'@nestjs/bullmq':
specifier: ^11.0.4
version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.69.3)
'@nestjs/common':
specifier: ^11.0.0
version: 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -38,6 +41,9 @@ importers:
'@nestjs/core':
specifier: ^11.0.0
version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/event-emitter':
specifier: ^3.0.1
version: 3.0.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)
'@nestjs/jwt':
specifier: ^11.0.0
version: 11.0.2(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))
@@ -47,9 +53,15 @@ importers:
'@nestjs/platform-express':
specifier: ^11.0.0
version: 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)
'@nestjs/schedule':
specifier: ^6.1.1
version: 6.1.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)
'@nestjs/swagger':
specifier: ^11.0.0
version: 11.2.6(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)
'@nestjs/throttler':
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(reflect-metadata@0.2.2)
'@prisma/client':
specifier: ^6.0.0
version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)
@@ -68,12 +80,18 @@ importers:
express:
specifier: ^5.0.0
version: 5.2.1
otplib:
specifier: ^13.3.0
version: 13.3.0
passport:
specifier: ^0.7.0
version: 0.7.0
passport-jwt:
specifier: ^4.0.1
version: 4.0.1
qrcode:
specifier: ^1.5.4
version: 1.5.4
reflect-metadata:
specifier: ^0.2.0
version: 0.2.2
@@ -102,6 +120,9 @@ importers:
'@types/passport-jwt':
specifier: ^4.0.1
version: 4.0.1
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/supertest':
specifier: ^6.0.0
version: 6.0.3
@@ -1575,6 +1596,19 @@ packages:
cpu: [x64]
os: [win32]
'@nestjs/bull-shared@11.0.4':
resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/bullmq@11.0.4':
resolution: {integrity: sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0
'@nestjs/cli@11.0.16':
resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==}
engines: {node: '>= 20.11'}
@@ -1625,6 +1659,12 @@ packages:
'@nestjs/websockets':
optional: true
'@nestjs/event-emitter@3.0.1':
resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/jwt@11.0.2':
resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==}
peerDependencies:
@@ -1655,6 +1695,12 @@ packages:
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
'@nestjs/schedule@6.1.1':
resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/schematics@11.0.9':
resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==}
peerDependencies:
@@ -1690,6 +1736,13 @@ packages:
'@nestjs/platform-express':
optional: true
'@nestjs/throttler@6.5.0':
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
peerDependencies:
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
reflect-metadata: ^0.1.13 || ^0.2.0
'@next/env@15.5.12':
resolution: {integrity: sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==}
@@ -1745,11 +1798,33 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@noble/hashes@2.0.1':
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
'@nuxt/opencollective@0.4.1':
resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==}
engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'}
hasBin: true
'@otplib/core@13.3.0':
resolution: {integrity: sha512-pnQDOuCmFVeF/XnboJq9TOJgLoo2idNPJKMymOF8vGqJJ+ReKRYM9bUGjNPRWC0tHjMwu1TXbnzyBp494JgRag==}
'@otplib/hotp@13.3.0':
resolution: {integrity: sha512-XJMZGz2bg4QJwK7ulvl1GUI2VMn/flaIk/E/BTKAejHsX2kUtPF1bRhlZ2+elq8uU5Fs9Z9FHcQK2CPZNQbbUQ==}
'@otplib/plugin-base32-scure@13.3.0':
resolution: {integrity: sha512-/jYbL5S6GB0Ie3XGEWtLIr9s5ZICl/BfmNL7+8/W7usZaUU4GiyLd2S+JGsNCslPyqNekSudD864nDAvRI0s8w==}
'@otplib/plugin-crypto-noble@13.3.0':
resolution: {integrity: sha512-wmV+jBVncepgwv99G7Plrdzd0tHfbpXk2U+OD7MO7DzpDqOYEgOPi+IIneksJSTL8QvWdfi+uQEuhnER4fKouA==}
'@otplib/totp@13.3.0':
resolution: {integrity: sha512-XfjGNoN8d9S3Ove2j7AwkVV7+QDFsV7Lm7YwSiezNaHffkWtJ60aJYpmf+01dARdPST71U2ptueMsRJso4sq4A==}
'@otplib/uri@13.3.0':
resolution: {integrity: sha512-3oh6nBXy+cm3UX9cxEAGZiDrfxHU2gfelYFV+XNCx+8dq39VaQVymwlU2yjPZiMAi/3agaUeEftf2RwM5F+Cyg==}
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
@@ -1900,6 +1975,9 @@ packages:
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@scure/base@2.0.0':
resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==}
'@sinclair/typebox@0.27.10':
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
@@ -2093,6 +2171,9 @@ packages:
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
'@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
@@ -2114,6 +2195,9 @@ packages:
'@types/passport@1.0.17':
resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
@@ -2651,6 +2735,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -2799,6 +2886,10 @@ packages:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'}
cron@4.4.0:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
engines: {node: '>=18.x'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -2823,6 +2914,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
@@ -2893,6 +2988,9 @@ packages:
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
engines: {node: '>=0.3.1'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dotenv-expand@12.0.3:
resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
engines: {node: '>=12'}
@@ -3051,6 +3149,9 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter2@6.4.9:
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -4228,6 +4329,9 @@ packages:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
otplib@13.3.0:
resolution: {integrity: sha512-VYMKyyDG8yt2q+z58sz54/EIyTh7+tyMrjeemR44iVh5+dkKtIs57irTqxjH+IkAL1uMmG1JIFhG5CxTpqdU5g==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@@ -4356,6 +4460,10 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
@@ -4405,6 +4513,11 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
engines: {node: '>=0.6'}
@@ -4542,6 +4655,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-cwd@3.0.0:
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
engines: {node: '>=8'}
@@ -5177,6 +5293,9 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -5237,6 +5356,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -5247,10 +5369,18 @@ packages:
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -6767,6 +6897,20 @@ snapshots:
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
optional: true
'@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)':
dependencies:
'@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
'@nestjs/bullmq@11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.69.3)':
dependencies:
'@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)
'@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
bullmq: 5.69.3
tslib: 2.8.1
'@nestjs/cli@11.0.16(@types/node@22.19.11)':
dependencies:
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
@@ -6830,6 +6974,12 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)
'@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)':
dependencies:
'@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
eventemitter2: 6.4.9
'@nestjs/jwt@11.0.2(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
dependencies:
'@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -6861,6 +7011,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@nestjs/schedule@6.1.1(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)':
dependencies:
'@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cron: 4.4.0
'@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)':
dependencies:
'@angular-devkit/core': 19.2.17(chokidar@4.0.3)
@@ -6895,6 +7051,12 @@ snapshots:
optionalDependencies:
'@nestjs/platform-express': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.13(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@next/env@15.5.12': {}
'@next/swc-darwin-arm64@15.5.12':
@@ -6923,10 +7085,39 @@ snapshots:
'@noble/hashes@1.8.0': {}
'@noble/hashes@2.0.1': {}
'@nuxt/opencollective@0.4.1':
dependencies:
consola: 3.4.2
'@otplib/core@13.3.0': {}
'@otplib/hotp@13.3.0':
dependencies:
'@otplib/core': 13.3.0
'@otplib/uri': 13.3.0
'@otplib/plugin-base32-scure@13.3.0':
dependencies:
'@otplib/core': 13.3.0
'@scure/base': 2.0.0
'@otplib/plugin-crypto-noble@13.3.0':
dependencies:
'@noble/hashes': 2.0.1
'@otplib/core': 13.3.0
'@otplib/totp@13.3.0':
dependencies:
'@otplib/core': 13.3.0
'@otplib/hotp': 13.3.0
'@otplib/uri': 13.3.0
'@otplib/uri@13.3.0':
dependencies:
'@otplib/core': 13.3.0
'@paralleldrive/cuid2@2.3.1':
dependencies:
'@noble/hashes': 1.8.0
@@ -7177,6 +7368,8 @@ snapshots:
'@scarf/scarf@1.4.0': {}
'@scure/base@2.0.0': {}
'@sinclair/typebox@0.27.10': {}
'@sinonjs/commons@3.0.1':
@@ -7375,6 +7568,8 @@ snapshots:
'@types/ms': 2.1.0
'@types/node': 22.19.11
'@types/luxon@3.7.1': {}
'@types/methods@1.1.4': {}
'@types/ms@2.1.0': {}
@@ -7401,6 +7596,10 @@ snapshots:
dependencies:
'@types/express': 5.0.6
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.19.11
'@types/qs@6.14.0': {}
'@types/range-parser@1.2.7': {}
@@ -8064,6 +8263,12 @@ snapshots:
client-only@0.0.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -8208,6 +8413,11 @@ snapshots:
dependencies:
luxon: 3.7.2
cron@4.4.0:
dependencies:
'@types/luxon': 3.7.1
luxon: 3.7.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -8224,6 +8434,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decode-uri-component@0.2.2: {}
dedent@1.7.1: {}
@@ -8265,6 +8477,8 @@ snapshots:
diff@4.0.4: {}
dijkstrajs@1.0.3: {}
dotenv-expand@12.0.3:
dependencies:
dotenv: 16.6.1
@@ -8423,6 +8637,8 @@ snapshots:
event-target-shim@5.0.1: {}
eventemitter2@6.4.9: {}
events@3.3.0: {}
execa@5.1.1:
@@ -9910,6 +10126,15 @@ snapshots:
strip-ansi: 6.0.1
wcwidth: 1.0.1
otplib@13.3.0:
dependencies:
'@otplib/core': 13.3.0
'@otplib/hotp': 13.3.0
'@otplib/plugin-base32-scure': 13.3.0
'@otplib/plugin-crypto-noble': 13.3.0
'@otplib/totp': 13.3.0
'@otplib/uri': 13.3.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
@@ -10016,6 +10241,8 @@ snapshots:
pluralize@8.0.0: {}
pngjs@5.0.0: {}
postcss@8.4.31:
dependencies:
nanoid: 3.3.11
@@ -10065,6 +10292,12 @@ snapshots:
pure-rand@6.1.0: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.0:
dependencies:
side-channel: 1.1.0
@@ -10243,6 +10476,8 @@ snapshots:
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resolve-cwd@3.0.0:
dependencies:
resolve-from: 5.0.0
@@ -10891,6 +11126,8 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -10936,14 +11173,35 @@ snapshots:
xtend@4.0.2: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
yallist@4.0.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@21.1.1: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@17.7.2:
dependencies:
cliui: 8.0.1