275 lines
12 KiB
Markdown
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~~ |
|