260318:1401 Fix UUID #05
Build and Deploy / deploy (push) Failing after 11m8s

This commit is contained in:
admin
2026-03-18 14:01:32 +07:00
parent ba642e7e42
commit e5769269a8
37 changed files with 460 additions and 328 deletions
-133
View File
@@ -1,133 +0,0 @@
const fs = require('fs');
const filepath = 'd:/nap-dms.lcbp3/specs/03-Data-and-Storage/n8n.workflow.json';
const workflow = JSON.parse(fs.readFileSync(filepath, 'utf8'));
const switchNodeIndex = workflow.nodes.findIndex(n => n.name === 'Route by Confidence');
if (switchNodeIndex > -1) {
workflow.nodes[switchNodeIndex] = {
id: "23d11b5e-49b4-4b53-911b-76b6bb77aab8",
name: "Route by Confidence",
type: "n8n-nodes-base.switch",
typeVersion: 3.2,
position: [6840, 3696],
parameters: {
rules: {
values: [
{
conditions: {
options: {
caseSensitive: true,
leftValue: "",
typeValidation: "strict",
version: 2
},
conditions: [
{
leftValue: "={{ $json.route_index }}",
rightValue: 0,
operator: {
type: "number",
operation: "equals",
singleValue: true
}
}
],
combinator: "and"
},
renameOutput: true,
outputKey: "Auto Ingest"
},
{
conditions: {
options: {
caseSensitive: true,
leftValue: "",
typeValidation: "strict",
version: 2
},
conditions: [
{
leftValue: "={{ $json.route_index }}",
rightValue: 1,
operator: {
type: "number",
operation: "equals",
singleValue: true
}
}
],
combinator: "and"
},
renameOutput: true,
outputKey: "Review Queue"
},
{
conditions: {
options: {
caseSensitive: true,
leftValue: "",
typeValidation: "strict",
version: 2
},
conditions: [
{
leftValue: "={{ $json.route_index }}",
rightValue: 2,
operator: {
type: "number",
operation: "equals",
singleValue: true
}
}
],
combinator: "and"
},
renameOutput: true,
outputKey: "Reject"
},
{
conditions: {
options: {
caseSensitive: true,
leftValue: "",
typeValidation: "strict",
version: 2
},
conditions: [
{
leftValue: "={{ $json.route_index }}",
rightValue: 3,
operator: {
type: "number",
operation: "equals",
singleValue: true
}
}
],
combinator: "and"
},
renameOutput: true,
outputKey: "Error Log"
}
]
}
}
};
if (workflow.connections['Confidence Router'] || workflow.connections['Route by Confidence']) {
workflow.connections['Route by Confidence'] = {
'main': [
[ { 'node': 'Import to Backend', 'type': 'main', 'index': 0 } ],
[ { 'node': 'Insert Review Queue', 'type': 'main', 'index': 0 } ],
[ { 'node': 'Log Reject to CSV', 'type': 'main', 'index': 0 } ],
[ { 'node': 'Log Error to CSV', 'type': 'main', 'index': 0 } ]
]
};
}
fs.writeFileSync(filepath, JSON.stringify(workflow, null, 2));
console.log("Updated Switch node to use typeVersion 3.2 properly.");
} else {
console.log("Could not find Route by Confidence node.");
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "1.5.1", "version": "1.8.0",
"description": "<p align=\"center\">\r <a href=\"http://nestjs.com/\" target=\"blank\"><img src=\"https://nestjs.com/img/logo-small.svg\" width=\"120\" alt=\"Nest Logo\" /></a>\r </p>", "description": "<p align=\"center\">\r <a href=\"http://nestjs.com/\" target=\"blank\"><img src=\"https://nestjs.com/img/logo-small.svg\" width=\"120\" alt=\"Nest Logo\" /></a>\r </p>",
"author": "", "author": "",
"private": true, "private": true,
@@ -100,7 +100,7 @@ describe('CorrespondenceController', () => {
const mockReq = { user: { user_id: 1 } }; const mockReq = { user: { user_id: 1 } };
const result = await controller.submit( const result = await controller.submit(
1, 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
{ note: 'Test note' }, { note: 'Test note' },
mockReq as Parameters<typeof controller.submit>[2] mockReq as Parameters<typeof controller.submit>[2]
); );
@@ -6,7 +6,6 @@ import {
UseGuards, UseGuards,
Request, Request,
Param, Param,
ParseIntPipe,
Query, Query,
Delete, Delete,
Put, Put,
@@ -43,7 +42,7 @@ export class CorrespondenceController {
private readonly workflowService: CorrespondenceWorkflowService private readonly workflowService: CorrespondenceWorkflowService
) {} ) {}
@Post(':id/workflow/action') @Post(':uuid/workflow/action')
@ApiOperation({ summary: 'Process workflow action (Approve/Reject/Review)' }) @ApiOperation({ summary: 'Process workflow action (Approve/Reject/Review)' })
@ApiResponse({ status: 201, description: 'Action processed successfully.' }) @ApiResponse({ status: 201, description: 'Action processed successfully.' })
@RequirePermission('workflow.action_review') @RequirePermission('workflow.action_review')
@@ -188,15 +187,16 @@ export class CorrespondenceController {
return this.correspondenceService.addReference(corr.id, dto); return this.correspondenceService.addReference(corr.id, dto);
} }
@Delete(':uuid/references/:targetId') @Delete(':uuid/references/:targetUuid')
@ApiOperation({ summary: 'Remove reference' }) @ApiOperation({ summary: 'Remove reference' })
@ApiResponse({ status: 200, description: 'Reference removed successfully.' }) @ApiResponse({ status: 200, description: 'Reference removed successfully.' })
@RequirePermission('document.edit') @RequirePermission('document.edit')
async removeReference( async removeReference(
@Param('uuid', ParseUuidPipe) uuid: string, @Param('uuid', ParseUuidPipe) uuid: string,
@Param('targetId', ParseIntPipe) targetId: number @Param('targetUuid', ParseUuidPipe) targetUuid: string
) { ) {
const corr = await this.correspondenceService.findOneByUuid(uuid); const corr = await this.correspondenceService.findOneByUuid(uuid);
return this.correspondenceService.removeReference(corr.id, targetId); const target = await this.correspondenceService.findOneByUuid(targetUuid);
return this.correspondenceService.removeReference(corr.id, target.id);
} }
} }
@@ -423,8 +423,9 @@ export class CorrespondenceService {
async addReference(id: number, dto: AddReferenceDto) { async addReference(id: number, dto: AddReferenceDto) {
const source = await this.correspondenceRepo.findOne({ where: { id } }); const source = await this.correspondenceRepo.findOne({ where: { id } });
// ADR-019: Resolve target UUID → internal INT id
const target = await this.correspondenceRepo.findOne({ const target = await this.correspondenceRepo.findOne({
where: { id: dto.targetId }, where: { uuid: dto.targetUuid },
}); });
if (!source || !target) { if (!source || !target) {
@@ -438,7 +439,7 @@ export class CorrespondenceService {
const exists = await this.referenceRepo.findOne({ const exists = await this.referenceRepo.findOne({
where: { where: {
sourceId: id, sourceId: id,
targetId: dto.targetId, targetId: target.id,
}, },
}); });
@@ -448,7 +449,7 @@ export class CorrespondenceService {
const ref = this.referenceRepo.create({ const ref = this.referenceRepo.create({
sourceId: id, sourceId: id,
targetId: dto.targetId, targetId: target.id,
}); });
return this.referenceRepo.save(ref); return this.referenceRepo.save(ref);
@@ -1,12 +1,12 @@
import { IsInt, IsNotEmpty } from 'class-validator'; import { IsUUID, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class AddReferenceDto { export class AddReferenceDto {
@ApiProperty({ @ApiProperty({
description: 'Target Correspondence ID to reference', description: 'Target Correspondence UUID to reference (ADR-019)',
example: 20, example: '019505a1-7c3e-7000-8000-abc123def456',
}) })
@IsInt() @IsUUID('all')
@IsNotEmpty() @IsNotEmpty()
targetId!: number; targetUuid!: string;
} }
@@ -76,7 +76,7 @@ export class CreateCorrespondenceDto {
}) })
@IsObject() @IsObject()
@IsOptional() @IsOptional()
details?: Record<string, any>; // ข้อมูล JSON (เช่น RFI question) details?: Record<string, unknown>; // ข้อมูล JSON (เช่น RFI question)
@ApiPropertyOptional({ description: 'Is internal document?', default: false }) @ApiPropertyOptional({ description: 'Is internal document?', default: false })
@IsBoolean() @IsBoolean()
@@ -53,7 +53,7 @@ export class CorrespondenceRevision extends UuidBaseEntity {
remarks?: string; remarks?: string;
@Column({ type: 'json', nullable: true }) @Column({ type: 'json', nullable: true })
details?: any; // เก็บข้อมูลแบบ Dynamic ตาม Type details?: object; // Dynamic JSON — typed as `object` per TypeORM JSON column convention (no-any, ADR-019)
@Column({ name: 'schema_version', default: 1 }) @Column({ name: 'schema_version', default: 1 })
schemaVersion!: number; schemaVersion!: number;
@@ -267,12 +267,17 @@ export class DocumentNumberingService {
// --- Admin / Legacy --- // --- Admin / Legacy ---
async getTemplates() { async getTemplates() {
return this.formatRepo.find(); return this.formatRepo.find({
relations: ['project', 'correspondenceType'],
});
} }
async getTemplatesByProject(projectId: number | string) { async getTemplatesByProject(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId); const internalId = await this.resolveProjectId(projectId);
return this.formatRepo.find({ where: { projectId: internalId } }); return this.formatRepo.find({
where: { projectId: internalId },
relations: ['project', 'correspondenceType'],
});
} }
async saveTemplate(dto: any) { async saveTemplate(dto: any) {
@@ -33,11 +33,12 @@ describe('MigrationController', () => {
it('should call importCorrespondence on service', async () => { it('should call importCorrespondence on service', async () => {
const dto: ImportCorrespondenceDto = { const dto: ImportCorrespondenceDto = {
document_number: 'DOC-001', document_number: 'DOC-001',
title: 'Legacy Record', subject: 'Legacy Record',
category: 'Correspondence', category: 'Correspondence',
source_file_path: '/staging_ai/test.pdf', source_file_path: '/staging_ai/test.pdf',
migrated_by: 'SYSTEM_IMPORT', migrated_by: 'SYSTEM_IMPORT',
batch_id: 'batch1', batch_id: 'batch1',
project_id: 1,
}; };
const idempotencyKey = 'key123'; const idempotencyKey = 'key123';
+12 -8
View File
@@ -1,11 +1,12 @@
import { IsInt, IsOptional, IsString, IsNotEmpty } from 'class-validator'; import { IsInt, IsOptional, IsString, IsUUID } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
export class SearchRfaDto { export class SearchRfaDto {
@IsInt() @IsUUID('all')
@Type(() => Number) projectUuid!: string; // ADR-019: Public UUID of the project
@IsNotEmpty()
projectId!: number; // บังคับระบุ Project /** @internal Resolved INT ID — set by controller, do NOT expose in API */
projectId?: number;
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@@ -13,9 +14,12 @@ export class SearchRfaDto {
rfaTypeId?: number; // กรองตามประเภท RFA rfaTypeId?: number; // กรองตามประเภท RFA
@IsOptional() @IsOptional()
@IsInt() @IsString()
@Type(() => Number) statusCode?: string; // กรองตามสถานะโดยใช้ status code เช่น 'DFT', 'FAP'
statusId?: number; // กรองตามสถานะ (เช่น Draft, For Approve)
@IsOptional()
@IsString()
revisionStatus?: string; // 'CURRENT' | 'OLD' | 'ALL' — default 'CURRENT'
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -35,7 +35,7 @@ export class RfaRevision {
// --- JSON & Schema Section --- // --- JSON & Schema Section ---
@Column({ type: 'json', nullable: true }) @Column({ type: 'json', nullable: true })
details?: any; details?: object; // Dynamic JSON — typed as `object` per TypeORM JSON column convention (no-any, ADR-019)
// ✅ [New] จำเป็นสำหรับ Data Migration (T2.5.5) // ✅ [New] จำเป็นสำหรับ Data Migration (T2.5.5)
@Column({ name: 'schema_version', default: 1 }) @Column({ name: 'schema_version', default: 1 })
+29 -17
View File
@@ -4,7 +4,6 @@ import {
Controller, Controller,
Get, Get,
Param, Param,
ParseIntPipe,
Post, Post,
Query, Query,
UseGuards, UseGuards,
@@ -22,6 +21,7 @@ import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity';
import { CreateRfaDto } from './dto/create-rfa.dto'; import { CreateRfaDto } from './dto/create-rfa.dto';
import { SubmitRfaDto } from './dto/submit-rfa.dto'; import { SubmitRfaDto } from './dto/submit-rfa.dto';
import { SearchRfaDto } from './dto/search-rfa.dto';
import { RfaService } from './rfa.service'; import { RfaService } from './rfa.service';
import { Audit } from '../../common/decorators/audit.decorator'; import { Audit } from '../../common/decorators/audit.decorator';
@@ -29,13 +29,18 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard'; import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { ProjectService } from '../project/project.service';
@ApiTags('RFA (Request for Approval)') @ApiTags('RFA (Request for Approval)')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard) @UseGuards(JwtAuthGuard, RbacGuard)
@Controller('rfas') @Controller('rfas')
export class RfaController { export class RfaController {
constructor(private readonly rfaService: RfaService) {} constructor(
private readonly rfaService: RfaService,
private readonly projectService: ProjectService
) {}
@Post() @Post()
@ApiOperation({ summary: 'Create new RFA (Draft)' }) @ApiOperation({ summary: 'Create new RFA (Draft)' })
@@ -47,24 +52,26 @@ export class RfaController {
return this.rfaService.create(createDto, user); return this.rfaService.create(createDto, user);
} }
@Post(':id/submit') @Post(':uuid/submit')
@ApiOperation({ summary: 'Submit RFA to Workflow' }) @ApiOperation({ summary: 'Submit RFA to Workflow' })
@ApiParam({ name: 'id', description: 'RFA ID' }) @ApiParam({ name: 'uuid', description: 'RFA UUID (from correspondences.uuid)' })
@ApiBody({ type: SubmitRfaDto }) @ApiBody({ type: SubmitRfaDto })
@ApiResponse({ status: 200, description: 'RFA submitted successfully' }) @ApiResponse({ status: 200, description: 'RFA submitted successfully' })
@RequirePermission('rfa.create') @RequirePermission('rfa.create')
@Audit('rfa.submit', 'rfa') @Audit('rfa.submit', 'rfa')
submit( async submit(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() submitDto: SubmitRfaDto, @Body() submitDto: SubmitRfaDto,
@CurrentUser() user: User @CurrentUser() user: User
) { ) {
return this.rfaService.submit(id, submitDto.templateId, user); // ADR-019: resolve UUID → internal INT id via findOneByUuidRaw
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
return this.rfaService.submit(rfa.id, submitDto.templateId, user);
} }
@Post(':id/action') @Post(':uuid/action')
@ApiOperation({ summary: 'Process Workflow Action (Approve/Reject)' }) @ApiOperation({ summary: 'Process Workflow Action (Approve/Reject)' })
@ApiParam({ name: 'id', description: 'RFA ID' }) @ApiParam({ name: 'uuid', description: 'RFA UUID (from correspondences.uuid)' })
@ApiBody({ type: WorkflowActionDto }) @ApiBody({ type: WorkflowActionDto })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -72,28 +79,33 @@ export class RfaController {
}) })
@RequirePermission('workflow.action_review') @RequirePermission('workflow.action_review')
@Audit('rfa.action', 'rfa') @Audit('rfa.action', 'rfa')
processAction( async processAction(
@Param('id', ParseIntPipe) id: number, @Param('uuid', ParseUuidPipe) uuid: string,
@Body() actionDto: WorkflowActionDto, @Body() actionDto: WorkflowActionDto,
@CurrentUser() user: User @CurrentUser() user: User
) { ) {
return this.rfaService.processAction(id, actionDto, user); // ADR-019: resolve UUID → internal INT id
const rfa = await this.rfaService.findOneByUuidRaw(uuid);
return this.rfaService.processAction(rfa.id, actionDto, user);
} }
@Get() @Get()
@ApiOperation({ summary: 'List all RFAs with pagination' }) @ApiOperation({ summary: 'List all RFAs with pagination' })
@ApiResponse({ status: 200, description: 'List of RFAs' }) @ApiResponse({ status: 200, description: 'List of RFAs' })
@RequirePermission('document.view') @RequirePermission('document.view')
findAll(@Query() query: any) { async findAll(@Query() query: SearchRfaDto) {
// ADR-019: resolve projectUuid → internal INT projectId
const project = await this.projectService.findOneByUuid(query.projectUuid);
query.projectId = project.id;
return this.rfaService.findAll(query); return this.rfaService.findAll(query);
} }
@Get(':id') @Get(':uuid')
@ApiOperation({ summary: 'Get RFA details with revisions and items' }) @ApiOperation({ summary: 'Get RFA details with revisions and items' })
@ApiParam({ name: 'id', description: 'RFA ID' }) @ApiParam({ name: 'uuid', description: 'RFA UUID (from correspondences.uuid)' })
@ApiResponse({ status: 200, description: 'RFA details' }) @ApiResponse({ status: 200, description: 'RFA details' })
@RequirePermission('document.view') @RequirePermission('document.view')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.rfaService.findOne(id); return this.rfaService.findOneByUuid(uuid);
} }
} }
+2
View File
@@ -29,6 +29,7 @@ import { RfaService } from './rfa.service';
// External Modules // External Modules
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module'; import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { NotificationModule } from '../notification/notification.module'; import { NotificationModule } from '../notification/notification.module';
import { ProjectModule } from '../project/project.module';
import { SearchModule } from '../search/search.module'; import { SearchModule } from '../search/search.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'; import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module';
@@ -56,6 +57,7 @@ import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'
]), ]),
DocumentNumberingModule, DocumentNumberingModule,
UserModule, UserModule,
ProjectModule,
SearchModule, SearchModule,
WorkflowEngineModule, WorkflowEngineModule,
NotificationModule, NotificationModule,
+78 -29
View File
@@ -32,6 +32,17 @@ import { Rfa } from './entities/rfa.entity';
// DTOs // DTOs
import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto'; import { WorkflowActionDto } from '../correspondence/dto/workflow-action.dto';
import { CreateRfaDto } from './dto/create-rfa.dto'; import { CreateRfaDto } from './dto/create-rfa.dto';
import { SearchRfaDto } from './dto/search-rfa.dto';
// ------- Local type helpers (no-any ADR-019) -------
/** CorrespondenceRevision with the rfaRevision relation loaded at runtime */
type CorrRevWithRfa = CorrespondenceRevision & { rfaRevision?: RfaRevision };
/** RFA entity + a flat `revisions` convenience array for the frontend */
export interface RfaMapped extends Rfa {
uuid?: string; // ADR-019: top-level UUID from correspondence
revisions: CorrRevWithRfa[];
}
// Interfaces & Enums // Interfaces & Enums
import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface'; import { WorkflowAction } from '../workflow-engine/interfaces/workflow.interface';
@@ -272,6 +283,7 @@ export class RfaService {
this.searchService this.searchService
.indexDocument({ .indexDocument({
id: savedCorr.id, id: savedCorr.id,
uuid: savedCorr.uuid, // ADR-019: index UUID for search
type: 'rfa', type: 'rfa',
docNumber: docNumber.number, docNumber: docNumber.number,
title: createDto.subject, title: createDto.subject,
@@ -298,13 +310,19 @@ export class RfaService {
// ... (ส่วน findOne, submit, processAction คงเดิมจากไฟล์ที่แนบมา แค่ปรับปรุงเล็กน้อยตาม Context) ... // ... (ส่วน findOne, submit, processAction คงเดิมจากไฟล์ที่แนบมา แค่ปรับปรุงเล็กน้อยตาม Context) ...
async findAll(query: any) { async findAll(query: SearchRfaDto) {
const { page = 1, limit = 20, projectId, status, search } = query; const {
page = 1,
limit = 20,
projectId,
search,
revisionStatus = 'CURRENT',
statusCode,
} = query;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
// Fix: Start query from Rfa entity instead of Correspondence, // Fix: Start query from Rfa entity instead of Correspondence,
// because Correspondence has no 'rfas' relation. // because Correspondence has no 'rfas' relation.
// [Force Rebuild]
const queryBuilder = this.rfaRepo const queryBuilder = this.rfaRepo
.createQueryBuilder('rfa') .createQueryBuilder('rfa')
.leftJoinAndSelect('rfa.correspondence', 'corr') .leftJoinAndSelect('rfa.correspondence', 'corr')
@@ -318,11 +336,9 @@ export class RfaService {
.leftJoinAndSelect('sdRev.attachments', 'attachments'); .leftJoinAndSelect('sdRev.attachments', 'attachments');
// Filter by Revision Status (from query param 'revisionStatus') // Filter by Revision Status (from query param 'revisionStatus')
const revStatus = query.revisionStatus || 'CURRENT'; if (revisionStatus === 'CURRENT') {
if (revStatus === 'CURRENT') {
queryBuilder.where('corrRev.isCurrent = :isCurrent', { isCurrent: true }); queryBuilder.where('corrRev.isCurrent = :isCurrent', { isCurrent: true });
} else if (revStatus === 'OLD') { } else if (revisionStatus === 'OLD') {
queryBuilder.where('corrRev.isCurrent = :isCurrent', { queryBuilder.where('corrRev.isCurrent = :isCurrent', {
isCurrent: false, isCurrent: false,
}); });
@@ -333,8 +349,8 @@ export class RfaService {
queryBuilder.andWhere('corr.projectId = :projectId', { projectId }); queryBuilder.andWhere('corr.projectId = :projectId', { projectId });
} }
if (status) { if (statusCode) {
queryBuilder.andWhere('status.statusCode = :status', { status }); queryBuilder.andWhere('status.statusCode = :statusCode', { statusCode });
} }
if (search) { if (search) {
@@ -355,15 +371,18 @@ export class RfaService {
); );
// Map `revisions` property back to the expected payload for the frontend // Map `revisions` property back to the expected payload for the frontend
const mappedItems = items.map((rfa) => { const mappedItems: RfaMapped[] = items.map((rfa) => {
const mappedRfa = { ...rfa } as any; const revisions =
mappedRfa.revisions = (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
rfa.correspondence?.revisions?.map((cr) => ({ return {
...rfa,
uuid: rfa.correspondence?.uuid, // ADR-019: expose UUID at top level
revisions: revisions.map((cr) => ({
...cr, ...cr,
...(cr.rfaRevision || {}), ...(cr.rfaRevision ?? {}),
id: cr.rfaRevision?.id || cr.id, id: cr.rfaRevision?.id ?? cr.id,
})) || []; })) as CorrRevWithRfa[],
return mappedRfa; };
}); });
return { return {
@@ -377,6 +396,32 @@ export class RfaService {
}; };
} }
/**
* ADR-019: Find RFA by the parent Correspondence UUID (public identifier).
* Resolves correspondence.uuid → internal rfa.id
*/
async findOneByUuid(uuid: string) {
const correspondence = await this.correspondenceRepo.findOne({
where: { uuid },
select: ['id'],
});
if (!correspondence) {
throw new NotFoundException(`RFA with UUID ${uuid} not found`);
}
return this.findOne(correspondence.id);
}
async findOneByUuidRaw(uuid: string) {
const correspondence = await this.correspondenceRepo.findOne({
where: { uuid },
select: ['id'],
});
if (!correspondence) {
throw new NotFoundException(`RFA with UUID ${uuid} not found`);
}
return this.findOne(correspondence.id, true);
}
async findOne(id: number, rawEntities = false) { async findOne(id: number, rawEntities = false) {
const rfa = await this.rfaRepo.findOne({ const rfa = await this.rfaRepo.findOne({
where: { id }, where: { id },
@@ -405,22 +450,26 @@ export class RfaService {
} }
// Map to structure expected by frontend DTO // Map to structure expected by frontend DTO
const mappedRfa = { ...rfa } as any; const revisions =
mappedRfa.revisions = (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
rfa.correspondence?.revisions?.map((cr) => ({ const mappedRfa: RfaMapped = {
...rfa,
uuid: rfa.correspondence?.uuid, // ADR-019: expose UUID at top level
revisions: revisions.map((cr) => ({
...cr, ...cr,
...(cr.rfaRevision || {}), ...(cr.rfaRevision ?? {}),
id: cr.rfaRevision?.id || cr.id, id: cr.rfaRevision?.id ?? cr.id,
})) || []; })) as CorrRevWithRfa[],
};
return mappedRfa; return mappedRfa;
} }
async submit(rfaId: number, templateId: number, user: User) { async submit(rfaId: number, templateId: number, user: User) {
const rfa = await this.findOne(rfaId, true); const rfa = await this.findOne(rfaId, true);
const currentCorrRev = rfa.correspondence?.revisions?.find( const corrRevisions =
(r: any) => r.isCurrent (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
); const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
if (!currentCorrRev || !currentCorrRev.rfaRevision) if (!currentCorrRev || !currentCorrRev.rfaRevision)
throw new NotFoundException('Current revision not found'); throw new NotFoundException('Current revision not found');
@@ -512,9 +561,9 @@ export class RfaService {
async processAction(rfaId: number, dto: WorkflowActionDto, user: User) { async processAction(rfaId: number, dto: WorkflowActionDto, user: User) {
// Logic คงเดิม: หา Current Routing -> Check Permission -> Call Workflow Engine -> Update DB // Logic คงเดิม: หา Current Routing -> Check Permission -> Call Workflow Engine -> Update DB
const rfa = await this.findOne(rfaId, true); const rfa = await this.findOne(rfaId, true);
const currentCorrRev = rfa.correspondence?.revisions?.find( const corrRevisions =
(r: any) => r.isCurrent (rfa.correspondence?.revisions as CorrRevWithRfa[] | undefined) ?? [];
); const currentCorrRev = corrRevisions.find((r) => r.isCurrent);
if (!currentCorrRev || !currentCorrRev.rfaRevision) if (!currentCorrRev || !currentCorrRev.rfaRevision)
throw new NotFoundException('Current revision not found'); throw new NotFoundException('Current revision not found');
+5 -4
View File
@@ -10,7 +10,7 @@ export class SearchService implements OnModuleInit {
constructor( constructor(
private readonly esService: ElasticsearchService, private readonly esService: ElasticsearchService,
private readonly configService: ConfigService, private readonly configService: ConfigService
) {} ) {}
async onModuleInit() { async onModuleInit() {
@@ -34,6 +34,7 @@ export class SearchService implements OnModuleInit {
mappings: { mappings: {
properties: { properties: {
id: { type: 'integer' }, id: { type: 'integer' },
uuid: { type: 'keyword' }, // ADR-019: public identifier
type: { type: 'keyword' }, // correspondence, rfa, drawing type: { type: 'keyword' }, // correspondence, rfa, drawing
docNumber: { type: 'text' }, docNumber: { type: 'text' },
title: { type: 'text', analyzer: 'standard' }, title: { type: 'text', analyzer: 'standard' },
@@ -60,12 +61,12 @@ export class SearchService implements OnModuleInit {
try { try {
return await this.esService.index({ return await this.esService.index({
index: this.indexName, index: this.indexName,
id: `${doc.type}_${doc.id}`, // Unique ID: rfa_101 id: doc.uuid ? `${doc.type}_${doc.uuid}` : `${doc.type}_${doc.id}`, // ADR-019: prefer UUID key
document: doc, // ✅ Library รุ่นใหม่ใช้ 'document' แทน 'body' ในบางเวอร์ชัน document: doc, // ✅ Library รุ่นใหม่ใช้ 'document' แทน 'body' ในบางเวอร์ชัน
}); });
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to index document: ${(error as Error).message}`, `Failed to index document: ${(error as Error).message}`
); );
} }
} }
@@ -81,7 +82,7 @@ export class SearchService implements OnModuleInit {
}); });
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Failed to remove document: ${(error as Error).message}`, `Failed to remove document: ${(error as Error).message}`
); );
} }
} }
@@ -3,16 +3,18 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsEnum, IsEnum,
IsNotEmpty, IsUUID,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { TransmittalPurpose } from './create-transmittal.dto'; import { TransmittalPurpose } from './create-transmittal.dto';
export class SearchTransmittalDto { export class SearchTransmittalDto {
@IsInt() @IsUUID('all')
@Type(() => Number) @IsOptional()
@IsNotEmpty() projectUuid?: string; // ADR-019: Public UUID of the project
projectId!: number; // บังคับระบุ Project
/** @internal Resolved INT ID — set by controller, do NOT expose in API */
projectId?: number;
@IsEnum(TransmittalPurpose) @IsEnum(TransmittalPurpose)
@IsOptional() @IsOptional()
@@ -5,39 +5,59 @@ import {
Body, Body,
Param, Param,
UseGuards, UseGuards,
ParseIntPipe,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { TransmittalService } from './transmittal.service'; import { TransmittalService } from './transmittal.service';
import { CreateTransmittalDto } from './dto/create-transmittal.dto'; import { CreateTransmittalDto } from './dto/create-transmittal.dto';
import { SearchTransmittalDto } from './dto/search-transmittal.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { ProjectService } from '../project/project.service';
@ApiTags('Transmittals') @ApiTags('Transmittals')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard, RbacGuard)
@Controller('transmittals') @Controller('transmittals')
export class TransmittalController { export class TransmittalController {
constructor(private readonly transmittalService: TransmittalService) {} constructor(
private readonly transmittalService: TransmittalService,
private readonly projectService: ProjectService
) {}
@Post() @Post()
@ApiOperation({ summary: 'Create a new Transmittal' }) @ApiOperation({ summary: 'Create a new Transmittal' })
@RequirePermission('document.create')
create(@Body() createDto: CreateTransmittalDto, @CurrentUser() user: User) { create(@Body() createDto: CreateTransmittalDto, @CurrentUser() user: User) {
return this.transmittalService.create(createDto, user); return this.transmittalService.create(createDto, user);
} }
@Get() @Get()
@ApiOperation({ summary: 'Search Transmittals' }) @ApiOperation({ summary: 'Search Transmittals' })
findAll(@Query() searchDto: any) { @RequirePermission('document.view')
// Using any for simplicity as I can't import SearchTransmittalDto easily without checking its export async findAll(
@Query() searchDto: SearchTransmittalDto,
@CurrentUser() _user: User
) {
// ADR-019: resolve projectUuid → internal INT projectId if needed
if (searchDto.projectUuid) {
const project = await this.projectService.findOneByUuid(
searchDto.projectUuid
);
searchDto.projectId = project.id;
}
return this.transmittalService.findAll(searchDto); return this.transmittalService.findAll(searchDto);
} }
@Get(':id') @Get(':uuid')
@ApiOperation({ summary: 'Get Transmittal details' }) @ApiOperation({ summary: 'Get Transmittal details' })
findOne(@Param('id', ParseIntPipe) id: number) { @ApiParam({ name: 'uuid', description: 'Transmittal UUID (from correspondences.uuid)' })
return this.transmittalService.findOne(id); @RequirePermission('document.view')
findOne(@Param('uuid', ParseUuidPipe) uuid: string) {
return this.transmittalService.findOneByUuid(uuid);
} }
} }
@@ -8,6 +8,7 @@ import { CorrespondenceStatus } from '../correspondence/entities/correspondence-
import { TransmittalService } from './transmittal.service'; import { TransmittalService } from './transmittal.service';
import { TransmittalController } from './transmittal.controller'; import { TransmittalController } from './transmittal.controller';
import { DocumentNumberingModule } from '../document-numbering/document-numbering.module'; import { DocumentNumberingModule } from '../document-numbering/document-numbering.module';
import { ProjectModule } from '../project/project.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { SearchModule } from '../search/search.module'; import { SearchModule } from '../search/search.module';
@@ -21,6 +22,7 @@ import { SearchModule } from '../search/search.module';
CorrespondenceStatus, CorrespondenceStatus,
]), ]),
DocumentNumberingModule, DocumentNumberingModule,
ProjectModule,
UserModule, UserModule,
SearchModule, SearchModule,
], ],
@@ -9,7 +9,11 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm'; import { Repository, DataSource } from 'typeorm';
import { Transmittal } from './entities/transmittal.entity'; import { Transmittal } from './entities/transmittal.entity';
import { TransmittalItem } from './entities/transmittal-item.entity'; import { TransmittalItem } from './entities/transmittal-item.entity';
import { CreateTransmittalDto } from './dto/create-transmittal.dto'; import {
CreateTransmittalDto,
TransmittalItemDto,
} from './dto/create-transmittal.dto';
import { SearchTransmittalDto } from './dto/search-transmittal.dto';
import { User } from '../user/entities/user.entity'; import { User } from '../user/entities/user.entity';
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service'; import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity';
@@ -125,14 +129,7 @@ export class TransmittalService {
// 6. Create Items // 6. Create Items
if (createDto.items && createDto.items.length > 0) { if (createDto.items && createDto.items.length > 0) {
// Filter only items that are effectively correspondences (or mapped as such) const items = createDto.items.map((item: TransmittalItemDto) =>
// For now, assuming itemId refers to correspondenceId if itemType is CORRESPONDENCE
// If itemType is DRAWING, we skip or throw error (Schema Restriction)
const validItems = createDto.items.filter(
(i) => i.itemType === 'CORRESPONDENCE' || i.itemType === 'DRAWING' // Temporary allow DRAWING if ID matches Correspondence? Unsafe.
);
const items = createDto.items.map((item) =>
queryRunner.manager.create(TransmittalItem, { queryRunner.manager.create(TransmittalItem, {
transmittalId: savedCorr.id, transmittalId: savedCorr.id,
itemCorrespondenceId: item.itemId, // Direct mapping forced by Schema itemCorrespondenceId: item.itemId, // Direct mapping forced by Schema
@@ -160,6 +157,21 @@ export class TransmittalService {
} }
} }
/**
* ADR-019: Find Transmittal by parent Correspondence UUID (public identifier).
* Resolves correspondence.uuid internal correspondenceId (INT)
*/
async findOneByUuid(uuid: string) {
const correspondence = await this.dataSource.manager.findOne(
Correspondence,
{ where: { uuid }, select: ['id'] }
);
if (!correspondence) {
throw new NotFoundException(`Transmittal with UUID ${uuid} not found`);
}
return this.findOne(correspondence.id);
}
async findOne(id: number) { async findOne(id: number) {
const transmittal = await this.transmittalRepo.findOne({ const transmittal = await this.transmittalRepo.findOne({
where: { correspondenceId: id }, where: { correspondenceId: id },
@@ -170,9 +182,9 @@ export class TransmittalService {
return transmittal; return transmittal;
} }
async findAll(query: any) { async findAll(query: SearchTransmittalDto) {
const { page = 1, limit = 20, projectId, search } = query; const { page = 1, limit = 20, projectId, search } = query;
const skip = (page - 1) * limit; const skip = ((page ?? 1) - 1) * (limit ?? 20);
const queryBuilder = this.transmittalRepo const queryBuilder = this.transmittalRepo
.createQueryBuilder('transmittal') .createQueryBuilder('transmittal')
@@ -205,8 +217,14 @@ export class TransmittalService {
.take(limit) .take(limit)
.getManyAndCount(); .getManyAndCount();
// ADR-019: Map correspondence.uuid to top level for frontend convenience
const mappedItems = items.map((t) => ({
...t,
uuid: t.correspondence?.uuid,
}));
return { return {
data: items, data: mappedItems,
meta: { meta: {
total, total,
page, page,
@@ -31,7 +31,7 @@ export default function NumberingPage() {
useEffect(() => { useEffect(() => {
if (projects.length > 0 && !selectedProjectId) { if (projects.length > 0 && !selectedProjectId) {
const first = projects[0] as any; const first = projects[0] as any;
setSelectedProjectId(String(first.id || first.uuid)); setSelectedProjectId(String(first.id ?? first.uuid));
} }
}, [projects, selectedProjectId]); }, [projects, selectedProjectId]);
@@ -41,7 +41,7 @@ export default function NumberingPage() {
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(null); const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(null);
const selectedProject = projects.find((p: any) => String(p.id || p.uuid) === selectedProjectId) as any; const selectedProject = projects.find((p: any) => String(p.id ?? p.uuid) === selectedProjectId) as any;
const selectedProjectName = selectedProject?.projectName || 'Unknown Project'; const selectedProjectName = selectedProject?.projectName || 'Unknown Project';
// Master Data // Master Data
@@ -109,7 +109,7 @@ export default function NumberingPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(projects as any[]).map((project) => ( {(projects as any[]).map((project) => (
<SelectItem key={project.uuid || project.id} value={String(project.id || project.uuid)}> <SelectItem key={project.id ?? project.uuid} value={String(project.id ?? project.uuid)}>
{project.projectCode} - {project.projectName} {project.projectCode} - {project.projectName}
</SelectItem> </SelectItem>
))} ))}
@@ -137,7 +137,7 @@ export default function NumberingPage() {
<div className="lg:col-span-2 space-y-4"> <div className="lg:col-span-2 space-y-4">
<div className="grid gap-4"> <div className="grid gap-4">
{templates {templates
.filter((t: any) => !t.projectId || String(t.projectId) === selectedProjectId || t.project?.uuid === selectedProjectId) .filter((t: any) => !t.projectId || String(t.project?.id ?? t.project?.uuid) === selectedProjectId || t.project?.uuid === selectedProjectId)
.map((template) => ( .map((template) => (
<Card key={template.id} className="p-6 hover:shadow-md transition-shadow"> <Card key={template.id} className="p-6 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
@@ -6,11 +6,11 @@ import { useRFA } from "@/hooks/use-rfa";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
export default function RFADetailPage() { export default function RFADetailPage() {
const { id } = useParams(); const { uuid } = useParams();
if (!id) notFound(); if (!uuid) notFound();
const { data: rfa, isLoading, isError } = useRFA(String(id)); const { data: rfa, isLoading, isError } = useRFA(String(uuid));
if (isLoading) { if (isLoading) {
return ( return (
@@ -22,12 +22,12 @@ import { toast } from "sonner";
export default function TransmittalDetailPage() { export default function TransmittalDetailPage() {
const params = useParams(); const params = useParams();
const id = params.id as string; const uuid = params.uuid as string;
const { data: transmittal, isLoading, error } = useQuery<Transmittal>({ const { data: transmittal, isLoading, error } = useQuery<Transmittal>({
queryKey: ["transmittal", id], queryKey: ["transmittal", uuid],
queryFn: () => transmittalService.getById(id), queryFn: () => transmittalService.getByUuid(uuid),
enabled: !!id, enabled: !!uuid,
}); });
const handlePrint = () => { const handlePrint = () => {
@@ -100,7 +100,7 @@ export default function TransmittalDetailPage() {
<p className="text-sm text-muted-foreground">Generated From</p> <p className="text-sm text-muted-foreground">Generated From</p>
{transmittal.correspondence ? ( {transmittal.correspondence ? (
<Link <Link
href={`/correspondences/${transmittal.correspondenceId}`} href={`/correspondences/${transmittal.correspondence.uuid}`}
className="font-medium text-primary hover:underline" className="font-medium text-primary hover:underline"
> >
{transmittal.correspondence.correspondence_number} {transmittal.correspondence.correspondence_number}
+38 -2
View File
@@ -1,22 +1,41 @@
"use client"; "use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { TransmittalList } from "@/components/transmittal/transmittal-list"; import { TransmittalList } from "@/components/transmittal/transmittal-list";
import { transmittalService } from "@/lib/services/transmittal.service"; import { transmittalService } from "@/lib/services/transmittal.service";
import { projectService } from "@/lib/services/project.service";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, RefreshCw } from "lucide-react"; import { Plus, RefreshCw } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { TransmittalListResponse } from "@/types/transmittal"; import { TransmittalListResponse } from "@/types/transmittal";
export default function TransmittalPage() { export default function TransmittalPage() {
// ADR-019: Dynamic project selection via UUID
const [selectedProjectUuid, setSelectedProjectUuid] = useState<string>("");
const { data: projectsData } = useQuery({
queryKey: ["projects-for-transmittals"],
queryFn: () => projectService.getAll(),
});
const projects = projectsData?.data || projectsData || [];
const { const {
data, data,
isLoading, isLoading,
error, error,
refetch, refetch,
} = useQuery<TransmittalListResponse>({ } = useQuery<TransmittalListResponse>({
queryKey: ["transmittals"], queryKey: ["transmittals", selectedProjectUuid],
queryFn: () => transmittalService.getAll({ projectId: 1 }), queryFn: () => transmittalService.getAll({ projectId: selectedProjectUuid }),
enabled: !!selectedProjectUuid,
}); });
return ( return (
@@ -47,6 +66,23 @@ export default function TransmittalPage() {
</div> </div>
</div> </div>
{/* ADR-019: Project filter */}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">Project:</span>
<Select value={selectedProjectUuid} onValueChange={setSelectedProjectUuid}>
<SelectTrigger className="w-[280px]">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent>
{(Array.isArray(projects) ? projects : []).map((p: { uuid: string; projectName?: string; projectCode?: string }) => (
<SelectItem key={p.uuid} value={p.uuid}>
{p.projectName || p.projectCode}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && ( {error && (
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md"> <div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">
Failed to load transmittals. Failed to load transmittals.
@@ -53,7 +53,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Master Data Hooks // Master Data Hooks
const projectId = template?.projectId || 1; const projectId = (template as any)?.project?.id ?? (template as any)?.project?.uuid ?? template?.projectId ?? 1;
const { data: organizations } = useOrganizations({ isActive: true }); const { data: organizations } = useOrganizations({ isActive: true });
const { data: correspondenceTypes } = useCorrespondenceTypes(); const { data: correspondenceTypes } = useCorrespondenceTypes();
const { data: contracts } = useContracts(projectId); const { data: contracts } = useContracts(projectId);
@@ -117,7 +117,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(organizations as Organization[])?.map((org) => ( {(organizations as Organization[])?.map((org) => (
<SelectItem key={org.uuid} value={(org.id ?? org.uuid).toString()}> <SelectItem key={org.uuid} value={org.uuid}>
{org.organizationCode} - {org.organizationName} {org.organizationCode} - {org.organizationName}
</SelectItem> </SelectItem>
))} ))}
@@ -137,7 +137,7 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(organizations as Organization[])?.map((org) => ( {(organizations as Organization[])?.map((org) => (
<SelectItem key={org.uuid} value={(org.id ?? org.uuid).toString()}> <SelectItem key={org.uuid} value={org.uuid}>
{org.organizationCode} - {org.organizationName} {org.organizationCode} - {org.organizationName}
</SelectItem> </SelectItem>
))} ))}
+1 -1
View File
@@ -30,7 +30,7 @@ export function RFADetail({ data }: RFADetailProps) {
processMutation.mutate( processMutation.mutate(
{ {
id: data.rfaId, uuid: data.uuid,
data: { data: {
action: apiAction, action: apiAction,
comments: comments, comments: comments,
+41 -9
View File
@@ -19,6 +19,7 @@ import {
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCreateRFA } from "@/hooks/use-rfa"; import { useCreateRFA } from "@/hooks/use-rfa";
import { useDisciplines, useContracts } from "@/hooks/use-master-data"; import { useDisciplines, useContracts } from "@/hooks/use-master-data";
import { useProjects } from "@/hooks/use-projects";
import { CreateRFADto } from "@/types/rfa"; import { CreateRFADto } from "@/types/rfa";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { correspondenceService } from "@/lib/services/correspondence.service"; import { correspondenceService } from "@/lib/services/correspondence.service";
@@ -30,6 +31,7 @@ const rfaItemSchema = z.object({
unit: z.string().min(1, "Unit is required"), unit: z.string().min(1, "Unit is required"),
}); });
const rfaSchema = z.object({ const rfaSchema = z.object({
projectId: z.string().min(1, "Project is required"), // ADR-019: UUID
contractId: z.string().min(1, "Contract is required"), contractId: z.string().min(1, "Contract is required"),
disciplineId: z.number().min(1, "Discipline is required"), disciplineId: z.number().min(1, "Discipline is required"),
rfaTypeId: z.number().min(1, "Type is required"), rfaTypeId: z.number().min(1, "Type is required"),
@@ -49,9 +51,9 @@ export function RFAForm() {
const router = useRouter(); const router = useRouter();
const createMutation = useCreateRFA(); const createMutation = useCreateRFA();
// Dynamic Contract Loading (Default Project Context: 1) // ADR-019: Dynamic project selection
const currentProjectId = 1; const { data: projectsData, isLoading: isLoadingProjects } = useProjects();
const { data: contracts, isLoading: isLoadingContracts } = useContracts(currentProjectId); const projects = projectsData?.data || projectsData || [];
const { const {
register, register,
@@ -63,6 +65,7 @@ export function RFAForm() {
} = useForm<RFAFormData>({ } = useForm<RFAFormData>({
resolver: zodResolver(rfaSchema), resolver: zodResolver(rfaSchema),
defaultValues: { defaultValues: {
projectId: "",
contractId: "", contractId: "",
disciplineId: 0, disciplineId: 0,
rfaTypeId: 0, rfaTypeId: 0,
@@ -77,6 +80,9 @@ export function RFAForm() {
}, },
}); });
const selectedProjectId = watch("projectId");
const { data: contracts, isLoading: isLoadingContracts } = useContracts(selectedProjectId);
const selectedContractId = watch("contractId"); const selectedContractId = watch("contractId");
const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId); const { data: disciplines, isLoading: isLoadingDisciplines } = useDisciplines(selectedContractId);
@@ -97,7 +103,7 @@ export function RFAForm() {
const fetchPreview = async () => { const fetchPreview = async () => {
try { try {
const res = await correspondenceService.previewNumber({ const res = await correspondenceService.previewNumber({
projectId: currentProjectId, projectId: selectedProjectId,
typeId: rfaTypeId, // RfaTypeId acts as TypeId typeId: rfaTypeId, // RfaTypeId acts as TypeId
disciplineId, disciplineId,
// RFA uses 'TO' organization as recipient // RFA uses 'TO' organization as recipient
@@ -112,7 +118,7 @@ export function RFAForm() {
const timer = setTimeout(fetchPreview, 500); const timer = setTimeout(fetchPreview, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [rfaTypeId, disciplineId, toOrganizationId, currentProjectId]); }, [rfaTypeId, disciplineId, toOrganizationId, selectedProjectId]);
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control, control,
@@ -122,7 +128,7 @@ export function RFAForm() {
const onSubmit = (data: RFAFormData) => { const onSubmit = (data: RFAFormData) => {
const payload: CreateRFADto = { const payload: CreateRFADto = {
...data, ...data,
projectId: currentProjectId, // ADR-019: projectId is already a UUID string from the form
}; };
createMutation.mutate(payload as any, { createMutation.mutate(payload as any, {
onSuccess: () => { onSuccess: () => {
@@ -178,19 +184,45 @@ export function RFAForm() {
<Input id="description" {...register("description")} placeholder="Enter key description" /> <Input id="description" {...register("description")} placeholder="Enter key description" />
</div> </div>
{/* ADR-019: Project selector */}
<div>
<Label>Project *</Label>
<Select
onValueChange={(val) => {
setValue("projectId", val);
setValue("contractId", ""); // Reset contract when project changes
}}
disabled={isLoadingProjects}
>
<SelectTrigger>
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
</SelectTrigger>
<SelectContent>
{(Array.isArray(projects) ? projects : []).map((p: { uuid: string; projectName?: string; projectCode?: string }) => (
<SelectItem key={p.uuid} value={p.uuid}>
{p.projectName || p.projectCode}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.projectId && (
<p className="text-sm text-destructive mt-1">{errors.projectId.message}</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<Label>Contract *</Label> <Label>Contract *</Label>
<Select <Select
onValueChange={(val) => setValue("contractId", val)} onValueChange={(val) => setValue("contractId", val)}
disabled={isLoadingContracts} disabled={!selectedProjectId || isLoadingContracts}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={isLoadingContracts ? "Loading..." : "Select Contract"} /> <SelectValue placeholder={isLoadingContracts ? "Loading..." : "Select Contract"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{contracts?.map((c: any) => ( {contracts?.map((c: { uuid: string; contractName?: string; name?: string; contractCode?: string }) => (
<SelectItem key={c.id} value={String(c.id)}> <SelectItem key={c.uuid} value={c.uuid}>
{c.contractName || c.name || c.contractCode} {c.contractName || c.name || c.contractCode}
</SelectItem> </SelectItem>
))} ))}
+2 -2
View File
@@ -89,7 +89,7 @@ export function RFAList({ data }: RFAListProps) {
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<Link href={`/rfas/${row.original.id}`}> <Link href={`/rfas/${row.original.uuid}`}>
<Button variant="ghost" size="icon" title="View Details"> <Button variant="ghost" size="icon" title="View Details">
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
@@ -97,7 +97,7 @@ export function RFAList({ data }: RFAListProps) {
<Button variant="ghost" size="icon" title="View File" onClick={handleViewFile}> <Button variant="ghost" size="icon" title="View File" onClick={handleViewFile}>
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
</Button> </Button>
<Link href={`/rfas/${row.original.id}/edit`}> <Link href={`/rfas/${row.original.uuid}/edit`}>
<Button variant="ghost" size="icon" title="Edit"> <Button variant="ghost" size="icon" title="Edit">
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
@@ -9,6 +9,8 @@ import { z } from "zod";
import { transmittalService } from "@/lib/services/transmittal.service"; import { transmittalService } from "@/lib/services/transmittal.service";
import { correspondenceService } from "@/lib/services/correspondence.service"; import { correspondenceService } from "@/lib/services/correspondence.service";
import { projectService } from "@/lib/services/project.service";
import { organizationService } from "@/lib/services/organization.service";
import { CreateTransmittalDto } from "@/types/dto/transmittal/transmittal.dto"; import { CreateTransmittalDto } from "@/types/dto/transmittal/transmittal.dto";
// UI Components // UI Components
@@ -59,6 +61,8 @@ const itemSchema = z.object({
// Main form schema // Main form schema
const formSchema = z.object({ const formSchema = z.object({
projectId: z.string().min(1, "Project is required"), // ADR-019: UUID
recipientOrganizationId: z.string().min(1, "Recipient is required"), // ADR-019: UUID
correspondenceId: z.string().min(1, "Correspondence is required"), // ADR-019: UUID string correspondenceId: z.string().min(1, "Correspondence is required"), // ADR-019: UUID string
subject: z.string().min(1, "Subject is required"), subject: z.string().min(1, "Subject is required"),
purpose: z.enum(["FOR_APPROVAL", "FOR_INFORMATION", "FOR_REVIEW", "OTHER"]), purpose: z.enum(["FOR_APPROVAL", "FOR_INFORMATION", "FOR_REVIEW", "OTHER"]),
@@ -75,6 +79,9 @@ export function TransmittalForm() {
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
projectId: "",
recipientOrganizationId: "",
correspondenceId: "",
subject: "", subject: "",
purpose: "FOR_APPROVAL", purpose: "FOR_APPROVAL",
remarks: "", remarks: "",
@@ -89,6 +96,19 @@ export function TransmittalForm() {
name: "items", name: "items",
}); });
// ADR-019: Fetch projects and organizations for UUID-based selectors
const { data: projectsData, isLoading: isLoadingProjects } = useQuery({
queryKey: ["projects-dropdown"],
queryFn: () => projectService.getAll(),
});
const projectsList = projectsData?.data || projectsData || [];
const { data: orgsData, isLoading: isLoadingOrgs } = useQuery({
queryKey: ["organizations-dropdown"],
queryFn: () => organizationService.getAll(),
});
const orgsList = orgsData?.data || orgsData || [];
// Fetch correspondences (for header linkage) // Fetch correspondences (for header linkage)
const { data: correspondences } = useQuery({ const { data: correspondences } = useQuery({
queryKey: ["correspondences-dropdown"], queryKey: ["correspondences-dropdown"],
@@ -99,7 +119,8 @@ export function TransmittalForm() {
mutationFn: (data: CreateTransmittalDto) => transmittalService.create(data), mutationFn: (data: CreateTransmittalDto) => transmittalService.create(data),
onSuccess: (result) => { onSuccess: (result) => {
toast.success("Transmittal created successfully"); toast.success("Transmittal created successfully");
router.push(`/transmittals/${result.id}`); // ADR-019: Navigate using UUID from correspondence
router.push(`/transmittals/${result.correspondence?.uuid || result.uuid}`);
}, },
onError: () => { onError: () => {
toast.error("Failed to create transmittal"); toast.error("Failed to create transmittal");
@@ -107,13 +128,13 @@ export function TransmittalForm() {
}); });
const onSubmit = (data: FormData) => { const onSubmit = (data: FormData) => {
// Better fix: Add missing recipientOrganizationId mock // ADR-019: All IDs are now UUID strings from the form
const cleanPayload: CreateTransmittalDto = { const cleanPayload: CreateTransmittalDto = {
projectId: 1, projectId: data.projectId,
recipientOrganizationId: 99, // Mock recipientOrganizationId: data.recipientOrganizationId,
correspondenceId: data.correspondenceId, correspondenceId: data.correspondenceId,
subject: data.subject, subject: data.subject,
purpose: data.purpose as any, purpose: data.purpose as string,
remarks: data.remarks, remarks: data.remarks,
items: data.items.map(item => ({ items: data.items.map(item => ({
itemType: item.itemType, itemType: item.itemType,
@@ -139,6 +160,59 @@ export function TransmittalForm() {
<CardTitle>Transmittal Details</CardTitle> <CardTitle>Transmittal Details</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* ADR-019: Project & Recipient Organization selectors */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="projectId"
render={({ field }) => (
<FormItem>
<FormLabel>Project</FormLabel>
<Select onValueChange={field.onChange} value={field.value} disabled={isLoadingProjects}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={isLoadingProjects ? "Loading..." : "Select Project"} />
</SelectTrigger>
</FormControl>
<SelectContent>
{(Array.isArray(projectsList) ? projectsList : []).map((p: { uuid: string; projectName?: string; projectCode?: string }) => (
<SelectItem key={p.uuid} value={p.uuid}>
{p.projectName || p.projectCode}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="recipientOrganizationId"
render={({ field }) => (
<FormItem>
<FormLabel>Recipient Organization</FormLabel>
<Select onValueChange={field.onChange} value={field.value} disabled={isLoadingOrgs}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={isLoadingOrgs ? "Loading..." : "Select Organization"} />
</SelectTrigger>
</FormControl>
<SelectContent>
{(Array.isArray(orgsList) ? orgsList : []).map((o: { uuid: string; organizationName?: string; orgCode?: string }) => (
<SelectItem key={o.uuid} value={o.uuid}>
{o.organizationName || o.orgCode}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Linked Correspondence (Ref No) */} {/* Linked Correspondence (Ref No) */}
<FormField <FormField
@@ -59,7 +59,7 @@ export function TransmittalList({ data }: TransmittalListProps) {
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original; const item = row.original;
return ( return (
<Link href={`/transmittals/${item.id}`}> <Link href={`/transmittals/${item.uuid}`}>
<Button variant="ghost" size="icon" title="View Details"> <Button variant="ghost" size="icon" title="View Details">
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
+13 -13
View File
@@ -11,7 +11,7 @@ export const rfaKeys = {
lists: () => [...rfaKeys.all, 'list'] as const, lists: () => [...rfaKeys.all, 'list'] as const,
list: (params: SearchRfaDto) => [...rfaKeys.lists(), params] as const, list: (params: SearchRfaDto) => [...rfaKeys.lists(), params] as const,
details: () => [...rfaKeys.all, 'detail'] as const, details: () => [...rfaKeys.all, 'detail'] as const,
detail: (id: number | string) => [...rfaKeys.details(), id] as const, detail: (uuid: string) => [...rfaKeys.details(), uuid] as const,
}; };
// --- Queries --- // --- Queries ---
@@ -24,11 +24,11 @@ export function useRFAs(params: SearchRfaDto) {
}); });
} }
export function useRFA(id: number | string) { export function useRFA(uuid: string) {
return useQuery({ return useQuery({
queryKey: rfaKeys.detail(id), queryKey: rfaKeys.detail(uuid),
queryFn: () => rfaService.getById(id), queryFn: () => rfaService.getByUuid(uuid),
enabled: !!id, enabled: !!uuid,
}); });
} }
@@ -55,11 +55,11 @@ export function useUpdateRFA() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: number | string; data: UpdateRfaDto }) => mutationFn: ({ uuid, data }: { uuid: string; data: UpdateRfaDto }) =>
rfaService.update(id, data), rfaService.update(uuid, data),
onSuccess: (_, { id }) => { onSuccess: (_, { uuid }) => {
toast.success('RFA updated successfully'); toast.success('RFA updated successfully');
queryClient.invalidateQueries({ queryKey: rfaKeys.detail(id) }); queryClient.invalidateQueries({ queryKey: rfaKeys.detail(uuid) });
queryClient.invalidateQueries({ queryKey: rfaKeys.lists() }); queryClient.invalidateQueries({ queryKey: rfaKeys.lists() });
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
@@ -74,11 +74,11 @@ export function useProcessRFA() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, data }: { id: number | string; data: WorkflowActionDto }) => mutationFn: ({ uuid, data }: { uuid: string; data: WorkflowActionDto }) =>
rfaService.processWorkflow(id, data), rfaService.processWorkflow(uuid, data),
onSuccess: (_, { id }) => { onSuccess: (_, { uuid }) => {
toast.success('Workflow status updated successfully'); toast.success('Workflow status updated successfully');
queryClient.invalidateQueries({ queryKey: rfaKeys.detail(id) }); queryClient.invalidateQueries({ queryKey: rfaKeys.detail(uuid) });
queryClient.invalidateQueries({ queryKey: rfaKeys.lists() }); queryClient.invalidateQueries({ queryKey: rfaKeys.lists() });
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
+12 -11
View File
@@ -26,9 +26,9 @@ export const rfaService = {
/** /**
* RFA Workflow * RFA Workflow
*/ */
getById: async (id: string | number) => { getByUuid: async (uuid: string) => {
// GET /rfas/:id // GET /rfas/:uuid (ADR-019)
const response = await apiClient.get(`/rfas/${id}`); const response = await apiClient.get(`/rfas/${uuid}`);
return response.data; return response.data;
}, },
@@ -44,26 +44,27 @@ export const rfaService = {
/** /**
* RFA ( Draft) * RFA ( Draft)
*/ */
update: async (id: string | number, data: UpdateRfaDto) => { update: async (uuid: string, data: UpdateRfaDto) => {
// PUT /rfas/:id // PUT /rfas/:uuid (ADR-019)
const response = await apiClient.put(`/rfas/${id}`, data); const response = await apiClient.put(`/rfas/${uuid}`, data);
return response.data; return response.data;
}, },
/** /**
* Workflow ( / / ) * Workflow ( / / )
*/ */
processWorkflow: async (id: string | number, actionData: WorkflowActionDto) => { processWorkflow: async (uuid: string, actionData: WorkflowActionDto) => {
// POST /rfas/:id/workflow // POST /rfas/:uuid/workflow (ADR-019)
const response = await apiClient.post(`/rfas/${id}/workflow`, actionData); const response = await apiClient.post(`/rfas/${uuid}/workflow`, actionData);
return response.data; return response.data;
}, },
/** /**
* (Optional) RFA (Soft Delete) * (Optional) RFA (Soft Delete)
*/ */
delete: async (id: string | number) => { delete: async (uuid: string) => {
const response = await apiClient.delete(`/rfas/${id}`); // DELETE /rfas/:uuid (ADR-019)
const response = await apiClient.delete(`/rfas/${uuid}`);
return response.data; return response.data;
} }
}; };
+10 -10
View File
@@ -17,11 +17,11 @@ export const transmittalService = {
}, },
/** /**
* Transmittal ID * Transmittal UUID (ADR-019)
*/ */
getById: async (id: string | number) => { getByUuid: async (uuid: string) => {
// GET /transmittals/:id // GET /transmittals/:uuid
const response = await apiClient.get(`/transmittals/${id}`); const response = await apiClient.get(`/transmittals/${uuid}`);
return response.data; return response.data;
}, },
@@ -37,18 +37,18 @@ export const transmittalService = {
/** /**
* Transmittal ( Draft) * Transmittal ( Draft)
*/ */
update: async (id: string | number, data: UpdateTransmittalDto) => { update: async (uuid: string, data: UpdateTransmittalDto) => {
// PUT /transmittals/:id // PUT /transmittals/:uuid (ADR-019)
const response = await apiClient.put(`/transmittals/${id}`, data); const response = await apiClient.put(`/transmittals/${uuid}`, data);
return response.data; return response.data;
}, },
/** /**
* (Soft Delete) * (Soft Delete)
*/ */
delete: async (id: string | number) => { delete: async (uuid: string) => {
// DELETE /transmittals/:id // DELETE /transmittals/:uuid (ADR-019)
const response = await apiClient.delete(`/transmittals/${id}`); const response = await apiClient.delete(`/transmittals/${uuid}`);
return response.data; return response.data;
} }
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lcbp3-frontend", "name": "lcbp3-frontend",
"version": "1.5.1", "version": "1.8.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+5 -2
View File
@@ -8,7 +8,8 @@ export interface RFAItem {
} }
export interface RFA { export interface RFA {
id: number; // Shared PK with Correspondence uuid: string; // ADR-019: from correspondence.uuid
id?: number; // Excluded from API responses (ADR-019)
rfaTypeId: number; rfaTypeId: number;
createdBy: number; createdBy: number;
disciplineId?: number; disciplineId?: number;
@@ -35,12 +36,14 @@ export interface RFA {
}; };
// Shared Correspondence Relation // Shared Correspondence Relation
correspondence?: { correspondence?: {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
correspondenceNumber: string; correspondenceNumber: string;
projectId: number; projectId: number;
originatorId?: number; originatorId?: number;
createdAt?: string; createdAt?: string;
project?: { project?: {
uuid: string;
projectName: string; projectName: string;
projectCode: string; projectCode: string;
}; };
+9 -7
View File
@@ -28,8 +28,9 @@ export interface TransmittalItem {
* Main Transmittal entity * Main Transmittal entity
*/ */
export interface Transmittal { export interface Transmittal {
id: number; uuid: string; // ADR-019: from correspondence.uuid
correspondenceId: number; id?: number; // Excluded from API responses (ADR-019)
correspondenceId?: number | string;
transmittalNo: string; transmittalNo: string;
subject: string; subject: string;
purpose?: TransmittalPurpose; purpose?: TransmittalPurpose;
@@ -38,7 +39,8 @@ export interface Transmittal {
// Joined relations from API // Joined relations from API
items?: TransmittalItem[]; items?: TransmittalItem[];
correspondence?: { correspondence?: {
id: number; uuid: string;
id?: number; // Excluded from API responses (ADR-019)
correspondence_number: string; correspondence_number: string;
project_id: number; project_id: number;
}; };
@@ -70,9 +72,9 @@ export interface CreateTransmittalItemDto {
* DTO for creating a transmittal * DTO for creating a transmittal
*/ */
export interface CreateTransmittalDto { export interface CreateTransmittalDto {
projectId?: number; projectId?: number | string; // ADR-019: Accept UUID
recipientOrganizationId?: number; recipientOrganizationId?: number | string; // ADR-019: Accept UUID
correspondenceId: number; correspondenceId: number | string; // ADR-019: Accept UUID
subject: string; subject: string;
purpose?: TransmittalPurpose; purpose?: TransmittalPurpose;
remarks?: string; remarks?: string;
@@ -85,6 +87,6 @@ export interface CreateTransmittalDto {
export interface SearchTransmittalDto { export interface SearchTransmittalDto {
page?: number; page?: number;
limit?: number; limit?: number;
projectId?: number; projectId?: number | string; // ADR-019: Accept UUID
search?: string; search?: string;
} }
+1 -1
View File
@@ -928,6 +928,6 @@
], ],
}, },
"extensions": { "extensions": {
"recommendations": ["jlcodes.antigravity-cockpit"], "recommendations": [],
}, },
} }