Merge pull request 'feat(api): реализация 8 backend-модулей NestJS' (#1) from master into main
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Build All Apps (push) Has been cancelled

This commit was merged in pull request #1.
This commit is contained in:
2026-02-18 09:36:39 +00:00
68 changed files with 3097 additions and 9 deletions

View File

@@ -6,7 +6,6 @@ import { AuthModule } from './modules/auth';
import { UsersModule } from './modules/users'; import { UsersModule } from './modules/users';
import { TenantMiddleware } from './common/middleware/tenant.middleware'; import { TenantMiddleware } from './common/middleware/tenant.middleware';
// Future modules (uncomment as implemented):
import { ClientsModule } from './modules/clients'; import { ClientsModule } from './modules/clients';
import { ScheduleModule } from './modules/schedule'; import { ScheduleModule } from './modules/schedule';
import { FunnelModule } from './modules/funnel'; import { FunnelModule } from './modules/funnel';
@@ -14,19 +13,19 @@ import { SalesModule } from './modules/sales';
import { StatsModule } from './modules/stats'; import { StatsModule } from './modules/stats';
// import { RatingModule } from './modules/rating'; // import { RatingModule } from './modules/rating';
import { NotificationsModule } from './modules/notifications'; import { NotificationsModule } from './modules/notifications';
// import { ReportsModule } from './modules/reports'; import { ReportsModule } from './modules/reports';
import { WorkScheduleModule } from './modules/work-schedule'; import { WorkScheduleModule } from './modules/work-schedule';
import { SipModule } from './modules/sip'; import { SipModule } from './modules/sip';
// import { WebhooksModule } from './modules/webhooks'; import { WebhooksModule } from './modules/webhooks';
// import { ClubsModule } from './modules/clubs'; import { ClubsModule } from './modules/clubs';
// import { LicensesModule } from './modules/licenses'; import { LicensesModule } from './modules/licenses';
import { ModulesModule } from './modules/modules'; import { ModulesModule } from './modules/modules';
import { MeteringModule } from './modules/metering'; import { MeteringModule } from './modules/metering';
import { ProvisioningModule } from './modules/provisioning'; import { ProvisioningModule } from './modules/provisioning';
// import { CatalogModule } from './modules/catalog'; import { CatalogModule } from './modules/catalog';
// import { DepartmentsModule } from './modules/departments'; import { DepartmentsModule } from './modules/departments';
// import { RoomsModule } from './modules/rooms'; import { RoomsModule } from './modules/rooms';
// import { IntegrationModule } from './modules/integration'; import { IntegrationModule } from './modules/integration';
@Module({ @Module({
imports: [ imports: [
@@ -48,6 +47,15 @@ import { ProvisioningModule } from './modules/provisioning';
StatsModule, StatsModule,
WorkScheduleModule, WorkScheduleModule,
SipModule, SipModule,
// New modules
DepartmentsModule,
RoomsModule,
CatalogModule,
ClubsModule,
LicensesModule,
ReportsModule,
WebhooksModule,
IntegrationModule,
], ],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {

View File

@@ -0,0 +1,247 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiParam,
} from '@nestjs/swagger';
import { CatalogService } from './catalog.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles, UserRole } from '../../common/decorators/roles.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/current-user.decorator';
import {
CreateCategoryDto,
UpdateCategoryDto,
CreateServiceDto,
UpdateServiceDto,
CreatePackageDto,
UpdatePackageDto,
FindCategoriesDto,
FindServicesDto,
FindPackagesDto,
} from './dto';
@ApiTags('Catalog')
@ApiBearerAuth()
@Controller('catalog')
@UseGuards(JwtAuthGuard)
export class CatalogController {
constructor(private readonly catalogService: CatalogService) {}
// ─── Categories ──────────────────────────────────────────────────
@Get('categories')
@ApiOperation({ summary: 'List service categories' })
@ApiResponse({ status: 200, description: 'Paginated list of categories' })
async findAllCategories(
@CurrentUser() user: JwtPayload,
@Query() query: FindCategoriesDto,
) {
return this.catalogService.findAllCategories(user.clubId, query);
}
@Get('categories/:id')
@ApiOperation({ summary: 'Get category by ID' })
@ApiParam({ name: 'id', description: 'Category UUID' })
@ApiResponse({ status: 200, description: 'Category details with services' })
@ApiResponse({ status: 404, description: 'Category not found' })
async findCategoryById(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.catalogService.findCategoryById(user.clubId, id);
}
@Post('categories')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Create a service category' })
@ApiResponse({ status: 201, description: 'Category created' })
async createCategory(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateCategoryDto,
) {
return this.catalogService.createCategory(user.clubId, dto);
}
@Patch('categories/:id')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Update a service category' })
@ApiParam({ name: 'id', description: 'Category UUID' })
@ApiResponse({ status: 200, description: 'Category updated' })
@ApiResponse({ status: 404, description: 'Category not found' })
async updateCategory(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCategoryDto,
) {
return this.catalogService.updateCategory(user.clubId, id, dto);
}
@Delete('categories/:id')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a service category' })
@ApiParam({ name: 'id', description: 'Category UUID' })
@ApiResponse({ status: 204, description: 'Category deleted' })
@ApiResponse({ status: 404, description: 'Category not found' })
async removeCategory(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.catalogService.removeCategory(user.clubId, id);
}
// ─── Services ────────────────────────────────────────────────────
@Get('services')
@ApiOperation({ summary: 'List services with filters' })
@ApiResponse({ status: 200, description: 'Paginated list of services' })
async findAllServices(
@CurrentUser() user: JwtPayload,
@Query() query: FindServicesDto,
) {
return this.catalogService.findAllServices(user.clubId, query);
}
@Get('services/:id')
@ApiOperation({ summary: 'Get service by ID' })
@ApiParam({ name: 'id', description: 'Service UUID' })
@ApiResponse({ status: 200, description: 'Service details with packages' })
@ApiResponse({ status: 404, description: 'Service not found' })
async findServiceById(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.catalogService.findServiceById(user.clubId, id);
}
@Post('services')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Create a service' })
@ApiResponse({ status: 201, description: 'Service created' })
async createService(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateServiceDto,
) {
return this.catalogService.createService(user.clubId, dto);
}
@Patch('services/:id')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Update a service' })
@ApiParam({ name: 'id', description: 'Service UUID' })
@ApiResponse({ status: 200, description: 'Service updated' })
@ApiResponse({ status: 404, description: 'Service not found' })
async updateService(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateServiceDto,
) {
return this.catalogService.updateService(user.clubId, id, dto);
}
@Delete('services/:id')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a service' })
@ApiParam({ name: 'id', description: 'Service UUID' })
@ApiResponse({ status: 204, description: 'Service deleted' })
@ApiResponse({ status: 404, description: 'Service not found' })
async removeService(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.catalogService.removeService(user.clubId, id);
}
// ─── Packages ────────────────────────────────────────────────────
@Get('packages')
@ApiOperation({ summary: 'List packages with filters' })
@ApiResponse({ status: 200, description: 'Paginated list of packages' })
async findAllPackages(
@CurrentUser() user: JwtPayload,
@Query() query: FindPackagesDto,
) {
return this.catalogService.findAllPackages(user.clubId, query);
}
@Get('packages/:id')
@ApiOperation({ summary: 'Get package by ID' })
@ApiParam({ name: 'id', description: 'Package UUID' })
@ApiResponse({ status: 200, description: 'Package details' })
@ApiResponse({ status: 404, description: 'Package not found' })
async findPackageById(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.catalogService.findPackageById(user.clubId, id);
}
@Post('packages')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Create a package' })
@ApiResponse({ status: 201, description: 'Package created' })
@ApiResponse({ status: 404, description: 'Service not found' })
async createPackage(
@CurrentUser() user: JwtPayload,
@Body() dto: CreatePackageDto,
) {
return this.catalogService.createPackage(user.clubId, dto);
}
@Patch('packages/:id')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Update a package' })
@ApiParam({ name: 'id', description: 'Package UUID' })
@ApiResponse({ status: 200, description: 'Package updated' })
@ApiResponse({ status: 404, description: 'Package not found' })
async updatePackage(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePackageDto,
) {
return this.catalogService.updatePackage(user.clubId, id, dto);
}
@Delete('packages/:id')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a package' })
@ApiParam({ name: 'id', description: 'Package UUID' })
@ApiResponse({ status: 204, description: 'Package deleted' })
@ApiResponse({ status: 404, description: 'Package not found' })
async removePackage(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.catalogService.removePackage(user.clubId, id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service';
@Module({
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
})
export class CatalogModule {}

View File

@@ -0,0 +1,299 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
import { CreateServiceDto } from './dto/create-service.dto';
import { UpdateServiceDto } from './dto/update-service.dto';
import { CreatePackageDto } from './dto/create-package.dto';
import { UpdatePackageDto } from './dto/update-package.dto';
import { FindCategoriesDto, FindServicesDto, FindPackagesDto } from './dto/find-catalog.dto';
import { Prisma } from '@prisma/client';
export interface PaginatedResult<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
}
@Injectable()
export class CatalogService {
constructor(private readonly prisma: PrismaService) {}
// ─── Categories ──────────────────────────────────────────────────
async findAllCategories(
clubId: string,
params: FindCategoriesDto,
): Promise<PaginatedResult<any>> {
const { cursor, limit = 20 } = params;
const items = await this.prisma.serviceCategory.findMany({
where: { clubId },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { sortOrder: 'asc' },
include: { _count: { select: { services: true } } },
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
return {
data,
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
};
}
async findCategoryById(clubId: string, id: string) {
const category = await this.prisma.serviceCategory.findFirst({
where: { id, clubId },
include: {
services: { select: { id: true, name: true, price: true, isActive: true } },
},
});
if (!category) {
throw new NotFoundException(`Category with id ${id} not found`);
}
return category;
}
async createCategory(clubId: string, dto: CreateCategoryDto) {
return this.prisma.serviceCategory.create({
data: { clubId, name: dto.name, sortOrder: dto.sortOrder ?? 0 },
});
}
async updateCategory(clubId: string, id: string, dto: UpdateCategoryDto) {
await this.ensureCategoryExists(clubId, id);
return this.prisma.serviceCategory.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
},
});
}
async removeCategory(clubId: string, id: string) {
await this.ensureCategoryExists(clubId, id);
await this.prisma.serviceCategory.delete({ where: { id } });
}
// ─── Services ────────────────────────────────────────────────────
async findAllServices(
clubId: string,
params: FindServicesDto,
): Promise<PaginatedResult<any>> {
const { cursor, limit = 20, categoryId, search, isActive } = params;
const where: Prisma.ServiceWhereInput = {
clubId,
...(categoryId ? { categoryId } : {}),
...(search
? { name: { contains: search, mode: 'insensitive' as const } }
: {}),
...(isActive !== undefined ? { isActive } : {}),
};
const items = await this.prisma.service.findMany({
where,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { name: 'asc' },
include: {
category: { select: { id: true, name: true } },
_count: { select: { packages: true } },
},
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
return {
data,
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
};
}
async findServiceById(clubId: string, id: string) {
const service = await this.prisma.service.findFirst({
where: { id, clubId },
include: {
category: { select: { id: true, name: true } },
packages: {
select: {
id: true,
name: true,
price: true,
totalSessions: true,
validDays: true,
isActive: true,
},
},
},
});
if (!service) {
throw new NotFoundException(`Service with id ${id} not found`);
}
return service;
}
async createService(clubId: string, dto: CreateServiceDto) {
if (dto.categoryId) {
await this.ensureCategoryExists(clubId, dto.categoryId);
}
return this.prisma.service.create({
data: {
clubId,
name: dto.name,
description: dto.description,
price: dto.price,
duration: dto.duration,
categoryId: dto.categoryId,
isActive: dto.isActive ?? true,
},
include: { category: { select: { id: true, name: true } } },
});
}
async updateService(clubId: string, id: string, dto: UpdateServiceDto) {
await this.ensureServiceExists(clubId, id);
if (dto.categoryId) {
await this.ensureCategoryExists(clubId, dto.categoryId);
}
return this.prisma.service.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.description !== undefined ? { description: dto.description } : {}),
...(dto.price !== undefined ? { price: dto.price } : {}),
...(dto.duration !== undefined ? { duration: dto.duration } : {}),
...(dto.categoryId !== undefined ? { categoryId: dto.categoryId } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
include: { category: { select: { id: true, name: true } } },
});
}
async removeService(clubId: string, id: string) {
await this.ensureServiceExists(clubId, id);
await this.prisma.service.delete({ where: { id } });
}
// ─── Packages ────────────────────────────────────────────────────
async findAllPackages(
clubId: string,
params: FindPackagesDto,
): Promise<PaginatedResult<any>> {
const { cursor, limit = 20, serviceId, isActive } = params;
const where: Prisma.PackageWhereInput = {
clubId,
...(serviceId ? { serviceId } : {}),
...(isActive !== undefined ? { isActive } : {}),
};
const items = await this.prisma.package.findMany({
where,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { name: 'asc' },
include: { service: { select: { id: true, name: true } } },
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
return {
data,
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
};
}
async findPackageById(clubId: string, id: string) {
const pkg = await this.prisma.package.findFirst({
where: { id, clubId },
include: { service: { select: { id: true, name: true } } },
});
if (!pkg) {
throw new NotFoundException(`Package with id ${id} not found`);
}
return pkg;
}
async createPackage(clubId: string, dto: CreatePackageDto) {
await this.ensureServiceExists(clubId, dto.serviceId);
return this.prisma.package.create({
data: {
clubId,
name: dto.name,
description: dto.description,
price: dto.price,
totalSessions: dto.totalSessions,
validDays: dto.validDays,
serviceId: dto.serviceId,
isActive: dto.isActive ?? true,
},
include: { service: { select: { id: true, name: true } } },
});
}
async updatePackage(clubId: string, id: string, dto: UpdatePackageDto) {
await this.ensurePackageExists(clubId, id);
if (dto.serviceId) {
await this.ensureServiceExists(clubId, dto.serviceId);
}
return this.prisma.package.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.description !== undefined ? { description: dto.description } : {}),
...(dto.price !== undefined ? { price: dto.price } : {}),
...(dto.totalSessions !== undefined ? { totalSessions: dto.totalSessions } : {}),
...(dto.validDays !== undefined ? { validDays: dto.validDays } : {}),
...(dto.serviceId !== undefined ? { serviceId: dto.serviceId } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
include: { service: { select: { id: true, name: true } } },
});
}
async removePackage(clubId: string, id: string) {
await this.ensurePackageExists(clubId, id);
await this.prisma.package.delete({ where: { id } });
}
// ─── Helpers ─────────────────────────────────────────────────────
private async ensureCategoryExists(clubId: string, id: string): Promise<void> {
const count = await this.prisma.serviceCategory.count({ where: { id, clubId } });
if (count === 0) {
throw new NotFoundException(`Category with id ${id} not found`);
}
}
private async ensureServiceExists(clubId: string, id: string): Promise<void> {
const count = await this.prisma.service.count({ where: { id, clubId } });
if (count === 0) {
throw new NotFoundException(`Service with id ${id} not found`);
}
}
private async ensurePackageExists(clubId: string, id: string): Promise<void> {
const count = await this.prisma.package.count({ where: { id, clubId } });
if (count === 0) {
throw new NotFoundException(`Package with id ${id} not found`);
}
}
}

View File

@@ -0,0 +1,15 @@
import { IsString, IsNotEmpty, IsOptional, IsInt, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateCategoryDto {
@ApiProperty({ description: 'Category name', example: 'Group Classes' })
@IsString()
@IsNotEmpty()
name: string;
@ApiPropertyOptional({ description: 'Sort order', default: 0 })
@IsInt()
@Min(0)
@IsOptional()
sortOrder?: number;
}

View File

@@ -0,0 +1,48 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsUUID,
IsNumber,
IsBoolean,
IsInt,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePackageDto {
@ApiProperty({ description: 'Package name', example: '10-session package' })
@IsString()
@IsNotEmpty()
name: string;
@ApiPropertyOptional({ description: 'Package description' })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: 'Package price', example: 25000 })
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
price: number;
@ApiProperty({ description: 'Total sessions included', example: 10 })
@IsInt()
@Min(1)
totalSessions: number;
@ApiProperty({ description: 'Validity period in days', example: 90 })
@IsInt()
@Min(1)
validDays: number;
@ApiProperty({ description: 'Service UUID this package belongs to' })
@IsUUID()
@IsNotEmpty()
serviceId: string;
@ApiPropertyOptional({ description: 'Whether package is active', default: true })
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,43 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsUUID,
IsNumber,
IsBoolean,
IsInt,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateServiceDto {
@ApiProperty({ description: 'Service name', example: 'Personal Training' })
@IsString()
@IsNotEmpty()
name: string;
@ApiPropertyOptional({ description: 'Service description' })
@IsString()
@IsOptional()
description?: string;
@ApiProperty({ description: 'Price', example: 3000 })
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
price: number;
@ApiProperty({ description: 'Duration in minutes', example: 60 })
@IsInt()
@Min(1)
duration: number;
@ApiPropertyOptional({ description: 'Category UUID' })
@IsUUID()
@IsOptional()
categoryId?: string;
@ApiPropertyOptional({ description: 'Whether service is active', default: true })
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,75 @@
import { IsOptional, IsString, IsUUID, IsInt, IsBoolean, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class FindCategoriesDto {
@ApiPropertyOptional({ description: 'Cursor for pagination' })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ description: 'Items per page (1-100)', default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
}
export class FindServicesDto {
@ApiPropertyOptional({ description: 'Cursor for pagination' })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ description: 'Items per page (1-100)', default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
@ApiPropertyOptional({ description: 'Filter by category UUID' })
@IsUUID()
@IsOptional()
categoryId?: string;
@ApiPropertyOptional({ description: 'Search by name' })
@IsString()
@IsOptional()
search?: string;
@ApiPropertyOptional({ description: 'Filter by active status' })
@Type(() => Boolean)
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
export class FindPackagesDto {
@ApiPropertyOptional({ description: 'Cursor for pagination' })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ description: 'Items per page (1-100)', default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
@ApiPropertyOptional({ description: 'Filter by service UUID' })
@IsUUID()
@IsOptional()
serviceId?: string;
@ApiPropertyOptional({ description: 'Filter by active status' })
@Type(() => Boolean)
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,7 @@
export { CreateCategoryDto } from './create-category.dto';
export { UpdateCategoryDto } from './update-category.dto';
export { CreateServiceDto } from './create-service.dto';
export { UpdateServiceDto } from './update-service.dto';
export { CreatePackageDto } from './create-package.dto';
export { UpdatePackageDto } from './update-package.dto';
export { FindCategoriesDto, FindServicesDto, FindPackagesDto } from './find-catalog.dto';

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCategoryDto } from './create-category.dto';
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePackageDto } from './create-package.dto';
export class UpdatePackageDto extends PartialType(CreatePackageDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateServiceDto } from './create-service.dto';
export class UpdateServiceDto extends PartialType(CreateServiceDto) {}

View File

@@ -0,0 +1,2 @@
export { CatalogModule } from './catalog.module';
export { CatalogService } from './catalog.service';

View File

@@ -0,0 +1,72 @@
import {
Controller,
Get,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiParam,
} from '@nestjs/swagger';
import { ClubsService } from './clubs.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles, UserRole } from '../../common/decorators/roles.decorator';
import { FindClubsDto, UpdateClubDto } from './dto';
@ApiTags('Clubs')
@ApiBearerAuth()
@Controller('admin/clubs')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.SUPER_ADMIN)
export class ClubsController {
constructor(private readonly clubsService: ClubsService) {}
@Get()
@ApiOperation({ summary: 'List all clubs with pagination and search' })
@ApiResponse({ status: 200, description: 'Paginated list of clubs' })
async findAll(@Query() query: FindClubsDto) {
return this.clubsService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get club by ID with license and modules' })
@ApiParam({ name: 'id', description: 'Club UUID' })
@ApiResponse({ status: 200, description: 'Club details' })
@ApiResponse({ status: 404, description: 'Club not found' })
async findById(@Param('id', ParseUUIDPipe) id: string) {
return this.clubsService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update club details' })
@ApiParam({ name: 'id', description: 'Club UUID' })
@ApiResponse({ status: 200, description: 'Club updated' })
@ApiResponse({ status: 404, description: 'Club not found' })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateClubDto,
) {
return this.clubsService.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a club (set isActive = false)' })
@ApiParam({ name: 'id', description: 'Club UUID' })
@ApiResponse({ status: 204, description: 'Club deactivated' })
@ApiResponse({ status: 404, description: 'Club not found' })
async remove(@Param('id', ParseUUIDPipe) id: string) {
return this.clubsService.softDelete(id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ClubsController } from './clubs.controller';
import { ClubsService } from './clubs.service';
@Module({
controllers: [ClubsController],
providers: [ClubsService],
exports: [ClubsService],
})
export class ClubsModule {}

View File

@@ -0,0 +1,105 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { FindClubsDto } from './dto/find-clubs.dto';
import { UpdateClubDto } from './dto/update-club.dto';
import { Prisma } from '@prisma/client';
export interface PaginatedResult<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
}
const CLUB_INCLUDE = {
license: true,
clubModules: {
select: { moduleId: true, enabled: true },
},
_count: {
select: { users: true, clients: true },
},
};
@Injectable()
export class ClubsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(params: FindClubsDto): Promise<PaginatedResult<any>> {
const { cursor, limit = 20, search, isActive } = params;
const where: Prisma.ClubWhereInput = {
...(search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' as const } },
{ slug: { contains: search, mode: 'insensitive' as const } },
],
}
: {}),
...(isActive !== undefined ? { isActive } : {}),
};
const items = await this.prisma.club.findMany({
where,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { createdAt: 'desc' },
include: CLUB_INCLUDE,
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
return {
data,
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
};
}
async findById(id: string) {
const club = await this.prisma.club.findUnique({
where: { id },
include: CLUB_INCLUDE,
});
if (!club) {
throw new NotFoundException(`Club with id ${id} not found`);
}
return club;
}
async update(id: string, dto: UpdateClubDto) {
await this.ensureExists(id);
return this.prisma.club.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.address !== undefined ? { address: dto.address } : {}),
...(dto.phone !== undefined ? { phone: dto.phone } : {}),
...(dto.email !== undefined ? { email: dto.email } : {}),
...(dto.logoUrl !== undefined ? { logoUrl: dto.logoUrl } : {}),
...(dto.timezone !== undefined ? { timezone: dto.timezone } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
include: CLUB_INCLUDE,
});
}
async softDelete(id: string) {
await this.ensureExists(id);
return this.prisma.club.update({
where: { id },
data: { isActive: false },
});
}
private async ensureExists(id: string): Promise<void> {
const count = await this.prisma.club.count({ where: { id } });
if (count === 0) {
throw new NotFoundException(`Club with id ${id} not found`);
}
}
}

View File

@@ -0,0 +1,29 @@
import { IsOptional, IsString, IsInt, IsBoolean, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class FindClubsDto {
@ApiPropertyOptional({ description: 'Cursor for pagination' })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ description: 'Items per page (1-100)', default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
@ApiPropertyOptional({ description: 'Search by name or slug' })
@IsString()
@IsOptional()
search?: string;
@ApiPropertyOptional({ description: 'Filter by active status' })
@Type(() => Boolean)
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,2 @@
export { FindClubsDto } from './find-clubs.dto';
export { UpdateClubDto } from './update-club.dto';

View File

@@ -0,0 +1,39 @@
import { IsString, IsOptional, IsBoolean, IsEmail, IsUrl } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateClubDto {
@ApiPropertyOptional({ description: 'Club name', example: 'FitLife Moscow' })
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional({ description: 'Club address' })
@IsString()
@IsOptional()
address?: string;
@ApiPropertyOptional({ description: 'Club phone' })
@IsString()
@IsOptional()
phone?: string;
@ApiPropertyOptional({ description: 'Club email' })
@IsEmail()
@IsOptional()
email?: string;
@ApiPropertyOptional({ description: 'Logo URL' })
@IsUrl()
@IsOptional()
logoUrl?: string;
@ApiPropertyOptional({ description: 'Timezone', example: 'Europe/Moscow' })
@IsString()
@IsOptional()
timezone?: string;
@ApiPropertyOptional({ description: 'Whether club is active' })
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,2 @@
export { ClubsModule } from './clubs.module';
export { ClubsService } from './clubs.service';

View File

@@ -0,0 +1,107 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiParam,
} from '@nestjs/swagger';
import { DepartmentsService } from './departments.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles, UserRole } from '../../common/decorators/roles.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/current-user.decorator';
import {
CreateDepartmentDto,
UpdateDepartmentDto,
FindDepartmentsDto,
} from './dto';
@ApiTags('Departments')
@ApiBearerAuth()
@Controller('departments')
@UseGuards(JwtAuthGuard)
export class DepartmentsController {
constructor(private readonly departmentsService: DepartmentsService) {}
@Get()
@ApiOperation({ summary: 'List departments with pagination' })
@ApiResponse({ status: 200, description: 'Paginated list of departments' })
async findAll(
@CurrentUser() user: JwtPayload,
@Query() query: FindDepartmentsDto,
) {
return this.departmentsService.findAll(user.clubId, query);
}
@Get(':id')
@ApiOperation({ summary: 'Get department by ID' })
@ApiParam({ name: 'id', description: 'Department UUID' })
@ApiResponse({ status: 200, description: 'Department details' })
@ApiResponse({ status: 404, description: 'Department not found' })
async findById(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.departmentsService.findById(user.clubId, id);
}
@Post()
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Create a new department' })
@ApiResponse({ status: 201, description: 'Department created' })
@ApiResponse({ status: 403, description: 'Insufficient role' })
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateDepartmentDto,
) {
return this.departmentsService.create(user.clubId, dto);
}
@Patch(':id')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Update a department' })
@ApiParam({ name: 'id', description: 'Department UUID' })
@ApiResponse({ status: 200, description: 'Department updated' })
@ApiResponse({ status: 404, description: 'Department not found' })
async update(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDepartmentDto,
) {
return this.departmentsService.update(user.clubId, id, dto);
}
@Delete(':id')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a department' })
@ApiParam({ name: 'id', description: 'Department UUID' })
@ApiResponse({ status: 204, description: 'Department deleted' })
@ApiResponse({ status: 404, description: 'Department not found' })
async remove(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.departmentsService.remove(user.clubId, id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { DepartmentsController } from './departments.controller';
import { DepartmentsService } from './departments.service';
@Module({
controllers: [DepartmentsController],
providers: [DepartmentsService],
exports: [DepartmentsService],
})
export class DepartmentsModule {}

View File

@@ -0,0 +1,104 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateDepartmentDto } from './dto/create-department.dto';
import { UpdateDepartmentDto } from './dto/update-department.dto';
import { FindDepartmentsDto } from './dto/find-departments.dto';
import { Prisma } from '@prisma/client';
export interface PaginatedResult<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
}
@Injectable()
export class DepartmentsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(
clubId: string,
params: FindDepartmentsDto,
): Promise<PaginatedResult<any>> {
const { cursor, limit = 20, search } = params;
const where: Prisma.DepartmentWhereInput = {
clubId,
...(search
? { name: { contains: search, mode: 'insensitive' as const } }
: {}),
};
const items = await this.prisma.department.findMany({
where,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { name: 'asc' },
include: {
_count: { select: { users: true } },
},
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
return {
data,
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
};
}
async findById(clubId: string, id: string) {
const department = await this.prisma.department.findFirst({
where: { id, clubId },
include: {
users: {
select: { id: true, firstName: true, lastName: true, role: true },
},
},
});
if (!department) {
throw new NotFoundException(`Department with id ${id} not found`);
}
return department;
}
async create(clubId: string, dto: CreateDepartmentDto) {
return this.prisma.department.create({
data: {
clubId,
name: dto.name,
description: dto.description,
},
});
}
async update(clubId: string, id: string, dto: UpdateDepartmentDto) {
await this.ensureExists(clubId, id);
return this.prisma.department.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.description !== undefined
? { description: dto.description }
: {}),
},
});
}
async remove(clubId: string, id: string) {
await this.ensureExists(clubId, id);
await this.prisma.department.delete({ where: { id } });
}
private async ensureExists(clubId: string, id: string): Promise<void> {
const count = await this.prisma.department.count({
where: { id, clubId },
});
if (count === 0) {
throw new NotFoundException(`Department with id ${id} not found`);
}
}
}

View File

@@ -0,0 +1,20 @@
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateDepartmentDto {
@ApiProperty({
description: 'Department name',
example: 'Personal Training',
})
@IsString()
@IsNotEmpty()
name: string;
@ApiPropertyOptional({
description: 'Department description',
example: 'Personal training department',
})
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,28 @@
import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class FindDepartmentsDto {
@ApiPropertyOptional({
description: 'Cursor for pagination (UUID of last item)',
})
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({
description: 'Number of items to return (1-100)',
default: 20,
})
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
@ApiPropertyOptional({ description: 'Search by name' })
@IsString()
@IsOptional()
search?: string;
}

View File

@@ -0,0 +1,3 @@
export { CreateDepartmentDto } from './create-department.dto';
export { UpdateDepartmentDto } from './update-department.dto';
export { FindDepartmentsDto } from './find-departments.dto';

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateDepartmentDto } from './create-department.dto';
export class UpdateDepartmentDto extends PartialType(CreateDepartmentDto) {}

View File

@@ -0,0 +1,2 @@
export { DepartmentsModule } from './departments.module';
export { DepartmentsService } from './departments.service';

View File

@@ -0,0 +1,33 @@
import { IsOptional, IsString, IsInt, IsEnum, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export enum SyncDirection {
INBOUND = 'inbound',
OUTBOUND = 'outbound',
}
export class FindSyncLogsDto {
@ApiPropertyOptional({ description: 'Cursor for pagination' })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ description: 'Items per page (1-100)', default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
@ApiPropertyOptional({ description: 'Filter by sync direction', enum: SyncDirection })
@IsEnum(SyncDirection)
@IsOptional()
direction?: SyncDirection;
@ApiPropertyOptional({ description: 'Filter by entity type', example: 'clients' })
@IsString()
@IsOptional()
entityType?: string;
}

View File

@@ -0,0 +1,3 @@
export { UpdateIntegrationConfigDto } from './update-integration-config.dto';
export { FindSyncLogsDto, SyncDirection } from './find-sync-logs.dto';
export { TriggerSyncDto } from './trigger-sync.dto';

View File

@@ -0,0 +1,17 @@
import { IsString, IsNotEmpty, IsEnum } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { SyncDirection } from './find-sync-logs.dto';
export class TriggerSyncDto {
@ApiProperty({
description: 'Entity type to sync',
example: 'clients',
})
@IsString()
@IsNotEmpty()
entityType: string;
@ApiProperty({ description: 'Sync direction', enum: SyncDirection })
@IsEnum(SyncDirection)
direction: SyncDirection;
}

View File

@@ -0,0 +1,26 @@
import { IsString, IsOptional, IsBoolean, IsUrl } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateIntegrationConfigDto {
@ApiPropertyOptional({ description: '1C API URL', example: 'https://1c.example.com/api' })
@IsUrl()
@IsOptional()
apiUrl?: string;
@ApiPropertyOptional({ description: '1C API key' })
@IsString()
@IsOptional()
apiKey?: string;
@ApiPropertyOptional({ description: 'Whether sync is enabled' })
@IsBoolean()
@IsOptional()
syncEnabled?: boolean;
@ApiPropertyOptional({
description: 'Additional settings (JSON)',
example: { syncInterval: 3600, entities: ['clients', 'memberships'] },
})
@IsOptional()
settings?: Record<string, any>;
}

View File

@@ -0,0 +1,2 @@
export { IntegrationModule } from './integration.module';
export { IntegrationService } from './integration.service';

View File

@@ -0,0 +1,78 @@
import {
Controller,
Get,
Post,
Put,
Body,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
} from '@nestjs/swagger';
import { IntegrationService } from './integration.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleGuard } from '../../common/guards/module.guard';
import { Roles, UserRole } from '../../common/decorators/roles.decorator';
import { RequireModule } from '../../common/decorators/require-module.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/current-user.decorator';
import {
UpdateIntegrationConfigDto,
FindSyncLogsDto,
TriggerSyncDto,
} from './dto';
@ApiTags('Integration')
@ApiBearerAuth()
@Controller('integration')
@UseGuards(JwtAuthGuard, ModuleGuard, RolesGuard)
@RequireModule('1c_sync')
@Roles(UserRole.CLUB_ADMIN)
export class IntegrationController {
constructor(private readonly integrationService: IntegrationService) {}
@Get('config')
@ApiOperation({ summary: 'Get integration configuration' })
@ApiResponse({ status: 200, description: 'Integration config (API key masked)' })
async getConfig(@CurrentUser() user: JwtPayload) {
return this.integrationService.getConfig(user.clubId);
}
@Put('config')
@ApiOperation({ summary: 'Create or update integration configuration' })
@ApiResponse({ status: 200, description: 'Integration config saved' })
async upsertConfig(
@CurrentUser() user: JwtPayload,
@Body() dto: UpdateIntegrationConfigDto,
) {
return this.integrationService.upsertConfig(user.clubId, dto);
}
@Post('sync')
@ApiOperation({ summary: 'Trigger a sync operation' })
@ApiResponse({ status: 201, description: 'Sync operation queued' })
@ApiResponse({ status: 400, description: 'Sync not enabled or not configured' })
async triggerSync(
@CurrentUser() user: JwtPayload,
@Body() dto: TriggerSyncDto,
) {
return this.integrationService.triggerSync(user.clubId, dto);
}
@Get('sync')
@ApiOperation({ summary: 'Get sync log with pagination' })
@ApiResponse({ status: 200, description: 'Paginated list of sync logs' })
async findSyncLogs(
@CurrentUser() user: JwtPayload,
@Query() query: FindSyncLogsDto,
) {
return this.integrationService.findSyncLogs(user.clubId, query);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { IntegrationController } from './integration.controller';
import { IntegrationService } from './integration.service';
@Module({
controllers: [IntegrationController],
providers: [IntegrationService],
exports: [IntegrationService],
})
export class IntegrationModule {}

View File

@@ -0,0 +1,149 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { UpdateIntegrationConfigDto } from './dto/update-integration-config.dto';
import { FindSyncLogsDto } from './dto/find-sync-logs.dto';
import { TriggerSyncDto } from './dto/trigger-sync.dto';
import { Prisma } from '@prisma/client';
export interface PaginatedResult<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
}
@Injectable()
export class IntegrationService {
constructor(private readonly prisma: PrismaService) {}
// ─── Config ──────────────────────────────────────────────────────
async getConfig(clubId: string) {
const config = await this.prisma.integrationConfig.findUnique({
where: { clubId },
});
if (!config) {
// Return a default empty config rather than 404
return {
clubId,
provider: '1c',
apiUrl: null,
apiKey: null,
syncEnabled: false,
lastSyncAt: null,
settings: null,
};
}
// Mask API key for security
return {
...config,
apiKey: config.apiKey ? '***' + config.apiKey.slice(-4) : null,
};
}
async upsertConfig(clubId: string, dto: UpdateIntegrationConfigDto) {
const existing = await this.prisma.integrationConfig.findUnique({
where: { clubId },
});
if (existing) {
return this.prisma.integrationConfig.update({
where: { clubId },
data: {
...(dto.apiUrl !== undefined ? { apiUrl: dto.apiUrl } : {}),
...(dto.apiKey !== undefined ? { apiKey: dto.apiKey } : {}),
...(dto.syncEnabled !== undefined
? { syncEnabled: dto.syncEnabled }
: {}),
...(dto.settings !== undefined ? { settings: dto.settings } : {}),
},
});
}
return this.prisma.integrationConfig.create({
data: {
clubId,
provider: '1c',
apiUrl: dto.apiUrl,
apiKey: dto.apiKey,
syncEnabled: dto.syncEnabled ?? false,
settings: dto.settings ?? {},
},
});
}
// ─── Sync ────────────────────────────────────────────────────────
async triggerSync(clubId: string, dto: TriggerSyncDto) {
const config = await this.prisma.integrationConfig.findUnique({
where: { clubId },
});
if (!config || !config.syncEnabled) {
throw new BadRequestException(
'Integration sync is not enabled. Configure and enable sync first.',
);
}
if (!config.apiUrl || !config.apiKey) {
throw new BadRequestException(
'Integration API URL and API key must be configured before sync.',
);
}
// Create a sync log entry with status "in progress"
const syncLog = await this.prisma.syncLog.create({
data: {
clubId,
integrationId: config.id,
direction: dto.direction,
entityType: dto.entityType,
recordsProcessed: 0,
recordsFailed: 0,
startedAt: new Date(),
},
});
// In a production system, this would dispatch to a BullMQ queue.
// The actual sync would happen asynchronously.
return {
syncLogId: syncLog.id,
status: 'started',
message: `Sync ${dto.direction} for ${dto.entityType} has been queued.`,
};
}
async findSyncLogs(
clubId: string,
params: FindSyncLogsDto,
): Promise<PaginatedResult<any>> {
const { cursor, limit = 20, direction, entityType } = params;
const where: Prisma.SyncLogWhereInput = {
clubId,
...(direction ? { direction } : {}),
...(entityType ? { entityType } : {}),
};
const items = await this.prisma.syncLog.findMany({
where,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { createdAt: 'desc' },
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
return {
data,
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
};
}
}

View File

@@ -0,0 +1,40 @@
import {
IsString,
IsNotEmpty,
IsUUID,
IsEnum,
IsDateString,
IsInt,
Min,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { LicenseType } from '@prisma/client';
export class CreateLicenseDto {
@ApiProperty({ description: 'Club UUID' })
@IsUUID()
@IsNotEmpty()
clubId: string;
@ApiProperty({ description: 'License type', enum: LicenseType })
@IsEnum(LicenseType)
type: LicenseType;
@ApiProperty({ description: 'Start date (ISO 8601)' })
@IsDateString()
startDate: string;
@ApiProperty({ description: 'End date (ISO 8601)' })
@IsDateString()
endDate: string;
@ApiProperty({ description: 'Maximum users allowed', example: 10 })
@IsInt()
@Min(1)
maxUsers: number;
@ApiProperty({ description: 'Maximum clients allowed', example: 1000 })
@IsInt()
@Min(1)
maxClients: number;
}

View File

@@ -0,0 +1,30 @@
import { IsOptional, IsString, IsInt, IsEnum, IsBoolean, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { LicenseType } from '@prisma/client';
export class FindLicensesDto {
@ApiPropertyOptional({ description: 'Cursor for pagination' })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ description: 'Items per page (1-100)', default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
@ApiPropertyOptional({ description: 'Filter by license type', enum: LicenseType })
@IsEnum(LicenseType)
@IsOptional()
type?: LicenseType;
@ApiPropertyOptional({ description: 'Filter by active status' })
@Type(() => Boolean)
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,4 @@
export { CreateLicenseDto } from './create-license.dto';
export { UpdateLicenseDto } from './update-license.dto';
export { RenewLicenseDto } from './renew-license.dto';
export { FindLicensesDto } from './find-licenses.dto';

View File

@@ -0,0 +1,14 @@
import { IsDateString, IsOptional, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { LicenseType } from '@prisma/client';
export class RenewLicenseDto {
@ApiProperty({ description: 'New end date (ISO 8601)' })
@IsDateString()
newEndDate: string;
@ApiPropertyOptional({ description: 'Optionally change license type', enum: LicenseType })
@IsEnum(LicenseType)
@IsOptional()
type?: LicenseType;
}

View File

@@ -0,0 +1,39 @@
import {
IsOptional,
IsEnum,
IsDateString,
IsBoolean,
IsInt,
Min,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { LicenseType } from '@prisma/client';
export class UpdateLicenseDto {
@ApiPropertyOptional({ description: 'License type', enum: LicenseType })
@IsEnum(LicenseType)
@IsOptional()
type?: LicenseType;
@ApiPropertyOptional({ description: 'End date (ISO 8601)' })
@IsDateString()
@IsOptional()
endDate?: string;
@ApiPropertyOptional({ description: 'Whether license is active' })
@IsBoolean()
@IsOptional()
isActive?: boolean;
@ApiPropertyOptional({ description: 'Maximum users allowed' })
@IsInt()
@Min(1)
@IsOptional()
maxUsers?: number;
@ApiPropertyOptional({ description: 'Maximum clients allowed' })
@IsInt()
@Min(1)
@IsOptional()
maxClients?: number;
}

View File

@@ -0,0 +1,2 @@
export { LicensesModule } from './licenses.module';
export { LicensesService } from './licenses.service';

View File

@@ -0,0 +1,124 @@
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiParam,
} from '@nestjs/swagger';
import { LicensesService } from './licenses.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles, UserRole } from '../../common/decorators/roles.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/current-user.decorator';
import {
CreateLicenseDto,
UpdateLicenseDto,
RenewLicenseDto,
FindLicensesDto,
} from './dto';
@ApiTags('Licenses')
@ApiBearerAuth()
@Controller('licenses')
@UseGuards(JwtAuthGuard)
export class LicensesController {
constructor(private readonly licensesService: LicensesService) {}
@Get()
@UseGuards(RolesGuard)
@Roles(UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'List all licenses with pagination' })
@ApiResponse({ status: 200, description: 'Paginated list of licenses' })
async findAll(@Query() query: FindLicensesDto) {
return this.licensesService.findAll(query);
}
@Get('my')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN)
@ApiOperation({ summary: 'Get license for current club' })
@ApiResponse({ status: 200, description: 'License details with status' })
@ApiResponse({ status: 404, description: 'License not found' })
async findMyLicense(@CurrentUser() user: JwtPayload) {
return this.licensesService.findByClubId(user.clubId);
}
@Get('club/:clubId')
@UseGuards(RolesGuard)
@Roles(UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Get license by club ID' })
@ApiParam({ name: 'clubId', description: 'Club UUID' })
@ApiResponse({ status: 200, description: 'License details' })
@ApiResponse({ status: 404, description: 'License not found' })
async findByClubId(@Param('clubId', ParseUUIDPipe) clubId: string) {
return this.licensesService.findByClubId(clubId);
}
@Get(':id')
@UseGuards(RolesGuard)
@Roles(UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Get license by ID' })
@ApiParam({ name: 'id', description: 'License UUID' })
@ApiResponse({ status: 200, description: 'License details' })
@ApiResponse({ status: 404, description: 'License not found' })
async findById(@Param('id', ParseUUIDPipe) id: string) {
return this.licensesService.findById(id);
}
@Post()
@UseGuards(RolesGuard)
@Roles(UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Create a new license for a club' })
@ApiResponse({ status: 201, description: 'License created' })
@ApiResponse({ status: 400, description: 'Club already has a license' })
@ApiResponse({ status: 404, description: 'Club not found' })
async create(@Body() dto: CreateLicenseDto) {
return this.licensesService.create(dto);
}
@Patch(':id')
@UseGuards(RolesGuard)
@Roles(UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Update license details' })
@ApiParam({ name: 'id', description: 'License UUID' })
@ApiResponse({ status: 200, description: 'License updated' })
@ApiResponse({ status: 404, description: 'License not found' })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateLicenseDto,
) {
return this.licensesService.update(id, dto);
}
@Post(':id/renew')
@HttpCode(HttpStatus.OK)
@UseGuards(RolesGuard)
@Roles(UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Renew a license' })
@ApiParam({ name: 'id', description: 'License UUID' })
@ApiResponse({ status: 200, description: 'License renewed' })
@ApiResponse({ status: 400, description: 'Invalid renewal date' })
@ApiResponse({ status: 404, description: 'License not found' })
async renew(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RenewLicenseDto,
) {
return this.licensesService.renew(id, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LicensesController } from './licenses.controller';
import { LicensesService } from './licenses.service';
@Module({
controllers: [LicensesController],
providers: [LicensesService],
exports: [LicensesService],
})
export class LicensesModule {}

View File

@@ -0,0 +1,182 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateLicenseDto } from './dto/create-license.dto';
import { UpdateLicenseDto } from './dto/update-license.dto';
import { RenewLicenseDto } from './dto/renew-license.dto';
import { FindLicensesDto } from './dto/find-licenses.dto';
import { Prisma } from '@prisma/client';
export interface PaginatedResult<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
}
const GRACE_PERIOD_DAYS = 3;
@Injectable()
export class LicensesService {
constructor(private readonly prisma: PrismaService) {}
async findAll(params: FindLicensesDto): Promise<PaginatedResult<any>> {
const { cursor, limit = 20, type, isActive } = params;
const where: Prisma.LicenseWhereInput = {
...(type ? { type } : {}),
...(isActive !== undefined ? { isActive } : {}),
};
const items = await this.prisma.license.findMany({
where,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { createdAt: 'desc' },
include: { club: { select: { id: true, name: true, slug: true } } },
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
return {
data,
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
};
}
async findById(id: string) {
const license = await this.prisma.license.findUnique({
where: { id },
include: { club: { select: { id: true, name: true, slug: true } } },
});
if (!license) {
throw new NotFoundException(`License with id ${id} not found`);
}
return license;
}
async findByClubId(clubId: string) {
const license = await this.prisma.license.findUnique({
where: { clubId },
include: { club: { select: { id: true, name: true, slug: true } } },
});
if (!license) {
throw new NotFoundException(`License for club ${clubId} not found`);
}
return this.enrichWithStatus(license);
}
async create(dto: CreateLicenseDto) {
const existing = await this.prisma.license.findUnique({
where: { clubId: dto.clubId },
});
if (existing) {
throw new BadRequestException(
`Club ${dto.clubId} already has a license. Use PATCH to update or POST :id/renew to renew.`,
);
}
const clubCount = await this.prisma.club.count({
where: { id: dto.clubId },
});
if (clubCount === 0) {
throw new NotFoundException(`Club with id ${dto.clubId} not found`);
}
return this.prisma.license.create({
data: {
clubId: dto.clubId,
type: dto.type,
startDate: new Date(dto.startDate),
endDate: new Date(dto.endDate),
maxUsers: dto.maxUsers,
maxClients: dto.maxClients,
isActive: true,
},
include: { club: { select: { id: true, name: true, slug: true } } },
});
}
async update(id: string, dto: UpdateLicenseDto) {
await this.ensureExists(id);
return this.prisma.license.update({
where: { id },
data: {
...(dto.type !== undefined ? { type: dto.type } : {}),
...(dto.endDate !== undefined
? { endDate: new Date(dto.endDate) }
: {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(dto.maxUsers !== undefined ? { maxUsers: dto.maxUsers } : {}),
...(dto.maxClients !== undefined
? { maxClients: dto.maxClients }
: {}),
},
include: { club: { select: { id: true, name: true, slug: true } } },
});
}
async renew(id: string, dto: RenewLicenseDto) {
const license = await this.prisma.license.findUnique({ where: { id } });
if (!license) {
throw new NotFoundException(`License with id ${id} not found`);
}
const newEndDate = new Date(dto.newEndDate);
if (newEndDate <= new Date()) {
throw new BadRequestException('New end date must be in the future');
}
return this.prisma.license.update({
where: { id },
data: {
endDate: newEndDate,
isActive: true,
gracePeriodEnd: null,
...(dto.type ? { type: dto.type } : {}),
},
include: { club: { select: { id: true, name: true, slug: true } } },
});
}
private enrichWithStatus(license: any) {
const now = new Date();
const endDate = new Date(license.endDate);
const daysUntilExpiry = Math.ceil(
(endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
let status: string;
if (!license.isActive) {
status = 'BLOCKED';
} else if (daysUntilExpiry > 30) {
status = 'ACTIVE';
} else if (daysUntilExpiry > 7) {
status = 'EXPIRING_SOON';
} else if (daysUntilExpiry > 0) {
status = 'EXPIRING_CRITICAL';
} else if (daysUntilExpiry > -GRACE_PERIOD_DAYS) {
status = 'GRACE_PERIOD';
} else if (daysUntilExpiry > -14) {
status = 'READ_ONLY';
} else {
status = 'BLOCKED';
}
return { ...license, status, daysUntilExpiry };
}
private async ensureExists(id: string): Promise<void> {
const count = await this.prisma.license.count({ where: { id } });
if (count === 0) {
throw new NotFoundException(`License with id ${id} not found`);
}
}
}

View File

@@ -0,0 +1,29 @@
import { IsOptional, IsString, IsInt, IsEnum, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { ReportStatus } from '@prisma/client';
export class FindReportsDto {
@ApiPropertyOptional({ description: 'Cursor for pagination' })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ description: 'Items per page (1-100)', default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
@ApiPropertyOptional({ description: 'Filter by report status', enum: ReportStatus })
@IsEnum(ReportStatus)
@IsOptional()
status?: ReportStatus;
@ApiPropertyOptional({ description: 'Filter by report type' })
@IsString()
@IsOptional()
type?: string;
}

View File

@@ -0,0 +1,28 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export enum ReportType {
CLIENTS_SUMMARY = 'clients_summary',
FUNNEL_CONVERSION = 'funnel_conversion',
TRAINER_PERFORMANCE = 'trainer_performance',
SALES_SUMMARY = 'sales_summary',
SLEEPING_CLIENTS = 'sleeping_clients',
}
export class GenerateReportDto {
@ApiProperty({ description: 'Report type', enum: ReportType })
@IsEnum(ReportType)
type: ReportType;
@ApiProperty({ description: 'Report title', example: 'Monthly Sales Report' })
@IsString()
@IsNotEmpty()
title: string;
@ApiPropertyOptional({
description: 'Report parameters (JSON)',
example: { dateFrom: '2026-01-01', dateTo: '2026-01-31' },
})
@IsOptional()
parameters?: Record<string, any>;
}

View File

@@ -0,0 +1,2 @@
export { GenerateReportDto, ReportType } from './generate-report.dto';
export { FindReportsDto } from './find-reports.dto';

View File

@@ -0,0 +1,2 @@
export { ReportsModule } from './reports.module';
export { ReportsService } from './reports.service';

View File

@@ -0,0 +1,89 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiParam,
} from '@nestjs/swagger';
import { ReportsService } from './reports.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleGuard } from '../../common/guards/module.guard';
import { Roles, UserRole } from '../../common/decorators/roles.decorator';
import { RequireModule } from '../../common/decorators/require-module.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/current-user.decorator';
import { GenerateReportDto, FindReportsDto } from './dto';
@ApiTags('Reports')
@ApiBearerAuth()
@Controller('reports')
@UseGuards(JwtAuthGuard, ModuleGuard)
@RequireModule('web_reports')
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
@Get()
@UseGuards(RolesGuard)
@Roles(UserRole.MANAGER, UserRole.RECEPTIONIST, UserRole.CLUB_ADMIN)
@ApiOperation({ summary: 'List reports with pagination' })
@ApiResponse({ status: 200, description: 'Paginated list of reports' })
async findAll(
@CurrentUser() user: JwtPayload,
@Query() query: FindReportsDto,
) {
return this.reportsService.findAll(user.clubId, query);
}
@Get(':id')
@UseGuards(RolesGuard)
@Roles(UserRole.MANAGER, UserRole.RECEPTIONIST, UserRole.CLUB_ADMIN)
@ApiOperation({ summary: 'Get report by ID' })
@ApiParam({ name: 'id', description: 'Report UUID' })
@ApiResponse({ status: 200, description: 'Report details' })
@ApiResponse({ status: 404, description: 'Report not found' })
async findById(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.reportsService.findById(user.clubId, id);
}
@Post('generate')
@UseGuards(RolesGuard)
@Roles(UserRole.MANAGER, UserRole.RECEPTIONIST, UserRole.CLUB_ADMIN)
@ApiOperation({ summary: 'Generate a new report' })
@ApiResponse({ status: 201, description: 'Report generation started' })
async generate(
@CurrentUser() user: JwtPayload,
@Body() dto: GenerateReportDto,
) {
return this.reportsService.generate(user.clubId, user.sub, dto);
}
@Get(':id/download')
@UseGuards(RolesGuard)
@Roles(UserRole.MANAGER, UserRole.RECEPTIONIST, UserRole.CLUB_ADMIN)
@ApiOperation({ summary: 'Get report download URL' })
@ApiParam({ name: 'id', description: 'Report UUID' })
@ApiResponse({ status: 200, description: 'File download URL' })
@ApiResponse({ status: 404, description: 'Report not found or not ready' })
async download(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.reportsService.getDownloadUrl(user.clubId, id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
controllers: [ReportsController],
providers: [ReportsService],
exports: [ReportsService],
})
export class ReportsModule {}

View File

@@ -0,0 +1,103 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { GenerateReportDto } from './dto/generate-report.dto';
import { FindReportsDto } from './dto/find-reports.dto';
import { Prisma, ReportStatus } from '@prisma/client';
export interface PaginatedResult<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
}
const REPORT_INCLUDE = {
generatedBy: {
select: { id: true, firstName: true, lastName: true },
},
};
@Injectable()
export class ReportsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(
clubId: string,
params: FindReportsDto,
): Promise<PaginatedResult<any>> {
const { cursor, limit = 20, status, type } = params;
const where: Prisma.ReportWhereInput = {
clubId,
...(status ? { status } : {}),
...(type ? { type } : {}),
};
const items = await this.prisma.report.findMany({
where,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { createdAt: 'desc' },
include: REPORT_INCLUDE,
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
return {
data,
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
};
}
async findById(clubId: string, id: string) {
const report = await this.prisma.report.findFirst({
where: { id, clubId },
include: REPORT_INCLUDE,
});
if (!report) {
throw new NotFoundException(`Report with id ${id} not found`);
}
return report;
}
async generate(clubId: string, userId: string, dto: GenerateReportDto) {
const report = await this.prisma.report.create({
data: {
clubId,
generatedById: userId,
type: dto.type,
title: dto.title,
parametersJson: dto.parameters ?? {},
status: ReportStatus.PENDING,
},
include: REPORT_INCLUDE,
});
// In a production system, this would be dispatched to a BullMQ queue.
// For now, we mark it as GENERATING to signal that the job was accepted.
await this.prisma.report.update({
where: { id: report.id },
data: { status: ReportStatus.GENERATING },
});
return { ...report, status: ReportStatus.GENERATING };
}
async getDownloadUrl(clubId: string, id: string) {
const report = await this.prisma.report.findFirst({
where: { id, clubId },
});
if (!report) {
throw new NotFoundException(`Report with id ${id} not found`);
}
if (report.status !== ReportStatus.COMPLETED || !report.fileUrl) {
throw new NotFoundException('Report file is not available yet');
}
return { fileUrl: report.fileUrl };
}
}

View File

@@ -0,0 +1,31 @@
import {
IsString,
IsNotEmpty,
IsOptional,
IsInt,
IsBoolean,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateRoomDto {
@ApiProperty({ description: 'Room name', example: 'Main Hall' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ description: 'Room capacity', example: 30, minimum: 1 })
@IsInt()
@Min(1)
capacity: number;
@ApiPropertyOptional({ description: 'Room description' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({ description: 'Whether room is active', default: true })
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,29 @@
import { IsOptional, IsString, IsInt, IsBoolean, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class FindRoomsDto {
@ApiPropertyOptional({ description: 'Cursor for pagination (UUID of last item)' })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ description: 'Number of items to return (1-100)', default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
@ApiPropertyOptional({ description: 'Search by name' })
@IsString()
@IsOptional()
search?: string;
@ApiPropertyOptional({ description: 'Filter by active status' })
@Type(() => Boolean)
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,3 @@
export { CreateRoomDto } from './create-room.dto';
export { UpdateRoomDto } from './update-room.dto';
export { FindRoomsDto } from './find-rooms.dto';

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRoomDto } from './create-room.dto';
export class UpdateRoomDto extends PartialType(CreateRoomDto) {}

View File

@@ -0,0 +1,2 @@
export { RoomsModule } from './rooms.module';
export { RoomsService } from './rooms.service';

View File

@@ -0,0 +1,103 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiParam,
} from '@nestjs/swagger';
import { RoomsService } from './rooms.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles, UserRole } from '../../common/decorators/roles.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/current-user.decorator';
import { CreateRoomDto, UpdateRoomDto, FindRoomsDto } from './dto';
@ApiTags('Rooms')
@ApiBearerAuth()
@Controller('rooms')
@UseGuards(JwtAuthGuard)
export class RoomsController {
constructor(private readonly roomsService: RoomsService) {}
@Get()
@ApiOperation({ summary: 'List rooms with pagination' })
@ApiResponse({ status: 200, description: 'Paginated list of rooms' })
async findAll(
@CurrentUser() user: JwtPayload,
@Query() query: FindRoomsDto,
) {
return this.roomsService.findAll(user.clubId, query);
}
@Get(':id')
@ApiOperation({ summary: 'Get room by ID' })
@ApiParam({ name: 'id', description: 'Room UUID' })
@ApiResponse({ status: 200, description: 'Room details' })
@ApiResponse({ status: 404, description: 'Room not found' })
async findById(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.roomsService.findById(user.clubId, id);
}
@Post()
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Create a new room' })
@ApiResponse({ status: 201, description: 'Room created' })
@ApiResponse({ status: 403, description: 'Insufficient role' })
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateRoomDto,
) {
return this.roomsService.create(user.clubId, dto);
}
@Patch(':id')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@ApiOperation({ summary: 'Update a room' })
@ApiParam({ name: 'id', description: 'Room UUID' })
@ApiResponse({ status: 200, description: 'Room updated' })
@ApiResponse({ status: 404, description: 'Room not found' })
async update(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateRoomDto,
) {
return this.roomsService.update(user.clubId, id, dto);
}
@Delete(':id')
@UseGuards(RolesGuard)
@Roles(UserRole.CLUB_ADMIN, UserRole.SUPER_ADMIN)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a room' })
@ApiParam({ name: 'id', description: 'Room UUID' })
@ApiResponse({ status: 204, description: 'Room deleted' })
@ApiResponse({ status: 404, description: 'Room not found' })
async remove(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.roomsService.remove(user.clubId, id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RoomsController } from './rooms.controller';
import { RoomsService } from './rooms.service';
@Module({
controllers: [RoomsController],
providers: [RoomsService],
exports: [RoomsService],
})
export class RoomsModule {}

View File

@@ -0,0 +1,99 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateRoomDto } from './dto/create-room.dto';
import { UpdateRoomDto } from './dto/update-room.dto';
import { FindRoomsDto } from './dto/find-rooms.dto';
import { Prisma } from '@prisma/client';
export interface PaginatedResult<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
}
@Injectable()
export class RoomsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(
clubId: string,
params: FindRoomsDto,
): Promise<PaginatedResult<any>> {
const { cursor, limit = 20, search, isActive } = params;
const where: Prisma.RoomWhereInput = {
clubId,
...(search
? { name: { contains: search, mode: 'insensitive' as const } }
: {}),
...(isActive !== undefined ? { isActive } : {}),
};
const items = await this.prisma.room.findMany({
where,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { name: 'asc' },
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
return {
data,
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
};
}
async findById(clubId: string, id: string) {
const room = await this.prisma.room.findFirst({
where: { id, clubId },
});
if (!room) {
throw new NotFoundException(`Room with id ${id} not found`);
}
return room;
}
async create(clubId: string, dto: CreateRoomDto) {
return this.prisma.room.create({
data: {
clubId,
name: dto.name,
capacity: dto.capacity,
description: dto.description,
isActive: dto.isActive ?? true,
},
});
}
async update(clubId: string, id: string, dto: UpdateRoomDto) {
await this.ensureExists(clubId, id);
return this.prisma.room.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.capacity !== undefined ? { capacity: dto.capacity } : {}),
...(dto.description !== undefined
? { description: dto.description }
: {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
});
}
async remove(clubId: string, id: string) {
await this.ensureExists(clubId, id);
await this.prisma.room.delete({ where: { id } });
}
private async ensureExists(clubId: string, id: string): Promise<void> {
const count = await this.prisma.room.count({ where: { id, clubId } });
if (count === 0) {
throw new NotFoundException(`Room with id ${id} not found`);
}
}
}

View File

@@ -0,0 +1,45 @@
import {
IsString,
IsNotEmpty,
IsUrl,
IsArray,
IsEnum,
ArrayMinSize,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export enum WebhookEvent {
CLIENT_CREATED = 'client.created',
CLIENT_UPDATED = 'client.updated',
TRAINING_CREATED = 'training.created',
TRAINING_CANCELLED = 'training.cancelled',
TRAINING_RESCHEDULED = 'training.rescheduled',
TRAINING_COMPLETED = 'training.completed',
SALE_CREATED = 'sale.created',
SALE_PAID = 'sale.paid',
FUNNEL_STAGE_CHANGED = 'funnel.stage_changed',
FUNNEL_ASSIGNED = 'funnel.assigned',
SCHEDULE_UPDATED = 'schedule.updated',
MEMBERSHIP_EXPIRING = 'membership.expiring',
}
export class CreateWebhookDto {
@ApiProperty({
description: 'Webhook delivery URL',
example: 'https://example.com/webhook',
})
@IsUrl()
@IsNotEmpty()
url: string;
@ApiProperty({
description: 'Events to subscribe to',
enum: WebhookEvent,
isArray: true,
example: [WebhookEvent.CLIENT_CREATED, WebhookEvent.SALE_CREATED],
})
@IsArray()
@ArrayMinSize(1)
@IsEnum(WebhookEvent, { each: true })
events: WebhookEvent[];
}

View File

@@ -0,0 +1,38 @@
import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class FindWebhooksDto {
@ApiPropertyOptional({ description: 'Cursor for pagination' })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ description: 'Items per page (1-100)', default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
}
export class FindWebhookLogsDto {
@ApiPropertyOptional({ description: 'Cursor for pagination' })
@IsString()
@IsOptional()
cursor?: string;
@ApiPropertyOptional({ description: 'Items per page (1-100)', default: 20 })
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 20;
@ApiPropertyOptional({ description: 'Filter by event type' })
@IsString()
@IsOptional()
event?: string;
}

View File

@@ -0,0 +1,3 @@
export { CreateWebhookDto, WebhookEvent } from './create-webhook.dto';
export { UpdateWebhookDto } from './update-webhook.dto';
export { FindWebhooksDto, FindWebhookLogsDto } from './find-webhooks.dto';

View File

@@ -0,0 +1,33 @@
import {
IsOptional,
IsUrl,
IsArray,
IsEnum,
IsBoolean,
ArrayMinSize,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { WebhookEvent } from './create-webhook.dto';
export class UpdateWebhookDto {
@ApiPropertyOptional({ description: 'Webhook delivery URL' })
@IsUrl()
@IsOptional()
url?: string;
@ApiPropertyOptional({
description: 'Events to subscribe to',
enum: WebhookEvent,
isArray: true,
})
@IsArray()
@ArrayMinSize(1)
@IsEnum(WebhookEvent, { each: true })
@IsOptional()
events?: WebhookEvent[];
@ApiPropertyOptional({ description: 'Whether webhook is active' })
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,2 @@
export { WebhooksModule } from './webhooks.module';
export { WebhooksService } from './webhooks.service';

View File

@@ -0,0 +1,133 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiParam,
} from '@nestjs/swagger';
import { WebhooksService } from './webhooks.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleGuard } from '../../common/guards/module.guard';
import { Roles, UserRole } from '../../common/decorators/roles.decorator';
import { RequireModule } from '../../common/decorators/require-module.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/current-user.decorator';
import {
CreateWebhookDto,
UpdateWebhookDto,
FindWebhooksDto,
FindWebhookLogsDto,
} from './dto';
@ApiTags('Webhooks')
@ApiBearerAuth()
@Controller('webhooks')
@UseGuards(JwtAuthGuard, ModuleGuard, RolesGuard)
@RequireModule('webhooks')
@Roles(UserRole.CLUB_ADMIN)
export class WebhooksController {
constructor(private readonly webhooksService: WebhooksService) {}
@Get()
@ApiOperation({ summary: 'List webhook subscriptions' })
@ApiResponse({ status: 200, description: 'Paginated list of webhook subscriptions' })
async findAll(
@CurrentUser() user: JwtPayload,
@Query() query: FindWebhooksDto,
) {
return this.webhooksService.findAll(user.clubId, query);
}
@Get(':id')
@ApiOperation({ summary: 'Get webhook subscription by ID' })
@ApiParam({ name: 'id', description: 'Webhook subscription UUID' })
@ApiResponse({ status: 200, description: 'Webhook subscription details' })
@ApiResponse({ status: 404, description: 'Subscription not found' })
async findById(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.webhooksService.findById(user.clubId, id);
}
@Post()
@ApiOperation({ summary: 'Create a webhook subscription' })
@ApiResponse({
status: 201,
description: 'Webhook subscription created (secret is returned only once)',
})
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateWebhookDto,
) {
return this.webhooksService.create(user.clubId, dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a webhook subscription' })
@ApiParam({ name: 'id', description: 'Webhook subscription UUID' })
@ApiResponse({ status: 200, description: 'Webhook subscription updated' })
@ApiResponse({ status: 404, description: 'Subscription not found' })
async update(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateWebhookDto,
) {
return this.webhooksService.update(user.clubId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Delete a webhook subscription' })
@ApiParam({ name: 'id', description: 'Webhook subscription UUID' })
@ApiResponse({ status: 204, description: 'Webhook subscription deleted' })
@ApiResponse({ status: 404, description: 'Subscription not found' })
async remove(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.webhooksService.remove(user.clubId, id);
}
@Get(':id/logs')
@ApiOperation({ summary: 'Get delivery logs for a webhook subscription' })
@ApiParam({ name: 'id', description: 'Webhook subscription UUID' })
@ApiResponse({ status: 200, description: 'Paginated list of delivery logs' })
async findLogs(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Query() query: FindWebhookLogsDto,
) {
return this.webhooksService.findLogs(user.clubId, id, query);
}
@Post(':id/test')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send a test webhook delivery' })
@ApiParam({ name: 'id', description: 'Webhook subscription UUID' })
@ApiResponse({ status: 200, description: 'Test delivery result' })
@ApiResponse({ status: 404, description: 'Subscription not found' })
async testDelivery(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.webhooksService.testDelivery(user.clubId, id);
}
}

View File

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

View File

@@ -0,0 +1,214 @@
import {
Injectable,
NotFoundException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateWebhookDto } from './dto/create-webhook.dto';
import { UpdateWebhookDto } from './dto/update-webhook.dto';
import { FindWebhooksDto, FindWebhookLogsDto } from './dto/find-webhooks.dto';
import { Prisma } from '@prisma/client';
import { randomBytes, createHmac } from 'crypto';
export interface PaginatedResult<T> {
data: T[];
nextCursor: string | null;
hasMore: boolean;
}
@Injectable()
export class WebhooksService {
private readonly logger = new Logger(WebhooksService.name);
constructor(private readonly prisma: PrismaService) {}
// ─── Subscriptions ───────────────────────────────────────────────
async findAll(
clubId: string,
params: FindWebhooksDto,
): Promise<PaginatedResult<any>> {
const { cursor, limit = 20 } = params;
const items = await this.prisma.webhookSubscription.findMany({
where: { clubId },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { createdAt: 'desc' },
include: { _count: { select: { logs: true } } },
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
return {
data: data.map((s) => ({ ...s, secret: undefined })),
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
};
}
async findById(clubId: string, id: string) {
const sub = await this.prisma.webhookSubscription.findFirst({
where: { id, clubId },
include: { _count: { select: { logs: true } } },
});
if (!sub) {
throw new NotFoundException(`Webhook subscription with id ${id} not found`);
}
return { ...sub, secret: undefined };
}
async create(clubId: string, dto: CreateWebhookDto) {
const secret = randomBytes(32).toString('hex');
const subscription = await this.prisma.webhookSubscription.create({
data: {
clubId,
url: dto.url,
events: dto.events,
secret,
isActive: true,
},
});
// Return secret only on creation so the user can save it
return subscription;
}
async update(clubId: string, id: string, dto: UpdateWebhookDto) {
await this.ensureSubscriptionExists(clubId, id);
return this.prisma.webhookSubscription.update({
where: { id },
data: {
...(dto.url !== undefined ? { url: dto.url } : {}),
...(dto.events !== undefined ? { events: dto.events } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
},
});
}
async remove(clubId: string, id: string) {
await this.ensureSubscriptionExists(clubId, id);
await this.prisma.webhookSubscription.delete({ where: { id } });
}
// ─── Logs ────────────────────────────────────────────────────────
async findLogs(
clubId: string,
subscriptionId: string,
params: FindWebhookLogsDto,
): Promise<PaginatedResult<any>> {
await this.ensureSubscriptionExists(clubId, subscriptionId);
const { cursor, limit = 20, event } = params;
const where: Prisma.WebhookLogWhereInput = {
clubId,
subscriptionId,
...(event ? { event } : {}),
};
const items = await this.prisma.webhookLog.findMany({
where,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
orderBy: { createdAt: 'desc' },
});
const hasMore = items.length > limit;
const data = hasMore ? items.slice(0, -1) : items;
return {
data,
nextCursor: hasMore ? data[data.length - 1].id : null,
hasMore,
};
}
// ─── Test delivery ───────────────────────────────────────────────
async testDelivery(clubId: string, id: string) {
const sub = await this.prisma.webhookSubscription.findFirst({
where: { id, clubId },
});
if (!sub) {
throw new NotFoundException(`Webhook subscription with id ${id} not found`);
}
const testPayload = {
event: 'test',
timestamp: new Date().toISOString(),
club_id: clubId,
data: { message: 'Test webhook delivery' },
};
const signature = this.signPayload(
JSON.stringify(testPayload),
sub.secret,
);
let statusCode: number | null = null;
let response: string | null = null;
try {
const res = await fetch(sub.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
},
body: JSON.stringify(testPayload),
signal: AbortSignal.timeout(10000),
});
statusCode = res.status;
response = await res.text();
} catch (err: any) {
response = err.message;
}
const log = await this.prisma.webhookLog.create({
data: {
clubId,
subscriptionId: id,
event: 'test',
payload: testPayload,
statusCode,
response: response?.slice(0, 1000),
attempt: 1,
deliveredAt: statusCode && statusCode >= 200 && statusCode < 300
? new Date()
: null,
},
});
return {
success: statusCode !== null && statusCode >= 200 && statusCode < 300,
statusCode,
log,
};
}
// ─── Helpers ─────────────────────────────────────────────────────
private signPayload(payload: string, secret: string): string {
return createHmac('sha256', secret).update(payload).digest('hex');
}
private async ensureSubscriptionExists(
clubId: string,
id: string,
): Promise<void> {
const count = await this.prisma.webhookSubscription.count({
where: { id, clubId },
});
if (count === 0) {
throw new NotFoundException(`Webhook subscription with id ${id} not found`);
}
}
}