Compare commits
2 Commits
fafea40fc5
...
0f23e4fdce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f23e4fdce | ||
|
|
7fcebf2739 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
125
apps/api/src/common/guards/module.guard.spec.ts
Normal file
125
apps/api/src/common/guards/module.guard.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
97
apps/api/src/common/middleware/tenant.middleware.spec.ts
Normal file
97
apps/api/src/common/middleware/tenant.middleware.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
apps/api/src/modules/audit/audit.controller.ts
Normal file
22
apps/api/src/modules/audit/audit.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/audit/audit.module.ts
Normal file
10
apps/api/src/modules/audit/audit.module.ts
Normal 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 {}
|
||||
84
apps/api/src/modules/audit/audit.service.ts
Normal file
84
apps/api/src/modules/audit/audit.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
40
apps/api/src/modules/audit/dto/find-audit-logs.dto.ts
Normal file
40
apps/api/src/modules/audit/dto/find-audit-logs.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
217
apps/api/src/modules/auth/auth.service.spec.ts
Normal file
217
apps/api/src/modules/auth/auth.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
|
||||
40
apps/api/src/modules/auth/dto/totp.dto.ts
Normal file
40
apps/api/src/modules/auth/dto/totp.dto.ts
Normal 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;
|
||||
}
|
||||
148
apps/api/src/modules/auth/guards/roles.guard.spec.ts
Normal file
148
apps/api/src/modules/auth/guards/roles.guard.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
207
apps/api/src/modules/auth/totp.service.spec.ts
Normal file
207
apps/api/src/modules/auth/totp.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
90
apps/api/src/modules/auth/totp.service.ts
Normal file
90
apps/api/src/modules/auth/totp.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
324
apps/api/src/modules/clients/clients.service.spec.ts
Normal file
324
apps/api/src/modules/clients/clients.service.spec.ts
Normal 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' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
396
apps/api/src/modules/funnel/funnel.service.spec.ts
Normal file
396
apps/api/src/modules/funnel/funnel.service.spec.ts
Normal 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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
350
apps/api/src/modules/metering/metering.service.spec.ts
Normal file
350
apps/api/src/modules/metering/metering.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
433
apps/api/src/modules/sales/sales.service.spec.ts
Normal file
433
apps/api/src/modules/sales/sales.service.spec.ts
Normal 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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
415
apps/api/src/modules/schedule/schedule.service.spec.ts
Normal file
415
apps/api/src/modules/schedule/schedule.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
443
apps/api/src/modules/stats/stats.service.spec.ts
Normal file
443
apps/api/src/modules/stats/stats.service.spec.ts
Normal 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),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
apps/api/src/modules/webhooks/webhook-delivery.processor.spec.ts
Normal file
171
apps/api/src/modules/webhooks/webhook-delivery.processor.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
84
apps/api/src/modules/webhooks/webhook-delivery.processor.ts
Normal file
84
apps/api/src/modules/webhooks/webhook-delivery.processor.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
207
apps/api/src/modules/webhooks/webhook-dispatch.service.spec.ts
Normal file
207
apps/api/src/modules/webhooks/webhook-dispatch.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
132
apps/api/src/modules/webhooks/webhook-dispatch.service.ts
Normal file
132
apps/api/src/modules/webhooks/webhook-dispatch.service.ts
Normal 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)];
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1
apps/api/src/queue/index.ts
Normal file
1
apps/api/src/queue/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { QueueModule } from './queue.module';
|
||||
27
apps/api/src/queue/queue.module.ts
Normal file
27
apps/api/src/queue/queue.module.ts
Normal 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 {}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
208
apps/web-club-admin/src/components/catalog/package-dialog.tsx
Normal file
208
apps/web-club-admin/src/components/catalog/package-dialog.tsx
Normal 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">
|
||||
Общая цена, ₽ <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>
|
||||
);
|
||||
}
|
||||
184
apps/web-club-admin/src/components/catalog/service-dialog.tsx
Normal file
184
apps/web-club-admin/src/components/catalog/service-dialog.tsx
Normal 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">
|
||||
Цена, ₽ <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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
146
apps/web-club-admin/src/components/license/renew-dialog.tsx
Normal file
146
apps/web-club-admin/src/components/license/renew-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
225
apps/web-club-admin/src/components/rooms/room-dialog.tsx
Normal file
225
apps/web-club-admin/src/components/rooms/room-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
206
apps/web-club-admin/src/components/staff/employee-dialog.tsx
Normal file
206
apps/web-club-admin/src/components/staff/employee-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/web-club-admin/src/components/ui/alert-dialog.tsx
Normal file
76
apps/web-club-admin/src/components/ui/alert-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
apps/web-club-admin/src/components/ui/dialog.tsx
Normal file
98
apps/web-club-admin/src/components/ui/dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
76
apps/web-platform-admin/src/components/ui/alert-dialog.tsx
Normal file
76
apps/web-platform-admin/src/components/ui/alert-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
apps/web-platform-admin/src/components/ui/dialog.tsx
Normal file
98
apps/web-platform-admin/src/components/ui/dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
258
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user