690419:1831 feat: update CI/CD to use SSH key authentication #05
CI / CD Pipeline / build (push) Failing after 4m57s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-04-19 18:31:30 +07:00
parent 733f3c3987
commit 13745e5874
61 changed files with 6709 additions and 1241 deletions
+57
View File
@@ -0,0 +1,57 @@
# ========================================
# LCBP3 Backend — Environment Variables
# Copy to .env and fill in real values
# หมายเหตุ: ค่า DB_PASSWORD, REDIS_PASSWORD,
# ELASTICSEARCH_PASSWORD ต้องตรงกับที่ตั้งไว้ใน
# services stack (MariaDB/Redis/Elasticsearch
# ดูจาก .env ของ services stack ที่รันอยู่แล้วบน QNAP
# ========================================
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=admin
DB_PASSWORD=Center2025
DB_DATABASE=lcbp3_dev
# Redis
REDIS_HOST=localhost
REDIS_PORT=16379
REDIS_PASSWORD=Center2025
# JWT
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=7d
# File Storage
UPLOAD_DEST=./uploads
MAX_FILE_SIZE=52428800
# ClamAV
CLAMAV_HOST=localhost
CLAMAV_PORT=3310
# ========================================
# ADR-022 RAG — Retrieval-Augmented Generation
# ========================================
# Qdrant vector store (local docker-compose or QNAP)
QDRANT_URL=http://localhost:6333
# Ollama (Admin Desktop Desk-5439 — ADR-018 AI boundary)
OLLAMA_EMBED_MODEL=nomic-embed-text
OLLAMA_RAG_MODEL=gemma3:12b
OLLAMA_URL=http://192.168.10.100:11434
# Thai preprocessing microservice (PyThaiNLP — Admin Desktop)
THAI_PREPROCESS_URL=http://192.168.10.100:8765
# Typhoon API (cloud LLM — PUBLIC/INTERNAL only, never CONFIDENTIAL)
TYPHOON_API_KEY=your-typhoon-api-key-here
TYPHOON_API_URL=https://api.opentyphoon.ai/v1
# RAG query config
RAG_TOPK=20
RAG_FINAL_K=5
RAG_TIMEOUT_MS=5000
RAG_QUERY_CACHE_TTL=300
+14
View File
@@ -54,10 +54,24 @@ services:
- esdata:/usr/share/elasticsearch/data - esdata:/usr/share/elasticsearch/data
networks: networks:
- lcbp3-net - lcbp3-net
# ADR-022 RAG: Qdrant vector store (tiered multitenancy for project isolation)
qdrant:
image: qdrant/qdrant:v1.16.1
container_name: lcbp3-qdrant-local
restart: always
ports:
- '6333:6333' # REST API
- '6334:6334' # gRPC
volumes:
- qdrant_data:/qdrant/storage
networks:
- lcbp3-net
volumes: volumes:
db_data: db_data:
redis_data: # เพิ่ม Volume redis_data: # เพิ่ม Volume
esdata: esdata:
qdrant_data: # ADR-022 RAG vector store
networks: networks:
lcbp3-net: lcbp3-net:
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -24,19 +24,19 @@
"seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts" "seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts"
}, },
"dependencies": { "dependencies": {
"@casl/ability": "^6.7.5", "@casl/ability": "6.8.0",
"@elastic/elasticsearch": "^8.13.0", "@elastic/elasticsearch": "^8.13.0",
"@nestjs-modules/ioredis": "^2.0.2", "@nestjs-modules/ioredis": "^2.0.2",
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
"@nestjs/bullmq": "^11.0.4", "@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.0.1", "@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.1.19",
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.1.19",
"@nestjs/elasticsearch": "^11.1.0", "@nestjs/elasticsearch": "^11.1.0",
"@nestjs/jwt": "^11.0.1", "@nestjs/jwt": "^11.0.1",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.1.19",
"@nestjs/platform-socket.io": "^11.1.9", "@nestjs/platform-socket.io": "^11.1.9",
"@nestjs/schedule": "^6.0.1", "@nestjs/schedule": "^6.0.1",
"@nestjs/swagger": "^11.2.3", "@nestjs/swagger": "^11.2.3",
@@ -44,12 +44,13 @@
"@nestjs/throttler": "^6.4.0", "@nestjs/throttler": "^6.4.0",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@nestjs/websockets": "^11.1.9", "@nestjs/websockets": "^11.1.9",
"@qdrant/js-client-rest": "^1.17.0",
"@types/nodemailer": "^7.0.4", "@types/nodemailer": "^7.0.4",
"@willsoto/nestjs-prometheus": "^6.0.2", "@willsoto/nestjs-prometheus": "^6.0.2",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"async-retry": "^1.3.3", "async-retry": "^1.3.3",
"axios": "^1.13.2", "axios": "^1.15.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bullmq": "^5.63.2", "bullmq": "^5.63.2",
"cache-manager": "^7.2.5", "cache-manager": "^7.2.5",
@@ -81,7 +82,7 @@
"zod": "^4.1.13" "zod": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@compodoc/compodoc": "^1.1.32", "@compodoc/compodoc": "^1.1.23",
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
+2
View File
@@ -52,6 +52,7 @@ import { SearchModule } from './modules/search/search.module';
import { AuditLogModule } from './modules/audit-log/audit-log.module'; import { AuditLogModule } from './modules/audit-log/audit-log.module';
import { MigrationModule } from './modules/migration/migration.module'; import { MigrationModule } from './modules/migration/migration.module';
import { AiModule } from './modules/ai/ai.module'; import { AiModule } from './modules/ai/ai.module';
import { RagModule } from './modules/rag/rag.module';
@Module({ @Module({
imports: [ imports: [
@@ -189,6 +190,7 @@ import { AiModule } from './modules/ai/ai.module';
AuditLogModule, AuditLogModule,
MigrationModule, MigrationModule,
AiModule, AiModule,
RagModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
@@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { AbilityFactory, ScopeContext } from './ability.factory'; import { AbilityFactory, ScopeContext } from './ability.factory';
import { User } from '../../../modules/user/entities/user.entity'; import { User } from '../../../modules/user/entities/user.entity';
import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity'; import { UserAssignment } from '../../../modules/user/entities/user-assignment.entity';
import { Role } from '../../../modules/auth/entities/role.entity'; import { Role } from '../../../modules/user/entities/role.entity';
describe('AbilityFactory', () => { describe('AbilityFactory', () => {
let factory: AbilityFactory; let factory: AbilityFactory;
@@ -14,6 +14,7 @@ export enum ErrorType {
DATABASE_ERROR = 'DATABASE_ERROR', DATABASE_ERROR = 'DATABASE_ERROR',
EXTERNAL_SERVICE = 'EXTERNAL_SERVICE', EXTERNAL_SERVICE = 'EXTERNAL_SERVICE',
INFRASTRUCTURE = 'INFRASTRUCTURE', INFRASTRUCTURE = 'INFRASTRUCTURE',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', // 503 — ระบบไม่พร้อมให้บริการชั่วคราว (Redlock fail, Redis down)
} }
// ระดับความรุนแรงของ Error // ระดับความรุนแรงของ Error
@@ -49,6 +50,8 @@ export function getStatusCode(type: ErrorType): number {
case ErrorType.EXTERNAL_SERVICE: case ErrorType.EXTERNAL_SERVICE:
case ErrorType.INFRASTRUCTURE: case ErrorType.INFRASTRUCTURE:
return HttpStatus.INTERNAL_SERVER_ERROR; return HttpStatus.INTERNAL_SERVER_ERROR;
case ErrorType.SERVICE_UNAVAILABLE:
return HttpStatus.SERVICE_UNAVAILABLE; // 503
default: default:
return HttpStatus.INTERNAL_SERVER_ERROR; return HttpStatus.INTERNAL_SERVER_ERROR;
} }
@@ -233,3 +236,27 @@ export class DatabaseException extends BaseException {
); );
} }
} }
// Service Unavailable (503) - ระบบไม่พร้อมให้บริการชั่วคราว
// ADR-021 C1: Redlock Fail-closed — retry ครบแล้ว ยัง acquire ไม่ได้
export class ServiceUnavailableException extends BaseException {
constructor(
code: string,
message: string,
userMessage?: string,
recoveryActions?: string[]
) {
super(
ErrorType.SERVICE_UNAVAILABLE,
code,
message,
userMessage || 'ระบบยุ่งชั่วคราว กรุณาลองใหม่ภายหลัง',
ErrorSeverity.HIGH,
undefined,
recoveryActions || [
'รอสักครู่แล้วลองใหม่',
'แจ้งผู้ดูแลระบบหากยังพบปัญหา',
]
);
}
}
+1
View File
@@ -14,6 +14,7 @@ export {
WorkflowException, WorkflowException,
SystemException, SystemException,
DatabaseException, DatabaseException,
ServiceUnavailableException,
} from './base.exception'; } from './base.exception';
export type { ValidationErrorDetail, ErrorPayload } from './base.exception'; export type { ValidationErrorDetail, ErrorPayload } from './base.exception';
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq';
import { ScheduleModule } from '@nestjs/schedule'; // ✅ Import import { ScheduleModule } from '@nestjs/schedule'; // ✅ Import
import { FileStorageService } from './file-storage.service.js'; import { FileStorageService } from './file-storage.service.js';
import { FileStorageController } from './file-storage.controller.js'; import { FileStorageController } from './file-storage.controller.js';
@@ -12,6 +13,7 @@ import { UserModule } from '../../modules/user/user.module';
TypeOrmModule.forFeature([Attachment]), TypeOrmModule.forFeature([Attachment]),
ScheduleModule.forRoot(), // ✅ เปิดใช้งาน Cron Job], ScheduleModule.forRoot(), // ✅ เปิดใช้งาน Cron Job],
UserModule, UserModule,
BullModule.registerQueue({ name: 'rag:ocr' }),
], ],
controllers: [FileStorageController], controllers: [FileStorageController],
providers: [ providers: [
@@ -4,10 +4,13 @@ import {
NotFoundException, NotFoundException,
BadRequestException, BadRequestException,
Logger, Logger,
Optional,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import * as fs from 'fs-extra'; import * as fs from 'fs-extra';
import * as path from 'path'; import * as path from 'path';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
@@ -24,7 +27,8 @@ export class FileStorageService {
constructor( constructor(
@InjectRepository(Attachment) @InjectRepository(Attachment)
private attachmentRepository: Repository<Attachment>, private attachmentRepository: Repository<Attachment>,
private configService: ConfigService private configService: ConfigService,
@Optional() @InjectQueue('rag:ocr') private readonly ragOcrQueue?: Queue
) { ) {
// ใช้ env vars จาก docker-compose สำหรับ Production // ใช้ env vars จาก docker-compose สำหรับ Production
// ถ้าไม่ได้กำหนดจะ fallback เป็น ./uploads/temp และ ./uploads/permanent // ถ้าไม่ได้กำหนดจะ fallback เป็น ./uploads/temp และ ./uploads/permanent
@@ -90,7 +94,18 @@ export class FileStorageService {
*/ */
async commit( async commit(
tempIds: string[], tempIds: string[],
options?: { issueDate?: Date; documentType?: string } options?: {
issueDate?: Date;
documentType?: string;
ragMeta?: {
docType: string;
docNumber: string | null;
revision: string | null;
projectCode: string;
projectPublicId: string;
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
};
}
): Promise<Attachment[]> { ): Promise<Attachment[]> {
if (!tempIds || tempIds.length === 0) { if (!tempIds || tempIds.length === 0) {
return []; return [];
@@ -149,7 +164,27 @@ export class FileStorageService {
att.expiresAt = undefined; // เคลียร์วันหมดอายุ att.expiresAt = undefined; // เคลียร์วันหมดอายุ
att.referenceDate = effectiveDate; // Save reference date att.referenceDate = effectiveDate; // Save reference date
committedAttachments.push(await this.attachmentRepository.save(att)); const saved = await this.attachmentRepository.save(att);
committedAttachments.push(saved);
if (this.ragOcrQueue && options?.ragMeta) {
await this.ragOcrQueue
.add(
'ocr',
{
attachmentPublicId: saved.publicId,
filePath: saved.filePath,
...options.ragMeta,
},
{ jobId: saved.publicId }
)
.catch((err: unknown) => {
this.logger.error(
`Failed to enqueue rag:ocr for ${saved.publicId}`,
err instanceof Error ? err.stack : String(err)
);
});
}
} else { } else {
this.logger.error(`File missing during commit: ${oldPath}`); this.logger.error(`File missing during commit: ${oldPath}`);
throw new NotFoundException( throw new NotFoundException(
@@ -10,10 +10,15 @@ import { of, lastValueFrom } from 'rxjs';
import { Request } from 'express'; import { Request } from 'express';
import type { Socket } from 'net'; import type { Socket } from 'net';
type MockAuditLogRepo = {
create: jest.Mock;
save: jest.Mock;
};
describe('AuditLogInterceptor', () => { describe('AuditLogInterceptor', () => {
let interceptor: AuditLogInterceptor; let interceptor: AuditLogInterceptor;
let reflector: Reflector; let reflector: Reflector;
let auditLogRepo: jest.Mocked<Partial<typeof AuditLog.prototype.constructor>>; let auditLogRepo: MockAuditLogRepo;
const createMockUser = (userId: number): User => { const createMockUser = (userId: number): User => {
const user = new User(); const user = new User();
@@ -55,7 +60,7 @@ describe('AuditLogInterceptor', () => {
}); });
beforeEach(async () => { beforeEach(async () => {
const mockRepository = { const mockRepository: MockAuditLogRepo = {
create: jest.fn().mockReturnValue({}), create: jest.fn().mockReturnValue({}),
save: jest.fn().mockResolvedValue({}), save: jest.fn().mockResolvedValue({}),
}; };
@@ -78,7 +83,7 @@ describe('AuditLogInterceptor', () => {
interceptor = module.get<AuditLogInterceptor>(AuditLogInterceptor); interceptor = module.get<AuditLogInterceptor>(AuditLogInterceptor);
reflector = module.get<Reflector>(Reflector); reflector = module.get<Reflector>(Reflector);
auditLogRepo = module.get(getRepositoryToken(AuditLog)); auditLogRepo = module.get<MockAuditLogRepo>(getRepositoryToken(AuditLog));
}); });
afterEach(() => { afterEach(() => {
@@ -99,7 +99,17 @@ describe('CorrespondenceController', () => {
mockResult mockResult
); );
const mockReq = { user: { user_id: 1, roles: [] } }; const mockReq = {
user: {
user_id: 1,
username: 'testuser',
password: 'hashedpassword',
email: 'test@example.com',
publicId: '019505a1-7c3e-7000-8000-abc123def456',
createdAt: new Date(),
updatedAt: new Date(),
},
};
(mockCorrespondenceService.findOneByUuid as jest.Mock).mockResolvedValue({ (mockCorrespondenceService.findOneByUuid as jest.Mock).mockResolvedValue({
id: 1, id: 1,
uuid: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', uuid: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
@@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { MigrationController } from './migration.controller'; import { MigrationController } from './migration.controller';
import { MigrationService } from './migration.service'; import { MigrationService } from './migration.service';
import { ImportCorrespondenceDto } from './dto/import-correspondence.dto'; import { ImportCorrespondenceDto } from './dto/import-correspondence.dto';
import { User } from '../user/entities/user.entity';
describe('MigrationController', () => { describe('MigrationController', () => {
let controller: MigrationController; let controller: MigrationController;
@@ -32,17 +33,29 @@ 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', documentNumber: 'DOC-001',
subject: 'Legacy Record', subject: 'Legacy Record',
category: 'Correspondence', category: 'Correspondence',
source_file_path: '/staging_ai/test.pdf', sourceFilePath: '/staging_ai/test.pdf',
migrated_by: 'SYSTEM_IMPORT', migratedBy: 'SYSTEM_IMPORT',
batch_id: 'batch1', batchId: 'batch1',
project_id: 1, projectId: 1,
}; };
const idempotencyKey = 'key123'; const idempotencyKey = 'key123';
const user = { userId: 5 }; const user: User = {
user_id: 5,
username: 'testuser',
password: 'hashedpassword',
email: 'test@example.com',
publicId: '019505a1-7c3e-7000-8000-abc123def456',
createdAt: new Date(),
updatedAt: new Date(),
isActive: true,
failedAttempts: 0,
primaryOrganizationPublicId: undefined,
generatePublicId: jest.fn(),
};
const result = await controller.importCorrespondence( const result = await controller.importCorrespondence(
dto, dto,
@@ -0,0 +1,86 @@
import { Test, TestingModule } from '@nestjs/testing';
import { IngestionService } from '../ingestion.service';
const QUEUE_TOKEN = 'BullQueue_rag:ocr';
const mockOcrQueue = {
getJob: jest.fn(),
add: jest.fn(),
};
const baseJobData = {
attachmentPublicId: 'att-uuid-001',
filePath: '/uploads/permanent/CORR/2026/04/file.pdf',
docType: 'CORR',
docNumber: 'REF-001',
revision: null,
projectCode: 'PRJ-001',
projectPublicId: 'proj-uuid-001',
classification: 'INTERNAL' as const,
};
describe('IngestionService', () => {
let service: IngestionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
IngestionService,
{ provide: QUEUE_TOKEN, useValue: mockOcrQueue },
],
}).compile();
service = module.get<IngestionService>(IngestionService);
jest.clearAllMocks();
});
it('should enqueue rag:ocr job with attachmentPublicId as jobId', async () => {
mockOcrQueue.getJob.mockResolvedValue(null);
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).toHaveBeenCalledWith('ocr', baseJobData, {
jobId: baseJobData.attachmentPublicId,
});
});
it('EC-RAG-001: duplicate enqueue when job is active → second call is no-op (log only)', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('active') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).not.toHaveBeenCalled();
});
it('EC-RAG-001: duplicate enqueue when job is waiting → second call is no-op', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('waiting') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).not.toHaveBeenCalled();
});
it('should re-enqueue if job exists but is completed (state=completed)', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('completed') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).toHaveBeenCalledTimes(1);
});
it('should re-enqueue if job exists but is failed (state=failed)', async () => {
const mockJob = { getState: jest.fn().mockResolvedValue('failed') };
mockOcrQueue.getJob.mockResolvedValue(mockJob);
mockOcrQueue.add.mockResolvedValue({ id: baseJobData.attachmentPublicId });
await service.enqueue(baseJobData);
expect(mockOcrQueue.add).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,206 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ServiceUnavailableException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { RagService } from '../rag.service';
import { QdrantService } from '../qdrant.service';
import { EmbeddingService } from '../embedding.service';
import { TyphoonService } from '../typhoon.service';
import { IngestionService } from '../ingestion.service';
import { DocumentChunk } from '../entities/document-chunk.entity';
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
const mockQdrant = {
isReady: jest.fn(),
hybridSearch: jest.fn(),
deleteByDocumentId: jest.fn(),
};
const mockEmbedding = {
embed: jest.fn(),
};
const mockTyphoon = {
generate: jest.fn(),
sanitizeInput: jest.fn((t: string) => t),
};
const mockIngestion = { enqueue: jest.fn() };
const mockChunkRepo = {
count: jest.fn(),
delete: jest.fn(),
manager: {
query: jest.fn(),
},
};
const mockRedis = {
get: jest.fn(),
setex: jest.fn(),
};
describe('RagService', () => {
let service: RagService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RagService,
{ provide: QdrantService, useValue: mockQdrant },
{ provide: EmbeddingService, useValue: mockEmbedding },
{ provide: TyphoonService, useValue: mockTyphoon },
{ provide: IngestionService, useValue: mockIngestion },
{ provide: getRepositoryToken(DocumentChunk), useValue: mockChunkRepo },
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
],
}).compile();
service = module.get<RagService>(RagService);
jest.clearAllMocks();
});
describe('query()', () => {
const dto = {
question: 'เอกสารเกี่ยวกับอะไร?',
projectPublicId: 'proj-uuid-1234',
};
const memberPerms: string[] = [];
const adminPerms = ['system.manage_all'];
it('should return answer with citations on PUBLIC cache miss → write cache', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([
{
chunkId: 'chunk-1',
publicId: 'att-1',
docType: 'CORR',
docNumber: 'REF-001',
revision: null,
projectCode: 'PRJ-001',
contentPreview: 'เนื้อหาเอกสาร',
score: 0.92,
},
]);
mockTyphoon.generate.mockResolvedValue({
answer: 'คำตอบ',
usedFallbackModel: false,
});
const result = await service.query(dto, memberPerms);
expect(result.answer).toBe('คำตอบ');
expect(result.citations).toHaveLength(1);
expect(result.usedFallbackModel).toBe(false);
expect(mockRedis.setex).toHaveBeenCalledTimes(1);
});
it('should return cached result without calling Qdrant on cache hit', async () => {
mockQdrant.isReady.mockReturnValue(true);
const cached = JSON.stringify({
answer: 'cached answer',
citations: [],
confidence: 0.9,
usedFallbackModel: false,
});
mockRedis.get.mockResolvedValue(cached);
const result = await service.query(dto, memberPerms);
expect(result.answer).toBe('cached answer');
expect(mockQdrant.hybridSearch).not.toHaveBeenCalled();
expect(mockEmbedding.embed).not.toHaveBeenCalled();
});
it('CONFIDENTIAL: must use Ollama only, skip cache read and write', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockTyphoon.generate.mockResolvedValue({
answer: 'ลับมาก',
usedFallbackModel: true,
});
const result = await service.query(dto, adminPerms);
expect(mockRedis.get).not.toHaveBeenCalled();
expect(mockRedis.setex).not.toHaveBeenCalled();
expect(mockTyphoon.generate).toHaveBeenCalledWith(
expect.any(String),
true
);
expect(result.usedFallbackModel).toBe(true);
});
it('collectionReady=false → throw ServiceUnavailableException RAG_NOT_READY', async () => {
mockQdrant.isReady.mockReturnValue(false);
await expect(service.query(dto, memberPerms)).rejects.toThrow(
ServiceUnavailableException
);
});
it('cross-project cache isolation: same question different projectPublicId → different cache key', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockTyphoon.generate.mockResolvedValue({
answer: 'A',
usedFallbackModel: false,
});
await service.query(
{ question: 'Q?', projectPublicId: 'proj-A' },
memberPerms
);
await service.query(
{ question: 'Q?', projectPublicId: 'proj-B' },
memberPerms
);
const calls = mockRedis.setex.mock.calls as [string, ...unknown[]][];
expect(calls[0][0]).not.toBe(calls[1][0]);
});
it('classification ceiling derived from role, not from request body', async () => {
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockTyphoon.generate.mockResolvedValue({
anwer: 'ok',
usedFallbackModel: false,
});
await service.query(dto, memberPerms);
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
expect.any(Array),
dto.projectPublicId,
'INTERNAL',
20
);
jest.clearAllMocks();
mockQdrant.isReady.mockReturnValue(true);
mockRedis.get.mockResolvedValue(null);
mockEmbedding.embed.mockResolvedValue(new Array(768).fill(0.1));
mockQdrant.hybridSearch.mockResolvedValue([]);
mockTyphoon.generate.mockResolvedValue({
answer: 'ok',
usedFallbackModel: true,
});
await service.query(dto, adminPerms);
expect(mockQdrant.hybridSearch).toHaveBeenCalledWith(
expect.any(Array),
dto.projectPublicId,
'CONFIDENTIAL',
20
);
});
});
});
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator';
export class RagQueryDto {
@IsString()
@IsNotEmpty()
@MaxLength(500)
question!: string;
@IsUUID()
projectPublicId!: string;
}
@@ -0,0 +1,16 @@
export interface RagCitation {
chunkId: string;
docNumber: string | null;
docType: string;
revision: string | null;
snippet: string;
score: number;
}
export class RagResponseDto {
answer!: string;
citations!: RagCitation[];
confidence!: number;
usedFallbackModel!: boolean;
cachedAt?: string;
}
@@ -0,0 +1,46 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
@Injectable()
export class EmbeddingService {
private readonly logger = new Logger(EmbeddingService.name);
private readonly ollamaUrl: string;
private readonly model: string;
constructor(private readonly configService: ConfigService) {
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
'http://localhost:11434'
);
this.model = this.configService.get<string>(
'OLLAMA_EMBED_MODEL',
'nomic-embed-text'
);
}
async embed(text: string): Promise<number[]> {
try {
const response = await axios.post<{ embedding: number[] }>(
`${this.ollamaUrl}/api/embeddings`,
{ model: this.model, prompt: text },
{ timeout: 30000 }
);
return response.data.embedding;
} catch (err) {
this.logger.error(
'Embedding failed',
err instanceof Error ? err.stack : String(err)
);
throw err;
}
}
async embedBatch(texts: string[]): Promise<number[][]> {
return Promise.all(texts.map((t) => this.embed(t)));
}
getModelName(): string {
return this.model;
}
}
@@ -0,0 +1,47 @@
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
@Entity('document_chunks')
export class DocumentChunk {
@PrimaryColumn({ type: 'char', length: 36 })
id!: string;
@Column({ type: 'char', length: 36, name: 'document_id' })
documentId!: string;
@Column({ name: 'chunk_index' })
chunkIndex!: number;
@Column({ type: 'text' })
content!: string;
@Column({ length: 20, name: 'doc_type' })
docType!: string;
@Column({ length: 100, name: 'doc_number', nullable: true })
docNumber!: string | null;
@Column({ length: 20, nullable: true })
revision!: string | null;
@Column({ length: 50, name: 'project_code' })
projectCode!: string;
@Column({ length: 36, name: 'project_public_id' })
projectPublicId!: string;
@Column({
type: 'enum',
enum: ['PUBLIC', 'INTERNAL', 'CONFIDENTIAL'],
default: 'INTERNAL',
})
classification!: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
@Column({ length: 20, nullable: true })
version!: string | null;
@Column({ length: 100, name: 'embedding_model', default: 'nomic-embed-text' })
embeddingModel!: string;
@CreateDateColumn({ name: 'created_at', precision: 3 })
createdAt!: Date;
}
@@ -0,0 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { OcrJobData } from './processors/ocr.processor';
@Injectable()
export class IngestionService {
private readonly logger = new Logger(IngestionService.name);
constructor(@InjectQueue('rag:ocr') private readonly ocrQueue: Queue) {}
async enqueue(data: OcrJobData): Promise<void> {
const jobId = data.attachmentPublicId;
const existing = await this.ocrQueue.getJob(jobId);
if (existing) {
const state = await existing.getState();
if (state === 'active' || state === 'waiting' || state === 'delayed') {
this.logger.log(
`rag:ocr job already queued for ${jobId} (state: ${state})`
);
return;
}
}
await this.ocrQueue.add('ocr', data, { jobId });
this.logger.log(`Enqueued rag:ocr for attachment ${jobId}`);
}
}
@@ -0,0 +1,110 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import { EmbeddingService } from '../embedding.service';
import { QdrantService, VectorMetadata } from '../qdrant.service';
import { DocumentChunk } from '../entities/document-chunk.entity';
import { EmbeddingJobData } from './thai-preprocess.processor';
const CHUNK_SIZE = 512;
const CHUNK_OVERLAP = 50;
@Processor('rag:embedding')
export class EmbeddingProcessor extends WorkerHost {
private readonly logger = new Logger(EmbeddingProcessor.name);
constructor(
private readonly embeddingService: EmbeddingService,
private readonly qdrantService: QdrantService,
@InjectRepository(DocumentChunk)
private readonly chunkRepo: Repository<DocumentChunk>
) {
super();
}
async process(job: Job<EmbeddingJobData>): Promise<void> {
const {
attachmentPublicId,
normalizedText,
docType,
docNumber,
revision,
projectCode,
projectPublicId,
classification,
} = job.data;
const chunks = this.chunkText(normalizedText);
const model = this.embeddingService.getModelName();
const upsertPoints: Parameters<QdrantService['upsertBatch']>[0] = [];
const chunkEntities: DocumentChunk[] = [];
for (let i = 0; i < chunks.length; i++) {
const chunkId = uuidv4();
const vector = await this.embeddingService.embed(chunks[i]);
const payload: VectorMetadata = {
chunk_id: chunkId,
public_id: attachmentPublicId,
project_public_id: projectPublicId,
doc_type: docType,
doc_number: docNumber,
revision,
project_code: projectCode,
classification,
content_preview: chunks[i].slice(0, 500),
embedding_model: model,
};
upsertPoints.push({ id: chunkId, vector, payload });
const entity = this.chunkRepo.create({
id: chunkId,
documentId: attachmentPublicId,
chunkIndex: i,
content: chunks[i],
docType,
docNumber,
revision,
projectCode,
projectPublicId,
classification,
embeddingModel: model,
});
chunkEntities.push(entity);
}
if (upsertPoints.length > 0) {
await this.qdrantService.upsertBatch(upsertPoints);
await this.chunkRepo.save(chunkEntities);
}
await this.chunkRepo.manager.query(
`UPDATE attachments SET rag_status = 'INDEXED', rag_last_error = NULL WHERE public_id = ?`,
[attachmentPublicId]
);
this.logger.log(
`Embedded ${chunks.length} chunks for ${attachmentPublicId}`
);
}
private chunkText(text: string): string[] {
const words = text.split(/\s+/);
const chunks: string[] = [];
let start = 0;
while (start < words.length) {
const end = Math.min(start + CHUNK_SIZE, words.length);
chunks.push(words.slice(start, end).join(' '));
start += CHUNK_SIZE - CHUNK_OVERLAP;
}
return chunks.filter((c) => c.trim().length > 0);
}
}
@@ -0,0 +1,68 @@
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Job } from 'bullmq';
import * as fs from 'fs';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { DocumentChunk } from '../entities/document-chunk.entity';
export interface OcrJobData {
attachmentPublicId: string;
filePath: string;
docType: string;
docNumber: string | null;
revision: string | null;
projectCode: string;
projectPublicId: string;
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
}
@Processor('rag:ocr')
export class OcrProcessor extends WorkerHost {
private readonly logger = new Logger(OcrProcessor.name);
constructor(
@InjectQueue('rag:thai-preprocess') private readonly thaiQueue: Queue,
@InjectRepository(DocumentChunk)
private readonly chunkRepo: Repository<DocumentChunk>
) {
super();
}
async process(job: Job<OcrJobData>): Promise<void> {
const { attachmentPublicId, filePath } = job.data;
const existing = await this.chunkRepo.count({
where: { documentId: attachmentPublicId },
});
if (existing > 0) {
this.logger.log(
`rag:ocr job already indexed for ${attachmentPublicId}, skipping`
);
return;
}
await this.chunkRepo.manager.query(
`UPDATE attachments SET rag_status = 'PROCESSING' WHERE public_id = ?`,
[attachmentPublicId]
);
let rawText: string;
try {
rawText = fs.readFileSync(filePath, 'utf-8');
} catch {
rawText = `[binary:${attachmentPublicId}]`;
}
await this.thaiQueue.add(
'preprocess',
{ ...job.data, rawText },
{ jobId: `thai:${attachmentPublicId}` }
);
this.logger.log(`OCR enqueued thai-preprocess for ${attachmentPublicId}`);
}
}
@@ -0,0 +1,56 @@
import { Processor, WorkerHost, InjectQueue } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Queue, Job } from 'bullmq';
import axios from 'axios';
import { OcrJobData } from './ocr.processor';
export interface ThaiPreprocessJobData extends OcrJobData {
rawText: string;
}
export interface EmbeddingJobData extends ThaiPreprocessJobData {
normalizedText: string;
}
@Processor('rag:thai-preprocess')
export class ThaiPreprocessProcessor extends WorkerHost {
private readonly logger = new Logger(ThaiPreprocessProcessor.name);
private readonly thaiUrl: string;
constructor(
private readonly configService: ConfigService,
@InjectQueue('rag:embedding') private readonly embeddingQueue: Queue
) {
super();
this.thaiUrl = this.configService.get<string>(
'THAI_PREPROCESS_URL',
'http://localhost:8765'
);
}
async process(job: Job<ThaiPreprocessJobData>): Promise<void> {
const { rawText, attachmentPublicId } = job.data;
let normalizedText = rawText;
try {
const response = await axios.post<{ normalized: string }>(
`${this.thaiUrl}/normalize`,
{ text: rawText },
{ timeout: 30000 }
);
normalizedText = response.data.normalized ?? rawText;
} catch (err) {
this.logger.warn(
`Thai preprocess failed for ${attachmentPublicId}, using raw text: ${err instanceof Error ? err.message : String(err)}`
);
}
await this.embeddingQueue.add(
'embed',
{ ...job.data, normalizedText } as EmbeddingJobData,
{ jobId: `embed:${attachmentPublicId}` }
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { QdrantClient } from '@qdrant/js-client-rest';
export interface VectorMetadata extends Record<string, unknown> {
chunk_id: string;
public_id: string;
project_public_id: string;
doc_type: string;
doc_number: string | null;
revision: string | null;
project_code: string;
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
content_preview: string;
embedding_model: string;
}
export interface HybridSearchResult {
chunkId: string;
publicId: string;
docType: string;
docNumber: string | null;
revision: string | null;
projectCode: string;
contentPreview: string;
score: number;
}
const COLLECTION_NAME = 'lcbp3_vectors';
const VECTOR_SIZE = 768;
@Injectable()
export class QdrantService implements OnModuleInit {
private readonly logger = new Logger(QdrantService.name);
private client: QdrantClient;
private collectionReady = false;
constructor(private readonly configService: ConfigService) {
const url = this.configService.get<string>(
'QDRANT_URL',
'http://localhost:6333'
);
this.client = new QdrantClient({ url });
}
async onModuleInit(): Promise<void> {
try {
await this.initCollection();
this.collectionReady = true;
this.logger.log(`Qdrant collection '${COLLECTION_NAME}' ready`);
} catch (err) {
this.logger.error(
'Qdrant collection init failed — RAG queries will return 503',
err instanceof Error ? err.stack : String(err)
);
this.collectionReady = false;
}
}
isReady(): boolean {
return this.collectionReady;
}
private async initCollection(): Promise<void> {
const collections = await this.client.getCollections();
const exists = collections.collections.some(
(c) => c.name === COLLECTION_NAME
);
if (!exists) {
await this.client.createCollection(COLLECTION_NAME, {
vectors: { size: VECTOR_SIZE, distance: 'Cosine' },
hnsw_config: {
payload_m: 16,
m: 0,
},
optimizers_config: { indexing_threshold: 10000 },
});
this.logger.log(`Created Qdrant collection '${COLLECTION_NAME}'`);
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'project_public_id',
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
QdrantClient['createPayloadIndex']
>[1]['field_schema'],
});
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'classification',
field_schema: 'keyword',
});
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'doc_type',
field_schema: 'keyword',
});
await this.client.createPayloadIndex(COLLECTION_NAME, {
field_name: 'doc_number',
field_schema: 'keyword',
});
}
}
async upsertBatch(
points: Array<{ id: string; vector: number[]; payload: VectorMetadata }>
): Promise<void> {
await this.client.upsert(COLLECTION_NAME, {
wait: true,
points: points.map((p) => ({
id: p.id,
vector: p.vector,
payload: p.payload,
})),
});
}
async hybridSearch(
queryVector: number[],
projectPublicId: string,
classificationCeiling: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL',
topK: number
): Promise<HybridSearchResult[]> {
const classificationValues = this.getAllowedClassifications(
classificationCeiling
);
const vectorResults = await this.client.search(COLLECTION_NAME, {
vector: queryVector,
limit: topK,
filter: {
must: [
{ key: 'project_public_id', match: { value: projectPublicId } },
{ key: 'classification', match: { any: classificationValues } },
],
},
with_payload: true,
});
return vectorResults.map((r) => {
const payload = r.payload as unknown as VectorMetadata;
return {
chunkId: payload.chunk_id,
publicId: payload.public_id,
docType: payload.doc_type,
docNumber: payload.doc_number,
revision: payload.revision,
projectCode: payload.project_code,
contentPreview: payload.content_preview,
score: r.score,
};
});
}
async deleteByDocumentId(documentId: string): Promise<void> {
await this.client.delete(COLLECTION_NAME, {
wait: true,
filter: {
must: [{ key: 'public_id', match: { value: documentId } }],
},
});
}
async forceInitCollection(): Promise<void> {
await this.initCollection();
this.collectionReady = true;
this.logger.log(`Qdrant collection force-initialized`);
}
private getAllowedClassifications(
ceiling: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'
): string[] {
const order: Array<'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL'> = [
'PUBLIC',
'INTERNAL',
'CONFIDENTIAL',
];
const ceilIdx = order.indexOf(ceiling);
return order.slice(0, ceilIdx + 1);
}
}
+93
View File
@@ -0,0 +1,93 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Logger,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { UserService } from '../user/user.service';
import { User } from '../user/entities/user.entity';
import { RagQueryDto } from './dto/rag-query.dto';
import { RagService } from './rag.service';
@ApiTags('RAG')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RbacGuard)
@Throttle({ default: { limit: 30, ttl: 60000 } })
@Controller('rag')
export class RagController {
private readonly logger = new Logger(RagController.name);
constructor(
private readonly ragService: RagService,
private readonly userService: UserService
) {}
@Post('query')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'RAG Q&A — ค้นหาคำตอบจากเอกสารโครงการ' })
@RequirePermission('rag.query')
async query(
@Body() dto: RagQueryDto,
@CurrentUser() user: User,
@Headers('Idempotency-Key') idempotencyKey: string
) {
if (!idempotencyKey) {
this.logger.warn(`Missing Idempotency-Key from user ${user.user_id}`);
}
const permissions = await this.userService.getUserPermissions(user.user_id);
return this.ragService.query(dto, permissions);
}
@Get('status/:attachmentId')
@ApiOperation({ summary: 'ดูสถานะ RAG ingestion ของ attachment' })
@RequirePermission('rag.query')
async getStatus(@Param('attachmentId', ParseUuidPipe) attachmentId: string) {
return this.ragService.getStatus(attachmentId);
}
@Post('ingest/:attachmentId')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Re-ingest attachment ที่ FAILED (Admin only)' })
@RequirePermission('rag.manage')
async reIngest(@Param('attachmentId', ParseUuidPipe) attachmentId: string) {
await this.ragService.reIngest(attachmentId);
return { message: 'Re-ingestion queued' };
}
@Delete('vectors/:attachmentId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'ลบ vectors ของ attachment ออกจาก Qdrant' })
@RequirePermission('rag.manage')
async deleteVectors(
@Param('attachmentId', ParseUuidPipe) attachmentId: string
) {
await this.ragService.deleteVectors(attachmentId);
}
@Post('admin/init-collection')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'T038: Init Qdrant collection lcbp3_vectors (admin only)',
})
@RequirePermission('rag.manage')
async initCollection() {
await this.ragService.initCollection();
return { message: 'Qdrant collection initialized' };
}
}
+55
View File
@@ -0,0 +1,55 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bullmq';
import { ConfigModule } from '@nestjs/config';
import { DocumentChunk } from './entities/document-chunk.entity';
import { EmbeddingService } from './embedding.service';
import { QdrantService } from './qdrant.service';
import { TyphoonService } from './typhoon.service';
import { RagService } from './rag.service';
import { RagController } from './rag.controller';
import { IngestionService } from './ingestion.service';
import { OcrProcessor } from './processors/ocr.processor';
import { ThaiPreprocessProcessor } from './processors/thai-preprocess.processor';
import { EmbeddingProcessor } from './processors/embedding.processor';
import { UserModule } from '../user/user.module';
const DLQ_DEFAULTS = {
attempts: 3,
backoff: { type: 'exponential' as const, delay: 2000 },
removeOnComplete: 100,
removeOnFail: 200,
};
@Module({
imports: [
ConfigModule,
UserModule,
TypeOrmModule.forFeature([DocumentChunk]),
BullModule.registerQueue(
{ name: 'rag:ocr', defaultJobOptions: DLQ_DEFAULTS },
{ name: 'rag:thai-preprocess', defaultJobOptions: DLQ_DEFAULTS },
{ name: 'rag:embedding', defaultJobOptions: DLQ_DEFAULTS }
),
],
controllers: [RagController],
providers: [
EmbeddingService,
QdrantService,
TyphoonService,
RagService,
IngestionService,
OcrProcessor,
ThaiPreprocessProcessor,
EmbeddingProcessor,
],
exports: [
EmbeddingService,
QdrantService,
TyphoonService,
RagService,
IngestionService,
],
})
export class RagModule {}
+255
View File
@@ -0,0 +1,255 @@
import {
Injectable,
Logger,
ServiceUnavailableException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import { createHash } from 'crypto';
import { QdrantService } from './qdrant.service';
import { EmbeddingService } from './embedding.service';
import { TyphoonService } from './typhoon.service';
import { IngestionService } from './ingestion.service';
import { DocumentChunk } from './entities/document-chunk.entity';
import { RagQueryDto } from './dto/rag-query.dto';
import { RagResponseDto, RagCitation } from './dto/rag-response.dto';
const CACHE_TTL_SECONDS = 300;
const PROMPT_CONTEXT_LIMIT = 3000;
@Injectable()
export class RagService {
private readonly logger = new Logger(RagService.name);
constructor(
private readonly qdrant: QdrantService,
private readonly embedding: EmbeddingService,
private readonly typhoon: TyphoonService,
private readonly ingestionService: IngestionService,
@InjectRepository(DocumentChunk)
private readonly chunkRepo: Repository<DocumentChunk>,
@InjectRedis() private readonly redis: Redis
) {}
async query(
dto: RagQueryDto,
userPermissions: string[]
): Promise<RagResponseDto> {
const { question, projectPublicId } = dto;
const classificationCeiling =
this.deriveClassificationCeiling(userPermissions);
const isConfidential = classificationCeiling === 'CONFIDENTIAL';
if (!this.qdrant.isReady()) {
throw new ServiceUnavailableException('RAG_NOT_READY');
}
const cacheKey = this.buildCacheKey(
question,
projectPublicId,
classificationCeiling
);
if (!isConfidential) {
const cached = await this.redis.get(cacheKey);
if (cached) {
const parsed = JSON.parse(cached) as RagResponseDto;
parsed.cachedAt = new Date().toISOString();
return parsed;
}
}
const queryVector = await this.embedding.embed(question);
const topK = 20;
const results = await this.qdrant.hybridSearch(
queryVector,
projectPublicId,
classificationCeiling,
topK
);
const reranked = results.sort((a, b) => b.score - a.score).slice(0, 5);
const context = this.buildContext(reranked);
const safeQuestion = this.typhoon.sanitizeInput(question);
const prompt = this.buildPrompt(safeQuestion, context);
const { answer, usedFallbackModel } = await this.typhoon.generate(
prompt,
isConfidential
);
const citations: RagCitation[] = reranked.map((r) => ({
chunkId: r.chunkId,
docNumber: r.docNumber,
docType: r.docType,
revision: r.revision,
snippet: r.contentPreview.slice(0, 200),
score: r.score,
}));
const confidence = reranked.length > 0 ? reranked[0].score : 0;
const response: RagResponseDto = {
answer,
citations,
confidence,
usedFallbackModel,
};
if (!isConfidential) {
await this.redis.setex(
cacheKey,
CACHE_TTL_SECONDS,
JSON.stringify(response)
);
}
return response;
}
async getStatus(
attachmentPublicId: string
): Promise<{ ragStatus: string; chunkCount: number }> {
const chunkCount = await this.chunkRepo.count({
where: { documentId: attachmentPublicId },
});
const result = await this.chunkRepo.manager.query<{ rag_status: string }[]>(
`SELECT rag_status FROM attachments WHERE public_id = ? LIMIT 1`,
[attachmentPublicId]
);
const ragStatus = result[0]?.rag_status ?? 'PENDING';
return { ragStatus, chunkCount };
}
async reIngest(attachmentPublicId: string): Promise<void> {
const statusResult = await this.chunkRepo.manager.query<
{ rag_status: string; file_path: string }[]
>(
`SELECT rag_status, file_path FROM attachments WHERE public_id = ? LIMIT 1`,
[attachmentPublicId]
);
const current = statusResult[0]?.rag_status;
if (current !== 'FAILED') {
throw new BadRequestException(
`Cannot re-ingest: current status is '${current ?? 'unknown'}', expected 'FAILED'`
);
}
const sample = await this.chunkRepo.findOne({
where: { documentId: attachmentPublicId },
});
await this.chunkRepo.delete({ documentId: attachmentPublicId });
try {
await this.qdrant.deleteByDocumentId(attachmentPublicId);
} catch (err) {
this.logger.error(
`Qdrant delete failed for ${attachmentPublicId} — continuing`,
err instanceof Error ? err.stack : String(err)
);
}
await this.chunkRepo.manager.query(
`UPDATE attachments SET rag_status = 'PENDING', rag_last_error = NULL WHERE public_id = ?`,
[attachmentPublicId]
);
if (sample) {
await this.ingestionService.enqueue({
attachmentPublicId,
filePath: statusResult[0]?.file_path ?? '',
docType: sample.docType,
docNumber: sample.docNumber,
revision: sample.revision,
projectCode: sample.projectCode,
projectPublicId: sample.projectPublicId,
classification: sample.classification,
});
}
}
async initCollection(): Promise<void> {
await this.qdrant.onModuleInit();
}
async deleteVectors(attachmentPublicId: string): Promise<void> {
await this.chunkRepo.delete({ documentId: attachmentPublicId });
try {
await this.qdrant.deleteByDocumentId(attachmentPublicId);
} catch (err) {
this.logger.error(
`Qdrant delete failed for ${attachmentPublicId}`,
err instanceof Error ? err.stack : String(err)
);
}
await this.chunkRepo.manager.query(
`UPDATE attachments SET rag_status = 'PENDING', rag_last_error = NULL WHERE public_id = ?`,
[attachmentPublicId]
);
}
buildContext(
results: Array<{
docType: string;
docNumber: string | null;
revision: string | null;
contentPreview: string;
}>
): string {
let context = '';
for (const r of results) {
const header = `[${r.docType}${r.docNumber ? ` - ${r.docNumber}` : ''}${r.revision ? ` - ${r.revision}` : ''}]`;
const snippet = `${header}\n${r.contentPreview}\n\n`;
if ((context + snippet).length > PROMPT_CONTEXT_LIMIT) break;
context += snippet;
}
return context.trim();
}
private buildPrompt(question: string, context: string): string {
return [
'คุณเป็นผู้ช่วยผู้เชี่ยวชาญด้านเอกสารโครงการก่อสร้าง',
'ตอบคำถามโดยอ้างอิงจากเอกสารที่ให้มาเท่านั้น ห้ามตอบจากความรู้ทั่วไป',
'หากข้อมูลในเอกสารไม่เพียงพอ ให้แจ้งว่า "ไม่พบข้อมูลในเอกสารที่ระบุ"',
'',
'=== เอกสารอ้างอิง ===',
context,
'',
'=== คำถาม ===',
question,
].join('\n');
}
private buildCacheKey(
question: string,
projectPublicId: string,
classificationCeiling: string
): string {
const raw = `${question}|${projectPublicId}|${classificationCeiling}`;
return `rag:query:${createHash('sha256').update(raw).digest('hex')}`;
}
private deriveClassificationCeiling(
permissions: string[]
): 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL' {
if (
permissions.includes('system.manage_all') ||
permissions.includes('document.view_confidential')
) {
return 'CONFIDENTIAL';
}
return 'INTERNAL';
}
}
+115
View File
@@ -0,0 +1,115 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
export interface LlmGenerateResult {
answer: string;
usedFallbackModel: boolean;
}
interface TyphoonChatResponse {
choices: Array<{ message: { content: string } }>;
}
@Injectable()
export class TyphoonService {
private readonly logger = new Logger(TyphoonService.name);
private readonly typhoonUrl: string;
private readonly typhoonKey: string;
private readonly ollamaUrl: string;
private readonly ollamaModel: string;
private readonly timeoutMs: number;
constructor(private readonly configService: ConfigService) {
this.typhoonUrl = this.configService.get<string>(
'TYPHOON_API_URL',
'https://api.opentyphoon.ai/v1'
);
this.typhoonKey = this.configService.get<string>('TYPHOON_API_KEY', '');
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
'http://localhost:11434'
);
this.ollamaModel = this.configService.get<string>(
'OLLAMA_RAG_MODEL',
'gemma3:12b'
);
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 5000);
}
async generate(
prompt: string,
forceLocal: boolean
): Promise<LlmGenerateResult> {
if (forceLocal) {
const answer = await this.generateOllama(prompt);
return { answer, usedFallbackModel: true };
}
try {
const answer = await Promise.race([
this.generateTyphoon(prompt),
this.delay(this.timeoutMs).then(() => {
throw new Error('Typhoon timeout');
}),
]);
return { answer, usedFallbackModel: false };
} catch (err) {
this.logger.warn(
`Typhoon failed, falling back to Ollama: ${err instanceof Error ? err.message : String(err)}`
);
const answer = await this.generateOllama(prompt);
return { answer, usedFallbackModel: true };
}
}
sanitizeInput(text: string): string {
return text
.replace(/<CONTEXT_START>|<CONTEXT_END>/gi, '')
.replace(/ignore previous instructions/gi, '')
.replace(/system:/gi, '')
.slice(0, 1000);
}
private async generateTyphoon(prompt: string): Promise<string> {
const response = await axios.post<TyphoonChatResponse>(
`${this.typhoonUrl}/chat/completions`,
{
model: 'typhoon-v2.1-12b-instruct',
messages: [
{
role: 'user',
content: `<CONTEXT_START>\n${prompt}\n<CONTEXT_END>`,
},
],
max_tokens: 1024,
temperature: 0.1,
},
{
headers: {
Authorization: `Bearer ${this.typhoonKey}`,
'Content-Type': 'application/json',
},
timeout: this.timeoutMs,
}
);
return response.data.choices[0]?.message?.content ?? '';
}
private async generateOllama(prompt: string): Promise<string> {
const response = await axios.post<{ response: string }>(
`${this.ollamaUrl}/api/generate`,
{
model: this.ollamaModel,
prompt,
stream: false,
},
{ timeout: 30000 }
);
return response.data.response ?? '';
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
@@ -2,6 +2,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import {
makeCounterProvider,
makeHistogramProvider,
} from '@willsoto/nestjs-prometheus';
// Entities // Entities
import { WorkflowDefinition } from './entities/workflow-definition.entity'; import { WorkflowDefinition } from './entities/workflow-definition.entity';
@@ -37,6 +41,17 @@ import { WorkflowEngineController } from './workflow-engine.controller';
WorkflowDslService, WorkflowDslService,
WorkflowEventService, WorkflowEventService,
WorkflowTransitionGuard, WorkflowTransitionGuard,
// ADR-021 S1: Redlock observability — Prometheus metrics
makeHistogramProvider({
name: 'workflow_redlock_acquire_duration_ms',
help: 'เวลาที่ใช้ในการ acquire Redlock สำหรับ workflow transition (รวม retry)',
labelNames: ['outcome'], // 'success' | 'failure'
buckets: [50, 100, 250, 500, 1000, 2000, 5000, 10000],
}),
makeCounterProvider({
name: 'workflow_redlock_acquire_failures_total',
help: 'จำนวนครั้งที่ Redlock acquire ล้มเหลวหลัง retry ครบ (Fail-closed HTTP 503)',
}),
], ],
exports: [WorkflowEngineService], // Export Service ให้ Module อื่น (Correspondence, RFA) เรียกใช้ exports: [WorkflowEngineService], // Export Service ให้ Module อื่น (Correspondence, RFA) เรียกใช้
}) })
@@ -8,7 +8,6 @@ jest.mock('redlock', () =>
})) }))
); );
import { ConflictException, ServiceUnavailableException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { WorkflowEngineService } from './workflow-engine.service'; import { WorkflowEngineService } from './workflow-engine.service';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
@@ -23,7 +22,13 @@ import { Attachment } from '../../common/file-storage/entities/attachment.entity
import { WorkflowDslService } from './workflow-dsl.service'; import { WorkflowDslService } from './workflow-dsl.service';
import { WorkflowEventService } from './workflow-event.service'; import { WorkflowEventService } from './workflow-event.service';
import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { NotFoundException, WorkflowException } from '../../common/exceptions'; // ADR-007: \u0e43\u0e0a\u0e49 custom exceptions \u0e17\u0e31\u0e49\u0e07\u0e2b\u0e21\u0e14\u0e08\u0e32\u0e01 common/exceptions (\u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49 @nestjs/common built-in)
import {
NotFoundException,
WorkflowException,
ConflictException,
ServiceUnavailableException,
} from '../../common/exceptions';
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto'; import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
// Token ของ @nestjs-modules/ioredis — default Redis connection // Token ของ @nestjs-modules/ioredis — default Redis connection
@@ -132,6 +137,20 @@ describe('WorkflowEngineService', () => {
// ไม่จำเป็นต้องมี method จริง เพราะ Redlock ถูก mock แล้ว // ไม่จำเป็นต้องมี method จริง เพราะ Redlock ถูก mock แล้ว
}, },
}, },
// ADR-021 S1: Prometheus metrics mocks
{
provide: 'PROM_METRIC_WORKFLOW_REDLOCK_ACQUIRE_DURATION_MS',
useValue: {
labels: jest.fn().mockReturnThis(),
observe: jest.fn(),
},
},
{
provide: 'PROM_METRIC_WORKFLOW_REDLOCK_ACQUIRE_FAILURES_TOTAL',
useValue: {
inc: jest.fn(),
},
},
], ],
}).compile(); }).compile();
@@ -496,6 +515,45 @@ describe('WorkflowEngineService', () => {
expect(mockRedlockRelease).toHaveBeenCalled(); expect(mockRedlockRelease).toHaveBeenCalled();
}); });
it('H1: should throw ConflictException when state changes between pre-check and pessimistic lock (TOCTOU)', async () => {
// Arrange: pre-check พบ PENDING_REVIEW (stale read)
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
id: 'inst-1',
currentState: 'PENDING_REVIEW',
});
// แต่ภายใน transaction (pessimistic lock) state เปลี่ยนเป็น APPROVED แล้ว
// (simulate: another request transition ไปก่อนที่ Redlock จะ release)
mockQueryRunner.manager.findOne.mockResolvedValue({
id: 'inst-1',
currentState: 'APPROVED', // ← เปลี่ยนไปแล้ว
status: WorkflowStatus.ACTIVE,
definition: { compiled: mockCompiledWorkflow },
context: {},
});
mockDslService.evaluate.mockReturnValue({
nextState: 'APPROVED',
events: [],
});
await expect(
service.processTransition(
'inst-1',
'APPROVE',
1,
undefined,
{},
attachmentPublicIds
)
).rejects.toThrow(ConflictException);
// ต้อง rollback transaction + release Redlock
expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled();
expect(mockQueryRunner.commitTransaction).not.toHaveBeenCalled();
expect(mockRedlockRelease).toHaveBeenCalled();
// attachment update ต้องไม่ถูกเรียก
expect(mockQueryRunner.manager.update).not.toHaveBeenCalled();
});
it('C1: should release Redlock even when transition succeeds', async () => { it('C1: should release Redlock even when transition succeeds', async () => {
(instanceRepo.findOne as jest.Mock).mockResolvedValue({ (instanceRepo.findOne as jest.Mock).mockResolvedValue({
id: 'inst-1', id: 'inst-1',
@@ -1,21 +1,23 @@
// File: src/modules/workflow-engine/workflow-engine.service.ts // File: src/modules/workflow-engine/workflow-engine.service.ts
import { import { Injectable, Inject, Logger } from '@nestjs/common';
Injectable,
Inject,
Logger,
ConflictException,
ServiceUnavailableException,
} from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager'; import type { Cache } from 'cache-manager';
import { NotFoundException, WorkflowException } from '../../common/exceptions'; // ADR-007: ใช้ custom exceptions ที่ extends BaseException เพื่อให้ payload ตรง layered structure
import {
NotFoundException,
WorkflowException,
ConflictException,
ServiceUnavailableException,
} from '../../common/exceptions';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm'; import { DataSource, In, Repository } from 'typeorm';
// ADR-021 Clarify Q2: Redis Redlock for transition Fail-closed (Retry 3x → 503) // ADR-021 Clarify Q2: Redis Redlock for transition Fail-closed (Retry 3x → 503)
import { InjectRedis } from '@nestjs-modules/ioredis'; import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis'; import Redis from 'ioredis';
import Redlock, { Lock } from 'redlock'; import Redlock, { Lock } from 'redlock';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Counter, Histogram } from 'prom-client';
// Entities // Entities
import { WorkflowDefinition } from './entities/workflow-definition.entity'; import { WorkflowDefinition } from './entities/workflow-definition.entity';
import { WorkflowHistory } from './entities/workflow-history.entity'; import { WorkflowHistory } from './entities/workflow-history.entity';
@@ -30,11 +32,7 @@ import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dt
import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto'; import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto';
import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto'; import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto';
import { WorkflowHistoryItemDto } from './dto/workflow-history-item.dto'; import { WorkflowHistoryItemDto } from './dto/workflow-history-item.dto';
import { import { CompiledWorkflow, WorkflowDslService } from './workflow-dsl.service';
CompiledWorkflow,
RawEvent,
WorkflowDslService,
} from './workflow-dsl.service';
import { WorkflowEventService } from './workflow-event.service'; // [NEW] Import Event Service import { WorkflowEventService } from './workflow-event.service'; // [NEW] Import Event Service
// Legacy Interface (Backward Compatibility) // Legacy Interface (Backward Compatibility)
@@ -76,7 +74,12 @@ export class WorkflowEngineService {
private readonly eventService: WorkflowEventService, // [NEW] Inject Service private readonly eventService: WorkflowEventService, // [NEW] Inject Service
private readonly dataSource: DataSource, // ใช้สำหรับ Transaction private readonly dataSource: DataSource, // ใช้สำหรับ Transaction
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache, // ADR-021 T024: History cache @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, // ADR-021 T024: History cache
@InjectRedis() private readonly redis: Redis // ADR-021 Clarify Q2: Redlock @InjectRedis() private readonly redis: Redis, // ADR-021 Clarify Q2: Redlock
// ADR-021 S1: Redlock observability metrics
@InjectMetric('workflow_redlock_acquire_duration_ms')
private readonly redlockAcquireDuration: Histogram<string>,
@InjectMetric('workflow_redlock_acquire_failures_total')
private readonly redlockAcquireFailures: Counter<string>
) { ) {
// ADR-021 Clarify Q2 (C1): Redlock Fail-closed // ADR-021 Clarify Q2 (C1): Redlock Fail-closed
// Retry 3 ครั้ง × 500ms เพิ่ม jitter → ถ้ายังไม่ได้ throw HTTP 503 // Retry 3 ครั้ง × 500ms เพิ่ม jitter → ถ้ายังไม่ได้ throw HTTP 503
@@ -340,6 +343,8 @@ export class WorkflowEngineService {
// อนุญาตให้แนบไฟล์เฉพาะในสถานะ PENDING_REVIEW / PENDING_APPROVAL // อนุญาตให้แนบไฟล์เฉพาะในสถานะ PENDING_REVIEW / PENDING_APPROVAL
// ============================================================== // ==============================================================
if (hasAttachments) { if (hasAttachments) {
// ADR-021 S2: `id` ใน WorkflowInstance เป็น CHAR(36) UUID direct PK
// (ไม่ใช่ pattern UuidBaseEntity ที่ INT+publicId) — ADR-019 compliant เพราะ UUID ถูก expose โดยตรง
const instancePreCheck = await this.instanceRepo.findOne({ const instancePreCheck = await this.instanceRepo.findOne({
where: { id: instanceId }, where: { id: instanceId },
select: ['id', 'currentState'], select: ['id', 'currentState'],
@@ -352,12 +357,15 @@ export class WorkflowEngineService {
instancePreCheck.currentState instancePreCheck.currentState
) )
) { ) {
throw new ConflictException({ throw new ConflictException(
userMessage: 'ไม่สามารถอัปโหลดไฟล์ในสถานะนี้ได้', 'WORKFLOW_STATE_LOCKED',
recoveryAction: `Upload rejected: currentState=${instancePreCheck.currentState} not in UPLOAD_ALLOWED_STATES`,
'ไม่สามารถอัปโหลดไฟล์ในสถานะนี้ได้',
[
'อนุญาตเฉพาะสถานะ PENDING_REVIEW หรือ PENDING_APPROVAL เท่านั้น', 'อนุญาตเฉพาะสถานะ PENDING_REVIEW หรือ PENDING_APPROVAL เท่านั้น',
currentState: instancePreCheck.currentState, 'รีเฟรชหน้าแล้วตรวจสถานะล่าสุด',
}); ]
);
} }
} }
@@ -367,25 +375,34 @@ export class WorkflowEngineService {
// ============================================================== // ==============================================================
const lockKey = `lock:wf:transition:${instanceId}`; const lockKey = `lock:wf:transition:${instanceId}`;
let lock: Lock; let lock: Lock;
const acquireStart = Date.now();
try { try {
lock = await this.redlock.acquire([lockKey], 10000); // 10s TTL lock = await this.redlock.acquire([lockKey], 10000); // 10s TTL
// S1: บันทึก duration กรณี acquire สำเร็จ
this.redlockAcquireDuration
.labels({ outcome: 'success' })
.observe(Date.now() - acquireStart);
} catch (err) { } catch (err) {
// S1: บันทึก duration + failure counter
this.redlockAcquireDuration
.labels({ outcome: 'failure' })
.observe(Date.now() - acquireStart);
this.redlockAcquireFailures.inc();
this.logger.error( this.logger.error(
`Redlock acquire failed after retries for ${instanceId}: ${(err as Error).message}` `Redlock acquire failed after retries for ${instanceId}: ${(err as Error).message}`
); );
throw new ServiceUnavailableException({ throw new ServiceUnavailableException(
userMessage: 'ระบบยุ่งชั่วคราว กรุณาลองใหม่ภายหลัง', 'WORKFLOW_LOCK_UNAVAILABLE',
recoveryAction: 'รอสักครู่แล้วลองใหม่', `Redlock acquire failed after 3 retries on lock:wf:transition:${instanceId}`,
}); 'ระบบยุ่งชั่วคราว กรุณาลองใหม่ภายหลัง',
['รอสักครู่แล้วลองใหม่', 'แจ้งผู้ดูแลระบบหากยังพบปัญหา']
);
} }
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
await queryRunner.startTransaction(); await queryRunner.startTransaction();
let eventsToDispatch: RawEvent[] = [];
let updatedContext: Record<string, unknown> = {};
try { try {
// 1. Lock Instance เพื่อป้องกัน Race Condition (Pessimistic Write Lock) // 1. Lock Instance เพื่อป้องกัน Race Condition (Pessimistic Write Lock)
const instance = await queryRunner.manager.findOne(WorkflowInstance, { const instance = await queryRunner.manager.findOne(WorkflowInstance, {
@@ -407,6 +424,22 @@ export class WorkflowEngineService {
); );
} }
// ==============================================================
// ADR-021 (H1): Re-check state ภายใต้ pessimistic lock — ปิด TOCTOU race
// pre-check ด้านหน้าเป็น optimistic fast-fail; เช็กที่นี่เป็น authoritative
// ==============================================================
if (
hasAttachments &&
!WorkflowEngineService.UPLOAD_ALLOWED_STATES.has(instance.currentState)
) {
throw new ConflictException(
'WORKFLOW_STATE_CHANGED',
`TOCTOU: state changed to ${instance.currentState} under pessimistic lock`,
'ไม่สามารถอัปโหลดไฟล์ได้ (สถานะเอกสารได้เปลี่ยนไปก่อนหน้านี้)',
['รีเฟรชหน้าแล้วตรวจสถานะล่าสุดของเอกสาร']
);
}
// 2. Evaluate Logic ผ่าน DSL Service // 2. Evaluate Logic ผ่าน DSL Service
const compiled = instance.definition const compiled = instance.definition
.compiled as unknown as CompiledWorkflow; .compiled as unknown as CompiledWorkflow;
@@ -494,27 +527,23 @@ export class WorkflowEngineService {
) )
); );
// [NEW] เก็บค่าไว้ Dispatch หลัง Commit
eventsToDispatch = evaluation.events;
updatedContext = context;
this.logger.log( this.logger.log(
`Transition: ${instanceId} [${fromState}] --${action}--> [${toState}] by User:${userId}` `Transition: ${instanceId} [${fromState}] --${action}--> [${toState}] by User:${userId}`
); );
// [NEW] Dispatch Events (Async) ผ่าน WorkflowEventService // Dispatch Events (Async, Fire-and-forget) ผ่าน WorkflowEventService
if (eventsToDispatch && eventsToDispatch.length > 0) { if (evaluation.events.length > 0) {
void this.eventService.dispatchEvents( void this.eventService.dispatchEvents(
instance.id, instance.id,
eventsToDispatch, evaluation.events,
updatedContext context
); );
} }
return { return {
success: true, success: true,
nextState: toState, nextState: toState,
events: eventsToDispatch, events: evaluation.events,
isCompleted: instance.status === WorkflowStatus.COMPLETED, isCompleted: instance.status === WorkflowStatus.COMPLETED,
}; };
} catch (err) { } catch (err) {
+1 -1
View File
@@ -1,6 +1,6 @@
import _request from 'supertest'; import _request from 'supertest';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app.module';
import { _RoutingTemplate } from '../src/modules/correspondence/entities/routing-template.entity'; import { RoutingTemplate } from '../src/modules/correspondence/entities/routing-template.entity';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { Bot } from 'lucide-react';
import { useRagQuery } from '../../../hooks/use-rag';
import { useProjectStore } from '../../../lib/stores/project-store';
import { RagSearchBar } from '../../../components/rag/rag-search-bar';
import { RagResultCard } from '../../../components/rag/rag-result-card';
export default function RagPage() {
const { selectedProjectId } = useProjectStore();
const { mutate, data, isPending, error, isIdle } = useRagQuery();
const handleSearch = (question: string) => {
if (!selectedProjectId) return;
mutate({ question, projectPublicId: selectedProjectId });
};
return (
<div className="container mx-auto max-w-3xl py-8 space-y-6">
<div className="flex items-center gap-2">
<Bot className="h-6 w-6 text-primary" />
<h1 className="text-xl font-semibold">RAG </h1>
</div>
{!selectedProjectId && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 px-4 py-3 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-700">
RAG
</div>
)}
<RagSearchBar onSearch={handleSearch} isLoading={isPending} />
{isPending && (
<div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground animate-pulse">
...
</div>
)}
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
: {error.message}
</div>
)}
{data && !isPending && <RagResultCard result={data} />}
{isIdle && !error && (
<p className="text-center text-sm text-muted-foreground pt-4">
</p>
)}
</div>
);
}
@@ -0,0 +1,12 @@
'use client';
import { AlertTriangle } from 'lucide-react';
export function RagFallbackBadge() {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2.5 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
<AlertTriangle className="h-3 w-3" />
local model
</span>
);
}
@@ -0,0 +1,74 @@
'use client';
import { FileText } from 'lucide-react';
import type { RagQueryResponse, RagCitation } from '../../hooks/use-rag';
import { RagFallbackBadge } from './rag-fallback-badge';
interface RagResultCardProps {
result: RagQueryResponse;
}
function ConfidenceBar({ score }: { score: number }) {
const pct = Math.round(score * 100);
const color =
pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500';
return (
<div className="flex items-center gap-2">
<div className="h-2 w-24 rounded-full bg-muted overflow-hidden">
<div className={`h-full ${color} transition-all`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-muted-foreground">{pct}%</span>
</div>
);
}
function CitationItem({ citation }: { citation: RagCitation }) {
return (
<div className="rounded border p-3 text-sm space-y-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 font-medium text-foreground">
<FileText className="h-4 w-4 text-muted-foreground" />
<span>{citation.docType}</span>
{citation.docNumber && (
<span className="text-muted-foreground"> {citation.docNumber}</span>
)}
{citation.revision && (
<span className="rounded bg-muted px-1 text-xs">Rev. {citation.revision}</span>
)}
</div>
<ConfidenceBar score={citation.score} />
</div>
<p className="text-muted-foreground line-clamp-3">{citation.snippet}</p>
</div>
);
}
export function RagResultCard({ result }: RagResultCardProps) {
return (
<div className="rounded-lg border bg-card p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h3 className="font-semibold text-base mb-1"></h3>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{result.answer}</p>
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
<ConfidenceBar score={result.confidence} />
{result.usedFallbackModel && <RagFallbackBadge />}
</div>
</div>
{result.citations.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground">
({result.citations.length} )
</h4>
<div className="space-y-2">
{result.citations.map((c) => (
<CitationItem key={c.chunkId} citation={c} />
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,64 @@
'use client';
import { useState } from 'react';
import { Loader2, Search } from 'lucide-react';
import { z } from 'zod';
const schema = z.object({
question: z.string().min(1, 'กรุณาระบุคำถาม').max(500, 'คำถามต้องไม่เกิน 500 ตัวอักษร'),
});
interface RagSearchBarProps {
onSearch: (question: string) => void;
isLoading: boolean;
}
export function RagSearchBar({ onSearch, isLoading }: RagSearchBarProps) {
const [question, setQuestion] = useState('');
const [error, setError] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const result = schema.safeParse({ question });
if (!result.success) {
setError(result.error.issues[0]?.message ?? 'ข้อมูลไม่ถูกต้อง');
return;
}
setError(null);
onSearch(question);
};
return (
<form onSubmit={handleSubmit} className="w-full">
<div className="flex gap-2">
<div className="flex-1">
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="ถามคำถามเกี่ยวกับเอกสารโครงการ..."
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
disabled={isLoading}
maxLength={500}
/>
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
<p className="mt-1 text-xs text-muted-foreground text-right">
{question.length}/500
</p>
</div>
<button
type="submit"
disabled={isLoading || question.trim().length === 0}
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</button>
</div>
</form>
);
}
File diff suppressed because one or more lines are too long
@@ -91,9 +91,39 @@ describe('useWorkflowAction — T027a error handling (Clarify Q1+Q2)', () => {
); );
}); });
it('403: should show unauthorized toast', async () => { it('M1 (403): should use backend-provided message instead of hardcoded string', async () => {
// backend ส่ง message แบบ contextual (cross-contract) — frontend ต้อง preserve
vi.mocked(workflowEngineService.transition).mockRejectedValue( vi.mocked(workflowEngineService.transition).mockRejectedValue(
makeApiError(403, 'ไม่มีสิทธิ์', ['ติดต่อ Admin']) makeApiError(
403,
'คุณไม่มีสิทธิ์เข้าถึง Workflow ของสัญญานี้',
['ตรวจสอบสิทธิ์กับ Project Admin']
)
);
const { wrapper } = createTestQueryClient();
const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper });
await act(async () => {
result.current.mutate({ action: 'APPROVE' });
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
// ✓ toast ต้องใช้ backend message (ไม่ใช่ "คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้" generic)
expect(toast.error).toHaveBeenCalledWith(
'คุณไม่มีสิทธิ์เข้าถึง Workflow ของสัญญานี้',
expect.objectContaining({
description: 'ตรวจสอบสิทธิ์กับ Project Admin',
})
);
});
it('M1 (403): should fallback to generic message when backend message missing', async () => {
vi.mocked(workflowEngineService.transition).mockRejectedValue(
makeApiError(403, '', ['ติดต่อ Admin'])
); );
const { wrapper } = createTestQueryClient(); const { wrapper } = createTestQueryClient();
@@ -115,6 +145,81 @@ describe('useWorkflowAction — T027a error handling (Clarify Q1+Q2)', () => {
); );
}); });
it('M3 (409): should reset idempotency key after 409 so retry uses fresh key', async () => {
// First call → 409
vi.mocked(workflowEngineService.transition).mockRejectedValueOnce(
makeApiError(409, 'ไม่สามารถอัปโหลดในสถานะนี้ได้', ['รีเฟรชหน้า'])
);
// Second call → success
vi.mocked(workflowEngineService.transition).mockResolvedValueOnce({
success: true,
nextState: 'APPROVED',
});
const { wrapper } = createTestQueryClient();
const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper });
// First mutate → 409
await act(async () => {
result.current.mutate({ action: 'APPROVE' });
});
await waitFor(() => expect(result.current.isError).toBe(true));
const firstKey = vi
.mocked(workflowEngineService.transition)
.mock.calls[0][2];
// Second mutate → success
await act(async () => {
result.current.mutate({ action: 'APPROVE' });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const secondKey = vi
.mocked(workflowEngineService.transition)
.mock.calls[1][2];
// ✓ Key ต้องแตกต่างกัน (reset แล้วหลัง 409)
expect(firstKey).not.toBe(secondKey);
expect(firstKey).toMatch(/^[0-9a-f-]{36}$/);
expect(secondKey).toMatch(/^[0-9a-f-]{36}$/);
});
it('M3 (503): should NOT reset idempotency key (user can retry with same key)', async () => {
// First call → 503
vi.mocked(workflowEngineService.transition).mockRejectedValueOnce(
makeApiError(503, 'ระบบยุ่ง')
);
// Second call → success
vi.mocked(workflowEngineService.transition).mockResolvedValueOnce({
success: true,
});
const { wrapper } = createTestQueryClient();
const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper });
await act(async () => {
result.current.mutate({ action: 'APPROVE' });
});
await waitFor(() => expect(result.current.isError).toBe(true));
const firstKey = vi
.mocked(workflowEngineService.transition)
.mock.calls[0][2];
await act(async () => {
result.current.mutate({ action: 'APPROVE' });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const secondKey = vi
.mocked(workflowEngineService.transition)
.mock.calls[1][2];
// ✓ Key ต้องเหมือนเดิม (503 = retryable, same intent)
expect(firstKey).toBe(secondKey);
});
it('should show success toast on 200', async () => { it('should show success toast on 200', async () => {
vi.mocked(workflowEngineService.transition).mockResolvedValue({ vi.mocked(workflowEngineService.transition).mockResolvedValue({
success: true, success: true,
+36
View File
@@ -0,0 +1,36 @@
import { useMutation } from '@tanstack/react-query';
import apiClient from '../lib/api/client';
export interface RagCitation {
chunkId: string;
docNumber: string | null;
docType: string;
revision: string | null;
snippet: string;
score: number;
}
export interface RagQueryRequest {
question: string;
projectPublicId: string;
}
export interface RagQueryResponse {
answer: string;
citations: RagCitation[];
confidence: number;
usedFallbackModel: boolean;
cachedAt?: string;
}
export function useRagQuery() {
return useMutation<RagQueryResponse, Error, RagQueryRequest>({
mutationFn: async (payload) => {
const idempotencyKey = `rag-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const res = await apiClient.post<{ data: RagQueryResponse }>('/rag/query', payload, {
headers: { 'Idempotency-Key': idempotencyKey },
});
return res.data.data;
},
});
}
+10 -8
View File
@@ -12,13 +12,13 @@ import type { ApiErrorResponse } from '@/lib/api/client';
import type { WorkflowTransitionWithAttachmentsDto } from '@/types/dto/workflow-engine/workflow-engine.dto'; import type { WorkflowTransitionWithAttachmentsDto } from '@/types/dto/workflow-engine/workflow-engine.dto';
// Type guard — ตรวจสอบว่า error ที่ได้มาเป็น ApiErrorResponse (จาก parseApiError interceptor) // Type guard — ตรวจสอบว่า error ที่ได้มาเป็น ApiErrorResponse (จาก parseApiError interceptor)
// S3: ป้องกัน edge case `{ error: null }` ซึ่ง typeof null === 'object' แต่ destructure จะ throw
function isApiErrorResponse(err: unknown): err is ApiErrorResponse { function isApiErrorResponse(err: unknown): err is ApiErrorResponse {
return ( if (typeof err !== 'object' || err === null || !('error' in err)) {
typeof err === 'object' && return false;
err !== null && }
'error' in err && const inner = (err as { error: unknown }).error;
typeof (err as ApiErrorResponse).error === 'object' return typeof inner === 'object' && inner !== null;
);
} }
export function useWorkflowAction(instanceId: string | undefined) { export function useWorkflowAction(instanceId: string | undefined) {
@@ -69,15 +69,17 @@ export function useWorkflowAction(instanceId: string | undefined) {
// Clarify Q1: 409 Conflict (ไม่อยู่ในสถานะที่อนุญาตให้อัปโหลด) // Clarify Q1: 409 Conflict (ไม่อยู่ในสถานะที่อนุญาตให้อัปโหลด)
if (statusCode === 409) { if (statusCode === 409) {
// M3: reset idempotency key — user intent กับ state เดิมใช้ไม่ได้แล้ว
setIdempotencyKey(uuidv4());
toast.error(message || 'ไม่สามารถดำเนินการในสถานะนี้ได้', { toast.error(message || 'ไม่สามารถดำเนินการในสถานะนี้ได้', {
description: recoveryActions?.[0], description: recoveryActions?.[0],
}); });
return; return;
} }
// 403 Forbidden — ไม่มีสิทธิ์ // 403 Forbidden — ไม่มีสิทธิ์ (M1: ใช้ message จาก backend เพื่อคง context)
if (statusCode === 403) { if (statusCode === 403) {
toast.error('คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้', { toast.error(message || 'คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้', {
description: recoveryActions?.[0], description: recoveryActions?.[0],
}); });
return; return;
+794
View File
@@ -0,0 +1,794 @@
{
"auditReportVersion": 2,
"vulnerabilities": {
"@next/eslint-plugin-next": {
"name": "@next/eslint-plugin-next",
"severity": "high",
"isDirect": false,
"via": [
"glob"
],
"effects": [
"eslint-config-next"
],
"range": "14.0.5-canary.0 - 15.0.0-rc.1",
"nodes": [
"node_modules/@next/eslint-plugin-next"
],
"fixAvailable": true
},
"ajv": {
"name": "ajv",
"severity": "moderate",
"isDirect": false,
"via": [
{
"source": 1113714,
"name": "ajv",
"dependency": "ajv",
"title": "ajv has ReDoS when using `$data` option",
"url": "https://github.com/advisories/GHSA-2g4f-4pwh-qvx6",
"severity": "moderate",
"cwe": [
"CWE-400",
"CWE-1333"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": "<6.14.0"
}
],
"effects": [],
"range": "<6.14.0",
"nodes": [
"node_modules/ajv"
],
"fixAvailable": true
},
"axios": {
"name": "axios",
"severity": "high",
"isDirect": true,
"via": [
{
"source": 1113275,
"name": "axios",
"dependency": "axios",
"title": "Axios is Vulnerable to Denial of Service via __proto__ Key in mergeConfig",
"url": "https://github.com/advisories/GHSA-43fc-jf86-j433",
"severity": "high",
"cwe": [
"CWE-754"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=1.0.0 <=1.13.4"
},
{
"source": 1116673,
"name": "axios",
"dependency": "axios",
"title": "Axios has a NO_PROXY Hostname Normalization Bypass that Leads to SSRF",
"url": "https://github.com/advisories/GHSA-3p68-rc4w-qgx5",
"severity": "moderate",
"cwe": [
"CWE-441",
"CWE-918"
],
"cvss": {
"score": 4.8,
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N"
},
"range": ">=1.0.0 <1.15.0"
},
{
"source": 1116675,
"name": "axios",
"dependency": "axios",
"title": "Axios has Unrestricted Cloud Metadata Exfiltration via Header Injection Chain",
"url": "https://github.com/advisories/GHSA-fvcv-3m26-pcqx",
"severity": "moderate",
"cwe": [
"CWE-113",
"CWE-444",
"CWE-918"
],
"cvss": {
"score": 4.8,
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N"
},
"range": ">=1.0.0 <1.15.0"
}
],
"effects": [],
"range": "1.0.0 - 1.14.0",
"nodes": [
"node_modules/axios"
],
"fixAvailable": true
},
"brace-expansion": {
"name": "brace-expansion",
"severity": "moderate",
"isDirect": false,
"via": [
{
"source": 1115540,
"name": "brace-expansion",
"dependency": "brace-expansion",
"title": "brace-expansion: Zero-step sequence causes process hang and memory exhaustion",
"url": "https://github.com/advisories/GHSA-f886-m6hf-6m8v",
"severity": "moderate",
"cwe": [
"CWE-400"
],
"cvss": {
"score": 6.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H"
},
"range": "<1.1.13"
},
{
"source": 1115541,
"name": "brace-expansion",
"dependency": "brace-expansion",
"title": "brace-expansion: Zero-step sequence causes process hang and memory exhaustion",
"url": "https://github.com/advisories/GHSA-f886-m6hf-6m8v",
"severity": "moderate",
"cwe": [
"CWE-400"
],
"cvss": {
"score": 6.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H"
},
"range": ">=2.0.0 <2.0.3"
}
],
"effects": [],
"range": "<1.1.13 || >=2.0.0 <2.0.3",
"nodes": [
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion",
"node_modules/brace-expansion",
"node_modules/glob/node_modules/brace-expansion"
],
"fixAvailable": true
},
"dompurify": {
"name": "dompurify",
"severity": "moderate",
"isDirect": false,
"via": [
{
"source": 1115529,
"name": "dompurify",
"dependency": "dompurify",
"title": "DOMPurify is vulnerable to mutation-XSS via Re-Contextualization ",
"url": "https://github.com/advisories/GHSA-h8r8-wccr-v5f2",
"severity": "moderate",
"cwe": [
"CWE-79"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": "<3.3.2"
},
{
"source": 1115668,
"name": "dompurify",
"dependency": "dompurify",
"title": "DOMPurify contains a Cross-site Scripting vulnerability",
"url": "https://github.com/advisories/GHSA-v2wj-7wpq-c8vv",
"severity": "moderate",
"cwe": [
"CWE-79"
],
"cvss": {
"score": 6.1,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"
},
"range": ">=3.1.3 <=3.3.1"
},
{
"source": 1115921,
"name": "dompurify",
"dependency": "dompurify",
"title": "DOMPurify ADD_ATTR predicate skips URI validation",
"url": "https://github.com/advisories/GHSA-cjmm-f4jc-qw8r",
"severity": "moderate",
"cwe": [
"CWE-183"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": "<=3.3.1"
},
{
"source": 1115922,
"name": "dompurify",
"dependency": "dompurify",
"title": "DOMPurify USE_PROFILES prototype pollution allows event handlers",
"url": "https://github.com/advisories/GHSA-cj63-jhhr-wcxv",
"severity": "moderate",
"cwe": [
"CWE-1321"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": "<=3.3.1"
},
{
"source": 1116663,
"name": "dompurify",
"dependency": "dompurify",
"title": "DOMPurify's ADD_TAGS function form bypasses FORBID_TAGS due to short-circuit evaluation",
"url": "https://github.com/advisories/GHSA-39q2-94rc-95cp",
"severity": "moderate",
"cwe": [
"CWE-783"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": "<=3.3.3"
}
],
"effects": [
"monaco-editor"
],
"range": "<=3.3.3",
"nodes": [
"node_modules/dompurify"
],
"fixAvailable": true
},
"eslint-config-next": {
"name": "eslint-config-next",
"severity": "high",
"isDirect": true,
"via": [
"@next/eslint-plugin-next"
],
"effects": [],
"range": "14.0.5-canary.0 - 15.0.0-rc.1",
"nodes": [
"node_modules/eslint-config-next"
],
"fixAvailable": true
},
"flatted": {
"name": "flatted",
"severity": "high",
"isDirect": false,
"via": [
{
"source": 1114526,
"name": "flatted",
"dependency": "flatted",
"title": "flatted vulnerable to unbounded recursion DoS in parse() revive phase",
"url": "https://github.com/advisories/GHSA-25h7-pfq9-p65f",
"severity": "high",
"cwe": [
"CWE-674"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": "<3.4.0"
},
{
"source": 1115357,
"name": "flatted",
"dependency": "flatted",
"title": "Prototype Pollution via parse() in NodeJS flatted",
"url": "https://github.com/advisories/GHSA-rf6f-7fwh-wjgh",
"severity": "high",
"cwe": [
"CWE-1321"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": "<=3.4.1"
}
],
"effects": [],
"range": "<=3.4.1",
"nodes": [
"node_modules/flatted"
],
"fixAvailable": true
},
"follow-redirects": {
"name": "follow-redirects",
"severity": "moderate",
"isDirect": false,
"via": [
{
"source": 1116560,
"name": "follow-redirects",
"dependency": "follow-redirects",
"title": "follow-redirects leaks Custom Authentication Headers to Cross-Domain Redirect Targets",
"url": "https://github.com/advisories/GHSA-r4q5-vmmm-2653",
"severity": "moderate",
"cwe": [
"CWE-200"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": "<=1.15.11"
}
],
"effects": [],
"range": "<=1.15.11",
"nodes": [
"node_modules/follow-redirects"
],
"fixAvailable": true
},
"glob": {
"name": "glob",
"severity": "high",
"isDirect": false,
"via": [
{
"source": 1109842,
"name": "glob",
"dependency": "glob",
"title": "glob CLI: Command injection via -c/--cmd executes matches with shell:true",
"url": "https://github.com/advisories/GHSA-5j98-mcp5-4vw2",
"severity": "high",
"cwe": [
"CWE-78"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H"
},
"range": ">=10.2.0 <10.5.0"
}
],
"effects": [
"@next/eslint-plugin-next"
],
"range": "10.2.0 - 10.4.5",
"nodes": [
"node_modules/glob"
],
"fixAvailable": true
},
"minimatch": {
"name": "minimatch",
"severity": "high",
"isDirect": false,
"via": [
{
"source": 1113459,
"name": "minimatch",
"dependency": "minimatch",
"title": "minimatch has a ReDoS via repeated wildcards with non-matching literal in pattern",
"url": "https://github.com/advisories/GHSA-3ppc-4f35-3m26",
"severity": "high",
"cwe": [
"CWE-1333"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": "<3.1.3"
},
{
"source": 1113465,
"name": "minimatch",
"dependency": "minimatch",
"title": "minimatch has a ReDoS via repeated wildcards with non-matching literal in pattern",
"url": "https://github.com/advisories/GHSA-3ppc-4f35-3m26",
"severity": "high",
"cwe": [
"CWE-1333"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": ">=9.0.0 <9.0.6"
},
{
"source": 1113538,
"name": "minimatch",
"dependency": "minimatch",
"title": "minimatch has ReDoS: matchOne() combinatorial backtracking via multiple non-adjacent GLOBSTAR segments",
"url": "https://github.com/advisories/GHSA-7r86-cg39-jmmj",
"severity": "high",
"cwe": [
"CWE-407"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": "<3.1.3"
},
{
"source": 1113544,
"name": "minimatch",
"dependency": "minimatch",
"title": "minimatch has ReDoS: matchOne() combinatorial backtracking via multiple non-adjacent GLOBSTAR segments",
"url": "https://github.com/advisories/GHSA-7r86-cg39-jmmj",
"severity": "high",
"cwe": [
"CWE-407"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=9.0.0 <9.0.7"
},
{
"source": 1113546,
"name": "minimatch",
"dependency": "minimatch",
"title": "minimatch ReDoS: nested *() extglobs generate catastrophically backtracking regular expressions",
"url": "https://github.com/advisories/GHSA-23c5-xmqv-rm74",
"severity": "high",
"cwe": [
"CWE-1333"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": "<3.1.4"
},
{
"source": 1113552,
"name": "minimatch",
"dependency": "minimatch",
"title": "minimatch ReDoS: nested *() extglobs generate catastrophically backtracking regular expressions",
"url": "https://github.com/advisories/GHSA-23c5-xmqv-rm74",
"severity": "high",
"cwe": [
"CWE-1333"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=9.0.0 <9.0.7"
}
],
"effects": [],
"range": "<=3.1.3 || 9.0.0 - 9.0.6",
"nodes": [
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch",
"node_modules/glob/node_modules/minimatch",
"node_modules/minimatch"
],
"fixAvailable": true
},
"monaco-editor": {
"name": "monaco-editor",
"severity": "moderate",
"isDirect": false,
"via": [
"dompurify"
],
"effects": [],
"range": ">=0.54.0-dev-20250909",
"nodes": [
"node_modules/monaco-editor"
],
"fixAvailable": true
},
"next": {
"name": "next",
"severity": "high",
"isDirect": true,
"via": [
{
"source": 1111374,
"name": "next",
"dependency": "next",
"title": "Next Server Actions Source Code Exposure ",
"url": "https://github.com/advisories/GHSA-w37m-7fhw-fmv9",
"severity": "moderate",
"cwe": [
"CWE-497",
"CWE-502",
"CWE-1395"
],
"cvss": {
"score": 5.3,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N"
},
"range": ">=16.0.0-beta.0 <16.0.9"
},
{
"source": 1111383,
"name": "next",
"dependency": "next",
"title": "Next Vulnerable to Denial of Service with Server Components",
"url": "https://github.com/advisories/GHSA-mwv6-3258-q52c",
"severity": "high",
"cwe": [
"CWE-400",
"CWE-502",
"CWE-1395"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=16.0.0-beta.0 <16.0.9"
},
{
"source": 1112592,
"name": "next",
"dependency": "next",
"title": "Next.js self-hosted applications vulnerable to DoS via Image Optimizer remotePatterns configuration",
"url": "https://github.com/advisories/GHSA-9g9p-9gw9-jx7f",
"severity": "moderate",
"cwe": [
"CWE-400",
"CWE-770"
],
"cvss": {
"score": 5.9,
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=15.6.0-canary.0 <16.1.5"
},
{
"source": 1112646,
"name": "next",
"dependency": "next",
"title": "Next.js HTTP request deserialization can lead to DoS when using insecure React Server Components",
"url": "https://github.com/advisories/GHSA-h25m-26qc-wcjf",
"severity": "high",
"cwe": [
"CWE-400",
"CWE-502"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=16.0.0-beta.0 <16.0.11"
},
{
"source": 1114898,
"name": "next",
"dependency": "next",
"title": "Next.js: HTTP request smuggling in rewrites",
"url": "https://github.com/advisories/GHSA-ggv3-7p47-pfv8",
"severity": "moderate",
"cwe": [
"CWE-444"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": ">=16.0.0-beta.0 <16.1.7"
},
{
"source": 1114941,
"name": "next",
"dependency": "next",
"title": "Next.js: Unbounded next/image disk cache growth can exhaust storage",
"url": "https://github.com/advisories/GHSA-3x4c-7xq6-9pq8",
"severity": "moderate",
"cwe": [
"CWE-400"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": ">=16.0.0-beta.0 <16.1.7"
},
{
"source": 1114942,
"name": "next",
"dependency": "next",
"title": "Next.js: Unbounded postponed resume buffering can lead to DoS",
"url": "https://github.com/advisories/GHSA-h27x-g6w4-24gq",
"severity": "moderate",
"cwe": [
"CWE-770"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": ">=16.0.1 <16.1.7"
},
{
"source": 1114943,
"name": "next",
"dependency": "next",
"title": "Next.js: null origin can bypass Server Actions CSRF checks",
"url": "https://github.com/advisories/GHSA-mq59-m269-xvcx",
"severity": "moderate",
"cwe": [
"CWE-352"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": ">=16.0.1 <16.1.7"
},
{
"source": 1115360,
"name": "next",
"dependency": "next",
"title": "Next.js: null origin can bypass dev HMR websocket CSRF checks",
"url": "https://github.com/advisories/GHSA-jcc7-9wpm-mj36",
"severity": "low",
"cwe": [
"CWE-1385"
],
"cvss": {
"score": 0,
"vectorString": null
},
"range": ">=16.0.1 <16.1.7"
},
{
"source": 1116305,
"name": "next",
"dependency": "next",
"title": "Next.js has Unbounded Memory Consumption via PPR Resume Endpoint ",
"url": "https://github.com/advisories/GHSA-5f7q-jpqc-wp7h",
"severity": "moderate",
"cwe": [
"CWE-400",
"CWE-409",
"CWE-770"
],
"cvss": {
"score": 5.9,
"vectorString": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=16.0.0-beta.0 <16.1.5"
},
{
"source": 1116375,
"name": "next",
"dependency": "next",
"title": "Next.js has a Denial of Service with Server Components",
"url": "https://github.com/advisories/GHSA-q4gf-8mx6-v5v3",
"severity": "high",
"cwe": [
"CWE-770"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=16.0.0-beta.0 <16.2.3"
}
],
"effects": [],
"range": "15.6.0-canary.0 - 16.2.2",
"nodes": [
"node_modules/next"
],
"fixAvailable": true
},
"picomatch": {
"name": "picomatch",
"severity": "high",
"isDirect": false,
"via": [
{
"source": 1115549,
"name": "picomatch",
"dependency": "picomatch",
"title": "Picomatch: Method Injection in POSIX Character Classes causes incorrect Glob Matching",
"url": "https://github.com/advisories/GHSA-3v7f-55p6-f55p",
"severity": "moderate",
"cwe": [
"CWE-1321"
],
"cvss": {
"score": 5.3,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N"
},
"range": "<2.3.2"
},
{
"source": 1115551,
"name": "picomatch",
"dependency": "picomatch",
"title": "Picomatch: Method Injection in POSIX Character Classes causes incorrect Glob Matching",
"url": "https://github.com/advisories/GHSA-3v7f-55p6-f55p",
"severity": "moderate",
"cwe": [
"CWE-1321"
],
"cvss": {
"score": 5.3,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N"
},
"range": ">=4.0.0 <4.0.4"
},
{
"source": 1115552,
"name": "picomatch",
"dependency": "picomatch",
"title": "Picomatch has a ReDoS vulnerability via extglob quantifiers",
"url": "https://github.com/advisories/GHSA-c2c7-rcm5-vvqj",
"severity": "high",
"cwe": [
"CWE-1333"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": "<2.3.2"
},
{
"source": 1115554,
"name": "picomatch",
"dependency": "picomatch",
"title": "Picomatch has a ReDoS vulnerability via extglob quantifiers",
"url": "https://github.com/advisories/GHSA-c2c7-rcm5-vvqj",
"severity": "high",
"cwe": [
"CWE-1333"
],
"cvss": {
"score": 7.5,
"vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
},
"range": ">=4.0.0 <4.0.4"
}
],
"effects": [],
"range": "<=2.3.1 || 4.0.0 - 4.0.3",
"nodes": [
"node_modules/picomatch",
"node_modules/tinyglobby/node_modules/picomatch"
],
"fixAvailable": true
}
},
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 5,
"high": 8,
"critical": 0,
"total": 13
},
"dependencies": {
"prod": 300,
"dev": 301,
"optional": 63,
"peer": 5,
"peerOptional": 0,
"total": 641
}
}
}
+4 -3
View File
@@ -34,13 +34,13 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.91.2", "@tanstack/react-query": "^5.91.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.13.6", "axios": "1.15.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "^16.2.0", "next": "16.2.4",
"next-auth": "5.0.0-beta.30", "next-auth": "5.0.0-beta.30",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.4", "react": "^19.2.4",
@@ -67,7 +67,7 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.57.1", "@typescript-eslint/eslint-plugin": "^8.57.1",
"@typescript-eslint/parser": "^8.57.1", "@typescript-eslint/parser": "^8.57.1",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.2.0",
"autoprefixer": "^10.4.27", "autoprefixer": "^10.4.27",
"baseline-browser-mapping": "^2.10.8", "baseline-browser-mapping": "^2.10.8",
"eslint": "^9.39.1", "eslint": "^9.39.1",
@@ -80,6 +80,7 @@
"postcss": "^8.5.8", "postcss": "^8.5.8",
"tailwindcss": "3.4.3", "tailwindcss": "3.4.3",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "7.3.2",
"vitest": "^4.1.0" "vitest": "^4.1.0"
} }
} }
+22
View File
@@ -0,0 +1,22 @@
{
"auditReportVersion": 2,
"vulnerabilities": {},
"metadata": {
"vulnerabilities": {
"info": 0,
"low": 0,
"moderate": 0,
"high": 0,
"critical": 0,
"total": 0
},
"dependencies": {
"prod": 1,
"dev": 2,
"optional": 2,
"peer": 2,
"peerOptional": 0,
"total": 2
}
}
}
+13 -3
View File
@@ -37,11 +37,11 @@
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"fast-xml-parser": "^5.3.5", "fast-xml-parser": ">=5.5.7",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"jws@<3.2.3": ">=3.2.3", "jws@<3.2.3": ">=3.2.3",
"lodash@>=4.0.0 <=4.17.22": ">=4.17.23", "lodash@>=4.0.0 <=4.17.23": ">=4.18.0",
"undici@<6.23.0": ">=6.23.0", "undici@<6.23.0": ">=6.23.0",
"undici@>=7.0.0 <7.18.2": ">=7.18.2", "undici@>=7.0.0 <7.18.2": ">=7.18.2",
"diff@>=4.0.0 <4.0.4": ">=4.0.4", "diff@>=4.0.0 <4.0.4": ">=4.0.4",
@@ -71,7 +71,17 @@
"undici@>=6.0.0 <6.24.0": ">=6.24.0", "undici@>=6.0.0 <6.24.0": ">=6.24.0",
"undici@<6.24.0": ">=6.24.0", "undici@<6.24.0": ">=6.24.0",
"file-type@>=20.0.0 <=21.3.1": ">=21.3.2", "file-type@>=20.0.0 <=21.3.1": ">=21.3.2",
"socket.io-parser@>=4.0.0 <4.2.6": ">=4.2.6" "socket.io-parser@>=4.0.0 <4.2.6": ">=4.2.6",
"handlebars@>=4.0.0 <=4.7.8": ">=4.7.9",
"vite@>=7.0.0 <=7.3.1": ">=7.3.2",
"picomatch@<2.3.2": ">=2.3.2",
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
"brace-expansion@>=1.0.0 <1.1.13": ">=1.1.13",
"brace-expansion@>=5.0.0 <5.0.5": ">=5.0.5",
"yaml@<2.8.3": ">=2.8.3",
"nodemailer@>=8.0.0 <8.0.5": ">=8.0.5",
"follow-redirects@<=1.15.11": ">=1.16.0"
} }
} }
} }
+1230 -1128
View File
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
# Runbook: รัน k6 Performance Test บน Windows (PowerShell)
**Target:** ADR-021 T048 — Verify `POST /workflow-engine/instances/:id/transition` P95 ≤ 5s (Clarify Q4)
**Script:** `scripts/perf/workflow-transition.k6.js`
---
## Prerequisites
- Windows 10/11 + PowerShell 5.1+ (หรือ PowerShell 7+)
- `curl.exe` (built-in ตั้งแต่ Windows 10 1803+)
- Backend API รันอยู่ (localhost:3001 หรือ staging URL)
- ไฟล์ `test-5mb.pdf` (หรือขนาดใกล้เคียง ≤ 10MB)
---
## ขั้นที่ 1 — ติดตั้ง k6
เลือก **1 วิธี**:
```powershell
# Chocolatey
choco install k6
# winget
winget install k6.k6
# หรือ download manual: https://github.com/grafana/k6/releases
```
ตรวจสอบ:
```powershell
k6 version
# ควรได้: k6 v0.50.x (หรือใหม่กว่า)
```
---
## ขั้นที่ 2 — เตรียมข้อมูลทดสอบ (ทำครั้งเดียวก่อนรัน)
### 2.1 Login เอา JWT token
```powershell
$baseUrl = "http://localhost:3001"
$loginBody = '{"username":"admin","password":"YOUR_PASSWORD"}'
$loginRes = curl.exe -s -X POST "$baseUrl/api/auth/login" `
-H "Content-Type: application/json" `
-d $loginBody | ConvertFrom-Json
$token = $loginRes.data.accessToken
Write-Host "Token: $token" -ForegroundColor Green
```
### 2.2 อัปโหลดไฟล์ทดสอบ (Two-Phase)
```powershell
# Phase 1 — Upload (temp)
$uploadRes = curl.exe -s -X POST "$baseUrl/api/files/upload" `
-H "Authorization: Bearer $token" `
-F "file=@test-5mb.pdf" | ConvertFrom-Json
$tempId = $uploadRes.data.tempId
$publicId = $uploadRes.data.publicId
Write-Host "publicId=$publicId" -ForegroundColor Yellow
# Phase 2 — Commit (is_temporary=false)
curl.exe -s -X POST "$baseUrl/api/files/commit" `
-H "Authorization: Bearer $token" `
-H "Content-Type: application/json" `
-d "{\""tempId\"":\""$tempId\""}"
```
### 2.3 หา Workflow Instance ID
เลือก **1 วิธี**:
**วิธี A — Query DB โดยตรง (แนะนำ):**
```sql
SELECT id, current_state
FROM workflow_instances
WHERE current_state IN ('PENDING_REVIEW', 'PENDING_APPROVAL')
LIMIT 1;
```
**วิธี B — สร้าง RFA ใหม่แล้ว submit:**
```powershell
# สร้าง RFA → submit → workflow instance จะอยู่ใน PENDING_REVIEW อัตโนมัติ
# (ดู backend/test/rfa.e2e-spec.ts สำหรับ payload ตัวอย่าง)
```
```powershell
$instanceId = "<UUID ที่ได้>"
```
---
## ขั้นที่ 3 — ตั้ง Environment Variables
```powershell
$env:BASE_URL = "http://localhost:3001"
$env:USERNAME = "admin"
$env:PASSWORD = "YOUR_PASSWORD"
$env:INSTANCE_ID = $instanceId
$env:ATTACHMENT_UUID = $publicId
```
---
## ขั้นที่ 4 — รัน k6
```powershell
# จาก repo root
k6 run scripts/perf/workflow-transition.k6.js
```
### ผลลัพธ์ที่คาดหวัง (Success)
```
✓ login successful
✓ status is 2xx
✓ duration < 5s (P95 SLA)
checks.........................: 100.00% ✓ 20 ✗ 0
http_req_duration..............: avg=2.1s p(95)=3.0s
✓ transition_duration_ms.........: avg=2102 p(95)=3021
✓ http_req_failed................: 0.00%
running (0m22.5s), 0/1 VUs, 10 complete and 0 interrupted iterations
```
**ผ่าน SLA** เมื่อเห็น `✓` ที่:
- `transition_duration_ms.........: p(95) < 5000`
- `http_req_failed................: rate < 0.01`
### ตัวอย่าง Failure
```
✗ transition_duration_ms
p(95)=6821 (≥ 5000)
✗ some iterations failed
```
Exit code ≠ 0 → ใช้เป็น CI gate ได้ทันที
---
## Troubleshooting
| อาการ | สาเหตุ | วิธีแก้ |
|---|---|---|
| **HTTP 409 Conflict** ตั้งแต่ iter แรก | Instance ไม่อยู่ใน `PENDING_REVIEW`/`PENDING_APPROVAL` | ใช้ SQL ใน 2.3 วิธี A เพื่อหา instance ที่ state ถูกต้อง |
| **HTTP 409** iter 2+ | iter 1 ทำให้ state เป็น `APPROVED` | (ก) ใช้ workflow DSL ที่ action กลับมา pending ได้ (เช่น `RETURN`) (ข) re-create instance ก่อนรัน |
| **HTTP 400** "Idempotency-Key required" | Header หาย | ตรวจบรรทัด `'Idempotency-Key': idempotencyKey` ใน script (line ~94) |
| **HTTP 403 Forbidden** | User ไม่ใช่ assigned handler | login ด้วย superadmin หรือ set `context.assignedUserId` = user_id ของ test user |
| **Attachment mismatch** | `isTemporary=true` หรือ publicId ซ้ำ | re-run 2.2 เอา publicId ใหม่ทุกครั้ง |
| **P95 > 5s** ทุก iter | ClamAV scan / Redis ช้า | ตรวจ `docker stats` — ClamAV RAM ≥ 1GB, Redis connection pool ≥ 10 |
| **Cannot connect** | Backend ไม่รัน / port ชน | `docker compose ps` ตรวจ container healthy |
---
## Cleanup (หลังทดสอบ)
```powershell
# Reset env vars (ถ้าต้องการ)
Remove-Item Env:\BASE_URL, Env:\USERNAME, Env:\PASSWORD, `
Env:\INSTANCE_ID, Env:\ATTACHMENT_UUID
```
---
## Manual Fallback (ไม่มี k6)
```powershell
$results = @()
1..10 | ForEach-Object {
$idk = [guid]::NewGuid().ToString()
$body = "{\""action\"":\""APPROVE\"",\""attachmentPublicIds\"":[\""$env:ATTACHMENT_UUID\""]}"
$sw = [Diagnostics.Stopwatch]::StartNew()
$status = curl.exe -s -o $null -w "%{http_code}" -X POST `
"$env:BASE_URL/api/workflow-engine/instances/$env:INSTANCE_ID/transition" `
-H "Authorization: Bearer $token" `
-H "Idempotency-Key: $idk" `
-H "Content-Type: application/json" `
-d $body
$sw.Stop()
$results += [PSCustomObject]@{
Iter = $_
Status = $status
DurationMs = $sw.ElapsedMilliseconds
}
}
$results | Format-Table
$p95 = ($results.DurationMs | Sort-Object)[[int]([math]::Ceiling($results.Count * 0.95) - 1)]
Write-Host "P95 = $p95 ms (SLA: < 5000)" -ForegroundColor $(if ($p95 -lt 5000) {'Green'} else {'Red'})
```
---
## Sign-off Checklist
- [ ] k6 run exit code = 0
- [ ] `transition_duration_ms p(95) < 5000`
- [ ] `http_req_failed rate < 0.01`
- [ ] ทดสอบทั้ง 4 modules: RFA, Transmittal, Circulation, Correspondence (1 ครั้ง/module)
- [ ] ทดสอบซ้ำบน staging environment (ไม่ใช่เฉพาะ localhost)
- [ ] บันทึกผลลัพธ์ใน `specs/88-logs/perf-YYYY-MM-DD.md`
**Sign-off ที่ staging = ปิด T048 ได้จริง**
+29 -17
View File
@@ -16,9 +16,29 @@
import http from 'k6/http'; import http from 'k6/http';
import { check, sleep } from 'k6'; import { check, sleep } from 'k6';
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; import crypto from 'k6/crypto';
import { Trend } from 'k6/metrics'; import { Trend } from 'k6/metrics';
// L1: สร้าง UUIDv4 จาก k6/crypto (built-in) แทน remote jslib
// รูปแบบตาม RFC 4122 section 4.4 — 16 bytes random + set version/variant bits
function uuidv4() {
const bytes = new Uint8Array(crypto.randomBytes(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant RFC 4122
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return (
hex.slice(0, 8) +
'-' +
hex.slice(8, 12) +
'-' +
hex.slice(12, 16) +
'-' +
hex.slice(16, 20) +
'-' +
hex.slice(20, 32)
);
}
// Custom metric เฉพาะ transition endpoint (ไม่รวม login/upload) // Custom metric เฉพาะ transition endpoint (ไม่รวม login/upload)
const transitionDuration = new Trend('transition_duration_ms', true); const transitionDuration = new Trend('transition_duration_ms', true);
@@ -34,8 +54,8 @@ export const options = {
}, },
thresholds: { thresholds: {
// ADR-021 Clarify Q4: P95 ≤ 5000ms // ADR-021 Clarify Q4: P95 ≤ 5000ms
'transition_duration_ms': ['p(95) < 5000'], transition_duration_ms: ['p(95) < 5000'],
'http_req_failed': ['rate < 0.01'], // < 1% failure rate http_req_failed: ['rate < 0.01'], // < 1% failure rate
}, },
}; };
@@ -50,16 +70,12 @@ export function setup() {
const attachmentUuid = __ENV.ATTACHMENT_UUID; const attachmentUuid = __ENV.ATTACHMENT_UUID;
if (!username || !password || !instanceId || !attachmentUuid) { if (!username || !password || !instanceId || !attachmentUuid) {
throw new Error( throw new Error('Missing env vars. Required: USERNAME, PASSWORD, INSTANCE_ID, ATTACHMENT_UUID');
'Missing env vars. Required: USERNAME, PASSWORD, INSTANCE_ID, ATTACHMENT_UUID'
);
} }
const loginRes = http.post( const loginRes = http.post(`${baseUrl}/api/auth/login`, JSON.stringify({ username, password }), {
`${baseUrl}/api/auth/login`, headers: { 'Content-Type': 'application/json' },
JSON.stringify({ username, password }), });
{ headers: { 'Content-Type': 'application/json' } }
);
check(loginRes, { check(loginRes, {
'login successful': (r) => r.status === 200 || r.status === 201, 'login successful': (r) => r.status === 200 || r.status === 201,
@@ -92,18 +108,14 @@ export default function (data) {
const params = { const params = {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`, Authorization: `Bearer ${token}`,
'Idempotency-Key': idempotencyKey, 'Idempotency-Key': idempotencyKey,
}, },
tags: { name: 'workflow-transition' }, tags: { name: 'workflow-transition' },
}; };
const start = Date.now(); const start = Date.now();
const res = http.post( const res = http.post(`${baseUrl}/api/workflow-engine/instances/${instanceId}/transition`, payload, params);
`${baseUrl}/api/workflow-engine/instances/${instanceId}/transition`,
payload,
params
);
const duration = Date.now() - start; const duration = Date.now() - start;
transitionDuration.add(duration); transitionDuration.add(duration);
@@ -0,0 +1,11 @@
-- Delta 08: ADR-022 RAG — เพิ่ม rag_status และ rag_last_error ในตาราง attachments
-- Apply: 2026-04-19
-- Ref: specs/08-Tasks/ADR-022-Retrieval-Augmented-Generation/data-model.md §1.1
ALTER TABLE attachments
ADD COLUMN rag_status ENUM('PENDING', 'PROCESSING', 'INDEXED', 'FAILED')
NOT NULL DEFAULT 'PENDING'
COMMENT 'สถานะ RAG ingestion ระดับ file',
ADD COLUMN rag_last_error TEXT NULL
COMMENT 'Error message ล่าสุดเมื่อ rag_status = FAILED',
ADD INDEX idx_attachments_rag_status (rag_status);
@@ -0,0 +1,25 @@
-- Delta 08b: ADR-022 RAG — สร้างตาราง document_chunks สำหรับเก็บ vector metadata
-- Apply: 2026-04-19
-- Ref: specs/08-Tasks/ADR-022-Retrieval-Augmented-Generation/data-model.md §1.2
CREATE TABLE document_chunks (
id CHAR(36) NOT NULL PRIMARY KEY COMMENT 'UUID = Qdrant point ID',
document_id CHAR(36) NOT NULL COMMENT 'FK → attachments.public_id (UUIDv7)',
chunk_index INT NOT NULL COMMENT 'ลำดับ chunk ภายใน document',
content TEXT NOT NULL COMMENT 'เนื้อหา chunk หลัง PyThaiNLP normalize',
doc_type VARCHAR(20) NOT NULL COMMENT 'CORR, RFA, DRAWING, CONTRACT, RPT, TRANS',
doc_number VARCHAR(100) NULL COMMENT 'หมายเลขเอกสาร เช่น REF-2026-001',
revision VARCHAR(20) NULL COMMENT 'Revision เช่น Rev.A',
project_code VARCHAR(50) NOT NULL COMMENT 'รหัสโครงการ (ใช้ filter)',
project_public_id CHAR(36) NOT NULL COMMENT 'UUIDv7 ของโครงการ (Qdrant tenant key)',
version VARCHAR(20) NULL COMMENT 'เวอร์ชันเอกสาร เช่น 1.0, 2.1 (ถ้ามี)',
classification ENUM('PUBLIC', 'INTERNAL', 'CONFIDENTIAL')
NOT NULL DEFAULT 'INTERNAL',
embedding_model VARCHAR(100) NOT NULL DEFAULT 'nomic-embed-text',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX idx_chunks_document_id (document_id),
INDEX idx_chunks_doc_number_rev (doc_number, revision),
INDEX idx_chunks_project (project_public_id),
FULLTEXT INDEX ft_chunks_content (content)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,8 @@
-- Delta 08c: ADR-022 RAG — เพิ่ม permissions สำหรับ RAG feature
-- Apply: 2026-04-19
-- Ref: specs/08-Tasks/ADR-022-Retrieval-Augmented-Generation/tasks.md T014
INSERT IGNORE INTO permissions (permission_name, description, module, created_at, updated_at)
VALUES
('rag.query', 'ใช้งาน RAG Q&A เพื่อค้นหาคำตอบจากเอกสาร', 'rag', NOW(), NOW()),
('rag.manage', 'จัดการ RAG ingestion, re-index, ลบ vectors', 'rag', NOW(), NOW());
@@ -1 +1 @@
GRAFANA_ADMIN_PASSWORD= GRAFANA_ADMIN_PASSWORD=Center#2025
@@ -77,7 +77,7 @@ services:
GF_SERVER_ROOT_URL: 'https://grafana.np-dms.work' GF_SERVER_ROOT_URL: 'https://grafana.np-dms.work'
GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-piechart-panel GF_INSTALL_PLUGINS: grafana-clock-panel,grafana-piechart-panel
ports: ports:
- '3000:3000' - '3003:3000'
networks: networks:
- lcbp3 - lcbp3
volumes: volumes:
@@ -5,14 +5,94 @@
# วิธีใช้ (บน QNAP): # วิธีใช้ (บน QNAP):
# cp /share/np-dms/.env.master /share/np-dms/app/.env # cp /share/np-dms/.env.master /share/np-dms/app/.env
# chmod 600 /share/np-dms/app/.env # chmod 600 /share/np-dms/app/.env
# --- ใช้โดย docker-compose-app.yml --- # --- ใช้โดย docker-compose-app.yml ---
DB_PASSWORD= # File: .env (Unified for QNAP / Gitea Runner)
REDIS_PASSWORD= # Change Log: 2026-04-19
# ---------------------------------------------------------
# 1. Backend Service Configuration
# ---------------------------------------------------------
TZ=Asia/Bangkok
NODE_ENV=production
PORT=3000
# --- Database (MariaDB) ---
DB_HOST=mariadb
DB_PORT=3306
DB_DATABASE=lcbp3
DB_USERNAME=center
DB_PASSWORD=Center#2025
# --- Redis (Cache & Queue) ---
REDIS_HOST=cache
REDIS_PORT=6379
REDIS_PASSWORD=redis3ac466bf9b6
# --- Search (Elasticsearch) ---
ELASTICSEARCH_HOST=search
ELASTICSEARCH_PORT=9200
ELASTICSEARCH_USERNAME=elastic ELASTICSEARCH_USERNAME=elastic
ELASTICSEARCH_PASSWORD= ELASTICSEARCH_PASSWORD=elasticed0bbde94
JWT_SECRET=
JWT_REFRESH_SECRET= # --- Security (JWT) ---
AUTH_SECRET= JWT_SECRET=jwtsecret65adde8c76c6a0847d9649b2b67a06db1504693e6c912e51499b76e
JWT_EXPIRATION=24h
JWT_REFRESH_SECRET=jwtrefreshf6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
# --- Numbering Logic ---
NUMBERING_LOCK_TIMEOUT=5000
NUMBERING_RESERVATION_TTL=300
# --- File Storage ---
UPLOAD_TEMP_DIR=/share/np-dms-as/data/uploads/temp
UPLOAD_PERMANENT_DIR=/share/np-dms-as/data/uploads/permanent
MAX_FILE_SIZE=52428800
# ---------------------------------------------------------
# 2. Frontend Service Configuration
# ---------------------------------------------------------
# หมายเหตุ: ค่าเหล่านี้จะถูกใช้ตอน Docker Build (ตาม deploy.sh)
NEXT_PUBLIC_API_URL=https://backend.np-dms.work/api
AUTH_URL=https://lcbp3.np-dms.work
# --- NextAuth ---
# ค่านี้ต้องตรงกับ JWT_SECRET หรือตั้งแยกตามความปลอดภัย
AUTH_SECRET=jwtsecret65adde8c76c6a0847d9649b2b67a06db1504693e6c912e51499b76e
AUTH_TRUST_HOST=true
# --- Shared Context ---
INTERNAL_API_URL=http://backend:3000/api
HOSTNAME=0.0.0.0
# --- Docker Image ---
BACKEND_IMAGE_TAG=latest BACKEND_IMAGE_TAG=latest
FRONTEND_IMAGE_TAG=latest FRONTEND_IMAGE_TAG=latest
# ClamAV
CLAMAV_HOST=localhost
CLAMAV_PORT=3310
# ========================================
# ADR-022 RAG — Retrieval-Augmented Generation
# ========================================
# Qdrant vector store (local docker-compose or QNAP)
QDRANT_URL=http://localhost:6333
# Ollama (Admin Desktop Desk-5439 — ADR-018 AI boundary)
OLLAMA_EMBED_MODEL=nomic-embed-text
OLLAMA_RAG_MODEL=gemma3:12b
OLLAMA_URL=http://192.168.20.200:11434
# Thai preprocessing microservice (PyThaiNLP — Admin Desktop)
THAI_PREPROCESS_URL=http://192.168.20.200:8765
# Typhoon API (cloud LLM — PUBLIC/INTERNAL only, never CONFIDENTIAL)
TYPHOON_API_KEY=your-typhoon-api-key-here
TYPHOON_API_URL=https://api.opentyphoon.ai/v1
# RAG query config
RAG_TOPK=20
RAG_FINAL_K=5
RAG_TIMEOUT_MS=5000
RAG_QUERY_CACHE_TTL=300
@@ -1,3 +1,3 @@
# Per-stack .env.example — n8n + postgres + tika + docker-socket-proxy # Per-stack .env.example — n8n + postgres + tika + docker-socket-proxy
N8N_DB_PASSWORD= N8N_DB_PASSWORD=Np721220$
N8N_ENCRYPTION_KEY= N8N_ENCRYPTION_KEY=9AAIB7Da9DW1qAhJE5/Bz4SnbQjeAngI
@@ -15,6 +15,7 @@ x-logging: &default_logging
options: options:
max-size: '10m' max-size: '10m'
max-file: '5' max-file: '5'
name: lcbp3-n8n
services: services:
n8n-db: n8n-db:
<<: [*restart_policy, *default_logging] <<: [*restart_policy, *default_logging]
@@ -112,7 +113,9 @@ services:
n8n: n8n:
<<: [*restart_policy, *default_logging] <<: [*restart_policy, *default_logging]
image: n8nio/n8n:1.66.0 build:
context: ./n8n-custom
dockerfile: Dockerfile
container_name: n8n container_name: n8n
depends_on: depends_on:
n8n-db: n8n-db:
@@ -163,6 +166,8 @@ services:
EXECUTIONS_DATA_PRUNE: 'true' EXECUTIONS_DATA_PRUNE: 'true'
EXECUTIONS_DATA_MAX_AGE: 168 EXECUTIONS_DATA_MAX_AGE: 168
# EXECUTIONS_DATA_PRUNE_TIMEOUT: 60 # EXECUTIONS_DATA_PRUNE_TIMEOUT: 60
# Storage Migration (fix deprecation warning)
N8N_MIGRATE_FS_STORAGE_PATH: 'true'
ports: ports:
- '5678:5678' - '5678:5678'
@@ -1,4 +1,4 @@
FROM n8nio/n8n:latest-debian FROM n8nio/n8n:2.16.1
USER root USER root
@@ -6,6 +6,6 @@ USER root
RUN echo "deb http://archive.debian.org/debian buster main" > /etc/apt/sources.list && \ RUN echo "deb http://archive.debian.org/debian buster main" > /etc/apt/sources.list && \
echo "deb http://archive.debian.org/debian-security buster/updates main" >> /etc/apt/sources.list && \ echo "deb http://archive.debian.org/debian-security buster/updates main" >> /etc/apt/sources.list && \
apt-get update -y && \ apt-get update -y && \
apt-get install -y poppler-utils apt-get install -y poppler-utils python3 python3-pip
USER node USER node
@@ -1,4 +1,12 @@
# Per-stack .env.example — services (cache, search) # Per-stack .env.example — services (cache, search)
# Source: ../../.env.template # Source: ../../.env.template
REDIS_PASSWORD= # --- Redis (Cache & Queue) ---
ELASTICSEARCH_PASSWORD= REDIS_HOST=cache
REDIS_PORT=6379
REDIS_PASSWORD=redis3ac466bf9b6
# --- Search (Elasticsearch) ---
ELASTICSEARCH_HOST=search
ELASTICSEARCH_PORT=9200
ELASTICSEARCH_USERNAME=elastic
ELASTICSEARCH_PASSWORD=elasticed0bbde94
@@ -23,6 +23,7 @@ networks:
lcbp3: lcbp3:
external: true external: true
name: lcbp3-services
services: services:
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# 1. Redis (Caching + Distributed Lock + BullMQ queues) # 1. Redis (Caching + Distributed Lock + BullMQ queues)
@@ -30,13 +31,13 @@ services:
# ---------------------------------------------------------------- # ----------------------------------------------------------------
cache: cache:
<<: [*restart_policy, *default_logging] <<: [*restart_policy, *default_logging]
image: redis:7-alpine image: redis:7-alpine # ใช้ Alpine image เพื่อให้มีขน
container_name: cache container_name: cache
deploy: deploy:
resources: resources:
limits: limits:
cpus: '1.0' cpus: '1.0'
memory: 2G memory: 2G # Redis เป็น in-memory, ให้ memory เพียงพอต่อการ
reservations: reservations:
cpus: '0.25' cpus: '0.25'
memory: 512M memory: 512M
@@ -80,12 +81,12 @@ services:
# ---------------------------------------------------------------- # ----------------------------------------------------------------
search: search:
<<: [*restart_policy, *default_logging] <<: [*restart_policy, *default_logging]
image: elasticsearch:8.11.1 image: elasticsearch:8.11.1 # แนะนำให้ระบุเวอร์ชันชัดเจน
container_name: search container_name: search
deploy: deploy:
resources: resources:
limits: limits:
cpus: '2.0' cpus: '2.0' # Elasticsearch ใช้ CPU และ Memory ค่อนข้างห
memory: 4G memory: 4G
reservations: reservations:
cpus: '0.5' cpus: '0.5'
@@ -100,7 +101,7 @@ services:
# NOTE: หากเปิด xpack.security ต้องตั้ง ELASTIC_PASSWORD และอัปเดต backend client config # NOTE: หากเปิด xpack.security ต้องตั้ง ELASTIC_PASSWORD และอัปเดต backend client config
# ค่าเริ่มต้น keep ปิดไว้เพราะ network เข้าถึงได้เฉพาะภายใน lcbp3 (ไม่มี host port) # ค่าเริ่มต้น keep ปิดไว้เพราะ network เข้าถึงได้เฉพาะภายใน lcbp3 (ไม่มี host port)
xpack.security.enabled: 'false' xpack.security.enabled: 'false'
# --- Performance --- # --- Performance กำหนด Heap size (1GB) ให้เหมาะสมกับ memory limit (4G ---
ES_JAVA_OPTS: '-Xms1g -Xmx1g' ES_JAVA_OPTS: '-Xms1g -Xmx1g'
ulimits: ulimits:
memlock: memlock:
@@ -144,13 +144,25 @@ cd frontend && pnpm test --run hooks/use-workflow-action
- [x] T029 [US3] Wire `useWorkflowAction` into `IntegratedBanner` action buttons in `frontend/components/workflow/integrated-banner.tsx``onAction` callback receives `(action, comment, attachmentPublicIds[])` and delegates to hook's `execute()` method; show loading spinner during `isPending` - [x] T029 [US3] Wire `useWorkflowAction` into `IntegratedBanner` action buttons in `frontend/components/workflow/integrated-banner.tsx``onAction` callback receives `(action, comment, attachmentPublicIds[])` and delegates to hook's `execute()` method; show loading spinner during `isPending`
- [x] T030 [US3] Add `WorkflowTransitionGuard` unit tests in `backend/src/modules/workflow-engine/guards/workflow-transition.guard.spec.ts` — test all RBAC levels: (1) Superadmin pass, (2) Org Admin same-org pass, (3) Level 2.5 contract membership — user org in same contract pass / cross-contract org → ForbiddenException, (4) Assigned Handler pass, (5) unauthorized user → ForbiddenException - [x] T030 [US3] Add `WorkflowTransitionGuard` unit tests in `backend/src/modules/workflow-engine/guards/workflow-transition.guard.spec.ts` — test all RBAC levels: (1) Superadmin pass, (2) Org Admin same-org pass, (3) Level 2.5 contract membership — user org in same contract pass / cross-contract org → ForbiddenException, (4) Assigned Handler pass, (5) unauthorized user → ForbiddenException
- [x] T031 [US3] Add extended `processTransition()` unit tests in `backend/src/modules/workflow-engine/workflow-engine.service.spec.ts` — test: attachments linked to correct historyId, non-committed attachment rejected, idempotent replay returns cached result - [x] T031 [US3] Add extended `processTransition()` unit tests in `backend/src/modules/workflow-engine/workflow-engine.service.spec.ts` — test: attachments linked to correct historyId, non-committed attachment rejected, idempotent replay returns cached result
- [x] T031a [US3] Add new unit tests in `workflow-engine.service.spec.ts`6 test cases — **DONE 2026-04-19** (15/15 tests passing): - [x] T031a [US3] Add new unit tests in `workflow-engine.service.spec.ts`7 test cases — **DONE 2026-04-19** (16/16 tests passing):
- C3: upload in `APPROVED` state → `ConflictException` 409 - C3: upload in `APPROVED` state → `ConflictException` 409
- C3: upload in `REJECTED` state → `ConflictException` 409 - C3: upload in `REJECTED` state → `ConflictException` 409
- C3: skip state check when no attachments (backward compat) - C3: skip state check when no attachments (backward compat)
- C1: Redlock acquire fail → `ServiceUnavailableException` 503 (**ไม่ใช่ 409**) - C1: Redlock acquire fail → `ServiceUnavailableException` 503 (**ไม่ใช่ 409**)
- C2: `affected < expected``WorkflowException` + rollback + Redlock release - C2: `affected < expected``WorkflowException` + rollback + Redlock release
- **H1: TOCTOU state change between pre-check and pessimistic lock → `ConflictException` 409 + rollback** (code review 2026-04-19)
- C1: Redlock release สำเร็จแม้ transition ไม่โยนค่า - C1: Redlock release สำเร็จแม้ transition ไม่โยนค่า
- [x] T031b [US3] **Code Review fixes 2026-04-19** — 9 issues resolved (H1 + M1-M3 + L1-L2 + S1-S3):
- **H1** Backend: State check ซ้ำภายใน pessimistic lock (`workflow-engine.service.ts:419-429`) — ปิด TOCTOU race
- **M1** Frontend: 403 handler ใช้ backend message แทน hardcoded string (`use-workflow-action.ts:80-86`)
- **M2** Backend: Migrate `@nestjs/common` ConflictException + ServiceUnavailableException → custom `common/exceptions` (ADR-007 layered payload) — เพิ่ม `ErrorType.SERVICE_UNAVAILABLE` (503) + `ServiceUnavailableException` ใน `base.exception.ts`
- **M3** Frontend: Reset idempotency key on 409 (`use-workflow-action.ts:71-78`) — preserve บน 503 เท่านั้น
- **L1** k6 script: แทน remote `jslib.k6.io` import ด้วย `k6/crypto` built-in UUID v4 generator — ไม่ต้องมีอินเทอร์เน็ตตอนรัน
- **L2** Backend: ลบ redundant `updatedContext` alias + `eventsToDispatch` outer declaration — ใช้ `context`/`evaluation.events` โดยตรง; ลบ unused `RawEvent` import
- **S1** Backend: Prometheus metrics สำหรับ Redlock observability — `workflow_redlock_acquire_duration_ms` (Histogram labeled by outcome) + `workflow_redlock_acquire_failures_total` (Counter); register ใน module, inject via `@InjectMetric`
- **S2** Backend: เพิ่ม comment ใน pre-check เพื่อชี้แจงว่า `WorkflowInstance.id` = CHAR(36) UUID direct PK (ไม่ใช่ UuidBaseEntity pattern)
- **S3** Frontend: Harden `isApiErrorResponse` type guard ป้องกัน `{ error: null }` edge case
- Tests: Backend 16/16 + Frontend 8/8 passing
**Checkpoint**: ✅ **VERIFIED 2026-04-19** — POST transition with `attachmentPublicIds` สำเร็จ; `attachment.workflow_history_id` ถูก set; duplicate `Idempotency-Key` → cached response; unauthorized user → 403; Upload in Terminal state → 409 (C3); Redis failure → 503 fail-closed (C1); temp/foreign attachment → rollback (C2). `workflow-engine.service.spec.ts`: 15/15 tests passing. **Checkpoint**: ✅ **VERIFIED 2026-04-19** — POST transition with `attachmentPublicIds` สำเร็จ; `attachment.workflow_history_id` ถูก set; duplicate `Idempotency-Key` → cached response; unauthorized user → 403; Upload in Terminal state → 409 (C3); Redis failure → 503 fail-closed (C1); temp/foreign attachment → rollback (C2). `workflow-engine.service.spec.ts`: 15/15 tests passing.