feat(ai): implement unified prompt management UX/UI (ADR-037)
CI / CD Pipeline / build (push) Failing after 3m23s
CI / CD Pipeline / deploy (push) Has been skipped

- Add context config endpoints (GET/PUT /api/ai/prompts/:type/:version/context-config)
- Add execution profile endpoints (CRUD /api/ai/execution-profiles)
- Add sandbox RAG Prep endpoint (POST /api/ai/admin/sandbox/rag-prep)
- Create Prompt Management UI with multi-type support
- Add ContextConfigEditor, PromptEditor, RuntimeParametersPanel components
- Add SandboxTabs for 3-step workflow (OCR, Extract, RAG Prep)
- Add database deltas for ai_execution_profiles and additional prompt types
- Update quickstart.md with production backend URLs
- Add comprehensive test coverage for new features
This commit is contained in:
2026-06-14 19:55:43 +07:00
parent 56f9544cb0
commit 67da186672
64 changed files with 6327 additions and 6107 deletions
@@ -4,6 +4,7 @@ import {
Post,
Body,
UseGuards,
UseInterceptors,
Request,
Param,
Query,
@@ -36,6 +37,8 @@ import { RbacGuard } from '../../common/guards/rbac.guard';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Audit } from '../../common/decorators/audit.decorator';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { ValidationException } from '../../common/exceptions';
import { IdempotencyInterceptor } from '../../common/interceptors/idempotency.interceptor';
import type { RequestWithUser } from '../../common/interfaces/request-with-user.interface';
@ApiTags('Correspondences')
@@ -52,6 +55,8 @@ export class CorrespondenceController {
@ApiOperation({ summary: 'Process workflow action (Approve/Reject/Review)' })
@ApiResponse({ status: 201, description: 'Action processed successfully.' })
@RequirePermission('workflow.action_review')
@Audit('correspondence.workflow_action', 'correspondence')
@UseInterceptors(IdempotencyInterceptor)
processAction(
@Body() actionDto: WorkflowActionDto,
@Request() req: RequestWithUser
@@ -62,7 +67,9 @@ export class CorrespondenceController {
// Use Unified Workflow Engine via CorrespondenceWorkflowService
if (!actionDto.instanceId) {
throw new Error('instanceId is required for workflow action');
throw new ValidationException(
'instanceId is required for workflow action'
);
}
return this.workflowService.processAction(
@@ -85,6 +92,7 @@ export class CorrespondenceController {
})
@RequirePermission('correspondence.create')
@Audit('correspondence.create', 'correspondence')
@UseInterceptors(IdempotencyInterceptor)
create(
@Body() createDto: CreateCorrespondenceDto,
@Request() req: RequestWithUser
@@ -125,6 +133,7 @@ export class CorrespondenceController {
})
@RequirePermission('correspondence.create')
@Audit('correspondence.submit', 'correspondence')
@UseInterceptors(IdempotencyInterceptor)
async submit(
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() submitDto: SubmitCorrespondenceDto,
@@ -158,8 +167,9 @@ export class CorrespondenceController {
status: 200,
description: 'Correspondence updated successfully.',
})
@RequirePermission('correspondence.create') // Assuming create permission is enough for draft update, or add 'correspondence.edit'
@RequirePermission('correspondence.edit')
@Audit('correspondence.update', 'correspondence')
@UseInterceptors(IdempotencyInterceptor)
async update(
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() updateDto: UpdateCorrespondenceDto,
@@ -241,6 +251,7 @@ export class CorrespondenceController {
@ApiOperation({ summary: 'Bulk cancel correspondences (Org Admin+)' })
@RequirePermission('correspondence.cancel')
@Audit('correspondence.bulk_cancel', 'correspondence')
@UseInterceptors(IdempotencyInterceptor)
async bulkCancel(
@Body() dto: BulkCancelDto,
@Request() req: RequestWithUser
@@ -274,6 +285,7 @@ export class CorrespondenceController {
})
@RequirePermission('correspondence.cancel')
@Audit('correspondence.cancel', 'correspondence')
@UseInterceptors(IdempotencyInterceptor)
async cancel(
@Param('uuid', ParseUuidPipe) uuid: string,
@Body() cancelDto: CancelCorrespondenceDto,
@@ -890,6 +890,11 @@ export class CorrespondenceService {
const updated = await this.findOne(id);
// Re-index updated document in Elasticsearch (fire-and-forget)
// ใช้ status จริงจาก current revision แทนการ hardcode 'DRAFT'
const currentRevisionStatus =
updated.revisions?.find((r) => r.isCurrent)?.status?.statusCode ??
updated.revisions?.[0]?.status?.statusCode ??
'DRAFT';
void this.searchService.indexDocument({
id: updated.id,
publicId: updated.publicId,
@@ -897,7 +902,7 @@ export class CorrespondenceService {
docNumber: updated.correspondenceNumber,
title: updateDto.subject ?? updated.revisions?.[0]?.subject,
description: updateDto.description ?? updated.revisions?.[0]?.description,
status: 'DRAFT',
status: currentRevisionStatus,
projectId: updated.projectId,
createdAt: updated.createdAt,
});
@@ -1141,7 +1146,10 @@ export class CorrespondenceService {
try {
await this.cancel(publicId, reason, user);
succeeded.push(publicId);
} catch {
} catch (err) {
this.logger.warn(
`Bulk cancel failed for ${publicId}: ${(err as Error).message}`
);
failed.push(publicId);
}
}
@@ -1150,7 +1158,12 @@ export class CorrespondenceService {
}
async exportCsv(searchDto: SearchCorrespondenceDto): Promise<string> {
const { data } = await this.findAll(searchDto);
// ดึงทุกแถวที่ตรงเงื่อนไข — ไม่ใช้ pagination สำหรับ export
const { data } = await this.findAll({
...searchDto,
page: 1,
limit: 10000,
});
const header = [
'Document No.',
@@ -1182,9 +1195,12 @@ export class CorrespondenceService {
}
private escapeCsv(value: string): string {
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return `"${value.replace(/"/g, '""')}"`;
// กัน CSV formula injection (OWASP)
let v = value;
if (/^[=+\-@\t\r]/.test(v)) v = `'${v}`;
if (v.includes(',') || v.includes('"') || v.includes('\n')) {
return `"${v.replace(/"/g, '""')}"`;
}
return value;
return v;
}
}
@@ -7,9 +7,32 @@ import {
IsObject,
IsDateString,
IsArray,
IsIn,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO ของผู้รับเอกสาร — ใช้กับ @ValidateNested เพื่อตรวจสอบแต่ละ element ใน recipients array
*/
export class RecipientDto {
@ApiProperty({
description: 'Organization ID or UUID ของผู้รับ',
example: '019505a1-7c3e-7000-8000-abc123def456',
})
@IsNotEmpty()
organizationId!: number | string;
@ApiProperty({
description: 'ประเภทผู้รับ: TO หรือ CC',
enum: ['TO', 'CC'],
example: 'TO',
})
@IsIn(['TO', 'CC'])
type!: 'TO' | 'CC';
}
export class CreateCorrespondenceDto {
@ApiProperty({ description: 'Project ID or UUID', example: 1 })
@IsNotEmpty()
@@ -125,9 +148,15 @@ export class CreateCorrespondenceDto {
@ApiPropertyOptional({
description: 'Recipients',
example: [{ organizationId: 1, type: 'TO' }],
example: [
{ organizationId: '019505a1-7c3e-7000-8000-abc123def456', type: 'TO' },
],
type: () => RecipientDto,
isArray: true,
})
@IsArray()
@IsOptional()
recipients?: { organizationId: number | string; type: 'TO' | 'CC' }[];
@ValidateNested({ each: true })
@Type(() => RecipientDto)
recipients?: RecipientDto[];
}