251126:1700 1.4.4

This commit is contained in:
admin
2025-11-26 16:43:48 +07:00
parent 304f7fddf6
commit 6abb746e08
9 changed files with 358 additions and 98 deletions

View File

@@ -21,10 +21,21 @@
"username": "root"
}
],
"editor.fontSize": 15,
"editor.fontSize": 16,
"editor.codeActionsOnSave": {
"terminal": true
},
"editor.codeActions.triggerOnFocusChange": true
"editor.codeActions.triggerOnFocusChange": true,
"editor.tabSize": 2,
"editor.minimap.sectionHeaderFontSize": 12,
"terminal.integrated.fontSize": 15,
"workbench.colorTheme": "Default Dark Modern",
"workbench.colorCustomizations": {
"terminal.background": "#07003c",
"terminal.foreground": "#ffffff",
"terminalCursor.background": "#ffffff",
"terminalCursor.foreground": "#eeff00"
},
"geminicodeassist.agentYoloMode": false,
}
}

View File

@@ -1,7 +1,8 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
"source.fixAll.eslint": "explicit",
"source.fixAll.sqltools": "explicit"
},
"eslint.validate": [
"javascript",
@@ -25,5 +26,9 @@
"username": "root"
}
],
"editor.fontSize": 16
"editor.fontSize": 16,
"editor.fontLigatures": false,
"editor.tabSize": 2,
"editor.minimap.sectionHeaderFontSize": 12,
"markdown.extension.print.theme": "dark"
}

View File

@@ -1,6 +1,7 @@
// File: src/app.module.ts
// บันทึกการแก้ไข: เพิ่ม CacheModule (Redis), Config สำหรับ Idempotency และ Maintenance Mode (T1.1)
// บันทึกการแก้ไข: เพิ่ม MonitoringModule และ WinstonModule (T6.3)
// เพิ่ม MasterModule
import { Module } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
@@ -9,26 +10,28 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { WinstonModule } from 'nest-winston'; // ✅ Import WinstonModule
import { WinstonModule } from 'nest-winston';
import { redisStore } from 'cache-manager-redis-yet';
import { RedisModule } from '@nestjs-modules/ioredis';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { envValidationSchema } from './common/config/env.validation.js';
import redisConfig from './common/config/redis.config';
import { winstonConfig } from './modules/monitoring/logger/winston.config'; // ✅ Import Config
import { winstonConfig } from './modules/monitoring/logger/winston.config';
// Entities & Interceptors
import { AuditLog } from './common/entities/audit-log.entity';
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
import { MaintenanceModeGuard } from './common/guards/maintenance-mode.guard';
// import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor';
// Modules
import { AuthModule } from './common/auth/auth.module.js';
import { UserModule } from './modules/user/user.module';
import { ProjectModule } from './modules/project/project.module';
import { MasterModule } from './modules/master/master.module'; // [NEW] ✅ เพิ่ม MasterModule
import { FileStorageModule } from './common/file-storage/file-storage.module.js';
import { DocumentNumberingModule } from './modules/document-numbering/document-numbering.module';
import { AuthModule } from './common/auth/auth.module.js';
import { JsonSchemaModule } from './modules/json-schema/json-schema.module.js';
import { WorkflowEngineModule } from './modules/workflow-engine/workflow-engine.module';
import { CorrespondenceModule } from './modules/correspondence/correspondence.module';
@@ -37,12 +40,10 @@ import { DrawingModule } from './modules/drawing/drawing.module';
import { TransmittalModule } from './modules/transmittal/transmittal.module';
import { CirculationModule } from './modules/circulation/circulation.module';
import { NotificationModule } from './modules/notification/notification.module';
// ✅ Import Monitoring Module
import { MonitoringModule } from './modules/monitoring/monitoring.module';
import { ResilienceModule } from './common/resilience/resilience.module'; // ✅ Import
// ... imports
import { SearchModule } from './modules/search/search.module'; // ✅ Import
import { RedisModule } from '@nestjs-modules/ioredis'; // [NEW]
import { ResilienceModule } from './common/resilience/resilience.module';
import { SearchModule } from './modules/search/search.module';
@Module({
imports: [
// 1. Setup Config Module พร้อม Validation
@@ -80,7 +81,7 @@ import { RedisModule } from '@nestjs-modules/ioredis'; // [NEW]
inject: [ConfigService],
}),
// 📝 Setup Winston Logger (Structured Logging) [Req 6.10]
// 📝 Setup Winston Logger
WinstonModule.forRoot(winstonConfig),
// 2. Setup TypeORM (MariaDB)
@@ -114,7 +115,8 @@ import { RedisModule } from '@nestjs-modules/ioredis'; // [NEW]
},
}),
}),
// [NEW] Setup Redis Module (สำหรับ InjectRedis)
// Setup Redis Module (for InjectRedis)
RedisModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
@@ -126,25 +128,27 @@ import { RedisModule } from '@nestjs-modules/ioredis'; // [NEW]
}),
inject: [ConfigService],
}),
// 📊 Register Monitoring Module (Health & Metrics) [Req 6.10]
MonitoringModule,
// Feature Modules
// 📊 Monitoring & Resilience
MonitoringModule,
ResilienceModule,
// 📦 Feature Modules
AuthModule,
UserModule,
ProjectModule,
MasterModule, // ✅ [NEW] Register MasterModule here
FileStorageModule,
DocumentNumberingModule,
JsonSchemaModule,
WorkflowEngineModule,
CorrespondenceModule,
RfaModule, // 👈 ต้องมี
DrawingModule, // 👈 ต้องมี
TransmittalModule, // 👈 ต้องมี
CirculationModule, // 👈 ต้องมี
SearchModule, // ✅ Register Module
NotificationModule, // 👈 ต้องมี
ResilienceModule, // ✅ Register Module
RfaModule,
DrawingModule,
TransmittalModule,
CirculationModule,
SearchModule,
NotificationModule,
],
controllers: [AppController],
providers: [
@@ -164,17 +168,6 @@ import { RedisModule } from '@nestjs-modules/ioredis'; // [NEW]
provide: APP_INTERCEPTOR,
useClass: AuditLogInterceptor,
},
// 🔄 4. Register Idempotency (ถ้าต้องการ Global)
// {
// provide: APP_INTERCEPTOR,
// useClass: IdempotencyInterceptor,
// },
],
})
export class AppModule {}
/*วิธีใช้งาน
เมื่อต้องการเปิด Maintenance Mode ให้ Admin (หรือคุณ) ยิงคำสั่งเข้า Redis หรือสร้าง API เพื่อ Set ค่า: SET system:maintenance_mode true (หรือ "true")
ระบบจะตอบกลับด้วย 503 Service Unavailable ทันที ยกเว้น Controller ที่คุณใส่ @BypassMaintenance() ไว้ครับ
*/

