260320:1131 Refactor Overrall #01
Build and Deploy / deploy (push) Has been cancelled

This commit is contained in:
admin
2026-03-20 11:31:27 +07:00
parent f1b81a7d0d
commit 1d3479770b
147 changed files with 1745 additions and 1567 deletions
@@ -11,7 +11,16 @@ export class AuditLogController {
@Get()
@RequirePermission('audit-log.view')
findAll(@Query() query: any) {
findAll(
@Query()
query: {
page?: number;
limit?: number;
entityName?: string;
action?: string;
userId?: number;
}
) {
return this.auditLogService.findAll(query);
}
}
@@ -10,7 +10,13 @@ export class AuditLogService {
private readonly auditLogRepository: Repository<AuditLog>
) {}
async findAll(query: any) {
async findAll(query: {
page?: number;
limit?: number;
entityName?: string;
action?: string;
userId?: number;
}) {
const { page = 1, limit = 20, entityName, action, userId } = query;
const skip = (page - 1) * limit;
@@ -25,7 +25,7 @@ export class CirculationWorkflowService {
private readonly circulationRepo: Repository<Circulation>,
@InjectRepository(CirculationStatusCode)
private readonly statusRepo: Repository<CirculationStatusCode>,
private readonly dataSource: DataSource,
private readonly dataSource: DataSource
) {}
/**
@@ -44,7 +44,7 @@ export class CirculationWorkflowService {
if (!circulation) {
throw new NotFoundException(
`Circulation ID ${circulationId} not found`,
`Circulation ID ${circulationId} not found`
);
}
@@ -59,7 +59,7 @@ export class CirculationWorkflowService {
this.WORKFLOW_CODE,
'circulation',
circulation.id.toString(),
context,
context
);
// Auto start (OPEN -> IN_REVIEW)
@@ -68,14 +68,14 @@ export class CirculationWorkflowService {
'START',
userId,
'Start Circulation Process',
{},
{}
);
// Sync Status
await this.syncStatus(
circulation,
transitionResult.nextState,
queryRunner,
queryRunner
);
await queryRunner.commitTransaction();
@@ -99,7 +99,7 @@ export class CirculationWorkflowService {
async processAction(
instanceId: string,
userId: number,
dto: WorkflowTransitionDto,
dto: WorkflowTransitionDto
) {
// ส่งให้ Engine
const result = await this.workflowEngine.processTransition(
@@ -107,7 +107,7 @@ export class CirculationWorkflowService {
dto.action,
userId,
dto.comment,
dto.payload,
dto.payload
);
// Sync Status กลับ
@@ -130,7 +130,7 @@ export class CirculationWorkflowService {
private async syncStatus(
circulation: Circulation,
workflowState: string,
queryRunner?: any,
queryRunner?: import('typeorm').QueryRunner
) {
const statusMap: Record<string, string> = {
DRAFT: 'OPEN',
@@ -158,7 +158,7 @@ export class CirculationWorkflowService {
await manager.save(circulation);
this.logger.log(
`Synced Circulation #${circulation.id}: State=${workflowState} -> Status=${targetCode}`,
`Synced Circulation #${circulation.id}: State=${workflowState} -> Status=${targetCode}`
);
}
}
@@ -14,8 +14,7 @@ import { CreateCirculationDto } from './dto/create-circulation.dto';
import { UpdateCirculationRoutingDto } from './dto/update-circulation-routing.dto';
import { SearchCirculationDto } from './dto/search-circulation.dto';
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
import { Project } from '../project/entities/project.entity';
import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
@Injectable()
export class CirculationService {
@@ -25,61 +24,10 @@ export class CirculationService {
@InjectRepository(CirculationRouting)
private routingRepo: Repository<CirculationRouting>,
private numberingService: DocumentNumberingService,
private dataSource: DataSource
private dataSource: DataSource,
private uuidResolver: UuidResolverService
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.dataSource.manager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* ADR-019: Resolve correspondenceId (INT or UUID string) to internal INT ID
*/
private async resolveCorrespondenceId(
corrId: number | string
): Promise<number> {
if (typeof corrId === 'number') return corrId;
const num = Number(corrId);
if (!isNaN(num)) return num;
const corr = await this.dataSource.manager.findOne(Correspondence, {
where: { uuid: corrId },
select: ['id'],
});
if (!corr)
throw new NotFoundException(
`Correspondence with UUID ${corrId} not found`
);
return corr.id;
}
/**
* ADR-019: Resolve userId (INT or UUID string) to internal user_id
*/
private async resolveUserId(userId: number | string): Promise<number> {
if (typeof userId === 'number') return userId;
const num = Number(userId);
if (!isNaN(num)) return num;
const user = await this.dataSource.manager.findOne(User, {
where: { uuid: userId },
select: ['user_id'],
});
if (!user)
throw new NotFoundException(`User with UUID ${userId} not found`);
return user.user_id;
}
async create(createDto: CreateCirculationDto, user: User) {
if (!user.primaryOrganizationId) {
throw new BadRequestException('User must belong to an organization');
@@ -92,13 +40,13 @@ export class CirculationService {
try {
// ADR-019: Resolve UUID references to internal INT IDs
const resolvedProjectId = createDto.projectId
? await this.resolveProjectId(createDto.projectId)
? await this.uuidResolver.resolveProjectId(createDto.projectId)
: 0;
const resolvedCorrId = await this.resolveCorrespondenceId(
const resolvedCorrId = await this.uuidResolver.resolveCorrespondenceId(
createDto.correspondenceId
);
const resolvedAssigneeIds = await Promise.all(
createDto.assigneeIds.map((id) => this.resolveUserId(id))
createDto.assigneeIds.map((id) => this.uuidResolver.resolveUserId(id))
);
// Generate No. using DocumentNumberingService (Type 900 - Circulation)
@@ -3,45 +3,26 @@ import {
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
import { Repository, Like, EntityManager } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, FindOptionsWhere, FindManyOptions } from 'typeorm';
import { Contract } from './entities/contract.entity';
import { CreateContractDto } from './dto/create-contract.dto.js';
import { UpdateContractDto } from './dto/update-contract.dto.js';
import { Project } from '../project/entities/project.entity';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
@Injectable()
export class ContractService {
constructor(
@InjectRepository(Contract)
private readonly contractRepo: Repository<Contract>,
@InjectEntityManager()
private readonly entityManager: EntityManager
private readonly uuidResolver: UuidResolverService
) {}
/**
* Helper to resolve projectId (ID or UUID) to internal INT ID
*/
async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.entityManager.findOne(Project, {
where: { uuid: projectId as string },
select: ['id'],
});
if (!project) {
throw new NotFoundException(`Project with UUID ${projectId} not found`);
}
return project.id;
}
async create(dto: CreateContractDto) {
const internalProjectId = await this.resolveProjectId(dto.projectId);
const internalProjectId = await this.uuidResolver.resolveProjectId(
dto.projectId
);
const existing = await this.contractRepo.findOne({
where: { contractCode: dto.contractCode },
});
@@ -50,28 +31,35 @@ export class ContractService {
`Contract Code "${dto.contractCode}" already exists`
);
}
const contract = this.contractRepo.create({ ...dto, projectId: internalProjectId });
const contract = this.contractRepo.create({
...dto,
projectId: internalProjectId,
});
return this.contractRepo.save(contract);
}
async findAll(params?: any) {
async findAll(params?: {
search?: string;
projectId?: number | string;
page?: number;
limit?: number;
}) {
const { search, projectId, page = 1, limit = 100 } = params || {};
const skip = (page - 1) * limit;
let internalProjectId = undefined;
let internalProjectId: number | undefined = undefined;
if (projectId) {
internalProjectId = await this.resolveProjectId(projectId);
internalProjectId = await this.uuidResolver.resolveProjectId(projectId);
}
const findOptions: any = {
const findOptions: FindManyOptions<Contract> = {
relations: ['project'],
order: { contractCode: 'ASC' },
skip,
take: limit,
where: [],
};
const searchConditions = [];
const searchConditions: FindOptionsWhere<Contract>[] = [];
if (search) {
searchConditions.push({ contractCode: Like(`%${search}%`) });
searchConditions.push({ contractName: Like(`%${search}%`) });
@@ -86,12 +74,8 @@ export class ContractService {
} else {
findOptions.where = { projectId: internalProjectId };
}
} else {
if (searchConditions.length > 0) {
findOptions.where = searchConditions;
} else {
delete findOptions.where;
}
} else if (searchConditions.length > 0) {
findOptions.where = searchConditions;
}
const [data, total] = await this.contractRepo.findAndCount(findOptions);
@@ -129,7 +113,7 @@ export class ContractService {
async update(uuid: string, dto: UpdateContractDto) {
const contract = await this.findOneByUuid(uuid);
if (dto.projectId) {
dto.projectId = await this.resolveProjectId(dto.projectId);
dto.projectId = await this.uuidResolver.resolveProjectId(dto.projectId);
}
Object.assign(contract, dto);
return this.contractRepo.save(contract);
@@ -126,7 +126,7 @@ export class CorrespondenceWorkflowService {
private async syncStatus(
revision: CorrespondenceRevision,
workflowState: string,
queryRunner?: any
queryRunner?: import('typeorm').QueryRunner
) {
const statusMap: Record<string, string> = {
DRAFT: 'DRAFT',
@@ -35,7 +35,7 @@ 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 { Project } from '../project/entities/project.entity';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
/**
* CorrespondenceService - Document management (CRUD)
@@ -58,60 +58,30 @@ export class CorrespondenceService {
private statusRepo: Repository<CorrespondenceStatus>,
@InjectRepository(CorrespondenceReference)
private referenceRepo: Repository<CorrespondenceReference>,
@InjectRepository(Organization)
private orgRepo: Repository<Organization>,
private numberingService: DocumentNumberingService,
private jsonSchemaService: JsonSchemaService,
private workflowEngine: WorkflowEngineService,
private userService: UserService,
private dataSource: DataSource,
private searchService: SearchService,
private fileStorageService: FileStorageService
private fileStorageService: FileStorageService,
private uuidResolver: UuidResolverService
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.dataSource.manager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* ADR-019: Resolve organizationId (INT or UUID string) to internal INT ID
*/
private async resolveOrganizationId(orgId: number | string): Promise<number> {
if (typeof orgId === 'number') return orgId;
const num = Number(orgId);
if (!isNaN(num)) return num;
const org = await this.orgRepo.findOne({
where: { uuid: orgId },
select: ['id'],
});
if (!org)
throw new NotFoundException(`Organization with UUID ${orgId} not found`);
return org.id;
}
async create(createDto: CreateCorrespondenceDto, user: User) {
// ADR-019: Resolve UUID references to internal INT IDs
const resolvedProjectId = await this.resolveProjectId(createDto.projectId);
const resolvedProjectId = await this.uuidResolver.resolveProjectId(
createDto.projectId
);
const resolvedOriginatorId = createDto.originatorId
? await this.resolveOrganizationId(createDto.originatorId)
? await this.uuidResolver.resolveOrganizationId(createDto.originatorId)
: undefined;
const resolvedRecipients = createDto.recipients
? await Promise.all(
createDto.recipients.map(async (r) => ({
organizationId: await this.resolveOrganizationId(r.organizationId),
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
}))
)
@@ -174,9 +144,12 @@ export class CorrespondenceService {
try {
// [Fix #6] Fetch real ORG Code from Organization entity
const originatorOrg = await this.orgRepo.findOne({
where: { id: userOrgId },
});
const originatorOrg = await this.dataSource.manager.findOne(
Organization,
{
where: { id: userOrgId },
}
);
const orgCode = originatorOrg?.organizationCode ?? 'UNK';
// [v1.5.1] Extract recipient organization from recipients array (Primary TO)
@@ -185,7 +158,7 @@ export class CorrespondenceService {
let recipientCode = '';
if (recipientOrganizationId) {
const recOrg = await this.orgRepo.findOne({
const recOrg = await this.dataSource.manager.findOne(Organization, {
where: { id: recipientOrganizationId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
@@ -508,15 +481,17 @@ export class CorrespondenceService {
// ADR-019: Resolve UUID references in update DTO
const updResolvedProjectId = updateDto.projectId
? await this.resolveProjectId(updateDto.projectId)
? await this.uuidResolver.resolveProjectId(updateDto.projectId)
: undefined;
const updResolvedOriginatorId = updateDto.originatorId
? await this.resolveOrganizationId(updateDto.originatorId)
? await this.uuidResolver.resolveOrganizationId(updateDto.originatorId)
: undefined;
const updResolvedRecipients = updateDto.recipients
? await Promise.all(
updateDto.recipients.map(async (r) => ({
organizationId: await this.resolveOrganizationId(r.organizationId),
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
}))
)
@@ -642,18 +617,21 @@ export class CorrespondenceService {
// Resolve Recipient Code for the NEW context
let recipientCode = '';
if (targetRecipientId) {
const recOrg = await this.orgRepo.findOne({
const recOrg = await this.dataSource.manager.findOne(Organization, {
where: { id: targetRecipientId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
}
// [Fix #6] Fetch real ORG Code from originator organization
const originatorOrgForUpdate = await this.orgRepo.findOne({
where: {
id: updResolvedOriginatorId ?? currentCorr.originatorId ?? 0,
},
});
const originatorOrgForUpdate = await this.dataSource.manager.findOne(
Organization,
{
where: {
id: updResolvedOriginatorId ?? currentCorr.originatorId ?? 0,
},
}
);
const orgCode = originatorOrgForUpdate?.organizationCode ?? 'UNK';
// Prepare Contexts
@@ -708,14 +686,18 @@ export class CorrespondenceService {
async previewDocumentNumber(createDto: CreateCorrespondenceDto, user: User) {
// ADR-019: Resolve UUID references
const previewProjectId = await this.resolveProjectId(createDto.projectId);
const previewProjectId = await this.uuidResolver.resolveProjectId(
createDto.projectId
);
const previewOriginatorId = createDto.originatorId
? await this.resolveOrganizationId(createDto.originatorId)
? await this.uuidResolver.resolveOrganizationId(createDto.originatorId)
: undefined;
const previewRecipients = createDto.recipients
? await Promise.all(
createDto.recipients.map(async (r) => ({
organizationId: await this.resolveOrganizationId(r.organizationId),
organizationId: await this.uuidResolver.resolveOrganizationId(
r.organizationId
),
type: r.type,
}))
)
@@ -743,7 +725,7 @@ export class CorrespondenceService {
let recipientCode = '';
if (recipientOrganizationId) {
const recOrg = await this.orgRepo.findOne({
const recOrg = await this.dataSource.manager.findOne(Organization, {
where: { id: recipientOrganizationId },
});
if (recOrg) recipientCode = recOrg.organizationCode;
@@ -52,7 +52,7 @@ export class CorrespondenceRouting {
// ✅ [New] เพิ่ม State Context เพื่อเก็บ Snapshot ข้อมูล Workflow ณ จุดนั้น
@Column({ name: 'state_context', type: 'json', nullable: true })
stateContext?: any;
stateContext?: Record<string, unknown>;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@@ -15,6 +15,9 @@ import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../../common/guards/rbac.guard';
import { RequirePermission } from '../../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { User } from '../../user/entities/user.entity';
import { DocumentNumberFormat } from '../entities/document-number-format.entity';
import { ManualOverrideDto } from '../dto/manual-override.dto';
@ApiTags('Admin / Document Numbering')
@ApiBearerAuth()
@@ -40,7 +43,9 @@ export class DocumentNumberingAdminController {
@Post('templates')
@ApiOperation({ summary: 'Create or Update a numbering template' })
@RequirePermission('system.manage_settings')
async saveTemplate(@Body() dto: any) {
async saveTemplate(
@Body() dto: Partial<DocumentNumberFormat> & { projectId?: number | string }
) {
return this.service.saveTemplate(dto);
}
@@ -74,28 +79,48 @@ export class DocumentNumberingAdminController {
summary: 'Manually override or set a document number counter',
})
@RequirePermission('system.manage_settings')
async manualOverride(@Body() dto: any, @CurrentUser() user: any) {
return this.service.manualOverride(dto, user.userId);
async manualOverride(
@Body() dto: ManualOverrideDto,
@CurrentUser() user: User
) {
return this.service.manualOverride(dto, user.user_id);
}
@Post('void-and-replace')
@ApiOperation({ summary: 'Void a number and replace with a new generation' })
@RequirePermission('system.manage_settings')
async voidAndReplace(@Body() dto: any) {
async voidAndReplace(
@Body()
dto: {
documentNumber: string;
reason: string;
replace: boolean;
projectId?: number;
typeId?: number;
}
) {
return this.service.voidAndReplace(dto);
}
@Post('cancel')
@ApiOperation({ summary: 'Cancel/Skip a specific document number' })
@RequirePermission('system.manage_settings')
async cancelNumber(@Body() dto: any) {
async cancelNumber(
@Body()
dto: {
documentNumber: string;
reason: string;
projectId?: number;
typeId?: number;
}
) {
return this.service.cancelNumber(dto);
}
@Post('bulk-import')
@ApiOperation({ summary: 'Bulk import/set document number counters' })
@RequirePermission('system.manage_settings')
async bulkImport(@Body() items: any[]) {
async bulkImport(@Body() items: ManualOverrideDto[]) {
return this.service.bulkImport(items);
}
}
@@ -28,7 +28,7 @@ export class ReserveNumberDto {
@IsObject()
@IsOptional()
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
}
export class ReserveNumberResponseDto {
@@ -25,7 +25,7 @@ export class DocumentNumberAudit {
documentNumber!: string;
@Column({ name: 'counter_key', type: 'json' })
counterKey!: any;
counterKey!: Record<string, unknown> | unknown;
@Column({ name: 'template_used', length: 200 })
templateUsed!: string;
@@ -73,7 +73,7 @@ export class DocumentNumberAudit {
newValue?: string;
@Column({ name: 'metadata', type: 'json', nullable: true })
metadata?: any;
metadata?: Record<string, unknown>;
@Column({ name: 'user_id', nullable: true })
userId?: number;
@@ -38,7 +38,7 @@ export class DocumentNumberError {
stackTrace?: string;
@Column({ name: 'context_data', type: 'json', nullable: true })
contextData?: any;
contextData?: Record<string, unknown>;
@Column({ name: 'user_id', nullable: true })
userId?: number;
@@ -93,5 +93,5 @@ export class DocumentNumberReservation {
userAgent!: string | null;
@Column({ type: 'json', nullable: true })
metadata!: any | null;
metadata!: Record<string, unknown> | null;
}
@@ -23,10 +23,16 @@ import { MetricsService } from './metrics.service';
// DTOs
import { CounterKeyDto } from '../dto/counter-key.dto';
import { GenerateNumberContext } from '../interfaces/document-numbering.interface';
import { ReserveNumberDto } from '../dto/reserve-number.dto';
import { ConfirmReservationDto } from '../dto/confirm-reservation.dto';
import { Project } from '../../project/entities/project.entity';
import { Organization } from '../../organization/entities/organization.entity';
import {
ReserveNumberDto,
ReserveNumberResponseDto,
} from '../dto/reserve-number.dto';
import {
ConfirmReservationDto,
ConfirmReservationResponseDto,
} from '../dto/confirm-reservation.dto';
import { ManualOverrideDto } from '../dto/manual-override.dto';
import { UuidResolverService } from '../../../common/services/uuid-resolver.service';
@Injectable()
export class DocumentNumberingService {
@@ -48,25 +54,10 @@ export class DocumentNumberingService {
private manualOverrideService: ManualOverrideService,
private metricsService: MetricsService,
@InjectEntityManager()
private entityManager: EntityManager
private entityManager: EntityManager,
private uuidResolver: UuidResolverService
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.entityManager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* ADR-019: Public facade for controllers to resolve project/organization IDs
*/
@@ -74,24 +65,8 @@ export class DocumentNumberingService {
type: 'project' | 'organization',
id: number | string
): Promise<number> {
if (type === 'project') return this.resolveProjectId(id);
return this.resolveOrganizationId(id);
}
/**
* ADR-019: Resolve organizationId (INT or UUID string) to internal INT ID
*/
private async resolveOrganizationId(orgId: number | string): Promise<number> {
if (typeof orgId === 'number') return orgId;
const num = Number(orgId);
if (!isNaN(num)) return num;
const org = await this.entityManager.findOne(Organization, {
where: { uuid: orgId },
select: ['id'],
});
if (!org)
throw new NotFoundException(`Organization with UUID ${orgId} not found`);
return org.id;
if (type === 'project') return this.uuidResolver.resolveProjectId(id);
return this.uuidResolver.resolveOrganizationId(id);
}
async generateNextNumber(
@@ -176,7 +151,7 @@ export class DocumentNumberingService {
});
return { number: documentNumber, auditId: audit.id };
} catch (error: any) {
} catch (error: unknown) {
await this.logError(error, ctx, 'GENERATE');
throw error;
} finally {
@@ -190,7 +165,7 @@ export class DocumentNumberingService {
dto: ReserveNumberDto,
userId: number,
ipAddress?: string
): Promise<any> {
): Promise<ReserveNumberResponseDto> {
try {
// Delegate completely to ReservationService
return await this.reservationService.reserve(
@@ -199,7 +174,7 @@ export class DocumentNumberingService {
ipAddress || '0.0.0.0',
'Unknown' // userAgent not passed in legacy call
);
} catch (error: any) {
} catch (error: unknown) {
this.logger.error('Reservation failed', error);
throw error;
}
@@ -208,7 +183,7 @@ export class DocumentNumberingService {
async confirmReservation(
dto: ConfirmReservationDto,
userId: number
): Promise<any> {
): Promise<ConfirmReservationResponseDto> {
return this.reservationService.confirm(dto, userId);
}
@@ -273,16 +248,18 @@ export class DocumentNumberingService {
}
async getTemplatesByProject(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId);
const internalId = await this.uuidResolver.resolveProjectId(projectId);
return this.formatRepo.find({
where: { projectId: internalId },
relations: ['project', 'correspondenceType'],
});
}
async saveTemplate(dto: any) {
async saveTemplate(
dto: Partial<DocumentNumberFormat> & { projectId?: number | string }
) {
if (dto.projectId) {
dto.projectId = await this.resolveProjectId(dto.projectId);
dto.projectId = await this.uuidResolver.resolveProjectId(dto.projectId);
}
return this.formatRepo.save(dto);
}
@@ -312,7 +289,7 @@ export class DocumentNumberingService {
);
}
async manualOverride(dto: any, userId: number) {
async manualOverride(dto: ManualOverrideDto, userId: number) {
return this.manualOverrideService.applyOverride(dto, userId);
}
async voidAndReplace(dto: {
@@ -433,7 +410,7 @@ export class DocumentNumberingService {
return { status: 'CANCELLED' };
}
async bulkImport(items: any[]) {
async bulkImport(items: ManualOverrideDto[]) {
const results = { success: 0, failed: 0, errors: [] as string[] };
// items expected to be ManualOverrideDto[] or similar
@@ -464,15 +441,32 @@ export class DocumentNumberingService {
return results;
}
private async logAudit(data: any): Promise<DocumentNumberAudit> {
private async logAudit(data: {
documentNumber: string;
counterKey: unknown;
templateUsed: string;
context: { projectId?: number; userId?: number; ipAddress?: string };
isSuccess: boolean;
operation: string;
status?: string;
oldValue?: string;
newValue?: string;
metadata?: Record<string, unknown>;
}): Promise<DocumentNumberAudit> {
const audit = this.auditRepo.create({
...data,
projectId: data.context.projectId,
createdBy: data.context.userId,
documentNumber: data.documentNumber,
counterKey: data.counterKey,
templateUsed: data.templateUsed,
isSuccess: data.isSuccess,
operation: data.operation,
status: data.status,
oldValue: data.oldValue,
newValue: data.newValue,
metadata: data.metadata,
userId: data.context.userId,
ipAddress: data.context.ipAddress,
// map other fields
});
return (await this.auditRepo.save(audit)) as unknown as DocumentNumberAudit;
return this.auditRepo.save(audit);
}
private mapErrorType(error: Error): string {
@@ -13,7 +13,6 @@ import { AsBuiltDrawingRevision } from './entities/asbuilt-drawing-revision.enti
import { ShopDrawingRevision } from './entities/shop-drawing-revision.entity';
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
import { User } from '../user/entities/user.entity';
import { Project } from '../project/entities/project.entity';
// DTOs
import { CreateAsBuiltDrawingDto } from './dto/create-asbuilt-drawing.dto';
@@ -22,6 +21,7 @@ import { SearchAsBuiltDrawingDto } from './dto/search-asbuilt-drawing.dto';
// Services
import { FileStorageService } from '../../common/file-storage/file-storage.service';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
@Injectable()
export class AsBuiltDrawingService {
@@ -37,25 +37,10 @@ export class AsBuiltDrawingService {
@InjectRepository(Attachment)
private attachmentRepo: Repository<Attachment>,
private fileStorageService: FileStorageService,
private dataSource: DataSource
private dataSource: DataSource,
private uuidResolver: UuidResolverService
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.dataSource.manager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* สร้าง AS Built Drawing ใหม่ พร้อม Revision แรก (Rev 0)
*/
@@ -91,7 +76,7 @@ export class AsBuiltDrawingService {
}
// ADR-019: Resolve UUID→INT
const internalProjectId = await this.resolveProjectId(
const internalProjectId = await this.uuidResolver.resolveProjectId(
createDto.projectId
);
@@ -12,7 +12,6 @@ import { ContractDrawing } from './entities/contract-drawing.entity';
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
import { User } from '../user/entities/user.entity';
import { Contract } from '../contract/entities/contract.entity';
import { Project } from '../project/entities/project.entity';
// DTOs
import { CreateContractDrawingDto } from './dto/create-contract-drawing.dto';
@@ -21,6 +20,7 @@ import { UpdateContractDrawingDto } from './dto/update-contract-drawing.dto';
// Services
import { FileStorageService } from '../../common/file-storage/file-storage.service';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
@Injectable()
export class ContractDrawingService {
@@ -34,25 +34,10 @@ export class ContractDrawingService {
@InjectRepository(Contract)
private contractRepo: Repository<Contract>,
private fileStorageService: FileStorageService,
private dataSource: DataSource
private dataSource: DataSource,
private uuidResolver: UuidResolverService
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.dataSource.manager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* Resolve issueDate from contract.startDate for file storage path
* Fallback: contract.startDate → current date
@@ -72,7 +57,9 @@ export class ContractDrawingService {
*/
async create(createDto: CreateContractDrawingDto, user: User) {
// ADR-019: Resolve UUID→INT for projectId
const internalProjectId = await this.resolveProjectId(createDto.projectId);
const internalProjectId = await this.uuidResolver.resolveProjectId(
createDto.projectId
);
// 1. ตรวจสอบเลขที่แบบซ้ำ (Unique per Project)
const exists = await this.drawingRepo.findOne({
@@ -19,6 +19,12 @@ import {
} from '@nestjs/swagger';
import { DrawingMasterDataService } from './drawing-master-data.service';
import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity';
import { ContractDrawingCategory } from './entities/contract-drawing-category.entity';
import { ContractDrawingSubCategory } from './entities/contract-drawing-sub-category.entity';
import { ContractDrawingSubcatCatMap } from './entities/contract-drawing-subcat-cat-map.entity';
import { ShopDrawingMainCategory } from './entities/shop-drawing-main-category.entity';
import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@@ -47,7 +53,10 @@ export class DrawingMasterDataController {
@Post('contract/volumes')
@ApiOperation({ summary: 'Create Volume' })
@RequirePermission('master_data.drawing_category.manage')
createVolume(@Body() body: any) {
createVolume(
@Body()
body: Partial<ContractDrawingVolume> & { projectId: number | string }
) {
return this.masterDataService.createVolume(body);
}
@@ -56,7 +65,7 @@ export class DrawingMasterDataController {
@RequirePermission('master_data.drawing_category.manage')
updateVolume(
@Param('id', ParseIntPipe) id: number,
@Body() body: any
@Body() body: Partial<ContractDrawingVolume>
) {
return this.masterDataService.updateVolume(id, body);
}
@@ -83,7 +92,10 @@ export class DrawingMasterDataController {
@Post('contract/categories')
@ApiOperation({ summary: 'Create Category' })
@RequirePermission('master_data.drawing_category.manage')
createCategory(@Body() body: any) {
createCategory(
@Body()
body: Partial<ContractDrawingCategory> & { projectId: number | string }
) {
return this.masterDataService.createCategory(body);
}
@@ -92,7 +104,7 @@ export class DrawingMasterDataController {
@RequirePermission('master_data.drawing_category.manage')
updateCategory(
@Param('id', ParseIntPipe) id: number,
@Body() body: any
@Body() body: Partial<ContractDrawingCategory>
) {
return this.masterDataService.updateCategory(id, body);
}
@@ -119,7 +131,10 @@ export class DrawingMasterDataController {
@Post('contract/sub-categories')
@ApiOperation({ summary: 'Create Contract Sub-Category' })
@RequirePermission('master_data.drawing_category.manage')
createContractSubCat(@Body() body: any) {
createContractSubCat(
@Body()
body: Partial<ContractDrawingSubCategory> & { projectId: number | string }
) {
return this.masterDataService.createContractSubCat(body);
}
@@ -128,7 +143,7 @@ export class DrawingMasterDataController {
@RequirePermission('master_data.drawing_category.manage')
updateContractSubCat(
@Param('id', ParseIntPipe) id: number,
@Body() body: any
@Body() body: Partial<ContractDrawingSubCategory>
) {
return this.masterDataService.updateContractSubCat(id, body);
}
@@ -162,7 +177,10 @@ export class DrawingMasterDataController {
@Post('contract/mappings')
@ApiOperation({ summary: 'Create Contract Drawing Mapping' })
@RequirePermission('master_data.drawing_category.manage')
createContractMapping(@Body() body: any) {
createContractMapping(
@Body()
body: Partial<ContractDrawingSubcatCatMap> & { projectId: number | string }
) {
return this.masterDataService.createContractMapping(body);
}
@@ -188,7 +206,10 @@ export class DrawingMasterDataController {
@Post('shop/main-categories')
@ApiOperation({ summary: 'Create Shop Main Category' })
@RequirePermission('master_data.drawing_category.manage')
createShopMainCat(@Body() body: any) {
createShopMainCat(
@Body()
body: Partial<ShopDrawingMainCategory> & { projectId: number | string }
) {
return this.masterDataService.createShopMainCat(body);
}
@@ -197,7 +218,7 @@ export class DrawingMasterDataController {
@RequirePermission('master_data.drawing_category.manage')
updateShopMainCat(
@Param('id', ParseIntPipe) id: number,
@Body() body: any
@Body() body: Partial<ShopDrawingMainCategory>
) {
return this.masterDataService.updateShopMainCat(id, body);
}
@@ -231,7 +252,10 @@ export class DrawingMasterDataController {
@Post('shop/sub-categories')
@ApiOperation({ summary: 'Create Shop Sub-Category' })
@RequirePermission('master_data.drawing_category.manage')
createShopSubCat(@Body() body: any) {
createShopSubCat(
@Body()
body: Partial<ShopDrawingSubCategory> & { projectId: number | string }
) {
return this.masterDataService.createShopSubCat(body);
}
@@ -240,7 +264,7 @@ export class DrawingMasterDataController {
@RequirePermission('master_data.drawing_category.manage')
updateShopSubCat(
@Param('id', ParseIntPipe) id: number,
@Body() body: any
@Body() body: Partial<ShopDrawingSubCategory>
) {
return this.masterDataService.updateShopSubCat(id, body);
}
@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere, EntityManager } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere } from 'typeorm';
// Entities
import { ContractDrawingVolume } from './entities/contract-drawing-volume.entity';
@@ -9,7 +9,7 @@ import { ContractDrawingSubCategory } from './entities/contract-drawing-sub-cate
import { ShopDrawingMainCategory } from './entities/shop-drawing-main-category.entity';
import { ShopDrawingSubCategory } from './entities/shop-drawing-sub-category.entity';
import { ContractDrawingSubcatCatMap } from './entities/contract-drawing-subcat-cat-map.entity';
import { Project } from '../project/entities/project.entity';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
@Injectable()
export class DrawingMasterDataService {
@@ -26,45 +26,25 @@ export class DrawingMasterDataService {
private sdSubCatRepo: Repository<ShopDrawingSubCategory>,
@InjectRepository(ContractDrawingSubcatCatMap)
private cdMapRepo: Repository<ContractDrawingSubcatCatMap>,
@InjectEntityManager()
private entityManager: EntityManager
private uuidResolver: UuidResolverService
) {}
/**
* Helper to resolve projectId (ID or UUID) to internal INT ID
*/
async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
// If it's a string and not a number, it's a UUID (ADR-019)
const project = await this.entityManager.findOne(Project, {
where: { uuid: projectId as string },
select: ['id'],
});
if (!project) {
throw new NotFoundException(`Project with UUID ${projectId} not found`);
}
return project.id;
}
// =====================================================
// Contract Drawing Volumes
// =====================================================
async findAllVolumes(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId);
const internalId = await this.uuidResolver.resolveProjectId(projectId);
return this.cdVolumeRepo.find({
where: { projectId: internalId },
order: { sortOrder: 'ASC' },
});
}
async createVolume(data: any) {
const internalId = await this.resolveProjectId(data.projectId);
async createVolume(
data: Partial<ContractDrawingVolume> & { projectId: number | string }
) {
const internalId = await this.uuidResolver.resolveProjectId(data.projectId);
const volume = this.cdVolumeRepo.create({ ...data, projectId: internalId });
return this.cdVolumeRepo.save(volume);
}
@@ -88,15 +68,17 @@ export class DrawingMasterDataService {
// =====================================================
async findAllCategories(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId);
const internalId = await this.uuidResolver.resolveProjectId(projectId);
return this.cdCatRepo.find({
where: { projectId: internalId },
order: { sortOrder: 'ASC' },
});
}
async createCategory(data: any) {
const internalId = await this.resolveProjectId(data.projectId);
async createCategory(
data: Partial<ContractDrawingCategory> & { projectId: number | string }
) {
const internalId = await this.uuidResolver.resolveProjectId(data.projectId);
const cat = this.cdCatRepo.create({ ...data, projectId: internalId });
return this.cdCatRepo.save(cat);
}
@@ -120,15 +102,17 @@ export class DrawingMasterDataService {
// =====================================================
async findAllContractSubCats(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId);
const internalId = await this.uuidResolver.resolveProjectId(projectId);
return this.cdSubCatRepo.find({
where: { projectId: internalId },
order: { sortOrder: 'ASC' },
});
}
async createContractSubCat(data: any) {
const internalId = await this.resolveProjectId(data.projectId);
async createContractSubCat(
data: Partial<ContractDrawingSubCategory> & { projectId: number | string }
) {
const internalId = await this.uuidResolver.resolveProjectId(data.projectId);
const subCat = this.cdSubCatRepo.create({ ...data, projectId: internalId });
return this.cdSubCatRepo.save(subCat);
}
@@ -155,8 +139,10 @@ export class DrawingMasterDataService {
// =====================================================
async findContractMappings(projectId: number | string, categoryId?: number) {
const internalId = await this.resolveProjectId(projectId);
const where: FindOptionsWhere<ContractDrawingSubcatCatMap> = { projectId: internalId };
const internalId = await this.uuidResolver.resolveProjectId(projectId);
const where: FindOptionsWhere<ContractDrawingSubcatCatMap> = {
projectId: internalId,
};
if (categoryId) {
where.categoryId = categoryId;
}
@@ -167,8 +153,10 @@ export class DrawingMasterDataService {
});
}
async createContractMapping(data: any) {
const internalId = await this.resolveProjectId(data.projectId);
async createContractMapping(
data: Partial<ContractDrawingSubcatCatMap> & { projectId: number | string }
) {
const internalId = await this.uuidResolver.resolveProjectId(data.projectId);
// Check if mapping already exists to prevent duplicates (though DB has UNIQUE constraint)
const existing = await this.cdMapRepo.findOne({
where: {
@@ -196,15 +184,17 @@ export class DrawingMasterDataService {
// =====================================================
async findAllShopMainCats(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId);
const internalId = await this.uuidResolver.resolveProjectId(projectId);
return this.sdMainCatRepo.find({
where: { projectId: internalId },
order: { sortOrder: 'ASC' },
});
}
async createShopMainCat(data: any) {
const internalId = await this.resolveProjectId(data.projectId);
async createShopMainCat(
data: Partial<ShopDrawingMainCategory> & { projectId: number | string }
) {
const internalId = await this.uuidResolver.resolveProjectId(data.projectId);
const cat = this.sdMainCatRepo.create({ ...data, projectId: internalId });
return this.sdMainCatRepo.save(cat);
}
@@ -227,8 +217,11 @@ export class DrawingMasterDataService {
// Shop Drawing Sub-Categories
// =====================================================
async findAllShopSubCats(projectId: number | string, mainCategoryId?: number) {
const internalId = await this.resolveProjectId(projectId);
async findAllShopSubCats(
projectId: number | string,
mainCategoryId?: number
) {
const internalId = await this.uuidResolver.resolveProjectId(projectId);
const where: FindOptionsWhere<ShopDrawingSubCategory> = {
projectId: internalId,
...(mainCategoryId ? { mainCategoryId } : {}),
@@ -240,8 +233,10 @@ export class DrawingMasterDataService {
});
}
async createShopSubCat(data: any) {
const internalId = await this.resolveProjectId(data.projectId);
async createShopSubCat(
data: Partial<ShopDrawingSubCategory> & { projectId: number | string }
) {
const internalId = await this.uuidResolver.resolveProjectId(data.projectId);
const subCat = this.sdSubCatRepo.create({ ...data, projectId: internalId });
return this.sdSubCatRepo.save(subCat);
}
@@ -13,7 +13,6 @@ import { ShopDrawingRevision } from './entities/shop-drawing-revision.entity';
import { ContractDrawing } from './entities/contract-drawing.entity';
import { Attachment } from '../../common/file-storage/entities/attachment.entity';
import { User } from '../user/entities/user.entity';
import { Project } from '../project/entities/project.entity';
// DTOs
import { CreateShopDrawingDto } from './dto/create-shop-drawing.dto';
@@ -22,6 +21,7 @@ import { SearchShopDrawingDto } from './dto/search-shop-drawing.dto';
// Services
import { FileStorageService } from '../../common/file-storage/file-storage.service';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
@Injectable()
export class ShopDrawingService {
@@ -37,25 +37,10 @@ export class ShopDrawingService {
@InjectRepository(Attachment)
private attachmentRepo: Repository<Attachment>,
private fileStorageService: FileStorageService,
private dataSource: DataSource
private dataSource: DataSource,
private uuidResolver: UuidResolverService
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.dataSource.manager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* สร้าง Shop Drawing ใหม่ พร้อม Revision แรก (Rev 0)
*/
@@ -91,7 +76,7 @@ export class ShopDrawingService {
}
// ADR-019: Resolve UUID→INT
const internalProjectId = await this.resolveProjectId(
const internalProjectId = await this.uuidResolver.resolveProjectId(
createDto.projectId
);
@@ -32,16 +32,16 @@ export class JsonSchema {
tableName!: string;
@Column({ name: 'schema_definition', type: 'json' })
schemaDefinition!: any;
schemaDefinition!: Record<string, unknown>;
@Column({ name: 'ui_schema', type: 'json', nullable: true })
uiSchema?: any;
uiSchema?: Record<string, unknown>;
@Column({ name: 'virtual_columns', type: 'json', nullable: true })
virtualColumns?: VirtualColumnConfig[];
@Column({ name: 'migration_script', type: 'json', nullable: true })
migrationScript?: any;
migrationScript?: Record<string, unknown>;
@Column({ name: 'is_active', default: true })
isActive!: boolean;
@@ -23,7 +23,7 @@ export type Operator =
export interface FieldCondition {
field: string;
operator: Operator;
value: any;
value: unknown;
}
export interface FieldDependency {
@@ -32,7 +32,7 @@ export interface FieldDependency {
visibility?: boolean; // true = show, false = hide
required?: boolean;
disabled?: boolean;
filterOptions?: Record<string, any>; // เช่น กรอง Dropdown ตามค่าที่เลือก
filterOptions?: Record<string, unknown>; // เช่น กรอง Dropdown ตามค่าที่เลือก
};
}
@@ -42,10 +42,10 @@ export interface UiSchemaField {
title: string;
description?: string;
placeholder?: string;
enum?: any[]; // กรณีเป็น static options
enum?: unknown[]; // กรณีเป็น static options
enumNames?: string[]; // label สำหรับ options
dataSource?: string; // กรณีดึง options จาก API (เช่น 'master-data/disciplines')
defaultValue?: any;
defaultValue?: unknown;
readOnly?: boolean;
hidden?: boolean;
@@ -72,7 +72,7 @@ export interface LayoutGroup {
export interface LayoutConfig {
type: 'stack' | 'grid' | 'tabs' | 'steps' | 'wizard';
groups: LayoutGroup[];
options?: Record<string, any>; // Config เพิ่มเติมเฉพาะ Layout type
options?: Record<string, unknown>; // Config เพิ่มเติมเฉพาะ Layout type
}
export interface UiSchema {
@@ -81,4 +81,3 @@ export interface UiSchema {
[key: string]: UiSchemaField;
};
}
@@ -23,12 +23,11 @@ export interface ValidationOptions {
export interface ValidationErrorDetail {
field: string;
message: string;
value?: any;
value?: unknown;
}
export interface ValidationResult {
isValid: boolean;
errors: ValidationErrorDetail[];
sanitizedData: any;
sanitizedData: Record<string, unknown> | null;
}
@@ -42,7 +42,7 @@ import { User } from '../user/entities/user.entity';
export class JsonSchemaController {
constructor(
private readonly jsonSchemaService: JsonSchemaService,
private readonly migrationService: SchemaMigrationService,
private readonly migrationService: SchemaMigrationService
) {}
// ----------------------------------------------------------------------
@@ -93,7 +93,7 @@ export class JsonSchemaController {
@RequirePermission('system.manage_all')
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateDto: UpdateJsonSchemaDto,
@Body() updateDto: UpdateJsonSchemaDto
) {
return this.jsonSchemaService.update(id, updateDto);
}
@@ -117,7 +117,10 @@ export class JsonSchemaController {
description: 'Validation result including errors and sanitized data',
})
@RequirePermission('document.view')
async validate(@Param('code') code: string, @Body() data: any) {
async validate(
@Param('code') code: string,
@Body() data: Record<string, unknown>
) {
// Note: Validation API นี้ใช้สำหรับ Test หรือ Pre-check เท่านั้น
// การ Save จริงจะเรียกผ่าน Service ภายใน
return this.jsonSchemaService.validateData(code, data);
@@ -131,15 +134,16 @@ export class JsonSchemaController {
@RequirePermission('document.view')
async processReadData(
@Param('code') code: string,
@Body() data: any,
@CurrentUser() user: User,
@Body() data: Record<string, unknown>,
@CurrentUser() user: User
) {
// แปลง User Entity เป็น Security Context
// ใช้ as any เพื่อ bypass type checking ชั่วคราว เนื่องจาก roles มักจะถูก inject เข้ามาใน request.user
// โดย Strategy หรือ Guard แม้จะไม่มีใน Entity หลัก
const userWithRoles = user as any;
// roles มักจะถูก inject เข้ามาใน request.user โดย Strategy หรือ Guard แม้จะไม่มีใน Entity หลัก
const userWithRoles = user as User & {
roles?: Array<{ roleName: string } | string>;
};
const userRoles = userWithRoles.roles
? userWithRoles.roles.map((r: any) => r.roleName || r) // รองรับทั้ง Object Role และ String Role
? userWithRoles.roles.map((r) => (typeof r === 'string' ? r : r.roleName)) // รองรับทั้ง Object Role และ String Role
: [];
return this.jsonSchemaService.processReadData(code, data, { userRoles });
@@ -160,13 +164,13 @@ export class JsonSchemaController {
async migrateData(
@Param('table') tableName: string,
@Param('id', ParseIntPipe) id: number,
@Body() dto: MigrateDataDto,
@Body() dto: MigrateDataDto
) {
return this.migrationService.migrateData(
tableName,
id,
dto.targetSchemaCode,
dto.targetVersion,
dto.targetVersion
);
}
}
@@ -24,6 +24,7 @@ import {
SecurityContext,
} from './services/json-security.service';
import { UiSchemaService } from './services/ui-schema.service';
import { UiSchema } from './interfaces/ui-schema.interface';
import { VirtualColumnService } from './services/virtual-column.service';
import {
@@ -50,7 +51,7 @@ export class JsonSchemaService implements OnModuleInit {
private readonly jsonSchemaRepository: Repository<JsonSchema>,
private readonly virtualColumnService: VirtualColumnService,
private readonly uiSchemaService: UiSchemaService,
private readonly jsonSecurityService: JsonSecurityService,
private readonly jsonSecurityService: JsonSecurityService
) {
// กำหนดค่าเริ่มต้นให้กับ AJV Validation Engine
this.ajv = new Ajv({
@@ -78,7 +79,7 @@ export class JsonSchemaService implements OnModuleInit {
validate: (value: string) => {
// Regex อย่างง่าย: กลุ่มตัวอักษรขีดคั่นด้วย -
return /^[A-Z0-9]{2,10}-[A-Z]{2,5}(-[A-Z0-9]{2,5})?-\d{4}-\d{3,5}$/.test(
value,
value
);
},
});
@@ -88,7 +89,7 @@ export class JsonSchemaService implements OnModuleInit {
keyword: 'requiredRole',
type: 'string',
metaSchema: { type: 'string' },
validate: (schema: string, data: any) => true, // ผ่านเสมอในขั้น AJV (Security Service จะจัดการเอง)
validate: (_schema: string, _data: unknown) => true, // ผ่านเสมอในขั้น AJV (Security Service จะจัดการเอง)
});
}
@@ -99,9 +100,9 @@ export class JsonSchemaService implements OnModuleInit {
// 1. ตรวจสอบความถูกต้องของ JSON Schema Definition (AJV Syntax)
try {
this.ajv.compile(createDto.schemaDefinition);
} catch (error: any) {
} catch (error: unknown) {
throw new BadRequestException(
`Invalid JSON Schema format: ${error.message}`,
`Invalid JSON Schema format: ${error instanceof Error ? error.message : String(error)}`
);
}
@@ -109,13 +110,13 @@ export class JsonSchemaService implements OnModuleInit {
if (createDto.uiSchema) {
// ถ้าส่งมา ให้ตรวจสอบความถูกต้องเทียบกับ Data Schema
this.uiSchemaService.validateUiSchema(
createDto.uiSchema as any,
createDto.schemaDefinition,
createDto.uiSchema as unknown as UiSchema,
createDto.schemaDefinition
);
} else {
// ถ้าไม่ส่งมา ให้สร้าง UI Schema พื้นฐานให้อัตโนมัติ
createDto.uiSchema = this.uiSchemaService.generateDefaultUiSchema(
createDto.schemaDefinition,
createDto.schemaDefinition
);
}
@@ -149,7 +150,7 @@ export class JsonSchemaService implements OnModuleInit {
this.validators.delete(savedSchema.schemaCode);
this.logger.log(
`Schema '${savedSchema.schemaCode}' created (v${savedSchema.version})`,
`Schema '${savedSchema.schemaCode}' created (v${savedSchema.version})`
);
// 5. สร้าง/อัปเดต Virtual Columns บน Database จริง (Performance Optimization)
@@ -157,7 +158,7 @@ export class JsonSchemaService implements OnModuleInit {
if (savedSchema.virtualColumns && savedSchema.virtualColumns.length > 0) {
await this.virtualColumnService.setupVirtualColumns(
savedSchema.tableName,
savedSchema.virtualColumns || [],
savedSchema.virtualColumns || []
);
}
@@ -216,7 +217,7 @@ export class JsonSchemaService implements OnModuleInit {
*/
async findOneByCodeAndVersion(
code: string,
version: number,
version: number
): Promise<JsonSchema> {
const schema = await this.jsonSchemaRepository.findOne({
where: { schemaCode: code, version },
@@ -224,7 +225,7 @@ export class JsonSchemaService implements OnModuleInit {
if (!schema) {
throw new NotFoundException(
`JsonSchema '${code}' version ${version} not found`,
`JsonSchema '${code}' version ${version} not found`
);
}
return schema;
@@ -241,7 +242,7 @@ export class JsonSchemaService implements OnModuleInit {
if (!schema) {
throw new NotFoundException(
`Active JsonSchema with code '${code}' not found`,
`Active JsonSchema with code '${code}' not found`
);
}
return schema;
@@ -253,15 +254,17 @@ export class JsonSchemaService implements OnModuleInit {
*/
async validateData(
schemaCode: string,
data: any,
options: ValidationOptions = {},
data: Record<string, unknown>,
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 = JSON.parse(JSON.stringify(data));
const dataToValidate: Record<string, unknown> = JSON.parse(
JSON.stringify(data)
);
// 3. เริ่มการตรวจสอบ (AJV จะทำการ Coerce Type และ Remove Additional Properties ให้ด้วย)
const valid = validate(dataToValidate);
@@ -273,7 +276,7 @@ export class JsonSchemaService implements OnModuleInit {
field: err.instancePath || 'root',
message: err.message || 'Validation error',
value: err.params,
}),
})
);
return {
@@ -286,7 +289,7 @@ export class JsonSchemaService implements OnModuleInit {
// 5. เข้ารหัสข้อมูล (Encryption) สำหรับ Field ที่มีความลับ (x-encrypt: true)
const secureData = this.jsonSecurityService.encryptFields(
dataToValidate,
schema.schemaDefinition,
schema.schemaDefinition
);
return {
@@ -302,9 +305,9 @@ export class JsonSchemaService implements OnModuleInit {
*/
async processReadData(
schemaCode: string,
data: any,
userContext: SecurityContext,
): Promise<any> {
data: Record<string, unknown>,
userContext: SecurityContext
): Promise<Record<string, unknown>> {
if (!data) return data;
// ดึง Schema เพื่อดู Config การถอดรหัสและการมองเห็น
@@ -313,7 +316,7 @@ export class JsonSchemaService implements OnModuleInit {
return this.jsonSecurityService.decryptAndFilterFields(
data,
schema.schemaDefinition,
userContext,
userContext
);
}
@@ -328,9 +331,9 @@ export class JsonSchemaService implements OnModuleInit {
try {
validate = this.ajv.compile(schema.schemaDefinition);
this.validators.set(schemaCode, validate);
} catch (error: any) {
} catch (error: unknown) {
throw new BadRequestException(
`Invalid Schema Definition for '${schemaCode}': ${error.message}`,
`Invalid Schema Definition for '${schemaCode}': ${error instanceof Error ? error.message : String(error)}`
);
}
}
@@ -340,7 +343,10 @@ export class JsonSchemaService implements OnModuleInit {
/**
* Wrapper เก่าสำหรับ Backward Compatibility (ถ้ามีโค้ดเก่าเรียกใช้)
*/
async validate(schemaCode: string, data: any): Promise<boolean> {
async validate(
schemaCode: string,
data: Record<string, unknown>
): Promise<boolean> {
const result = await this.validateData(schemaCode, data);
if (!result.isValid) {
const errorMsg = result.errors
@@ -356,7 +362,7 @@ export class JsonSchemaService implements OnModuleInit {
*/
async update(
id: number,
updateDto: UpdateJsonSchemaDto,
updateDto: UpdateJsonSchemaDto
): Promise<JsonSchema> {
const schema = await this.findOne(id);
@@ -364,9 +370,9 @@ export class JsonSchemaService implements OnModuleInit {
if (updateDto.schemaDefinition) {
try {
this.ajv.compile(updateDto.schemaDefinition);
} catch (error: any) {
} catch (error: unknown) {
throw new BadRequestException(
`Invalid JSON Schema format: ${error.message}`,
`Invalid JSON Schema format: ${error instanceof Error ? error.message : String(error)}`
);
}
this.validators.delete(schema.schemaCode); // เคลียร์ Cache เก่า
@@ -375,8 +381,8 @@ export class JsonSchemaService implements OnModuleInit {
// ตรวจสอบ UI Schema
if (updateDto.uiSchema) {
this.uiSchemaService.validateUiSchema(
updateDto.uiSchema as any,
updateDto.schemaDefinition || schema.schemaDefinition,
updateDto.uiSchema as unknown as UiSchema,
updateDto.schemaDefinition || schema.schemaDefinition
);
}
@@ -388,7 +394,7 @@ export class JsonSchemaService implements OnModuleInit {
if (updateDto.virtualColumns && updatedSchema.virtualColumns) {
await this.virtualColumnService.setupVirtualColumns(
savedSchema.tableName,
savedSchema.virtualColumns || [],
savedSchema.virtualColumns || []
);
}
@@ -13,28 +13,41 @@ export class JsonSecurityService {
/**
* ขาเข้า (Write): เข้ารหัสข้อมูล Sensitive ก่อนบันทึก
*/
encryptFields(data: any, schemaDefinition: any): any {
encryptFields(
data: Record<string, unknown>,
schemaDefinition: Record<string, unknown>
): Record<string, unknown> {
if (!data || typeof data !== 'object') return data;
const processed = Array.isArray(data) ? [...data] : { ...data };
const processed: Record<string, unknown> = { ...data };
// Traverse schema properties
if (schemaDefinition.properties) {
for (const [key, propSchema] of Object.entries<any>(
schemaDefinition.properties,
)) {
const properties = schemaDefinition.properties as
| Record<string, Record<string, unknown>>
| undefined;
if (properties) {
for (const [key, propSchema] of Object.entries(properties)) {
if (data[key] !== undefined) {
// 1. Check encryption flag
if (propSchema['x-encrypt'] === true) {
processed[key] = this.cryptoService.encrypt(data[key]);
processed[key] = this.cryptoService.encrypt(
data[key] as string | number | boolean
);
}
// 2. Recursive for nested objects/arrays
if (propSchema.type === 'object' && propSchema.properties) {
processed[key] = this.encryptFields(data[key], propSchema);
processed[key] = this.encryptFields(
data[key] as Record<string, unknown>,
propSchema
);
} else if (propSchema.type === 'array' && propSchema.items) {
if (Array.isArray(data[key])) {
processed[key] = data[key].map((item: any) =>
this.encryptFields(item, propSchema.items),
processed[key] = (data[key] as Record<string, unknown>[]).map(
(item) =>
this.encryptFields(
item,
propSchema.items as Record<string, unknown>
)
);
}
}
@@ -48,33 +61,34 @@ export class JsonSecurityService {
* ขาออก (Read): ถอดรหัส และ กรองข้อมูลตามสิทธิ์
*/
decryptAndFilterFields(
data: any,
schemaDefinition: any,
context: SecurityContext,
): any {
data: Record<string, unknown>,
schemaDefinition: Record<string, unknown>,
context: SecurityContext
): Record<string, unknown> {
if (!data || typeof data !== 'object') return data;
// Clone data to avoid mutation
const processed = Array.isArray(data) ? [...data] : { ...data };
const processed: Record<string, unknown> = { ...data };
if (schemaDefinition.properties) {
for (const [key, propSchema] of Object.entries<any>(
schemaDefinition.properties,
)) {
const properties = schemaDefinition.properties as
| Record<string, Record<string, unknown>>
| undefined;
if (properties) {
for (const [key, propSchema] of Object.entries(properties)) {
if (data[key] !== undefined) {
// 1. Decrypt (ถ้ามีค่าและถูกเข้ารหัสไว้)
if (propSchema['x-encrypt'] === true) {
processed[key] = this.cryptoService.decrypt(data[key]);
processed[key] = this.cryptoService.decrypt(data[key] as string);
}
// 2. Security Check (Role-based Access Control)
if (propSchema['x-security']) {
const rule = propSchema['x-security'];
const requiredRoles = rule.roles || [];
const rule = propSchema['x-security'] as Record<string, unknown>;
const requiredRoles = (rule.roles as string[]) || [];
const hasPermission = requiredRoles.some(
(role: string) =>
context.userRoles.includes(role) ||
context.userRoles.includes('SUPERADMIN'),
context.userRoles.includes('SUPERADMIN')
);
if (!hasPermission) {
@@ -93,14 +107,20 @@ export class JsonSecurityService {
if (processed[key] !== undefined) {
if (propSchema.type === 'object' && propSchema.properties) {
processed[key] = this.decryptAndFilterFields(
processed[key],
processed[key] as Record<string, unknown>,
propSchema,
context,
context
);
} else if (propSchema.type === 'array' && propSchema.items) {
if (Array.isArray(processed[key])) {
processed[key] = processed[key].map((item: any) =>
this.decryptAndFilterFields(item, propSchema.items, context),
processed[key] = (
processed[key] as Record<string, unknown>[]
).map((item) =>
this.decryptAndFilterFields(
item,
propSchema.items as Record<string, unknown>,
context
)
);
}
}
@@ -10,7 +10,7 @@ export interface MigrationStep {
| 'FIELD_ADD'
| 'FIELD_REMOVE'
| 'STRUCTURE_CHANGE';
config: any;
config: Record<string, unknown>;
}
export interface MigrationResult {
@@ -27,7 +27,7 @@ export class SchemaMigrationService {
constructor(
private readonly dataSource: DataSource,
private readonly jsonSchemaService: JsonSchemaService,
private readonly jsonSchemaService: JsonSchemaService
) {}
/**
@@ -37,7 +37,7 @@ export class SchemaMigrationService {
entityType: string, // e.g., 'rfa_revisions', 'correspondence_revisions'
entityId: number,
targetSchemaCode: string,
targetVersion?: number,
targetVersion?: number
): Promise<MigrationResult> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
@@ -49,7 +49,7 @@ export class SchemaMigrationService {
if (targetVersion) {
targetSchema = await this.jsonSchemaService.findOneByCodeAndVersion(
targetSchemaCode,
targetVersion,
targetVersion
);
} else {
targetSchema =
@@ -61,12 +61,12 @@ export class SchemaMigrationService {
// 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],
[entityId]
);
if (!entity || entity.length === 0) {
throw new BadRequestException(
`Entity ${entityType} with ID ${entityId} not found.`,
`Entity ${entityType} with ID ${entityId} not found.`
);
}
@@ -90,12 +90,12 @@ export class SchemaMigrationService {
for (let v = currentVersion + 1; v <= targetSchema.version; v++) {
const schemaVer = await this.jsonSchemaService.findOneByCodeAndVersion(
targetSchemaCode,
v,
v
);
if (schemaVer && schemaVer.migrationScript) {
this.logger.log(
`Applying migration script for ${targetSchemaCode} v${v}...`,
`Applying migration script for ${targetSchemaCode} v${v}...`
);
const script = schemaVer.migrationScript;
@@ -115,12 +115,12 @@ export class SchemaMigrationService {
// 4. Validate Migrated Data against Target Schema
const validation = await this.jsonSchemaService.validateData(
targetSchema.schemaCode,
migratedData,
migratedData
);
if (!validation.isValid) {
throw new BadRequestException(
`Migration failed: Resulting data does not match target schema v${targetSchema.version}. Errors: ${JSON.stringify(validation.errors)}`,
`Migration failed: Resulting data does not match target schema v${targetSchema.version}. Errors: ${JSON.stringify(validation.errors)}`
);
}
@@ -132,7 +132,7 @@ export class SchemaMigrationService {
JSON.stringify(validation.sanitizedData),
targetSchema.version,
entityId,
],
]
);
await queryRunner.commitTransaction();
@@ -143,9 +143,12 @@ export class SchemaMigrationService {
toVersion: targetSchema.version,
migratedFields: [...new Set(migratedFields)],
};
} catch (err: any) {
} catch (err: unknown) {
await queryRunner.rollbackTransaction();
this.logger.error(`Migration failed: ${err.message}`, err.stack);
this.logger.error(
`Migration failed: ${err instanceof Error ? err.message : String(err)}`,
err instanceof Error ? err.stack : undefined
);
throw err;
} finally {
await queryRunner.release();
@@ -157,40 +160,45 @@ export class SchemaMigrationService {
*/
private async applyMigrationStep(
step: MigrationStep,
data: any,
): Promise<any> {
data: Record<string, unknown>
): Promise<Record<string, unknown>> {
const newData = { ...data };
const field = step.config.field as string;
const oldField = step.config.old_field as string;
const newField = step.config.new_field as string;
switch (step.type) {
case 'FIELD_RENAME':
if (newData[step.config.old_field] !== undefined) {
newData[step.config.new_field] = newData[step.config.old_field];
delete newData[step.config.old_field];
if (newData[oldField] !== undefined) {
newData[newField] = newData[oldField];
delete newData[oldField];
}
break;
case 'FIELD_ADD':
if (newData[step.config.field] === undefined) {
newData[step.config.field] = step.config.default_value;
if (newData[field] === undefined) {
newData[field] = step.config.default_value;
}
break;
case 'FIELD_REMOVE':
delete newData[step.config.field];
delete newData[field];
break;
case 'FIELD_TRANSFORM':
if (newData[step.config.field] !== undefined) {
if (newData[field] !== undefined) {
// Simple transform logic (e.g., map values)
if (step.config.transform === 'MAP_VALUES' && step.config.mapping) {
const oldVal = newData[step.config.field];
newData[step.config.field] = step.config.mapping[oldVal] || oldVal;
const oldVal = String(newData[field]);
const mapping = step.config.mapping as Record<string, unknown>;
newData[field] = mapping[oldVal] || newData[field];
}
// Type casting
else if (step.config.transform === 'TO_NUMBER') {
newData[step.config.field] = Number(newData[step.config.field]);
newData[field] = Number(newData[field]);
} else if (step.config.transform === 'TO_STRING') {
newData[step.config.field] = String(newData[step.config.field]);
newData[field] = String(newData[field]);
}
}
break;
@@ -202,4 +210,3 @@ export class SchemaMigrationService {
return newData;
}
}
@@ -1,6 +1,10 @@
// File: src/modules/json-schema/services/ui-schema.service.ts
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { UiSchema, UiSchemaField } from '../interfaces/ui-schema.interface';
import {
UiSchema,
UiSchemaField,
WidgetType,
} from '../interfaces/ui-schema.interface';
@Injectable()
export class UiSchemaService {
@@ -9,13 +13,16 @@ export class UiSchemaService {
/**
* ตรวจสอบความถูกต้องของ UI Schema
*/
validateUiSchema(uiSchema: UiSchema, dataSchema: any): boolean {
validateUiSchema(
uiSchema: UiSchema,
dataSchema: Record<string, unknown>
): boolean {
if (!uiSchema) return true; // Optional field
// 1. Validate Structure เบื้องต้น
if (!uiSchema.layout || !uiSchema.fields) {
throw new BadRequestException(
'UI Schema must contain "layout" and "fields" properties.',
'UI Schema must contain "layout" and "fields" properties.'
);
}
@@ -28,7 +35,7 @@ export class UiSchemaService {
layoutFields.add(fieldKey);
if (!definedFields.has(fieldKey)) {
throw new BadRequestException(
`Field "${fieldKey}" used in layout "${group.title}" is not defined in "fields".`,
`Field "${fieldKey}" used in layout "${group.title}" is not defined in "fields".`
);
}
});
@@ -42,7 +49,7 @@ export class UiSchemaService {
if (missingFields.length > 0) {
this.logger.warn(
`Data schema properties [${missingFields.join(', ')}] are missing from UI Schema.`,
`Data schema properties [${missingFields.join(', ')}] are missing from UI Schema.`
);
// ไม่ Throw Error เพราะบางทีเราอาจตั้งใจซ่อน Field (Hidden field)
}
@@ -55,7 +62,7 @@ export class UiSchemaService {
* สร้าง UI Schema พื้นฐานจาก Data Schema (AJV) อัตโนมัติ
* ใช้กรณี user ไม่ได้ส่ง UI Schema มาให้
*/
generateDefaultUiSchema(dataSchema: any): UiSchema {
generateDefaultUiSchema(dataSchema: Record<string, unknown>): UiSchema {
if (!dataSchema || !dataSchema.properties) {
return {
layout: { type: 'stack', groups: [] },
@@ -66,15 +73,17 @@ export class UiSchemaService {
const fields: { [key: string]: UiSchemaField } = {};
const groupFields: string[] = [];
for (const [key, value] of Object.entries<any>(dataSchema.properties)) {
for (const [key, value] of Object.entries(
dataSchema.properties as Record<string, Record<string, unknown>>
)) {
groupFields.push(key);
fields[key] = {
type: value.type || 'string',
title: value.title || this.humanize(key),
description: value.description,
required: (dataSchema.required || []).includes(key),
widget: this.guessWidget(value),
type: (value.type as UiSchemaField['type']) || 'string',
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,
colSpan: 12, // Default full width
};
}
@@ -103,7 +112,7 @@ export class UiSchemaService {
.trim();
}
private guessWidget(schemaProp: any): any {
private guessWidget(schemaProp: Record<string, unknown>): WidgetType {
if (schemaProp.enum) return 'select';
if (schemaProp.type === 'boolean') return 'checkbox';
if (schemaProp.format === 'date') return 'date';
@@ -112,4 +121,3 @@ export class UiSchemaService {
return 'text';
}
}
@@ -21,14 +21,14 @@ export class VirtualColumnService {
try {
this.logger.log(
`Start setting up virtual columns for table '${tableName}'...`,
`Start setting up virtual columns for table '${tableName}'...`
);
// 1. ตรวจสอบว่าตารางมีอยู่จริงไหม
const tableExists = await queryRunner.hasTable(tableName);
if (!tableExists) {
this.logger.warn(
`Table '${tableName}' not found. Skipping virtual columns.`,
`Table '${tableName}' not found. Skipping virtual columns.`
);
return;
}
@@ -42,12 +42,12 @@ export class VirtualColumnService {
}
this.logger.log(
`Finished setting up virtual columns for '${tableName}'.`,
`Finished setting up virtual columns for '${tableName}'.`
);
} catch (err: any) {
} catch (err: unknown) {
this.logger.error(
`Failed to setup virtual columns: ${err.message}`,
err.stack,
`Failed to setup virtual columns: ${err instanceof Error ? err.message : String(err)}`,
err instanceof Error ? err.stack : undefined
);
throw err;
} finally {
@@ -61,11 +61,11 @@ export class VirtualColumnService {
private async ensureVirtualColumn(
queryRunner: QueryRunner,
tableName: string,
config: VirtualColumnConfig,
config: VirtualColumnConfig
) {
const hasColumn = await queryRunner.hasColumn(
tableName,
config.column_name,
config.column_name
);
if (!hasColumn) {
@@ -75,7 +75,7 @@ export class VirtualColumnService {
} else {
// TODO: (Advance) ถ้ามี Column แล้ว แต่ Definition เปลี่ยน อาจต้อง ALTER MODIFY
this.logger.debug(
`Column '${config.column_name}' already exists in '${tableName}'.`,
`Column '${config.column_name}' already exists in '${tableName}'.`
);
}
}
@@ -86,7 +86,7 @@ export class VirtualColumnService {
private async ensureIndex(
queryRunner: QueryRunner,
tableName: string,
config: VirtualColumnConfig,
config: VirtualColumnConfig
) {
const indexName = `idx_${tableName}_${config.column_name}`;
@@ -116,7 +116,7 @@ export class VirtualColumnService {
*/
private generateAddColumnSql(
tableName: string,
config: VirtualColumnConfig,
config: VirtualColumnConfig
): string {
const dbType = this.mapDataTypeToSql(config.data_type);
// JSON_UNQUOTE(JSON_EXTRACT(details, '$.path'))
@@ -149,4 +149,3 @@ export class VirtualColumnService {
}
}
}
@@ -1,4 +1,4 @@
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
import { IsString, IsNotEmpty, IsOptional, IsInt } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateTagDto {
@@ -11,4 +11,12 @@ export class CreateTagDto {
@IsString()
@IsOptional()
description?: string;
@ApiProperty({
example: 1,
description: 'Project ID or UUID',
required: false,
})
@IsOptional()
project_id?: number | string;
}
@@ -13,12 +13,23 @@ import {
UseGuards,
ParseIntPipe,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { MasterService } from './master.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { RfaType } from '../rfa/entities/rfa-type.entity';
import { CreateDisciplineDto } from './dto/create-discipline.dto';
import { CreateSubTypeDto } from './dto/create-sub-type.dto';
import { SaveNumberFormatDto } from './dto/save-number-format.dto';
// Import DTOs
import { CreateTagDto } from './dto/create-tag.dto';
@@ -41,7 +52,7 @@ export class MasterController {
@Post('correspondence-types')
@RequirePermission('master_data.manage')
createCorrespondenceType(@Body() dto: any) {
createCorrespondenceType(@Body() dto: Partial<CorrespondenceType>) {
return this.masterService.createCorrespondenceType(dto);
}
@@ -55,7 +66,9 @@ export class MasterController {
@Post('rfa-types')
@RequirePermission('master_data.manage')
createRfaType(@Body() dto: any) {
createRfaType(
@Body() dto: Partial<RfaType> & { contractId: number | string }
) {
return this.masterService.createRfaType(dto);
}
@@ -69,7 +82,9 @@ export class MasterController {
@Post('disciplines')
@RequirePermission('master_data.manage')
createDiscipline(@Body() dto: any) {
createDiscipline(
@Body() dto: CreateDisciplineDto & { contractId: number | string }
) {
return this.masterService.createDiscipline(dto);
}
@@ -92,7 +107,9 @@ export class MasterController {
@Post('sub-types')
@RequirePermission('master_data.manage')
createSubType(@Body() dto: any) {
createSubType(
@Body() dto: CreateSubTypeDto & { contractId: number | string }
) {
return this.masterService.createSubType(dto);
}
@@ -108,7 +125,7 @@ export class MasterController {
@Post('numbering-formats')
@RequirePermission('master_data.manage')
saveNumberFormat(@Body() dto: any) {
saveNumberFormat(@Body() dto: SaveNumberFormatDto) {
return this.masterService.saveNumberFormat(dto);
}
@@ -128,8 +145,8 @@ export class MasterController {
@Post('tags')
@RequirePermission('master_data.tag.manage')
@ApiOperation({ summary: 'Create a new tag' })
createTag(@Body() dto: CreateTagDto, @CurrentUser() user: any) {
return this.masterService.createTag(dto, user.userId);
createTag(@Body() dto: CreateTagDto, @CurrentUser() user: User) {
return this.masterService.createTag(dto, user.user_id);
}
@Patch('tags/:id')
+45 -60
View File
@@ -5,8 +5,8 @@ import {
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
import { Repository, EntityManager } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
// Import Entities
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
@@ -21,8 +21,7 @@ import { Tag } from './entities/tag.entity';
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 { Project } from '../project/entities/project.entity';
import { Contract } from '../contract/entities/contract.entity';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
// Import DTOs
import { CreateTagDto } from './dto/create-tag.dto';
@@ -58,42 +57,9 @@ export class MasterService {
@InjectRepository(DocumentNumberFormat)
private readonly formatRepo: Repository<DocumentNumberFormat>,
@InjectEntityManager()
private readonly entityManager: EntityManager
private readonly uuidResolver: UuidResolverService
) {}
/**
* Helper to resolve projectId (ID or UUID) to internal INT ID
*/
async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.entityManager.findOne(Project, {
where: { uuid: projectId as string },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* Helper to resolve contractId (ID or UUID) to internal INT ID
*/
async resolveContractId(contractId: number | string): Promise<number> {
if (typeof contractId === 'number') return contractId;
const num = Number(contractId);
if (!isNaN(num)) return num;
const contract = await this.entityManager.findOne(Contract, {
where: { uuid: contractId as string },
select: ['id'],
});
if (!contract)
throw new NotFoundException(`Contract with UUID ${contractId} not found`);
return contract.id;
}
async findAllCorrespondenceTypes() {
return this.corrTypeRepo.find({
where: { isActive: true },
@@ -101,12 +67,12 @@ export class MasterService {
});
}
async createCorrespondenceType(dto: any) {
async createCorrespondenceType(dto: Partial<CorrespondenceType>) {
const item = this.corrTypeRepo.create(dto);
return this.corrTypeRepo.save(item);
}
async updateCorrespondenceType(id: number, dto: any) {
async updateCorrespondenceType(id: number, dto: Partial<CorrespondenceType>) {
const item = await this.corrTypeRepo.findOne({ where: { id } });
if (!item) throw new NotFoundException('Correspondence Type not found');
Object.assign(item, dto);
@@ -126,9 +92,11 @@ export class MasterService {
});
}
async findAllRfaTypes(contractId?: number | string) {
const where: any = { isActive: true };
const where: { isActive: boolean; contractId?: number } = {
isActive: true,
};
if (contractId) {
where.contractId = await this.resolveContractId(contractId);
where.contractId = await this.uuidResolver.resolveContractId(contractId);
}
return this.rfaTypeRepo.find({
where,
@@ -137,8 +105,10 @@ export class MasterService {
});
}
async createRfaType(dto: any) {
const internalContractId = await this.resolveContractId(dto.contractId);
async createRfaType(dto: Partial<RfaType> & { contractId: number | string }) {
const internalContractId = await this.uuidResolver.resolveContractId(
dto.contractId
);
const rfaType = this.rfaTypeRepo.create({
...dto,
contractId: internalContractId,
@@ -146,11 +116,16 @@ export class MasterService {
return this.rfaTypeRepo.save(rfaType);
}
async updateRfaType(id: number, dto: any) {
async updateRfaType(
id: number,
dto: Partial<RfaType> & { contractId?: number | string }
) {
const rfaType = await this.rfaTypeRepo.findOne({ where: { id } });
if (!rfaType) throw new NotFoundException('RFA Type not found');
if (dto.contractId) {
dto.contractId = await this.resolveContractId(dto.contractId);
dto.contractId = await this.uuidResolver.resolveContractId(
dto.contractId
);
}
Object.assign(rfaType, dto);
return this.rfaTypeRepo.save(rfaType);
@@ -192,7 +167,7 @@ export class MasterService {
.orderBy('d.disciplineCode', 'ASC');
if (contractId) {
const internalId = await this.resolveContractId(contractId);
const internalId = await this.uuidResolver.resolveContractId(contractId);
query.where('d.contractId = :contractId', { contractId: internalId });
}
query.andWhere('d.isActive = :isActive', { isActive: true });
@@ -200,8 +175,12 @@ export class MasterService {
return query.getMany();
}
async createDiscipline(dto: any) {
const internalContractId = await this.resolveContractId(dto.contractId);
async createDiscipline(
dto: CreateDisciplineDto & { contractId: number | string }
) {
const internalContractId = await this.uuidResolver.resolveContractId(
dto.contractId
);
const exists = await this.disciplineRepo.findOne({
where: {
contractId: internalContractId,
@@ -239,7 +218,7 @@ export class MasterService {
.orderBy('st.subTypeCode', 'ASC');
if (contractId) {
const internalId = await this.resolveContractId(contractId);
const internalId = await this.uuidResolver.resolveContractId(contractId);
query.andWhere('st.contractId = :contractId', { contractId: internalId });
}
if (typeId) query.andWhere('st.correspondenceTypeId = :typeId', { typeId });
@@ -247,8 +226,10 @@ export class MasterService {
return query.getMany();
}
async createSubType(dto: any) {
const internalContractId = await this.resolveContractId(dto.contractId);
async createSubType(dto: CreateSubTypeDto & { contractId: number | string }) {
const internalContractId = await this.uuidResolver.resolveContractId(
dto.contractId
);
const subType = this.subTypeRepo.create({
...dto,
contractId: internalContractId,
@@ -268,15 +249,17 @@ export class MasterService {
// =================================================================
async findNumberFormat(projectId: number | string, typeId: number) {
const internalId = await this.resolveProjectId(projectId);
const internalId = await this.uuidResolver.resolveProjectId(projectId);
const format = await this.formatRepo.findOne({
where: { projectId: internalId, correspondenceTypeId: typeId },
});
return format || null;
}
async saveNumberFormat(dto: any) {
const internalProjectId = await this.resolveProjectId(dto.projectId);
async saveNumberFormat(dto: SaveNumberFormatDto) {
const internalProjectId = await this.uuidResolver.resolveProjectId(
dto.projectId
);
let format: DocumentNumberFormat | null = await this.formatRepo.findOne({
where: {
projectId: internalProjectId,
@@ -303,7 +286,9 @@ export class MasterService {
if (query?.project_id) {
// In Tags, we use project_id (INT) directly or resolve if UUID passed via query
const internalId = await this.resolveProjectId(query.project_id);
const internalId = await this.uuidResolver.resolveProjectId(
query.project_id
);
qb.andWhere('tag.project_id = :projectId', {
projectId: internalId,
});
@@ -337,9 +322,9 @@ export class MasterService {
return tag;
}
async createTag(dto: any, userId: number) {
async createTag(dto: CreateTagDto, userId: number) {
const internalProjectId = dto.project_id
? await this.resolveProjectId(dto.project_id)
? await this.uuidResolver.resolveProjectId(dto.project_id)
: null;
const tag = this.tagRepo.create({
...dto,
@@ -349,10 +334,10 @@ export class MasterService {
return this.tagRepo.save(tag);
}
async updateTag(id: number, dto: any) {
async updateTag(id: number, dto: UpdateTagDto) {
const tag = await this.findOneTag(id);
if (dto.project_id) {
dto.project_id = await this.resolveProjectId(dto.project_id);
dto.project_id = await this.uuidResolver.resolveProjectId(dto.project_id);
}
Object.assign(tag, dto);
return this.tagRepo.save(tag);
@@ -77,5 +77,5 @@ export class EnqueueMigrationDto {
@IsArray()
@IsOptional()
ai_issues?: any[];
ai_issues?: Record<string, unknown>[];
}
@@ -32,7 +32,7 @@ export class ImportCorrespondenceDto {
ai_confidence?: number;
@IsOptional()
ai_issues?: any;
ai_issues?: Record<string, unknown>[];
@IsString()
@IsNotEmpty()
@@ -44,7 +44,7 @@ export class ImportCorrespondenceDto {
@IsObject()
@IsOptional()
details?: Record<string, any>;
details?: Record<string, unknown>;
@IsNumber()
@IsNotEmpty()
@@ -41,7 +41,7 @@ export class MigrationReviewQueue {
aiConfidence?: number;
@Column({ name: 'ai_issues', type: 'json', nullable: true })
aiIssues?: any;
aiIssues?: Record<string, unknown>[];
@Column({ name: 'review_reason', length: 255, nullable: true })
reviewReason?: string;
@@ -81,7 +81,7 @@ export class MigrationReviewQueue {
aiSummary?: string;
@Column({ name: 'extracted_tags', type: 'json', nullable: true })
extractedTags?: any;
extractedTags?: Record<string, string>[];
@Column({ name: 'temp_attachment_id', type: 'int', nullable: true })
tempAttachmentId?: number;
@@ -17,6 +17,7 @@ import { CommitBatchDto } from './dto/commit-batch.dto';
import { CreateMigrationErrorDto } from './dto/create-migration-error.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { User } from '../user/entities/user.entity';
import {
ApiTags,
ApiOperation,
@@ -48,9 +49,9 @@ export class MigrationController {
async importCorrespondence(
@Body() dto: ImportCorrespondenceDto,
@Headers('idempotency-key') idempotencyKey: string,
@CurrentUser() user: any
@CurrentUser() user: User
) {
const userId = user?.id || user?.userId || 5;
const userId = user?.user_id || 5;
return this.migrationService.importCorrespondence(
dto,
idempotencyKey,
@@ -72,9 +73,9 @@ export class MigrationController {
async commitBatch(
@Body() dto: CommitBatchDto,
@Headers('idempotency-key') idempotencyKey: string,
@CurrentUser() user: any
@CurrentUser() user: User
) {
const userId = user?.id || user?.userId || 5;
const userId = user?.user_id || 5;
return this.migrationService.commitBatch(dto, idempotencyKey, userId);
}
@@ -135,9 +136,9 @@ export class MigrationController {
@Param('id', ParseIntPipe) id: number,
@Body() dto: ImportCorrespondenceDto,
@Headers('idempotency-key') idempotencyKey: string,
@CurrentUser() user: any
@CurrentUser() user: User
) {
const userId = user?.id || user?.userId || 5;
const userId = user?.user_id || 5;
return this.migrationService.approveQueueItem(
id,
dto,
@@ -152,9 +153,9 @@ export class MigrationController {
@ApiParam({ name: 'id', type: Number })
async rejectQueueItem(
@Param('id', ParseIntPipe) id: number,
@CurrentUser() user: any
@CurrentUser() user: User
) {
const userId = user?.id || user?.userId || 5;
const userId = user?.user_id || 5;
return this.migrationService.rejectQueueItem(id, userId);
}
@@ -30,7 +30,7 @@ export class NotificationController {
) {
const { page = 1, limit = 20, isRead } = searchDto;
const where: any = { userId: user.user_id };
const where: Record<string, unknown> = { userId: user.user_id };
// เพิ่ม Filter isRead ถ้ามีการส่งมา
if (isRead !== undefined) {
@@ -6,6 +6,7 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
import { Notification } from './entities/notification.entity';
@WebSocketGateway({
cors: {
@@ -32,7 +33,7 @@ export class NotificationGateway
/**
* ส่งแจ้งเตือนไปหา User แบบ Real-time
*/
sendToUser(userId: number, payload: any) {
sendToUser(userId: number, payload: Notification) {
this.server.to(`user_${userId}`).emit('new_notification', payload);
}
}
@@ -10,6 +10,7 @@ import * as nodemailer from 'nodemailer';
import axios from 'axios';
import { UserService } from '../user/user.service';
import { User } from '../user/entities/user.entity';
interface NotificationPayload {
userId: number;
@@ -78,7 +79,7 @@ export class NotificationProcessor extends WorkerHost {
*/
private async handleDispatch(data: NotificationPayload) {
// 1. ดึง User พร้อม Preferences
const user: any = await this.userService.findOne(data.userId);
const user = await this.userService.findOne(data.userId);
if (!user) {
this.logger.warn(`User ${data.userId} not found, skipping notification.`);
@@ -86,17 +87,17 @@ export class NotificationProcessor extends WorkerHost {
}
const prefs = user.preference || {
notify_email: true,
notify_line: true,
digest_mode: false,
notifyEmail: true,
notifyLine: true,
digestMode: false,
};
// 2. ตรวจสอบว่า User ปิดรับการแจ้งเตือนหรือไม่
if (data.type === 'EMAIL' && !prefs.notify_email) return;
if (data.type === 'LINE' && !prefs.notify_line) return;
if (data.type === 'EMAIL' && !prefs.notifyEmail) return;
if (data.type === 'LINE' && !prefs.notifyLine) return;
// 3. ตรวจสอบ Digest Mode
if (prefs.digest_mode) {
if (prefs.digestMode) {
await this.addToDigest(data);
} else {
// ส่งทันที (Real-time)
@@ -167,7 +168,7 @@ export class NotificationProcessor extends WorkerHost {
// SENDERS (Immediate & Digest)
// =====================================================
private async sendEmailImmediate(user: any, data: NotificationPayload) {
private async sendEmailImmediate(user: User, data: NotificationPayload) {
if (!user.email) return;
await this.mailerTransport.sendMail({
from: '"LCBP3 DMS" <no-reply@np-dms.work>',
@@ -178,7 +179,7 @@ export class NotificationProcessor extends WorkerHost {
this.logger.log(`Email sent to ${user.email}`);
}
private async sendEmailDigest(user: any, messages: NotificationPayload[]) {
private async sendEmailDigest(user: User, messages: NotificationPayload[]) {
if (!user.email) return;
// สร้าง HTML List
@@ -204,7 +205,7 @@ export class NotificationProcessor extends WorkerHost {
);
}
private async sendLineImmediate(user: any, data: NotificationPayload) {
private async sendLineImmediate(user: User, data: NotificationPayload) {
const n8nWebhookUrl = this.configService.get('N8N_LINE_WEBHOOK_URL');
if (!n8nWebhookUrl) return;
@@ -221,7 +222,7 @@ export class NotificationProcessor extends WorkerHost {
}
}
private async sendLineDigest(user: any, messages: NotificationPayload[]) {
private async sendLineDigest(user: User, messages: NotificationPayload[]) {
const n8nWebhookUrl = this.configService.get('N8N_LINE_WEBHOOK_URL');
if (!n8nWebhookUrl) return;
@@ -29,7 +29,13 @@ export class OrganizationService {
return this.orgRepo.save(org);
}
async findAll(params?: any) {
async findAll(params?: {
search?: string;
roleId?: number;
projectId?: number;
page?: number;
limit?: number;
}) {
const { search, roleId, projectId, page = 1, limit = 100 } = params || {};
const skip = (page - 1) * limit;
@@ -155,7 +155,7 @@ export class RfaWorkflowService {
revision: RfaRevision,
workflowState: string,
approveCodeStr?: string, // เช่น '1A', '1C'
queryRunner?: any
queryRunner?: import('typeorm').QueryRunner
) {
// 1. Map Workflow State -> RFA Status Code (DFT, FAP, FCO...)
const statusMap: Record<string, string> = {
+6 -36
View File
@@ -12,8 +12,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
// Entities
import { Project } from '../project/entities/project.entity';
import { Organization } from '../organization/entities/organization.entity';
import { CorrespondenceRouting } from '../correspondence/entities/correspondence-routing.entity';
import { Correspondence } from '../correspondence/entities/correspondence.entity';
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
@@ -53,6 +51,7 @@ import { NotificationService } from '../notification/notification.service';
import { SearchService } from '../search/search.service';
import { UserService } from '../user/user.service';
import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
@Injectable()
export class RfaService {
@@ -91,44 +90,15 @@ export class RfaService {
private workflowEngine: WorkflowEngineService,
private notificationService: NotificationService,
private dataSource: DataSource,
private searchService: SearchService
private searchService: SearchService,
private uuidResolver: UuidResolverService
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.dataSource.manager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* ADR-019: Resolve organizationId (INT or UUID string) to internal INT ID
*/
private async resolveOrganizationId(orgId: number | string): Promise<number> {
if (typeof orgId === 'number') return orgId;
const num = Number(orgId);
if (!isNaN(num)) return num;
const org = await this.dataSource.manager.findOne(Organization, {
where: { uuid: orgId },
select: ['id'],
});
if (!org)
throw new NotFoundException(`Organization with UUID ${orgId} not found`);
return org.id;
}
async create(createDto: CreateRfaDto, user: User) {
// ADR-019: Resolve UUID→INT for projectId
const internalProjectId = await this.resolveProjectId(createDto.projectId);
const internalProjectId = await this.uuidResolver.resolveProjectId(
createDto.projectId
);
const rfaType = await this.rfaTypeRepo.findOne({
where: { id: createDto.rfaTypeId },
+5 -3
View File
@@ -72,7 +72,9 @@ export class SearchService implements OnModuleInit {
/**
* Index เอกสาร (Create/Update)
*/
async indexDocument(doc: any) {
async indexDocument(
doc: Record<string, unknown> & { type: string; id?: number; uuid?: string }
) {
try {
return await this.esService.index({
index: this.indexName,
@@ -115,7 +117,7 @@ export class SearchService implements OnModuleInit {
return { data: [], meta: { total: 0, page, limit, took: 0 } };
}
const mustQueries: any[] = [];
const mustQueries: Record<string, unknown>[] = [];
// 1. Full-text search logic
if (q) {
@@ -131,7 +133,7 @@ export class SearchService implements OnModuleInit {
}
// 2. Filter logic
const filterQueries: any[] = [];
const filterQueries: Record<string, unknown>[] = [];
if (type) filterQueries.push({ term: { type } });
if (projectId) filterQueries.push({ term: { projectId } });
@@ -20,8 +20,7 @@ import { Correspondence } from '../correspondence/entities/correspondence.entity
import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity';
import { Project } from '../project/entities/project.entity';
import { Organization } from '../organization/entities/organization.entity';
import { UuidResolverService } from '../../common/services/uuid-resolver.service';
import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity';
@Injectable()
@@ -38,41 +37,10 @@ export class TransmittalService {
@InjectRepository(CorrespondenceStatus)
private statusRepo: Repository<CorrespondenceStatus>,
private numberingService: DocumentNumberingService,
private dataSource: DataSource
private dataSource: DataSource,
private uuidResolver: UuidResolverService
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.dataSource.manager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* ADR-019: Resolve organizationId (INT or UUID string) to internal INT ID
*/
private async resolveOrganizationId(orgId: number | string): Promise<number> {
if (typeof orgId === 'number') return orgId;
const num = Number(orgId);
if (!isNaN(num)) return num;
const org = await this.dataSource.manager.findOne(Organization, {
where: { uuid: orgId },
select: ['id'],
});
if (!org)
throw new NotFoundException(`Organization with UUID ${orgId} not found`);
return org.id;
}
async create(createDto: CreateTransmittalDto, user: User) {
// 1. Get Transmittal Type (Assuming Code '901' or 'TRN')
const type = await this.typeRepo.findOne({
@@ -98,7 +66,7 @@ export class TransmittalService {
try {
// ADR-019: Resolve UUID→INT for projectId
const internalProjectId = await this.resolveProjectId(
const internalProjectId = await this.uuidResolver.resolveProjectId(
createDto.projectId
);
@@ -138,9 +106,10 @@ export class TransmittalService {
await queryRunner.manager.save(revision);
// ADR-019: Resolve recipientOrganizationId UUID→INT and create recipient record
const internalRecipientOrgId = await this.resolveOrganizationId(
createDto.recipientOrganizationId
);
const internalRecipientOrgId =
await this.uuidResolver.resolveOrganizationId(
createDto.recipientOrganizationId
);
const recipient = queryRunner.manager.create(CorrespondenceRecipient, {
correspondenceId: savedCorr.id,
recipientOrganizationId: internalRecipientOrgId,
@@ -70,7 +70,12 @@ export class UserAssignmentService {
results.push(await queryRunner.manager.save(newAssignment));
} else if (action === ActionType.REMOVE) {
// Construct delete criteria
const criteria: any = { userId, roleId };
const criteria: {
userId: number;
roleId: number;
organizationId?: number;
projectId?: number;
} = { userId, roleId };
if (organizationId) criteria.organizationId = organizationId;
if (projectId) criteria.projectId = projectId;
+25 -26
View File
@@ -17,7 +17,9 @@ import { Role } from './entities/role.entity';
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()
export class UserService {
@@ -28,25 +30,10 @@ export class UserService {
private roleRepository: Repository<Role>,
@InjectRepository(Permission)
private permissionRepository: Repository<Permission>,
@Inject(CACHE_MANAGER) private cacheManager: Cache
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private uuidResolver: UuidResolverService
) {}
/**
* ADR-019: Resolve organizationId (INT or UUID string) to internal INT ID
*/
private async resolveOrganizationId(orgId: number | string): Promise<number> {
if (typeof orgId === 'number') return orgId;
const num = Number(orgId);
if (!isNaN(num)) return num;
const org = await this.usersRepository.manager.findOne(Organization, {
where: { uuid: orgId },
select: ['id'],
});
if (!org)
throw new NotFoundException(`Organization with UUID ${orgId} not found`);
return org.id;
}
// 1. สร้างผู้ใช้ (Hash Password ก่อนบันทึก)
async create(createUserDto: CreateUserDto): Promise<User> {
const salt = await bcrypt.genSalt();
@@ -54,7 +41,9 @@ export class UserService {
// ADR-019: Resolve UUID→INT for primaryOrganizationId
const resolvedOrgId = createUserDto.primaryOrganizationId
? await this.resolveOrganizationId(createUserDto.primaryOrganizationId)
? await this.uuidResolver.resolveOrganizationId(
createUserDto.primaryOrganizationId
)
: undefined;
const newUser = this.usersRepository.create({
@@ -65,8 +54,9 @@ export class UserService {
try {
return await this.usersRepository.save(newUser);
} catch (error: any) {
if (error.code === 'ER_DUP_ENTRY') {
} catch (error: unknown) {
const dbError = error as { code?: string };
if (dbError.code === 'ER_DUP_ENTRY') {
throw new ConflictException('Username or Email already exists');
}
throw error;
@@ -74,7 +64,13 @@ export class UserService {
}
// 2. ดึงข้อมูลทั้งหมด (Search & Pagination)
async findAll(params?: any): Promise<any> {
async findAll(params?: SearchUserDto): Promise<{
data: User[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
const {
search,
roleId,
@@ -116,7 +112,7 @@ export class UserService {
if (primaryOrganizationId) {
// ADR-019: Resolve UUID→INT for filtering
const resolvedOrgId = await this.resolveOrganizationId(
const resolvedOrgId = await this.uuidResolver.resolveOrganizationId(
primaryOrganizationId
);
query.andWhere('user.primaryOrganizationId = :orgId', {
@@ -195,9 +191,10 @@ export class UserService {
// ADR-019: Resolve UUID→INT for primaryOrganizationId before merge
const resolvedDto: Record<string, unknown> = { ...updateUserDto };
if (updateUserDto.primaryOrganizationId !== undefined) {
resolvedDto.primaryOrganizationId = await this.resolveOrganizationId(
updateUserDto.primaryOrganizationId
);
resolvedDto.primaryOrganizationId =
await this.uuidResolver.resolveOrganizationId(
updateUserDto.primaryOrganizationId
);
}
const updatedUser = this.usersRepository.merge(
@@ -250,7 +247,9 @@ export class UserService {
[userId]
);
const permissionList = permissions.map((row: any) => row.permission_name);
const permissionList = permissions.map(
(row: { permission_name: string }) => row.permission_name
);
// 3. บันทึกลง Cache (TTL 1800 วินาที = 30 นาที)
await this.cacheManager.set(cacheKey, permissionList, 1800 * 1000);
@@ -34,13 +34,18 @@ export class WorkflowDslParser {
// Step 5: Save to database
return await this.workflowDefRepo.save(definition);
} catch (error: any) {
} catch (error: unknown) {
if (error instanceof SyntaxError) {
throw new BadRequestException(`Invalid JSON: ${error.message}`);
}
if (error.name === 'ZodError') {
const err = error as {
name?: string;
errors?: unknown;
message?: string;
};
if (err.name === 'ZodError') {
throw new BadRequestException(
`Invalid workflow DSL: ${JSON.stringify(error.errors)}`
`Invalid workflow DSL: ${JSON.stringify(err.errors)}`
);
}
throw error;
@@ -161,12 +166,14 @@ export class WorkflowDslParser {
try {
const dsl = definition.dsl;
return WorkflowDslSchema.parse(dsl);
} catch (error: any) {
} catch (error: unknown) {
this.logger.error(
`Failed to parse stored DSL for definition ${definitionId}`,
error
);
throw new BadRequestException(`Invalid stored DSL: ${error?.message}`);
throw new BadRequestException(
`Invalid stored DSL: ${error instanceof Error ? error.message : String(error)}`
);
}
}
@@ -179,10 +186,12 @@ export class WorkflowDslParser {
const dsl = WorkflowDslSchema.parse(rawDsl);
this.validateStateMachine(dsl);
return { valid: true };
} catch (error: any) {
} catch (error: unknown) {
return {
valid: false,
errors: [error?.message || 'Unknown validation error'],
errors: [
error instanceof Error ? error.message : 'Unknown validation error',
],
};
}
}
@@ -7,6 +7,7 @@ import {
IsBoolean,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import type { RawWorkflowDSL } from '../workflow-dsl.service';
export class CreateWorkflowDefinitionDto {
@ApiProperty({ example: 'RFA', description: 'รหัสของ Workflow' })
@@ -17,7 +18,7 @@ export class CreateWorkflowDefinitionDto {
@ApiProperty({ description: 'นิยาม Workflow' })
@IsObject()
@IsNotEmpty()
dsl!: any; // เพิ่ม !
dsl!: RawWorkflowDSL; // เพิ่ม !
@ApiProperty({ description: 'เปิดใช้งานทันทีหรือไม่', default: true })
@IsBoolean()
@@ -38,14 +38,14 @@ export class WorkflowDefinition {
type: 'json',
comment: 'Raw DSL ที่ User/Admin เขียน (เก็บไว้เพื่อดูหรือแก้ไข)',
})
dsl!: any; // ควรตรงกับ RawWorkflowDSL interface
dsl!: Record<string, unknown>; // RawWorkflowDSL | WorkflowDsl
@Column({
type: 'json',
comment:
'Compiled JSON Structure ที่ผ่านการ Validate และ Optimize สำหรับ Runtime Engine แล้ว',
})
compiled!: any; // ควรตรงกับ CompiledWorkflow interface
compiled!: Record<string, unknown>; // CompiledWorkflow | WorkflowDsl
@Column({ default: true, comment: 'สถานะการใช้งาน (Soft Disable)' })
is_active!: boolean;
@@ -33,7 +33,7 @@ export interface RawEvent {
type: 'notify' | 'webhook' | 'assign' | 'auto_action';
target?: string;
template?: string;
payload?: any;
payload?: Record<string, unknown>;
}
// ==========================================
@@ -147,7 +147,7 @@ export class WorkflowDslService {
compiled: CompiledWorkflow,
currentState: string,
action: string,
context: any = {}
context: Record<string, unknown> = {}
): { nextState: string; events: RawEvent[] } {
const stateConfig = compiled.states[currentState];
@@ -197,11 +197,12 @@ export class WorkflowDslService {
// Private Helpers
// --------------------------------------------------------
private validateSchemaStructure(dsl: any) {
private validateSchemaStructure(dsl: unknown) {
if (!dsl || typeof dsl !== 'object') {
throw new BadRequestException('DSL must be a JSON object.');
}
if (!dsl.workflow || !dsl.states || !Array.isArray(dsl.states)) {
const d = dsl as Record<string, unknown>;
if (!d.workflow || !d.states || !Array.isArray(d.states)) {
throw new BadRequestException(
'DSL Error: Missing required fields (workflow, states).'
);
@@ -210,15 +211,15 @@ export class WorkflowDslService {
private checkRequirements(
req: CompiledTransition['requirements'],
context: any
context: Record<string, unknown>
) {
// [FIX] Early return if no requirements defined
if (!req) {
return;
}
const userRoles: string[] = context.roles || [];
const userId: string | number = context.userId;
const userRoles: string[] = (context.roles as string[]) || [];
const userId: string | number = context.userId as string | number;
// Check Roles (OR logic inside array) - with null-safety
const requiredRoles = req.roles || [];
@@ -242,7 +243,10 @@ export class WorkflowDslService {
* NOTE: In production, use a safe parser like 'json-logic-js' or vm2
* For this phase, we use a simple Function constructor with restricted scope.
*/
private evaluateCondition(expression: string, context: any): boolean {
private evaluateCondition(
expression: string,
context: Record<string, unknown>
): boolean {
try {
// Simple guard against malicious code (basic)
if (expression.includes('process') || expression.includes('require')) {
@@ -253,8 +257,10 @@ export class WorkflowDslService {
// "context" is available inside the expression
const func = new Function('context', `return ${expression};`);
return !!func(context);
} catch (error: any) {
this.logger.error(`Condition Error: "${expression}" -> ${error.message}`);
} catch (error: unknown) {
this.logger.error(
`Condition Error: "${expression}" -> ${error instanceof Error ? error.message : String(error)}`
);
return false; // Fail safe
}
}
@@ -21,7 +21,11 @@ import {
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
import { CompiledWorkflow, WorkflowDslService } from './workflow-dsl.service';
import {
CompiledWorkflow,
RawEvent,
WorkflowDslService,
} from './workflow-dsl.service';
import { WorkflowEventService } from './workflow-event.service'; // [NEW] Import Event Service
// Legacy Interface (Backward Compatibility)
@@ -51,7 +55,7 @@ export class WorkflowEngineService {
private readonly historyRepo: Repository<WorkflowHistory>,
private readonly dslService: WorkflowDslService,
private readonly eventService: WorkflowEventService, // [NEW] Inject Service
private readonly dataSource: DataSource, // ใช้สำหรับ Transaction
private readonly dataSource: DataSource // ใช้สำหรับ Transaction
) {}
// =================================================================
@@ -62,7 +66,7 @@ export class WorkflowEngineService {
* สร้างหรืออัปเดต Workflow Definition ใหม่ (Auto Versioning)
*/
async createDefinition(
dto: CreateWorkflowDefinitionDto,
dto: CreateWorkflowDefinitionDto
): Promise<WorkflowDefinition> {
// 1. Compile & Validate DSL
const compiled = this.dslService.compile(dto.dsl);
@@ -79,16 +83,16 @@ export class WorkflowEngineService {
const entity = this.workflowDefRepo.create({
workflow_code: dto.workflow_code,
version: nextVersion,
dsl: dto.dsl,
compiled: compiled,
dsl: dto.dsl as unknown as Record<string, unknown>,
compiled: compiled as unknown as Record<string, unknown>,
is_active: dto.is_active ?? true,
});
const saved = await this.workflowDefRepo.save(entity);
this.logger.log(
`Created Workflow Definition: ${saved.workflow_code} v${saved.version}`,
`Created Workflow Definition: ${(saved as WorkflowDefinition).workflow_code} v${(saved as WorkflowDefinition).version}`
);
return saved;
return saved as WorkflowDefinition;
}
/**
@@ -96,22 +100,24 @@ export class WorkflowEngineService {
*/
async update(
id: string,
dto: UpdateWorkflowDefinitionDto,
dto: UpdateWorkflowDefinitionDto
): Promise<WorkflowDefinition> {
const definition = await this.workflowDefRepo.findOne({ where: { id } });
if (!definition) {
throw new NotFoundException(
`Workflow Definition with ID "${id}" not found`,
`Workflow Definition with ID "${id}" not found`
);
}
if (dto.dsl) {
try {
const compiled = this.dslService.compile(dto.dsl);
definition.dsl = dto.dsl;
definition.compiled = compiled;
} catch (error: any) {
throw new BadRequestException(`Invalid DSL: ${error.message}`);
definition.dsl = dto.dsl as unknown as Record<string, unknown>;
definition.compiled = compiled as unknown as Record<string, unknown>;
} catch (error: unknown) {
throw new BadRequestException(
`Invalid DSL: ${error instanceof Error ? error.message : String(error)}`
);
}
}
@@ -130,7 +136,7 @@ export class WorkflowEngineService {
const latestDefinitions = await this.workflowDefRepo
.createQueryBuilder('def')
.where(
'def.version = (SELECT MAX(sub.version) FROM workflow_definitions sub WHERE sub.workflow_code = def.workflow_code)',
'def.version = (SELECT MAX(sub.version) FROM workflow_definitions sub WHERE sub.workflow_code = def.workflow_code)'
)
.getMany();
@@ -143,7 +149,9 @@ export class WorkflowEngineService {
async getDefinitionById(id: string): Promise<WorkflowDefinition> {
const definition = await this.workflowDefRepo.findOne({ where: { id } });
if (!definition) {
throw new NotFoundException(`Workflow Definition with ID "${id}" not found`);
throw new NotFoundException(
`Workflow Definition with ID "${id}" not found`
);
}
return definition;
}
@@ -153,7 +161,7 @@ export class WorkflowEngineService {
*/
async getAvailableActions(
workflowCode: string,
currentState: string,
currentState: string
): Promise<string[]> {
const definition = await this.workflowDefRepo.findOne({
where: { workflow_code: workflowCode, is_active: true },
@@ -162,7 +170,8 @@ export class WorkflowEngineService {
if (!definition) return [];
const stateConfig = definition.compiled.states[currentState];
const compiled = definition.compiled as unknown as CompiledWorkflow;
const stateConfig = compiled.states[currentState];
if (!stateConfig || !stateConfig.transitions) return [];
return Object.keys(stateConfig.transitions);
@@ -179,7 +188,7 @@ export class WorkflowEngineService {
workflowCode: string,
entityType: string,
entityId: string,
initialContext: Record<string, any> = {},
initialContext: Record<string, unknown> = {}
): Promise<WorkflowInstance> {
// 1. หา Definition ล่าสุด
const definition = await this.workflowDefRepo.findOne({
@@ -189,19 +198,19 @@ export class WorkflowEngineService {
if (!definition) {
throw new NotFoundException(
`Workflow "${workflowCode}" not found or inactive.`,
`Workflow "${workflowCode}" not found or inactive.`
);
}
// 2. หา Initial State จาก Compiled Structure
const compiled: CompiledWorkflow = definition.compiled;
const compiled = definition.compiled as unknown as CompiledWorkflow;
// [FIX] ใช้ initialState จาก Root Property โดยตรง (ตามที่ Optimize ใน DSL Service)
// เพราะ CompiledState ใน states map ไม่มี property 'initial' แล้ว
const initialState = compiled.initialState;
if (!initialState) {
throw new BadRequestException(
`Workflow "${workflowCode}" has no initial state defined.`,
`Workflow "${workflowCode}" has no initial state defined.`
);
}
@@ -217,7 +226,7 @@ export class WorkflowEngineService {
const savedInstance = await this.instanceRepo.save(instance);
this.logger.log(
`Started Workflow Instance: ${workflowCode} for ${entityType}:${entityId}`,
`Started Workflow Instance: ${workflowCode} for ${entityType}:${entityId}`
);
return savedInstance;
}
@@ -234,7 +243,7 @@ export class WorkflowEngineService {
if (!instance) {
throw new NotFoundException(
`Workflow Instance "${instanceId}" not found`,
`Workflow Instance "${instanceId}" not found`
);
}
@@ -249,14 +258,14 @@ export class WorkflowEngineService {
action: string,
userId: number,
comment?: string,
payload: Record<string, any> = {},
payload: Record<string, any> = {}
) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
let eventsToDispatch: any[] = [];
let updatedContext: any = {};
let eventsToDispatch: RawEvent[] = [];
let updatedContext: Record<string, unknown> = {};
try {
// 1. Lock Instance เพื่อป้องกัน Race Condition (Pessimistic Write Lock)
@@ -268,18 +277,19 @@ export class WorkflowEngineService {
if (!instance) {
throw new NotFoundException(
`Workflow Instance "${instanceId}" not found.`,
`Workflow Instance "${instanceId}" not found.`
);
}
if (instance.status !== WorkflowStatus.ACTIVE) {
throw new BadRequestException(
`Workflow is not active (Status: ${instance.status}).`,
`Workflow is not active (Status: ${instance.status}).`
);
}
// 2. Evaluate Logic ผ่าน DSL Service
const compiled: CompiledWorkflow = instance.definition.compiled;
const compiled = instance.definition
.compiled as unknown as CompiledWorkflow;
const context = { ...instance.context, userId, ...payload }; // Merge Context
// * DSL Service จะ throw error ถ้า action ไม่ถูกต้อง หรือสิทธิ์ไม่พอ
@@ -287,7 +297,7 @@ export class WorkflowEngineService {
compiled,
instance.currentState,
action,
context,
context
);
const fromState = instance.currentState;
@@ -326,7 +336,7 @@ export class WorkflowEngineService {
updatedContext = context;
this.logger.log(
`Transition: ${instanceId} [${fromState}] --${action}--> [${toState}] by User:${userId}`,
`Transition: ${instanceId} [${fromState}] --${action}--> [${toState}] by User:${userId}`
);
// [NEW] Dispatch Events (Async) ผ่าน WorkflowEventService
@@ -334,7 +344,7 @@ export class WorkflowEngineService {
this.eventService.dispatchEvents(
instance.id,
eventsToDispatch,
updatedContext,
updatedContext
);
}
@@ -347,7 +357,7 @@ export class WorkflowEngineService {
} catch (err) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Transition Failed for ${instanceId}: ${(err as Error).message}`,
`Transition Failed for ${instanceId}: ${(err as Error).message}`
);
throw err;
} finally {
@@ -369,10 +379,10 @@ export class WorkflowEngineService {
}
return this.dslService.evaluate(
definition.compiled,
definition.compiled as unknown as CompiledWorkflow,
dto.current_state,
dto.action,
dto.context || {},
dto.context || {}
);
}
@@ -389,7 +399,7 @@ export class WorkflowEngineService {
currentSequence: number,
totalSteps: number,
action: string,
returnToSequence?: number,
returnToSequence?: number
): TransitionResult {
switch (action) {
case WorkflowAction.APPROVE:
@@ -430,7 +440,7 @@ export class WorkflowEngineService {
default:
this.logger.warn(
`Unknown legacy action: ${action}, treating as next step.`,
`Unknown legacy action: ${action}, treating as next step.`
);
if (currentSequence >= totalSteps) {
return {
@@ -9,9 +9,9 @@ export interface WorkflowEventHandler {
handleNotification(
target: string,
template: string,
payload: any,
payload: Record<string, unknown>
): Promise<void>;
handleWebhook(url: string, payload: any): Promise<void>;
handleWebhook(url: string, payload: Record<string, unknown>): Promise<void>;
handleAutoAction(instanceId: string, action: string): Promise<void>;
}
@@ -28,19 +28,17 @@ export class WorkflowEventService {
async dispatchEvents(
instanceId: string,
events: RawEvent[],
context: Record<string, any>,
context: Record<string, any>
) {
if (!events || events.length === 0) return;
this.logger.log(
`Dispatching ${events.length} events for Instance ${instanceId}`,
`Dispatching ${events.length} events for Instance ${instanceId}`
);
// ทำแบบ Async ไม่รอผล (Fire-and-forget) เพื่อไม่ให้กระทบ Response Time ของ User
Promise.allSettled(
events.map((event) =>
this.processSingleEvent(instanceId, event, context),
),
events.map((event) => this.processSingleEvent(instanceId, event, context))
).then((results) => {
// Log errors if any
results.forEach((res, idx) => {
@@ -54,7 +52,7 @@ export class WorkflowEventService {
private async processSingleEvent(
instanceId: string,
event: RawEvent,
context: any,
context: Record<string, unknown>
) {
try {
switch (event.type) {
@@ -79,18 +77,24 @@ export class WorkflowEventService {
// --- Handlers ---
private async handleNotify(event: RawEvent, context: any) {
private async handleNotify(
event: RawEvent,
_context: Record<string, unknown>
) {
// Mockup: ในของจริงจะเรียก NotificationService.send()
// const recipients = this.resolveRecipients(event.target, context);
this.logger.log(
`[EVENT] Notify target: "${event.target}" | Template: "${event.template}"`,
`[EVENT] Notify target: "${event.target}" | Template: "${event.template}"`
);
}
private async handleWebhook(event: RawEvent, context: any) {
private async handleWebhook(
event: RawEvent,
_context: Record<string, unknown>
) {
// Mockup: เรียก HttpService.post()
this.logger.log(
`[EVENT] Webhook to: "${event.target}" | Payload: ${JSON.stringify(event.payload)}`,
`[EVENT] Webhook to: "${event.target}" | Payload: ${JSON.stringify(event.payload)}`
);
}
}