Files

11 KiB
Raw Permalink Blame History

Quickstart: ADR-021 Implementation Guide

Phase 1 Output | Generated: 2026-04-12 | Branch: feat/adr-021-integrated-workflow-context


Prerequisites

  • backend/ and frontend/ dev servers running
  • MariaDB accessible
  • Redis running (Redlock + Cache)
  • ClamAV running (file upload pipeline)

Step 1: Apply SQL Delta (🔴 CRITICAL FIRST)

# Apply delta to DB before touching any entity code
# ตรวจสอบ DB connection ก่อน

mysql -h <DB_HOST> -u <USER> -p <DB_NAME> < \
  specs/03-Data-and-Storage/deltas/04-add-workflow-history-id-to-attachments.sql

Verify:

DESCRIBE attachments;
-- ต้องเห็น workflow_history_id CHAR(36) NULL
SHOW INDEX FROM attachments;
-- ต้องเห็น idx_att_wfhist_created

Step 2: Backend — Entity Updates (T2, T3)

T2: attachment.entity.ts

เพิ่มหลัง referenceDate column:

@Column({ name: 'workflow_history_id', length: 36, nullable: true })
workflowHistoryId?: string;

@ManyToOne(
  () => WorkflowHistory,
  (history: WorkflowHistory) => history.attachments,
  { nullable: true, onDelete: 'SET NULL', lazy: true }
)
@JoinColumn({ name: 'workflow_history_id' })
workflowHistory?: Promise<WorkflowHistory>;

เพิ่ม import:

import { WorkflowHistory } from '../../../modules/workflow-engine/entities/workflow-history.entity';

T3: workflow-history.entity.ts

เพิ่มหลัง createdAt:

@OneToMany(
  () => Attachment,
  (attachment: Attachment) => attachment.workflowHistory,
  { lazy: true }
)
attachments?: Promise<Attachment[]>;

เพิ่ม import:

import { OneToMany } from 'typeorm';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';

Step 3: Backend — DTO Update (T4)

workflow-transition.dto.ts

เพิ่มใน WorkflowTransitionDto:

@ApiPropertyOptional({
  description: 'รายการ publicId ของไฟล์แนบ (ต้องอัปโหลดผ่าน Two-Phase ก่อน)',
  type: [String],
})
@IsArray()
@IsUUID('all', { each: true })
@ArrayMaxSize(20)
@IsOptional()
attachmentPublicIds?: string[];

Step 4: Backend — Guard (T5)

สร้าง backend/src/modules/workflow-engine/guards/workflow-transition.guard.ts:

// Guard ตรวจสอบสิทธิ์ 4-Level RBAC ตาม ADR-021 §6
// Level 1: system.manage_all (Superadmin)
// Level 2: organization.manage_users + same org
// Level 3: Assigned handler (context.userId matches req.user.user_id)
// Level 4: Read-only → FORBIDDEN

@Injectable()
export class WorkflowTransitionGuard implements CanActivate {
  constructor(
    private readonly caslFactory: CaslAbilityFactory,
    @InjectRepository(WorkflowInstance)
    private readonly instanceRepo: Repository<WorkflowInstance>,
    private readonly logger = new Logger(WorkflowTransitionGuard.name)
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<RequestWithUser>();
    const instanceId = request.params['id'];
    const user = request.user;

    // Level 1: Superadmin bypass
    if (user.permissions?.includes('system.manage_all')) {
      return true;
    }

    // ดึง Instance เพื่อตรวจสอบ Context
    const instance = await this.instanceRepo.findOne({
      where: { id: instanceId },
    });

    if (!instance) {
      throw new NotFoundException('Workflow Instance', instanceId);
    }

    // Level 2: Org Admin (organization.manage_users + same org)
    const docOrgId = instance.context?.organizationId as number | undefined;
    if (
      user.permissions?.includes('organization.manage_users') &&
      docOrgId &&
      user.organizationId === docOrgId
    ) {
      return true;
    }

    // Level 3: Assigned Handler
    const assignedUserId = instance.context?.assignedUserId as number | undefined;
    if (assignedUserId && user.user_id === assignedUserId) {
      return true;
    }

    this.logger.warn(
      `Unauthorized transition attempt: User ${user.user_id} on Instance ${instanceId}`
    );
    throw new ForbiddenException({
      userMessage: 'คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้',
      recoveryAction: 'ติดต่อผู้รับผิดชอบหรือ Admin',
    });
  }
}

Step 5: Backend — Service Extension (T6)

ใน WorkflowEngineService.processTransition() — เพิ่มหลัง queryRunner.commitTransaction():

// Link attachments ไปยัง history record (ถ้ามี)
if (attachmentPublicIds && attachmentPublicIds.length > 0) {
  await queryRunner.manager
    .createQueryBuilder()
    .update(Attachment)
    .set({ workflowHistoryId: history.id })
    .where('uuid IN (:...publicIds)', { publicIds: attachmentPublicIds })
    .andWhere('is_temporary = :temp', { temp: false })  // ต้องเป็น committed files เท่านั้น
    .execute();
}

Signature change:

async processTransition(
  instanceId: string,
  action: string,
  userId: number,
  comment?: string,
  payload: Record<string, unknown> = {},
  attachmentPublicIds: string[] = []   // NEW parameter
): Promise<WorkflowTransitionResponseDto>

Step 6: Backend — Controller Update (T7)

// เพิ่ม Idempotency-Key header validation + guard + history endpoint

