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