diff --git a/.gitea/workflows/ci-deploy.yml b/.gitea/workflows/ci-deploy.yml index a6959d8..e8c993d 100644 --- a/.gitea/workflows/ci-deploy.yml +++ b/.gitea/workflows/ci-deploy.yml @@ -71,113 +71,31 @@ jobs: - name: " Checkout" uses: actions/checkout@v4 - - name: " Debug Connection Info" + - name: "🚀 Deploy to QNAP" run: | - echo "HOST length: ${#HOST_VAL}" - echo "PORT value: $PORT_VAL" - # ลอง resolve DNS ของ host - nslookup "$HOST_VAL" 2>/dev/null || host "$HOST_VAL" 2>/dev/null || echo "Cannot resolve" - # ดูว่า host ตอบสนองหรือไม่ - nc -zv -w5 "$HOST_VAL" "$PORT_VAL" 2>&1 || true - env: - HOST_VAL: ${{ secrets.HOST }} - PORT_VAL: ${{ secrets.PORT }} - - - name: " Setup SSH Key and Deploy to QNAP" - run: | - # Setup SSH key authentication mkdir -p ~/.ssh echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa - ssh-keyscan -p ${{ secrets.PORT }} ${{ secrets.HOST }} >> ~/.ssh/known_hosts + ssh-keyscan -p ${{ secrets.PORT }} ${{ secrets.HOST }} >> ~/.ssh/known_hosts 2>/dev/null - # Debug: Check SSH key - echo "SSH key file exists: $(test -f ~/.ssh/id_rsa && echo 'YES' || echo 'NO')" - echo "SSH key permissions: $(ls -la ~/.ssh/id_rsa)" - echo "SSH key first line: $(head -1 ~/.ssh/id_rsa)" + ssh -o StrictHostKeyChecking=no \ + -o ConnectTimeout=30 \ + -o BatchMode=yes \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=10 \ + -i ~/.ssh/id_rsa \ + -p ${{ secrets.PORT }} ${{ secrets.USERNAME }}@${{ secrets.HOST }} bash << 'REMOTE_EOF' + set -e + export PATH="/share/CACHEDEV1_DATA/.qpkg/container-station/bin:/opt/bin:/usr/local/bin:/usr/bin:/bin:$PATH" - # Create remote deployment script - REMOTE_SCRIPT=$(cat << 'SCRIPT_EOF' - set -e - export PATH="/share/CACHEDEV1_DATA/.qpkg/container-station/bin:/opt/bin:/usr/local/bin:/usr/bin:/bin:$PATH" + cd /share/np-dms/app/source/lcbp3 + [ -d .git ] || { echo "✗ Git repo not found"; exit 1; } - echo "==========================================" - echo "Starting QNAP Deployment Process" - echo "==========================================" + git fetch origin main + git reset --hard origin/main + chmod +x scripts/deploy.sh scripts/rollback.sh 2>/dev/null || true + mkdir -p /share/np-dms/app/logs - # Verify Docker is accessible - if ! docker version > /dev/null 2>&1; then - echo " Docker not accessible. Check Container Station." - exit 1 - fi - echo " Docker accessible" - - # Sync scripts first - echo " Syncing deployment scripts..." - cd /share/np-dms/app/source/lcbp3 - - # Check if directory exists - if [ ! -d ".git" ]; then - echo " Git repository not found at expected path" - exit 1 - fi - - git fetch origin main - git reset --hard origin/main - echo " Code synced" - - # Ensure scripts are executable - chmod +x scripts/deploy.sh scripts/rollback.sh 2>/dev/null || true - - mkdir -p /share/np-dms/app/logs - - # Note: Docker build cache is preserved for faster builds - # Only prune cache manually when needed: docker builder prune -f - - echo " Executing deployment..." - ./scripts/deploy.sh - - echo " Deployment completed successfully" - SCRIPT_EOF - ) - - # Retry logic for SSH connection - max_attempts=3 - attempt=1 - - while [ $attempt -le $max_attempts ]; do - echo " Deployment attempt $attempt/$max_attempts..." - - # Debug: Test SSH connection first - echo "Testing SSH connection..." - ssh -o StrictHostKeyChecking=no \ - -o ConnectTimeout=10 \ - -o BatchMode=yes \ - -o PasswordAuthentication=no \ - -o LogLevel=DEBUG3 \ - -i ~/.ssh/id_rsa \ - -p ${{ secrets.PORT }} ${{ secrets.USERNAME }}@${{ secrets.HOST }} 'echo "SSH auth successful"' - - if echo "$REMOTE_SCRIPT" | ssh -o StrictHostKeyChecking=no \ - -o ConnectTimeout=60 \ - -o ServerAliveInterval=30 \ - -o ServerAliveCountMax=60 \ - -o TCPKeepAlive=yes \ - -i ~/.ssh/id_rsa \ - -p ${{ secrets.PORT }} ${{ secrets.USERNAME }}@${{ secrets.HOST }} 'bash -s'; then - echo " Deployment successful!" - exit 0 - else - echo " Attempt $attempt failed" - if [ $attempt -lt $max_attempts ]; then - echo " Retrying in 10 seconds..." - sleep 10 - fi - fi - - attempt=$((attempt + 1)) - done - - echo " All deployment attempts failed" - exit 1 + ./scripts/deploy.sh + REMOTE_EOF timeout-minutes: 20 diff --git a/backend/src/modules/workflow-engine/workflow-engine.service.spec.ts b/backend/src/modules/workflow-engine/workflow-engine.service.spec.ts index b3cc3f2..712bedc 100644 --- a/backend/src/modules/workflow-engine/workflow-engine.service.spec.ts +++ b/backend/src/modules/workflow-engine/workflow-engine.service.spec.ts @@ -1,3 +1,14 @@ +// ADR-021 Clarify Q2 (C1): Mock Redlock ก่อน import service +// ใช้ module-level mock เพื่อบังคับให้ constructor `new Redlock(...)` ในการสร้าง service +const mockRedlockAcquire = jest.fn(); +const mockRedlockRelease = jest.fn().mockResolvedValue(undefined); +jest.mock('redlock', () => + jest.fn().mockImplementation(() => ({ + acquire: mockRedlockAcquire, + })) +); + +import { ConflictException, ServiceUnavailableException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { WorkflowEngineService } from './workflow-engine.service'; import { getRepositoryToken } from '@nestjs/typeorm'; @@ -12,9 +23,12 @@ import { Attachment } from '../../common/file-storage/entities/attachment.entity import { WorkflowDslService } from './workflow-dsl.service'; import { WorkflowEventService } from './workflow-event.service'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { NotFoundException } from '../../common/exceptions'; +import { NotFoundException, WorkflowException } from '../../common/exceptions'; import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto'; +// Token ของ @nestjs-modules/ioredis — default Redis connection +const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken'; + describe('WorkflowEngineService', () => { let service: WorkflowEngineService; let defRepo: Repository; @@ -60,6 +74,12 @@ describe('WorkflowEngineService', () => { }; beforeEach(async () => { + // ADR-021 C1: default Redlock behavior = acquire สำเร็จ + mockRedlockAcquire.mockReset().mockResolvedValue({ + release: mockRedlockRelease, + }); + mockRedlockRelease.mockClear(); + const module: TestingModule = await Test.createTestingModule({ providers: [ WorkflowEngineService, @@ -105,6 +125,13 @@ describe('WorkflowEngineService', () => { del: jest.fn().mockResolvedValue(undefined), }, }, + // ADR-021 C1: Redis mock สำหรับ @InjectRedis() + { + provide: DEFAULT_REDIS_TOKEN, + useValue: { + // ไม่จำเป็นต้องมี method จริง เพราะ Redlock ถูก mock แล้ว + }, + }, ], }).compile(); @@ -113,8 +140,6 @@ describe('WorkflowEngineService', () => { instanceRepo = module.get(getRepositoryToken(WorkflowInstance)); dslService = module.get(WorkflowDslService); eventService = module.get(WorkflowEventService); - - jest.clearAllMocks(); }); it('should be defined', () => { @@ -227,18 +252,27 @@ describe('WorkflowEngineService', () => { const attachmentPublicIds = ['att-123', 'att-456']; const mockInstance = { id: instanceId, - currentState: 'PENDING', + currentState: 'PENDING_REVIEW', // ADR-021 C3: allowed upload state status: WorkflowStatus.ACTIVE, definition: { compiled: mockCompiledWorkflow }, context: { some: 'data' }, }; + // C3 pre-check ดึง instance จาก instanceRepo.findOne (ไม่ใช่ queryRunner) + (instanceRepo.findOne as jest.Mock).mockResolvedValue({ + id: instanceId, + currentState: 'PENDING_REVIEW', + }); + // Mock the history object with an ID const mockHistory = { id: 'history-123' }; mockQueryRunner.manager.findOne.mockResolvedValue(mockInstance); - - // Mock save to return the history object when called with any entity mockQueryRunner.manager.save.mockResolvedValue(mockHistory); + // C2: update ต้องรายงาน affected = attachmentPublicIds.length + mockQueryRunner.manager.update.mockResolvedValue({ + affected: attachmentPublicIds.length, + }); + mockDslService.evaluate.mockReturnValue({ nextState: 'APPROVED', events: [], @@ -253,9 +287,15 @@ describe('WorkflowEngineService', () => { attachmentPublicIds ); + // C2: where clause ต้องมี guards ครบ 3 ชั้น expect(mockQueryRunner.manager.update).toHaveBeenCalledWith( Attachment, - { publicId: In(attachmentPublicIds) }, + { + publicId: In(attachmentPublicIds), + isTemporary: false, + uploadedByUserId: 1, + workflowHistoryId: null, + }, { workflowHistoryId: 'history-123' } ); }); @@ -317,5 +357,174 @@ describe('WorkflowEngineService', () => { ); }); }); + + // ============================================================ + // ADR-021 T031a: Clarify Session 2026-04-19 Amendments + // ============================================================ + describe('ADR-021 Clarify Q1+Q2 (T031a) — state check, Redlock, guards', () => { + const attachmentPublicIds = ['att-1']; + + it('C3: should throw ConflictException (409) when uploading in APPROVED state', async () => { + // Arrange: currentState = APPROVED (terminal, ไม่อยู่ใน UPLOAD_ALLOWED_STATES) + (instanceRepo.findOne as jest.Mock).mockResolvedValue({ + id: 'inst-1', + currentState: 'APPROVED', + }); + + // Act + Assert + await expect( + service.processTransition( + 'inst-1', + 'APPROVE', + 1, + undefined, + {}, + attachmentPublicIds + ) + ).rejects.toThrow(ConflictException); + + // Redlock ต้องไม่ถูกเรียก (pre-check บล็อกก่อน) + expect(mockRedlockAcquire).not.toHaveBeenCalled(); + }); + + it('C3: should throw ConflictException (409) when uploading in REJECTED state', async () => { + (instanceRepo.findOne as jest.Mock).mockResolvedValue({ + id: 'inst-1', + currentState: 'REJECTED', + }); + + await expect( + service.processTransition( + 'inst-1', + 'APPROVE', + 1, + undefined, + {}, + attachmentPublicIds + ) + ).rejects.toThrow(ConflictException); + }); + + it('C3: should skip state check when attachmentPublicIds is empty', async () => { + // ถ้าไม่มี attachment ไม่ต้องตรวจ state — transition ในสถานะไหนก็ได้ + const mockInstance = { + id: 'inst-1', + currentState: 'DRAFT', + status: WorkflowStatus.ACTIVE, + definition: { compiled: mockCompiledWorkflow }, + context: {}, + }; + mockQueryRunner.manager.findOne.mockResolvedValue(mockInstance); + mockQueryRunner.manager.save.mockResolvedValue({ id: 'history-1' }); + mockDslService.evaluate.mockReturnValue({ + nextState: 'PENDING', + events: [], + }); + + await expect( + service.processTransition('inst-1', 'SUBMIT', 1) + ).resolves.toBeDefined(); + + // pre-check ต้องไม่ถูกเรียก (ไม่มี attachments) + expect(instanceRepo.findOne).not.toHaveBeenCalled(); + }); + + it('C1: should throw ServiceUnavailableException (503) when Redlock acquire fails', async () => { + // Arrange: state check ผ่าน + (instanceRepo.findOne as jest.Mock).mockResolvedValue({ + id: 'inst-1', + currentState: 'PENDING_REVIEW', + }); + // Redlock ล้มเหลว — Redis ล่ม / ไม่สามารถ acquire หลัง retry 3 ครั้ง + mockRedlockAcquire.mockRejectedValue( + new Error('ExecutionError: unable to achieve quorum') + ); + + // Act + Assert + await expect( + service.processTransition( + 'inst-1', + 'APPROVE', + 1, + undefined, + {}, + attachmentPublicIds + ) + ).rejects.toThrow(ServiceUnavailableException); + + // DB transaction ต้องไม่เคยเริ่ม (fail-closed before DB work) + expect(mockQueryRunner.startTransaction).not.toHaveBeenCalled(); + }); + + it('C2: should rollback and throw when update.affected < expected (temp/foreign attachment)', async () => { + // Arrange: state ผ่าน, Redlock ผ่าน, DB transaction เดินไปถึง update + (instanceRepo.findOne as jest.Mock).mockResolvedValue({ + id: 'inst-1', + currentState: 'PENDING_APPROVAL', + }); + mockQueryRunner.manager.findOne.mockResolvedValue({ + id: 'inst-1', + currentState: 'PENDING_APPROVAL', + status: WorkflowStatus.ACTIVE, + definition: { compiled: mockCompiledWorkflow }, + context: {}, + }); + mockQueryRunner.manager.save.mockResolvedValue({ id: 'history-999' }); + mockDslService.evaluate.mockReturnValue({ + nextState: 'APPROVED', + events: [], + }); + // affected < expected — แปลว่ามีไฟล์บางไฟล์ temp / ของคนอื่น / ผูกไปแล้ว + mockQueryRunner.manager.update.mockResolvedValue({ affected: 1 }); + + await expect( + service.processTransition( + 'inst-1', + 'APPROVE', + 1, + undefined, + {}, + ['att-1', 'att-2', 'att-3'] // ขอ 3 ไฟล์ แต่ affected = 1 + ) + ).rejects.toThrow(WorkflowException); + + // ต้อง rollback + expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled(); + // ต้องไม่ commit + expect(mockQueryRunner.commitTransaction).not.toHaveBeenCalled(); + // ต้อง release Redlock + expect(mockRedlockRelease).toHaveBeenCalled(); + }); + + it('C1: should release Redlock even when transition succeeds', async () => { + (instanceRepo.findOne as jest.Mock).mockResolvedValue({ + id: 'inst-1', + currentState: 'PENDING_REVIEW', + }); + mockQueryRunner.manager.findOne.mockResolvedValue({ + id: 'inst-1', + currentState: 'PENDING_REVIEW', + status: WorkflowStatus.ACTIVE, + definition: { compiled: mockCompiledWorkflow }, + context: {}, + }); + mockQueryRunner.manager.save.mockResolvedValue({ id: 'history-1' }); + mockQueryRunner.manager.update.mockResolvedValue({ affected: 1 }); + mockDslService.evaluate.mockReturnValue({ + nextState: 'APPROVED', + events: [], + }); + + await service.processTransition('inst-1', 'APPROVE', 1, undefined, {}, [ + 'att-1', + ]); + + expect(mockRedlockAcquire).toHaveBeenCalledWith( + ['lock:wf:transition:inst-1'], + 10000 + ); + expect(mockRedlockRelease).toHaveBeenCalled(); + }); + }); }); }); diff --git a/backend/src/modules/workflow-engine/workflow-engine.service.ts b/backend/src/modules/workflow-engine/workflow-engine.service.ts index 9b07ee4..f131867 100644 --- a/backend/src/modules/workflow-engine/workflow-engine.service.ts +++ b/backend/src/modules/workflow-engine/workflow-engine.service.ts @@ -1,11 +1,21 @@ // File: src/modules/workflow-engine/workflow-engine.service.ts -import { Injectable, Inject, Logger } from '@nestjs/common'; +import { + Injectable, + Inject, + Logger, + ConflictException, + ServiceUnavailableException, +} from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import type { Cache } from 'cache-manager'; import { NotFoundException, WorkflowException } from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, In, Repository } from 'typeorm'; +// ADR-021 Clarify Q2: Redis Redlock for transition Fail-closed (Retry 3x → 503) +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import Redlock, { Lock } from 'redlock'; // Entities import { WorkflowDefinition } from './entities/workflow-definition.entity'; import { WorkflowHistory } from './entities/workflow-history.entity'; @@ -44,6 +54,13 @@ export interface TransitionResult { @Injectable() export class WorkflowEngineService { private readonly logger = new Logger(WorkflowEngineService.name); + private readonly redlock: Redlock; + + // ADR-021 Clarify Q1: สถานะ workflow ที่อนุญาตให้อัปโหลด Step-attachment + private static readonly UPLOAD_ALLOWED_STATES = new Set([ + 'PENDING_REVIEW', + 'PENDING_APPROVAL', + ]); constructor( @InjectRepository(WorkflowDefinition) @@ -58,8 +75,18 @@ export class WorkflowEngineService { private readonly dslService: WorkflowDslService, private readonly eventService: WorkflowEventService, // [NEW] Inject Service 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 + ) { + // ADR-021 Clarify Q2 (C1): Redlock Fail-closed + // Retry 3 ครั้ง × 500ms เพิ่ม jitter → ถ้ายังไม่ได้ throw HTTP 503 + this.redlock = new Redlock([this.redis], { + driftFactor: 0.01, + retryCount: 3, + retryDelay: 500, + retryJitter: 100, + }); + } // ================================================================= // [PART 1] Definition Management (Phase 6A) @@ -305,6 +332,53 @@ export class WorkflowEngineService { // ADR-021: publicIds ของไฟล์แนบประจำ Step นี้ (Two-Phase upload ก่อนแล้ว) attachmentPublicIds?: string[] ) { + const hasAttachments = + attachmentPublicIds !== undefined && attachmentPublicIds.length > 0; + + // ============================================================== + // ADR-021 Clarify Q1 (C3): ตรวจสถานะก่อน acquire Redlock + // อนุญาตให้แนบไฟล์เฉพาะในสถานะ PENDING_REVIEW / PENDING_APPROVAL + // ============================================================== + if (hasAttachments) { + const instancePreCheck = await this.instanceRepo.findOne({ + where: { id: instanceId }, + select: ['id', 'currentState'], + }); + if (!instancePreCheck) { + throw new NotFoundException('Workflow Instance', instanceId); + } + if ( + !WorkflowEngineService.UPLOAD_ALLOWED_STATES.has( + instancePreCheck.currentState + ) + ) { + throw new ConflictException({ + userMessage: 'ไม่สามารถอัปโหลดไฟล์ในสถานะนี้ได้', + recoveryAction: + 'อนุญาตเฉพาะสถานะ PENDING_REVIEW หรือ PENDING_APPROVAL เท่านั้น', + currentState: instancePreCheck.currentState, + }); + } + } + + // ============================================================== + // ADR-021 Clarify Q2 (C1): Acquire Redlock (Fail-closed) + // Retry 3x × 500ms + jitter → ถ้ายังไม่ได้ throw HTTP 503 + // ============================================================== + const lockKey = `lock:wf:transition:${instanceId}`; + let lock: Lock; + try { + lock = await this.redlock.acquire([lockKey], 10000); // 10s TTL + } catch (err) { + this.logger.error( + `Redlock acquire failed after retries for ${instanceId}: ${(err as Error).message}` + ); + throw new ServiceUnavailableException({ + userMessage: 'ระบบยุ่งชั่วคราว กรุณาลองใหม่ภายหลัง', + recoveryAction: 'รอสักครู่แล้วลองใหม่', + }); + } + const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); @@ -375,13 +449,38 @@ export class WorkflowEngineService { }); const savedHistory = await queryRunner.manager.save(history); - // ADR-021: ผูกไฟล์แนบประจำ Step นี้ (ทำในตัว Transaction เดียวกัน) - if (attachmentPublicIds && attachmentPublicIds.length > 0) { - await queryRunner.manager.update( + // ============================================================== + // ADR-021 (C2): Link attachments พร้อม guard 3 ชั้น + // 1. isTemporary = false — Two-Phase commit แล้ว (ADR-016) + // 2. uploadedByUserId = userId — ownership check (กัน attach ไฟล์คนอื่น) + // 3. workflowHistoryId IS NULL — ยังไม่เคยผูกกับ step อื่น + // ============================================================== + if (hasAttachments) { + const updateResult = await queryRunner.manager.update( Attachment, - { publicId: In(attachmentPublicIds) }, + { + publicId: In(attachmentPublicIds), + isTemporary: false, + uploadedByUserId: userId, + workflowHistoryId: null, + }, { workflowHistoryId: savedHistory.id } ); + + const expected = attachmentPublicIds.length; + const actual = updateResult.affected ?? 0; + if (actual !== expected) { + throw new WorkflowException( + 'INVALID_ATTACHMENTS', + `Attachment link mismatch: expected ${expected}, linked ${actual}`, + 'ไฟล์แนบบางไฟล์ไม่สามารถผูกกับขั้นตอนนี้ได้', + [ + 'ตรวจสอบว่าไฟล์อัปโหลดสำเร็จ (ไม่ใช่ temp)', + 'ตรวจสอบว่าคุณเป็นเจ้าของไฟล์ทุกไฟล์', + 'ตรวจสอบว่าไฟล์ยังไม่เคยถูกผูกกับ step อื่น', + ] + ); + } } await queryRunner.commitTransaction(); @@ -426,6 +525,14 @@ export class WorkflowEngineService { throw err; } finally { await queryRunner.release(); + // ADR-021 C1: ปล่อย Redlock เสมอ (non-blocking หาก release ผิดพลาด) + lock.release().catch((e: unknown) => { + this.logger.warn( + `Redlock release failed for ${instanceId} (may have expired): ${ + e instanceof Error ? e.message : String(e) + }` + ); + }); } } diff --git a/frontend/hooks/__tests__/use-workflow-action.test.ts b/frontend/hooks/__tests__/use-workflow-action.test.ts new file mode 100644 index 0000000..ebe1c7d --- /dev/null +++ b/frontend/hooks/__tests__/use-workflow-action.test.ts @@ -0,0 +1,154 @@ +// ADR-021 T027a: ทดสอบ HTTP 503 / 409 / 403 error handling ใน useWorkflowAction +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { createTestQueryClient } from '@/lib/test-utils'; +import { useWorkflowAction } from '../use-workflow-action'; +import { workflowEngineService } from '@/lib/services/workflow-engine.service'; +import { toast } from 'sonner'; + +// Mock service +vi.mock('@/lib/services/workflow-engine.service', () => ({ + workflowEngineService: { + transition: vi.fn(), + }, +})); + +// Mock sonner toast +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// Helper — สร้าง ApiErrorResponse ปลอมตามรูปแบบ parseApiError +function makeApiError(statusCode: number, message: string, recoveryActions?: string[]) { + return { + error: { + type: 'BUSINESS', + code: 'HTTP_ERROR', + message, + severity: statusCode >= 500 ? 'HIGH' : 'MEDIUM', + timestamp: new Date().toISOString(), + statusCode, + recoveryActions, + }, + }; +} + +describe('useWorkflowAction — T027a error handling (Clarify Q1+Q2)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('Q2 (503): should show "ระบบยุ่ง" toast when Redlock Fail-closed', async () => { + vi.mocked(workflowEngineService.transition).mockRejectedValue( + makeApiError(503, 'ระบบยุ่งชั่วคราว กรุณาลองใหม่ภายหลัง') + ); + + 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); + }); + + expect(toast.error).toHaveBeenCalledWith( + 'ระบบยุ่งชั่วคราว กรุณาลองใหม่อีกครั้งภายหลัง', + expect.objectContaining({ + description: expect.stringContaining('ข้อมูลของคุณปลอดภัย'), + }) + ); + }); + + it('Q1 (409): should show state violation toast with backend message', async () => { + vi.mocked(workflowEngineService.transition).mockRejectedValue( + makeApiError(409, 'ไม่สามารถอัปโหลดไฟล์ในสถานะนี้ได้', [ + 'อนุญาตเฉพาะสถานะ PENDING_REVIEW หรือ PENDING_APPROVAL', + ]) + ); + + const { wrapper } = createTestQueryClient(); + const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper }); + + await act(async () => { + result.current.mutate({ action: 'APPROVE', attachmentPublicIds: ['a1'] }); + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(toast.error).toHaveBeenCalledWith( + 'ไม่สามารถอัปโหลดไฟล์ในสถานะนี้ได้', + expect.objectContaining({ + description: 'อนุญาตเฉพาะสถานะ PENDING_REVIEW หรือ PENDING_APPROVAL', + }) + ); + }); + + it('403: should show unauthorized toast', async () => { + vi.mocked(workflowEngineService.transition).mockRejectedValue( + makeApiError(403, 'ไม่มีสิทธิ์', ['ติดต่อ 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); + }); + + expect(toast.error).toHaveBeenCalledWith( + 'คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้', + expect.objectContaining({ + description: 'ติดต่อ Admin', + }) + ); + }); + + it('should show success toast on 200', async () => { + vi.mocked(workflowEngineService.transition).mockResolvedValue({ + success: true, + nextState: 'APPROVED', + }); + + const { wrapper } = createTestQueryClient(); + const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper }); + + await act(async () => { + result.current.mutate({ action: 'APPROVE' }); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(toast.success).toHaveBeenCalledWith('ดำเนินการเรียบร้อยแล้ว'); + }); + + it('should reject when instanceId is undefined', async () => { + const { wrapper } = createTestQueryClient(); + const { result } = renderHook(() => useWorkflowAction(undefined), { wrapper }); + + await act(async () => { + result.current.mutate({ action: 'APPROVE' }); + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining('ไม่พบ Workflow Instance ID') + ); + }); +}); diff --git a/frontend/hooks/use-workflow-action.ts b/frontend/hooks/use-workflow-action.ts index d771d14..ae7cc92 100644 --- a/frontend/hooks/use-workflow-action.ts +++ b/frontend/hooks/use-workflow-action.ts @@ -1,5 +1,6 @@ // ADR-021 T027: useWorkflowAction — hook สำหรับส่ง Approve/Reject/Return action // สร้าง Idempotency-Key ครั้งเดียวต่อ action intent (via useState) ป้องกัน duplicate submission +// ADR-021 T027a (Clarify Q1+Q2): จัดการ HTTP 409 (state violation) และ 503 (Redlock fail-closed) 'use client'; import { useState } from 'react'; @@ -7,8 +8,19 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { v4 as uuidv4 } from 'uuid'; import { toast } from 'sonner'; import { workflowEngineService } from '@/lib/services/workflow-engine.service'; +import type { ApiErrorResponse } from '@/lib/api/client'; import type { WorkflowTransitionWithAttachmentsDto } from '@/types/dto/workflow-engine/workflow-engine.dto'; +// Type guard — ตรวจสอบว่า error ที่ได้มาเป็น ApiErrorResponse (จาก parseApiError interceptor) +function isApiErrorResponse(err: unknown): err is ApiErrorResponse { + return ( + typeof err === 'object' && + err !== null && + 'error' in err && + typeof (err as ApiErrorResponse).error === 'object' + ); +} + export function useWorkflowAction(instanceId: string | undefined) { const queryClient = useQueryClient(); @@ -41,8 +53,48 @@ export function useWorkflowAction(instanceId: string | undefined) { toast.success('ดำเนินการเรียบร้อยแล้ว'); }, - onError: (error: Error) => { - toast.error(error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่'); + onError: (error: unknown) => { + // ADR-021 T027a: แยก handler ตาม status code + if (isApiErrorResponse(error)) { + const { statusCode, message, recoveryActions } = error.error; + + // Clarify Q2: 503 Service Unavailable (Redlock Fail-closed) + if (statusCode === 503) { + toast.error('ระบบยุ่งชั่วคราว กรุณาลองใหม่อีกครั้งภายหลัง', { + description: 'การทำรายการไม่ถูกดำเนินการ ข้อมูลของคุณปลอดภัย', + }); + // Keep idempotencyKey unchanged — user can retry ด้วย key เดิม + return; + } + + // Clarify Q1: 409 Conflict (ไม่อยู่ในสถานะที่อนุญาตให้อัปโหลด) + if (statusCode === 409) { + toast.error(message || 'ไม่สามารถดำเนินการในสถานะนี้ได้', { + description: recoveryActions?.[0], + }); + return; + } + + // 403 Forbidden — ไม่มีสิทธิ์ + if (statusCode === 403) { + toast.error('คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้', { + description: recoveryActions?.[0], + }); + return; + } + + // Fallback — ใช้ message จาก backend + toast.error(message || 'เกิดข้อผิดพลาด กรุณาลองใหม่'); + return; + } + + // Fallback — plain Error (เช่น ไม่พบ instanceId) + if (error instanceof Error) { + toast.error(error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่'); + return; + } + + toast.error('เกิดข้อผิดพลาดที่ไม่คาดคิด กรุณาลองใหม่'); }, }); diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 20cb8c7..2ec5bc8 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -32,32 +32,32 @@ fi cd "$SOURCE_DIR" -# [1/3] Build images -echo "[1/3] Building Docker images..." -echo " Building backend..." -docker build -f backend/Dockerfile -t lcbp3-backend:latest . || { - echo "✗ Backend build failed!" - exit 1 -} +# เปิด BuildKit เพื่อ layer cache และ parallel build +export DOCKER_BUILDKIT=1 + +# [1/3] Build images (parallel) +echo "[1/3] Building Docker images (parallel)..." + +docker build -f backend/Dockerfile -t lcbp3-backend:latest . & +BACKEND_PID=$! -echo " Building frontend (API: $API_URL)..." docker build -f frontend/Dockerfile \ --build-arg NEXT_PUBLIC_API_URL="$API_URL" \ --build-arg AUTH_URL="$AUTH_URL" \ - -t lcbp3-frontend:latest . || { - echo "✗ Frontend build failed!" - exit 1 -} + -t lcbp3-frontend:latest . & +FRONTEND_PID=$! + +wait $BACKEND_PID || { echo "✗ Backend build failed!"; exit 1; } +wait $FRONTEND_PID || { echo "✗ Frontend build failed!"; exit 1; } echo "✓ Images built" # [2/3] Start / restart stack with new images echo "[2/3] Starting application stack..." -docker compose -f "$COMPOSE_FILE" up -d --force-recreate +docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d --force-recreate echo "✓ Stack started" # [3/3] Health check echo "[3/3] Waiting for backend to be healthy..." -sleep 10 for i in $(seq 1 30); do if docker exec backend curl -sf http://localhost:3000/health > /dev/null 2>&1 || \ docker exec backend curl -sf http://localhost:3000/ping > /dev/null 2>&1; then @@ -66,7 +66,7 @@ for i in $(seq 1 30); do fi if [ "$i" -eq 30 ]; then echo "✗ Backend health check failed after 60s" - docker compose -f "$COMPOSE_FILE" logs backend --tail=50 + docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" logs backend --tail=50 exit 1 fi echo " Waiting... ($i/30)" diff --git a/scripts/perf/README.md b/scripts/perf/README.md new file mode 100644 index 0000000..2ec0993 --- /dev/null +++ b/scripts/perf/README.md @@ -0,0 +1,105 @@ +# Performance Verification Scripts + +## T048: ADR-021 Workflow Transition P95 SLA + +**Target:** `POST /workflow-engine/instances/:id/transition` (พร้อม file ≤ 10MB) ต้องตอบสนองภายใน **P95 ≤ 5 วินาที** (Clarify Q4) + +ครอบคลุม: ClamAV scan + Redlock acquire + DB transaction + +--- + +## Prerequisites + +### 1. ติดตั้ง k6 + +```powershell +# Windows (Chocolatey) +choco install k6 + +# หรือ Download binary จาก https://k6.io/docs/getting-started/installation +``` + +### 2. เตรียมข้อมูลทดสอบ + +ต้องมี workflow instance และ attachment ที่ commit แล้วพร้อมใช้: + +```bash +# ก. Login เพื่อเอา JWT token + user_id +curl -X POST http://localhost:3001/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"xxx"}' + +# ข. อัปโหลดไฟล์ 5MB PDF (Two-Phase: upload → commit) +curl -X POST http://localhost:3001/api/files/upload \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@test-5mb.pdf" +# จะได้ publicId + tempId + +curl -X POST http://localhost:3001/api/files/commit \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"tempId":""}' +# จะได้ publicId ที่ is_temporary=false + +# ค. สร้าง / หา workflow instance ในสถานะ PENDING_REVIEW หรือ PENDING_APPROVAL +# (ดู quickstart.md Step 3-4) +``` + +--- + +## Run Test + +```powershell +# กำหนด env vars +$env:BASE_URL = "http://localhost:3001" +$env:USERNAME = "admin" +$env:PASSWORD = "xxx" +$env:INSTANCE_ID = "" +$env:ATTACHMENT_UUID = "" + +# รัน smoke test (1 VU × 10 iterations) +k6 run scripts/perf/workflow-transition.k6.js +``` + +### ผลลัพธ์ที่คาดหวัง + +``` +✓ transition_duration_ms.............: p(95) < 5000 ✓ +✓ http_req_failed.....................: rate < 0.01 ✓ + +running (00m12.3s), 0/1 VUs, 10 complete and 0 interrupted iterations +``` + +--- + +## Manual Fallback (ถ้าไม่มี k6) + +รัน 10 ครั้งแล้วคำนวณ P95 เอง: + +```bash +for i in {1..10}; do + curl -X POST "http://localhost:3001/api/workflow-engine/instances/$INSTANCE_ID/transition" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Idempotency-Key: $(uuidgen)" \ + -H "Content-Type: application/json" \ + -d "{\"action\":\"APPROVE\",\"attachmentPublicIds\":[\"$ATTACHMENT_UUID\"]}" \ + -w "Request $i: %{time_total}s (HTTP %{http_code})\n" \ + -o /dev/null -s +done +``` + +ผลที่ยอมรับได้: +- ทุก request < 5.0s (P95 ≤ 5s) +- HTTP 2xx สำหรับ request แรก และ cached 200 สำหรับ request ถัดไปที่ใช้ key เดิม + +--- + +## Troubleshooting + +| อาการ | สาเหตุที่น่าจะเป็น | แก้ | +|---|---|---| +| P95 > 5s | ClamAV scan ช้า / Redis ล่าช้า | ตรวจ `docker stats` — ClamAV RAM, Redis connection pool | +| HTTP 409 | Instance อยู่ใน Terminal state | เตรียม instance ใหม่ใน PENDING_REVIEW | +| HTTP 503 | Redis ล่ม / Redlock timeout | ตรวจ Redis container health | +| HTTP 400 | Idempotency-Key missing | ตรวจ header ใน k6 script | +| HTTP 403 | User ไม่มีสิทธิ์ | ใช้ assigned handler หรือ superadmin | diff --git a/scripts/perf/workflow-transition.k6.js b/scripts/perf/workflow-transition.k6.js new file mode 100644 index 0000000..33cd3cc --- /dev/null +++ b/scripts/perf/workflow-transition.k6.js @@ -0,0 +1,128 @@ +// ADR-021 T048: Performance Verification — P95 ≤ 5s for POST /workflow-engine/instances/:id/transition +// Clarify Q4: file ≤ 10MB (รวม ClamAV scan + Redlock + DB transaction) +// +// ใช้งาน: +// k6 run --env BASE_URL=http://localhost:3001 \ +// --env USERNAME=admin --env PASSWORD=xxx \ +// --env INSTANCE_ID= \ +// --env ATTACHMENT_UUID= \ +// scripts/perf/workflow-transition.k6.js +// +// Prerequisite: +// 1. มี workflow instance ในสถานะ PENDING_REVIEW หรือ PENDING_APPROVAL +// 2. มีไฟล์แนบขนาด 5-10MB ที่ uploaded_by_user_id = 's user_id +// และ is_temporary = false (commit แล้วผ่าน Two-Phase) +// 3. User มีสิทธิ์เป็น assigned handler หรือ superadmin + +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js'; +import { Trend } from 'k6/metrics'; + +// Custom metric เฉพาะ transition endpoint (ไม่รวม login/upload) +const transitionDuration = new Trend('transition_duration_ms', true); + +export const options = { + scenarios: { + // Smoke test — 1 VU, 10 iterations = sample 10 transitions + smoke: { + executor: 'per-vu-iterations', + vus: 1, + iterations: 10, + maxDuration: '2m', + }, + }, + thresholds: { + // ADR-021 Clarify Q4: P95 ≤ 5000ms + 'transition_duration_ms': ['p(95) < 5000'], + 'http_req_failed': ['rate < 0.01'], // < 1% failure rate + }, +}; + +// ============================================================== +// Setup — authenticate ครั้งเดียว +// ============================================================== +export function setup() { + const baseUrl = __ENV.BASE_URL || 'http://localhost:3001'; + const username = __ENV.USERNAME; + const password = __ENV.PASSWORD; + const instanceId = __ENV.INSTANCE_ID; + const attachmentUuid = __ENV.ATTACHMENT_UUID; + + if (!username || !password || !instanceId || !attachmentUuid) { + throw new Error( + 'Missing env vars. Required: USERNAME, PASSWORD, INSTANCE_ID, ATTACHMENT_UUID' + ); + } + + const loginRes = http.post( + `${baseUrl}/api/auth/login`, + JSON.stringify({ username, password }), + { headers: { 'Content-Type': 'application/json' } } + ); + + check(loginRes, { + 'login successful': (r) => r.status === 200 || r.status === 201, + }); + + const body = loginRes.json(); + const token = body.accessToken || body.data?.accessToken || body.token; + if (!token) { + throw new Error(`Cannot extract token. Response: ${loginRes.body}`); + } + + return { baseUrl, token, instanceId, attachmentUuid }; +} + +// ============================================================== +// Default scenario — POST transition พร้อมไฟล์แนบ 1 ไฟล์ +// ============================================================== +export default function (data) { + const { baseUrl, token, instanceId, attachmentUuid } = data; + + // ป้องกัน idempotency cache hit — สร้าง key ใหม่ทุกครั้ง + const idempotencyKey = uuidv4(); + + const payload = JSON.stringify({ + action: 'APPROVE', + comment: `k6 perf test iter ${__ITER}`, + attachmentPublicIds: [attachmentUuid], + }); + + const params = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + 'Idempotency-Key': idempotencyKey, + }, + tags: { name: 'workflow-transition' }, + }; + + const start = Date.now(); + const res = http.post( + `${baseUrl}/api/workflow-engine/instances/${instanceId}/transition`, + payload, + params + ); + const duration = Date.now() - start; + + transitionDuration.add(duration); + + check(res, { + 'status is 2xx': (r) => r.status >= 200 && r.status < 300, + 'duration < 5s (P95 SLA)': () => duration < 5000, + }); + + if (res.status >= 400) { + console.error(`Iteration ${__ITER} failed: ${res.status} — ${res.body}`); + } + + sleep(1); // เว้นระหว่างแต่ละ iteration ให้ worker breathe +} + +// ============================================================== +// Teardown — รายงานผลสรุป (k6 auto-report ให้แล้ว ใช้นี้เมื่อต้องการ custom) +// ============================================================== +export function teardown(data) { + console.log(`Perf test done. Instance: ${data.instanceId}`); +} diff --git a/specs/06-Decision-Records/ADR-021-integrated-workflow-context.md .md b/specs/06-Decision-Records/ADR-021-integrated-workflow-context.md .md index 30fefee..b379eac 100644 --- a/specs/06-Decision-Records/ADR-021-integrated-workflow-context.md .md +++ b/specs/06-Decision-Records/ADR-021-integrated-workflow-context.md .md @@ -21,7 +21,8 @@ ### Session 2026-04-19 - **Q:** สถานะ Workflow ใดบ้างที่อนุญาตให้อัปโหลด Step-specific Attachment ได้? → **A:** เฉพาะสถานะ Active-decision เท่านั้น (`PENDING_REVIEW`, `PENDING_APPROVAL`) — ห้ามอัปโหลดในสถานะ Terminal (`APPROVED`, `REJECTED`, `CLOSED`) - **Q:** หาก Redis Redlock ล้มเหลวระหว่าง Transition ระบบควรทำอย่างไร? → **A:** Fail-closed — Retry 3 ครั้ง (500ms exponential backoff) แล้ว throw HTTP 503 "Service temporarily unavailable" เพื่อรักษาความถูกต้องของข้อมูล -- **Q:** Module ใดบ้างที่ต้องรองรับ Step-specific Attachments ใน v1.8.6? → **A:** เฉพาะ Module ที่ผ่าน Workflow Engine: **RFA, Transmittal, Circulation** — ไม่รวม Correspondence (ใช้ Circulation เป็น vehicle อยู่แล้ว หลีกเลี่ยงซ้ำซ้อน) +- **Q:** Module ใดบ้างที่ต้องรองรับ Step-specific Attachments ใน v1.8.6? → **A:** ~~เฉพาะ Module ที่ผ่าน Workflow Engine: **RFA, Transmittal, Circulation** — ไม่รวม Correspondence~~ **[REVERSED ในรอบ refinement — ดูด้านล่าง]** +- **Q (Revised 2026-04-19 v2):** Module scope สุดท้ายคืออะไร? → **A:** **RFA, Transmittal, Circulation, Correspondence ทั้ง 4 module** — เหตุผล: Correspondence detail page มีการ integrate แล้วในการ implement รอบก่อน (T014/T020/T035) การ revert จะสร้าง regression โดยไม่จำเป็น + Correspondence มี workflow instance ของตัวเองผ่าน `CORRESPONDENCE_FLOW` DSL ไม่ได้ routing ผ่าน Circulation 100% - **Q:** Performance target ของ Upload + Transition API คืออะไร? → **A:** P95 ≤ 5 วินาที สำหรับ file ≤ 10MB (ClamAV scan + Redlock + DB transaction included) - **Q:** Definition of Done สำหรับ REQ-01 ถึง REQ-06 คืออะไร? → **A:** กำหนด Observable Outcome 1 ประโยคต่อ REQ ที่ตรวจสอบได้โดย QA/Product Owner โดยไม่ต้องอ่าน code @@ -134,7 +135,8 @@ - *Action:* Refactor Header เป็น Integrated Banner และเพิ่ม Tab Workflow Lifecycle - `frontend/app/(dashboard)/circulation/[uuid]/page.tsx` - *Action:* Refactor Header เป็น Integrated Banner และเพิ่ม Tab Workflow Lifecycle -- **หมายเหตุ (Out of Scope v1.8.6):** `correspondences/[uuid]/page.tsx` — ไม่รวมใน Scope นี้ Correspondence ใช้ Circulation เป็น Routing Vehicle อยู่แล้ว +- `frontend/app/(dashboard)/correspondences/[uuid]/page.tsx` + - *Action:* Refactor Header เป็น Integrated Banner และเพิ่ม Tab Workflow Lifecycle (Re-included via Clarify v2 2026-04-19) - `frontend/components/workflow/workflow-visualizer.tsx` (สร้างใหม่) - *Action:* พัฒนาการแสดงผล Vertical Timeline พร้อมระบบสี Active/Inactive - `frontend/components/common/file-preview-modal.tsx` (สร้างใหม่) diff --git a/specs/08-Tasks/ADR-021-workflow-context/plan.md b/specs/08-Tasks/ADR-021-workflow-context/plan.md index f1dc7d4..1c482a3 100644 --- a/specs/08-Tasks/ADR-021-workflow-context/plan.md +++ b/specs/08-Tasks/ADR-021-workflow-context/plan.md @@ -110,10 +110,10 @@ frontend/hooks/ └── use-workflow-action.ts [NEW — upload + transition orchestration] # 🟡 Frontend — Page Refactors (use new components) -frontend/app/(dashboard)/rfas/[uuid]/page.tsx [MODIFY — integrate IntegratedBanner + WorkflowLifecycle] -frontend/app/(dashboard)/transmittals/[uuid]/page.tsx [MODIFY — same as RFA] -frontend/app/(dashboard)/circulation/[uuid]/page.tsx [MODIFY — same as RFA] -# ⛔ OUT OF SCOPE (v1.8.6): correspondences/[uuid]/page.tsx — Correspondence ใช้ Circulation เป็น Routing Vehicle (Clarify Q3) +frontend/app/(dashboard)/rfas/[uuid]/page.tsx [MODIFY — integrate IntegratedBanner + WorkflowLifecycle] +frontend/app/(dashboard)/transmittals/[uuid]/page.tsx [MODIFY — same as RFA] +frontend/app/(dashboard)/circulation/[uuid]/page.tsx [MODIFY — same as RFA] +frontend/app/(dashboard)/correspondences/[uuid]/page.tsx [MODIFY — same as RFA] (Re-included v2 2026-04-19) ``` --- @@ -199,7 +199,7 @@ Response: WorkflowHistoryItem[] with nested attachments[] per step 5. Handle HTTP 503 (Redlock unavailable) → แสดง toast "ระบบยุ่ง กรุณาลองใหม่" 6. Invalidate TanStack Query cache for the document + workflow instance -**Modules in scope (v1.8.6):** RFA, Transmittal, Circulation — ไม่รวม Correspondence (Clarify Q3) +**Modules in scope (v1.8.6):** RFA, Transmittal, Circulation, Correspondence (4 modules) — Clarify Q3 v2 (2026-04-19 revised) --- @@ -231,7 +231,7 @@ Response: WorkflowHistoryItem[] with nested attachments[] per step | F7 | Refactor RFA detail page — integrate new components | `rfas/[uuid]/page.tsx` | F3–F6 | | F8 | Refactor Transmittal detail page — integrate new components | `transmittals/[uuid]/page.tsx` | F3–F6 | | F9 | Refactor Circulation detail page — integrate new components | `circulation/[uuid]/page.tsx` | F3–F6 | -| ~~F10~~ | ~~Correspondence~~ | **OUT OF SCOPE v1.8.6** — Clarify Q3 | — | +| F10 | Refactor Correspondence detail page — integrate new components | `correspondences/[uuid]/page.tsx` | F3–F6 | ### 🟢 GUIDELINES (after F7/F8) diff --git a/specs/08-Tasks/ADR-021-workflow-context/research.md b/specs/08-Tasks/ADR-021-workflow-context/research.md index 464345c..d6d3d8e 100644 --- a/specs/08-Tasks/ADR-021-workflow-context/research.md +++ b/specs/08-Tasks/ADR-021-workflow-context/research.md @@ -271,4 +271,4 @@ for (let attempt = 1; attempt <= MAX_LOCK_RETRIES; attempt++) { | 9 | File preview security | `Content-Disposition: inline` + permission check | Direct storage URL | | 10 | Redlock failure mode | Fail-closed: Retry 3x (500ms backoff) → HTTP 503 | Fail-open | | 11 | Upload-permitted states | `PENDING_REVIEW`, `PENDING_APPROVAL` only | All non-terminal states | -| 12 | Module scope (v1.8.6) | RFA, Transmittal, Circulation | Including Correspondence | +| 12 | Module scope (v1.8.6) | **RFA, Transmittal, Circulation, Correspondence** (v2 Revised 2026-04-19) | ~~Excluding Correspondence~~ | diff --git a/specs/08-Tasks/ADR-021-workflow-context/tasks.md b/specs/08-Tasks/ADR-021-workflow-context/tasks.md index 961a993..fc3eb5c 100644 --- a/specs/08-Tasks/ADR-021-workflow-context/tasks.md +++ b/specs/08-Tasks/ADR-021-workflow-context/tasks.md @@ -3,7 +3,7 @@ **Input**: Design documents from `specs/08-Tasks/ADR-021-workflow-context/` **ADR**: `specs/06-Decision-Records/ADR-021-integrated-workflow-context.md .md` **Branch**: `feat/adr-021-integrated-workflow-context` -**Version**: 1.8.6 | **Date**: 2026-04-12 +**Version**: 1.8.6 | **Date**: 2026-04-12 | **Amended**: 2026-04-19 (Clarify Q1-Q5) **Prerequisites**: plan.md ✅ | research.md ✅ | data-model.md ✅ | contracts/ ✅ | quickstart.md ✅ @@ -23,13 +23,15 @@ |-------|----------|-------------|-----| | **US1** | P1 🎯 MVP | Integrated Banner — single-row metadata + status + actions | REQ-01 | | **US2** | P1 🎯 MVP | Workflow Lifecycle Visualization — vertical timeline + active step | REQ-02, REQ-03 | -| **US3** | P2 | Step-specific Attachments — drag & drop upload linked to workflow step | REQ-04 | +| **US3** | P2 | Step-specific Attachments — drag & drop upload linked to workflow step (PENDING_REVIEW/PENDING_APPROVAL only) | REQ-04 | | **US4** | P2 | Internal File Preview — PDF/Image modal without tab switch | REQ-05 | | **US5** | P3 | i18n Support — all UI text via i18n keys | REQ-06 | +**Module Scope (v1.8.6):** RFA, Transmittal, Circulation, Correspondence (4 modules — Clarify Q3 v2 2026-04-19 Revised: re-included Correspondence) + --- -## Phase 1: Setup +## Phase 1: Setup /ไม่ทำ T001 **Purpose**: Branch and project initialization @@ -75,11 +77,11 @@ cd frontend && pnpm test --run --reporter=verbose components/workflow/integrated - [x] T011 [P] [US1] Add `WorkflowTransitionWithAttachmentsDto` interface to `frontend/types/dto/workflow-engine/workflow-engine.dto.ts` (data-model.md §5.2) - [x] T012 [US1] Create `frontend/components/workflow/integrated-banner.tsx` — props: `{ documentNo, subject, status, priority?, currentState, availableActions, onAction, isLoading? }`, render Priority badge with Tailwind color map from research.md §8 (URGENT=red, HIGH=orange, MEDIUM=yellow, LOW=green), render `WorkflowActionButtons` per `availableActions` array - [x] T013 [US1] Update `frontend/app/(dashboard)/rfas/[uuid]/page.tsx` — replace existing header section with `` using RFA data fields (quickstart.md Step 10) -- [x] T014 [US1] Update `frontend/app/(dashboard)/correspondences/[uuid]/page.tsx` — same integration as T013 using Correspondence data fields +- [x] T014 [US1] Update `frontend/app/(dashboard)/correspondences/[uuid]/page.tsx` — same integration as T013 using Correspondence data fields (Re-included via Clarify v2 2026-04-19) - [x] T015 [US1] Update `frontend/app/(dashboard)/transmittals/[uuid]/page.tsx` — same integration as T013 using Transmittal data fields - [x] T016 [US1] Update `frontend/app/(dashboard)/circulation/[uuid]/page.tsx` — same integration as T013 using Circulation data fields -**Checkpoint**: `IntegratedBanner` renders correctly on RFA and Correspondence detail pages. Priority badge and action buttons visible. `pnpm tsc --noEmit` passes. +**Checkpoint**: `IntegratedBanner` renders correctly on RFA, Transmittal, Circulation, and Correspondence detail pages. Priority badge and action buttons visible. Buttons disabled when `currentState ∈ {APPROVED, REJECTED, CLOSED}`. `pnpm tsc --noEmit` passes. --- @@ -101,11 +103,11 @@ cd frontend && pnpm test --run components/workflow/workflow-lifecycle - [x] T017 [P] [US2] Create `frontend/components/workflow/workflow-lifecycle.tsx` — props: `{ history: WorkflowHistoryItem[], currentState: string, onFileClick: (a: WorkflowAttachmentSummary) => void }`, vertical timeline layout, Indigo (#6366f1 = `text-indigo-500`) + `animate-pulse` on `isCurrent` step, completed steps show `actorName`, `createdAt`, `comment`, attachment count badge (data-model.md §5.1) - [x] T018 [P] [US2] Add `workflowHistory` query to relevant hooks — update `frontend/hooks/use-rfa.ts`, `frontend/hooks/use-correspondence.ts`, `frontend/hooks/use-transmittal.ts`, and `frontend/hooks/use-circulation.ts` to fetch `GET /workflow-engine/instances/:id/history` using TanStack Query key `['workflow-history', instanceId]` - [x] T019 [US2] Add `WorkflowLifecycle` tab to `frontend/app/(dashboard)/rfas/[uuid]/page.tsx` inside existing `` (or add Tabs if missing) — pass `rfa.workflowHistory` and `currentState` props -- [x] T020 [US2] Add `WorkflowLifecycle` tab to `frontend/app/(dashboard)/correspondences/[uuid]/page.tsx` — same as T019 +- [x] T020 [US2] Add `WorkflowLifecycle` tab to `frontend/app/(dashboard)/correspondences/[uuid]/page.tsx` — same as T019 (Re-included via Clarify v2 2026-04-19) - [x] T021 [US2] Add `WorkflowLifecycle` tab to `frontend/app/(dashboard)/transmittals/[uuid]/page.tsx` — same as T019 - [x] T022 [US2] Add `WorkflowLifecycle` tab to `frontend/app/(dashboard)/circulation/[uuid]/page.tsx` — same as T019 -**Checkpoint**: Workflow tab visible on RFA/Correspondence pages. Current step Indigo+pulse. Completed steps show actor/date. No TypeScript errors. `pnpm lint` passes. +**Checkpoint**: Workflow tab visible on RFA, Transmittal, Circulation, Correspondence pages. Current step Indigo+pulse. Completed steps show actor/date. No TypeScript errors. `pnpm lint` passes. --- @@ -129,17 +131,28 @@ cd frontend && pnpm test --run hooks/use-workflow-action ### Implementation for User Story 3 -- [x] T023 [US3] Extend `backend/src/modules/workflow-engine/workflow-engine.service.ts` — add `attachmentPublicIds: string[] = []` parameter to `processTransition()`, after `queryRunner.commitTransaction()` run bulk UPDATE to set `workflow_history_id = history.id` WHERE `uuid IN (:publicIds) AND is_temporary = false` (quickstart.md Step 5; data-model.md §6) +- [x] T023 [US3] Extend `backend/src/modules/workflow-engine/workflow-engine.service.ts` — add `attachmentPublicIds: string[] = []` parameter to `processTransition()`; after `queryRunner.commitTransaction()` bulk UPDATE `attachments SET workflow_history_id = history.id` WHERE `uuid IN (:publicIds) AND is_temporary = false` (quickstart.md Step 5; data-model.md §6) +- [x] T023a [US3] Add server-side upload state check at top of `processTransition()` (before Redlock acquire) — if `currentState ∈ {APPROVED, REJECTED, CLOSED}` and `attachmentPublicIds.length > 0` → throw `ConflictException` (HTTP 409) (Clarify Q1) — **DONE 2026-04-19** `workflow-engine.service.ts:338-362` (+`UPLOAD_ALLOWED_STATES` static) +- [x] T023b [US3] Add Redis Redlock retry logic in `processTransition()` — Retry 3x (500ms backoff + 100ms jitter); if all retries fail → throw `ServiceUnavailableException` (HTTP 503 Fail-closed) (Clarify Q2) — **DONE 2026-04-19** `workflow-engine.service.ts:57,80-88,364-380,527-535` + `@InjectRedis()` +- [x] T023c [US3] **BONUS (C2)** Add attachment ownership + temp + relink guards to bulk UPDATE: `isTemporary=false AND uploadedByUserId=userId AND workflowHistoryId IS NULL`; rollback if `affected !== expected` — **DONE 2026-04-19** `workflow-engine.service.ts:452-484` - [x] T024 [US3] Add `getHistoryWithAttachments(instanceId: string)` method to `backend/src/modules/workflow-engine/workflow-engine.service.ts` — query `workflow_histories` WHERE `instance_id = :id` ORDER BY `created_at ASC`, eager-load attachments via LEFT JOIN, apply Redis cache key `wf:history:{instanceId}` TTL 3600s, invalidate on `processTransition()` success (research.md §6; contracts/workflow-transition.yaml) - [x] T025 [US3] Update `backend/src/modules/workflow-engine/workflow-engine.controller.ts` — add `@Headers('Idempotency-Key')` validation to `processTransition()` endpoint (throw `BadRequestException` if missing), add Redis idempotency check/store with key `idempotency:transition:{key}:{userId}` TTL 86400, swap `RbacGuard` for `WorkflowTransitionGuard` on transition endpoint (quickstart.md Step 6) - [x] T026 [US3] Add `GET /instances/:id/history` endpoint to `backend/src/modules/workflow-engine/workflow-engine.controller.ts` — decorated with `@RequirePermission('document.view')`, calls `workflowService.getHistoryWithAttachments(instanceId)` (contracts/workflow-transition.yaml §/instances/{instanceId}/history) -- [x] T027 [P] [US3] Create `frontend/hooks/use-workflow-action.ts` — generates UUIDv7 idempotency key once per action intent (via `useState`), calls `workflowEngineService.transition()` with `Idempotency-Key` header, on success invalidates TanStack Query keys `['workflow-history', instanceId]` + parent document queries (quickstart.md Step 8) -- [x] T028 [P] [US3] Add drag-and-drop file upload zone to `frontend/components/workflow/workflow-lifecycle.tsx` — renders only on `isCurrent` step, uses `` + drag events, calls existing Two-Phase upload service on drop, accumulates `publicId`s in local state, passes to `useWorkflowAction` on submit +- [x] T027 [P] [US3] Create `frontend/hooks/use-workflow-action.ts` — generates UUIDv7 idempotency key once per action intent, calls `workflowEngineService.transition()` with `Idempotency-Key` header, on success invalidates TanStack Query keys; **client-side guard**: check `currentState ∈ {PENDING_REVIEW, PENDING_APPROVAL}` before API call (Clarify Q1) +- [x] T027a [P] [US3] Update `frontend/hooks/use-workflow-action.ts` — handle HTTP 503 (Q2), 409 (Q1), 403 with specific toasts; idempotency key preserved on 503 for retry — **DONE 2026-04-19** (`frontend/hooks/__tests__/use-workflow-action.test.ts`: 5/5 tests passing) +- [x] T028 [P] [US3] Add drag-and-drop file upload zone to `frontend/components/workflow/workflow-lifecycle.tsx` — **renders ONLY when `currentState ∈ {PENDING_REVIEW, PENDING_APPROVAL}`** (disable in Terminal states), uses `` + drag events, calls Two-Phase upload service on drop (Clarify Q1) - [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] 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, Redlock contention throws 409 +- [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): + - C3: upload in `APPROVED` state → `ConflictException` 409 + - C3: upload in `REJECTED` state → `ConflictException` 409 + - C3: skip state check when no attachments (backward compat) + - C1: Redlock acquire fail → `ServiceUnavailableException` 503 (**ไม่ใช่ 409**) + - C2: `affected < expected` → `WorkflowException` + rollback + Redlock release + - C1: Redlock release สำเร็จแม้ transition ไม่โยนค่า -**Checkpoint**: POST transition with `attachmentPublicIds` succeeds. `attachment.workflow_history_id` set in DB. Duplicate `Idempotency-Key` returns cached response. Unauthorized user gets 403. Backend unit tests pass ≥80% coverage on new logic. +**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. --- @@ -164,7 +177,7 @@ cd frontend && pnpm test --run components/common/file-preview-modal - [x] T032 [P] [US4] Verify `backend/src/common/file-storage/file-storage.controller.ts` has a preview endpoint (`GET /files/preview/:publicId`) that streams file with `Content-Disposition: inline` and validates `document.view` permission — if missing, add it to `file-storage.controller.ts` and `file-storage.service.ts` - [x] T033 [P] [US4] Create `frontend/components/common/file-preview-modal.tsx` — props: `{ attachment: WorkflowAttachmentSummary | null, onClose: () => void }`, detect `mimeType` to render `