// 1. เพิ่ม Guard บน transition endpoint
@Post('instances/:id/transition')
@UseGuards(JwtAuthGuard, WorkflowTransitionGuard)
async processTransition(
  @Param('id') instanceId: string,
  @Body() dto: WorkflowTransitionDto,
  @Headers('Idempotency-Key') idempotencyKey: string,
  @Request() req: RequestWithUser
) {
  if (!idempotencyKey) {
    throw new BadRequestException({
      userMessage: 'กรุณาระบุ Idempotency-Key header',
      errorCode: 'MISSING_IDEMPOTENCY_KEY',
    });
  }

  // ตรวจสอบ Redis idempotency key
  const cached = await this.redisService.get(
    `idempotency:transition:${idempotencyKey}:${req.user.user_id}`
  );
  if (cached) {
    return JSON.parse(cached);
  }

  const result = await this.workflowService.processTransition(
    instanceId,
    dto.action,
    req.user.user_id,
    dto.comment,
    dto.payload,
    dto.attachmentPublicIds ?? []
  );

  // บันทึก idempotency key (TTL 24h)
  await this.redisService.set(
    `idempotency:transition:${idempotencyKey}:${req.user.user_id}`,
    JSON.stringify(result),
    86400
  );

  return result;
}

// 2. New history endpoint
@Get('instances/:id/history')
@RequirePermission('document.view')
async getInstanceHistory(@Param('id') instanceId: string) {
  return this.workflowService.getHistoryWithAttachments(instanceId);
}

Step 7: Frontend — Types (F1, F2)

อัปเดต frontend/types/workflow.ts และ frontend/types/dto/workflow-engine/workflow-engine.dto.ts ตาม data-model.md ส่วน 5.


Step 8: Frontend — use-workflow-action Hook (F3)

// frontend/hooks/use-workflow-action.ts
export function useWorkflowAction(instanceId: string) {
  const queryClient = useQueryClient();
  const [idempotencyKey] = useState(() => generateUUIDv7()); // สร้าง 1 ครั้งต่อ action intent

  const mutation = useMutation({
    mutationFn: async (dto: WorkflowTransitionWithAttachmentsDto) => {
      return workflowEngineService.transition(instanceId, dto, idempotencyKey);
    },
    onSuccess: () => {
      // Invalidate cache สำหรับ document + history
      void queryClient.invalidateQueries({ queryKey: ['workflow-history', instanceId] });
      // Invalidate parent document queries (RFA, Correspondence, etc.)
      void queryClient.invalidateQueries({ queryKey: ['rfa'] });
      void queryClient.invalidateQueries({ queryKey: ['correspondence'] });
    },
  });

  return { execute: mutation.mutateAsync, isPending: mutation.isPending };
}

Step 9: Frontend — Components (F4F6)

IntegratedBanner Props:

interface IntegratedBannerProps {
  documentNo: string;
  subject: string;
  status: string;
  priority?: WorkflowPriority;
  currentState: string;
  availableActions: string[];
  onAction: (action: string, comment?: string, files?: string[]) => void;
  isLoading?: boolean;
}

WorkflowLifecycle Props:

interface WorkflowLifecycleProps {
  history: WorkflowHistoryItem[];
  currentState: string;
  onFileClick: (attachment: WorkflowAttachmentSummary) => void;
}

FilePreviewModal Props:

interface FilePreviewModalProps {
  attachment: WorkflowAttachmentSummary | null;
  onClose: () => void;
}

Step 10: Page Refactors (F7, F8)

ตัวอย่าง RFA detail page integration:

// frontend/app/(dashboard)/rfas/[uuid]/page.tsx
export default function RFADetailPage() {
  const { uuid } = useParams();
  const { data: rfa, isLoading } = useRFA(String(uuid));
  const [previewFile, setPreviewFile] = useState<WorkflowAttachmentSummary | null>(null);
  const { execute, isPending } = useWorkflowAction(rfa?.workflowInstanceId ?? '');

  return (
    <>
      <IntegratedBanner
        documentNo={rfa?.rfaNo ?? ''}
        subject={rfa?.subject ?? ''}
        status={rfa?.status ?? ''}
        priority={rfa?.priority}
        currentState={rfa?.workflowState ?? ''}
        availableActions={rfa?.availableActions ?? []}
        onAction={(action, comment, files) =>
          execute({ action, comment, attachmentPublicIds: files })
        }
        isLoading={isPending}
      />
      <Tabs>
        <TabsContent value="workflow">
          <WorkflowLifecycle
            history={rfa?.workflowHistory ?? []}
            currentState={rfa?.workflowState ?? ''}
            onFileClick={setPreviewFile}
          />
        </TabsContent>
      </Tabs>
      <FilePreviewModal attachment={previewFile} onClose={() => setPreviewFile(null)} />
    </>
  );
}

Testing Checklist

# Backend unit tests
cd backend
pnpm test --testPathPattern=workflow-engine.service
pnpm test --testPathPattern=workflow-transition.guard

# Frontend component tests
cd frontend
pnpm test --run --reporter=verbose

# TypeScript check (both)
cd backend && pnpm tsc --noEmit
cd frontend && pnpm tsc --noEmit

# Lint (both)
cd backend && pnpm lint
cd frontend && pnpm lint

Security Pre-commit Checklist

  • workflow_history_id FK applied and verified in DB
  • attachmentPublicIds validates UUID format + is_temporary = false
  • Idempotency-Key header required — missing returns 400
  • WorkflowTransitionGuard blocks Level 4 users with 403
  • ClamAV test: EICAR test file blocked before transition
  • No parseInt() on any UUID in new code
  • No console.log in committed code
  • Comments in Thai, identifiers in English
  • TypeScript strict — no any types