260322:1648 Correct Coresspondence / Doing RFA / Correct CI
CI Pipeline / build (push) Failing after 12m41s
Build and Deploy / deploy (push) Failing after 2m44s

This commit is contained in:
admin
2026-03-22 16:48:12 +07:00
parent e5deedb42e
commit 11984bfa29
683 changed files with 105251 additions and 29068 deletions
@@ -86,7 +86,7 @@ export class CirculationWorkflowService {
};
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(`Failed to start circulation: ${error}`);
this.logger.error(`Failed to start circulation: ${String(error)}`);
throw error;
} finally {
await queryRunner.release();
@@ -114,7 +114,7 @@ export class CirculationWorkflowService {
const instance = await this.workflowEngine.getInstanceById(instanceId);
if (instance && instance.entityType === 'circulation') {
const circulation = await this.circulationRepo.findOne({
where: { id: parseInt(instance.entityId) },
where: { id: Number(instance.entityId) },
});
if (circulation) {
await this.syncStatus(circulation, result.nextState);
@@ -5,7 +5,7 @@ import {
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, Not } from 'typeorm';
import { Repository, DataSource } from 'typeorm';
import { Circulation } from './entities/circulation.entity';
import { CirculationRouting } from './entities/circulation-routing.entity';
@@ -95,7 +95,7 @@ export class CirculationService {
}
async findAll(searchDto: SearchCirculationDto, user: User) {
const { search, status, page = 1, limit = 20 } = searchDto;
const { status, page = 1, limit = 20 } = searchDto;
const query = this.circulationRepo
.createQueryBuilder('c')
.leftJoinAndSelect('c.creator', 'creator')
@@ -13,7 +13,7 @@ import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiQuery,
_ApiQuery,
} from '@nestjs/swagger';
import { ContractService } from './contract.service.js';
import { CreateContractDto } from './dto/create-contract.dto.js';
@@ -88,7 +88,7 @@ export class CorrespondenceWorkflowService {
};
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(`Failed to submit workflow: ${error}`);
this.logger.error(`Failed to submit workflow: ${String(error)}`);
throw error;
} finally {
await queryRunner.release();
@@ -113,7 +113,7 @@ export class CorrespondenceWorkflowService {
if (instance && instance.entityType === 'correspondence_revision') {
const revision = await this.revisionRepo.findOne({
where: { id: parseInt(instance.entityId) },
where: { id: Number(instance.entityId) },
});
if (revision) {
await this.syncStatus(revision, result.nextState);
@@ -79,7 +79,7 @@ describe('CorrespondenceController', () => {
subject: 'Test Subject',
};
const result = await controller.create(
const _result = await controller.create(
createDto as Parameters<typeof controller.create>[0],
mockReq as Parameters<typeof controller.create>[1]
);
@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { DataSource, Repository } from 'typeorm';
import { CorrespondenceService } from './correspondence.service';
import { Correspondence } from './entities/correspondence.entity';
import { CorrespondenceRevision } from './entities/correspondence-revision.entity';
@@ -15,13 +15,15 @@ import { WorkflowEngineService } from '../workflow-engine/workflow-engine.servic
import { UserService } from '../user/user.service';
import { SearchService } from '../search/search.service';
import { FileStorageService } from '../../common/file-storage/file-storage.service';
import { UpdateCorrespondenceDto } from './dto/update-correspondence.dto';
import { User } from '../user/entities/user.entity';
describe('CorrespondenceService', () => {
let service: CorrespondenceService;
let numberingService: DocumentNumberingService;
let correspondenceRepo: any;
let revisionRepo: any;
let dataSource: any;
let correspondenceRepo: Repository<Correspondence>;
let revisionRepo: Repository<CorrespondenceRevision>;
let _dataSource: DataSource;
const createMockRepository = () => ({
find: jest.fn(),
@@ -88,6 +90,10 @@ describe('CorrespondenceService', () => {
provide: getRepositoryToken(Organization),
useValue: createMockRepository(),
},
{
provide: getRepositoryToken(CorrespondenceRecipient),
useValue: createMockRepository(),
},
{
provide: DocumentNumberingService,
useValue: {
@@ -130,9 +136,13 @@ describe('CorrespondenceService', () => {
numberingService = module.get<DocumentNumberingService>(
DocumentNumberingService
);
correspondenceRepo = module.get(getRepositoryToken(Correspondence));
revisionRepo = module.get(getRepositoryToken(CorrespondenceRevision));
dataSource = module.get(DataSource);
correspondenceRepo = module.get<Repository<Correspondence>>(
getRepositoryToken(Correspondence)
);
revisionRepo = module.get<Repository<CorrespondenceRevision>>(
getRepositoryToken(CorrespondenceRevision)
);
_dataSource = module.get<DataSource>(DataSource);
});
it('should be defined', () => {
@@ -141,20 +151,17 @@ describe('CorrespondenceService', () => {
describe('update', () => {
it('should NOT regenerate number if critical fields unchanged', async () => {
const mockUser = { user_id: 1, primaryOrganizationId: 10 } as any;
const mockUser = { id: 1, primaryOrganizationId: 10 } as unknown as User;
const mockRevision = {
id: 100,
correspondenceId: 1,
isCurrent: true,
statusId: 5,
}; // Status 5 = Draft handled by logic?
// Mock status repo to return DRAFT
// But strict logic: revision.statusId check
jest.spyOn(revisionRepo, 'findOne').mockResolvedValue(mockRevision);
const mockStatus = { id: 5, statusCode: 'DRAFT' };
// Need to set statusRepo mock behavior... simplified here for brevity or assume defaults
// Injecting internal access to statusRepo is hard without `module.get` if I didn't save it.
// Let's assume it passes check for now.
};
jest
.spyOn(revisionRepo, 'findOne')
.mockResolvedValue(mockRevision as unknown as CorrespondenceRevision);
const mockCorr = {
id: 1,
@@ -165,89 +172,105 @@ describe('CorrespondenceService', () => {
correspondenceNumber: 'OLD-NUM',
recipients: [{ recipientType: 'TO', recipientOrganizationId: 99 }],
};
jest.spyOn(correspondenceRepo, 'findOne').mockResolvedValue(mockCorr);
jest
.spyOn(correspondenceRepo, 'findOne')
.mockResolvedValue(mockCorr as unknown as Correspondence);
// Update DTO with same values
const updateDto = {
const updateDto: UpdateCorrespondenceDto = {
projectId: 1,
disciplineId: 3,
// recipients missing -> imply no change
};
await service.update(1, updateDto as any, mockUser);
await service.update(1, updateDto, mockUser);
// Check that updateNumberForDraft was NOT called
expect(numberingService.updateNumberForDraft).not.toHaveBeenCalled();
expect(
numberingService.updateNumberForDraft as jest.Mock
).not.toHaveBeenCalled();
});
it('should regenerate number if Project ID changes', async () => {
const mockUser = { user_id: 1, primaryOrganizationId: 10 } as any;
const mockUser = { id: 1, primaryOrganizationId: 10 } as unknown as User;
const mockRevision = {
id: 100,
correspondenceId: 1,
isCurrent: true,
statusId: 5,
};
jest.spyOn(revisionRepo, 'findOne').mockResolvedValue(mockRevision);
jest
.spyOn(revisionRepo, 'findOne')
.mockResolvedValue(mockRevision as unknown as CorrespondenceRevision);
const mockCorr = {
id: 1,
projectId: 1, // Old Project
projectId: 1,
correspondenceTypeId: 2,
disciplineId: 3,
originatorId: 10,
correspondenceNumber: 'OLD-NUM',
recipients: [{ recipientType: 'TO', recipientOrganizationId: 99 }],
};
jest.spyOn(correspondenceRepo, 'findOne').mockResolvedValue(mockCorr);
jest
.spyOn(correspondenceRepo, 'findOne')
.mockResolvedValue(mockCorr as unknown as Correspondence);
const updateDto = {
projectId: 2, // New Project -> Change!
const updateDto: UpdateCorrespondenceDto = {
projectId: 2,
};
await service.update(1, updateDto as any, mockUser);
await service.update(1, updateDto, mockUser);
expect(numberingService.updateNumberForDraft).toHaveBeenCalled();
expect(
numberingService.updateNumberForDraft as jest.Mock
).toHaveBeenCalled();
});
it('should regenerate number if Document Type changes', async () => {
const mockUser = { user_id: 1, primaryOrganizationId: 10 } as any;
const mockUser = { id: 1, primaryOrganizationId: 10 } as unknown as User;
const mockRevision = {
id: 100,
correspondenceId: 1,
isCurrent: true,
statusId: 5,
};
jest.spyOn(revisionRepo, 'findOne').mockResolvedValue(mockRevision);
jest
.spyOn(revisionRepo, 'findOne')
.mockResolvedValue(mockRevision as unknown as CorrespondenceRevision);
const mockCorr = {
id: 1,
projectId: 1,
correspondenceTypeId: 2, // Old Type
correspondenceTypeId: 2,
disciplineId: 3,
originatorId: 10,
correspondenceNumber: 'OLD-NUM',
recipients: [{ recipientType: 'TO', recipientOrganizationId: 99 }],
};
jest.spyOn(correspondenceRepo, 'findOne').mockResolvedValue(mockCorr);
jest
.spyOn(correspondenceRepo, 'findOne')
.mockResolvedValue(mockCorr as unknown as Correspondence);
const updateDto = {
typeId: 999, // New Type
const updateDto: UpdateCorrespondenceDto = {
typeId: 999,
};
await service.update(1, updateDto as any, mockUser);
await service.update(1, updateDto, mockUser);
expect(numberingService.updateNumberForDraft).toHaveBeenCalled();
expect(
numberingService.updateNumberForDraft as jest.Mock
).toHaveBeenCalled();
});
it('should regenerate number if Recipient Organization changes', async () => {
const mockUser = { user_id: 1, primaryOrganizationId: 10 } as any;
const mockUser = { id: 1, primaryOrganizationId: 10 } as unknown as User;
const mockRevision = {
id: 100,
correspondenceId: 1,
isCurrent: true,
statusId: 5,
};
jest.spyOn(revisionRepo, 'findOne').mockResolvedValue(mockRevision);
jest
.spyOn(revisionRepo, 'findOne')
.mockResolvedValue(mockRevision as unknown as CorrespondenceRevision);
const mockCorr = {
id: 1,
@@ -256,20 +279,30 @@ describe('CorrespondenceService', () => {
disciplineId: 3,
originatorId: 10,
correspondenceNumber: 'OLD-NUM',
recipients: [{ recipientType: 'TO', recipientOrganizationId: 99 }], // Old Recipient 99
recipients: [{ recipientType: 'TO', recipientOrganizationId: 99 }],
};
jest.spyOn(correspondenceRepo, 'findOne').mockResolvedValue(mockCorr);
jest
.spyOn(service['orgRepo'], 'findOne')
.mockResolvedValue({ id: 88, organizationCode: 'NEW-ORG' } as any);
.spyOn(correspondenceRepo, 'findOne')
.mockResolvedValue(mockCorr as unknown as Correspondence);
const updateDto = {
recipients: [{ type: 'TO', organizationId: 88 }], // New Recipient 88
// Access private property for mocking via casting
const internalService = service as unknown as {
orgRepo: Repository<Organization>;
};
jest.spyOn(internalService.orgRepo, 'findOne').mockResolvedValue({
id: 88,
organizationCode: 'NEW-ORG',
} as unknown as Organization);
const updateDto: UpdateCorrespondenceDto = {
recipients: [{ type: 'TO', organizationId: 88 }],
};
await service.update(1, updateDto as any, mockUser);
await service.update(1, updateDto, mockUser);
expect(numberingService.updateNumberForDraft).toHaveBeenCalled();
expect(
numberingService.updateNumberForDraft as jest.Mock
).toHaveBeenCalled();
});
});
});
@@ -39,10 +39,11 @@ import { UuidResolverService } from '../../common/services/uuid-resolver.service
/**
* CorrespondenceService - Document management (CRUD)
*
* NOTE: Workflow operations (submit, processAction) have been moved to
* CorrespondenceWorkflowService which uses the Unified Workflow Engine.
*/
interface ResolvedRecipient {
organizationId: number;
type: 'TO' | 'CC';
}
@Injectable()
export class CorrespondenceService {
private readonly logger = new Logger(CorrespondenceService.name);
@@ -78,12 +79,14 @@ export class CorrespondenceService {
: undefined;
const resolvedRecipients = createDto.recipients
? await Promise.all(
createDto.recipients.map(async (r) => ({
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
}))
createDto.recipients.map(
async (r): Promise<ResolvedRecipient> => ({
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
})
)
)
: undefined;
const type = await this.typeRepo.findOne({
@@ -257,9 +260,9 @@ export class CorrespondenceService {
originatorId: userOrgId,
disciplineId: createDto.disciplineId,
initiatorId: user.user_id,
}
} as Record<string, unknown>
);
} catch (error) {
} catch (error: unknown) {
this.logger.warn(
`Workflow not started for ${docNumber.number} (Code: CORRESPONDENCE_${type.typeCode}): ${(error as Error).message}`
);
@@ -491,12 +494,14 @@ export class CorrespondenceService {
: undefined;
const updResolvedRecipients = updateDto.recipients
? await Promise.all(
updateDto.recipients.map(async (r) => ({
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
}))
updateDto.recipients.map(
async (r): Promise<ResolvedRecipient> => ({
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
})
)
)
: undefined;
@@ -699,12 +704,14 @@ export class CorrespondenceService {
: undefined;
const previewRecipients = createDto.recipients
? await Promise.all(
createDto.recipients.map(async (r) => ({
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
}))
createDto.recipients.map(
async (r): Promise<ResolvedRecipient> => ({
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
})
)
)
: undefined;
@@ -6,7 +6,6 @@ import {
IsInt,
IsArray,
ValidateNested,
IsEnum,
IsBoolean,
} from 'class-validator';
import { Type } from 'class-transformer';
@@ -1,4 +1,4 @@
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Entity, _Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { Correspondence } from './correspondence.entity';
import { Organization } from '../../organization/entities/organization.entity';
@@ -63,22 +63,23 @@ export class DashboardService {
// นับ RFA ทั้งหมด (correspondence_type_id = RFA type)
// ใช้ Raw Query เพราะต้อง JOIN กับ correspondence_types
const rfaCountResult = await this.dataSource.query(`
const rfaCountResult = await this.dataSource.query<
{ count: string | number }[]
>(`
SELECT COUNT(*) as count
FROM correspondences c
JOIN correspondence_types ct ON c.correspondence_type_id = ct.id
WHERE ct.type_code = 'RFA'
`);
const totalRfas = parseInt(rfaCountResult[0]?.count || '0', 10);
const totalRfas = Number(rfaCountResult[0]?.count || '0');
// นับ Circulation ทั้งหมด
const circulationsCountResult = await this.dataSource.query(`
const circulationsCountResult = await this.dataSource.query<
{ count: string | number }[]
>(`
SELECT COUNT(*) as count FROM circulations
`);
const totalCirculations = parseInt(
circulationsCountResult[0]?.count || '0',
10
);
const totalCirculations = Number(circulationsCountResult[0]?.count || '0');
// นับเอกสารที่อนุมัติแล้ว (APPROVED)
// NOTE: อาจจะต้องปรับ logic ตาม Business ว่า "อนุมัติ" หมายถึงอะไร
@@ -92,13 +93,15 @@ export class DashboardService {
// เพื่อความรวดเร็ว ใช้วิธีนับ Revision ที่ isCurrent = 1 และ statusCode = 'APR' (Approved)
// Check status code 'APR' exists
const aprStatusCount = await this.dataSource.query(`
const aprStatusCount = await this.dataSource.query<
{ count: string | number }[]
>(`
SELECT COUNT(r.id) as count
FROM correspondence_revisions r
JOIN correspondence_status s ON r.correspondence_status_id = s.id
WHERE r.is_current = 1 AND s.status_code IN ('APR', 'CMP')
`);
const approved = parseInt(aprStatusCount[0]?.count || '0', 10);
const approved = Number(aprStatusCount[0]?.count || '0');
return {
totalDocuments,
@@ -172,7 +175,7 @@ export class DashboardService {
const userIdNum = Number(userId);
const [tasks, countResult] = await Promise.all([
this.dataSource.query(
this.dataSource.query<PendingTaskItemDto[]>(
`
SELECT
instance_id as instanceId,
@@ -192,7 +195,7 @@ export class DashboardService {
`,
[userIdNum, userIdNum, limit, offset]
),
this.dataSource.query(
this.dataSource.query<{ total: string | number }[]>(
`
SELECT COUNT(*) as total
FROM v_user_tasks
@@ -204,7 +207,7 @@ export class DashboardService {
),
]);
const total = parseInt(countResult[0]?.total || '0', 10);
const total = Number(countResult[0]?.total || '0');
return {
data: tasks,
@@ -116,7 +116,10 @@ export class DocumentNumberingController {
year: dto.year,
customTokens: dto.customTokens,
});
console.log('[DocumentNumberingController] Preview result:', JSON.stringify(result));
// console.log(
// '[DocumentNumberingController] Preview result:',
// JSON.stringify(result)
// );
return result;
}
}
@@ -1,4 +1,4 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Controller, Get } from '@nestjs/common';
import { MetricsService } from '../services/metrics.service';
// import { PermissionGuard } from '../../auth/guards/permission.guard';
// import { Permissions } from '../../auth/decorators/permissions.decorator';
@@ -10,7 +10,7 @@ export class NumberingMetricsController {
@Get()
// @Permissions('system.view_logs')
async getMetrics() {
getMetrics() {
// Determine how to return metrics.
// Standard Prometheus metrics are usually exposed via a separate /metrics endpoint processing all metrics.
// If the frontend needs JSON data, we might need to query the current values from the registry or metrics service.
@@ -1,4 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Repository } from 'typeorm';
import { DocumentNumberingService } from './services/document-numbering.service';
import { CounterService } from './services/counter.service';
import { ReservationService } from './services/reservation.service';
@@ -124,8 +125,8 @@ describe('DocumentNumberingService', () => {
expect(result).toHaveProperty('number');
expect(result).toHaveProperty('auditId');
expect(result.number).toBe('DOC-0001');
expect(counterService.incrementCounter).toHaveBeenCalled();
expect(formatService.format).toHaveBeenCalled();
expect(counterService.incrementCounter as jest.Mock).toHaveBeenCalled();
expect(formatService.format as jest.Mock).toHaveBeenCalled();
});
it('should throw error when increment fails', async () => {
@@ -142,7 +143,9 @@ describe('DocumentNumberingService', () => {
describe('Admin Operations', () => {
it('voidAndReplace should verify audit log exists', async () => {
const auditRepo = module.get(getRepositoryToken(DocumentNumberAudit));
const auditRepo = module.get<Repository<DocumentNumberAudit>>(
getRepositoryToken(DocumentNumberAudit)
);
(auditRepo.findOne as jest.Mock).mockResolvedValue({
documentNumber: 'DOC-001',
counterKey: JSON.stringify({ projectId: 1, correspondenceTypeId: 1 }),
@@ -156,11 +159,13 @@ describe('DocumentNumberingService', () => {
replace: false,
});
expect(result.status).toBe('VOIDED');
expect(auditRepo.save).toHaveBeenCalled();
expect(auditRepo.save as jest.Mock).toHaveBeenCalled();
});
it('cancelNumber should log cancellation', async () => {
const auditRepo = module.get(getRepositoryToken(DocumentNumberAudit));
const auditRepo = module.get<Repository<DocumentNumberAudit>>(
getRepositoryToken(DocumentNumberAudit)
);
(auditRepo.findOne as jest.Mock).mockResolvedValue({
documentNumber: 'DOC-002',
counterKey: {},
@@ -173,7 +178,7 @@ describe('DocumentNumberingService', () => {
projectId: 1,
});
expect(result.status).toBe('CANCELLED');
expect(auditRepo.save).toHaveBeenCalled();
expect(auditRepo.save as jest.Mock).toHaveBeenCalled();
});
});
});
@@ -25,7 +25,7 @@ export class DocumentNumberAudit {
documentNumber!: string;
@Column({ name: 'counter_key', type: 'json' })
counterKey!: Record<string, unknown> | unknown;
counterKey!: Record<string, unknown>;
@Column({ name: 'template_used', length: 200 })
templateUsed!: string;
@@ -1,17 +1,6 @@
import {
Injectable,
Logger,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
import {
Repository,
EntityManager,
In,
IsNull,
Equal,
} from 'typeorm';
import { Repository, EntityManager, IsNull, Equal } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { DocumentNumberFormat } from '../entities/document-number-format.entity';
@@ -127,18 +116,20 @@ export class DocumentNumberingService {
const sequence = await this.counterService.incrementCounter(key);
// 4. Format Number
const { previewNumber: documentNumber } = await this.formatService.format({
projectId: ctx.projectId,
correspondenceTypeId: ctx.typeId,
subTypeId: ctx.subTypeId,
rfaTypeId: ctx.rfaTypeId,
disciplineId: ctx.disciplineId,
sequence: sequence,
resetScope: resetScope,
year: currentYear,
originatorOrganizationId: ctx.originatorOrganizationId,
recipientOrganizationId: ctx.recipientOrganizationId,
});
const { previewNumber: documentNumber } = await this.formatService.format(
{
projectId: ctx.projectId,
correspondenceTypeId: ctx.typeId,
subTypeId: ctx.subTypeId,
rfaTypeId: ctx.rfaTypeId,
disciplineId: ctx.disciplineId,
sequence: sequence,
resetScope: resetScope,
year: currentYear,
originatorOrganizationId: ctx.originatorOrganizationId,
recipientOrganizationId: ctx.recipientOrganizationId,
}
);
// 5. Audit Log
const audit = await this.logAudit({
@@ -197,9 +188,11 @@ export class DocumentNumberingService {
return this.reservationService.cancel(token, userId);
}
async previewNumber(
ctx: GenerateNumberContext
): Promise<{ previewNumber: string; nextSequence: number; isDefault: boolean }> {
async previewNumber(ctx: GenerateNumberContext): Promise<{
previewNumber: string;
nextSequence: number;
isDefault: boolean;
}> {
const currentYear = new Date().getFullYear();
const resetScope = `YEAR_${currentYear}`;
@@ -247,13 +240,15 @@ export class DocumentNumberingService {
// --- Admin / Legacy ---
async getTemplates() {
async getTemplates(): Promise<DocumentNumberFormat[]> {
return this.formatRepo.find({
relations: ['project', 'correspondenceType'],
});
}
async getTemplatesByProject(projectId: number | string) {
async getTemplatesByProject(
projectId: number | string
): Promise<DocumentNumberFormat[]> {
const internalId = await this.uuidResolver.resolveProjectId(projectId);
return this.formatRepo.find({
where: { projectId: internalId },
@@ -263,10 +258,10 @@ export class DocumentNumberingService {
async saveTemplate(
dto: Partial<DocumentNumberFormat> & { projectId?: number | string }
) {
): Promise<DocumentNumberFormat> {
try {
this.logger.log(`Saving numbering template: ${JSON.stringify(dto)}`);
// Resolve project ID if it's a UUID/String
if (dto.projectId && typeof dto.projectId === 'string') {
dto.projectId = await this.uuidResolver.resolveProjectId(dto.projectId);
@@ -277,12 +272,16 @@ export class DocumentNumberingService {
const existing = await this.formatRepo.findOne({
where: {
projectId: Number(dto.projectId),
correspondenceTypeId: dto.correspondenceTypeId ? Equal(dto.correspondenceTypeId) : IsNull(),
correspondenceTypeId: dto.correspondenceTypeId
? Equal(dto.correspondenceTypeId)
: IsNull(),
disciplineId: dto.disciplineId || 0,
},
});
if (existing) {
this.logger.log(`Found existing template ID: ${existing.id} for business key, updating instead of creating.`);
this.logger.log(
`Found existing template ID: ${existing.id} for business key, updating instead of creating.`
);
dto.id = existing.id;
}
}
@@ -290,8 +289,11 @@ export class DocumentNumberingService {
const result = await this.formatRepo.save(dto);
this.logger.log(`Successfully saved template ID: ${result.id}`);
return result;
} catch (e: any) {
this.logger.error(`Failed to save numbering template: ${e.message}`, e.stack);
} catch (e: unknown) {
this.logger.error(
`Failed to save numbering template: ${e instanceof Error ? e.message : String(e)}`,
e instanceof Error ? e.stack : undefined
);
throw e;
}
}
@@ -344,7 +346,7 @@ export class DocumentNumberingService {
// Create a void audit anyway if possible?
await this.logAudit({
documentNumber: dto.documentNumber,
counterKey: {}, // Unknown
counterKey: {},
templateUsed: 'VOID_UNKNOWN',
context: { userId: 0, ipAddress: '0.0.0.0' }, // System
isSuccess: true,
@@ -377,19 +379,20 @@ export class DocumentNumberingService {
// But we can reconstruct it.
let context: GenerateNumberContext;
try {
const rawKey = lastAudit.counterKey;
const key =
typeof lastAudit.counterKey === 'string'
? JSON.parse(lastAudit.counterKey)
: lastAudit.counterKey;
typeof rawKey === 'string'
? (JSON.parse(rawKey) as Record<string, unknown>)
: rawKey;
context = {
projectId: key.projectId,
typeId: key.correspondenceTypeId,
subTypeId: key.subTypeId,
rfaTypeId: key.rfaTypeId,
disciplineId: key.disciplineId,
originatorOrganizationId: key.originatorOrganizationId || 0,
recipientOrganizationId: key.recipientOrganizationId || 0,
projectId: Number(key.projectId),
typeId: Number(key.correspondenceTypeId),
subTypeId: Number(key.subTypeId),
rfaTypeId: Number(key.rfaTypeId),
disciplineId: Number(key.disciplineId),
originatorOrganizationId: Number(key.originatorOrganizationId) || 0,
recipientOrganizationId: Number(key.recipientOrganizationId) || 0,
userId: 0, // System replacement
};
@@ -527,9 +530,11 @@ export class DocumentNumberingService {
errorMessage: err.message || 'Unknown Error',
errorType: this.mapErrorType(err),
contextData: {
...(typeof ctx === 'object' && ctx !== null ? ctx : {}),
...(typeof ctx === 'object' && ctx !== null
? (ctx as Record<string, unknown>)
: {}),
operation,
} as Record<string, unknown>,
},
});
await this.errorRepo.save(errEntity);
} catch (e) {
@@ -39,13 +39,21 @@ export class FormatService {
private disciplineRepo: Repository<Discipline>
) {}
async format(options: FormatOptions): Promise<{ previewNumber: string; isDefault: boolean }> {
async format(
options: FormatOptions
): Promise<{ previewNumber: string; isDefault: boolean }> {
const { template, isDefault } = await this.resolveFormatAndScope(options);
const currentYear = options.year || new Date().getFullYear();
const tokens = await this.resolveTokens(options, currentYear);
const previewNumber = this.replaceTokens(template, tokens, options.sequence);
console.log(`[FormatService] Generated: "${previewNumber}" | Template: "${template}" | isDefault: ${isDefault}`);
const previewNumber = this.replaceTokens(
template,
tokens,
options.sequence
);
// console.log(
// `[FormatService] Generated: "${previewNumber}" | Template: "${template}" | isDefault: ${isDefault}`
// );
return { previewNumber, isDefault };
}
@@ -134,7 +142,7 @@ export class FormatService {
}
const seqMatch = result.match(/{SEQ:(\d+)}/);
if (seqMatch) {
const padding = parseInt(seqMatch[1], 10);
const padding = Number(seqMatch[1]);
result = result.replace(
seqMatch[0],
sequence.toString().padStart(padding, '0')
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, _Logger } from '@nestjs/common';
import { Counter, Gauge, Histogram } from 'prom-client';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
@@ -73,7 +73,7 @@ export class ReservationService {
Date.now() + this.RESERVATION_TTL_MINUTES * 60 * 1000
);
const reservation = await this.reservationRepo.save({
const _reservation = await this.reservationRepo.save({
token,
documentNumber,
status: ReservationStatus.RESERVED,
@@ -1,4 +1,4 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Injectable, Logger, _NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DocumentNumberFormat } from '../entities/document-number-format.entity';
@@ -2,5 +2,5 @@ import { PartialType } from '@nestjs/swagger';
import { CreateContractDrawingDto } from './create-contract-drawing.dto';
export class UpdateContractDrawingDto extends PartialType(
CreateContractDrawingDto,
CreateContractDrawingDto
) {}
@@ -48,11 +48,11 @@ export class CreateJsonSchemaDto {
@IsObject()
@IsNotEmpty()
schemaDefinition!: Record<string, any>;
schemaDefinition!: Record<string, unknown>;
@IsObject()
@IsOptional()
uiSchema?: Record<string, any>;
uiSchema?: Record<string, unknown>;
@IsArray()
@IsOptional()
@@ -62,7 +62,7 @@ export class CreateJsonSchemaDto {
@IsObject()
@IsOptional()
migrationScript?: Record<string, any>;
migrationScript?: Record<string, unknown>;
@IsBoolean()
@IsOptional()
@@ -16,4 +16,3 @@ export class MigrateDataDto {
@IsOptional()
targetVersion?: number;
}
@@ -11,7 +11,7 @@ export class SearchJsonSchemaDto {
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
return value as boolean | undefined;
})
isActive?: boolean;
@@ -117,7 +117,7 @@ export class JsonSchemaService implements OnModuleInit {
// ถ้าไม่ส่งมา ให้สร้าง UI Schema พื้นฐานให้อัตโนมัติ
createDto.uiSchema = this.uiSchemaService.generateDefaultUiSchema(
createDto.schemaDefinition
);
) as unknown as Record<string, unknown>;
}
// 3. จัดการ Versioning อัตโนมัติ (Auto-increment)
@@ -255,16 +255,17 @@ export class JsonSchemaService implements OnModuleInit {
async validateData(
schemaCode: string,
data: Record<string, unknown>,
options: ValidationOptions = {}
_options: ValidationOptions = {}
): Promise<ValidationResult> {
// 1. ดึงและ Compile Validator
const validate = await this.getValidator(schemaCode);
const schema = await this.findLatestByCode(schemaCode); // ดึง Full Schema เพื่อใช้ Config อื่นๆ
// 2. สำเนาข้อมูลเพื่อป้องกัน Side Effect และเตรียมสำหรับ AJV Mutation (Sanitization)
const dataToValidate: Record<string, unknown> = JSON.parse(
JSON.stringify(data)
);
const dataToValidate = JSON.parse(JSON.stringify(data)) as Record<
string,
unknown
>;
// 3. เริ่มการตรวจสอบ (AJV จะทำการ Coerce Type และ Remove Additional Properties ให้ด้วย)
const valid = validate(dataToValidate);
@@ -59,19 +59,21 @@ export class SchemaMigrationService {
// 2. Fetch Entity Data & Current Version
// Note: This assumes the entity table has 'details' (json) and 'schema_version' (int) columns
// If schema_version is not present, we assume version 1
const entity = await queryRunner.manager.query(
`SELECT details, schema_version FROM ${entityType} WHERE id = ?`,
[entityId]
);
const entities = await queryRunner.manager.query<
{ details: Record<string, unknown>; schema_version: number }[]
>(`SELECT details, schema_version FROM ${entityType} WHERE id = ?`, [
entityId,
]);
if (!entity || entity.length === 0) {
if (!entities || entities.length === 0) {
throw new BadRequestException(
`Entity ${entityType} with ID ${entityId} not found.`
);
}
const currentData = entity[0].details || {};
const currentVersion = entity[0].schema_version || 1;
const entity = entities[0];
const currentData = entity.details || {};
const currentVersion = entity.schema_version || 1;
if (currentVersion >= targetSchema.version) {
return {
@@ -83,7 +85,10 @@ export class SchemaMigrationService {
}
// 3. Find Migration Path (Iterative Upgrade)
let migratedData = JSON.parse(JSON.stringify(currentData));
let migratedData = JSON.parse(JSON.stringify(currentData)) as Record<
string,
unknown
>;
const migratedFields: string[] = [];
// Loop from current version up to target version
@@ -102,10 +107,11 @@ export class SchemaMigrationService {
// Apply steps defined in migrationScript
if (Array.isArray(script.steps)) {
for (const step of script.steps) {
migratedData = await this.applyMigrationStep(step, migratedData);
if (step.config.field || step.config.new_field) {
migratedFields.push(step.config.new_field || step.config.field);
for (const step of script.steps as MigrationStep[]) {
migratedData = this.applyMigrationStep(step, migratedData);
const config = step.config as Record<string, string>;
if (config.field || config.new_field) {
migratedFields.push(config.new_field || config.field);
}
}
}
@@ -158,10 +164,10 @@ export class SchemaMigrationService {
/**
* Apply a single migration step
*/
private async applyMigrationStep(
private applyMigrationStep(
step: MigrationStep,
data: Record<string, unknown>
): Promise<Record<string, unknown>> {
): Record<string, unknown> {
const newData = { ...data };
const field = step.config.field as string;
@@ -190,7 +196,11 @@ export class SchemaMigrationService {
if (newData[field] !== undefined) {
// Simple transform logic (e.g., map values)
if (step.config.transform === 'MAP_VALUES' && step.config.mapping) {
const oldVal = String(newData[field]);
const val = newData[field];
const oldVal =
typeof val === 'string' || typeof val === 'number'
? String(val)
: JSON.stringify(val);
const mapping = step.config.mapping as Record<string, unknown>;
newData[field] = mapping[oldVal] || newData[field];
}
@@ -198,7 +208,11 @@ export class SchemaMigrationService {
else if (step.config.transform === 'TO_NUMBER') {
newData[field] = Number(newData[field]);
} else if (step.config.transform === 'TO_STRING') {
newData[field] = String(newData[field]);
const val = newData[field];
newData[field] =
typeof val === 'string' || typeof val === 'number'
? String(val)
: JSON.stringify(val);
}
}
break;
@@ -83,7 +83,7 @@ export class UiSchemaService {
title: (value.title as string) || this.humanize(key),
description: value.description as string | undefined,
required: ((dataSchema.required as string[]) || []).includes(key),
widget: this.guessWidget(value) as WidgetType,
widget: this.guessWidget(value),
colSpan: 12, // Default full width
};
}
@@ -98,12 +98,12 @@ export class VirtualColumnService {
AND table_name = ?
AND index_name = ?
`;
const result = await queryRunner.query(checkIndexSql, [
const result = (await queryRunner.query(checkIndexSql, [
tableName,
indexName,
]);
])) as { count: number }[];
if (result[0].count == 0) {
if (result[0]?.count === 0) {
const sql = `CREATE ${config.index_type === 'UNIQUE' ? 'UNIQUE' : ''} INDEX ${indexName} ON ${tableName} (${config.column_name})`;
this.logger.log(`Creating Index: ${sql}`);
await queryRunner.query(sql);
@@ -1,4 +1,4 @@
import { IsString, IsNotEmpty, IsOptional, IsInt } from 'class-validator';
import { IsString, IsNotEmpty, IsOptional, _IsInt } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateTagDto {
@@ -1,4 +1,4 @@
import { IsInt, IsString, IsNotEmpty, IsOptional } from 'class-validator';
import { IsInt, IsString, IsNotEmpty, _IsOptional } from 'class-validator';
export class SaveNumberFormatDto {
@IsInt()
@@ -4,7 +4,7 @@ import {
Controller,
Get,
Post,
Put,
_Put,
Body,
Patch,
Param,
+1 -1
View File
@@ -276,7 +276,7 @@ export class MasterService {
} as Partial<DocumentNumberFormat>);
}
return this.formatRepo.save(format!);
return this.formatRepo.save(format);
}
async findAllTags(query?: SearchTagDto) {
@@ -163,7 +163,7 @@ export class MigrationController {
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Stream a file from staging' })
@ApiQuery({ name: 'path', required: true, type: String })
async getStagingFile(@Query('path') filePath: string, @Res() res: Response) {
getStagingFile(@Query('path') filePath: string, @Res() res: Response) {
const stream = this.migrationService.getStagingFileStream(filePath);
res.set({
'Content-Type': 'application/pdf',
@@ -27,6 +27,9 @@ import { MigrationQueueQueryDto } from './dto/migration-queue-query.dto';
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
import { createReadStream, existsSync } from 'fs';
import * as path from 'path';
import { Rfa } from '../rfa/entities/rfa.entity';
import { RfaRevision } from '../rfa/entities/rfa-revision.entity';
@Injectable()
export class MigrationService {
private readonly logger = new Logger(MigrationService.name);
@@ -171,15 +174,15 @@ export class MigrationService {
// --- CTI: insert RFA class ---
if (isRFA) {
// Default RFA type generic mapping
const rfaTypeRes = await queryRunner.manager.query(
const rfaTypeRes = await queryRunner.manager.query<{ id: number }[]>(
"SELECT id FROM rfa_types WHERE type_code = 'GEN' LIMIT 1"
);
const rfa = queryRunner.manager.create('Rfa', {
const rfa = queryRunner.manager.create(Rfa, {
id: correspondence.id,
rfaTypeId: rfaTypeRes[0]?.id || 1, // fallback to id 1
createdBy: userId,
});
await queryRunner.manager.save('Rfa', rfa);
await queryRunner.manager.save(Rfa, rfa);
}
} else {
// Update values if missing
@@ -292,11 +295,11 @@ export class MigrationService {
// --- CTI: insert RfaRevision ---
if (isRFA) {
// Map Status code to RFA Equivalent 'APP' (Approved) if exist, or id 3 (typically Approved)
const rfaStatusRes = await queryRunner.manager.query(
const rfaStatusRes = await queryRunner.manager.query<{ id: number }[]>(
"SELECT id FROM rfa_status_codes WHERE status_code = 'APP' LIMIT 1"
);
const rfaRev = queryRunner.manager.create('RfaRevision', {
const rfaRev = queryRunner.manager.create(RfaRevision, {
id: revision.id,
rfaStatusCodeId: rfaStatusRes[0]?.id || 3, // Fallback to 3 if APP not found
details: {
@@ -305,7 +308,7 @@ export class MigrationService {
},
schemaVersion: 1,
});
await queryRunner.manager.save('RfaRevision', rfaRev);
await queryRunner.manager.save(RfaRevision, rfaRev);
}
// 5.5 Handle Tags
@@ -329,7 +332,7 @@ export class MigrationService {
if (!tagName) continue;
// Find or create Tag
const tagRes = await queryRunner.manager.query(
const tagRes = await queryRunner.manager.query<{ id: number }[]>(
'SELECT id FROM tags WHERE project_id = ? AND tag_name = ? LIMIT 1',
[project.id, tagName]
);
@@ -338,7 +341,9 @@ export class MigrationService {
if (tagRes && tagRes.length > 0) {
tagId = tagRes[0].id;
} else {
const insertRes = await queryRunner.manager.query(
const insertRes = await queryRunner.manager.query<{
insertId: number;
}>(
"INSERT INTO tags (project_id, tag_name, color_code, created_by) VALUES (?, ?, 'default', ?)",
[project.id, tagName, userId]
);
@@ -22,7 +22,7 @@ export const winstonConfig: WinstonModuleOptions = {
: nestWinstonUtilities.format.nestLike('LCBP3-DMS', {
prettyPrint: true,
colors: true,
}),
})
),
}),
// สามารถเพิ่ม File Transport หรือ HTTP Transport ไปยัง Log Server ได้ที่นี่
@@ -32,7 +32,7 @@ export class MonitoringService {
await this.redis.set(this.MAINTENANCE_KEY, 'true');
// เก็บเหตุผลไว้ใน Key อื่นก็ได้ถ้าต้องการ แต่เบื้องต้น Guard เช็คแค่ Key นี้
this.logger.warn(
`⚠️ SYSTEM ENTERED MAINTENANCE MODE: ${dto.reason || 'No reason provided'}`,
`⚠️ SYSTEM ENTERED MAINTENANCE MODE: ${dto.reason || 'No reason provided'}`
);
} else {
await this.redis.del(this.MAINTENANCE_KEY);
@@ -4,7 +4,7 @@ import {
IsOptional,
IsEnum,
IsNotEmpty,
IsUrl,
_IsUrl,
} from 'class-validator';
import { NotificationType } from '../entities/notification.entity';
@@ -17,7 +17,7 @@ export class SearchNotificationDto {
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
return value as boolean | undefined;
})
isRead?: boolean; // กรอง: อ่านแล้ว/ยังไม่อ่าน
}
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm';
import { Repository, _LessThan } from 'typeorm';
import { Notification } from './entities/notification.entity';
@Injectable()
@@ -20,6 +20,10 @@ interface NotificationPayload {
type: 'EMAIL' | 'LINE' | 'SYSTEM';
}
type NotificationJobData =
| NotificationPayload
| { userId: number; type: 'EMAIL' | 'LINE' };
@Processor('notifications')
export class NotificationProcessor extends WorkerHost {
private readonly logger = new Logger(NotificationProcessor.name);
@@ -37,28 +41,30 @@ export class NotificationProcessor extends WorkerHost {
super();
// Setup Nodemailer
this.mailerTransport = nodemailer.createTransport({
host: this.configService.get('SMTP_HOST'),
port: Number(this.configService.get('SMTP_PORT')),
secure: this.configService.get('SMTP_SECURE') === 'true',
host: this.configService.get<string>('SMTP_HOST'),
port: Number(this.configService.get<number>('SMTP_PORT')),
secure: this.configService.get<string>('SMTP_SECURE') === 'true',
auth: {
user: this.configService.get('SMTP_USER'),
pass: this.configService.get('SMTP_PASS'),
user: this.configService.get<string>('SMTP_USER'),
pass: this.configService.get<string>('SMTP_PASS'),
},
});
}
async process(job: Job<any, any, string>): Promise<any> {
async process(
job: Job<NotificationJobData, unknown, string>
): Promise<unknown> {
this.logger.debug(`Processing job ${job.name} (ID: ${job.id})`);
try {
switch (job.name) {
case 'dispatch-notification':
// Job หลัก: ตัดสินใจว่าจะส่งเลย หรือจะเข้า Digest Queue
return this.handleDispatch(job.data);
return this.handleDispatch(job.data as NotificationPayload);
case 'process-digest':
// Job รอง: ทำงานเมื่อครบเวลา Delay เพื่อส่งแบบรวม
return this.handleProcessDigest(job.data.userId, job.data.type);
case 'process-digest': {
const data = job.data as { userId: number; type: 'EMAIL' | 'LINE' };
return this.handleProcessDigest(data.userId, data.type);
}
default:
throw new Error(`Unknown job name: ${job.name}`);
@@ -152,8 +158,8 @@ export class NotificationProcessor extends WorkerHost {
if (!messagesRaw || messagesRaw.length === 0) return;
const messages: NotificationPayload[] = messagesRaw.map((m) =>
JSON.parse(m)
const messages: NotificationPayload[] = messagesRaw.map(
(m) => JSON.parse(m) as NotificationPayload
);
const user = await this.userService.findOne(userId);
@@ -206,7 +212,9 @@ export class NotificationProcessor extends WorkerHost {
}
private async sendLineImmediate(user: User, data: NotificationPayload) {
const n8nWebhookUrl = this.configService.get('N8N_LINE_WEBHOOK_URL');
const n8nWebhookUrl = this.configService.get<string>(
'N8N_LINE_WEBHOOK_URL'
);
if (!n8nWebhookUrl) return;
try {
@@ -223,7 +231,9 @@ export class NotificationProcessor extends WorkerHost {
}
private async sendLineDigest(user: User, messages: NotificationPayload[]) {
const n8nWebhookUrl = this.configService.get('N8N_LINE_WEBHOOK_URL');
const n8nWebhookUrl = this.configService.get<string>(
'N8N_LINE_WEBHOOK_URL'
);
if (!n8nWebhookUrl) return;
const summary = messages.map((m, i) => `${i + 1}. ${m.title}`).join('\n');
@@ -9,7 +9,7 @@ import { Repository } from 'typeorm';
// Entities
import { Notification, NotificationType } from './entities/notification.entity';
import { User } from '../user/entities/user.entity';
import { UserPreference } from '../user/entities/user-preference.entity';
import { _UserPreference } from '../user/entities/user-preference.entity';
// Gateway
import { NotificationGateway } from './notification.gateway';
@@ -17,7 +17,7 @@ export class OrganizationRole extends BaseEntity {
name: 'role_name',
length: 20,
unique: true,
comment: 'Role name (OWNER, DESIGNER, CONSULTANT, CONTRACTOR, THIRD_PARTY)'
comment: 'Role name (OWNER, DESIGNER, CONSULTANT, CONTRACTOR, THIRD_PARTY)',
})
roleName!: string;
}
@@ -73,7 +73,7 @@ export class OrganizationService {
const [data, total] = await queryBuilder.getManyAndCount();
// Debug logging
console.log(`[OrganizationService] Found ${total} organizations`);
// console.log(`[OrganizationService] Found ${total} organizations`);
return {
data,
@@ -11,7 +11,7 @@ export class SearchProjectDto {
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
return value as boolean | undefined;
})
isActive?: boolean; // กรองตามสถานะ Active
@@ -46,7 +46,7 @@ describe('ProjectController', () => {
const mockResult = { data: [], meta: {} };
(mockProjectService.findAll as jest.Mock).mockResolvedValue(mockResult);
const result = await controller.findAll({ page: 1, limit: 10 });
const _result = await controller.findAll({ page: 1, limit: 10 });
expect(mockProjectService.findAll).toHaveBeenCalled();
});
@@ -59,7 +59,7 @@ describe('ProjectController', () => {
mockOrgs
);
const result = await controller.findAllOrgs();
const _result = await controller.findAllOrgs();
expect(mockProjectService.findAllOrganizations).toHaveBeenCalled();
});
@@ -61,9 +61,10 @@ describe('ProjectService', () => {
project_name: 'Test Project',
},
];
mockProjectRepository
.createQueryBuilder()
.getManyAndCount.mockResolvedValue([mockProjects, 1]);
const qb = mockProjectRepository.createQueryBuilder() as unknown as {
getManyAndCount: jest.Mock;
};
qb.getManyAndCount.mockResolvedValue([mockProjects, 1]);
const result = await service.findAll({ page: 1, limit: 10 });
@@ -5,7 +5,7 @@ import {
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like } from 'typeorm';
import { Repository, _Like } from 'typeorm';
// Entities
import { Project } from './entities/project.entity';
@@ -34,4 +34,3 @@ export class CreateRfaWorkflowDto {
@IsOptional()
comments?: string;
}
@@ -23,7 +23,7 @@ export class RfaWorkflowTemplate {
isActive!: boolean;
@Column({ type: 'json', nullable: true })
workflowConfig?: Record<string, any>; // Configuration เพิ่มเติม
workflowConfig?: Record<string, unknown>; // Configuration เพิ่มเติม
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@@ -52,7 +52,7 @@ export class RfaWorkflow {
completedAt?: Date;
@Column({ type: 'json', nullable: true })
stateContext?: Record<string, any>;
stateContext?: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@@ -108,7 +108,9 @@ export class RfaWorkflowService {
};
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(`Failed to submit RFA workflow: ${error}`);
this.logger.error(
`Failed to submit RFA workflow: ${error instanceof Error ? error.message : String(error)}`
);
throw error;
} finally {
await queryRunner.release();
@@ -136,11 +138,11 @@ export class RfaWorkflowService {
const instance = await this.workflowEngine.getInstanceById(instanceId);
if (instance && instance.entityType === 'rfa_revision') {
const rfaRev = await this.revisionRepo.findOne({
where: { id: parseInt(instance.entityId) },
where: { id: Number(instance.entityId) },
});
if (rfaRev) {
// เช็คว่า Action นี้มีการระบุ Approve Code มาใน Payload หรือไม่ (เช่น '1A', '3R')
const approveCodeStr = dto.payload?.approveCode;
const approveCodeStr = dto.payload?.approveCode as string | undefined;
await this.syncStatus(rfaRev, result.nextState, approveCodeStr);
}
}
+7 -5
View File
@@ -388,7 +388,7 @@ export class RfaService {
initiatorId: user.user_id,
}
);
} catch (error) {
} catch (error: unknown) {
this.logger.warn(
`Workflow not started for ${docNumber.number}: ${(error as Error).message}`
);
@@ -402,19 +402,21 @@ export class RfaService {
type: 'rfa',
docNumber: docNumber.number,
title: createDto.subject,
description: createDto.description,
description: createDto.description ?? '',
status: 'DRAFT',
projectId: internalProjectId,
createdAt: new Date(),
})
.catch((err) => this.logger.error(`Indexing failed: ${err}`));
.catch((err: unknown) =>
this.logger.error(`Indexing failed: ${(err as Error).message}`)
);
return {
...savedRfa,
correspondenceNumber: docNumber,
currentRevision: savedRevision,
};
} catch (err) {
} catch (err: unknown) {
await queryRunner.rollbackTransaction();
this.logger.error(`Failed to create RFA: ${(err as Error).message}`);
throw err;
@@ -490,7 +492,7 @@ export class RfaService {
);
// Map `revisions` property back to the expected payload for the frontend
const mappedItems: RfaMapped[] = items.map((rfa) => {
const mappedItems: RfaMapped[] = items.map((rfa: Rfa) => {
const revisions =
(rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
return {
@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsInt, IsNotEmpty } from 'class-validator';
import { IsString, IsOptional, IsInt } from 'class-validator';
import { Type } from 'class-transformer';
export class SearchQueryDto {
+3 -3
View File
@@ -60,7 +60,7 @@ export class SearchService implements OnModuleInit {
tags: { type: 'text' },
},
},
} as any,
} as unknown as Record<string, unknown>,
});
this.logger.log(`Elasticsearch index '${this.indexName}' created.`);
}
@@ -149,7 +149,7 @@ export class SearchService implements OnModuleInit {
filter: filterQueries,
},
},
sort: [{ createdAt: { order: 'desc' } }],
sort: [{ createdAt: { order: 'desc' as const } }],
});
// 3. Format Result
@@ -174,7 +174,7 @@ export class SearchService implements OnModuleInit {
this.logger.debug(
`Search query context: ${JSON.stringify({
query: queryDto,
esNode: this.configService.get('ELASTICSEARCH_NODE'),
esNode: String(this.configService.get('ELASTICSEARCH_NODE') ?? ''),
})}`
);
return { data: [], meta: { total: 0, page, limit, took: 0 } };
@@ -1,10 +1,4 @@
import {
IsInt,
IsOptional,
IsString,
IsEnum,
IsUUID,
} from 'class-validator';
import { IsInt, IsOptional, IsString, IsEnum, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
import { TransmittalPurpose } from './create-transmittal.dto';
@@ -15,7 +15,12 @@ import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { User } from '../user/entities/user.entity';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { ProjectService } from '../project/project.service';
@@ -55,7 +60,10 @@ export class TransmittalController {
@Get(':uuid')
@ApiOperation({ summary: 'Get Transmittal details' })
@ApiParam({ name: 'uuid', description: 'Transmittal UUID (from correspondences.uuid)' })
@ApiParam({
name: 'uuid',
description: 'Transmittal UUID (from correspondences.uuid)',
})
@RequirePermission('document.view')
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.transmittalService.findOneByUuid(uuid);
@@ -41,7 +41,10 @@ export class TransmittalService {
private uuidResolver: UuidResolverService
) {}
async create(createDto: CreateTransmittalDto, user: User) {
async create(
createDto: CreateTransmittalDto,
user: User
): Promise<Transmittal & { correspondence: Correspondence }> {
// 1. Get Transmittal Type (Assuming Code '901' or 'TRN')
const type = await this.typeRepo.findOne({
where: { typeCode: 'TRN' }, // Adjust code as per Master Data
@@ -144,7 +147,7 @@ export class TransmittalService {
...savedTransmittal,
correspondence: savedCorr,
};
} catch (err) {
} catch (err: unknown) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Failed to create transmittal: ${(err as Error).message}`
@@ -159,7 +162,7 @@ export class TransmittalService {
* ADR-019: Find Transmittal by parent Correspondence UUID (public identifier).
* Resolves correspondence.uuid → internal correspondenceId (INT)
*/
async findOneByUuid(uuid: string) {
async findOneByUuid(uuid: string): Promise<Transmittal> {
const correspondence = await this.dataSource.manager.findOne(
Correspondence,
{ where: { uuid }, select: ['id'] }
@@ -167,9 +170,10 @@ export class TransmittalService {
if (!correspondence) {
throw new NotFoundException(`Transmittal with UUID ${uuid} not found`);
}
return this.findOne(correspondence.id);
}
async findOne(id: number) {
async findOne(id: number): Promise<Transmittal> {
const transmittal = await this.transmittalRepo.findOne({
where: { correspondenceId: id },
relations: ['correspondence', 'correspondence.revisions', 'items'],
@@ -1,4 +1,4 @@
import { IsInt, IsNotEmpty, IsOptional, ValidateIf } from 'class-validator';
import { IsInt, IsNotEmpty, IsOptional, _ValidateIf } from 'class-validator';
export class AssignRoleDto {
@IsInt()
@@ -11,7 +11,7 @@ import { UpdatePreferenceDto } from './dto/update-preference.dto';
export class UserPreferenceService {
constructor(
@InjectRepository(UserPreference)
private prefRepo: Repository<UserPreference>,
private prefRepo: Repository<UserPreference>
) {}
// ดึง Preference ของ User (ถ้าไม่มีให้สร้าง Default)
@@ -35,7 +35,7 @@ export class UserPreferenceService {
// อัปเดต Preference
async update(
userId: number,
dto: UpdatePreferenceDto,
dto: UpdatePreferenceDto
): Promise<UserPreference> {
const pref = await this.findByUser(userId);
+6 -8
View File
@@ -18,7 +18,6 @@ import { Permission } from './entities/permission.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { SearchUserDto } from './dto/search-user.dto';
import { Organization } from '../organization/entities/organization.entity';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
@Injectable()
@@ -242,14 +241,13 @@ export class UserService {
}
// 2. ถ้าไม่มีใน Cache ให้ Query จาก DB (View: v_user_all_permissions)
const permissions = await this.usersRepository.query(
`SELECT permission_name FROM v_user_all_permissions WHERE user_id = ?`,
[userId]
);
const permissions = await this.usersRepository.query<
{ permission_name: string }[]
>(`SELECT permission_name FROM v_user_all_permissions WHERE user_id = ?`, [
userId,
]);
const permissionList = permissions.map(
(row: { permission_name: string }) => row.permission_name
);
const permissionList = permissions.map((row) => row.permission_name);
// 3. บันทึกลง Cache (TTL 1800 วินาที = 30 นาที)
await this.cacheManager.set(cacheKey, permissionList, 1800 * 1000);
@@ -21,7 +21,7 @@ export class WorkflowDslParser {
async parse(dslJson: string): Promise<WorkflowDefinition> {
try {
// Step 1: Parse JSON
const rawDsl = JSON.parse(dslJson);
const rawDsl = JSON.parse(dslJson) as unknown;
// Step 2: Validate with Zod schema
const dsl = WorkflowDslSchema.parse(rawDsl);
@@ -139,7 +139,7 @@ export class WorkflowDslParser {
const definition = new WorkflowDefinition();
definition.workflow_code = dsl.name;
// Map Semver (1.0.0) to version int (1)
const majorVersion = parseInt(dsl.version.split('.')[0], 10);
const majorVersion = Number(dsl.version.split('.')[0]);
definition.version = isNaN(majorVersion) ? 1 : majorVersion;
definition.description = dsl.description;
definition.dsl = dsl;
@@ -182,7 +182,7 @@ export class WorkflowDslParser {
*/
validateOnly(dslJson: string): { valid: boolean; errors?: string[] } {
try {
const rawDsl = JSON.parse(dslJson);
const rawDsl = JSON.parse(dslJson) as unknown;
const dsl = WorkflowDslSchema.parse(rawDsl);
this.validateStateMachine(dsl);
return { valid: true };
@@ -21,5 +21,5 @@ export class EvaluateWorkflowDto {
@ApiProperty({ description: 'Context', example: { userId: 1 } })
@IsObject()
@IsOptional()
context?: Record<string, any>;
context?: Record<string, unknown>;
}
@@ -6,5 +6,5 @@ import { CreateWorkflowDefinitionDto } from './create-workflow-definition.dto';
// PartialType จะทำให้ทุก field ใน CreateDto กลายเป็น Optional (?)
// เหมาะสำหรับ PATCH method
export class UpdateWorkflowDefinitionDto extends PartialType(
CreateWorkflowDefinitionDto,
CreateWorkflowDefinitionDto
) {}
@@ -26,5 +26,5 @@ export class WorkflowTransitionDto {
})
@IsObject()
@IsOptional()
payload?: Record<string, any>;
payload?: Record<string, unknown>;
}
@@ -54,7 +54,7 @@ export class WorkflowHistory {
nullable: true,
comment: 'Snapshot of Context or Metadata',
})
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@@ -70,7 +70,7 @@ export class WorkflowInstance {
// Context: เก็บตัวแปรที่จำเป็นสำหรับการตัดสินใจใน Workflow
// เช่น { "amount": 500000, "requester_role": "ENGINEER", "approver_ids": [1, 2] }
@Column({ type: 'json', nullable: true, comment: 'Runtime Context Data' })
context?: Record<string, any>;
context?: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@@ -255,7 +255,9 @@ export class WorkflowDslService {
// Create a function that returns the expression result
// "context" is available inside the expression
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const func = new Function('context', `return ${expression};`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
return !!func(context);
} catch (error: unknown) {
this.logger.error(
@@ -115,7 +115,7 @@ export class WorkflowEngineController {
summary: 'ดึงรายการปุ่ม Action ที่สามารถกดได้ ณ สถานะปัจจุบัน',
})
@RequirePermission('document.view') // ผู้ที่มีสิทธิ์ดูเอกสาร ควรดู Action ได้
async getAvailableActions(@Param('id') instanceId: string) {
getAvailableActions(@Param('id') _instanceId: string) {
// Note: Logic การดึง Action ตาม Instance ID จะถูก Implement ใน Task ถัดไป
return { message: 'Pending implementation in Service layer' };
}
@@ -90,9 +90,9 @@ export class WorkflowEngineService {
const saved = await this.workflowDefRepo.save(entity);
this.logger.log(
`Created Workflow Definition: ${(saved as WorkflowDefinition).workflow_code} v${(saved as WorkflowDefinition).version}`
`Created Workflow Definition: ${saved.workflow_code} v${saved.version}`
);
return saved as WorkflowDefinition;
return saved;
}
/**
@@ -258,7 +258,7 @@ export class WorkflowEngineService {
action: string,
userId: number,
comment?: string,
payload: Record<string, any> = {}
payload: Record<string, unknown> = {}
) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
@@ -341,7 +341,7 @@ export class WorkflowEngineService {
// [NEW] Dispatch Events (Async) ผ่าน WorkflowEventService
if (eventsToDispatch && eventsToDispatch.length > 0) {
this.eventService.dispatchEvents(
void this.eventService.dispatchEvents(
instance.id,
eventsToDispatch,
updatedContext
@@ -368,7 +368,7 @@ export class WorkflowEngineService {
/**
* (Utility) Evaluate แบบไม่บันทึกผล (Dry Run) สำหรับ Test หรือ Preview
*/
async evaluate(dto: EvaluateWorkflowDto): Promise<any> {
async evaluate(dto: EvaluateWorkflowDto): Promise<unknown> {
const definition = await this.workflowDefRepo.findOne({
where: { workflow_code: dto.workflow_code, is_active: true },
order: { version: 'DESC' },
@@ -401,9 +401,8 @@ export class WorkflowEngineService {
action: string,
returnToSequence?: number
): TransitionResult {
switch (action) {
case WorkflowAction.APPROVE:
case WorkflowAction.ACKNOWLEDGE:
const act = action.toUpperCase();
switch (act) {
case 'APPROVE':
case 'ACKNOWLEDGE':
if (currentSequence >= totalSteps) {
@@ -418,7 +417,6 @@ export class WorkflowEngineService {
shouldUpdateStatus: false,
};
case WorkflowAction.REJECT:
case 'REJECT':
return {
nextStepSequence: null,
@@ -426,8 +424,7 @@ export class WorkflowEngineService {
documentStatus: 'REJECTED',
};
case WorkflowAction.RETURN:
case 'RETURN':
case 'RETURN': {
const targetStep = returnToSequence || currentSequence - 1;
if (targetStep < 1) {
throw new BadRequestException('Cannot return beyond the first step');
@@ -437,6 +434,7 @@ export class WorkflowEngineService {
shouldUpdateStatus: true,
documentStatus: 'REVISE_REQUIRED',
};
}
default:
this.logger.warn(
@@ -25,10 +25,10 @@ export class WorkflowEventService {
/**
* ประมวลผลรายการ Events ที่เกิดจากการเปลี่ยนสถานะ
*/
async dispatchEvents(
dispatchEvents(
instanceId: string,
events: RawEvent[],
context: Record<string, any>
context: Record<string, unknown>
) {
if (!events || events.length === 0) return;
@@ -37,13 +37,15 @@ export class WorkflowEventService {
);
// ทำแบบ Async ไม่รอผล (Fire-and-forget) เพื่อไม่ให้กระทบ Response Time ของ User
Promise.allSettled(
void Promise.allSettled(
events.map((event) => this.processSingleEvent(instanceId, event, context))
).then((results) => {
// Log errors if any
results.forEach((res, idx) => {
if (res.status === 'rejected') {
this.logger.error(`Failed to process event [${idx}]: ${res.reason}`);
this.logger.error(
`Failed to process event [${idx}]: ${String(res.reason)}`
);
}
});
});
@@ -54,13 +56,14 @@ export class WorkflowEventService {
event: RawEvent,
context: Record<string, unknown>
) {
await Promise.resolve();
try {
switch (event.type) {
case 'notify':
await this.handleNotify(event, context);
this.handleNotify(event, context);
break;
case 'webhook':
await this.handleWebhook(event, context);
this.handleWebhook(event, context);
break;
case 'auto_action':
// Logic สำหรับ Auto Transition (เช่น ถ้าผ่านเงื่อนไข ให้ไปต่อเลย)
@@ -70,17 +73,16 @@ export class WorkflowEventService {
this.logger.warn(`Unknown event type: ${event.type}`);
}
} catch (error) {
this.logger.error(`Error processing event ${event.type}: ${error}`);
this.logger.error(
`Error processing event ${event.type}: ${String(error)}`
);
throw error;
}
}
// --- Handlers ---
private async handleNotify(
event: RawEvent,
_context: Record<string, unknown>
) {
private handleNotify(event: RawEvent, _context: Record<string, unknown>) {
// Mockup: ในของจริงจะเรียก NotificationService.send()
// const recipients = this.resolveRecipients(event.target, context);
this.logger.log(
@@ -88,10 +90,7 @@ export class WorkflowEventService {
);
}
private async handleWebhook(
event: RawEvent,
_context: Record<string, unknown>
) {
private handleWebhook(event: RawEvent, _context: Record<string, unknown>) {
// Mockup: เรียก HttpService.post()
this.logger.log(
`[EVENT] Webhook to: "${event.target}" | Payload: ${JSON.stringify(event.payload)}`