View File

@@ -0,0 +1,29 @@
import {
IsInt,
IsString,
IsNotEmpty,
IsOptional,
IsBoolean,
} from 'class-validator';
export class CreateDisciplineDto {
@IsInt()
@IsNotEmpty()
contractId!: number;
@IsString()
@IsNotEmpty()
disciplineCode!: string; // เช่น 'STR', 'ARC'
@IsString()
@IsOptional()
codeNameTh?: string;
@IsString()
@IsOptional()
codeNameEn?: string;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}

View File

@@ -0,0 +1,23 @@
import { IsInt, IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class CreateSubTypeDto {
@IsInt()
@IsNotEmpty()
contractId!: number;
@IsInt()
@IsNotEmpty()
correspondenceTypeId!: number;
@IsString()
@IsNotEmpty()
subTypeCode!: string; // เช่น 'MAT'
@IsString()
@IsOptional()
subTypeName?: string;
@IsString()
@IsOptional()
subTypeNumber?: string; // เช่น '11'
}

View File

@@ -0,0 +1,15 @@
import { IsInt, IsString, IsNotEmpty, IsOptional } from 'class-validator';
export class SaveNumberFormatDto {
@IsInt()
@IsNotEmpty()
projectId!: number;
@IsInt()
@IsNotEmpty()
correspondenceTypeId!: number;
@IsString()
@IsNotEmpty()
formatTemplate!: string; // เช่น '{ORG}-{TYPE}-{DISCIPLINE}-{SEQ:4}'
}

View File

@@ -4,6 +4,7 @@ import {
Controller,
Get,
Post,
Put,
Body,
Patch,
Param,
@@ -12,22 +13,28 @@ import {
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import { MasterService } from './master.service';
// DTOs (สมมติว่ามีการสร้างไฟล์เหล่านี้แล้วตามแผนงาน)
import { CreateTagDto } from './dto/create-tag.dto';
import { UpdateTagDto } from './dto/update-tag.dto';
import { SearchTagDto } from './dto/search-tag.dto';
import { CreateDisciplineDto } from './dto/create-discipline.dto'; // [New]
import { CreateSubTypeDto } from './dto/create-sub-type.dto'; // [New]
import { SaveNumberFormatDto } from './dto/save-number-format.dto'; // [New]
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@ApiTags('Master Data')
@Controller('master')
@UseGuards(JwtAuthGuard) // บังคับ Login ทุก Endpoint
@UseGuards(JwtAuthGuard)
export class MasterController {
constructor(private readonly masterService: MasterService) {}
// =================================================================
// 📦 Dropdowns Endpoints (Read-Only for Frontend)
// 📦 Common Dropdowns (Read-Only)
// =================================================================
@Get('correspondence-types')
@@ -67,7 +74,82 @@ export class MasterController {
}
// =================================================================
// 🏷 Tag Management Endpoints
// 🏗 Disciplines Management (Req 6B)
// =================================================================
@Get('disciplines')
@ApiOperation({ summary: 'Get disciplines (filter by contract optional)' })
@ApiQuery({ name: 'contractId', required: false, type: Number })
getDisciplines(@Query('contractId') contractId?: number) {
return this.masterService.findAllDisciplines(contractId);
}
@Post('disciplines')
@RequirePermission('master_data.manage') // สิทธิ์ Admin
@ApiOperation({ summary: 'Create a new discipline' })
createDiscipline(@Body() dto: CreateDisciplineDto) {
return this.masterService.createDiscipline(dto);
}
@Delete('disciplines/:id')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Delete a discipline' })
deleteDiscipline(@Param('id', ParseIntPipe) id: number) {
return this.masterService.deleteDiscipline(id);
}
// =================================================================
// 📑 Correspondence Sub-Types (Req 6B)
// =================================================================
@Get('sub-types')
@ApiOperation({ summary: 'Get sub-types (filter by contract/type optional)' })
@ApiQuery({ name: 'contractId', required: false, type: Number })
@ApiQuery({ name: 'typeId', required: false, type: Number })
getSubTypes(
@Query('contractId') contractId?: number,
@Query('typeId') typeId?: number,
) {
return this.masterService.findAllSubTypes(contractId, typeId);
}
@Post('sub-types')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Create/Map a new sub-type' })
createSubType(@Body() dto: CreateSubTypeDto) {
return this.masterService.createSubType(dto);
}
@Delete('sub-types/:id')
@RequirePermission('master_data.manage')
@ApiOperation({ summary: 'Delete a sub-type' })
deleteSubType(@Param('id', ParseIntPipe) id: number) {
return this.masterService.deleteSubType(id);
}
// =================================================================
// 🔢 Numbering Formats (Admin Config)
// =================================================================
@Get('numbering-formats')
@RequirePermission('master_data.manage') // ข้อมูล config ควรสงวนสิทธิ์
@ApiOperation({ summary: 'Get numbering format for specific project/type' })
getNumberFormat(
@Query('projectId', ParseIntPipe) projectId: number,
@Query('typeId', ParseIntPipe) typeId: number,
) {
return this.masterService.findNumberFormat(projectId, typeId);
}
@Post('numbering-formats')
@RequirePermission('system.manage_all') // เฉพาะ Superadmin/System Admin
@ApiOperation({ summary: 'Save or Update numbering format template' })
saveNumberFormat(@Body() dto: SaveNumberFormatDto) {
return this.masterService.saveNumberFormat(dto);
}
// =================================================================
// 🏷️ Tag Management
// =================================================================
@Get('tags')
@@ -83,21 +165,21 @@ export class MasterController {
}
@Post('tags')
@RequirePermission('master_data.tag.manage') // ต้องมีสิทธิ์ (Admin/Doc Control)
@RequirePermission('master_data.tag.manage')
@ApiOperation({ summary: 'Create a new tag' })
createTag(@Body() dto: CreateTagDto) {
return this.masterService.createTag(dto);
}
@Patch('tags/:id')
@RequirePermission('master_data.tag.manage') // ต้องมีสิทธิ์
@RequirePermission('master_data.tag.manage')
@ApiOperation({ summary: 'Update a tag' })
updateTag(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateTagDto) {
return this.masterService.updateTag(id, dto);
}
@Delete('tags/:id')
@RequirePermission('master_data.tag.manage') // ต้องมีสิทธิ์
@RequirePermission('master_data.tag.manage')
@ApiOperation({ summary: 'Delete a tag' })
deleteTag(@Param('id', ParseIntPipe) id: number) {
return this.masterService.deleteTag(id);

View File

@@ -5,7 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { MasterService } from './master.service';
import { MasterController } from './master.controller';
// Import Entities
// Import Entities เดิม
import { Tag } from './entities/tag.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
@@ -14,6 +14,12 @@ import { RfaStatusCode } from '../rfa/entities/rfa-status-code.entity';
import { RfaApproveCode } from '../rfa/entities/rfa-approve-code.entity';
import { CirculationStatusCode } from '../circulation/entities/circulation-status-code.entity';
// [New v1.4.4] Import Entities ใหม่ตาม Req 6B และ T2.6
import { Discipline } from './entities/discipline.entity';
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
// Entity นี้อาจจะอยู่ใน module document-numbering แต่นำมาใช้ที่นี่เพื่อการจัดการ Master Data
import { DocumentNumberFormat } from '../document-numbering/entities/document-number-format.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
@@ -24,10 +30,14 @@ import { CirculationStatusCode } from '../circulation/entities/circulation-statu
RfaStatusCode,
RfaApproveCode,
CirculationStatusCode,
// [New] Register Repositories
Discipline,
CorrespondenceSubType,
DocumentNumberFormat,
]),
],
controllers: [MasterController],
providers: [MasterService],
exports: [MasterService], // Export เผื่อ Module อื่นต้องใช้
exports: [MasterService],
})
export class MasterModule {}

