From 25c50792e7b44cc7d423c3fdab52592794f49205 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 16 Mar 2026 12:55:36 +0700 Subject: [PATCH] 260316:1255 Refactor to NestJS 11 --- backend/package.json | 1 - backend/src/common/auth/auth.controller.ts | 11 +- backend/src/common/auth/session.controller.ts | 8 +- .../auth/strategies/jwt-refresh.strategy.ts | 5 +- .../file-storage/file-storage.controller.ts | 13 +- .../interfaces/request-with-user.interface.ts | 30 + backend/src/main.ts | 2 +- .../correspondence.controller.ts | 36 +- .../workflow-engine.controller.ts | 5 +- n8n-workflow-lcbp3.json | 986 ++++++++++++++++++ pnpm-lock.yaml | 19 +- .../05-01-fullstack-js-guidelines.md | 24 + .../ADR-005-technology-stack.md | 3 +- 13 files changed, 1078 insertions(+), 65 deletions(-) create mode 100644 backend/src/common/interfaces/request-with-user.interface.ts create mode 100644 n8n-workflow-lcbp3.json diff --git a/backend/package.json b/backend/package.json index 33d7f4f..6424194 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,7 +34,6 @@ "@nestjs/core": "^11.0.1", "@nestjs/elasticsearch": "^11.1.0", "@nestjs/jwt": "^11.0.1", - "@nestjs/mapped-types": "^2.1.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-socket.io": "^11.1.9", diff --git a/backend/src/common/auth/auth.controller.ts b/backend/src/common/auth/auth.controller.ts index 11ce045..5100445 100644 --- a/backend/src/common/auth/auth.controller.ts +++ b/backend/src/common/auth/auth.controller.ts @@ -27,12 +27,7 @@ import { ApiResponse, ApiBody, } from '@nestjs/swagger'; -import { Request } from 'express'; - -// สร้าง Interface สำหรับ Request ที่มี User -interface RequestWithUser extends Request { - user: any; -} +import type { RequestWithUser, RequestWithRefreshUser } from '../interfaces/request-with-user.interface'; @ApiTags('Authentication') @Controller('auth') @@ -95,7 +90,7 @@ export class AuthController { }, }, }) - async refresh(@Req() req: RequestWithUser) { + async refresh(@Req() req: RequestWithRefreshUser) { return this.authService.refreshToken(req.user.sub, req.user.refreshToken); } @@ -121,7 +116,7 @@ export class AuthController { } // ส่ง refresh token ไปด้วยถ้ามี (ใน header หรือ body) // สำหรับตอนนี้ส่งแค่ access token ไป blacklist - return this.authService.logout(req.user.sub, token); + return this.authService.logout(req.user.user_id, token); } @UseGuards(JwtAuthGuard) diff --git a/backend/src/common/auth/session.controller.ts b/backend/src/common/auth/session.controller.ts index 2f01ff1..f4bdee4 100644 --- a/backend/src/common/auth/session.controller.ts +++ b/backend/src/common/auth/session.controller.ts @@ -17,6 +17,7 @@ import { import { AuthService } from './auth.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { User } from '../../modules/user/entities/user.entity'; +import type { RequestWithUser } from '../interfaces/request-with-user.interface'; @ApiTags('Authentication') @Controller('auth/sessions') @@ -28,7 +29,7 @@ export class SessionController { @Get() @ApiOperation({ summary: 'List all active sessions (Admin/DC Only)' }) @ApiResponse({ status: 200, description: 'List of active sessions' }) - async getActiveSessions(@Req() req: any) { + async getActiveSessions(@Req() req: RequestWithUser) { this.checkAdminRole(req.user); return this.authService.getActiveSessions(); } @@ -36,7 +37,10 @@ export class SessionController { @Delete(':id') @ApiOperation({ summary: 'Revoke a session by ID (Admin/DC Only)' }) @ApiResponse({ status: 200, description: 'Session revoked' }) - async revokeSession(@Param('id', ParseIntPipe) id: number, @Req() req: any) { + async revokeSession( + @Param('id', ParseIntPipe) id: number, + @Req() req: RequestWithUser + ) { this.checkAdminRole(req.user); await this.authService.revokeSession(id); return { message: 'Session revoked successfully' }; diff --git a/backend/src/common/auth/strategies/jwt-refresh.strategy.ts b/backend/src/common/auth/strategies/jwt-refresh.strategy.ts index f906a3a..169a65a 100644 --- a/backend/src/common/auth/strategies/jwt-refresh.strategy.ts +++ b/backend/src/common/auth/strategies/jwt-refresh.strategy.ts @@ -7,11 +7,12 @@ import { PassportStrategy } from '@nestjs/passport'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Request } from 'express'; +import type { JwtPayload } from './jwt.strategy'; @Injectable() export class JwtRefreshStrategy extends PassportStrategy( Strategy, - 'jwt-refresh', + 'jwt-refresh' ) { constructor(configService: ConfigService) { super({ @@ -23,7 +24,7 @@ export class JwtRefreshStrategy extends PassportStrategy( }); } - async validate(req: Request, payload: any) { + async validate(req: Request, payload: JwtPayload) { const refreshToken = ExtractJwt.fromAuthHeaderAsBearerToken()(req); return { ...payload, diff --git a/backend/src/common/file-storage/file-storage.controller.ts b/backend/src/common/file-storage/file-storage.controller.ts index 3d30716..61e2b27 100644 --- a/backend/src/common/file-storage/file-storage.controller.ts +++ b/backend/src/common/file-storage/file-storage.controller.ts @@ -20,14 +20,7 @@ import type { Response } from 'express'; import { FileInterceptor } from '@nestjs/platform-express'; import { FileStorageService } from './file-storage.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; - -// Interface เพื่อระบุ Type ของ Request ที่ผ่าน JwtAuthGuard มาแล้ว -interface RequestWithUser { - user: { - userId: number; - username: string; - }; -} +import type { RequestWithUser } from '../interfaces/request-with-user.interface'; @Controller('files') @UseGuards(JwtAuthGuard) @@ -53,7 +46,7 @@ export class FileStorageController { @Request() req: RequestWithUser ) { // ส่ง userId จาก Token ไปด้วย - return this.fileStorageService.upload(file, req.user.userId); + return this.fileStorageService.upload(file, req.user.user_id); } /** @@ -90,7 +83,7 @@ export class FileStorageController { @Request() req: RequestWithUser ) { // ส่ง userId ไปด้วยเพื่อตรวจสอบความเป็นเจ้าของ - await this.fileStorageService.delete(id, req.user.userId); + await this.fileStorageService.delete(id, req.user.user_id); return { message: 'File deleted successfully', id }; } } diff --git a/backend/src/common/interfaces/request-with-user.interface.ts b/backend/src/common/interfaces/request-with-user.interface.ts new file mode 100644 index 0000000..1386283 --- /dev/null +++ b/backend/src/common/interfaces/request-with-user.interface.ts @@ -0,0 +1,30 @@ +// File: src/common/interfaces/request-with-user.interface.ts +// NestJS 11: Shared typed Request interfaces (replaces scattered `req: any` patterns) + +import { Request } from 'express'; +import { User } from '../../modules/user/entities/user.entity'; + +/** + * Request object after JwtAuthGuard has validated the token. + * Passport attaches the User entity returned by JwtStrategy.validate() to `req.user`. + */ +export interface RequestWithUser extends Request { + user: User; +} + +/** + * Payload shape returned by JwtRefreshStrategy.validate(). + * Contains JWT claims + the raw refresh token for rotation. + */ +export interface JwtRefreshPayload { + sub: number; + username: string; + refreshToken: string; +} + +/** + * Request object after JwtRefreshGuard has validated the refresh token. + */ +export interface RequestWithRefreshUser extends Request { + user: JwtRefreshPayload; +} diff --git a/backend/src/main.ts b/backend/src/main.ts index 41f20b3..8dbb5d0 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -73,7 +73,7 @@ async function bootstrap() { const swaggerConfig = new DocumentBuilder() .setTitle('LCBP3 DMS API') .setDescription('Document Management System API Documentation') - .setVersion('1.4.3') + .setVersion('1.8.1') .addBearerAuth() // เพิ่มปุ่มใส่ Token (รูปกุญแจ) .build(); diff --git a/backend/src/modules/correspondence/correspondence.controller.ts b/backend/src/modules/correspondence/correspondence.controller.ts index ac085a6..a7e3214 100644 --- a/backend/src/modules/correspondence/correspondence.controller.ts +++ b/backend/src/modules/correspondence/correspondence.controller.ts @@ -31,6 +31,7 @@ import { RbacGuard } from '../../common/guards/rbac.guard'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { Audit } from '../../common/decorators/audit.decorator'; import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; +import type { RequestWithUser } from '../../common/interfaces/request-with-user.interface'; @ApiTags('Correspondences') @Controller('correspondences') @@ -48,13 +49,7 @@ export class CorrespondenceController { @RequirePermission('workflow.action_review') processAction( @Body() actionDto: WorkflowActionDto, - @Request() - req: Request & { - user: { - user_id: number; - assignments?: Array<{ role: { roleName: string } }>; - }; - } + @Request() req: RequestWithUser ) { // Extract roles from user assignments for DSL requirements check const userRoles = @@ -87,12 +82,9 @@ export class CorrespondenceController { @Audit('correspondence.create', 'correspondence') create( @Body() createDto: CreateCorrespondenceDto, - @Request() req: Request & { user: unknown } + @Request() req: RequestWithUser ) { - return this.correspondenceService.create( - createDto, - req.user as Parameters[1] - ); + return this.correspondenceService.create(createDto, req.user); } @Post('preview-number') @@ -104,11 +96,11 @@ export class CorrespondenceController { @RequirePermission('correspondence.create') previewNumber( @Body() createDto: CreateCorrespondenceDto, - @Request() req: Request & { user: unknown } + @Request() req: RequestWithUser ) { return this.correspondenceService.previewDocumentNumber( createDto, - req.user as Parameters[1] + req.user ); } @@ -131,13 +123,7 @@ export class CorrespondenceController { async submit( @Param('uuid', ParseUuidPipe) uuid: string, @Body() submitDto: SubmitCorrespondenceDto, - @Request() - req: Request & { - user: { - user_id: number; - assignments?: Array<{ role: { roleName: string } }>; - }; - } + @Request() req: RequestWithUser ) { const corr = await this.correspondenceService.findOneByUuid(uuid); // Extract roles from user assignments @@ -172,14 +158,10 @@ export class CorrespondenceController { async update( @Param('uuid', ParseUuidPipe) uuid: string, @Body() updateDto: UpdateCorrespondenceDto, - @Request() req: Request & { user: unknown } + @Request() req: RequestWithUser ) { const corr = await this.correspondenceService.findOneByUuid(uuid); - return this.correspondenceService.update( - corr.id, - updateDto, - req.user as Parameters[1] - ); + return this.correspondenceService.update(corr.id, updateDto, req.user); } @Get(':uuid/references') diff --git a/backend/src/modules/workflow-engine/workflow-engine.controller.ts b/backend/src/modules/workflow-engine/workflow-engine.controller.ts index f627b01..9175379 100644 --- a/backend/src/modules/workflow-engine/workflow-engine.controller.ts +++ b/backend/src/modules/workflow-engine/workflow-engine.controller.ts @@ -31,6 +31,7 @@ import { WorkflowTransitionDto } from './dto/workflow-transition.dto'; import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { RbacGuard } from '../../common/guards/rbac.guard'; +import type { RequestWithUser } from '../../common/interfaces/request-with-user.interface'; @ApiTags('Workflow Engine') @ApiBearerAuth() // ระบุว่าต้องใช้ Token ใน Swagger @@ -95,10 +96,10 @@ export class WorkflowEngineController { async processTransition( @Param('id') instanceId: string, @Body() dto: WorkflowTransitionDto, - @Request() req: any + @Request() req: RequestWithUser ) { // ดึง User ID จาก Token (req.user มาจาก JwtStrategy) - const userId = req.user?.userId; + const userId = req.user?.user_id; return this.workflowService.processTransition( instanceId, diff --git a/n8n-workflow-lcbp3.json b/n8n-workflow-lcbp3.json new file mode 100644 index 0000000..dd2d9c3 --- /dev/null +++ b/n8n-workflow-lcbp3.json @@ -0,0 +1,986 @@ +{ + "name": "LCBP3 Migration Workflow v1.8.1", + "nodes": [ + { + "parameters": { + "formTitle": "LCBP3 Migration - เลือก Model", + "formDescription": "กรุณาเลือก Ollama Model และตั้งค่าก่อนรัน", + "formFields": { + "values": [ + { + "fieldLabel": "Ollama Model (Primary)", + "fieldType": "dropdown", + "fieldOptions": { + "values": [ + { + "option": "scb10x/typhoon2.1-gemma3-4b (เร็ว + ไทยดี)" + }, + { + "option": "qwen2.5:7b-instruct-q4_K_M (สมดุล - แนะนำ)" + }, + { + "option": "promptnow/openthaigpt1.5-7b-instruct-q4_k_m (ไทยเฉพาะทาง)" + } + ] + }, + "requiredField": true + }, + { + "fieldLabel": "Batch Size", + "fieldType": "number", + "placeholder": "2" + }, + { + "fieldLabel": "Excel File Path", + "placeholder": "/home/node/.n8n-files/staging_ai/C22024.xlsx" + } + ] + }, + "options": {} + }, + "id": "4609ab68-f7e4-4800-ad39-19ce32de60d0", + "name": "Form Trigger", + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2.2, + "position": [31024, 13504], + "webhookId": "e164a362-0c6b-4243-a5ad-b325aa943f4f", + "notes": "เปิด URL เพื่อเลือก Model ก่อนรัน" + }, + { + "parameters": { + "jsCode": "// Read model selected from Form Trigger dropdown\nconst formData = $('Form Trigger').first()?.json || {};\nconst selectedModelLabel = String(formData['Ollama Model (Primary)'] || '');\n\n// Extract just the model ID (before the space in the label)\nconst MODEL_MAP = {\n 'qwen2.5:7b-instruct-q4_K_M (สมดุล - แนะนำ)': 'qwen2.5:7b-instruct-q4_K_M',\n 'scb10x/typhoon2.1-gemma3-4b (เร็ว + ไทยดี)': 'scb10x/typhoon2.1-gemma3-4b',\n 'promptnow/openthaigpt1.5-7b-instruct-q4_k_m (ไทยเฉพาะทาง)': 'promptnow/openthaigpt1.5-7b-instruct-q4_k_m'\n};\nconst selectedModel = MODEL_MAP[selectedModelLabel] || 'scb10x/typhoon2.1-gemma3-4b';\n\nconst batchSizeInput = parseInt(formData['Batch Size'] || '0');\nconst excelFileInput = String(formData['Excel File Path'] || '').trim();\n\nconst CONFIG = {\n // Ollama Settings\n OLLAMA_HOST: 'http://192.168.20.100:11434',\n // Model selected from Form UI\n OLLAMA_MODEL_PRIMARY: selectedModel,\n // Fallback\n OLLAMA_MODEL_FALLBACK: 'mistral:7b-instruct-q4_K_M',\n \n // Backend Settings\n BACKEND_URL: 'https://backend.np-dms.work',\n MIGRATION_TOKEN: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1pZ3JhdGlvbl9ib3QiLCJzdWIiOjUsInNjb3BlIjoiR2xvYmFsIiwiaWF0IjoxNzcyNzc0MzI5LCJleHAiOjQ5Mjg1MzQzMjl9.TtA8zoHy7G9J5jPgYQPv7yw-9X--B_hl-Nv-c9V4PaA',\n \n // Batch Settings\n BATCH_SIZE: batchSizeInput > 0 ? batchSizeInput : 2,\n BATCH_ID: (() => { const d = new Date(Date.now() + 7 * 3600000); const s = d.toISOString(); return s.substring(0,10).replace(/-/g,'') + ':' + s.substring(11,16).replace(/:/g,''); })(),\n DELAY_MS: 2000,\n \n // Thresholds\n CONFIDENCE_HIGH: 0.85,\n CONFIDENCE_LOW: 0.60,\n MAX_RETRY: 3,\n FALLBACK_THRESHOLD: 5,\n \n // Source Definitions - แก้ไขโฟลเดอร์และไฟล์ทำงานที่นี่\n EXCEL_FILE: excelFileInput || '/home/node/.n8n-files/staging_ai/C22024.xlsx',\n SOURCE_PDF_DIR: '/home/node/.n8n-files/staging_ai/Incoming/08C.2/2567',\n LOG_PATH: '/home/node/.n8n-files/migration_logs',\n PROJECT_ID: 1\n};\n\nreturn { config: CONFIG };" + }, + "id": "8f1d3378-cca6-48b6-99db-693e46ac81ef", + "name": "Set Configuration", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [31216, 13504], + "notes": "กำหนดค่า Configuration ทั้งหมด - แก้ไขที่นี่ก่อนรัน" + }, + { + "parameters": { + "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/master/correspondence-types", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" + } + ] + }, + "options": { + "timeout": 10000 + } + }, + "id": "6c6679b4-85f3-4c2c-ac8e-4281d6ae61f6", + "name": "Fetch Categories", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [31216, 13696], + "notes": "ดึง Categories จาก Backend" + }, + { + "parameters": { + "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/master/tags", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" + } + ] + }, + "options": { + "timeout": 10000 + } + }, + "id": "98b9159a-f21d-4b33-9524-058a78ccfc93", + "name": "Fetch Tags", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [31392, 13696], + "notes": "ดึง Tags ที่มีอยู่แล้วจาก Backend" + }, + { + "parameters": { + "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/health", + "options": { + "timeout": 5000 + } + }, + "id": "60e81de6-e9b2-4bff-afcc-bef9d5b959b5", + "name": "Check Backend Health", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [31392, 13504], + "onError": "continueErrorOutput", + "notes": "ตรวจสอบ Backend พร้อมใช้งาน" + }, + { + "parameters": { + "jsCode": "const fs = require('fs');\nconst config = $('Set Configuration').first().json.config;\n\n// Check file mount and inputs\ntry {\n if (!fs.existsSync(config.EXCEL_FILE)) {\n throw new Error(`Excel file not found at: ${config.EXCEL_FILE}`);\n }\n if (!fs.existsSync(config.SOURCE_PDF_DIR)) {\n throw new Error(`PDF Source directory not found at: ${config.SOURCE_PDF_DIR}`);\n }\n \n const files = fs.readdirSync(config.SOURCE_PDF_DIR);\n \n // Check write permission to log path\n if (!fs.existsSync(config.LOG_PATH)) {\n fs.mkdirSync(config.LOG_PATH, { recursive: true });\n }\n fs.writeFileSync(`${config.LOG_PATH}/.preflight_ok`, new Date().toISOString());\n \n // Grab categories out of the previous node (Fetch Categories) if available\n let categories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\n try {\n const upstreamData = $('Fetch Categories').first()?.json?.data;\n if (upstreamData && Array.isArray(upstreamData)) {\n categories = upstreamData.map(c => c.name || c.type || c); \n }\n } catch(e) {}\n \n // Grab existing tags from Fetch Tags node\n let existingTags = [];\n try {\n const tagData = $('Fetch Tags').first()?.json?.data || [];\n existingTags = Array.isArray(tagData) ? tagData.map(t => t.tag_name || t.name || '').filter(Boolean) : [];\n } catch(e) {}\n \n return [{ json: { \n preflight_ok: true, \n pdf_count_in_source: files.length,\n excel_target: config.EXCEL_FILE,\n system_categories: categories,\n existing_tags: existingTags,\n timestamp: new Date().toISOString()\n }}];\n} catch (err) {\n throw new Error(`Pre-flight check failed: ${err.message}`);\n}" + }, + "id": "910b13e2-994a-4fb6-bca1-637e1628c586", + "name": "File Mount Check", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [31216, 13904], + "notes": "ตรวจสอบ File System มีไฟล์ Excel และ Folder ตามตั้งค่า" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT last_processed_index, status FROM migration_progress WHERE batch_id = '{{$('Set Configuration').first().json.config.BATCH_ID}}' LIMIT 1", + "options": {} + }, + "id": "a83f8598-72fd-4cc8-9d98-1ea3cb3b42df", + "name": "Read Checkpoint", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [31632, 13744], + "alwaysOutputData": true, + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "onError": "continueErrorOutput", + "notes": "อ่านตำแหน่งล่าสุดที่ประมวลผล" + }, + { + "parameters": { + "fileSelector": "={{ $json.excel_target }}", + "options": {} + }, + "id": "063bcef1-791a-4923-a659-8b9a0ba3e336", + "name": "Read Excel Binary", + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1, + "position": [31392, 13904], + "notes": "ดึงไฟล์ Excel ขึ้นมาไว้ในหน่วยความจำ" + }, + { + "parameters": { + "options": {} + }, + "id": "e07efdde-b9b1-402a-ba01-44175982749b", + "name": "Read Excel", + "type": "n8n-nodes-base.spreadsheetFile", + "typeVersion": 2, + "position": [31392, 14112], + "notes": "แปลงข้อมูล Excel เป็น JSON Data" + }, + { + "parameters": { + "jsCode": "const cpJson = $input.first()?.json || {};\nconst startIndex = cpJson.last_processed_index || 0;\nconst config = $('Set Configuration').first().json.config;\n\nconst allItems = $('Read Excel').all().map(i => i.json);\nconst remaining = allItems.slice(startIndex);\nconst currentBatch = remaining.slice(0, config.BATCH_SIZE);\n\n// Encoding Normalization\nconst normalize = (str) => {\n if (!str) return '';\n return String(str).normalize('NFC').trim();\n};\n\nreturn currentBatch.map((item, i) => {\n const getVal = (possibleKeys) => {\n const exactMatch = possibleKeys.find(k => item[k] !== undefined);\n if (exactMatch) return item[exactMatch];\n const lowerTrimmedKeys = Object.keys(item).map(k => ({ original: k, parsed: k.toLowerCase().trim() }));\n for (const pk of possibleKeys) {\n const found = lowerTrimmedKeys.find(k => k.parsed === pk.toLowerCase().trim());\n if (found) return item[found.original];\n }\n return '';\n };\n\n const docNum = getVal(['document_number', 'correspondence_number', 'Document Number', 'Corr. No.']);\n const excelFileName = getVal(['File name', 'file_name', 'File Name', 'filename']);\n \n if (!excelFileName) {\n throw new Error(`Missing 'File name' column for row ${i + startIndex + 1}, document: ${docNum}`);\n }\n \n return {\n json: {\n document_number: normalize(docNum),\n title: normalize(getVal(['title', 'Title', 'Subject', 'subject'])),\n legacy_number: normalize(getVal(['legacy_number', 'Legacy Number', 'Response Doc.'])),\n excel_revision: getVal(['revision', 'Revision', 'rev']) || 1,\n original_index: startIndex + i,\n batch_id: config.BATCH_ID,\n file_name: normalize(excelFileName),\n issued_date: normalize(getVal(['issued_date', 'Issued_date', 'Issued Date', 'date', 'Date', 'document_date'])),\n received_date: normalize(getVal(['received_date', 'Received_date', 'Received Date'])),\n correspondence_type: getVal(['correspondence_type', 'type', 'Type', 'Category']),\n sender: normalize(getVal(['sender', 'Sender', 'From', 'from'])),\n receiver: normalize(getVal(['receiver', 'Receiver', 'To', 'to'])),\n project_code: normalize(getVal(['project', 'Project', 'project_code']))\n }\n };\n});" + }, + "id": "80845e32-c283-4e9f-af73-6339d675fb38", + "name": "Process Batch + Encoding", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [31808, 13488], + "alwaysOutputData": true, + "notes": "ตัด Batch + Normalize UTF-8" + }, + { + "parameters": { + "jsCode": "const fs = require('fs');\nconst path = require('path');\nconst config = $('Set Configuration').first().json.config;\n\nconst items = $input.all();\nif (!items || items.length === 0) return [];\n\nconst validated = [];\nconst errors = [];\n\nfor (const item of items) {\n const fileName = item.json?.file_name;\n if (!fileName) {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: 'file_name is missing', error_type: 'MISSING_FILENAME', file_exists: false }\n });\n continue;\n }\n \n let safeName = path.basename(String(fileName)).normalize('NFC');\n if (!safeName.toLowerCase().endsWith('.pdf')) {\n safeName += '.pdf';\n }\n const filePath = path.resolve(config.SOURCE_PDF_DIR, safeName);\n \n if (!filePath.startsWith(path.resolve(config.SOURCE_PDF_DIR))) {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: 'Path traversal detected', error_type: 'SECURITY', file_exists: false }\n });\n continue;\n }\n \n try {\n if (fs.existsSync(filePath)) {\n const stats = fs.statSync(filePath);\n validated.push({\n ...item,\n json: { ...item.json, file_valid: true, file_exists: true, file_size: stats.size, file_path: filePath }\n });\n } else {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: `File not found: ${safeName}`, error_type: 'FILE_NOT_FOUND', file_exists: false }\n });\n }\n } catch (err) {\n errors.push({\n ...item,\n json: { ...item.json, file_valid: false, error: err.message, error_type: 'FILE_ERROR', file_exists: false }\n });\n }\n}\n\nreturn [...validated, ...errors];" + }, + "id": "2183d687-4708-4d77-a0a9-13ccf29baf69", + "name": "File Validator", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [31984, 13488], + "notes": "ตรวจสอบไฟล์ PDF ตัวชี้ใน Directory จาก Config" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT is_fallback_active, recent_error_count FROM migration_fallback_state WHERE batch_id = '{{$('Set Configuration').first().json.config.BATCH_ID}}' LIMIT 1", + "options": {} + }, + "id": "8b0c61d0-96e4-468a-991f-a40e534e167a", + "name": "Check Fallback State", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [31792, 13888], + "alwaysOutputData": true, + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "onError": "continueErrorOutput", + "notes": "ตรวจสอบว่าต้องใช้ Fallback Model หรือไม่" + }, + { + "parameters": { + "jsCode": "const config = $('Set Configuration').first().json.config;\nconst fallbackState = $('Check Fallback State').first()?.json || { is_fallback_active: false, recent_error_count: 0 };\nconst isFallback = fallbackState.is_fallback_active || false;\nconst model = isFallback ? config.OLLAMA_MODEL_FALLBACK : config.OLLAMA_MODEL_PRIMARY;\n\nconst dbContext = $('Fetch DB Context').all().map(i => i.json);\nconst dbProjects = dbContext.filter(d => d.type === 'projects').map(d => ({id: d.id, code: d.text1, name: d.text2}));\nconst dbDisciplines = dbContext.filter(d => d.type === 'disciplines').map(d => ({id: d.id, th: d.text1, en: d.text2}));\nconst dbOrgs = dbContext.filter(d => d.type === 'organizations').map(d => ({id: d.id, name: d.text1, code: d.text2}));\nconst dbTags = dbContext.filter(d => d.type === 'tags').map(d => ({id: d.id, name: d.text1}));\nconst dbCorrTypes = dbContext.filter(d => d.type === 'correspondence_types').map(d => ({id: d.id, code: d.text1, name: d.text2}));\n\nlet systemCategories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\ntry { systemCategories = $('File Mount Check').first().json.system_categories || systemCategories; } catch (e) {}\n\nconst pdfItems = $('Extract PDF Text').all();\nconst metaItems = $('File Validator').all();\n\nreturn pdfItems.map((pdfItem, i) => {\n const item = metaItems[i] || pdfItem;\n\n const docNum = String(item.json.document_number || '');\n const title = String(item.json.title || '');\n const legacyNum = String(item.json.legacy_number || '');\n const issuedDate = String(item.json.issued_date || '');\n const receivedDate = String(item.json.received_date || '');\n const corrType = String(item.json.correspondence_type || '');\n const senderCode = String(item.json.sender || '');\n const receiverCode = String(item.json.receiver || '');\n\n const prompt = `Validate and summarize this document. Respond in JSON.\nDocument Number: ${docNum}\nOriginal Title: ${title}\nExtracted Text: ${(pdfItem.json.response || pdfItem.json.data || '').substring(0, 4000)}\n\nExisting Projects: ${JSON.stringify(dbProjects)}\nExisting Disciplines: ${JSON.stringify(dbDisciplines)}\nExisting Orgs: ${JSON.stringify(dbOrgs)}\nExisting Categories: ${JSON.stringify(systemCategories)}\nExisting Tags: ${JSON.stringify(dbTags)}\n\nAnalyze the content to provide:\n1. Validation of Subject/Dates with PDF text.\n2. A 4-5 sentence summary.\n3. Suggest tags. Select from Existing Tags if applicable.\n\nRespond ONLY with this exact JSON structure:\n{\n \"is_valid\": true,\n \"confidence\": 0.9,\n \"category\": \"Correspondence\",\n \"subject\": \"...\",\n \"summary\": \"...\",\n \"discipline_id\": 64,\n \"tags\": [{\"tag_name\": \"...\", \"description\": \"...\"}],\n \"key_points\": [\"...\"],\n \"document_date\": \"YYYY-MM-DD\",\n \"issued_date\": \"YYYY-MM-DD\",\n \"received_date\": \"YYYY-MM-DD\"\n}`;\n\n return {\n json: {\n ...item.json,\n temp_attachment_id: pdfItem.json.temp_attachment_id,\n ollama_payload: {\n model: model,\n prompt: prompt,\n stream: false,\n format: \"json\",\n options: { temperature: 0.2, num_ctx: 8192 }\n },\n system_categories: systemCategories,\n pre_mapped: {\n project_id: dbProjects.find(p => docNum.includes(p.code))?.id || config.PROJECT_ID,\n sender_id: dbOrgs.find(o => senderCode.includes(o.code) || senderCode.includes(o.name))?.id,\n receiver_id: dbOrgs.find(o => receiverCode.includes(o.code) || receiverCode.includes(o.name))?.id\n }\n }\n };\n});" + }, + "id": "2ba75d42-1de3-4846-a1a3-39d580e7d764", + "name": "Build AI Prompt", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [32144, 13872], + "notes": "สร้าง Prompt โดยใช้ Categories จาก System" + }, + { + "parameters": { + "method": "POST", + "url": "={{$('Set Configuration').first().json.config.OLLAMA_HOST}}/api/generate", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.ollama_payload }}", + "options": { + "timeout": 120000 + } + }, + "id": "3e8b33cb-8f8f-4d2e-b4cb-9d68cc54d96e", + "name": "Ollama AI Analysis", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [31792, 14096], + "notes": "เรียก Ollama วิเคราะห์เอกสาร" + }, + { + "parameters": { + "jsCode": "const ollamaItems = $input.all();\nconst originalItems = $('Build AI Prompt').all();\nconst results = [];\n\nconst CATEGORY_TO_TYPE_CODE = {\n 'Correspondence': 'LETTER',\n 'RFA': 'RFA',\n 'Transmittal': 'TRANSMITTAL',\n 'Drawing': 'OTHER',\n 'Report': 'OTHER',\n 'Other': 'OTHER',\n};\n\nfor (let i = 0; i < ollamaItems.length; i++) {\n const ollamaItem = ollamaItems[i];\n const originalItem = originalItems[i];\n if (!originalItem) continue;\n const baseJson = originalItem.json;\n\n try {\n let raw = ollamaItem.json.response || '';\n raw = raw.replace(/`{3}json/gi, '').replace(/`{3}/g, '').trim();\n if (!raw) throw new Error('Empty response from AI');\n\n const result = JSON.parse(raw);\n const systemCategories = baseJson.system_categories || [];\n let finalCategory = result.category;\n if (!systemCategories.includes(finalCategory)) {\n finalCategory = String(baseJson.document_number || '').includes('-RFA-') ? 'RFA' : 'Correspondence';\n }\n\n const typeCode = CATEGORY_TO_TYPE_CODE[finalCategory] || 'LETTER';\n const preMapped = baseJson.pre_mapped || {};\n\n results.push({\n json: {\n ...baseJson,\n ai_result: {\n suggested_category: finalCategory,\n type_code: typeCode,\n confidence: result.confidence || 0.8,\n project_id: preMapped.project_id || null,\n discipline_id: result.discipline_id || 64,\n sender_id: preMapped.sender_id || null,\n receiver_id: preMapped.receiver_id || null,\n subject: result.subject || baseJson.title,\n issued_date: result.issued_date || baseJson.issued_date,\n received_date: result.received_date || baseJson.received_date,\n summary: result.summary || '',\n key_points: result.key_points || [],\n tags: result.tags || [],\n is_valid: result.is_valid !== false\n }\n }\n });\n } catch (err) {\n results.push({\n json: {\n ...baseJson,\n parse_error: err.message,\n raw_ai_response: ollamaItem.json.response\n }\n });\n }\n}\n\nreturn results;" + }, + "id": "6716162f-1129-4552-a05f-a08ac115fe10", + "name": "Parse & Validate AI Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [32000, 14096], + "notes": "Parse JSON + Validate Schema + Enum Check" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "INSERT INTO migration_fallback_state (batch_id, recent_error_count, is_fallback_active) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', 1, FALSE) ON DUPLICATE KEY UPDATE recent_error_count = recent_error_count + 1, is_fallback_active = CASE WHEN recent_error_count + 1 >= {{$('Set Configuration').first().json.config.FALLBACK_THRESHOLD}} THEN TRUE ELSE is_fallback_active END, updated_at = NOW()", + "options": {} + }, + "id": "b2ac9722-f917-42fd-81e9-c77e84b84104", + "name": "Update Fallback State", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [32464, 13472], + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "notes": "เพิ่ม Error count และตรวจสอบ Fallback threshold" + }, + { + "parameters": { + "jsCode": "const config = $('Set Configuration').first().json.config;\nconst items = $('Parse & Validate AI Response').all();\n\nconst results = [];\n\nfor (const item of items) {\n const data = item.json;\n let resultItem = { json: { ...data } };\n \n if (data.parse_error || !data.ai_result) {\n resultItem.json.route_index = 3;\n results.push(resultItem);\n continue;\n }\n \n const ai = data.ai_result;\n \n if (ai.confidence >= config.CONFIDENCE_HIGH) {\n resultItem.json.route_index = 0;\n resultItem.json.staging_status = 'PENDING';\n resultItem.json.staging_remarks = 'Ready for auto-ingest (High Confidence)';\n } else if (ai.confidence >= config.CONFIDENCE_LOW) {\n resultItem.json.route_index = 1;\n resultItem.json.staging_status = 'PENDING';\n resultItem.json.staging_remarks = 'Flagged for human review (Medium Confidence)';\n } else {\n resultItem.json.route_index = 2;\n resultItem.json.staging_status = 'REJECTED';\n resultItem.json.staging_remarks = ai.is_valid === false ? 'AI marked invalid' : `Rejected for human review (Low Confidence: ${ai.confidence.toFixed(2)})`;\n }\n results.push(resultItem);\n}\n\nreturn results;" + }, + "id": "ccaaee30-ead6-46c0-954a-eb8b98620cb3", + "name": "Confidence Router", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [32160, 14096], + "notes": "แยกตาม Confidence: Auto(≥0.85) / Review(≥0.60) / Reject(<0.60)" + }, + { + "parameters": { + "jsCode": "const fs = require('fs');\nconst item = $input.first();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/reject_log.csv`;\nconst header = 'timestamp,document_number,title,reject_reason,ai_confidence,key_points\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(config.LOG_PATH)) {\n fs.mkdirSync(config.LOG_PATH, { recursive: true });\n}\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nconst line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.title),\n esc(item.json.staging_remarks),\n item.json.ai_result?.confidence ?? 'N/A',\n esc(JSON.stringify(item.json.ai_result?.key_points || []))\n].join(',') + '\\n';\n\nfs.appendFileSync(csvPath, line, 'utf8');\n\nreturn [$input.first()];" + }, + "id": "0bb3530f-02d5-44d0-ad94-c94d97d91b6a", + "name": "Log Reject to CSV", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [32624, 14032], + "notes": "บันทึกรายการที่ถูกปฏิเสธลง CSV" + }, + { + "parameters": { + "jsCode": "const fs = require('fs');\nconst items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/error_log.csv`;\nconst header = 'timestamp,document_number,error_type,error_message,raw_ai_response\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(config.LOG_PATH)) {\n fs.mkdirSync(config.LOG_PATH, { recursive: true });\n}\n\nif (!fs.existsSync(csvPath)) {\n fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nfor (const item of items) {\n const line = [\n new Date().toISOString(),\n esc(item.json.document_number),\n esc(item.json.error_type || 'UNKNOWN'),\n esc(item.json.error || item.json.parse_error),\n esc(item.json.raw_ai_response || '')\n ].join(',') + '\\n';\n \n fs.appendFileSync(csvPath, line, 'utf8');\n}\n\nreturn items;" + }, + "id": "8250dd88-ca81-45aa-93d8-480c9bcd6b14", + "name": "Log Error to CSV", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [32448, 14128], + "notes": "บันทึก Error ลง CSV (จาก File Validator)" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "INSERT INTO migration_errors (batch_id, document_number, error_type, error_message, raw_ai_response, created_at) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', '{{$json.document_number}}', '{{$json.error_type || \"UNKNOWN\"}}', '{{$json.error || $json.parse_error}}', '{{$json.raw_ai_response || \"\"}}', NOW())", + "options": {} + }, + "id": "0f058ad0-3c09-4c9f-bdcf-503cd58ee395", + "name": "Log Error to DB", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [32752, 14128], + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "notes": "บันทึก Error ลง MariaDB" + }, + { + "parameters": { + "amount": "={{$('Set Configuration').first().json.config.DELAY_MS / 1000}}", + "unit": "seconds" + }, + "id": "07c1c5d5-5ffc-4e3d-ab3e-4b62ad079388", + "name": "Delay", + "type": "n8n-nodes-base.wait", + "typeVersion": 1, + "position": [33104, 14080], + "webhookId": "38e97a99-4dcc-4b63-977a-a02945a1c369", + "notes": "หน่วงเวลาระหว่าง Batches" + }, + { + "parameters": { + "rules": { + "values": [ + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "leftValue": "={{ $json.route_index }}", + "rightValue": 0, + "operator": { + "type": "number", + "operation": "equals", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Staging (High)" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "leftValue": "={{ $json.route_index }}", + "rightValue": 1, + "operator": { + "type": "number", + "operation": "equals", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Staging (Review)" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "leftValue": "={{ $json.route_index }}", + "rightValue": 2, + "operator": { + "type": "number", + "operation": "equals", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Reject" + }, + { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 2 + }, + "conditions": [ + { + "leftValue": "={{ $json.route_index }}", + "rightValue": 3, + "operator": { + "type": "number", + "operation": "equals", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "renameOutput": true, + "outputKey": "Error Log" + } + ] + }, + "options": {} + }, + "id": "65f0bb6c-496a-4409-8b88-3132866cf9a4", + "name": "Route by Confidence", + "type": "n8n-nodes-base.switch", + "typeVersion": 3.2, + "position": [32336, 13744] + }, + { + "parameters": { + "fileSelector": "={{ $json.file_path }}", + "options": {} + }, + "id": "4fd3133e-39e1-4860-95c7-3e87ee43ed51", + "name": "Read PDF File", + "type": "n8n-nodes-base.readWriteFile", + "typeVersion": 1, + "position": [31824, 13680], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "method": "POST", + "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/files/upload", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" + } + ] + }, + "sendBody": true, + "contentType": "multipart-form-data", + "bodyParameters": { + "parameters": [ + { + "name": "file", + "parameterType": "formBinaryData", + "inputDataFieldName": "data" + } + ] + }, + "options": { + "timeout": 60000 + } + }, + "id": "452d3a33-4f51-404c-8f4b-7a3d3a3a3a3a", + "name": "Upload to Backend", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [31968, 13680], + "notes": "Upload PDF to Backend Temp Storage" + }, + { + "parameters": { + "jsCode": "const uploadRes = $input.first()?.json || {};\nconst binaryData = $('Read PDF File').first().binary.data;\n\nreturn {\n json: {\n ...$input.first().json,\n temp_attachment_id: uploadRes.id\n },\n binary: {\n data: binaryData\n }\n};" + }, + "id": "b3e3e3e3-e3e3-4e3e-ae3e-3e3e3e3e3e3e", + "name": "Process Upload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [32112, 13680], + "notes": "Capture ID and Restore Binary for Tika" + }, + { + "parameters": { + "method": "PUT", + "url": "http://tika:9998/tika", + "sendQuery": true, + "queryParameters": { + "parameters": [ + { + "name": "maxPages", + "value": "2" + } + ] + }, + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Accept", + "value": "text/plain" + }, + { + "name": "X-Tika-OCRLanguage", + "value": "tha+eng" + }, + { + "name": "X-Tika-PDFOcrStrategy", + "value": "ocr_only" + } + ] + }, + "sendBody": true, + "contentType": "binaryData", + "inputDataFieldName": "data", + "options": { + "timeout": 600000 + } + }, + "id": "2d3868e0-ed56-4921-8d68-bf7b69a64546", + "name": "Extract PDF Text", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [32256, 13664], + "onError": "continueErrorOutput" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "SELECT 'projects' as type, id, project_code as text1, project_name as text2 FROM projects\nUNION ALL\nSELECT 'disciplines' as type, id, code_name_th as text1, code_name_en as text2 FROM disciplines\nUNION ALL\nSELECT 'organizations' as type, id, organization_name as text1, organization_code as text2 FROM organizations\nUNION ALL\nSELECT 'tags' as type, id, tag_name as text1, description as text2 FROM tags\nUNION ALL\nSELECT 'correspondence_types' as type, id, type_code as text1, type_name as text2 FROM correspondence_types", + "options": {} + }, + "id": "2e31dc54-3d57-4c88-9d35-1aba0132cdf9", + "name": "Fetch DB Context", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [32000, 13872], + "alwaysOutputData": true, + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "notes": "ดึงข้อมูลจาก Database ส่งให้ AI" + }, + { + "parameters": { + "jsCode": "const items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nreturn items.map(itemWrapper => {\n const item = itemWrapper.json;\n const ai = item.ai_result || {};\n\n return {\n json: {\n ...item,\n enqueue_payload: {\n document_number: String(item.document_number || ''),\n title: String(ai.subject || item.title || ''),\n original_title: String(item.title || ''),\n category: ai.suggested_category || 'Correspondence',\n ai_summary: ai.summary || '',\n project_id: Number(ai.project_id || config.PROJECT_ID),\n sender_org_id: ai.sender_id || null,\n receiver_org_id: ai.receiver_id || null,\n issued_date: ai.issued_date || item.issued_date || '',\n received_date: ai.received_date || item.received_date || '',\n remarks: item.staging_remarks || '',\n extracted_tags: ai.tags || [],\n temp_attachment_id: item.temp_attachment_id || null,\n is_valid: ai.is_valid !== false,\n confidence: ai.confidence || 0.0,\n ai_issues: ai.key_points || []\n }\n }\n };\n});" + }, + "id": "57421305-8c7e-4fa4-a339-1144902cae22", + "name": "Build Enqueue Payload", + "typeVersion": 2, + "type": "n8n-nodes-base.code", + "position": [32544, 13664], + "notes": "สร้าง payload สำหรับ Enqueue Migration" + }, + { + "parameters": { + "method": "POST", + "url": "={{$('Set Configuration').first().json.config.BACKEND_URL}}/api/migration/queue", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{$('Set Configuration').first().json.config.MIGRATION_TOKEN}}" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.enqueue_payload }}", + "options": { + "timeout": 30000 + } + }, + "id": "f5f5f5f5-f5f5-4f5f-af5f-f5f5f5f5f5f5", + "name": "Enqueue to Review Queue", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.1, + "position": [32736, 13664], + "notes": "ส่งข้อมูลเข้า Staging Queue" + }, + { + "parameters": { + "operation": "executeQuery", + "query": "INSERT INTO migration_progress (batch_id, last_processed_index, status) VALUES ('{{$('Set Configuration').first().json.config.BATCH_ID}}', {{$json.original_index || 0}}, 'RUNNING') ON DUPLICATE KEY UPDATE last_processed_index = {{$json.original_index || 0}}, updated_at = NOW()", + "options": {} + }, + "id": "bb0e611b-db28-4266-ba40-3b5d534a16f7", + "name": "Save Checkpoint", + "type": "n8n-nodes-base.mySql", + "typeVersion": 2.4, + "position": [32928, 13856], + "credentials": { + "mySql": { + "id": "CHHfbKhMacNo03V4", + "name": "MySQL account" + } + }, + "notes": "บันทึกความคืบหน้าลง Database" + } + ], + "connections": { + "Form Trigger": { + "main": [ + [ + { + "node": "Set Configuration", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set Configuration": { + "main": [ + [ + { + "node": "Check Backend Health", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Backend Health": { + "main": [ + [ + { + "node": "Fetch Categories", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Categories": { + "main": [ + [ + { + "node": "Fetch Tags", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Tags": { + "main": [ + [ + { + "node": "File Mount Check", + "type": "main", + "index": 0 + } + ] + ] + }, + "File Mount Check": { + "main": [ + [ + { + "node": "Read Excel Binary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read Excel Binary": { + "main": [ + [ + { + "node": "Read Excel", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read Excel": { + "main": [ + [ + { + "node": "Read Checkpoint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read Checkpoint": { + "main": [ + [ + { + "node": "Process Batch + Encoding", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Check Backend Health", + "type": "main", + "index": 0 + } + ] + ] + }, + "Process Batch + Encoding": { + "main": [ + [ + { + "node": "File Validator", + "type": "main", + "index": 0 + } + ] + ] + }, + "File Validator": { + "main": [ + [ + { + "node": "Read PDF File", + "type": "main", + "index": 0 + } + ] + ] + }, + "Read PDF File": { + "main": [ + [ + { + "node": "Upload to Backend", + "type": "main", + "index": 0 + } + ] + ] + }, + "Upload to Backend": { + "main": [ + [ + { + "node": "Process Upload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Process Upload": { + "main": [ + [ + { + "node": "Extract PDF Text", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract PDF Text": { + "main": [ + [ + { + "node": "Check Fallback State", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check Fallback State": { + "main": [ + [ + { + "node": "Fetch DB Context", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch DB Context": { + "main": [ + [ + { + "node": "Build AI Prompt", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build AI Prompt": { + "main": [ + [ + { + "node": "Ollama AI Analysis", + "type": "main", + "index": 0 + } + ] + ] + }, + "Ollama AI Analysis": { + "main": [ + [ + { + "node": "Parse & Validate AI Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Parse & Validate AI Response": { + "main": [ + [ + { + "node": "Confidence Router", + "type": "main", + "index": 0 + } + ] + ] + }, + "Confidence Router": { + "main": [ + [ + { + "node": "Route by Confidence", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route by Confidence": { + "main": [ + [ + { + "node": "Build Enqueue Payload", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build Enqueue Payload", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Log Reject to CSV", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Log Error to CSV", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Enqueue Payload": { + "main": [ + [ + { + "node": "Enqueue to Review Queue", + "type": "main", + "index": 0 + } + ] + ] + }, + "Enqueue to Review Queue": { + "main": [ + [ + { + "node": "Save Checkpoint", + "type": "main", + "index": 0 + } + ] + ] + }, + "Save Checkpoint": { + "main": [ + [ + { + "node": "Delay", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log Reject to CSV": { + "main": [ + [ + { + "node": "Delay", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log Error to CSV": { + "main": [ + [ + { + "node": "Log Error to DB", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log Error to DB": { + "main": [ + [ + { + "node": "Delay", + "type": "main", + "index": 0 + } + ] + ] + }, + "Delay": { + "main": [ + [ + { + "node": "Read Checkpoint", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e54393c..68666d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,9 +43,6 @@ importers: '@nestjs/jwt': specifier: ^11.0.1 version: 11.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/mapped-types': - specifier: ^2.1.0 - version: 2.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) '@nestjs/passport': specifier: ^11.0.5 version: 11.0.5(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) @@ -13996,8 +13993,8 @@ snapshots: '@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) @@ -14020,7 +14017,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -14031,22 +14028,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.48.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -14057,7 +14054,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/specs/05-Engineering-Guidelines/05-01-fullstack-js-guidelines.md b/specs/05-Engineering-Guidelines/05-01-fullstack-js-guidelines.md index 2a2fbf7..56e6cbd 100644 --- a/specs/05-Engineering-Guidelines/05-01-fullstack-js-guidelines.md +++ b/specs/05-Engineering-Guidelines/05-01-fullstack-js-guidelines.md @@ -163,6 +163,30 @@ const testScenarios = { - ห่อหุ้มโค้ดที่ใช้ซ้ำได้ไว้ใน **common module** (@app/common): - Configs, decorators, DTOs, guards, interceptors, notifications, shared services, types, validators +### **3.1.1 NestJS 11 Patterns (Updated 2026-03-16)** + +| Pattern | คำอธิบาย | +| :--- | :--- | +| **`import type` สำหรับ decorated signatures** | เมื่อ `isolatedModules` + `emitDecoratorMetadata` เปิดอยู่ ต้องใช้ `import type` สำหรับ interface ที่ใช้ใน decorated parameter (เช่น `@Req() req: RequestWithUser`) | +| **Shared `RequestWithUser` interface** | ใช้ `src/common/interfaces/request-with-user.interface.ts` แทนการประกาศ local interface ในแต่ละ controller — ห้ามใช้ `req: any` | +| **`@nestjs/mapped-types` ถูกลบออก** | DTO utility types (`PartialType`, `OmitType`, `IntersectionType`) ต้อง import จาก `@nestjs/swagger` เท่านั้น | +| **Express v5** | `@nestjs/platform-express` v11 ใช้ Express 5 — path parameter syntax เปลี่ยน (`:id` → `:id` ยังใช้ได้ แต่ wildcard `*` → `*name`) | +| **Swagger version** | Swagger doc version ต้องตรงกับ project version ปัจจุบัน (`1.8.1`) | + +```typescript +// ✅ ถูกต้อง — NestJS 11 pattern +import type { RequestWithUser } from '../interfaces/request-with-user.interface'; + +@Get('profile') +getProfile(@Req() req: RequestWithUser) { + return req.user; +} + +// ❌ ห้ามใช้ +@Get('profile') +getProfile(@Req() req: any) { ... } +``` + ### **3.2 Database & Data Modeling (MariaDB + TypeORM)** #### **3.2.1 Optimistic Locking & Versioning** diff --git a/specs/06-Decision-Records/ADR-005-technology-stack.md b/specs/06-Decision-Records/ADR-005-technology-stack.md index 002d6a4..9930087 100644 --- a/specs/06-Decision-Records/ADR-005-technology-stack.md +++ b/specs/06-Decision-Records/ADR-005-technology-stack.md @@ -92,7 +92,8 @@ LCBP3-DMS ต้องเลือก Technology Stack สำหรับพั | Component | Technology | Rationale | | :----------------- | :-------------- | :------------------------------------------------------------------------- | | **Runtime** | Node.js 20 LTS | Stable, modern features, long-term support | -| **Framework** | NestJS | Modular, TypeScript-first, similar to Angular | +| **Framework** | NestJS 11 | Modular, TypeScript-first, Express v5 support | +| **HTTP Engine** | Express 5 | Path param changes, improved error handling | | **Language** | TypeScript 5.x | Type safety, better DX | | **ORM** | TypeORM | TypeScript support, migrations, repositories | | **Database** | MariaDB 11.8 | JSON support, virtual columns, QNAP compatible |