Files

403 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```bash
# 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:**
```sql
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:
```typescript
@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:
```typescript
import { WorkflowHistory } from '../../../modules/workflow-engine/entities/workflow-history.entity';
```
### T3: `workflow-history.entity.ts`
เพิ่มหลัง `createdAt`:
```typescript
@OneToMany(
() => Attachment,
(attachment: Attachment) => attachment.workflowHistory,
{ lazy: true }
)
attachments?: Promise<Attachment[]>;
```
เพิ่ม import:
```typescript
import { OneToMany } from 'typeorm';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
```
---
## Step 3: Backend — DTO Update (T4)
### `workflow-transition.dto.ts`
เพิ่มใน `WorkflowTransitionDto`:
```typescript
@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`:
```typescript
// 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()`:
```typescript
// 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:**
```typescript
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)
```typescript
// เพิ่ม 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)
```typescript
// 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:
```typescript
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:
```typescript
interface WorkflowLifecycleProps {
history: WorkflowHistoryItem[];
currentState: string;
onFileClick: (attachment: WorkflowAttachmentSummary) => void;
}
```
### `FilePreviewModal` Props:
```typescript
interface FilePreviewModalProps {
attachment: WorkflowAttachmentSummary | null;
onClose: () => void;
}
```
---
## Step 10: Page Refactors (F7, F8)
ตัวอย่าง RFA detail page integration:
```tsx
// 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
```bash
# 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