View File

@@ -1,6 +1,10 @@
// File: src/modules/master/master.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@@ -13,132 +17,220 @@ import { RfaApproveCode } from '../rfa/entities/rfa-approve-code.entity';
import { CirculationStatusCode } from '../circulation/entities/circulation-status-code.entity';
import { Tag } from './entities/tag.entity';
// [New] Entities
import { Discipline } from './entities/discipline.entity';
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
import { DocumentNumberFormat } from '../document-numbering/entities/document-number-format.entity';
// Import DTOs
import { CreateTagDto } from './dto/create-tag.dto';
import { UpdateTagDto } from './dto/update-tag.dto';
import { SearchTagDto } from './dto/search-tag.dto';
import { CreateDisciplineDto } from './dto/create-discipline.dto';
import { CreateSubTypeDto } from './dto/create-sub-type.dto';
import { SaveNumberFormatDto } from './dto/save-number-format.dto';
@Injectable()
export class MasterService {
constructor(
@InjectRepository(CorrespondenceType)
private readonly corrTypeRepo: Repository<CorrespondenceType>,
@InjectRepository(CorrespondenceStatus)
private readonly corrStatusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(RfaType)
private readonly rfaTypeRepo: Repository<RfaType>,
@InjectRepository(RfaStatusCode)
private readonly rfaStatusRepo: Repository<RfaStatusCode>,
@InjectRepository(RfaApproveCode)
private readonly rfaApproveRepo: Repository<RfaApproveCode>,
@InjectRepository(CirculationStatusCode)
private readonly circulationStatusRepo: Repository<CirculationStatusCode>,
@InjectRepository(Tag)
private readonly tagRepo: Repository<Tag>,
// [New] Repositories
@InjectRepository(Discipline)
private readonly disciplineRepo: Repository<Discipline>,
@InjectRepository(CorrespondenceSubType)
private readonly subTypeRepo: Repository<CorrespondenceSubType>,
@InjectRepository(DocumentNumberFormat)
private readonly formatRepo: Repository<DocumentNumberFormat>,
) {}
// =================================================================
// ✉️ Correspondence Master Data
// =================================================================
// ... (Method เดิม: findAllCorrespondenceTypes, findAllCorrespondenceStatuses, ฯลฯ เก็บไว้เหมือนเดิม) ...
// หมายเหตุ: ตรวจสอบว่า Entity ใช้ชื่อ property ว่า isActive หรือ is_active (ใน SQL เป็น is_active แต่ใน Entity มักเป็น isActive)
// โค้ดเดิมใช้ `where: { isActive: true }` ซึ่งถูกต้องถ้า Entity map column name แล้ว
async findAllCorrespondenceTypes() {
return this.corrTypeRepo.find({
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
where: { isActive: true },
order: { sortOrder: 'ASC' },
});
}
async findAllCorrespondenceStatuses() {
return this.corrStatusRepo.find({
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
where: { isActive: true },
order: { sortOrder: 'ASC' },
});
}
// =================================================================
// 📐 RFA Master Data
// =================================================================
async findAllRfaTypes() {
return this.rfaTypeRepo.find({
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
where: { isActive: true },
order: { sortOrder: 'ASC' },
});
}
async findAllRfaStatuses() {
return this.rfaStatusRepo.find({
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
where: { isActive: true },
order: { sortOrder: 'ASC' },
});
}
async findAllRfaApproveCodes() {
return this.rfaApproveRepo.find({
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
where: { isActive: true },
order: { sortOrder: 'ASC' },
});
}
// =================================================================
// 🔄 Circulation Master Data
// =================================================================
async findAllCirculationStatuses() {
return this.circulationStatusRepo.find({
where: { isActive: true }, // ✅ แก้เป็น camelCase
order: { sortOrder: 'ASC' }, // ✅ แก้เป็น camelCase
where: { isActive: true },
order: { sortOrder: 'ASC' },
});
}
// =================================================================
// 🏷 Tag Management (CRUD)
// 🏗 Disciplines Logic
// =================================================================
async findAllDisciplines(contractId?: number) {
const query = this.disciplineRepo
.createQueryBuilder('d')
.leftJoinAndSelect('d.contract', 'c')
.orderBy('d.disciplineCode', 'ASC');
if (contractId) {
query.where('d.contractId = :contractId', { contractId });
}
// เพิ่มเงื่อนไข Active หากต้องการ
query.andWhere('d.isActive = :isActive', { isActive: true });
return query.getMany();
}
async createDiscipline(dto: CreateDisciplineDto) {
const exists = await this.disciplineRepo.findOne({
where: { contractId: dto.contractId, disciplineCode: dto.disciplineCode },
});
if (exists)
throw new ConflictException(
'Discipline code already exists in this contract',
);
const discipline = this.disciplineRepo.create(dto);
return this.disciplineRepo.save(discipline);
}
async deleteDiscipline(id: number) {
const result = await this.disciplineRepo.delete(id);
if (result.affected === 0)
throw new NotFoundException(`Discipline ID ${id} not found`);
return { deleted: true };
}
// =================================================================
// 📑 Sub-Types Logic
// =================================================================
async findAllSubTypes(contractId?: number, typeId?: number) {
const query = this.subTypeRepo
.createQueryBuilder('st')
.leftJoinAndSelect('st.contract', 'c')
.leftJoinAndSelect('st.correspondenceType', 'ct')
.orderBy('st.subTypeCode', 'ASC');
if (contractId)
query.andWhere('st.contractId = :contractId', { contractId });
if (typeId) query.andWhere('st.correspondenceTypeId = :typeId', { typeId });
return query.getMany();
}
async createSubType(dto: CreateSubTypeDto) {
// อาจจะเช็ค Duplicate code ด้วย logic คล้าย discipline
const subType = this.subTypeRepo.create(dto);
return this.subTypeRepo.save(subType);
}
async deleteSubType(id: number) {
const result = await this.subTypeRepo.delete(id);
if (result.affected === 0)
throw new NotFoundException(`Sub-type ID ${id} not found`);
return { deleted: true };
}
// =================================================================
// 🔢 Numbering Formats Logic
// =================================================================
async findNumberFormat(projectId: number, typeId: number) {
const format = await this.formatRepo.findOne({
where: { projectId, correspondenceTypeId: typeId },
});
if (!format) {
// Optional: Return default format structure or null
return null;
}
return format;
}
async saveNumberFormat(dto: SaveNumberFormatDto) {
// Check if exists (Upsert)
let format = await this.formatRepo.findOne({
where: {
projectId: dto.projectId,
correspondenceTypeId: dto.correspondenceTypeId,
},
});
if (format) {
format.formatTemplate = dto.formatTemplate;
// format.updatedBy = ... (ถ้ามี)
} else {
format = this.formatRepo.create({
projectId: dto.projectId,
correspondenceTypeId: dto.correspondenceTypeId,
formatTemplate: dto.formatTemplate,
});
}
return this.formatRepo.save(format);
}
// ... (Tag Logic เดิม คงไว้ตามปกติ) ...
async findAllTags(query?: SearchTagDto) {
const qb = this.tagRepo.createQueryBuilder('tag');
if (query?.search) {
qb.where('tag.tag_name LIKE :search OR tag.description LIKE :search', {
search: `%${query.search}%`,
});
}
qb.orderBy('tag.tag_name', 'ASC');
if (query?.page && query?.limit) {
const page = query.page;
const limit = query.limit;
qb.skip((page - 1) * limit).take(limit);
}
if (query?.page && query?.limit) {
const [items, total] = await qb.getManyAndCount();
return {
data: items,
meta: {
total,
page: query.page,
limit: query.limit,
totalPages: Math.ceil(total / query.limit),
},
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
};
}
return qb.getMany();
}
async findOneTag(id: number) {
const tag = await this.tagRepo.findOne({ where: { id } });
if (!tag) {
throw new NotFoundException(`Tag with ID "${id}" not found`);
}
if (!tag) throw new NotFoundException(`Tag with ID "${id}" not found`);
return tag;
}