690414:1113 Update README.md /.agents/skills, /.windsurf/workflows

This commit is contained in:
2026-04-14 11:13:42 +07:00
parent 02400fd88c
commit 6d45bdaeb5
194 changed files with 12708 additions and 8762 deletions
@@ -0,0 +1,402 @@
# 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