11 KiB
11 KiB
Quickstart: ADR-021 Implementation Guide
Phase 1 Output | Generated: 2026-04-12 | Branch: feat/adr-021-integrated-workflow-context
Prerequisites
backend/andfrontend/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 (F4–F6)
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_idFK applied and verified in DBattachmentPublicIdsvalidates UUID format +is_temporary = falseIdempotency-Keyheader required — missing returns400WorkflowTransitionGuardblocks Level 4 users with403- ClamAV test: EICAR test file blocked before transition
- No
parseInt()on any UUID in new code - No
console.login committed code - Comments in Thai, identifiers in English
- TypeScript strict — no
anytypes