Merge pull request 'feat(api): реализация 8 backend-модулей NestJS' (#1) from master into main
This commit was merged in pull request #1.
This commit is contained in:
@@ -6,7 +6,6 @@ import { AuthModule } from './modules/auth';
|
||||
import { UsersModule } from './modules/users';
|
||||
import { TenantMiddleware } from './common/middleware/tenant.middleware';
|
||||
|
||||
// Future modules (uncomment as implemented):
|
||||
import { ClientsModule } from './modules/clients';
|
||||
import { ScheduleModule } from './modules/schedule';
|
||||
import { FunnelModule } from './modules/funnel';
|
||||
@@ -14,19 +13,19 @@ import { SalesModule } from './modules/sales';
|
||||
import { StatsModule } from './modules/stats';
|
||||
// import { RatingModule } from './modules/rating';
|
||||
import { NotificationsModule } from './modules/notifications';
|
||||
// import { ReportsModule } from './modules/reports';
|
||||
import { ReportsModule } from './modules/reports';
|
||||
import { WorkScheduleModule } from './modules/work-schedule';
|
||||
import { SipModule } from './modules/sip';
|
||||
// import { WebhooksModule } from './modules/webhooks';
|
||||
// import { ClubsModule } from './modules/clubs';
|
||||
// import { LicensesModule } from './modules/licenses';
|
||||
import { WebhooksModule } from './modules/webhooks';
|
||||
import { ClubsModule } from './modules/clubs';
|
||||
import { LicensesModule } from './modules/licenses';
|
||||
import { ModulesModule } from './modules/modules';
|
||||
import { MeteringModule } from './modules/metering';
|
||||
import { ProvisioningModule } from './modules/provisioning';
|
||||
// import { CatalogModule } from './modules/catalog';
|
||||
// import { DepartmentsModule } from './modules/departments';
|
||||
// import { RoomsModule } from './modules/rooms';
|
||||
// import { IntegrationModule } from './modules/integration';
|
||||
import { CatalogModule } from './modules/catalog';
|
||||
import { DepartmentsModule } from './modules/departments';
|
||||
import { RoomsModule } from './modules/rooms';
|
||||
import { IntegrationModule } from './modules/integration';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -48,6 +47,15 @@ import { ProvisioningModule } from './modules/provisioning';
|
||||
StatsModule,
|
||||
WorkScheduleModule,
|
||||
SipModule,
|
||||
// New modules
|
||||
DepartmentsModule,
|
||||
RoomsModule,
|
||||
CatalogModule,
|
||||
ClubsModule,
|
||||
LicensesModule,
|
||||
ReportsModule,
|
||||
WebhooksModule,
|
||||
IntegrationModule,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
|
||||
247
apps/api/src/modules/catalog/catalog.controller.ts
Normal file
247
apps/api/src/modules/catalog/catalog.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/catalog/catalog.module.ts
Normal file
10
apps/api/src/modules/catalog/catalog.module.ts
Normal 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 {}
|
||||
299
apps/api/src/modules/catalog/catalog.service.ts
Normal file
299
apps/api/src/modules/catalog/catalog.service.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
apps/api/src/modules/catalog/dto/create-category.dto.ts
Normal file
15
apps/api/src/modules/catalog/dto/create-category.dto.ts
Normal 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;
|
||||
}
|
||||
48
apps/api/src/modules/catalog/dto/create-package.dto.ts
Normal file
48
apps/api/src/modules/catalog/dto/create-package.dto.ts
Normal 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;
|
||||
}
|
||||
43
apps/api/src/modules/catalog/dto/create-service.dto.ts
Normal file
43
apps/api/src/modules/catalog/dto/create-service.dto.ts
Normal 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;
|
||||
}
|
||||
75
apps/api/src/modules/catalog/dto/find-catalog.dto.ts
Normal file
75
apps/api/src/modules/catalog/dto/find-catalog.dto.ts
Normal 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;
|
||||
}
|
||||
7
apps/api/src/modules/catalog/dto/index.ts
Normal file
7
apps/api/src/modules/catalog/dto/index.ts
Normal 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';
|
||||
4
apps/api/src/modules/catalog/dto/update-category.dto.ts
Normal file
4
apps/api/src/modules/catalog/dto/update-category.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateCategoryDto } from './create-category.dto';
|
||||
|
||||
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
|
||||
4
apps/api/src/modules/catalog/dto/update-package.dto.ts
Normal file
4
apps/api/src/modules/catalog/dto/update-package.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreatePackageDto } from './create-package.dto';
|
||||
|
||||
export class UpdatePackageDto extends PartialType(CreatePackageDto) {}
|
||||
4
apps/api/src/modules/catalog/dto/update-service.dto.ts
Normal file
4
apps/api/src/modules/catalog/dto/update-service.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateServiceDto } from './create-service.dto';
|
||||
|
||||
export class UpdateServiceDto extends PartialType(CreateServiceDto) {}
|
||||
2
apps/api/src/modules/catalog/index.ts
Normal file
2
apps/api/src/modules/catalog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CatalogModule } from './catalog.module';
|
||||
export { CatalogService } from './catalog.service';
|
||||
72
apps/api/src/modules/clubs/clubs.controller.ts
Normal file
72
apps/api/src/modules/clubs/clubs.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/clubs/clubs.module.ts
Normal file
10
apps/api/src/modules/clubs/clubs.module.ts
Normal 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 {}
|
||||
105
apps/api/src/modules/clubs/clubs.service.ts
Normal file
105
apps/api/src/modules/clubs/clubs.service.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
apps/api/src/modules/clubs/dto/find-clubs.dto.ts
Normal file
29
apps/api/src/modules/clubs/dto/find-clubs.dto.ts
Normal 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;
|
||||
}
|
||||
2
apps/api/src/modules/clubs/dto/index.ts
Normal file
2
apps/api/src/modules/clubs/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { FindClubsDto } from './find-clubs.dto';
|
||||
export { UpdateClubDto } from './update-club.dto';
|
||||
39
apps/api/src/modules/clubs/dto/update-club.dto.ts
Normal file
39
apps/api/src/modules/clubs/dto/update-club.dto.ts
Normal 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;
|
||||
}
|
||||
2
apps/api/src/modules/clubs/index.ts
Normal file
2
apps/api/src/modules/clubs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ClubsModule } from './clubs.module';
|
||||
export { ClubsService } from './clubs.service';
|
||||
107
apps/api/src/modules/departments/departments.controller.ts
Normal file
107
apps/api/src/modules/departments/departments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/departments/departments.module.ts
Normal file
10
apps/api/src/modules/departments/departments.module.ts
Normal 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 {}
|
||||
104
apps/api/src/modules/departments/departments.service.ts
Normal file
104
apps/api/src/modules/departments/departments.service.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
28
apps/api/src/modules/departments/dto/find-departments.dto.ts
Normal file
28
apps/api/src/modules/departments/dto/find-departments.dto.ts
Normal 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;
|
||||
}
|
||||
3
apps/api/src/modules/departments/dto/index.ts
Normal file
3
apps/api/src/modules/departments/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CreateDepartmentDto } from './create-department.dto';
|
||||
export { UpdateDepartmentDto } from './update-department.dto';
|
||||
export { FindDepartmentsDto } from './find-departments.dto';
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateDepartmentDto } from './create-department.dto';
|
||||
|
||||
export class UpdateDepartmentDto extends PartialType(CreateDepartmentDto) {}
|
||||
2
apps/api/src/modules/departments/index.ts
Normal file
2
apps/api/src/modules/departments/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DepartmentsModule } from './departments.module';
|
||||
export { DepartmentsService } from './departments.service';
|
||||
33
apps/api/src/modules/integration/dto/find-sync-logs.dto.ts
Normal file
33
apps/api/src/modules/integration/dto/find-sync-logs.dto.ts
Normal 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;
|
||||
}
|
||||
3
apps/api/src/modules/integration/dto/index.ts
Normal file
3
apps/api/src/modules/integration/dto/index.ts
Normal 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';
|
||||
17
apps/api/src/modules/integration/dto/trigger-sync.dto.ts
Normal file
17
apps/api/src/modules/integration/dto/trigger-sync.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
2
apps/api/src/modules/integration/index.ts
Normal file
2
apps/api/src/modules/integration/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { IntegrationModule } from './integration.module';
|
||||
export { IntegrationService } from './integration.service';
|
||||
78
apps/api/src/modules/integration/integration.controller.ts
Normal file
78
apps/api/src/modules/integration/integration.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/integration/integration.module.ts
Normal file
10
apps/api/src/modules/integration/integration.module.ts
Normal 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 {}
|
||||
149
apps/api/src/modules/integration/integration.service.ts
Normal file
149
apps/api/src/modules/integration/integration.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
40
apps/api/src/modules/licenses/dto/create-license.dto.ts
Normal file
40
apps/api/src/modules/licenses/dto/create-license.dto.ts
Normal 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;
|
||||
}
|
||||
30
apps/api/src/modules/licenses/dto/find-licenses.dto.ts
Normal file
30
apps/api/src/modules/licenses/dto/find-licenses.dto.ts
Normal 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;
|
||||
}
|
||||
4
apps/api/src/modules/licenses/dto/index.ts
Normal file
4
apps/api/src/modules/licenses/dto/index.ts
Normal 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';
|
||||
14
apps/api/src/modules/licenses/dto/renew-license.dto.ts
Normal file
14
apps/api/src/modules/licenses/dto/renew-license.dto.ts
Normal 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;
|
||||
}
|
||||
39
apps/api/src/modules/licenses/dto/update-license.dto.ts
Normal file
39
apps/api/src/modules/licenses/dto/update-license.dto.ts
Normal 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;
|
||||
}
|
||||
2
apps/api/src/modules/licenses/index.ts
Normal file
2
apps/api/src/modules/licenses/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LicensesModule } from './licenses.module';
|
||||
export { LicensesService } from './licenses.service';
|
||||
124
apps/api/src/modules/licenses/licenses.controller.ts
Normal file
124
apps/api/src/modules/licenses/licenses.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/licenses/licenses.module.ts
Normal file
10
apps/api/src/modules/licenses/licenses.module.ts
Normal 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 {}
|
||||
182
apps/api/src/modules/licenses/licenses.service.ts
Normal file
182
apps/api/src/modules/licenses/licenses.service.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
apps/api/src/modules/reports/dto/find-reports.dto.ts
Normal file
29
apps/api/src/modules/reports/dto/find-reports.dto.ts
Normal 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;
|
||||
}
|
||||
28
apps/api/src/modules/reports/dto/generate-report.dto.ts
Normal file
28
apps/api/src/modules/reports/dto/generate-report.dto.ts
Normal 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>;
|
||||
}
|
||||
2
apps/api/src/modules/reports/dto/index.ts
Normal file
2
apps/api/src/modules/reports/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { GenerateReportDto, ReportType } from './generate-report.dto';
|
||||
export { FindReportsDto } from './find-reports.dto';
|
||||
2
apps/api/src/modules/reports/index.ts
Normal file
2
apps/api/src/modules/reports/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ReportsModule } from './reports.module';
|
||||
export { ReportsService } from './reports.service';
|
||||
89
apps/api/src/modules/reports/reports.controller.ts
Normal file
89
apps/api/src/modules/reports/reports.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/reports/reports.module.ts
Normal file
10
apps/api/src/modules/reports/reports.module.ts
Normal 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 {}
|
||||
103
apps/api/src/modules/reports/reports.service.ts
Normal file
103
apps/api/src/modules/reports/reports.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
31
apps/api/src/modules/rooms/dto/create-room.dto.ts
Normal file
31
apps/api/src/modules/rooms/dto/create-room.dto.ts
Normal 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;
|
||||
}
|
||||
29
apps/api/src/modules/rooms/dto/find-rooms.dto.ts
Normal file
29
apps/api/src/modules/rooms/dto/find-rooms.dto.ts
Normal 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;
|
||||
}
|
||||
3
apps/api/src/modules/rooms/dto/index.ts
Normal file
3
apps/api/src/modules/rooms/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CreateRoomDto } from './create-room.dto';
|
||||
export { UpdateRoomDto } from './update-room.dto';
|
||||
export { FindRoomsDto } from './find-rooms.dto';
|
||||
4
apps/api/src/modules/rooms/dto/update-room.dto.ts
Normal file
4
apps/api/src/modules/rooms/dto/update-room.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateRoomDto } from './create-room.dto';
|
||||
|
||||
export class UpdateRoomDto extends PartialType(CreateRoomDto) {}
|
||||
2
apps/api/src/modules/rooms/index.ts
Normal file
2
apps/api/src/modules/rooms/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { RoomsModule } from './rooms.module';
|
||||
export { RoomsService } from './rooms.service';
|
||||
103
apps/api/src/modules/rooms/rooms.controller.ts
Normal file
103
apps/api/src/modules/rooms/rooms.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/rooms/rooms.module.ts
Normal file
10
apps/api/src/modules/rooms/rooms.module.ts
Normal 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 {}
|
||||
99
apps/api/src/modules/rooms/rooms.service.ts
Normal file
99
apps/api/src/modules/rooms/rooms.service.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
apps/api/src/modules/webhooks/dto/create-webhook.dto.ts
Normal file
45
apps/api/src/modules/webhooks/dto/create-webhook.dto.ts
Normal 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[];
|
||||
}
|
||||
38
apps/api/src/modules/webhooks/dto/find-webhooks.dto.ts
Normal file
38
apps/api/src/modules/webhooks/dto/find-webhooks.dto.ts
Normal 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;
|
||||
}
|
||||
3
apps/api/src/modules/webhooks/dto/index.ts
Normal file
3
apps/api/src/modules/webhooks/dto/index.ts
Normal 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';
|
||||
33
apps/api/src/modules/webhooks/dto/update-webhook.dto.ts
Normal file
33
apps/api/src/modules/webhooks/dto/update-webhook.dto.ts
Normal 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;
|
||||
}
|
||||
2
apps/api/src/modules/webhooks/index.ts
Normal file
2
apps/api/src/modules/webhooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { WebhooksModule } from './webhooks.module';
|
||||
export { WebhooksService } from './webhooks.service';
|
||||
133
apps/api/src/modules/webhooks/webhooks.controller.ts
Normal file
133
apps/api/src/modules/webhooks/webhooks.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/modules/webhooks/webhooks.module.ts
Normal file
10
apps/api/src/modules/webhooks/webhooks.module.ts
Normal 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 {}
|
||||
214
apps/api/src/modules/webhooks/webhooks.service.ts
Normal file
214
apps/api/src/modules/webhooks/webhooks.service.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user