Files
lcbp3/specs/08-Tasks/ADR-021-workflow-context/research.md
T
admin 733f3c3987
CI / CD Pipeline / build (push) Successful in 9m10s
CI / CD Pipeline / deploy (push) Failing after 4m10s
690419:1411 feat: update CI/CD to use SSH key authentication #05
2026-04-19 14:11:51 +07:00

275 lines
12 KiB
Markdown

# Research: ADR-021 Integrated Workflow Context & Step-specific Attachments
**Phase 0 Output** | Generated: 2026-04-12
---
## Research Question 1: File Attachment Strategy During Workflow Transition
**Question:** Should files be uploaded atomically with the transition (multipart), or pre-uploaded and referenced by ID?
### Option A: Upload-Then-Reference (Selected ✅)
Client uploads files first via existing Two-Phase upload endpoint → receives `publicId`s → sends `publicId`s in `WorkflowTransitionDto.attachmentPublicIds`.
**Pros:**
- ClamAV scan and file validation run **independently** before the transition DB transaction
- Simpler transaction boundary (no file I/O inside Redlock + DB transaction)
- Compatible with existing `file-storage.service.ts` Two-Phase pattern (ADR-016)
- Incremental upload UX (user can upload as they review, then click Approve)
- No multipart complexity in transition controller
**Cons:**
- Two API round-trips from client
- Orphaned temp files possible if user abandons without transitioning (mitigated by existing `FileCleanupService` cron job)
### Option B: Multipart Transition Request (Rejected ❌)
Single multipart request combining DTO JSON + files.
**Cons:**
- ClamAV scan must happen inside the Redlock-protected transaction — high latency risk
- Breaks ADR-016 Two-Phase Upload contract
- Controller complexity (Multer + DTO parsing + ClamAV + Redlock + TypeORM transaction)
**Decision:** Option A — Upload-Then-Reference.
**Rationale:** Consistent with ADR-016 Two-Phase and existing `file-storage.controller.ts` patterns. Zero change to ClamAV pipeline.
---
## Research Question 2: FK Structure for Step-Specific Attachments
**Question:** Add `workflow_history_id` directly to `attachments` table, or create a junction table `workflow_history_attachments`?
### Option A: Direct FK on `attachments` (Selected ✅)
```sql
ALTER TABLE attachments
ADD COLUMN workflow_history_id CHAR(36) NULL;
```
**Pros:**
- Specified explicitly in ADR-021 §5
- Single JOIN to get step attachments
- Backward-compatible: existing records have `NULL` (treated as main-doc attachments)
- Simpler query: `WHERE workflow_history_id = :historyId`
**Cons:**
- `attachments` table grows slightly (one nullable column)
### Option B: Junction Table `workflow_history_attachments` (Rejected ❌)
```sql
CREATE TABLE workflow_history_attachments (
history_id CHAR(36) NOT NULL,
attachment_id INT NOT NULL,
PRIMARY KEY (history_id, attachment_id)
);
```
**Cons:**
- Extra table + extra JOIN
- ADR-021 explicitly mandates Approach A
- More complex service code
**Decision:** Option A — Direct FK on `attachments` table.
**Rationale:** Explicitly mandated by ADR-021 §5 §6. Nullable FK provides backward compatibility.
---
## Research Question 3: `workflow_histories.id` UUID Type
**Finding from codebase:**
`@/e:/np-dms/lcbp3/backend/src/modules/workflow-engine/entities/workflow-history.entity.ts:22-23`
```typescript
@PrimaryGeneratedColumn('uuid')
id!: string;
```
**Conclusion:** `workflow_histories.id` is `CHAR(36)` UUID generated by TypeORM `@PrimaryGeneratedColumn('uuid')`. This is **NOT** `UuidBaseEntity` pattern (which uses INT PK + `uuid` column separately).
**Impact on FK:** `attachments.workflow_history_id` must be `CHAR(36)` to match.
**ADR-019 compliance:** `WorkflowHistory` does NOT have the standard `publicId`/`@Exclude() id` pattern — it uses UUID as its primary key directly. When exposing history via API, use `id` (which is already a UUID string). No conversion needed.
---
## Research Question 4: Existing Workflow Visualizer — Reuse vs. New
**Finding:**
`@/e:/np-dms/lcbp3/frontend/components/custom/workflow-visualizer.tsx:26-28`
```typescript
// WorkflowVisualizer Component
// แสดงเส้นเวลา (Timeline) ของกระบวนการอนุมัติแบบแนวนอน
export function WorkflowVisualizer({ steps, className }: WorkflowVisualizerProps)
```
The existing component is **horizontal** layout. ADR-021 §3.2 requires **Vertical Timeline** with Indigo active highlighting.
**Decision:** Create NEW `frontend/components/workflow/workflow-lifecycle.tsx` (vertical). Keep existing `workflow-visualizer.tsx` (horizontal) for backward compat on other pages.
**Interface Difference:**
- Existing: `WorkflowStep { id, label, subLabel, status, date }`
- New: `WorkflowHistoryStep { id, fromState, toState, action, actor, comment, date, attachments[], isCurrent }`
---
## Research Question 5: Idempotency-Key Implementation
**Existing pattern:** No existing idempotency implementation found in workflow engine.
**Design:**
- Key format: `idempotency:transition:{idempotencyKey}:{userId}`
- Storage: Redis `SET` with `EX 86400` (24h TTL)
- Value: Serialized transition response `{ success, nextState, historyId, isCompleted }`
- On hit: Return cached value, HTTP 200 (or original status code if stored)
- On miss: Execute transition, store result, return
**Where to implement:** `WorkflowEngineController.processTransition()` — pre-execution check using `RedisService`. If key exists → return cached. If not → run `workflowService.processTransition()` → store result.
**Frontend:** `use-workflow-action` hook generates a UUIDv7 idempotency key once per "action intent" (when user clicks Approve/Reject). Key is regenerated if the user cancels and re-opens the action dialog.
---
## Research Question 6: Cache Strategy for Workflow History
**ADR-021 §9:** Redis Cache TTL 1 hour for workflow history data. Invalidate immediately on state change.
**Cache key pattern:**
- `wf:history:{instanceId}` → JSON of history items with attachments
- Invalidated in `WorkflowEngineService.processTransition()` after `commitTransaction()`
**Implementation:** Use existing `CacheService` or `@nestjs/cache-manager` with Redis store. Set `ttl: 3600` on history queries.
---
## Research Question 7: CASL 4-Level RBAC for Transition Guard
**ADR-021 §6:** 4-Level hierarchy: Superadmin > Org Admin > Assigned Handler > Read-only
**Existing patterns:**
- `RbacGuard` + `@RequirePermission()` in `workflow-engine.controller.ts`
- CASL ability checked via `CaslAbilityFactory`
**New `WorkflowTransitionGuard` logic:**
```typescript
// Level 1: system.manage_all (Superadmin) → always allowed
// Level 2: organization.manage_users AND same org as document → allowed
// Level 3: Assigned handler (workflow instance context.userId === req.user.user_id) → allowed
// Level 4: Read-only → FORBIDDEN (403)
```
**Impersonation (Upload on behalf):** Superadmin and Org Admin may supply `actingAsUserId` in payload to impersonate the handler for upload. Guard validates this permission level.
---
## Research Question 8: Priority Enum Visual Indicators
**ADR-021 Clarification:** `URGENT`, `HIGH`, `MEDIUM`, `LOW` — 4-tier system with visual indicators.
**Proposed Tailwind classes:**
| Priority | Badge Color | Dot/Icon |
|----------|------------|---------|
| `URGENT` | `bg-red-100 text-red-800 border-red-300` | 🔴 Pulse animation |
| `HIGH` | `bg-orange-100 text-orange-800 border-orange-300` | 🟠 |
| `MEDIUM` | `bg-yellow-100 text-yellow-800 border-yellow-300` | 🟡 |
| `LOW` | `bg-green-100 text-green-800 border-green-300` | 🟢 |
**Note:** Priority field source — `workflow_instances.context` JSON (existing `metadata`/`payload` field) or document master table. Needs clarification on which document modules expose `priority`. **Interim:** Render from `WorkflowInstance.context.priority` if present.
---
## Research Question 9: File Preview — Security Considerations
**Requirements:** PDF and Image preview via Modal (inline, no tab change).
**Backend:** Need a `GET /files/preview/:publicId` or `GET /attachments/:publicId/preview` endpoint that:
1. Validates user has `document.view` permission on the parent document
2. Streams file with `Content-Disposition: inline` (not `attachment`)
3. Sets `Content-Security-Policy: frame-src 'self'` for PDFs in `<iframe>`
**Frontend:**
- PDF: `<iframe src="/api/files/preview/:publicId" className="w-full h-full" />`
- Image: `<img src="/api/files/preview/:publicId" alt={filename} />`
- Detection: by `mimeType` field from attachment metadata
**Finding:** `file-storage.controller.ts` exists — check if preview endpoint already implemented or needs addition.
---
## Research Question 10: Redis Redlock Failure Mode During Transition
**Question:** If Redis is unreachable or Redlock cannot be acquired, should the system fail-open (allow transition) or fail-closed (block transition)?
**Context (Clarify Q2):** In DMS, a race condition that creates duplicate approvals is worse than temporary service unavailability. Incorrect document state corrupts audit trails and may have legal/contractual consequences.
### Option A: Fail-open (Rejected ❌)
Allow transition even without a lock. Accepts Race Condition risk.
- **Con:** Two concurrent approvals possible — violates document integrity.
### Option B: Retry then fail-open (Rejected ❌)
Retry 3x then allow anyway.
- **Con:** Still permits race condition under high load.
### Option C: Fail-closed — Retry then HTTP 503 (Selected ✅)
Retry 3x with 500ms exponential backoff → if still unavailable → throw HTTP 503.
- **Pro:** Data integrity preserved at all times.
- **Pro:** HTTP 503 is a retryable error — client can display toast and user can retry manually.
- **Con:** Temporary service degradation during Redis outage (~50 concurrent users; outage rare).
**Decision:** Option C — Fail-closed.
**Implementation:**
```typescript
// ใน WorkflowEngineService.processTransition()
// ลอง Acquire Redlock สูงสุด 3 ครั้ง
const MAX_LOCK_RETRIES = 3;
const LOCK_BACKOFF_MS = 500;
for (let attempt = 1; attempt <= MAX_LOCK_RETRIES; attempt++) {
try {
lock = await this.redlock.acquire([`lock:wf:${instanceId}`], 10000);
break; // สำเร็จ
} catch {
if (attempt === MAX_LOCK_RETRIES) {
throw new ServiceUnavailableException('ระบบยุ่งชั่วคราว กรุณาลองใหม่ภายหลัง');
}
await sleep(LOCK_BACKOFF_MS * attempt); // exponential backoff
}
}
```
---
## Research Question 11: Upload-Permitted States
**Question:** Which workflow states allow step-attachment upload? (Clarify Q1)
**Decision:** Upload permitted ONLY in `PENDING_REVIEW` and `PENDING_APPROVAL`.
**Rationale:** Attachments are decision-support evidence. After a terminal decision (`APPROVED`, `REJECTED`, `CLOSED`), adding attachments retroactively would contaminate the audit trail.
**Implementation:** Both client-side guard (disable upload UI) AND server-side validation in `processTransition()` before acquiring Redlock.
---
## Summary of All Decisions
| # | Decision | Chosen | Rejected |
|---|---------|--------|---------|
| 1 | File attachment strategy | Upload-Then-Reference | Multipart atomic |
| 2 | FK structure | Direct nullable FK on `attachments` | Junction table |
| 3 | `workflow_histories.id` type | `CHAR(36)` UUID direct PK | N/A |
| 4 | Visualizer reuse | New vertical `workflow-lifecycle.tsx` | Reuse horizontal |
| 5 | Idempotency storage | Redis key `idempotency:transition:{key}:{userId}` TTL 24h | DB table |
| 6 | History cache | Redis `wf:history:{instanceId}` TTL 1h, invalidate on transition | No cache |
| 7 | Transition RBAC | New `WorkflowTransitionGuard` 4-Level | Extend existing `RbacGuard` |
| 8 | Priority display | Tailwind badges from `instance.context.priority` | Hardcoded status |
| 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, Correspondence** (v2 Revised 2026-04-19) | ~~Excluding Correspondence~~ |