690504:1641 Update specs [skip ci]

This commit is contained in:
2026-05-04 16:41:50 +07:00
parent 42a6d24318
commit 3575f3073b
106 changed files with 5813 additions and 259 deletions
@@ -0,0 +1,485 @@
# Implementation Plan: Transmittals + Circulation Complete Integration (v1.8.8 with Revision Refactor)
**Branch**: `001-transmittals-circulation` | **Date**: 2026-04-29 | **Spec**: `specs/001-transmittals-circulation/spec.md`
---
## Summary
Wire up the ADR-021 `IntegratedBanner` + `WorkflowLifecycle` components (already imported as stubs) into the Transmittal and Circulation detail pages with live workflow data, add the missing `workflowInstanceId` exposure on both backend services, implement pending edge-case handlers (EC-RFA-004, EC-CIRC-001/002/003), and create missing TanStack Query hooks and list-page features.
---
## Technical Context
**Language/Version**: TypeScript 5.x (strict)
**Primary Dependencies**: NestJS 10, Next.js 14 (App Router), TypeORM, MariaDB, TanStack Query v5, shadcn/ui, TailwindCSS
**Storage**: MariaDB (via `workflow_instances` + `workflow_history` + `attachments` tables)
**Testing**: Vitest (frontend), Jest (backend)
**Target Platform**: QNAP Container Station (on-prem)
**Performance Goals**: Detail page loads workflow data < 1s; TanStack Query staleTime 60s
**Constraints**: ADR-009 (no migrations), ADR-019 (UUID strings only), ADR-016 (ClamAV), ADR-008 (BullMQ for notifications)
---
## Constitution Check
| Rule | Status | Notes |
|------|--------|-------|
| UUID patterns (ADR-019) — no parseInt | ✅ PASS | Use `publicId` string throughout |
| Schema changes via SQL delta (ADR-009) | ✅ PASS | No schema changes needed — additive response only |
| Security: CASL Guard on new endpoints | ✅ REQUIRED | Reassign/ForceClose endpoints need guards |
| Security: Idempotency-Key on workflow transitions | ✅ PASS | `use-workflow-action.ts` already sends key |
| BullMQ for notifications (ADR-008) | ✅ PASS | Existing NotificationService handles this |
| No `any` types | ✅ REQUIRED | Strict TypeScript enforcement |
| Thin controllers, business logic in services | ✅ PASS | Following existing RFA/Correspondence pattern |
| Test coverage ≥ 80% business logic | ⚠️ REQUIRED | New service methods need unit tests |
| Redis Redlock for document numbering | ✅ N/A | No new document numbering in this feature |
---
## Project Structure
### Documentation
```text
specs/001-transmittals-circulation/
├── spec.md ✅ done
├── plan.md ✅ this file
├── data-model.md (inline in this plan)
└── tasks.md (next step)
```
### Source Code Changes
```text
backend/src/modules/
├── workflow-engine/
│ └── workflow-engine.service.ts [ADD] getInstanceByEntity()
├── transmittal/
│ ├── transmittal.service.ts [MODIFY] findOneByUuid + findAll + EC-RFA-004
│ └── transmittal.controller.ts [MODIFY] add /submit endpoint
├── circulation/
│ ├── circulation.service.ts [MODIFY] findOneByUuid + reassign + forceClose
│ └── circulation.controller.ts [MODIFY] add /reassign + /force-close endpoints
frontend/
├── types/
│ ├── transmittal.ts [MODIFY] add workflowInstanceId, workflowState, availableActions
│ └── circulation.ts [MODIFY] add workflowInstanceId, workflowState, deadline fields
├── hooks/
│ ├── use-transmittal.ts [NEW] useTransmittal(uuid) hook
│ └── use-circulation.ts [MODIFY] add useCirculation(uuid)
├── app/(dashboard)/
│ ├── transmittals/[uuid]/page.tsx [MODIFY] wire IntegratedBanner + WorkflowLifecycle
│ └── circulation/[uuid]/page.tsx [MODIFY] wire IntegratedBanner + WorkflowLifecycle + Overdue
└── public/locales/
├── th/transmittal.json [ADD/UPDATE] i18n keys
└── en/transmittal.json [ADD/UPDATE] i18n keys
```
---
## Phase 0: Research Findings
### Key Design Decisions
1. **`workflowInstanceId` exposure**: No schema changes needed. The `WorkflowInstance` table has `entity_type` + `entity_id` columns. Add `getInstanceByEntity(entityType, entityId)` to `WorkflowEngineService`, then call it in `findOneByUuid` for both modules.
2. **Transmittal entityType**: Use `'transmittal'`; join via `workflow_instances WHERE entity_type = 'TRANSMITTAL' AND entity_id = correspondences.id` (string). Consistent with RFA pattern. **No separate FK column added.**
3. **Circulation entityType**: Use `'circulation'`; join via `workflow_instances WHERE entity_type = 'CIRCULATION' AND entity_id = circulations.id` (string). The Circulation entity already extends `UuidBaseEntity` (has `publicId`).
4. **Transmittal publicId**: The Transmittal entity has no own `publicId` — it shares `correspondence.publicId`. The service already maps it: `publicId: t.correspondence?.publicId`. This is correct; no change needed.
5. **EC-RFA-004 validation**: Fires in `TransmittalService.submit()` (new method). Check all `transmittal_items` → fetch their correspondence's current revision status → if any is DRAFT, throw `ValidationException`.
6. **EC-CIRC-001 (Reassign)**: New `CirculationService.reassignRouting(routingId, newAssigneeUuid, user)`. Requires `Document Control` or above role (`ability.can('reassign', 'Circulation')`). Updates `routing.assignedTo` to the new user's INT id.
7. **EC-CIRC-002 (Force Close) — Synchronous, ≤3s SLA**: New `CirculationService.forceClose(uuid, reason, user)`. Requires `Document Control` or above (`ability.can('forceClose', 'Circulation')`). All routing status updates + audit log committed in **a single DB transaction**; respond `200 OK` after commit. BullMQ notification jobs enqueued **post-commit** (not inside transaction). SLA: ≤ 3 seconds for ≤ 50 routings.
8. **EC-CIRC-003 (Overdue) — Server-side**: `CirculationService.findOneByUuid()` computes `isOverdue = NOW() > deadline_date + INTERVAL 1 DAY` per routing and returns `isOverdue: boolean` in the response. **Frontend MUST NOT compute overdue independently** — render badge based solely on backend field. Unit test: `CirculationService` spec with mocked `Date`/`ClockService`.
9. **EC-CORR-001 (Cancel Correspondence → Force-close Circulations)**: When Correspondence is cancelled, all OPEN Circulations are force-closed + audit log written. A BullMQ job is enqueued **per affected assignee** with pending routing on queue `notification-queue`. Job payload: `{ circulationNo, correspondenceNo, cancellationReason }`. No inline notification.
10. **Close Circulation (FR-C09) RBAC**: The "Close Circulation" action (all Main/Action assignees COMPLETED) is exposed **only to Document Control** — guarded by `@UseGuards(JwtAuthGuard, CaslAbilityGuard)` with `ability.can('close', 'Circulation')`. Frontend hides button for all other roles.
---
## Phase 1: Design Decisions
### Data Model Changes (No SQL delta required)
The `WorkflowInstance` table already exists with `entity_type VARCHAR`, `entity_id VARCHAR`. The only change is:
- **Add** `getInstanceByEntity(entityType, entityId)` to `WorkflowEngineService` (TypeORM query, no schema change).
### API Contract
#### `GET /transmittals/:uuid`
**Response addition** (existing fields unchanged):
```json
{
"data": {
"publicId": "...",
"workflowInstanceId": "019abc...",
"workflowState": "IN_REVIEW",
"availableActions": ["APPROVE", "REJECT"],
...existing fields...
}
}
```
#### `GET /circulation/:uuid`
**Response addition**:
```json
{
"data": {
"publicId": "...",
"workflowInstanceId": "019def...",
"workflowState": "OPEN",
"availableActions": [],
"isOverdue": false,
"routings": [
{
"assigneePublicId": "...",
"assigneeType": "MAIN",
"deadline": "2026-04-20T00:00:00.000Z",
"isOverdue": true,
"status": "PENDING",
...
}
],
...
}
}
```
> ⚠️ `isOverdue` is computed **server-side** in `CirculationService` (`NOW() > deadline_date + INTERVAL 1 DAY`). Frontend must NOT recompute this field.
#### `POST /transmittals/:uuid/submit` (NEW)
**Request**:
```json
{ "templateId": "optional-workflow-template-uuid" }
```
**Response**: `{ "instanceId": "...", "currentState": "IN_REVIEW" }`
**Error (EC-RFA-004)**: `422` `{ "message": "RFA [doc-no] ยังอยู่ใน Draft กรุณา Submit ก่อน" }`
#### `PATCH /circulation/:id/routing/:routingId/reassign` (NEW)
**Request**: `{ "newAssigneeId": "uuid" }`
**Guards**: `@RequirePermission('circulation.manage')`
#### `POST /circulation/:id/force-close` (NEW)
**Request**: `{ "reason": "mandatory string" }`
**Guards**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)` with `ability.can('forceClose', 'Circulation')`
**Response**: `{ "success": true }`
**Behaviour**: Synchronous. Single DB transaction (all routing updates + audit log). BullMQ notification per assignee enqueued post-commit. **SLA: ≤ 3 seconds** for ≤ 50 routings.
#### `POST /circulation/:id/close` (NEW)
**Guards**: `@UseGuards(JwtAuthGuard, CaslAbilityGuard)` with `ability.can('close', 'Circulation')`**Document Control only**
**Pre-condition**: All Main/Action routings must be in COMPLETED state (server validates; 422 if not)
**Response**: `{ "success": true }`
### Frontend Architecture
#### `useTransmittal(uuid)` hook
```ts
useQuery<Transmittal>({
queryKey: ['transmittal', uuid],
queryFn: () => transmittalService.getByUuid(uuid),
enabled: !!uuid,
staleTime: 60_000,
})
```
Returns: `{ transmittal, isLoading, error }` — replaces inline `useQuery` in detail page.
#### `useCirculation(uuid)` hook
```ts
useQuery<Circulation>({
queryKey: ['circulation', uuid],
queryFn: () => circulationService.getByUuid(uuid),
enabled: !!uuid,
staleTime: 60_000,
})
```
#### `useWorkflowHistory(instanceId)` — already exists, use directly in pages.
#### Overdue Badge logic (backend-computed, frontend renders)
```ts
// ✅ CORRECT — render from backend field only
const isRoutingOverdue = routing.isOverdue; // boolean from API
// ❌ FORBIDDEN — frontend must NOT recompute
// const isOverdue = new Date() > new Date(routing.deadline);
```
The `isOverdue` field is computed in `CirculationService.findOneByUuid()` via:
```ts
const isOverdue = new Date() > addDays(new Date(routing.deadline), 1);
```
Unit test: mock `Date` in `circulation.service.spec.ts` (or inject `ClockService`).
---
## Phase 2: Task Breakdown
### Critical (Backend)
| Task | File | Description |
|------|------|-------------|
| B1 | `workflow-engine.service.ts` | Add `getInstanceByEntity(entityType, entityId)` — join `workflow_instances WHERE entity_type = ? AND entity_id = ?`; returns `{ id, currentState }` or null |
| B2 | `transmittal.service.ts` | `findOneByUuid`: join workflow instance via `entity_type='TRANSMITTAL'`, `entity_id=correspondences.id`; add `workflowInstanceId` to response |
| B3 | `transmittal.service.ts` | `findAll`: add `purpose` filter |
| B4 | `transmittal.service.ts` | Add `submit(uuid, user)` with EC-RFA-004 validation (pre-transition check) |
| B5 | `transmittal.controller.ts` | Add `POST /:uuid/submit` endpoint with `CaslAbilityGuard` |
| B6 | `circulation.service.ts` | `findOneByUuid`: join workflow instance via `entity_type='CIRCULATION'`, `entity_id=circulations.id`; compute `isOverdue: boolean` server-side per routing |
| B7 | `circulation.service.ts` | Add `reassignRouting(routingId, newAssigneeUuid, user)` — guard `ability.can('reassign', 'Circulation')` |
| B8 | `circulation.service.ts` | Add `forceClose(uuid, reason, user)` — single DB transaction; enqueue BullMQ `notification-queue` post-commit; SLA ≤ 3s / 50 routings |
| B9 | `circulation.service.ts` | Add `close(uuid, user)` — Document Control only; pre-condition all Main/Action routings COMPLETED |
| B10 | `circulation.controller.ts` | Add PATCH `/routing/:id/reassign` + POST `/force-close` + POST `/close` with CASL guards |
| B11 | `correspondence.service.ts` | On cancel: force-close all OPEN Circulations + enqueue BullMQ `notification-queue` job per affected assignee (EC-CORR-001, FR-X05) |
### Important (Frontend)
| Task | File | Description |
|------|------|-------------|
| F1 | `types/transmittal.ts` | Add `workflowInstanceId?`, `workflowState?`, `availableActions?` |
| F2 | `types/circulation.ts` | Add `workflowInstanceId?`, `workflowState?`, `isOverdue: boolean` per routing (backend-provided) |
| F3 | `hooks/use-transmittal.ts` | New `useTransmittal(uuid)` hook |
| F4 | `hooks/use-circulation.ts` | Add `useCirculation(uuid)` hook |
| F5 | `transmittals/[uuid]/page.tsx` | Wire banner + lifecycle with real data |
| F6 | `circulation/[uuid]/page.tsx` | Wire banner + lifecycle + Overdue badge from `routing.isOverdue` (backend field — no client-side date math); show "Close Circulation" button only when `canClose && user.role === 'DOCUMENT_CONTROL'` |
| F7 | `transmittals/page.tsx` | Verify list page works, add purpose filter |
### Guidelines (i18n + Tests)
| Task | File | Description |
|------|------|-------------|
| I1 | `public/locales/th/*.json` + `en/*.json` | Add missing transmittal/circulation workflow keys (force-close modal, overdue badge, close action) |
| T1 | `transmittal.service.spec.ts` | Unit test EC-RFA-004 submit validation (422 on DRAFT item) |
| T2 | `circulation.service.spec.ts` | Unit tests: EC-CIRC-001 reassign, EC-CIRC-002 force-close (transaction + BullMQ enqueue), EC-CIRC-003 `isOverdue` with mocked Date, EC-CORR-001 notification job enqueue |
| T3 | `circulation.service.spec.ts` | Integration test: Force Close with 50 routings completes within 3s (SC-008) |
---
## Phase 3: Verification Plan
### Backend
```bash
# TypeScript compile
cd backend && pnpm tsc --noEmit
# Unit tests (target files)
cd backend && pnpm jest --testPathPattern="transmittal|circulation"
# Manual curl — Transmittal detail with workflowInstanceId
curl http://localhost:3001/api/transmittals/{uuid} | jq '.data.workflowInstanceId'
# Manual curl — Circulation detail with workflowInstanceId
curl http://localhost:3001/api/circulation/{uuid} | jq '.data.workflowInstanceId'
# EC-RFA-004 — submit transmittal with DRAFT item (expect 422)
curl -X POST http://localhost:3001/api/transmittals/{uuid}/submit
# Force Close test
curl -X POST http://localhost:3001/api/circulation/{uuid}/force-close \
-H "Content-Type: application/json" \
-d '{"reason":"Test force close"}'
```
### Frontend
```bash
# TypeScript compile (zero errors)
cd frontend && pnpm tsc --noEmit
# Lint (zero warnings)
cd frontend && pnpm lint
# Vitest unit tests
cd frontend && pnpm test
# Manual: Navigate to /transmittals/{uuid}
# → IntegratedBanner shows doc number + status badge
# → Workflow tab shows history timeline
# → workflowState shown in banner (if instance exists)
# Manual: Navigate to /circulation/{uuid}
# → Overdue badge on past-deadline routings
# → Workflow tab shows history
```
### Security Verification
- [ ] Reassign endpoint: 403 if user is not Document Control or above (`ability.can('reassign', 'Circulation')`)
- [ ] Force Close endpoint: 403 if user is not Document Control or above (`ability.can('forceClose', 'Circulation')`)
- [ ] Close Circulation endpoint: 403 for all non-Document Control roles (`ability.can('close', 'Circulation')`)
- [ ] Close Circulation: 422 if any Main/Action routing is not COMPLETED
- [ ] Workflow transition: `Idempotency-Key` header enforced
- [ ] No `parseInt` on any UUID in new code
- [ ] `workflowInstanceId` is string (not number) in all responses
- [ ] EC-CORR-001: BullMQ notification job enqueued, NOT inline in request thread
- [ ] `isOverdue` on Circulation response is boolean from backend — no frontend date math
---
## Dependencies Map
```
ADR-001 (Unified Workflow Engine)
└→ WorkflowEngineService.getInstanceByEntity() [B1]
├→ TransmittalService.findOneByUuid() [B2]
└→ CirculationService.findOneByUuid() [B6]
├→ types/transmittal.ts [F1]
├→ types/circulation.ts [F2]
├→ useTransmittal() [F3]
├→ useCirculation() [F4]
├→ transmittals/[uuid]/page.tsx [F5]
└→ circulation/[uuid]/page.tsx [F6]
ADR-016 (Security/RBAC)
└→ CirculationService.reassignRouting() [B7]
└→ CirculationService.forceClose() [B8]
ADR-021 (IntegratedBanner/WorkflowLifecycle components)
└→ Already implemented — just need instanceId prop wired [F5, F6]
```
---
## Risk Register
| Risk | Probability | Impact | Mitigation |
|------|------------|--------|-----------|
| `entity_type` mismatch — Transmittal uses wrong entityType in WF instance | Medium | High | Join via `entity_type='TRANSMITTAL'` + `entity_id=correspondences.id` consistently in `getInstanceByEntity()` |
| Circulation has no WF instance (workflow never started) | High | Medium | `getInstanceByEntity()` returns null → `workflowInstanceId` = undefined → banner shows status only, no actions |
| Transmittal entity has no `publicId` own column | Medium | Low | Already handled: `publicId` maps from `correspondence.publicId` in `findAll()` |
| EC-CIRC-003 timezone — server TZ differs from Bangkok | Low | High | Compute `isOverdue` in MariaDB with `NOW()` (server TZ is UTC+7 per docker-compose); verify in unit test with fixed timestamp |
| Force Close latency > 3s with 50+ routings | Low | Medium | Use bulk UPDATE query (not loop), wrap in single transaction; add integration test SC-008 |
| EC-CORR-001 notification lost if BullMQ unavailable | Low | High | BullMQ persistence (Redis AOF) ensures job survives restarts; dead-letter queue `notification-queue-failed` alerts ops |
---
## Phase 4: Transmittal Revision Refactor (v1.8.8)
**Based on**: Clarifications Session 2026-04-29
**Goal**: Restructure Transmittal to follow Master-Revision Pattern like RFA/Correspondence
### Clarification Decisions Applied
| Decision | Implementation |
|----------|----------------|
| **B** — Reuse `correspondence_revisions` | Transmittal-specific data (`purpose`, `remarks`) stored in `correspondence_revisions.details` JSON field |
| **A** — Copy items on revision | When creating new revision, clone all `transmittal_items` with new `revision_id` |
| **B** — Add `revision_id` column | Schema change: `ALTER TABLE transmittal_items ADD revision_id INT NULL FK` |
| **A** — Workflow binds to current revision | `workflow_instances.entity_id` references `correspondence_revisions.id` (not master) |
| **B** — Keep same document number | Revision label (A, B, C) distinguishes versions; doc number unchanged |
### Schema Changes (ADR-009 — SQL Direct)
```sql
-- 1. Add revision_id to transmittal_items (backward compatible)
ALTER TABLE transmittal_items
ADD COLUMN revision_id INT NULL COMMENT 'FK to correspondence_revisions' AFTER transmittal_id,
ADD CONSTRAINT fk_transmittal_items_revision
FOREIGN KEY (revision_id) REFERENCES correspondence_revisions(id) ON DELETE CASCADE,
ADD INDEX idx_transmittal_items_revision (revision_id);
-- 2. Add item_type column to transmittal_items (H1 fix)
ALTER TABLE transmittal_items
ADD COLUMN item_type VARCHAR(50) NULL COMMENT 'ประเภทเอกสาร เช่น DRAWING, RFA, CORRESPONDENCE' AFTER item_correspondence_id;
-- 3. Drop deprecated columns from transmittals table (ADR-009)
-- Run AFTER R14 (service updated to write purpose/remarks → revision.details) is deployed
ALTER TABLE transmittals
DROP COLUMN purpose,
DROP COLUMN remarks;
```
### Data Model Update
```
correspondences (Master - type_code='TRANSMITTAL')
└── correspondence_revisions (Revisions)
├── details.purpose ← Transmittal purpose (JSON)
├── details.remarks ← Transmittal remarks (JSON)
└── transmittal_items (with revision_id FK)
└── item_correspondence_id → correspondences.id
```
### Backend Changes
| Task | File | Description |
|------|------|-------------|
| R1 | `transmittal.service.ts` | Update `findOneByUuid` to join `correspondence_revisions` and read `purpose`/`remarks` from `details` JSON |
| R2 | `transmittal.service.ts` | Add `createRevision()` method — copy items automatically, link to new revision |
| R3 | `transmittal.service.ts` | Update `submit()` to work with revision-scoped items (EC-RFA-004) |
| R4 | `transmittal-item.entity.ts` | Add `revisionId` column (nullable, FK to `correspondence_revisions.id`) |
| R5 | `transmittal.service.ts` | Add `copyItemsToRevision(oldRevisionId, newRevisionId)` helper |
| R6 | `workflow-engine.service.ts` | Update `getInstanceByEntity` to support `entity_type='transmittal'` with `entity_id=revision.publicId` (UUID string, ADR-019 — NOT INT revision.id) |
| R17 | `transmittal-item.entity.ts` + `transmittal.service.ts` + `schema-02-tables.sql` | Add `item_type VARCHAR(50) NULL` column to `transmittal_items` table (ADR-009 SQL), add TypeORM column, save `item.itemType` in `create()` — Fixes H1 |
| R18 | `transmittal.service.ts` | Replace `ORG_CODE: 'ORG'` hardcode with real `organizationCode` lookup via `dataSource.manager.findOne(Organization, { where: { id: userOrgId } })` — Fixes M1 |
### Frontend Changes
| Task | File | Description |
|------|------|-------------|
| R7 | `types/transmittal.ts` | Add `revisionId`, `revisionNumber`, `revisionLabel` to `Transmittal` type |
| R8 | `types/transmittal.ts` | Add `revisionId` to `TransmittalItem` type |
| R9 | `transmittals/[uuid]/page.tsx` | Show revision selector (like RFA) when multiple revisions exist |
| R10 | `transmittals/[uuid]/page.tsx` | Display revision label (A, B, C) in banner |
### API Contract Changes
#### `GET /transmittals/:uuid` (Updated)
```json
{
"data": {
"publicId": "...",
"revisionId": "019abc...",
"revisionNumber": 1,
"revisionLabel": "A",
"purpose": "FOR_APPROVAL",
"remarks": "...",
"workflowInstanceId": "019def...",
"items": [
{
"id": 1,
"revisionId": "019abc...",
"itemCorrespondenceId": "...",
...
}
]
}
}
```
#### `POST /transmittals/:uuid/revisions` (NEW)
**Request**: `{ "remarks": "optional revision reason" }`
**Response**: `{ "revisionId": "...", "revisionNumber": 2, "revisionLabel": "B" }`
**Action**: Creates new `correspondence_revisions` record, copies all items automatically
**Guards**: Document Control or above
### Constitution Check (Revision Refactor)
| Rule | Status | Notes |
|------|--------|-------|
| ADR-009 — Schema changes via SQL | ✅ REQUIRED | Add `revision_id` via direct SQL, no TypeORM migration |
| ADR-019 — UUID strings | ✅ REQUIRED | All IDs use `publicId` string |
| ADR-021 — Workflow Context | ✅ REQUIRED | Workflow binds to `correspondence_revisions.publicId` (UUID string, ADR-019) |
| Master-Revision Pattern | ✅ REQUIRED | Aligns with RFA/Correspondence pattern |
### Risk Register (Revision Refactor)
| Risk | Probability | Impact | Mitigation |
|------|------------|--------|-----------|
| Data migration complexity | Medium | High | Backward-compatible NULLable `revision_id`; migrate existing items post-deploy |
| Workflow instance re-binding | Medium | High | New revision = new workflow target; historical revisions preserve state |
| Correspondence type detection | Low | Medium | Ensure `type_code='TRANSMITTAL'` when querying revisions |
---
@@ -0,0 +1,204 @@
# Feature Specification: Transmittals + Circulation Complete Integration (v1.8.7 Post-ADR-021)
**Feature Branch**: `001-transmittals-circulation`
**Version**: 1.8.7
**Created**: 2026-04-12
**Status**: Draft
**Depends On**: ADR-021 (Integrated Workflow Context & Step-specific Attachments — in `feat/adr-021-integrated-workflow-context`)
**Input**: "Transmittals + Circulation (v1.8.7) Post-ADR-021"
---
## Context
ADR-021 introduced the shared `IntegratedBanner`, `WorkflowLifecycle`, and `use-workflow-action` components, and wired them fully for RFA and Correspondence. Both the **Transmittal** and **Circulation** detail pages already import these components but pass no workflow data — they are currently stub wired.
This feature delivers the **complete, production-ready integration** of both modules with the ADR-021 Workflow Engine, fixes known type violations (ADR-019), implements all pending edge cases (EC-RFA-004, EC-CIRC-001003), and adds missing hooks and list-page functionality.
---
## User Scenarios & Testing _(mandatory)_
### User Story 1 — Transmittal Workflow-Wired Detail Page (Priority: P1) 🎯 MVP
A Document Control officer opens an existing Transmittal and immediately sees the document number, subject, workflow state, and available action buttons in the `IntegratedBanner`. The Workflow tab shows the full vertical timeline with each step's actor, date, comment, and evidence files.
**Why this priority**: The detail page is the primary touchpoint for reviewing/approving a Transmittal. Without live workflow data, reviewers cannot take action — this is a blocking gap.
**Independent Test**: Navigate to `/transmittals/{uuid}`. Verify: (1) `IntegratedBanner` shows real doc number, status badge, and action buttons (when user is the handler); (2) Workflow tab renders `WorkflowLifecycle` with at least the creation step; (3) all `pnpm tsc --noEmit` checks pass.
**Acceptance Scenarios**:
1. **Given** a submitted Transmittal with an active workflow instance, **When** a Document Control user opens its detail page, **Then** the `IntegratedBanner` displays the correct doc number, status, `workflowState`, and the available action buttons (e.g., APPROVE, REJECT).
2. **Given** a Transmittal where the current user is not the assigned handler, **When** they open the detail page, **Then** no action buttons are shown in the banner.
3. **Given** a Transmittal detail page, **When** the user clicks the Workflow tab, **Then** a vertical timeline displays all history steps with actor, date, and comment. The most recent step is highlighted.
---
### User Story 2 — Circulation Workflow-Wired Detail Page (Priority: P1) 🎯 MVP
A Document Control officer opens a Circulation Sheet and sees the circulation number, linked Correspondence, all assignees with their status, a deadline (with Overdue badge if past), and the full workflow timeline — all in one screen. Assignees can mark their task complete via the `IntegratedBanner` actions.
**Why this priority**: Circulation drives internal task tracking. Without live data wiring the page is read-only and useless for task management.
**Independent Test**: Navigate to `/circulation/{uuid}`. Verify: (1) `IntegratedBanner` displays `circulationNo`, `statusCode`; (2) Assignees card shows all routings with status; (3) Overdue badge appears when `deadline_date` is past; (4) Workflow tab shows history.
**Acceptance Scenarios**:
1. **Given** an OPEN Circulation with a past deadline, **When** a user opens the detail page, **Then** an Overdue badge is displayed and the deadline date is highlighted in red.
2. **Given** a Circulation with multiple assignees, **When** an assignee marks their task complete, **Then** their routing status updates to COMPLETED and the page refreshes.
3. **Given** a Circulation where all Main/Action assignees are COMPLETED, **When** a **Document Control** user views the page, **Then** a "Close Circulation" action is available (other roles must NOT see this button).
---
### User Story 3 — Transmittal List Page with Search & Filter (Priority: P1)
A Document Control officer browses all Transmittals for a project, filters by purpose (FOR_APPROVAL, FOR_REVIEW, etc.), and searches by document number or subject.
**Why this priority**: The list page is the entry point for the module. Without working filters it cannot be used in production.
**Independent Test**: Navigate to `/transmittals`. Verify: paginated list loads; purpose filter updates results; search input filters by doc number/subject; clicking a row navigates to the detail page.
**Acceptance Scenarios**:
1. **Given** the Transmittals list page, **When** a user selects purpose "FOR_APPROVAL", **Then** only Transmittals with that purpose are shown.
2. **Given** the Transmittals list page, **When** a user types in the search box, **Then** results are filtered to matching document numbers or subjects within 500ms.
3. **Given** no Transmittals in the project, **When** the list page loads, **Then** an "empty state" message is shown.
---
### User Story 4 — Transmittal EC-RFA-004 Submit Validation (Priority: P2)
A Document Control officer tries to submit a Transmittal whose items include a DRAFT correspondence. The system blocks the submission with a clear, actionable error message.
**Why this priority**: EC-RFA-004 is a business integrity rule. Submitting a Transmittal with unsubmitted items violates the document lifecycle and must be blocked.
**Independent Test**: Create a Transmittal with one DRAFT item. Attempt to submit. Verify: 422 response with message "RFA [doc number] ยังอยู่ใน Draft กรุณา Submit ก่อน"; item is highlighted in the UI.
**Acceptance Scenarios**:
1. **Given** a Transmittal containing a DRAFT correspondence, **When** a user submits the Transmittal, **Then** the system returns an error identifying which item is in DRAFT status.
2. **Given** all Transmittal items are in SUBMITTED/APPROVED status, **When** a user submits the Transmittal, **Then** the submission succeeds and the status updates.
---
### User Story 5 — Circulation Edge Cases: Re-assign & Force Close (Priority: P2)
Document Control can re-assign a Circulation when an assignee is deactivated (EC-CIRC-001), and can force-close a Circulation with a mandatory reason when some assignees have not responded (EC-CIRC-002).
**Why this priority**: Without these controls, Circulations can get permanently stuck, blocking downstream work.
**Independent Test**: Deactivate an assignee in an OPEN Circulation. Verify: Document Control sees a "Re-assign" button for that routing. Force-close a Circulation with partial responses; verify reason is recorded in audit log.
**Acceptance Scenarios**:
1. **Given** an OPEN Circulation where one assignee has been deactivated, **When** Document Control opens the page, **Then** a "Re-assign" action is available for that assignee's routing.
2. **Given** an OPEN Circulation where some assignees have not responded, **When** Document Control performs Force Close with a reason, **Then** the Circulation status changes to CANCELLED, all pending routings are force-closed, and the reason is logged.
---
### Edge Cases
- **EC-RFA-004**: Transmittal with DRAFT items cannot be submitted → `422 Unprocessable Entity` with item identification.
- **EC-CIRC-001**: Assignee deactivated before responding → Document Control can re-assign.
- **EC-CIRC-002**: Multi-assignee, some not responded → Document Control can Force Close with mandatory reason.
- **EC-CIRC-003**: Deadline = today `23:59:59`; Overdue Badge the following day at `00:00`. Overdue is computed **server-side** — backend returns `isOverdue: boolean` in the Circulation response. Client renders badge based solely on this field (no client-side date math).
- **EC-CORR-001**: Cancelling a Correspondence with open Circulations → all Circulations force-closed + audit log + **BullMQ notification** (email + in-app) dispatched to all affected assignees with pending routings (ADR-008). No inline notification — must be queued job.
- Transmittal `workflowInstanceId` is `null` when no workflow has been started (Draft state) → banner shows status only, no action buttons.
- Circulation data is scoped to the user's organization — users from other organizations must receive a 403 response.
- Duplicate `Idempotency-Key` on workflow transition → return cached response, no re-processing.
---
## Requirements _(mandatory)_
### Functional Requirements
**Transmittal Module:**
- **FR-T01**: The Transmittal detail page MUST display `workflowState`, `availableActions`, and action buttons via `IntegratedBanner` using the live workflow instance.
- **FR-T02**: The Transmittal detail page Workflow tab MUST render `WorkflowLifecycle` wired to the workflow history of the Transmittal's workflow instance.
- **FR-T03**: The Transmittal list page MUST support pagination, search by document number/subject, and filter by `purpose`.
- **FR-T04**: The Transmittal `Transmittal` frontend type MUST include `workflowInstanceId?: string` and `workflowState?: string` fields (ADR-019: string UUID only).
- **FR-T05**: The `transmittalService.getByUuid()` response MUST include `workflowInstanceId` resolved via `workflow_instances JOIN ON reference_type = 'TRANSMITTAL' AND reference_id = correspondences.id` — no separate FK column on the Transmittal entity.
- **FR-T06**: A dedicated `useTransmittal(uuid)` TanStack Query hook MUST be created for the detail page.
- **FR-T07**: Submitting a Transmittal with DRAFT items MUST return a `422` error identifying the offending item (EC-RFA-004).
**Circulation Module:**
- **FR-C01**: The Circulation detail page MUST display `workflowState`, `availableActions`, and action buttons via `IntegratedBanner` using the live workflow instance.
- **FR-C02**: The Circulation detail page Workflow tab MUST render `WorkflowLifecycle` wired to the workflow history.
- **FR-C03**: The Circulation detail page assignee section MUST display deadline per assignee type and an Overdue badge based on the backend-provided `isOverdue: boolean` field in the API response (EC-CIRC-003). The frontend MUST NOT compute overdue status independently.
- **FR-C04**: The Circulation `Circulation` frontend type MUST include `workflowInstanceId?: string` and `workflowState?: string`.
- **FR-C05**: The `circulationService.getByUuid()` response MUST include `workflowInstanceId` from the backend.
- **FR-C06**: A dedicated `useCirculation(uuid)` TanStack Query hook MUST be created for the detail page.
- **FR-C07**: Document Control MUST be able to re-assign a routing when the assignee is deactivated (EC-CIRC-001).
- **FR-C08**: Document Control MUST be able to Force Close a Circulation with a mandatory reason. The operation MUST be **synchronous** — all routing status updates and the audit log entry are committed in a single DB transaction before the `200 OK` response is returned. BullMQ notification jobs are enqueued **after** the transaction commits. **SLA: ≤ 3 seconds** for a Circulation with up to 50 routings (EC-CIRC-002).
- **FR-C09**: The "Close Circulation" action (triggered when all Main/Action assignees are COMPLETED) is available to **Document Control only** — guarded by `@UseGuards(JwtAuthGuard, CaslAbilityGuard)` with `ability.can('close', 'Circulation')`. Assignees and other roles must NOT see the button.
**Cross-Cutting:**
- **FR-X01**: All new API calls MUST use `publicId` (UUIDv7 string) — no `parseInt` on UUID values (ADR-019).
- **FR-X02**: All new frontend types MUST NOT use `any` — strict TypeScript required.
- **FR-X03**: All backend responses for these modules MUST include `workflowInstanceId?: string` in the data shape.
- **FR-X04**: All new user-facing strings MUST use i18n keys — no hardcoded Thai/English text in JSX.
- **FR-X05**: When a Correspondence is cancelled and open Circulations are force-closed (EC-CORR-001), a BullMQ notification job MUST be enqueued for each affected assignee with a pending routing — delivering both email and in-app notifications (ADR-008). The job payload MUST include `circulationNo`, `correspondenceNo`, and `cancellationReason`.
### Key Entities
- **Transmittal**: Extends Correspondence (`type_code = 'TRANSMITTAL'`). Has `purpose`, `remarks`, and a list of `transmittal_items`. Has one `WorkflowInstance` via the Unified Workflow Engine.
- **TransmittalItem**: Links a Transmittal to the document it carries (`correspondences` M:N). Has `quantity`, `itemType` (DRAWING / RFA / CORRESPONDENCE), `remarks`, and `revisionId` (FK to `correspondence_revisions` — added in v1.8.8).
- **Circulation**: Internal task-tracking document linked 1:1 to a Correspondence per organization. Has `statusCode`, `deadline`, and a list of `routings` (assignees with type: Main/Action/Information).
- **CirculationRouting**: A single assignee entry in a Circulation. Has `assigneeType`, `status`, `deadline`, `comments`.
---
## Assumptions
- ADR-021 backend is fully deployed — `workflow_history_id` column exists on `attachments`, `workflowInstanceId` is exposed from the Workflow Engine module.
- The backend `transmittal` module resolves `workflowInstanceId` by joining `workflow_instances` on `reference_type = 'TRANSMITTAL'` and `reference_id = correspondences.id`**no separate FK column** is required on the Transmittal side.
- The backend `circulation` module resolves `workflowInstanceId` by joining `workflow_instances` on `reference_type = 'CIRCULATION'` and `reference_id = circulations.id`.
- The Unified Workflow Engine (`WorkflowEngineService`) is the single source of truth for state and transitions — Transmittal and Circulation statuses are NOT independently maintained once a workflow is started.
---
## Success Criteria _(mandatory)_
### Measurable Outcomes
- **SC-001**: Both Transmittal and Circulation detail pages display live workflow state and action buttons in under 1 second after page load (TanStack Query with staleTime 60s).
- **SC-002**: Submitting a Transmittal with a DRAFT item is rejected 100% of the time with a user-readable error message identifying the offending document.
- **SC-003**: Force-closing a Circulation with partial responses succeeds in a single action with the mandatory reason captured in the audit log every time.
- **SC-004**: All new TypeScript code passes `pnpm tsc --noEmit` with zero errors and `pnpm lint` with zero warnings.
- **SC-005**: No hardcoded Thai or English text in any new JSX component — verified by grep.
- **SC-006**: Unit test coverage ≥ 80% on new business logic (EC-RFA-004 validation, EC-CIRC-001/002/003 handlers).
- **SC-007**: `isOverdue: boolean` is computed correctly on the backend when `NOW() > deadline_date + 1 day` — verified by a **backend unit test** in `CirculationService` with mocked `Date` (or injected `ClockService`). Frontend unit test verifies badge renders when `isOverdue === true`.
- **SC-008**: Force Close Circulation (FR-C08) completes within **3 seconds** under a Circulation with 50 routings — verified by a backend integration test measuring total transaction time.
---
## Clarifications
### Session 2026-04-12
- Q: Should the Transmittal "Submit" action go through the Workflow Engine transition (ADR-021 pattern), or does it remain a direct status update on the `correspondences` table? → A: Submit uses the Workflow Engine transition (`action: 'SUBMIT'`), consistent with ADR-001 Unified Workflow Engine for all document types. EC-RFA-004 validation fires as a pre-transition check in the service.
- Q: Should Circulation `routings` "Complete" action be a Workflow Engine transition or a direct routing status update? → A: Direct routing status update (not a full Workflow Engine transition) because Circulation workflow state is controlled at the Circulation level, not per-routing. The overall Circulation transitions (OPEN → IN_REVIEW → COMPLETED) go through the Workflow Engine.
- Q: For `workflowInstanceId` in the Transmittal/Circulation API response — should it be added to the existing response shape or is a dedicated `/workflow` sub-resource needed? → A: Add `workflowInstanceId` directly to the existing `findOneByUuid` response shape (additive, backward-compatible). Consistent with how RFA/Correspondence expose it.
### Session 2026-04-29 — Transmittal Revision Refactor Clarifications
- Q: Revision Table Strategy — should Transmittal have its own revisions table or reuse `correspondence_revisions`? → A: **B** — Reuse `correspondence_revisions` with JSON `details` field for transmittal-specific data (purpose, remarks), following Correspondence pattern.
- Q: Transmittal Items Revision Handling — how should items behave when creating a new revision? → A: **A** — Copy all current items to new revision automatically, preserving the exact state at each revision point. Users can then add/remove items in the new revision.
- Q: `transmittal_items` Schema Change — how to support item revisioning? → A: **B** — Add `revision_id` column to existing `transmittal_items` table (NULLable FK to `correspondence_revisions.id`), backward compatible per ADR-009.
- Q: Workflow Instance Binding — which revision should the workflow bind to? → A: **A** — Bind to current revision only. New revision becomes the new workflow target. Historical revisions remain for audit but are no longer part of active workflow.
- Q: Document Numbering on Revision — should doc number change on revision? → A: **B** — Keep same document number, revision label distinguishes versions (follows Correspondence pattern).
### Session 2026-05-03
- Q: Who can trigger the "Close Circulation" action when all Main/Action assignees are COMPLETED? → A: **A** — Document Control only, consistent with ADR-016 and FR-C08. Guarded by `CaslAbilityGuard` with `ability.can('close', 'Circulation')`.
- Q: Should EC-CORR-001 (cancel Correspondence → force-close Circulations) trigger notifications to affected assignees? → A: **A** — Yes, BullMQ notification (email + in-app) to all affected assignees with pending routings (ADR-008). Job payload: `circulationNo`, `correspondenceNo`, `cancellationReason`. See FR-X05.
- Q: Where should the Overdue determination for Circulation deadline (EC-CIRC-003) be computed? → A: **A** — Server-side. Backend returns `isOverdue: boolean` in the Circulation response; client renders badge based solely on this field. Unit test targets `CirculationService` with mocked server time. See FR-C03, SC-007.
- Q: Where does `workflowInstanceId` bind in the data model for a Transmittal? → A: **A** — On the `correspondences` row; join via `workflow_instances ON reference_type = 'TRANSMITTAL' AND reference_id = correspondences.id`. No new FK column needed. See FR-T05 and updated Assumptions.
- Q: Should Force Close Circulation (FR-C08) be synchronous or asynchronous, and what is the latency SLA? → A: **A** — Synchronous; all routing updates + audit log in a single DB transaction; `200 OK` after commit; BullMQ notifications enqueued post-commit. SLA: ≤ 3 seconds for up to 50 routings. See FR-C08, SC-008.
---
@@ -0,0 +1,279 @@
# Tasks: Transmittals + Circulation Complete Integration (v1.8.8 + Session 2026-05-03 Clarifications)
**Branch**: `001-transmittals-circulation`
**Total Tasks**: 46 (27 v1.8.7 + 19 v1.8.8 Phase 4) | **Spec**: `specs/001-transmittals-circulation/spec.md`
**Last Updated**: 2026-05-03 — Added B9c, B10, B11, T3, expanded T2/I1 from Session 2026-05-03 clarifications
---
## Phase 1 — Backend Foundation (Critical — blocks all frontend work)
> **Goal**: Expose `workflowInstanceId` from both backend services; implement all EC handlers.
> **Join pattern**: `workflow_instances WHERE entity_type = ? AND entity_id = ?` (string) — no new FK columns.
- [x] T001 Implement `WorkflowEngineService.getInstanceByEntity(entityType, entityId)` — query `workflow_instances WHERE entity_type = ? AND entity_id = ?`; return `{ id, currentState, availableActions? } | null` in `backend/src/modules/workflow-engine/workflow-engine.service.ts`
- [x] T002 [P] Update `TransmittalService.findOneByUuid()` — call `getInstanceByEntity('TRANSMITTAL', correspondences.id)`, merge `workflowInstanceId`, `workflowState` into response in `backend/src/modules/transmittal/transmittal.service.ts`
- [x] T003 [P] Add `purpose?: string` to `SearchTransmittalDto` and apply `andWhere` filter in `TransmittalService.findAll()` in `backend/src/modules/transmittal/transmittal.service.ts`
- [x] T004 Add `TransmittalService.submit(uuid, user)` — pre-check all `transmittal_items` for DRAFT correspondence (EC-RFA-004); throw `422 ValidationException` identifying offending doc; then call `workflowEngine.createInstance` + transition `SUBMIT` in `backend/src/modules/transmittal/transmittal.service.ts`
- [x] T005 Add `POST /:uuid/submit` endpoint with `@UseGuards(JwtAuthGuard, CaslAbilityGuard)` + `@Audit('transmittal.submit', 'transmittal')` in `backend/src/modules/transmittal/transmittal.controller.ts`
- [x] T006 [P] Update `CirculationService.findOneByUuid()` — call `getInstanceByEntity('CIRCULATION', circulation.id)`, merge `workflowInstanceId`, `workflowState`; compute `isOverdue: boolean` **server-side** per routing (`NOW() > deadline_date + INTERVAL 1 DAY`) in `backend/src/modules/circulation/circulation.service.ts`
- [x] T007 [P] Add `CirculationService.reassignRouting(routingId, newAssigneeUuid, user)` — verify `ability.can('reassign', 'Circulation')` (Document Control+); resolve UUID→INT via `uuidResolver`; update `routing.assignedTo`; write audit log in `backend/src/modules/circulation/circulation.service.ts`
- [x] T008 [P] Add `CirculationService.forceClose(uuid, reason, user)` — single `queryRunner` transaction: update all PENDING routings to `CANCELLED`, set `circulation.statusCode = 'CANCELLED'`, write audit log; enqueue BullMQ `notification-queue` job **post-commit** per affected assignee (payload: `{ circulationNo, correspondenceNo, cancellationReason }`); verify `ability.can('forceClose', 'Circulation')` in `backend/src/modules/circulation/circulation.service.ts`
- [x] T009 Add `CirculationService.close(uuid, user)` — verify `ability.can('close', 'Circulation')` (Document Control only); pre-condition check: ALL Main/Action routings must be COMPLETED (throw `422` if not); update `circulation.statusCode = 'CLOSED'`; write audit log in `backend/src/modules/circulation/circulation.service.ts`
- [x] T010 Add PATCH `/:uuid/routing/:routingId/reassign` + POST `/:uuid/force-close` endpoints with `@UseGuards(JwtAuthGuard, CaslAbilityGuard)` in `backend/src/modules/circulation/circulation.controller.ts`
- [x] T011 Add POST `/:uuid/close` endpoint with `@UseGuards(JwtAuthGuard, CaslAbilityGuard)` (`ability.can('close', 'Circulation')`) + `@Audit('circulation.close', 'circulation')` in `backend/src/modules/circulation/circulation.controller.ts`
- [x] T012 Add EC-CORR-001 cascade handler in `CorrespondenceService.cancel()` — on cancel: find all OPEN Circulations for this correspondence; call `CirculationService.forceClose()` per Circulation; enqueue BullMQ `notification-queue` job per **affected assignee with pending routing** (payload: `{ circulationNo, correspondenceNo, cancellationReason }`); write combined audit log in `backend/src/modules/correspondence/correspondence.service.ts`
---
## Phase 2 — Frontend Types & Hooks (Important — depends on Phase 1 API shape)
- [x] T013 [P] Update `Transmittal` interface — add `workflowInstanceId?: string`, `workflowState?: string`, `availableActions?: string[]`; add `isOverdue?: boolean` to `CirculationRouting` (backend-provided, no client computation); no `any` types (ADR-019) in `frontend/types/transmittal.ts`
- [x] T014 [P] Update `Circulation` and `CirculationRouting` interfaces — add `workflowInstanceId?: string`, `workflowState?: string`, `availableActions?: string[]`; add `isOverdue: boolean`, `deadline?: string`, `assigneeType?: 'MAIN' | 'ACTION' | 'INFORMATION'` to `CirculationRouting` in `frontend/types/circulation.ts`
- [x] T015 Create `useTransmittal(uuid: string | undefined)` TanStack Query hook — `queryKey: ['transmittal', uuid]`, `staleTime: 60_000`, export `transmittalKeys` factory in `frontend/hooks/use-transmittal.ts`
- [x] T016 Add `useCirculation(uuid: string | undefined)` hook — `queryKey: ['circulation', uuid]`, `staleTime: 60_000` in `frontend/hooks/use-circulation.ts`
---
## Phase 3 — US1: Transmittal Workflow-Wired Detail Page (P1 🎯 MVP)
> **Story Goal**: Doc Control officer sees live doc number, `workflowState`, and action buttons in `IntegratedBanner`; Workflow tab shows full timeline.
> **Independent Test**: Navigate to `/transmittals/{uuid}` — verify `IntegratedBanner` shows real state + actions; Workflow tab renders at least creation step; `pnpm tsc --noEmit` zero errors.
- [x] T017 [US1] Wire `IntegratedBanner` with `instanceId={transmittal.workflowInstanceId}`, `workflowState`, `availableActions` from `useTransmittal()` hook; wire `WorkflowLifecycle` in Workflow tab with `useWorkflowHistory(instanceId)` in `frontend/app/(dashboard)/transmittals/[uuid]/page.tsx`
---
## Phase 4 — US2: Circulation Workflow-Wired Detail Page (P1 🎯 MVP)
> **Story Goal**: Doc Control sees circulation number, assignees with deadline, Overdue badge (from backend `isOverdue` field), full workflow timeline, and "Close Circulation" button (Document Control only when all Main/Action COMPLETED).
> **Independent Test**: Navigate to `/circulation/{uuid}` — verify `IntegratedBanner` shows `circulationNo`, `statusCode`; Overdue badge appears when `routing.isOverdue === true`; "Close Circulation" hidden for non-Document Control users.
- [x] T018 [US2] Wire `IntegratedBanner` + `WorkflowLifecycle` from `useCirculation()` + `useWorkflowHistory()` in `frontend/app/(dashboard)/circulation/[uuid]/page.tsx`
- [ ] T019 [US2] Add Overdue badge to routing rows — render badge when `routing.isOverdue === true` (backend field only; **FORBIDDEN: no client-side `new Date()` comparison**); highlight `deadline` date in red in `frontend/app/(dashboard)/circulation/[uuid]/page.tsx`
- [ ] T020 [US2] Show "Close Circulation" button conditionally — visible only when `user.role === 'DOCUMENT_CONTROL'` AND all Main/Action routings are `COMPLETED`; calls `POST /:uuid/close`; hide completely for all other roles in `frontend/app/(dashboard)/circulation/[uuid]/page.tsx`
---
## Phase 5 — US3: Transmittal List Page with Search & Filter (P1)
> **Story Goal**: Doc Control browses transmittals, filters by `purpose`, searches by doc number/subject within 500ms.
> **Independent Test**: Navigate to `/transmittals` — purpose filter updates list; search filters within 500ms; empty state shown when no results.
- [x] T021 [US3] Add `purpose` select filter (FOR_APPROVAL / FOR_INFORMATION / FOR_REVIEW / OTHER) and verify pagination + search in `frontend/app/(dashboard)/transmittals/page.tsx`
---
## Phase 6 — US4: EC-RFA-004 Submit Validation (P2)
> **Story Goal**: Doc Control blocked from submitting Transmittal with DRAFT items; 422 error clearly identifies the offending document.
> **Independent Test**: Create Transmittal with DRAFT item → submit → expect 422 with offending doc number in error message; item highlighted in UI.
- [x] T022 [US4] Verify UI shows 422 error from `POST /transmittals/:uuid/submit` with item-level identification — display `userMessage` from ADR-007 error response in `frontend/app/(dashboard)/transmittals/[uuid]/page.tsx`
---
## Phase 7 — US5: Circulation Edge Cases — Re-assign & Force Close (P2)
> **Story Goal**: Doc Control can re-assign deactivated assignee (EC-CIRC-001) and force-close stuck Circulation with mandatory reason (EC-CIRC-002).
> **Independent Test**: Deactivate assignee in OPEN Circulation → Re-assign button visible. Force-close with reason → status CANCELLED, reason in audit log.
- [x] T023 [US5] Add Re-assign UI — show "Re-assign" action button for deactivated assignee routings; open modal with user search; calls `PATCH /:uuid/routing/:routingId/reassign` in `frontend/app/(dashboard)/circulation/[uuid]/page.tsx`
- [x] T024 [US5] Add Force Close UI — "Force Close" button (Document Control only); modal requires mandatory reason field; calls `POST /:uuid/force-close`; invalidate `['circulation', uuid]` query on success in `frontend/app/(dashboard)/circulation/[uuid]/page.tsx`
---
## Phase 8 — Tests (Tier 2 — required before merge)
- [x] T025 Unit test `TransmittalService.submit()` — throws `ValidationException` (422) when any item correspondence is DRAFT; passes when all items are SUBMITTED/APPROVED in `backend/src/modules/transmittal/transmittal.service.spec.ts`
- [x] T026 Unit test `CirculationService.reassignRouting()` — permission check throws 403 for non-Document Control; updates `routing.assignedTo` correctly in `backend/src/modules/circulation/circulation.service.spec.ts`
- [x] T027 Unit test `CirculationService.forceClose()` — all PENDING routings set to CANCELLED in single transaction; mandatory reason logged; BullMQ `notification-queue` job enqueued post-commit (verify with mock queue) in `backend/src/modules/circulation/circulation.service.spec.ts`
- [ ] T028 Unit test `CirculationService.findOneByUuid()``isOverdue: boolean` computed correctly via server-side logic with mocked `Date` (or injected `ClockService`): `true` when `now > deadline + 1 day`, `false` when `now <= deadline + 1 day`, `false` when `deadline` is null (SC-007) in `backend/src/modules/circulation/circulation.service.spec.ts`
- [ ] T029 Unit test `CirculationService.close()` — throws 403 for non-Document Control; throws 422 when any Main/Action routing is not COMPLETED; succeeds and sets status to CLOSED when all COMPLETED in `backend/src/modules/circulation/circulation.service.spec.ts`
- [ ] T030 Unit test EC-CORR-001 — `CorrespondenceService.cancel()` enqueues BullMQ notification job per affected assignee; payload includes `circulationNo`, `correspondenceNo`, `cancellationReason`; audit log written in `backend/src/modules/correspondence/correspondence.service.spec.ts`
- [ ] T031 Integration test `CirculationService.forceClose()` with 50 routings — total transaction time ≤ 3 seconds (SC-008); use bulk UPDATE query not loop in `backend/src/modules/circulation/circulation.service.spec.ts`
- [ ] T032 Frontend unit test — Overdue badge renders when `routing.isOverdue === true` (no client-side date math); badge absent when `routing.isOverdue === false`; snapshot test for both states in `frontend/app/(dashboard)/circulation/[uuid]/__tests__/page.test.tsx`
- [ ] T033 Frontend unit test — "Close Circulation" button visible for `DOCUMENT_CONTROL` role only; hidden for `SUPERVISOR`, `ASSIGNEE`, `VIEWER` roles in `frontend/app/(dashboard)/circulation/[uuid]/__tests__/page.test.tsx`
---
## Phase 9 — i18n Polish (Guidelines)
- [ ] T034 [P] Add Thai i18n keys for force-close modal, close action, overdue badge, EC-CORR-001 notification in `frontend/public/locales/th/circulation.json` — keys: `circulation.forceClose.title`, `circulation.forceClose.reason`, `circulation.close.confirm`, `circulation.overdue`, `circulation.notification.cancelledByCorrespondence`
- [ ] T035 [P] Add English i18n keys matching Thai keys above in `frontend/public/locales/en/circulation.json`
---
## Phase 10 — Transmittal Revision Refactor (v1.8.8 — Based on Clarifications 2026-04-29)
> **Goal**: Restructure Transmittal to follow Master-Revision Pattern (like RFA/Correspondence).
- [ ] T036 Schema: Add `revision_id INT NULL` with FK to `correspondence_revisions(id)` + index to `transmittal_items`; add `item_type VARCHAR(50) NULL` column (ADR-009 direct SQL) in `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`
- [ ] T037 Update `TransmittalItem` entity — add nullable `revisionId` column + `@ManyToOne` relation to `CorrespondenceRevision`; add `itemType?: string` column in `backend/src/modules/transmittal/entities/transmittal-item.entity.ts`
- [ ] T038 Update `TransmittalService.findOneByUuid()` — join `correspondence_revisions`, read `purpose`/`remarks` from `details` JSON; include `revisionId`, `revisionNumber`, `revisionLabel` in response in `backend/src/modules/transmittal/transmittal.service.ts`
- [ ] T039 Add `TransmittalService.copyItemsToRevision(oldRevisionId, newRevisionId)` helper — bulk clone `transmittal_items` rows in single atomic transaction in `backend/src/modules/transmittal/transmittal.service.ts`
- [ ] T040 Add `TransmittalService.createRevision(uuid, user)` — create `correspondence_revisions` record; auto-copy all items via `copyItemsToRevision()` in `backend/src/modules/transmittal/transmittal.service.ts`
- [ ] T041 Add `POST /:uuid/revisions` endpoint with `@UseGuards(JwtAuthGuard, CaslAbilityGuard)` + `@Audit('transmittal.create-revision', 'transmittal')`; returns `{ revisionId, revisionNumber, revisionLabel }` in `backend/src/modules/transmittal/transmittal.controller.ts`
- [ ] T042 Update `TransmittalService.submit()` — EC-RFA-004 checks current revision items only; workflow instance binds to `correspondence_revisions.publicId` (UUID string, ADR-019 — NOT revision.id INT) in `backend/src/modules/transmittal/transmittal.service.ts`
- [ ] T043 Update `TransmittalService.create()` — write `purpose`/`remarks` to `CorrespondenceRevision.details` JSON; replace hardcoded `ORG_CODE: 'ORG'` with real org lookup (`organizationCode` from `Organization` entity, pattern: `correspondence.service.ts:263-269`); save `itemType` from DTO in `backend/src/modules/transmittal/transmittal.service.ts`
- [ ] T044 Schema: Drop `purpose` and `remarks` from `transmittals` table (ADR-009 direct SQL — deploy AFTER T043 is live); remove corresponding TypeORM columns from entity in `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` + `backend/src/modules/transmittal/transmittal.entity.ts`
- [ ] T045 Fix `TransmittalItemDto.itemId` — change `itemId: number` + `@IsInt()``itemId: string` + `@IsUUID()`; resolve UUID→INT via `uuidResolver.resolveCorrespondenceId()` in service (ADR-019 CRITICAL) in `backend/src/modules/transmittal/dto/create-transmittal.dto.ts` + `backend/src/modules/transmittal/transmittal.service.ts`
- [ ] T046 [P] Add revision fields to frontend types — `revisionId?: string`, `revisionNumber?: number`, `revisionLabel?: string` to `Transmittal`; `revisionId?: string` to `TransmittalItem`; update `useTransmittal(uuid, revisionId?)` hook in `frontend/types/transmittal.ts` + `frontend/hooks/use-transmittal.ts`
- [ ] T047 Add revision selector to Transmittal detail page — dropdown when multiple revisions exist (pattern: RFA detail page); display `revisionLabel` (A, B, C) in `IntegratedBanner` in `frontend/app/(dashboard)/transmittals/[uuid]/page.tsx`
- [ ] T048 ADR-019 compliance scan — verify all revision-related fields use `publicId` (string UUID) not `id` (INT) in both backend responses and frontend types; run `grep -rn "parseInt\|Number(\|\.id[^a-zA-Z]"` on new code in `backend/src/modules/transmittal/` + `frontend/types/transmittal.ts`
---
## Dependency Graph
```
Phase 1 (Backend Foundation):
T001 → T002 [P], T006 [P] (workflow instance join)
T001 → T004 → T005 (submit + EC-RFA-004)
T007 [P], T008 [P] (reassign + force-close — parallel)
T009 → T011 (close Circulation — Document Control only)
T012 (EC-CORR-001 cascade — no Phase 1 deps)
Phase 2 (Types & Hooks):
T013 [P], T014 [P] (types — parallel, no Phase 1 deps)
T013 → T015 (useTransmittal hook)
T014 → T016 (useCirculation hook)
Phase 3 (US1 — Transmittal Detail):
T015, T002 → T017
Phase 4 (US2 — Circulation Detail):
T016, T006, T009, T011 → T018, T019, T020
Phase 5 (US3 — List):
T013, T003 → T021
Phase 6 (US4 — EC-RFA-004 UI):
T005, T017 → T022
Phase 7 (US5 — EC-CIRC-001/002):
T007, T008, T018 → T023, T024
Phase 8 (Tests):
T004 → T025
T007 → T026
T008 → T027
T006 → T028
T009 → T029
T012 → T030
T008, T031 (integration — 50 routings ≤3s)
T019 → T032
T020 → T033
Phase 10 (Revision Refactor):
T036 → T037 → T038 → T039, T040 → T041
T038 → T042 (submit revision-scoped)
T038 → T043 (create writes to details JSON)
T043 deployed → T044 (drop columns)
T045 (UUID fix — parallel)
T046 [P] → T047
T038, T046 → T048 (ADR-019 scan)
```
---
## Parallel Execution Opportunities
| Group | Tasks | Condition |
|---|---|---|
| Backend foundation | T002, T003, T006, T007, T008, T012 | All start after T001 |
| Types | T013, T014 | Immediately (no Phase 1 deps) |
| Hooks | T015, T016 | After respective types |
| Detail pages | T017, T018 | After hooks + backend |
| Tests | T025T031 | After their respective service methods |
| i18n | T034, T035 | After Phase 4 UI complete |
| Revision types | T046 | Parallel with schema (T036) |
---
## Implementation Strategy (MVP → Full)
| Scope | Tasks | Deliverable |
|---|---|---|
| **MVP** (US1 + US2 core) | T001T009, T013T020 | Both detail pages live with workflow data |
| **P1 Complete** | + T021, T022 | List page + submit validation |
| **P2 Complete** | + T023, T024, T028T033 | EC edge cases + all tests |
| **Full** | + T034T048 | i18n polish + Revision Refactor |
---
## Commit Message Convention
```
feat(workflow-engine): add getInstanceByEntity() for Transmittal+Circulation join
feat(transmittal): expose workflowInstanceId via entity_type join in findOneByUuid
feat(transmittal): add submit endpoint with EC-RFA-004 DRAFT item validation
feat(circulation): expose workflowInstanceId + server-side isOverdue in findOneByUuid
feat(circulation): add reassignRouting EC-CIRC-001 handler with CASL guard
feat(circulation): add forceClose EC-CIRC-002 — single transaction + BullMQ post-commit
feat(circulation): add close endpoint — Document Control only (FR-C09)
feat(correspondence): add EC-CORR-001 cascade — force-close Circulations + BullMQ notify (FR-X05)
feat(frontend): wire WorkflowLifecycle in transmittal detail page (US1)
feat(frontend): wire WorkflowLifecycle + server-side overdue badge in circulation detail (US2)
feat(frontend): add Close Circulation button — Document Control role guard (FR-C09)
test(circulation): isOverdue server-side unit test with mocked Date (SC-007)
test(circulation): close() RBAC + pre-condition unit tests
test(circulation): forceClose integration test ≤3s / 50 routings (SC-008)
test(correspondence): EC-CORR-001 BullMQ notification enqueue test
feat(schema): add revision_id + item_type to transmittal_items (ADR-009)
fix(transmittal): TransmittalItemDto.itemId INT→UUID string (ADR-019)
fix(transmittal): replace hardcoded ORG_CODE with real organizationCode lookup
```
---
## Security Verification Checklist
- [ ] `ability.can('reassign', 'Circulation')` — 403 for non-Document Control (T007)
- [ ] `ability.can('forceClose', 'Circulation')` — 403 for non-Document Control (T008)
- [ ] `ability.can('close', 'Circulation')` — 403 for all non-Document Control roles (T009)
- [ ] Close Circulation pre-condition: 422 if any Main/Action routing not COMPLETED (T009)
- [ ] `Idempotency-Key` header enforced on all workflow transitions
- [ ] `workflowInstanceId` is `string` (UUID) — never `number` in any response (ADR-019)
- [ ] EC-CORR-001 notification is BullMQ job — NOT inline in request thread (ADR-008)
- [ ] Frontend `isOverdue` from backend field only — no `new Date()` comparison in JSX (T019)
- [ ] No `parseInt` / `Number()` / `+` on any UUID in new code (ADR-019)
- [ ] Two-phase file upload via `StorageService` for any new file operations (ADR-016)
@@ -0,0 +1,37 @@
# Test Report
**Date**: 2026-05-03
**Framework**: Jest
**Status**: PASS (Thresholds partially unmet)
## Summary
| Metric | Value |
| ----------- | ----- |
| Total Tests | 340 |
| Passed | 340 |
| Failed | 0 |
| Skipped | 0 |
| Duration | 40.7s |
| Coverage | Varies (some thresholds unmet) |
## Failed Tests
No tests failed. All 340 tests passed successfully.
However, the test suite exited with code `1` because some files did not meet the configured coverage thresholds (e.g., `virtual-column.service.ts`, `metrics.service.ts`, `maintenance-mode.guard.ts`).
## Coverage by File (Notable Exceptions)
| File | Lines | Branches | Functions |
| ----------- | ----- | -------- | --------- |
| `src/modules/json-schema/services/virtual-column.service.ts` | 22.97% | < 80% | 0% |
| `src/modules/monitoring/services/metrics.service.ts` | 68.75% | 0% | 0% |
| `src/common/guards/maintenance-mode.guard.ts` | 0% | 0% | 0% |
| `src/common/interceptors/idempotency.interceptor.ts` | >90% | 88.23% | >90% |
| `src/common/interceptors/performance.interceptor.ts` | >90% | 88.88% | >90% |
## Next Actions
1. The test coverage for recently modified files (`correspondence.service.ts`, `circulation.service.ts`, `circulation.controller.ts`) is passing the tests correctly, but the overall project thresholds are still failing in some unrelated utility modules.
2. Consider increasing coverage in `virtual-column.service.ts` and `metrics.service.ts` to satisfy global coverage thresholds.
@@ -0,0 +1,192 @@
# Implementation Plan: ADR-021 Integrated Workflow Context & Step-specific Attachments
**Branch**: `200-fullstacks/202-adr-021-integrated-workflow-context` | **Date**: 2026-04-12 | **Spec**: [spec.md](./spec.md)
**Location**: `specs/200-fullstacks/202-adr-021-integrated-workflow-context/`
**Input**: Feature specification from ADR-021
---
## Summary
ปรับปรุง Workflow Engine ให้รองรับ (1) **Integrated Banner** ที่ยุบรวม Metadata + Status + Actions ไว้ด้วยกัน (2) **Vertical Timeline Lifecycle** พร้อม Active Step Highlighting และ (3) **Step-specific Attachments** ที่เชื่อมโยงไฟล์แนบกับ `workflow_history` ของแต่ละขั้นตอนโดยตรง
แนวทางเทคนิค: ขยาย `workflow_histories` ด้วย FK ใน `attachments` (Nullable) + ขยาย `WorkflowTransitionDto` รับ `attachmentPublicIds` (pre-uploaded UUIDv7 list) + สร้าง Frontend components ใหม่ 4 ชิ้น
---
## Technical Context
**Language/Version**: TypeScript 5.x (strict mode), Node.js 20+
**Primary Dependencies**:
- Backend: NestJS 10, TypeORM 0.3, MariaDB 10.6+, Redis (Redlock), BullMQ
- Frontend: Next.js 14 (App Router), TailwindCSS 3.4, shadcn/ui, TanStack Query v5, React Hook Form + Zod
**Storage**: MariaDB (schema via SQL delta — ADR-009), MinIO / Local FS via `StorageService`
**Testing**: Jest (backend unit + e2e), Vitest (frontend)
**Target Platform**: QNAP Container Station (Docker), Browser (Chrome/Edge latest)
**Project Type**: Web application (backend/ + frontend/ monorepo)
**Performance Goals**: (1) Workflow history + attachment join query < 200ms p95 (mitigated by Redis Cache TTL 1h); (2) `POST /instances/:id/transition` (พร้อม file) P95 ≤ 5 วินาที สำหรับ file ≤ 10MB (รวม ClamAV + Redlock + DB transaction)
**Constraints**: No TypeORM migrations (ADR-009); UUID via `publicId` only (ADR-019); ClamAV scan mandatory (ADR-016); BullMQ for all async jobs (ADR-008)
**Scale/Scope**: ~50 concurrent users, documents in hundreds per project
---
## Constitution Check
_GATE: Checked against `.windsurfrules` before Phase 0. Re-verified after Phase 1._
| Gate | Status | Notes |
|------|--------|-------|
| **🔴 UUID Pattern (ADR-019)** | ✅ PASS | All attachment references via `publicId` (UUIDv7 string). `workflow_history_id` FK value is CHAR(36) UUID from `workflow_histories.id`. No `parseInt` usage. |
| **🔴 Schema via SQL Delta (ADR-009)** | ✅ PASS | Delta file `04-add-workflow-history-id-to-attachments.sql` — no TypeORM migration |
| **🔴 Two-Phase Upload (ADR-016)** | ✅ PASS | Files uploaded via existing Two-Phase endpoint first; `publicId`s referenced in transition DTO |
| **🔴 ClamAV Scan (ADR-016)** | ✅ PASS | ClamAV scan runs during Phase 1 of file upload (before transition) |
| **🔴 CASL Guard (ADR-016)** | ✅ PASS | New `WorkflowTransitionGuard` implements 4-Level RBAC |
| **🔴 Idempotency-Key (Security Rule #1)** | ✅ PASS | `POST /instances/:id/transition` validates `Idempotency-Key` header |
| **🔴 BullMQ Async (ADR-008)** | ✅ PASS | Notifications dispatched via `WorkflowEventService` (existing BullMQ pattern) |
| **🔴 No `any` types** | ✅ PASS | All new types fully typed |
| **🟡 Thin Controller** | ✅ PASS | Controller delegates to Service; Guard handles RBAC |
| **🟡 Test Coverage 80% business logic** | ⚠️ REQUIRED | See testing plan in Phase 3 |
| **🔴 Redis Redlock (ADR-002)** | ✅ PASS | Redlock applied to `instanceId` during `processTransition()` — Fail-closed: Retry 3x (500ms exponential backoff) → HTTP 503 if Redis unavailable |
| **🔴 Upload State Restriction** | ✅ PASS | Step-attachment upload permitted only in `PENDING_REVIEW`/`PENDING_APPROVAL`; Terminal states (`APPROVED`,`REJECTED`,`CLOSED`) → HTTP 409 |
---
## Project Structure
### Documentation (this feature)
```text
specs/feat/adr-021-integrated-workflow-context/
├── spec.md # Feature specification
├── plan.md # This file
├── tasks.md # Generated by speckit-tasks
└── checklists/ # Quality checklists
```
### Source Code (impacted files)
```text
# 🔴 Backend — DB & Entities
specs/03-Data-and-Storage/deltas/
└── 04-add-workflow-history-id-to-attachments.sql [NEW]
backend/src/common/file-storage/entities/
└── attachment.entity.ts [MODIFY — add workflowHistoryId + relation]
backend/src/modules/workflow-engine/entities/
└── workflow-history.entity.ts [MODIFY — add OneToMany attachments]
# 🔴 Backend — API & Guards
backend/src/modules/workflow-engine/dto/
└── workflow-transition.dto.ts [MODIFY — add attachmentPublicIds]
backend/src/modules/workflow-engine/guards/
└── workflow-transition.guard.ts [NEW — 4-Level RBAC]
backend/src/modules/workflow-engine/
├── workflow-engine.service.ts [MODIFY — extend processTransition()]
├── workflow-engine.controller.ts [MODIFY — add idempotency header, guard]
└── workflow-engine.module.ts [MODIFY — register guard]
# 🟡 Frontend — Types
frontend/types/
└── workflow.ts [MODIFY — add attachments to WorkflowHistoryStep]
frontend/types/dto/workflow-engine/
└── workflow-engine.dto.ts [MODIFY — add WorkflowTransitionWithAttachmentsDto]
# 🟡 Frontend — New Components
frontend/components/workflow/
├── integrated-banner.tsx [NEW — Status + Metadata + Action bar]
└── workflow-lifecycle.tsx [NEW — Vertical timeline with Indigo active step]
frontend/components/common/
└── file-preview-modal.tsx [NEW — PDF/Image inline preview]
# 🟡 Frontend — New Hook
frontend/hooks/
└── use-workflow-action.ts [NEW — upload + transition orchestration]
# 🟡 Frontend — Page Refactors (use new components)
frontend/app/(dashboard)/rfas/[uuid]/page.tsx [MODIFY — integrate IntegratedBanner + WorkflowLifecycle]
frontend/app/(dashboard)/transmittals/[uuid]/page.tsx [MODIFY — same as RFA]
frontend/app/(dashboard)/circulation/[uuid]/page.tsx [MODIFY — same as RFA]
frontend/app/(dashboard)/correspondences/[uuid]/page.tsx [MODIFY — same as RFA]
```
---
## Complexity Tracking
_No constitution violations. Architecture is additive (Nullable FK, extended DTO, new components)._
---
## Design Decisions
### Data Model
- `attachments.workflow_history_id` = `CHAR(36) NULL` FK → `workflow_histories.id`
- `ON DELETE SET NULL` (preserve attachment records if history row deleted)
- Composite index: `INDEX idx_att_wfhist_created (workflow_history_id, created_at)`
- `WorkflowHistory` gains `@OneToMany(() => Attachment, a => a.workflowHistory)`**lazy-loaded only**
### API Contract
**Extended `POST /workflow-engine/instances/:instanceId/transition`:**
```
Header: Idempotency-Key: <UUIDv7>
Body: {
action: string // existing
comment?: string // existing
payload?: Record // existing
attachmentPublicIds?: string[] // NEW — UUIDv7 list of pre-uploaded attachments
}
```
**New `GET /workflow-engine/instances/:instanceId/history`:**
```
Response: WorkflowHistoryItem[] with nested attachments[] per step
```
### Frontend Architecture
3 new components follow **compound pattern**:
- `<IntegratedBanner>` — Status + Metadata + Action bar
- `<WorkflowLifecycle>` — Vertical timeline, Indigo active step (pulse animation)
- `<FilePreviewModal>` — PDF iframe / Image viewer
**`use-workflow-action` hook responsibilities:**
1. Validate `Idempotency-Key` (generate UUIDv7 once per action intent)
2. Guard: Check `currentState ∈ {PENDING_REVIEW, PENDING_APPROVAL}` before transition
3. Ensure all `attachmentPublicIds` are committed (not temp) before transition
4. Call `POST /instances/:id/transition` with `Idempotency-Key` header
5. Handle HTTP 503 → toast "ระบบยุ่ง กรุณาลองใหม่"
6. Invalidate TanStack Query cache for the document + workflow instance
**Modules in scope (v1.8.6):** RFA, Transmittal, Circulation, Correspondence (4 modules)
---
## Risk Register
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| N+1 query on history + attachments join | Medium | High | Eager-load only when explicitly querying history; Redis cache TTL 1h |
| Race condition: 2 users upload to same step simultaneously | Low | High | Redis Redlock on `instanceId` — only 1 transition allowed at a time |
| Attachment linked to wrong history record | Low | High | `processTransition()` creates history row first, then links attachments in same transaction |
| ClamAV timeout during upload | Low | Medium | Upload endpoint has its own timeout; transition is decoupled |
| Frontend: stale workflow state after transition | Medium | Medium | `use-workflow-action` hook invalidates TanStack Query cache on success |
---
## Dependencies Map
```
ADR-021
├── ADR-001 (Workflow Engine DSL) — extends processTransition()
├── ADR-002 (Redis Redlock) — existing lock pattern applied to transition
├── ADR-016 (Security) — Two-Phase upload, ClamAV, CASL Guard
├── ADR-019 (UUID) — publicId for all attachment references
└── ADR-008 (BullMQ) — notification dispatch (unchanged, existing pattern)
```
@@ -0,0 +1,162 @@
# Feature Specification: Integrated Workflow Context & Step-specific Attachments
**Feature Branch**: `200-fullstacks/202-adr-021-integrated-workflow-context`
**Created**: 2026-05-03
**Status**: Draft
**Input**: ADR-021 Integrated Workflow Context & Step-specific Attachments
**Location**: `specs/200-fullstacks/202-adr-021-integrated-workflow-context/`
---
## Clarifications
### Session 2026-04-12 (from ADR-021)
- **Q:** What are the file size and attachment count limits per workflow step? → **A:** No explicit limit (controlled by infrastructure only)
- **Q:** What are the specific values and storage format for the "Priority" field in the Integrated Banner? → **A:** Enum "URGENT", "HIGH", "MEDIUM", "LOW" — 4-tier system with visual indicators
- **Q:** How should the system handle virus/malware detection during step-specific file upload? → **A:** Block upload immediately, delete temp file, show error "File rejected" to user
- **Q:** What is the cache TTL for Workflow History data to reduce join query overhead? → **A:** 1 hour — balanced cache duration for workflow history data
- **Q:** Who is authorized to upload step-specific attachments during a workflow transition? → **A:** Only assigned handler can upload; superadmin and organization admin can upload on behalf (impersonation)
### Session 2026-04-19 (from ADR-021)
- **Q:** Which workflow states allow step-specific attachment upload? → **A:** Only Active-decision states (`PENDING_REVIEW`, `PENDING_APPROVAL`) — Terminal states (`APPROVED`, `REJECTED`, `CLOSED`) are not allowed
- **Q:** What happens if Redis Redlock fails during transition? → **A:** Fail-closed — Retry 3 times (500ms exponential backoff) then throw HTTP 503 "Service temporarily unavailable" to preserve data integrity
- **Q:** Which modules must support step-specific attachments in v1.8.6? → **A:** **All 4 modules** — RFA, Transmittal, Circulation, and Correspondence
- **Q:** Performance target for Upload + Transition API? → **A:** P95 ≤ 5 seconds for files ≤10MB (ClamAV scan + Redlock + DB transaction included)
---
## User Scenarios & Testing
### User Story 1 - Integrated Banner (Priority: P1) 🎯 MVP
As a Reviewer/Approver, I want to see all critical document information (Doc No, Subject, Status, Priority) and available actions in a single header bar without scrolling or switching screens, so I can make approval decisions quickly with full context.
**Why this priority**: This is the core UX improvement of ADR-021. Without the Integrated Banner, users waste time scrolling and switching between document content and workflow controls.
**Independent Test**: The IntegratedBanner component can be rendered with mock RFA/Transmittal/Circulation/Correspondence data, verifying Priority badge colors, Status display, and Action button visibility. Buttons must be disabled when workflow is in terminal states.
**Acceptance Scenarios**:
1. **Given** an RFA in `PENDING_APPROVAL` state with `URGENT` priority, **When** I open the detail page, **Then** I see the Doc No, Subject, red URGENT badge, status badge, and Approve/Reject/Return buttons in a sticky header
2. **Given** a Transmittal in `APPROVED` state, **When** I view the detail page, **Then** action buttons are disabled and the status shows as completed
3. **Given** a Correspondence in `PENDING_REVIEW` state with `MEDIUM` priority, **When** I open the detail page, **Then** the priority badge shows yellow and all workflow actions are available
---
### User Story 2 - Workflow Lifecycle Visualization (Priority: P1) 🎯 MVP
As a document participant, I want to see a vertical timeline showing all workflow steps with the current step highlighted, so I understand where the document is in the approval process and what steps remain.
**Why this priority**: Users currently lack visibility into workflow progress. The vertical timeline provides immediate orientation and reduces confusion about approval status.
**Independent Test**: The WorkflowLifecycle component can be rendered with mock workflow history data containing completed, current, and pending steps. Verify current step has Indigo (#6366f1) color with pulse animation.
**Acceptance Scenarios**:
1. **Given** a 4-step RFA workflow where step 2 is current, **When** I view the Workflow tab, **Then** step 1 shows as completed (with actor/date), step 2 shows Indigo with pulse, steps 3-4 are muted/pending
2. **Given** a Circulation workflow with comments on completed steps, **When** I view the timeline, **Then** each completed step shows the handler name, action date, and any comments
3. **Given** a Transmittal in terminal state, **When** I view the timeline, **Then** the final step is marked complete and no pulse animation is shown
---
### User Story 3 - Step-specific Attachments (Priority: P2)
As a Reviewer, I want to upload supporting documents (images, PDFs) that are specifically linked to the current workflow step, so reviewers can see exactly which evidence was provided for each approval decision.
**Why this priority**: Currently all attachments are mixed at the document level. Step-specific attachments provide audit trail clarity and improve compliance tracking.
**Independent Test**: Upload files during a workflow transition in `PENDING_REVIEW` state, then verify via API that `attachments.workflow_history_id` is set correctly. Files uploaded in terminal states must be rejected with HTTP 409.
**Acceptance Scenarios**:
1. **Given** an RFA in `PENDING_REVIEW` state, **When** I drag-drop 2 PDF files and click Approve, **Then** the files are linked to that workflow history step and visible in the timeline
2. **Given** a Transmittal in `APPROVED` state, **When** I attempt to upload a file, **Then** the system rejects with "Upload not allowed in terminal state" error
3. **Given** a Circulation in `PENDING_APPROVAL` state, **When** I upload a file and the approver rejects, **Then** the attachment remains linked to that rejection step for audit purposes
---
### User Story 4 - Internal File Preview (Priority: P2)
As a Reviewer, I want to click on any attachment and preview it in a modal without leaving the document page, so I can review evidence while maintaining workflow context.
**Why this priority**: Current workflow requires downloading or opening files in new tabs, breaking user flow and reducing productivity.
**Independent Test**: Click on PDF and Image attachments in the workflow timeline, verify FilePreviewModal opens with correct content type rendering (iframe for PDF, img for images).
**Acceptance Scenarios**:
1. **Given** a step with 3 attachments (2 PDFs, 1 PNG), **When** I click the first PDF, **Then** a modal opens showing the PDF in an inline viewer
2. **Given** the preview modal is open, **When** I press Escape or click the X button, **Then** the modal closes and I remain on the document page
3. **Given** a large PDF attachment, **When** I open the preview, **Then** the modal loads within 2 seconds with proper scroll controls
---
### User Story 5 - i18n Support (Priority: P3)
As a Thai or English speaking user, I want all workflow UI text to display in my selected language, so I can use the system effectively regardless of my preferred language.
**Why this priority**: LCBP3-DMS must support bilingual operations. All new UI components must follow i18n standards from project inception.
**Independent Test**: Switch language between TH and EN, verify all IntegratedBanner labels, WorkflowLifecycle step labels, and FilePreviewModal controls display correctly in each language.
**Acceptance Scenarios**:
1. **Given** my language is set to Thai, **When** I view an RFA detail page, **Then** all workflow action buttons show Thai text (อนุมัติ, ปฏิเสธ, ส่งกลับ)
2. **Given** my language is set to English, **When** I view the Workflow tab, **Then** step labels show English text (Review, Approval, etc.)
3. **Given** I switch language while viewing a document, **When** the page refreshes, **Then** all ADR-021 components show the newly selected language immediately
---
### Edge Cases
- What happens when a user attempts transition with concurrent upload from another user? (Redis Redlock handles serialization)
- How does system handle ClamAV detecting malware during step upload? (Block immediately, delete temp file, show "File rejected")
- What happens when Redis is unavailable during transition? (Retry 3x with exponential backoff, then HTTP 503 fail-closed)
- How does system handle duplicate Idempotency-Key? (Return cached response, no re-processing)
- What happens when attachment file is deleted from storage after linking? (Show "File unavailable" in UI, preserve metadata)
- How does system handle unauthorized upload attempt? (CASL Guard blocks with 403 Forbidden)
---
## Requirements
### Functional Requirements
- **FR-001**: System MUST display Integrated Banner on RFA, Transmittal, Circulation, and Correspondence detail pages showing Doc No, Subject, Status, Priority, and available actions
- **FR-002**: Priority badge MUST support 4 levels: URGENT (red), HIGH (orange), MEDIUM (yellow), LOW (green) with visual indicators
- **FR-003**: Action buttons (Approve/Reject/Return) MUST be disabled when workflow is in terminal states (APPROVED, REJECTED, CLOSED)
- **FR-004**: System MUST display Workflow Lifecycle as vertical timeline with current step highlighted in Indigo (#6366f1) with pulse animation
- **FR-005**: System MUST support drag-drop file upload linked to workflow history steps, only allowed in PENDING_REVIEW or PENDING_APPROVAL states
- **FR-006**: Upload attempts in terminal states MUST be rejected with HTTP 409 Conflict
- **FR-007**: System MUST enforce 4-Level RBAC for workflow transitions: Superadmin > Org Admin > Assigned Handler > Read-only
- **FR-008**: System MUST validate Idempotency-Key header on all transition requests to prevent duplicate processing
- **FR-009**: File uploads MUST use Two-Phase pattern (Temp → ClamAV scan → Permanent)
- **FR-010**: System MUST provide internal File Preview Modal for PDF and Image attachments without page navigation
- **FR-011**: All UI text MUST use i18n keys supporting Thai and English languages
- **FR-012**: Workflow transitions MUST use optimistic locking (version_no) to prevent race conditions
- **FR-013**: Redis Redlock MUST serialize concurrent transitions on the same workflow instance
- **FR-014**: Attachment linking to workflow history MUST occur in same database transaction as state transition
### Key Entities
- **WorkflowInstance**: Represents a running workflow tied to a document (RFA/Transmittal/Circulation/Correspondence). Tracks current state, definition reference, and context data.
- **WorkflowHistory**: Audit record of each workflow transition. Contains from_state, to_state, action, actor, timestamp, and (ADR-021) linked attachments.
- **Attachment** (Extended): File entity with new `workflow_history_id` FK linking to specific workflow step. NULL value indicates main document attachment (pre-ADR-021 behavior).
- **IntegratedBanner**: UI component combining document metadata, workflow status, priority indicator, and action controls.
- **WorkflowLifecycle**: UI component displaying vertical timeline of all workflow steps with visual highlighting.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: Users can approve/reject documents 40% faster due to Integrated Banner reducing screen navigation
- **SC-002**: 100% of workflow attachments are traceable to specific approval steps via `workflow_history_id` linkage
- **SC-003**: File preview modal loads and displays PDF/Image files within 2 seconds (P95)
- **SC-004**: Zero duplicate workflow transitions occur due to Idempotency-Key enforcement (verified via audit logs)
- **SC-005**: System handles 50 concurrent workflow transitions per minute without data inconsistency (optimistic lock + Redlock)
- **SC-006**: 100% of UI text in ADR-021 components is translatable (verified by language switch testing)
- **SC-007**: Users can complete workflow transition with file upload within 5 seconds for files ≤10MB (P95, including ClamAV scan)
@@ -0,0 +1,129 @@
# Tasks: ADR-021 Integrated Workflow Context & Step-specific Attachments
**Branch**: `200-fullstacks/202-adr-021-integrated-workflow-context` | **Spec**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md)
**Location**: `specs/200-fullstacks/202-adr-021-integrated-workflow-context/`
**Input**: Comprehensive task breakdown from `specs/08-Tasks/ADR-021-workflow-context/tasks.md`
**Version**: 1.8.6 | **Date**: 2026-05-03
---
## Summary
This file provides a high-level task overview. For the **full detailed tasks** (with implementation notes, verification commands, and acceptance criteria), see:
👉 **`specs/200-fullstacks/202-adr-021-integrated-workflow-context/tasks.md`**
---
## Phase Overview
| Phase | Focus | Key Deliverables | Status |
|-------|-------|------------------|--------|
| **Phase 1** | Setup | Branch creation, dev environment verification | ⏳ Pending |
| **Phase 2** | Backend Foundation | SQL delta, Entity relations, Guards, Service extension | ⏳ Pending |
| **Phase 3** | Integrated Banner (US1) | `IntegratedBanner` component, 4 module integrations | ⏳ Pending |
| **Phase 4** | Workflow Lifecycle (US2) | `WorkflowLifecycle` component, vertical timeline | ⏳ Pending |
| **Phase 5** | Step Attachments (US3) | `use-workflow-action` hook, DTO extension, linking logic | ⏳ Pending |
| **Phase 6** | File Preview (US4) | `FilePreviewModal` component | ⏳ Pending |
| **Phase 7** | i18n & Testing (US5) | i18n keys, unit tests, component tests, E2E | ⏳ Pending |
---
## Critical Path Tasks (Phase 2)
| # | Task | File(s) | Dependencies |
|---|------|---------|--------------|
| T1 | Create SQL delta — add `workflow_history_id` to `attachments` | `deltas/04-*.sql` | None |
| T2 | Update `attachment.entity.ts` — add column + relation | `attachment.entity.ts` | T1 |
| T3 | Update `workflow-history.entity.ts` — add `@OneToMany` | `workflow-history.entity.ts` | T1 |
| T4 | Extend `WorkflowTransitionDto` — add `attachmentPublicIds` | `workflow-transition.dto.ts` | None |
| T5 | Create `WorkflowTransitionGuard` (CASL 4-Level) | `guards/workflow-transition.guard.ts` | None |
| T6 | Extend `processTransition()` — link attachments | `workflow-engine.service.ts` | T2, T3, T4 |
| T7 | Update Controller — idempotency + guard | `workflow-engine.controller.ts` | T5, T6 |
| T8 | Register guard in Module | `workflow-engine.module.ts` | T5 |
---
## Frontend Tasks Overview (Phases 3-6)
| # | Task | Component/Page | Dependencies |
|---|------|----------------|--------------|
| F1 | Add types — `WorkflowHistoryItem` | `types/workflow.ts` | T7 |
| F2 | Add DTO — `WorkflowTransitionWithAttachmentsDto` | `types/dto/workflow-engine/` | T4 |
| F3 | Create hook — `use-workflow-action.ts` | `hooks/` | F2 |
| F4 | Create component — `IntegratedBanner` | `components/workflow/` | F1 |
| F5 | Create component — `WorkflowLifecycle` | `components/workflow/` | F1 |
| F6 | Create component — `FilePreviewModal` | `components/common/` | F1 |
| F7-F10 | Integrate into 4 module pages | `rfas/`, `transmittals/`, `circulation/`, `correspondences/` | F3-F6 |
---
## Testing Tasks (Phase 7)
| # | Task | Target | Type |
|---|------|--------|------|
| G1 | Unit tests — `processTransition()` extended | `workflow-engine.service.spec.ts` | Backend |
| G2 | Unit tests — `WorkflowTransitionGuard` | `workflow-transition.guard.spec.ts` | Backend |
| G3 | Component tests — `IntegratedBanner` | `integrated-banner.test.tsx` | Frontend |
| G4 | Component tests — `WorkflowLifecycle` | `workflow-lifecycle.test.tsx` | Frontend |
| G5 | Component tests — `FilePreviewModal` | `file-preview-modal.test.tsx` | Frontend |
| G6 | E2E test — workflow with attachment | `test/workflow-with-attachment.e2e-spec.ts` | Integration |
---
## Verification Checkpoints
### Backend
```bash
# Schema check
grep -n "workflow_history_id" specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql
# Type check
cd backend && pnpm tsc --noEmit
# Unit tests
cd backend && pnpm test --testPathPattern=workflow-engine.service
cd backend && pnpm test --testPathPattern=workflow-transition.guard
# Integration test
curl -X POST http://localhost:3001/api/workflow-engine/instances/:id/transition \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"action":"APPROVE","comment":"OK","attachmentPublicIds":["<uuid>"]}'
```
### Frontend
```bash
# Type check
cd frontend && pnpm tsc --noEmit
# Component tests
cd frontend && pnpm test --run components/workflow/integrated-banner
cd frontend && pnpm test --run components/workflow/workflow-lifecycle
cd frontend && pnpm test --run components/common/file-preview-modal
```
---
## References
| Document | Path | Purpose |
|----------|------|---------|
| **Full Tasks** | `specs/08-Tasks/ADR-021-workflow-context/tasks.md` | Complete task breakdown with 360 lines of detail |
| **Data Model** | `specs/08-Tasks/ADR-021-workflow-context/data-model.md` | Entity definitions, SQL delta, DTO specs |
| **Quick Start** | `specs/08-Tasks/ADR-021-workflow-context/quickstart.md` | Developer onboarding guide |
| **Research** | `specs/08-Tasks/ADR-021-workflow-context/research.md` | Phase 0 findings and decisions |
| **Contracts** | `specs/08-Tasks/ADR-021-workflow-context/contracts/*.yaml` | API contracts |
---
## Definition of Done (Observable Outcomes)
| REQ | Done When |
|-----|-----------|
| **REQ-01** | Banner shows Doc No, Status, Priority badge, Approve/Reject buttons on all 4 module detail pages |
| **REQ-02** | Workflow tab displays Role + Handler + Description for every step without reload |
| **REQ-03** | Current step shows Indigo (#6366f1) with pulse animation; other steps distinct |
| **REQ-04** | Drag-drop works only in `PENDING_REVIEW`/`PENDING_APPROVAL`; disabled in terminal states |
| **REQ-05** | Clicking PDF/Image opens preview modal without page navigation |
| **REQ-06** | All UI text changes when switching EN/TH; no hardcoded strings |
@@ -0,0 +1,45 @@
# Specification Quality Checklist: Unified Workflow Engine — Production Hardening & Integrated Context
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-05-02
**Feature**: [spec.md](../spec.md)
---
## Content Quality
- [~] No implementation details (languages, frameworks, APIs) — *Note: Technology-specific terms (Redis, BullMQ, ClamAV, JSON Logic) are present in FRs as ADR-mandated architectural constraints (ADR-001/ADR-008/ADR-016), not spec-level implementation choices. Consistent with existing `001-transmittals-circulation/spec.md` pattern.*
- [x] Focused on user value and business needs
- [~] Written for non-technical stakeholders — *Note: Platform/infrastructure feature; technical Functional Requirements (FR-001 to FR-021) intentionally use ADR terminology. User Stories (P1-P3) and Success Criteria are non-technical.*
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Spec derived from ADR-001 (Unified Workflow Engine v1.1 — 2026-05-02 production hardening) and ADR-021 (Integrated Workflow Context & Step-specific Attachments)
- **Clarification session 2026-05-02 (5/5 questions resolved):**
- Q1: DSL `require.role` → CASL ability check (FR-002a)
- Q2: Observability = structured log + metrics (FR-022, FR-023, SC-009)
- Q3: File rollback on DB failure = move back to temp, 24h TTL (FR-019)
- Q4: Admin UI for DSL authoring is IN scope (FR-024, FR-025)
- Q5: All 4 modules (RFA/Transmittal/Circulation/Correspondence) need banner gap-filling (FR-011, Assumptions updated)
- ADR-001 clarifications fully captured in FR-001 through FR-010 and SC-001 through SC-005
- ADR-021 requirements (REQ-01 to REQ-06) fully captured in FR-011 through FR-025 and SC-006 through SC-009
- Visual workflow builder (drag-and-drop DSL editor) is explicitly **out of scope** (Phase 2)
@@ -0,0 +1,205 @@
openapi: "3.1.0"
info:
title: Workflow Engine — Definitions API
version: "1.1.0"
description: |
Endpoints for managing workflow DSL definitions.
Requires system.manage_all (Super Admin only) for all write operations (FR-009).
Includes DSL validation endpoint for Admin UI inline feedback (FR-025).
paths:
/workflow-engine/definitions:
get:
summary: List all workflow definitions (latest version per code)
tags: [WorkflowDefinitions]
security:
- BearerAuth: []
responses:
"200":
description: Array of latest definitions
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/WorkflowDefinitionDto"
post:
summary: Create a new workflow definition (auto-increments version)
description: |
Creates a new version for the given workflow_code.
DSL is compiled and validated (Phase 1 save-time check — FR-008).
Requires system.manage_all permission.
tags: [WorkflowDefinitions]
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateWorkflowDefinitionDto"
responses:
"201":
description: Definition created
content:
application/json:
schema:
$ref: "#/components/schemas/WorkflowDefinitionDto"
"400":
description: DSL structure validation failed (Phase 1)
"403":
description: Requires system.manage_all
/workflow-engine/definitions/{id}:
get:
summary: Get a specific definition by UUID
tags: [WorkflowDefinitions]
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/WorkflowDefinitionDto"
patch:
summary: Update a workflow definition (DSL or is_active toggle)
description: |
Updating DSL re-compiles and re-validates (Phase 1).
Toggling is_active=true invalidates the Redis active pointer cache immediately (FR-007, SC-005).
In-progress instances are NOT rebound (FR-010).
Requires system.manage_all.
tags: [WorkflowDefinitions]
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateWorkflowDefinitionDto"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/WorkflowDefinitionDto"
"400":
description: DSL validation failed
"403":
description: Requires system.manage_all
/workflow-engine/definitions/validate:
post:
summary: Validate a DSL JSON without saving (for Admin UI inline feedback — FR-025)
description: |
Runs Phase 1 (structure) validation only. Returns errors per field.
No authentication required for this endpoint (read-only, no state change)
— but still protected by JWT for Admin UI use.
tags: [WorkflowDefinitions]
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [dsl]
properties:
dsl:
type: object
description: DSL JSON to validate
responses:
"200":
description: Validation result
content:
application/json:
schema:
$ref: "#/components/schemas/DslValidationResultDto"
components:
schemas:
WorkflowDefinitionDto:
type: object
properties:
id:
type: string
format: uuid
workflowCode:
type: string
example: RFA_FLOW_V1
version:
type: integer
example: 2
isActive:
type: boolean
dsl:
type: object
description: Raw DSL JSON (JSON Logic conditions only — no eval/new Function)
createdAt:
type: string
format: date-time
CreateWorkflowDefinitionDto:
type: object
required: [workflow_code, dsl]
properties:
workflow_code:
type: string
example: RFA_FLOW_V2
dsl:
type: object
description: DSL JSON — must use JSON Logic format for conditions (FR-001)
is_active:
type: boolean
default: true
UpdateWorkflowDefinitionDto:
type: object
properties:
dsl:
type: object
is_active:
type: boolean
workflow_code:
type: string
DslValidationResultDto:
type: object
properties:
valid:
type: boolean
errors:
type: array
items:
type: object
properties:
path:
type: string
description: JSON path to the invalid field (e.g. "states.DRAFT.transitions")
message:
type: string
description: Human-readable error description
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
@@ -0,0 +1,276 @@
openapi: "3.1.0"
info:
title: Workflow Engine — Transition API
version: "1.1.0"
description: |
Endpoints for triggering workflow state transitions.
ADR-001 v1.1: Added version_no (optimistic lock) and action_by_user_uuid.
ADR-021: Step-specific attachment support via attachmentPublicIds.
paths:
/workflow-engine/instances/{id}/transition:
post:
summary: Trigger a workflow state transition
description: |
Transitions the workflow instance to the next state based on the DSL definition.
Requires Idempotency-Key header (ADR-016).
Optionally includes pre-uploaded attachment publicIds (ADR-021).
Supports optimistic concurrency control via versionNo (ADR-001 v1.1).
tags: [WorkflowEngine]
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
description: Workflow Instance UUID
- name: Idempotency-Key
in: header
required: true
schema:
type: string
format: uuid
description: UUIDv7 idempotency key — duplicate requests return cached response
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/WorkflowTransitionDto"
responses:
"200":
description: Transition successful
content:
application/json:
schema:
$ref: "#/components/schemas/WorkflowTransitionResponseDto"
"409":
description: |
Conflict — one of:
- version_no mismatch (optimistic lock) — refresh and retry
- Terminal state — cannot transition further
- Upload rejected (state not in PENDING_REVIEW/PENDING_APPROVAL)
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: DSL condition not met or required context field missing
content:
application/json:
schema:
$ref: "#/components/schemas/ValidationErrorResponse"
"403":
description: User lacks the required CASL ability for this transition
"503":
description: Redlock unavailable — retry after brief delay
/workflow-engine/instances/{id}:
get:
summary: Get workflow instance state
description: Returns current state, available actions, and versionNo for optimistic locking.
tags: [WorkflowEngine]
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Instance details
content:
application/json:
schema:
$ref: "#/components/schemas/WorkflowInstanceDto"
/workflow-engine/instances/{id}/history:
get:
summary: Get workflow history (timeline)
description: Returns all transition records for a workflow instance, including step-specific attachments.
tags: [WorkflowEngine]
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: History items
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/WorkflowHistoryItemDto"
components:
schemas:
WorkflowTransitionDto:
type: object
required: [action]
properties:
action:
type: string
example: APPROVE
description: Action name matching a DSL transition key
comment:
type: string
maxLength: 2000
description: Optional decision comment
versionNo:
type: integer
minimum: 1
description: |
Current version_no from the client. If provided, triggers optimistic
lock check — returns 409 if mismatch (ADR-001 v1.1 FR-002).
example: 5
payload:
type: object
additionalProperties: true
description: Additional context fields required by DSL conditions
attachmentPublicIds:
type: array
items:
type: string
format: uuid
maxItems: 20
description: |
Pre-uploaded attachment UUIDs (ADR-021). Files must have been
uploaded via Two-Phase upload and passed ClamAV scan before
this request. Only valid in PENDING_REVIEW or PENDING_APPROVAL.
WorkflowTransitionResponseDto:
type: object
properties:
success:
type: boolean
example: true
previousState:
type: string
example: PENDING_REVIEW
nextState:
type: string
example: PENDING_APPROVAL
historyId:
type: string
format: uuid
description: UUID of the created WorkflowHistory record
isCompleted:
type: boolean
description: True if the transition reached a terminal state
versionNo:
type: integer
description: Updated versionNo after successful transition — client must store for next request
WorkflowInstanceDto:
type: object
properties:
id:
type: string
format: uuid
currentState:
type: string
example: PENDING_REVIEW
status:
type: string
enum: [ACTIVE, COMPLETED, CANCELLED, TERMINATED]
versionNo:
type: integer
description: Current optimistic lock version — include in next transition request
availableActions:
type: array
items:
type: string
example: [APPROVE, REJECT, RETURN]
workflowCode:
type: string
example: RFA_FLOW_V1
WorkflowHistoryItemDto:
type: object
properties:
id:
type: string
format: uuid
fromState:
type: string
toState:
type: string
action:
type: string
actorUuid:
type: string
format: uuid
description: UUID of the acting user (ADR-019 — INT FK excluded from API)
actorName:
type: string
description: Populated via user join for display
comment:
type: string
nullable: true
createdAt:
type: string
format: date-time
attachments:
type: array
items:
$ref: "#/components/schemas/AttachmentSummaryDto"
AttachmentSummaryDto:
type: object
properties:
publicId:
type: string
format: uuid
description: ADR-019 public identifier
originalFilename:
type: string
mimeType:
type: string
fileSize:
type: integer
createdAt:
type: string
format: date-time
ErrorResponse:
type: object
properties:
userMessage:
type: string
recoveryAction:
type: string
errorCode:
type: string
ValidationErrorResponse:
type: object
properties:
userMessage:
type: string
fields:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
@@ -0,0 +1,388 @@
# Data Model: Unified Workflow Engine — Production Hardening
**Phase 1 Output** | Generated: 2026-05-02
**Extends**: `specs/08-Tasks/ADR-021-workflow-context/data-model.md` (deltas 0108 already applied)
---
## 1. Schema Deltas
### Delta 09 — `version_no` on `workflow_instances`
**File**: `specs/03-Data-and-Storage/deltas/09-add-version-no-to-workflow-instances.sql`
```sql
-- ============================================================
-- Delta 09: ADR-001 v1.1 — Optimistic Lock
-- เพิ่ม version_no ใน workflow_instances สำหรับ Optimistic Concurrency Control
-- ============================================================
-- ข้อควรระวัง: Existing rows จะได้ค่า DEFAULT 1 อัตโนมัติ — ไม่มี Data Loss
-- Rollback: ALTER TABLE workflow_instances DROP COLUMN version_no;
ALTER TABLE workflow_instances
ADD COLUMN version_no INT NOT NULL DEFAULT 1
COMMENT 'Optimistic lock counter — incremented on every successful transition (ADR-001 v1.1 FR-002)';
-- Index เพื่อรองรับ CAS check: WHERE id = ? AND version_no = ?
CREATE INDEX idx_wf_inst_version
ON workflow_instances (id, version_no);
```
**Migration Notes (ADR-009):**
- Apply via MariaDB CLI or n8n delta workflow — ไม่มี TypeORM migration file
- Existing instances get `version_no = 1` — no disruption to active workflows
- Rollback: `ALTER TABLE workflow_instances DROP INDEX idx_wf_inst_version; ALTER TABLE workflow_instances DROP COLUMN version_no;`
---
### Delta 10 — `action_by_user_uuid` on `workflow_histories`
**File**: `specs/03-Data-and-Storage/deltas/10-add-action-by-user-uuid-to-workflow-histories.sql`
```sql
-- ============================================================
-- Delta 10: ADR-001 v1.1 / ADR-019 UUID Compliance
-- เพิ่ม action_by_user_uuid ใน workflow_histories
-- เพื่อ expose User identity ผ่าน API โดยไม่ต้องเปิดเผย INT PK (ADR-019)
-- ============================================================
-- ข้อควรระวัง: NULL สำหรับ Historical records ที่สร้างก่อน delta นี้ (เป็น Acceptable)
-- Rollback: ALTER TABLE workflow_histories DROP COLUMN action_by_user_uuid;
ALTER TABLE workflow_histories
ADD COLUMN action_by_user_uuid VARCHAR(36) NULL
COMMENT 'UUID ของ User ผู้ดำเนินการ — ใช้ใน API Response (ADR-019). INT FK action_by_user_id ยังคงอยู่สำหรับ Internal use';
```
**Migration Notes (ADR-009):**
- NULL สำหรับ historical records — acceptable; API consumers treat NULL as "system action" or "pre-migration"
- Populate on all new transitions from this delta forward
---
## 2. Backend Entity Changes
### 2.1 `workflow-instance.entity.ts` — Add `versionNo`
**File**: `backend/src/modules/workflow-engine/entities/workflow-instance.entity.ts`
```typescript
// เพิ่มหลัง updatedAt column
@Column({
name: 'version_no',
type: 'int',
default: 1,
comment: 'Optimistic lock — incremented on each successful transition (ADR-001 v1.1)',
})
versionNo!: number;
```
**Import to add**: No new imports needed.
---
### 2.2 `workflow-history.entity.ts` — Add `actionByUserUuid`
**File**: `backend/src/modules/workflow-engine/entities/workflow-history.entity.ts`
```typescript
// เพิ่มหลัง actionByUserId column
@Column({
name: 'action_by_user_uuid',
length: 36,
nullable: true,
comment: 'UUID ของ User ผู้ดำเนินการ — expose ใน API Response per ADR-019',
})
actionByUserUuid?: string;
```
---
### 2.3 `workflow-history-item.dto.ts` — Add `actorUuid`
**File**: `backend/src/modules/workflow-engine/dto/workflow-history-item.dto.ts`
```typescript
// เพิ่ม field ใน WorkflowHistoryItemDto
@ApiPropertyOptional({
description: 'UUID ของ User ผู้ดำเนินการ (ADR-019)',
example: '019505a1-7c3e-7000-8000-abc123def456',
})
actorUuid?: string;
```
---
## 3. `processTransition()` — Optimistic Lock Changes
### Updated signature
```typescript
async processTransition(
instanceId: string,
action: string,
userId: number,
userUuid: string, // NEW: ADR-019 UUID for history record
comment?: string,
payload: Record<string, unknown> = {},
attachmentPublicIds?: string[],
clientVersionNo?: number, // NEW: Optimistic lock — sent by client
)
```
### Fast-fail check (before Redlock)
```typescript
if (clientVersionNo !== undefined) {
const current = await this.instanceRepo.findOne({
where: { id: instanceId },
select: ['id', 'versionNo'],
});
if (!current) throw new NotFoundException('Workflow Instance', instanceId);
if (current.versionNo !== clientVersionNo) {
throw new ConflictException(
'WORKFLOW_VERSION_CONFLICT',
`Expected version_no=${clientVersionNo}, actual=${current.versionNo}`,
'เอกสารถูกอนุมัติโดยผู้อื่นแล้ว กรุณารีเฟรช',
['รีเฟรชหน้าแล้วลองใหม่']
);
}
}
```
### History creation — add `actionByUserUuid`
```typescript
const history = this.historyRepo.create({
instanceId: instance.id,
fromState,
toState,
action,
actionByUserId: userId,
actionByUserUuid: userUuid, // NEW
comment,
metadata: { events: evaluation.events },
});
```
### Version increment (inside DB transaction, after history save)
```typescript
// CAS update — ถ้า version_no ถูกเปลี่ยนระหว่างนี้ (TOCTOU) จะไม่มีแถวถูก update
const result = await queryRunner.manager
.createQueryBuilder()
.update(WorkflowInstance)
.set({ versionNo: () => 'version_no + 1' })
.where('id = :id AND version_no = :expected', {
id: instanceId,
expected: instance.versionNo,
})
.execute();
if (result.affected === 0) {
// TOCTOU: version changed under pessimistic lock (edge case — should not normally occur)
throw new ConflictException(
'WORKFLOW_VERSION_CONFLICT',
'version_no changed between lock acquisition and update',
'เกิด Conflict กรุณารีเฟรชและลองใหม่',
['รีเฟรชหน้า', 'ลองดำเนินการอีกครั้ง']
);
}
```
---
## 4. `processTransition()` — Structured Observability Changes
### New metric injections in constructor
```typescript
@InjectMetric('workflow_transitions_total')
private readonly transitionsTotal: Counter<string>,
@InjectMetric('workflow_transition_duration_ms')
private readonly transitionDuration: Histogram<string>,
```
### Wrap in timer + log
```typescript
const startMs = Date.now();
let outcome: 'success' | 'conflict' | 'forbidden' | 'validation_error' | 'system_error' = 'system_error';
let workflowCode = 'unknown';
try {
// ... existing processTransition logic ...
workflowCode = instance.definition.workflow_code;
outcome = 'success';
} catch (err) {
if (err instanceof ConflictException) outcome = 'conflict';
else if (err instanceof ForbiddenException) outcome = 'forbidden';
else if (err instanceof WorkflowException) outcome = 'validation_error';
throw err;
} finally {
const durationMs = Date.now() - startMs;
this.transitionDuration.labels({ workflow_code: workflowCode }).observe(durationMs);
this.transitionsTotal.labels({ workflow_code: workflowCode, action, outcome }).inc();
this.logger.log(JSON.stringify({
instanceId, action, fromState: instance?.currentState,
toState: outcome === 'success' ? toState : undefined,
userUuid, durationMs, outcome, workflowCode,
}));
}
```
### Module registration (in `workflow-engine.module.ts`)
```typescript
import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus';
// Add to providers array:
makeCounterProvider({
name: 'workflow_transitions_total',
help: 'Total workflow transitions by code, action, and outcome',
labelNames: ['workflow_code', 'action', 'outcome'],
}),
makeHistogramProvider({
name: 'workflow_transition_duration_ms',
help: 'Workflow transition duration in milliseconds',
labelNames: ['workflow_code'],
buckets: [50, 100, 250, 500, 1000, 2500, 5000],
}),
```
---
## 5. DSL Cache Changes (FR-007)
### Cache methods in `workflow-engine.service.ts`
```typescript
// ใน createDefinition() — หลัง save
await this.cacheManager.set(
`wf:def:${saved.workflow_code}:${saved.version}`,
saved,
3600 * 1000 // 1 hour in ms (cache-manager v5 uses ms)
);
// ใน update() — ก่อน save (ถ้า DSL เปลี่ยน)
await this.cacheManager.del(`wf:def:${definition.workflow_code}:${definition.version}`);
// ใน activate/deactivate — invalidate active pointer
await this.redis.del(`wf:def:${definition.workflow_code}:active`);
if (dto.is_active === true) {
await this.cacheManager.set(
`wf:def:${definition.workflow_code}:active`,
saved,
3600 * 1000
);
}
```
---
## 6. BullMQ DLQ + n8n Webhook Changes (FR-005, FR-006)
### `workflow-event.service.ts` additions
```typescript
// ใน WorkflowEventProcessor:
@OnWorkerEvent('failed')
async onJobFailed(job: Job, error: Error): Promise<void> {
// ตรวจสอบว่าหมด retry แล้วหรือยัง
if ((job.attemptsMade ?? 0) >= (job.opts.attempts ?? 3)) {
// ส่งไปยัง DLQ
await this.failedQueue.add('dead-letter', {
originalJobId: job.id,
queue: 'workflow-events',
data: job.data,
failedAt: new Date().toISOString(),
error: error.message,
});
// แจ้ง Ops ผ่าน n8n webhook (ถ้าตั้งค่าไว้)
const webhookUrl = process.env.N8N_WEBHOOK_URL;
if (webhookUrl) {
try {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'workflow_event_failed',
jobId: job.id,
workflowCode: job.data?.workflowCode,
instanceId: job.data?.instanceId,
error: error.message,
timestamp: new Date().toISOString(),
}),
});
} catch (webhookErr) {
// Warning เท่านั้น — ไม่ throw เพื่อไม่ให้กระทบ DLQ add
this.logger.warn(`n8n webhook failed: ${(webhookErr as Error).message}`);
}
} else {
this.logger.warn('N8N_WEBHOOK_URL not configured — DLQ job created without ops notification');
}
}
}
```
### Worker configuration (verify/update in `workflow-engine.module.ts`)
```typescript
WorkerHost({
connection: { ... },
concurrency: 5,
limiter: { max: 50, duration: 60000 },
}),
// Job default options
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 500 },
removeOnComplete: { age: 86400 },
removeOnFail: false, // Keep in failed state for Bull Board visibility
}
```
---
## 7. Updated Entity Relationship Diagram
```
workflow_definitions
workflow_code + version (unique)
is_active: BOOLEAN
│ 1
▼ N
workflow_instances
version_no: INT DEFAULT 1 ← NEW (Delta 09)
current_state: VARCHAR(50)
context: JSON
contract_id: INT NULL
│ 1
▼ N
workflow_histories
action_by_user_id: INT NULL ← existing (internal FK)
action_by_user_uuid: VARCHAR(36) ← NEW (Delta 10, ADR-019)
from_state / to_state / action
metadata: JSON
│ 1
▼ N
attachments
workflow_history_id: CHAR(36) NULL ← Delta 04 (already applied)
uuid: VARCHAR(36) ← publicId (ADR-019)
```
---
## 8. Index Strategy (updated)
| Table | Index | Columns | Purpose | Status |
|-------|-------|---------|---------|--------|
| `workflow_instances` | `idx_wf_inst_version` | `(id, version_no)` | Optimistic lock CAS check | **NEW** |
| `workflow_instances` | `idx_wf_inst_entity` | `(entity_type, entity_id)` | Polymorphic lookup | Existing |
| `workflow_histories` | `idx_wf_hist_instance` | `(instance_id)` | History per instance | Existing |
| `attachments` | `idx_att_wfhist_created` | `(workflow_history_id, created_at)` | Step attachments | Delta 04 |
@@ -0,0 +1,272 @@
# Implementation Plan: Unified Workflow Engine — Production Hardening & Integrated Context
**Branch**: `003-unified-workflow-engine` | **Date**: 2026-05-02 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/003-unified-workflow-engine/spec.md`
---
## Summary
The Workflow Engine backend infrastructure is substantially implemented (service, entities, guards, DSL, Redlock, Prometheus metrics). This plan closes the remaining production-hardening gaps from ADR-001 v1.1 (optimistic lock, user UUID in history, CASL-mapped DSL roles, per-transition metrics, DSL Redis cache, DLQ + n8n webhook) and completes ADR-021 (step-specific attachment data-wiring in all 4 modules, file preview modal, Admin DSL editor UI).
Clarification decisions from `spec.md`:
- **Q1**: DSL `require.role` → CASL ability check (FR-002a)
- **Q2**: Observability = structured log + counter + histogram (FR-022, FR-023)
- **Q3**: File rollback on DB failure = move back to temp, 24h TTL (FR-019)
- **Q4**: Admin DSL editor UI is in scope (FR-024, FR-025)
- **Q5**: All 4 modules need banner gap-filling (FR-011)
---
## Technical Context
**Language/Version**: TypeScript 5.4, Node.js 20 LTS
**Primary Dependencies**: NestJS 10, TypeORM 0.3, BullMQ 5, `@willsoto/nestjs-prometheus`, `json-logic-js`, `redlock`, `ioredis`
**Frontend**: Next.js 14 (App Router), TanStack Query v5, React Hook Form + Zod, shadcn/ui
**Storage**: MariaDB 10.11, Redis 7, StorageService (Two-Phase Upload per ADR-016)
**Testing**: Jest + `@nestjs/testing` (backend), Vitest (frontend)
**Target Platform**: QNAP NAS Docker Compose (backend), Next.js SSR (frontend)
**Performance Goals**: Transition P95 < 1s (no upload); upload+transition P95 < 5s; cache invalidation < 1s across all instances
**Constraints**: ADR-009 (no TypeORM migrations), ADR-019 (UUID strings, no parseInt), ADR-016 (Two-Phase Upload), ADR-008 (BullMQ async)
**Scale/Scope**: 4 document modules × ~50 active workflows concurrently; up to 20 history records per instance
---
## Constitution Check
_GATE: Must pass before Phase 0. Re-checked after Phase 1 design._
| Gate | Rule | Status | Notes |
|------|------|--------|-------|
| ADR-019 UUID | No `parseInt` on UUIDs; expose `publicId` strings only | ✅ PASS | `WorkflowInstance.id` and `WorkflowHistory.id` are UUID PKs (native CHAR(36)); `action_by_user_uuid` addition follows pattern |
| ADR-009 Schema | No TypeORM migrations; edit SQL directly | ✅ PASS | Two new delta files planned (delta-09, delta-10) |
| ADR-016 Security | Two-Phase upload; ClamAV; whitelist | ✅ PASS | Already implemented in `processTransition()`; file preview uses existing attachment endpoint |
| ADR-008 BullMQ | Async notifications; no inline dispatch | ✅ PASS | `WorkflowEventService` dispatches to `workflow-events` queue; DLQ is the gap |
| ADR-007 Errors | Layered exception hierarchy | ✅ PASS | `WorkflowException`, `ConflictException`, `ServiceUnavailableException` already in use |
| ADR-002 Numbering | Redlock for document numbering | ✅ N/A | Workflow engine does not generate document numbers |
| ADR-018/020 AI | No AI direct DB access | ✅ N/A | No AI integration in this feature |
| FR-002 Optimistic Lock | `version_no` column on `workflow_instances` | ⚠️ GAP | Column missing — delta-09 required |
| FR-003 User UUID | `action_by_user_uuid` on `workflow_histories` | ⚠️ GAP | Column missing — delta-10 required |
**Post-gate verdict**: PASS with two schema deltas required before implementation begins.
---
## Project Structure
### Documentation (this feature)
```text
specs/003-unified-workflow-engine/
├── plan.md ← This file
├── research.md ← Phase 0 output
├── data-model.md ← Phase 1 output
├── quickstart.md ← Phase 1 output
└── contracts/ ← Phase 1 output
├── workflow-transition.yaml
└── workflow-definitions.yaml
```
### Source Code Layout
```text
backend/src/modules/workflow-engine/
├── entities/
│ ├── workflow-instance.entity.ts ← ADD versionNo column
│ └── workflow-history.entity.ts ← ADD actionByUserUuid column
├── guards/
│ └── workflow-transition.guard.ts ← ADD DSL require.role → CASL mapping (FR-002a)
├── dto/
│ └── workflow-history-item.dto.ts ← ADD actorUuid field
├── workflow-engine.service.ts ← ADD version_no check, structured log, metrics, cache invalidation
├── workflow-event.service.ts ← ADD DLQ processor + n8n webhook (FR-005/006)
└── workflow-engine.module.ts ← Register new metrics providers
specs/03-Data-and-Storage/deltas/
├── 09-add-version-no-to-workflow-instances.sql ← NEW
└── 10-add-action-by-user-uuid-to-workflow-histories.sql ← NEW
frontend/components/workflow/
├── integrated-banner.tsx ← GAP-FILL: step-attachment upload zone
├── workflow-lifecycle.tsx ← GAP-FILL: history items with attachment list
└── file-preview-modal.tsx ← NEW component
frontend/app/(admin)/admin/workflows/
└── definitions/
├── page.tsx ← NEW: DSL list + activate/deactivate
└── [id]/
└── page.tsx ← NEW: DSL JSON editor + inline validation
frontend/app/(admin)/admin/doc-control/
├── rfa/[uuid]/page.tsx ← GAP-FILL: availableActions, step-attach
├── transmittals/[uuid]/page.tsx ← GAP-FILL: step-attach upload zone
├── circulation/[uuid]/page.tsx ← GAP-FILL: step-attach upload zone
└── correspondence/[uuid]/page.tsx ← GAP-FILL + new IntegratedBanner wiring
```
---
## Implementation Phases
### Phase B1: Schema Deltas (prerequisite)
Apply before any code changes.
| Delta | File | Change |
|-------|------|--------|
| 09 | `09-add-version-no-to-workflow-instances.sql` | `ALTER TABLE workflow_instances ADD COLUMN version_no INT NOT NULL DEFAULT 1` |
| 10 | `10-add-action-by-user-uuid-to-workflow-histories.sql` | `ALTER TABLE workflow_histories ADD COLUMN action_by_user_uuid VARCHAR(36) NULL` |
### Phase B2: Entity & DTO Updates
| Task | File | Change |
|------|------|--------|
| B2-1 | `workflow-instance.entity.ts` | Add `@Column() versionNo: number` with `@Version()` decorator |
| B2-2 | `workflow-history.entity.ts` | Add `@Column() actionByUserUuid?: string` |
| B2-3 | `workflow-history-item.dto.ts` | Add `actorUuid: string` field (exposed in API per ADR-019) |
### Phase B3: Optimistic Lock in `processTransition()` (FR-002)
In `workflow-engine.service.ts`:
1. Accept `clientVersionNo?: number` parameter in `processTransition()`
2. If provided: compare against `instance.versionNo` BEFORE Redlock acquisition → throw `ConflictException` (HTTP 409) if mismatch
3. After DB transaction commit: increment `instance.versionNo + 1` via `UPDATE workflow_instances SET version_no = version_no + 1 WHERE id = :id AND version_no = :expected`
4. No separate pessimistic lock change needed — keep both as defense-in-depth
### Phase B4: CASL Role Mapping in Guard (FR-002a)
In `workflow-transition.guard.ts`:
1. After Level 1 (Superadmin) check, extract DSL `require.role` from the current step config
2. Map each DSL role string to a CASL ability string via `DSL_ROLE_TO_CASL` config map
3. Check `userPermissions.includes(mappedAbility)` for any match → pass
4. Fall through to existing Level 3 (assignedUserId) check for `"AssignedHandler"` role
```typescript
const DSL_ROLE_TO_CASL: Record<string, string> = {
'Superadmin': 'system.manage_all',
'OrgAdmin': 'organization.manage_users',
'ContractMember': 'contract.view',
'AssignedHandler': '__assigned__', // resolved by existing Level 3 check
};
```
### Phase B5: Structured Observability (FR-022, FR-023)
In `workflow-engine.service.ts`:
1. Inject two new metrics via `@InjectMetric()`:
- `workflow_transitions_total` (Counter: `workflow_code`, `action`, `outcome`)
- `workflow_transition_duration_ms` (Histogram: `workflow_code`)
2. Wrap `processTransition()` in a `startTimer``observe(duration)` block
3. Emit structured log on every outcome:
```typescript
this.logger.log(JSON.stringify({
instanceId, action, fromState, toState, userUuid,
durationMs, outcome, workflowCode
}));
```
4. Register providers in `workflow-engine.module.ts`
### Phase B6: DSL Redis Cache Invalidation (FR-007)
In `workflow-engine.service.ts`:
1. In `createDefinition()`: after save, call `cacheManager.set('wf:def:${code}:${version}', entity, 3600)`
2. In `update()`: call `cacheManager.del('wf:def:${code}:${oldVersion}')` before save
3. In `getDefinitionById()` / cached lookup: read-through with `cacheManager.get()` → fallback to DB
4. On `is_active` toggle: invalidate ALL `wf:def:{code}:*` keys (use `redis.keys()` + `redis.del()` pattern)
### Phase B7: BullMQ DLQ + n8n Webhook (FR-005, FR-006)
In `workflow-event.service.ts`:
1. Add `workflow-events-failed` queue registration
2. Add `@OnWorkerEvent('failed')` handler in the processor class
3. On `attempts === maxAttempts`: POST to `process.env.N8N_WEBHOOK_URL` with job payload (never hardcoded)
4. Verify existing `workflow-events` worker has `concurrency: 5, attempts: 3, backoff: { type: 'exponential', delay: 500 }`
### Phase B8: File Rollback on Transaction Failure (FR-019)
In `workflow-engine.service.ts` `processTransition()`:
1. After file linkage step inside transaction, if `queryRunner.commitTransaction()` throws:
- Call `storageService.moveToTemp(attachmentPublicIds)` in the `catch` block
- Log the rollback with attachment IDs for audit
2. The 24h TTL on temp files is handled by existing `FileCleanupService` cron
### Phase F1: File Preview Modal (FR-020)
New component: `frontend/components/workflow/file-preview-modal.tsx`
- Props: `attachment: WorkflowAttachmentSummary | null`, `onClose: () => void`
- Renders PDF via `<iframe src="/api/files/{publicId}/preview" />` for PDFs
- Renders `<img>` for image MIME types
- Falls back to download link for unsupported types
- Uses shadcn/ui `Dialog` component
### Phase F2: Step-Attachment Upload Zone (FR-014FR-019)
In `integrated-banner.tsx`:
1. Show upload zone only when `currentState ∈ {PENDING_REVIEW, PENDING_APPROVAL}` AND user is assigned handler/org-admin/superadmin
2. Upload zone calls existing Two-Phase upload endpoint, then appends `publicId` to pending list
3. On action button click, pass `attachmentPublicIds` array to `use-workflow-action.ts` hook
4. On success: invalidate TanStack Query cache for document + history
In `workflow-lifecycle.tsx`:
1. For each history item, render `attachments[]` as clickable file chips
2. On click: open `FilePreviewModal`
### Phase F3: Module Banner Gap-Fill (FR-011, all 4 modules)
For each detail page (`rfa`, `transmittals`, `circulation`, `correspondence`):
1. Ensure service `findOneByUuid()` exposes: `workflowInstanceId`, `workflowState`, `availableActions`, `workflowPriority`
2. Pass live values to `<IntegratedBanner>` and `<WorkflowLifecycle>`
3. Add step-attachment upload zone via Phase F2 components
4. Verify `WorkflowHistoryItemDto` includes `attachments[]` in the history endpoint
Correspondence is the only module requiring new backend wiring (Transmittal + Circulation already done per v1.8.7; RFA has partial wiring — needs `availableActions` + step-attach).
### Phase F4: Admin DSL Editor UI (FR-024, FR-025)
New pages under `frontend/app/(admin)/admin/workflows/definitions/`:
**List page** (`page.tsx`):
- Table of all workflow definitions with columns: `workflow_code`, `version`, `is_active`, actions (Edit / Activate / Deactivate)
- Uses TanStack Query `useWorkflowDefinitions()` hook
- Activate/Deactivate via `PATCH /workflow-engine/definitions/:id` with `{ is_active: true/false }`
**Editor page** (`[id]/page.tsx`):
- Load definition via `useWorkflowDefinition(id)`
- JSON editor (Monaco Editor or `@uiw/react-codemirror` in JSON mode)
- Inline validation: call `POST /workflow-engine/definitions/validate` with DSL JSON → display errors inline
- Save button disabled when validation errors present (FR-025)
- Form managed with React Hook Form + Zod (for wrapper metadata fields)
---
## Complexity Tracking
No constitution violations requiring justification.
---
## Risk Register
| Risk | Impact | Mitigation |
|------|--------|-----------|
| `version_no` delta on live DB with existing instances | Medium | Delta sets `DEFAULT 1`; existing rows auto-initialize; no data loss |
| `action_by_user_uuid` delta — NULL for historical records | Low | Column is NULLABLE; historical records remain valid |
| DSL role mapping gaps (unknown role strings) | Medium | `DSL_ROLE_TO_CASL` unknown keys default to `__assigned__` check — fail-safe |
| Monaco Editor bundle size (~2MB) | Low | Lazy-loaded only on Admin DSL editor page; no impact to user-facing pages |
| n8n webhook URL not configured in some environments | Medium | Guard with `if (!N8N_WEBHOOK_URL)` → warn log, don't throw; ops can configure later |
---
## Test Plan
| Area | Tests Required | Target |
|------|---------------|--------|
| `WorkflowEngineService.processTransition` | Concurrent optimistic lock (409), version increment, structured log emission | Unit (Jest) |
| `WorkflowTransitionGuard` | DSL role → CASL mapping for each level | Unit (Jest) |
| `WorkflowEventService` DLQ | Failed job triggers n8n webhook | Unit (Jest + mock) |
| Transition metrics | Counter/histogram incremented on success + failure | Unit (Jest) |
| DSL cache invalidation | Activate triggers cache del | Integration (Jest) |
| File rollback (FR-019) | DB failure → `moveToTemp()` called | Unit (Jest + mock) |
| `FilePreviewModal` | Renders PDF/image/fallback correctly | Frontend (Vitest) |
| Admin DSL editor | Validation errors shown inline; save blocked | Frontend (Vitest) |
| Module gap-fill E2E | Each module detail page renders live `availableActions` | Manual / Playwright |
@@ -0,0 +1,205 @@
# Quickstart: Unified Workflow Engine — Production Hardening
**Phase 1 Output** | Generated: 2026-05-02
**For**: Developers implementing tasks from `tasks.md` (generated by `/speckit-tasks`)
---
## Pre-flight Checklist
Before writing any code:
- [ ] Apply Delta 09: `specs/03-Data-and-Storage/deltas/09-add-version-no-to-workflow-instances.sql`
- [ ] Apply Delta 10: `specs/03-Data-and-Storage/deltas/10-add-action-by-user-uuid-to-workflow-histories.sql`
- [ ] Confirm `workflow_instances` has `version_no` column: `DESCRIBE workflow_instances;`
- [ ] Confirm `workflow_histories` has `action_by_user_uuid` column: `DESCRIBE workflow_histories;`
- [ ] Verify existing tests pass: `pnpm test --testPathPattern=workflow-engine`
---
## Implementation Order
Tasks MUST be implemented in this order to avoid breaking existing functionality:
```
[B1] Schema Deltas (DB)
[B2] Entity + DTO updates
[B3] processTransition() — optimistic lock
[B4] WorkflowTransitionGuard — CASL role mapping
[B5] Observability — metrics + structured log
[B6] DSL Redis cache invalidation
[B7] BullMQ DLQ + n8n webhook
[F1] FilePreviewModal component
[F2] Step-attachment upload zone in IntegratedBanner
[F3] Module gap-fill (all 4 modules)
[F4] Admin DSL editor UI
```
---
## Key Files Reference
| Task | File | Action |
|------|------|--------|
| B1 | `specs/03-Data-and-Storage/deltas/09-*.sql` | CREATE |
| B1 | `specs/03-Data-and-Storage/deltas/10-*.sql` | CREATE |
| B2 | `backend/src/modules/workflow-engine/entities/workflow-instance.entity.ts` | EDIT — add `versionNo` |
| B2 | `backend/src/modules/workflow-engine/entities/workflow-history.entity.ts` | EDIT — add `actionByUserUuid` |
| B2 | `backend/src/modules/workflow-engine/dto/workflow-history-item.dto.ts` | EDIT — add `actorUuid` |
| B3 | `backend/src/modules/workflow-engine/workflow-engine.service.ts` | EDIT — optimistic lock, rollback, metrics |
| B4 | `backend/src/modules/workflow-engine/guards/workflow-transition.guard.ts` | EDIT — DSL role → CASL |
| B5 | `backend/src/modules/workflow-engine/workflow-engine.module.ts` | EDIT — register metrics providers |
| B6 | `backend/src/modules/workflow-engine/workflow-engine.service.ts` | EDIT — cache set/del in createDefinition/update |
| B7 | `backend/src/modules/workflow-engine/workflow-event.service.ts` | EDIT — DLQ + n8n webhook |
| F1 | `frontend/components/workflow/file-preview-modal.tsx` | CREATE |
| F2 | `frontend/components/workflow/integrated-banner.tsx` | EDIT — upload zone |
| F2 | `frontend/components/workflow/workflow-lifecycle.tsx` | EDIT — attachment chips |
| F3 | `frontend/app/(admin)/admin/doc-control/correspondence/[uuid]/page.tsx` | EDIT — banner wiring |
| F3 | `frontend/app/(admin)/admin/doc-control/rfa/[uuid]/page.tsx` | EDIT — step-attach gap |
| F3 | `frontend/app/(admin)/admin/doc-control/transmittals/[uuid]/page.tsx` | EDIT — step-attach gap |
| F3 | `frontend/app/(admin)/admin/doc-control/circulation/[uuid]/page.tsx` | EDIT — step-attach gap |
| F4 | `frontend/app/(admin)/admin/workflows/definitions/page.tsx` | CREATE |
| F4 | `frontend/app/(admin)/admin/workflows/definitions/[id]/page.tsx` | CREATE |
---
## Critical Patterns
### Optimistic Lock — Client Side
```typescript
// Frontend: store versionNo from GET /workflow-engine/instances/:id
const { data: instance } = useWorkflowInstance(instanceId);
// On transition: pass versionNo in body
await triggerTransition({
action: 'APPROVE',
versionNo: instance.versionNo, // ← MUST include
attachmentPublicIds: pendingFiles,
comment,
});
// On 409 → show toast "เอกสารถูกอนุมัติโดยผู้อื่นแล้ว กรุณารีเฟรช"
// Invalidate query cache → user sees updated state
```
### DSL Role Mapping — Guard
```typescript
// backend/src/modules/workflow-engine/guards/workflow-transition.guard.ts
const DSL_ROLE_TO_CASL: Record<string, string> = {
'Superadmin': 'system.manage_all',
'OrgAdmin': 'organization.manage_users',
'ContractMember': 'contract.view',
'AssignedHandler': '__assigned__',
};
// In canActivate: extract require.role from DSL compiled state
const stepConfig = compiled?.states?.[instance.currentState];
const requiredRoles: string[] = stepConfig?.require?.role ?? [];
for (const dslRole of requiredRoles) {
const caslAbility = DSL_ROLE_TO_CASL[dslRole];
if (!caslAbility) continue;
if (caslAbility === '__assigned__') continue; // handled by Level 3 check
if (userPermissions.includes(caslAbility)) return true;
}
// Fall through to Level 3 (assignedUserId) check as before
```
### File Preview Modal — Usage
```tsx
// In workflow-lifecycle.tsx
import { FilePreviewModal } from './file-preview-modal';
const [preview, setPreview] = useState<WorkflowAttachmentSummary | null>(null);
// In attachment chip onClick:
<button onClick={() => setPreview(attachment)}>{attachment.originalFilename}</button>
<FilePreviewModal attachment={preview} onClose={() => setPreview(null)} />
```
### Admin DSL Editor — Monaco Setup
```tsx
// In definitions/[id]/page.tsx
import dynamic from 'next/dynamic';
const MonacoEditor = dynamic(() => import('@monaco-editor/react'), { ssr: false });
// Validate on change (debounced 800ms)
const handleEditorChange = useCallback(
debounce(async (value: string) => {
try {
const parsed = JSON.parse(value);
const result = await validateDsl(parsed);
setValidationErrors(result.errors);
} catch {
setValidationErrors([{ path: 'root', message: 'Invalid JSON' }]);
}
}, 800),
[]
);
```
---
## Testing Verification Commands
```bash
# Backend unit tests for workflow engine
cd backend
pnpm test --testPathPattern=workflow-engine --coverage
# Frontend typecheck
cd frontend
pnpm tsc --noEmit
# Frontend component tests
cd frontend
pnpm vitest run components/workflow
# Full backend test suite
cd backend
pnpm test --coverage
```
---
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `N8N_WEBHOOK_URL` | Prod only | URL for dead-letter job ops notifications |
| `REDIS_URL` | All | Redis connection for BullMQ + cache |
Both must be set in `docker-compose.yml` — never hardcoded.
---
## Commit Message Convention
```
feat(workflow-engine): add optimistic lock version_no (FR-002, ADR-001 v1.1)
feat(workflow-engine): add CASL DSL role mapping to guard (FR-002a)
feat(workflow-engine): structured transition log + metrics (FR-022/023)
feat(workflow-engine): DSL cache invalidation on activate (FR-007)
feat(workflow-engine): BullMQ DLQ + n8n webhook (FR-005/006)
feat(workflow-ui): FilePreviewModal component (FR-020)
feat(workflow-ui): step-attachment upload zone in IntegratedBanner (FR-014-019)
feat(workflow-ui): Admin DSL editor page (FR-024/025)
feat(correspondence): IntegratedBanner gap-fill wiring (FR-011)
chore(schema): delta-09 version_no, delta-10 action_by_user_uuid (ADR-009)
```
@@ -0,0 +1,209 @@
# Research: Unified Workflow Engine — Production Hardening Decisions
**Phase 0 Output** | Generated: 2026-05-02
**Builds on**: `specs/08-Tasks/ADR-021-workflow-context/research.md` (attachment strategy, FK structure, UUID type — all resolved previously)
---
## Decision 1: Optimistic Lock Strategy for `processTransition()` (FR-002)
**Question:** `processTransition()` already uses `pessimistic_write` DB lock. ADR-001 v1.1 requires adding `version_no` optimistic lock. Should they co-exist or replace?
### Option A: Replace pessimistic with optimistic (Selected ❌)
Remove `lock: { mode: 'pessimistic_write' }` and rely solely on `version_no` CAS.
**Cons:**
- Two concurrent requests with different `version_no` values still cause a race window between the DB read and the UPDATE
- Redlock already acquired before DB transaction — removing pessimistic adds no benefit to latency
### Option B: Dual-layer defense-in-depth (Selected ✅)
Keep `pessimistic_write` inside the transaction. Add `version_no` check as a **fast-fail before Redlock acquisition**.
**Flow:**
```
Client sends { action, version_no: N }
[Fast-fail] Read instance.version_no from DB (no lock)
If N ≠ instance.version_no → HTTP 409 immediately (no Redlock acquired)
[Acquire Redlock]
[DB Transaction with pessimistic_write]
Re-check version_no under lock (TOCTOU defense)
If still mismatch → 409 and release lock
Else: commit + increment version_no
```
**Pros:**
- Fast-fail saves Redlock round-trip for stale clients (SC-001 — no double approvals)
- Inner pessimistic lock prevents any residual race within the DB transaction
- Defense-in-depth: two independent barriers
**Decision:** Option B — dual-layer.
**Rationale:** Zero latency regression for non-conflicting requests; stale-client 409 fired before lock acquisition; inner lock remains for cross-process correctness.
---
## Decision 2: DSL `require.role` → CASL Ability Mapping (FR-002a)
**Question:** How should the guard resolve DSL `require.role: ["Admin"]` against the CASL permission model?
### Option A: Static config map in guard (Selected ✅)
```typescript
const DSL_ROLE_TO_CASL: Record<string, string> = {
'Superadmin': 'system.manage_all',
'OrgAdmin': 'organization.manage_users',
'ContractMember': 'contract.view',
'AssignedHandler': '__assigned__',
};
```
Guard resolves each DSL role string → CASL permission string → `userPermissions.includes(mapped)`.
**Pros:**
- No new DB tables or config entities
- Testable in isolation (mock `userPermissions`)
- Backward-compatible: unknown DSL roles fall through to `__assigned__` check
### Option B: Dynamic mapping table in DB (Rejected ❌)
Store DSL role → CASL ability mappings in a new `workflow_role_mappings` table.
**Cons:**
- New table requires ADR-009 delta + entity + service
- Over-engineering for a mapping that changes rarely
- Adds DB query to every transition guard check
**Decision:** Option A — static config map.
**Rationale:** The mapping is stable (tied to ADR-016 RBAC levels); config-driven in code is sufficient and avoids over-engineering.
---
## Decision 3: Per-Transition Prometheus Metrics (FR-023)
**Question:** The existing service has Redlock-specific metrics. Where should workflow-level transition metrics be registered?
### Existing metrics (keep)
- `workflow_redlock_acquire_duration_ms` (Histogram)
- `workflow_redlock_acquire_failures_total` (Counter)
### New metrics needed
- `workflow_transitions_total` (Counter, labels: `workflow_code`, `action`, `outcome`)
- `workflow_transition_duration_ms` (Histogram, labels: `workflow_code`)
**Registration approach:** Add `makeCounterProvider` and `makeHistogramProvider` in `workflow-engine.module.ts` via `@willsoto/nestjs-prometheus`. Inject with `@InjectMetric('workflow_transitions_total')`.
**Outcome label values:**
- `success` — transition committed
- `conflict` — optimistic lock mismatch (409) or TOCTOU
- `forbidden` — CASL guard rejection (403)
- `validation_error` — DSL condition failed (422)
- `system_error` — unexpected exception (500)
**Decision:** Register in `WorkflowEngineModule`; inject into `WorkflowEngineService`; record in `processTransition()` try/catch/finally block.
---
## Decision 4: DSL Definition Redis Cache Pattern (FR-007)
**Question:** Cache key format, TTL, and invalidation strategy for `workflow_definitions`.
### Cache key design
```
wf:def:{workflow_code}:{version} → single definition
wf:def:{workflow_code}:active → pointer to active version (for fast active lookup)
```
### Invalidation triggers
| Event | Action |
|-------|--------|
| `createDefinition()` | SET new key; leave old active pointer |
| `update()` — DSL change | DEL old key; SET updated key |
| `is_active = true` | SET `wf:def:{code}:active`; DEL previous active pointer |
| `is_active = false` | DEL `wf:def:{code}:active` |
### TTL: 3600 seconds (1 hour). Acceptable stale window for inactive definitions; active pointer is always invalidated on toggle.
**Decision:** Two-key pattern with a separate `:active` pointer key. Invalidate pointer immediately on `is_active` change → satisfies SC-005 (< 1s invalidation).
---
## Decision 5: BullMQ Dead-Letter Queue Architecture (FR-005, FR-006)
**Question:** How to implement `workflow-events-failed` DLQ with n8n webhook notification?
### Option A: Separate `workflow-events-failed` queue (Selected ✅)
```typescript
// WorkflowEventProcessor
@OnWorkerEvent('failed')
async onFailed(job: Job, error: Error) {
if (job.attemptsMade >= job.opts.attempts) {
// All retries exhausted → DLQ + webhook
await this.failedQueue.add('dead-letter', { jobId: job.id, ...job.data });
await this.notifyOps(job, error);
}
}
```
**Pros:**
- Failed jobs visible in Bull Board under `workflow-events-failed`
- Can be requeued manually via Bull Board UI
- n8n only notified on final failure (not on intermediate retries)
### Option B: BullMQ native `removeOnFail: false` only (Rejected ❌)
Keep jobs in `workflow-events` completed/failed states, no separate queue.
**Cons:**
- Bull Board has no separate DLQ view
- No ops notification mechanism
- Harder to isolate and requeue failed jobs
**Decision:** Option A — separate `workflow-events-failed` queue.
**n8n webhook:** Send via `fetch(process.env.N8N_WEBHOOK_URL, { method: 'POST', body: JSON.stringify(payload) })`. Guard with `if (!process.env.N8N_WEBHOOK_URL)` to avoid hard failure in dev environment.
---
## Decision 6: Admin DSL Editor — JSON Editor Library (FR-024, FR-025)
**Question:** Which JSON editor library for the DSL authoring UI?
### Option A: Monaco Editor (`@monaco-editor/react`) (Selected ✅)
Full VS Code-like editor with JSON syntax highlighting, bracket matching, and inline error markers.
**Pros:**
- Inline error decoration (squiggle underlines) — satisfies FR-025 inline validation feedback
- JSON schema validation via `monaco.languages.json.jsonDefaults.setDiagnosticsOptions()`
- Familiar to developers
- Already potentially used elsewhere in admin UIs
**Cons:**
- ~2MB bundle (lazy loaded via `dynamic(() => import('@monaco-editor/react'), { ssr: false })`)
### Option B: CodeMirror 6 (`@uiw/react-codemirror`) (Alternative)
Lighter (~400KB for JSON extension).
**Cons:**
- No native JSON Schema validation; requires custom linting extension
- Inline error decoration requires manual setup
**Decision:** Option A — Monaco Editor, lazy-loaded. The bundle cost is acceptable for an Admin-only page (not user-facing); inline validation is a critical FR-025 requirement.
**DSL Schema**: Provide the compiled DSL JSON Schema to Monaco for inline validation → errors shown before the user clicks Save.
---
## Carry-Forward from Prior Research
The following decisions from `specs/08-Tasks/ADR-021-workflow-context/research.md` remain valid and are not re-litigated:
- **File attachment strategy**: Upload-then-reference (Two-Phase, ADR-016) ✅
- **FK structure**: Direct `workflow_history_id` on `attachments` table ✅
- **UUID type for `workflow_histories.id`**: CHAR(36) UUID direct PK ✅
- **Redlock scope**: Transition-level Redlock (not document-numbering Redlock) ✅
- **Preview endpoint**: Use existing `/api/files/{publicId}` with `Content-Disposition: inline`
@@ -0,0 +1,216 @@
# Feature Specification: Unified Workflow Engine — Production Hardening & Integrated Context
**Feature Branch**: `003-unified-workflow-engine`
**Created**: 2026-05-02
**Status**: Draft
**References**: ADR-001 (Unified Workflow Engine v1.1), ADR-021 (Integrated Workflow Context & Step-specific Attachments)
---
## Clarifications
### Session 2026-05-02
- Q: How should the `WorkflowTransitionGuard` resolve DSL `require.role` values against the CASL permission system? → A: DSL `require.role` values map to **CASL ability checks** — each role string corresponds to a defined CASL `action:subject` permission pair (e.g., `"Admin"``workflow.manage`). The guard resolves permissions dynamically at transition time; it does NOT match DB role names directly.
- Q: What level of observability is required for workflow transition operations? → A: **Structured log + metrics** — one structured log entry per transition (instance ID, action, user UUID, duration ms, outcome: success/conflict/forbidden/error) plus a counter metric for transition throughput and a latency histogram. No distributed tracing required at this stage.
- Q: When a file has been moved to permanent storage but the DB transition subsequently fails, what is the recovery action? → A: **Move back to temp**`StorageService` moves the file from permanent back to temp on DB failure; temp files expire after a 24-hour TTL, allowing the user to retry the transition without re-uploading or re-scanning.
- Q: Does this feature include a frontend Admin UI for DSL authoring, or is API-only sufficient? → A: **Full Admin UI in scope** — a frontend page for Super Admins to create, edit (JSON editor), activate, and deactivate workflow definitions with inline DSL validation feedback. Visual workflow builder (drag-and-drop) remains Phase 2 / out of scope.
- Q: Which modules still need new Integrated Banner + Workflow Lifecycle integration work? → A: **All four modules need gap-filling** — RFA, Transmittal, Circulation, and Correspondence all have the banner component mounted but have incomplete data wiring (e.g., missing `availableActions`, no step-attachment upload support). None are fully complete; all require targeted completion work.
---
## User Scenarios & Testing _(mandatory)_
### User Story 1 — Workflow Transition with State Integrity (Priority: P1)
A Reviewer or Approver assigned to an active workflow step transitions a document from one state to the next (e.g., `PENDING_REVIEW``APPROVED`). The system must guarantee that only one transition occurs even if two users click "Approve" simultaneously, that the workflow history records who acted and when, and that downstream notifications are dispatched asynchronously without slowing down the response.
**Why this priority**: Core correctness of the Workflow Engine — without reliable, race-condition-free transitions the entire approval chain is unreliable.
**Independent Test**: Can be fully tested by submitting two concurrent approval requests and verifying only one succeeds (the other returns 409), and that the history table contains exactly one new record.
**Acceptance Scenarios**:
1. **Given** a document in `PENDING_REVIEW` state with `version_no = 5`, **When** an assigned handler submits the `APPROVE` action, **Then** the state transitions to `APPROVED`, `version_no` increments to `6`, and a new `workflow_histories` record is written within the same DB transaction.
2. **Given** two concurrent `APPROVE` requests for the same instance at the same `version_no`, **When** both reach the server simultaneously, **Then** exactly one succeeds (200) and the other receives 409 "Concurrent transition detected — please retry" without any data corruption.
3. **Given** a successful transition, **When** the transition commits, **Then** a BullMQ job is enqueued on the `workflow-events` queue within the same request (no inline notification call).
4. **Given** a `PENDING_REVIEW` instance and a user who is NOT the assigned handler and does NOT have the required CASL ability (e.g., `workflow.manage`) mapped from the DSL `require.role` value, **When** they attempt to transition, **Then** they receive 403 Forbidden.
---
### User Story 2 — Condition-Gated Transitions via DSL (Priority: P1)
A workflow step requires a condition to be met (e.g., `requiresLegal > 0`) before a transition is allowed. The DSL defines this as a JSON Logic rule, and the engine evaluates it against the current `context` at transition time.
**Why this priority**: Without reliable condition evaluation, automated gating (legal review, approval thresholds) fails and documents could bypass required steps.
**Independent Test**: Can be fully tested by configuring a DSL with a JSON Logic condition, providing a context that both satisfies and fails the condition, and observing that transitions are allowed/blocked accordingly.
**Acceptance Scenarios**:
1. **Given** a DSL transition with `{ "type": "json-logic", "rule": { ">": [{ "var": "requiresLegal" }, 0] } }` and context `{ "requiresLegal": 1 }`, **When** the `SUBMIT` action is triggered, **Then** the transition proceeds.
2. **Given** the same DSL and context `{ "requiresLegal": 0 }`, **When** `SUBMIT` is triggered, **Then** the transition is blocked and the caller receives a `ValidationException` (HTTP 422) with a field-level error.
3. **Given** a DSL that uses a raw JS string expression (`"context.x === true"`) instead of JSON Logic format, **When** an Admin attempts to save the DSL, **Then** the save is rejected with a validation error explaining only JSON Logic format is permitted.
---
### User Story 3 — Integrated Contextual Banner & Workflow Lifecycle View (Priority: P1)
A Reviewer opens a document detail page (RFA, Transmittal, Circulation, or Correspondence). Instead of navigating to a separate Workflow panel, the document header immediately shows the document number, current status, priority badge, and Approve/Reject action buttons. A "Workflow Engine" tab below displays a vertical timeline of all workflow steps — active step highlighted in indigo with a pulse animation.
**Why this priority**: Without the Integrated Banner and Lifecycle View (ADR-021 REQ-01 to REQ-03), Reviewers must switch between screens to understand context, increasing approval time and error rate.
**Independent Test**: Can be fully tested by opening any document in `PENDING_REVIEW` or `PENDING_APPROVAL` state and visually confirming the banner shows correct status + action buttons, and the timeline tab shows the active step in indigo.
**Acceptance Scenarios**:
1. **Given** an RFA in `PENDING_APPROVAL` state with priority `URGENT`, **When** the detail page loads, **Then** the banner at the top displays the document number, `PENDING_APPROVAL` status badge, `URGENT` priority badge, and `Approve`/`Reject` action buttons — all before the document body content.
2. **Given** a workflow with 4 steps (DRAFT → PENDING_REVIEW → PENDING_APPROVAL → APPROVED), **When** the document is in `PENDING_REVIEW`, **Then** step 2 shows indigo color with CSS pulse animation; steps 1, 3, 4 show no animation.
3. **Given** a completed document (`APPROVED` or `CLOSED`), **When** the detail page loads, **Then** the action buttons are disabled/hidden and no upload controls are visible.
---
### User Story 4 — Step-specific Attachment Upload & Preview (Priority: P2)
While reviewing a document in an active workflow step, a handler uploads evidence files (PDF, DWG, DOCX, XLSX, ZIP) to be linked specifically to that step's history record. Later, any authorized user can click the file to preview it inline via a modal without navigating away.
**Why this priority**: Step-specific attachments provide the audit trail required for compliance — files are traceable to the exact decision step. Preview reduces time spent downloading/opening files.
**Independent Test**: Can be fully tested by uploading a PDF during `PENDING_REVIEW`, transitioning to `APPROVED`, and verifying the file is visible under the `PENDING_REVIEW` history entry with inline preview working.
**Acceptance Scenarios**:
1. **Given** a document in `PENDING_REVIEW` state, **When** the assigned handler drags and drops a valid PDF onto the upload zone, **Then** the file is scanned by ClamAV, stored in permanent storage after a successful transition, and linked to the `workflow_histories` record for that step.
2. **Given** a document in `APPROVED` (terminal) state, **When** any user attempts to upload a file, **Then** the upload zone is disabled and the system returns HTTP 409 "Cannot upload to terminal state".
3. **Given** a file linked to a step, **When** any authorized user clicks the file name, **Then** a preview modal opens in-browser without navigating away from the detail page.
4. **Given** a file infected with malware detected by ClamAV, **When** upload is attempted, **Then** the temp file is deleted immediately, the upload is rejected, and the user sees "File rejected: security scan failed".
5. **Given** a duplicate upload request with the same `Idempotency-Key`, **When** the duplicate request arrives, **Then** the system returns the cached 201 response without creating a second record.
---
### User Story 5 — Workflow Definition Authoring (Super Admin Only) (Priority: P2)
A Super Admin creates or updates a workflow DSL definition via an **Admin UI page** (JSON editor with inline validation feedback). The system validates the DSL structure and activates the new version. In-progress workflow instances continue using their bound version until completion.
**Why this priority**: Without safe DSL authoring, new document types cannot be onboarded and workflow changes cannot be deployed without code releases.
**Independent Test**: Can be fully tested by creating a new DSL definition, activating it, and verifying existing in-progress instances still use the old version while new instances use the new version.
**Acceptance Scenarios**:
1. **Given** a Super Admin submits a valid DSL JSON, **When** the definition is saved and activated, **Then** the Redis cache key `wf:def:{workflow_code}:{version}` is invalidated immediately and new instances start using the new version.
2. **Given** an in-progress `workflow_instances` record bound to version 1, **When** version 2 is activated, **Then** the in-progress instance continues using version 1's `definition_id` until it reaches a terminal state.
3. **Given** a non-Super-Admin user, **When** they attempt to create or activate a DSL definition, **Then** they receive 403 Forbidden (`system.manage_all` required).
4. **Given** a context_schema with a `required` field, **When** a transition is triggered with a context missing that field, **Then** HTTP 422 is returned with `{ "field": "<context_field>", "message": "required field missing" }`.
---
### User Story 6 — Dead-letter Queue & Ops Recovery (Priority: P3)
A BullMQ `workflow-events` job fails all 3 retry attempts and moves to `workflow-events-failed`. Ops team is notified via n8n webhook and can manually requeue the job via Bull Board UI.
**Why this priority**: Without dead-letter recovery, failed event dispatches (notifications, downstream triggers) are silently lost, breaking audit trail integrity.
**Independent Test**: Can be fully tested by causing a simulated worker failure and verifying the n8n webhook fires and the job appears in the Bull Board dead-letter queue.
**Acceptance Scenarios**:
1. **Given** a `workflow-events` job that fails 3 times with exponential backoff, **When** attempts are exhausted, **Then** the job moves to `workflow-events-failed` queue and a webhook call is sent to `N8N_WEBHOOK_URL`.
2. **Given** a job in `workflow-events-failed`, **When** an Ops admin clicks "Retry" in Bull Board UI, **Then** the job re-enters `workflow-events` queue for processing.
3. **Given** a failed job, **When** the system auto-retries, **Then** it uses exponential backoff: attempt 1 immediately, attempt 2 after 500ms, attempt 3 after 1000ms — and does NOT auto-requeue after the dead-letter queue.
---
### Edge Cases
- What happens when Redis is down during a workflow transition (no Redlock available for state transition)? The optimistic lock (`version_no`) alone handles concurrency for transitions — Redis is NOT required for transitions (only for Document Numbering per ADR-002). Transition proceeds normally; only file-upload-plus-transition uses Redlock.
- What happens when a Redis Redlock fails during file-upload-plus-transition? Retry 3 times (500ms exponential backoff); if still failing, return HTTP 503 "Service temporarily unavailable" (Fail-closed — no partial state).
- What happens when a terminal-state workflow receives a transition request? The engine returns 409 `BusinessException` — "Workflow is already in a terminal state".
- What happens when `context_schema.required` field is missing at transition time? HTTP 422 `ValidationException` with field-level error — transition is blocked; caller must supply the missing context field and retry.
- What happens when a file is deleted from storage after being linked to a workflow step? The UI shows "File unavailable" for that attachment; the `workflow_histories` metadata record is preserved.
- What happens when two Admins concurrently activate different DSL versions for the same `workflow_code`? Last-write-wins on `is_active`; Redis cache is invalidated by both writes; existing instances are unaffected (already bound to a `definition_id`).
---
## Requirements _(mandatory)_
### Functional Requirements
**Workflow Engine Core (ADR-001)**
- **FR-001**: The system MUST evaluate workflow transition conditions using JSON Logic format (`{ "type": "json-logic", "rule": {...} }`) exclusively — no JavaScript string evaluation (`eval` / `new Function`).
- **FR-002**: The system MUST use optimistic locking (`version_no INT NOT NULL DEFAULT 1`) on `workflow_instances` to prevent concurrent double-transitions — only one transition per `(id, current_state, version_no)` tuple succeeds; the other receives HTTP 409.
- **FR-002a**: The `WorkflowTransitionGuard` MUST resolve DSL `require.role` values as **CASL ability checks** — each string value maps to a defined CASL `action:subject` pair (e.g., `"Admin"``workflow.manage`). Direct DB role-name matching is forbidden; permissions are evaluated dynamically at transition time via the CASL `AbilityFactory`.
- **FR-003**: The system MUST record every state transition in `workflow_histories`, including `action_by_user_id` (INT FK, internal, excluded from API) and `action_by_user_uuid` (VARCHAR 36, exposed in API per ADR-019).
- **FR-004**: All workflow events (notifications, side effects) MUST be dispatched via the dedicated BullMQ queue `workflow-events` — never inline within the request thread.
- **FR-005**: The `workflow-events` worker MUST be configured with concurrency 5, 3 retry attempts with exponential backoff, and a `workflow-events-failed` dead-letter queue.
- **FR-006**: When a job enters `workflow-events-failed`, the system MUST send a webhook to `N8N_WEBHOOK_URL` (env var, never hardcoded) to alert the ops team.
- **FR-007**: `workflow_definitions` MUST be cached in Redis with key `wf:def:{workflow_code}:{version}` (TTL: 1 hour), invalidated immediately when a Super Admin saves or activates a definition.
- **FR-008**: Context schema validation MUST occur in two phases: Phase 1 at definition save-time (structure), Phase 2 at transition-time (values against required fields) — missing required fields return HTTP 422 with field-level errors.
- **FR-009**: Only users with `system.manage_all` permission MAY create, update, activate, or deactivate workflow definitions.
- **FR-010**: In-progress `workflow_instances` MUST remain bound to the `definition_id` at time of creation — activating a new DSL version MUST NOT rebind in-progress instances.
**Integrated Banner & Lifecycle View (ADR-021 REQ-01 to REQ-03)**
- **FR-011**: Every document detail page (RFA, Transmittal, Circulation, Correspondence) MUST complete the Integrated Banner wiring — all four modules already have the component mounted but require gap-filling: live `workflowState`, `availableActions`, priority badge, and step-attachment upload support must be fully connected. No module is exempt.
- **FR-012**: The "Workflow Engine" tab on detail pages MUST display a vertical timeline of all workflow steps with: step role, handler name, description, and visual state (completed/active/pending).
- **FR-013**: The active step MUST be rendered with indigo color (`#6366f1`) and a CSS pulse animation; all other steps MUST NOT have the pulse animation.
**Step-specific Attachments (ADR-021 REQ-04 to REQ-05)**
- **FR-014**: The `attachments` table MUST have a nullable FK `workflow_history_id` — existing attachments without this FK are treated as main-document attachments.
- **FR-015**: Users MAY upload attachments only when the document is in an active-decision state (`PENDING_REVIEW` or `PENDING_APPROVAL`); uploads MUST be rejected with HTTP 409 when the document is in a terminal state (`APPROVED`, `REJECTED`, `CLOSED`).
- **FR-016**: Only the assigned step handler, organization admin, or Super Admin may upload step-specific attachments; unauthorized attempts return HTTP 403.
- **FR-017**: All uploaded files MUST be scanned by ClamAV before moving from temp to permanent storage; infected files MUST be deleted immediately and the user notified with "File rejected: security scan failed".
- **FR-018**: File uploads with a transition MUST require an `Idempotency-Key` header; duplicate requests with the same key return the cached result without re-processing.
- **FR-019**: Every step-specific attachment upload MUST be atomic with the workflow transition. Recovery on failure is: (1) if DB transition fails after file reaches permanent storage, `StorageService` MUST move the file back to temp storage; (2) temp files expire after a **24-hour TTL** and are automatically purged; (3) the user MAY retry the transition within the TTL window without re-uploading or re-scanning the file.
- **FR-020**: Any authorized user MAY preview PDF and image files inline via a modal without navigating away from the detail page.
**Admin UI — DSL Authoring (Super Admin)**
- **FR-024**: The system MUST provide an Admin UI page (accessible only to Super Admins) where DSL definitions can be created, edited (JSON editor), activated, and deactivated.
- **FR-025**: The DSL editor MUST display inline validation feedback — structure errors (Phase 1 save-time) are highlighted before the user saves; the page MUST NOT allow saving a DSL that fails Phase 1 validation.
**i18n (ADR-021 REQ-06)**
- **FR-021**: All UI text on new and updated components MUST use i18n keys — no hardcoded Thai or English strings.
**Observability**
- **FR-022**: The Workflow Engine MUST emit one structured log entry per transition containing: `instanceId`, `action`, `fromState`, `toState`, `userUuid`, `durationMs`, and `outcome` (`success` | `conflict` | `forbidden` | `validation_error` | `system_error`).
- **FR-023**: The Workflow Engine MUST record two metrics: (1) a **transition counter** labelled by `workflow_code`, `action`, and `outcome`; (2) a **transition latency histogram** (ms) labelled by `workflow_code`.
### Key Entities
- **WorkflowDefinition**: Versioned DSL template defining states, transitions, conditions, events, and context schema. Identified by `workflow_code` + `version`. One active version per code.
- **WorkflowInstance**: Running instance bound to a specific entity (RFA, Transmittal, Correspondence, Circulation). Tracks `current_state`, `context` (JSON), and `version_no` (optimistic lock).
- **WorkflowHistory**: Immutable record of every state transition. Linked to the acting user (both INT FK and UUID), comment, and metadata. Step-specific attachments link here.
- **Attachment**: File stored in permanent storage. May be a main-document attachment (`workflow_history_id = NULL`) or a step-specific attachment (`workflow_history_id` set).
---
## Success Criteria _(mandatory)_
### Measurable Outcomes
- **SC-001**: Zero concurrent double-approvals — a load test with 50 simultaneous `APPROVE` requests on the same workflow instance results in exactly 1 success and 49 responses with status 409.
- **SC-002**: Transition throughput — workflow state change (without file upload) completes in under 1 second (P95) for documents with up to 20 workflow history records under normal load.
- **SC-003**: Upload + transition SLA — `POST /workflow/:uuid/transition` with a file ≤ 10MB (including ClamAV scan, Redlock, and DB transaction) responds within 5 seconds (P95).
- **SC-004**: Event delivery reliability — less than 0.1% of `workflow-events` jobs reach the dead-letter queue under normal operating conditions.
- **SC-005**: DSL cache effectiveness — activating a new DSL version results in the stale cache entry being invalidated within 1 second on all app instances.
- **SC-006**: Integrated Banner adoption — 100% of document detail pages (RFA, Transmittal, Circulation, Correspondence) display the Integrated Banner and Workflow Engine tab after release.
- **SC-007**: No navigation required — reviewers complete document approval (view context + act) without leaving the detail page in 95%+ of sessions.
- **SC-008**: Audit completeness — every workflow transition has a corresponding `workflow_histories` record with user UUID, timestamp, action, and comment (if provided); zero orphaned transitions.
- **SC-009**: Observability coverage — 100% of workflow transitions (success, conflict, forbidden, error) produce a structured log entry and increment the transition counter metric; no silent failures.
---
## Assumptions
- ADR-001 Unified Workflow Engine backend infrastructure (`workflow_definitions`, `workflow_instances`, `workflow_histories` tables) is already partially implemented; this spec covers the production-hardening gaps (JSON Logic, `version_no`, dedicated BullMQ queue, context schema two-phase validation, ADR-019 UUID compliance for history records).
- ADR-021 Integrated Banner and Workflow Lifecycle components are **mounted but incompletely wired** across all four modules (RFA, Transmittal, Circulation, Correspondence). Common gaps include: missing live `availableActions`, no step-specific attachment upload zone, incomplete i18n. This spec closes all four modules to full completion.
- `json-logic-js` npm package is used for condition evaluation in `WorkflowDslService` (in-process, no external service).
- Redis and BullMQ infrastructure are available in all environments.
- ClamAV is available as a service and integrated via the existing `StorageService` two-phase upload pattern.
- `N8N_WEBHOOK_URL` environment variable will be set in `docker-compose.yml` for all environments before deploy.
- Bull Board UI (`@bull-board/nestjs`) will be installed for `workflow-events` and `workflow-events-failed` queue visibility.
@@ -0,0 +1,313 @@
# Tasks: Unified Workflow Engine — Production Hardening & Integrated Context
**Input**: Design documents from `specs/003-unified-workflow-engine/`
**Prerequisites**: plan.md ✅ | spec.md ✅ | data-model.md ✅ | research.md ✅ | contracts/ ✅ | quickstart.md ✅
**Tests**: Included for business-critical paths (per plan.md Test Plan)
**Organization**: Tasks grouped by user story (US1US5) enabling independent implementation and testing.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no shared dependencies)
- **[Story]**: Which user story this task belongs to
- **Exact file paths** included in all descriptions
---
## Phase 1: Setup (Schema Deltas — DB Prerequisites)
**Purpose**: Create and apply schema changes that ALL subsequent code depends on. No code changes until Phase 1 is complete.
**⚠️ MUST apply to DB before writing any entity code**
- [ ] T001 Create `specs/03-Data-and-Storage/deltas/09-add-version-no-to-workflow-instances.sql``ALTER TABLE workflow_instances ADD COLUMN version_no INT NOT NULL DEFAULT 1` with `idx_wf_inst_version` index (per data-model.md §1 Delta 09)
- [ ] T002 Create `specs/03-Data-and-Storage/deltas/10-add-action-by-user-uuid-to-workflow-histories.sql``ALTER TABLE workflow_histories ADD COLUMN action_by_user_uuid VARCHAR(36) NULL` (per data-model.md §1 Delta 10)
- [ ] T003 Apply Delta 09 to MariaDB: `source specs/03-Data-and-Storage/deltas/09-add-version-no-to-workflow-instances.sql` — verify with `DESCRIBE workflow_instances`
- [ ] T004 Apply Delta 10 to MariaDB: `source specs/03-Data-and-Storage/deltas/10-add-action-by-user-uuid-to-workflow-histories.sql` — verify with `DESCRIBE workflow_histories`
**Checkpoint**: Run `DESCRIBE workflow_instances` and `DESCRIBE workflow_histories` — both new columns must be present before Phase 2 begins.
---
## Phase 2: Foundational (Entity & Module Setup — Blocking Prerequisites)
**Purpose**: Entity/DTO/module changes that ALL user story implementations depend on. No user story work until Phase 2 is complete.
**⚠️ CRITICAL — blocks all phases 3+**
- [ ] T005 [P] Add `versionNo: number` column to `backend/src/modules/workflow-engine/entities/workflow-instance.entity.ts``@Column({ name: 'version_no', type: 'int', default: 1 })` (per data-model.md §2.1)
- [ ] T006 [P] Add `actionByUserUuid?: string` column to `backend/src/modules/workflow-engine/entities/workflow-history.entity.ts``@Column({ name: 'action_by_user_uuid', length: 36, nullable: true })` (per data-model.md §2.2)
- [ ] T007 [P] Add `actorUuid?: string` field to `backend/src/modules/workflow-engine/dto/workflow-history-item.dto.ts` with `@ApiPropertyOptional` decorator (per data-model.md §2.3)
- [ ] T008 Register `workflow_transitions_total` Counter and `workflow_transition_duration_ms` Histogram in `backend/src/modules/workflow-engine/workflow-engine.module.ts` via `makeCounterProvider` / `makeHistogramProvider` from `@willsoto/nestjs-prometheus` (per data-model.md §4, plan.md Phase B5)
- [ ] T009 [P] Verify backend TypeScript compiles with no errors after T005T008: `pnpm tsc --noEmit` in `backend/`
**Checkpoint**: `pnpm tsc --noEmit` passes in backend. Existing workflow-engine tests still pass: `pnpm test --testPathPattern=workflow-engine`.
---
## Phase 3: User Story 1 — Workflow Transition with State Integrity (P1) 🎯 MVP
**Goal**: Guarantee race-condition-free state transitions with optimistic lock, CASL-mapped DSL role checks, structured observability, BullMQ dead-letter queue, and file rollback on DB failure.
**Independent Test**: POST 50 concurrent APPROVE requests on one instance → exactly 1 success (200) + 49 conflicts (409). Transition log entry appears for each outcome. Redlock metric increments.
### Implementation — US1 Core: Optimistic Lock
- [ ] T010 [US1] Update `processTransition()` signature in `backend/src/modules/workflow-engine/workflow-engine.service.ts` — add `userUuid: string` and `clientVersionNo?: number` parameters (per data-model.md §3, quickstart.md)
- [ ] T011 [US1] Add fast-fail optimistic lock check in `processTransition()` BEFORE Redlock acquisition: read `instance.versionNo`, compare with `clientVersionNo`, throw `ConflictException('WORKFLOW_VERSION_CONFLICT')` HTTP 409 on mismatch (per data-model.md §3 "Fast-fail check")
- [ ] T012 [US1] Add CAS version increment inside DB transaction in `processTransition()`: `UPDATE workflow_instances SET version_no = version_no + 1 WHERE id = :id AND version_no = :expected` — throw `ConflictException` if `affected === 0` (per data-model.md §3 "Version increment")
- [ ] T013 [US1] Populate `actionByUserUuid: userUuid` when creating `WorkflowHistory` record inside `processTransition()` (per data-model.md §3 "History creation")
- [ ] T014 [US1] Return `versionNo` (post-increment value) in the transition response DTO so clients can update their local version
### Implementation — US1: CASL DSL Role Mapping (FR-002a)
- [ ] T015 [US1] Add `DSL_ROLE_TO_CASL` config map constant in `backend/src/modules/workflow-engine/guards/workflow-transition.guard.ts`: map `Superadmin → system.manage_all`, `OrgAdmin → organization.manage_users`, `ContractMember → contract.view`, `AssignedHandler → __assigned__` (per research.md Decision 2, quickstart.md)
- [ ] T016 [US1] Add DSL role resolution step in `WorkflowTransitionGuard.canActivate()`: load compiled definition from instance, extract `require.role[]` for `currentState`, map each via `DSL_ROLE_TO_CASL`, check `userPermissions.includes(mapped)` — pass if any match; fall through to existing Level 3 check for `__assigned__` (per plan.md Phase B4, quickstart.md "DSL Role Mapping" pattern)
### Implementation — US1: Structured Observability (FR-022, FR-023)
- [ ] T017 [US1] Inject `workflow_transitions_total` Counter and `workflow_transition_duration_ms` Histogram via `@InjectMetric()` in `WorkflowEngineService` constructor (per data-model.md §4)
- [ ] T018 [US1] Wrap `processTransition()` body in `startMs = Date.now()` timer; add `try/catch/finally` block that: labels `outcome` from exception type, calls `transitionDuration.labels({workflow_code}).observe(durationMs)`, calls `transitionsTotal.labels({workflow_code, action, outcome}).inc()`, emits structured `this.logger.log(JSON.stringify({instanceId, action, fromState, toState, userUuid, durationMs, outcome, workflowCode}))` (per data-model.md §4, FR-022/023)
### Implementation — US1: BullMQ Dead-Letter Queue (FR-005, FR-006)
- [ ] T019 [US1] Register `workflow-events-failed` queue in `backend/src/modules/workflow-engine/workflow-engine.module.ts` — inject via `BullModule.registerQueue({ name: 'workflow-events-failed' })` (per plan.md Phase B7)
- [ ] T020 [US1] Add `@OnWorkerEvent('failed')` handler `onJobFailed(job, error)` in `backend/src/modules/workflow-engine/workflow-event.service.ts`: if `job.attemptsMade >= job.opts.attempts`, add job to `workflow-events-failed` queue; if `N8N_WEBHOOK_URL` env var set, POST JSON payload via `fetch`; else `logger.warn('N8N_WEBHOOK_URL not configured')` (per data-model.md §6, research.md Decision 5)
- [ ] T021 [US1] Verify worker default options in `workflow-engine.module.ts` have `concurrency: 5`, `attempts: 3`, `backoff: { type: 'exponential', delay: 500 }`, `removeOnFail: false` (per FR-005, plan.md Phase B7)
### Implementation — US1: File Rollback on DB Failure (FR-019)
- [ ] T022 [US1] In `processTransition()` `catch` block, after `queryRunner.rollbackTransaction()`, call `storageService.moveToTemp(attachmentPublicIds)` when `attachmentPublicIds` is non-empty — log rollback with attachment IDs for audit (per plan.md Phase B8, FR-019)
- [ ] T023 [US1] Inject `StorageService` (or `FileStorageService`) into `WorkflowEngineService` constructor for rollback call — add to `workflow-engine.module.ts` imports if not already present
### Tests — US1
- [ ] T024 [P] [US1] Write unit test in `backend/src/modules/workflow-engine/workflow-engine.service.spec.ts` — concurrent optimistic lock: mock two simultaneous calls with same `clientVersionNo`, assert first resolves success and second throws `ConflictException` with code `WORKFLOW_VERSION_CONFLICT`
- [ ] T025 [P] [US1] Write unit test in `backend/src/modules/workflow-engine/guards/workflow-transition.guard.spec.ts` — DSL role CASL mapping: assert `Superadmin` maps to `system.manage_all` pass, `OrgAdmin` with matching org passes, unknown role falls through to assignedUserId check
- [ ] T026 [P] [US1] Write unit test for `onJobFailed` in `workflow-event.service.ts` — assert `workflow-events-failed` queue receives dead-letter job and `fetch` is called with correct payload when `N8N_WEBHOOK_URL` is set; assert `logger.warn` when unset
**Checkpoint**: `pnpm test --testPathPattern=workflow-engine --coverage` — T024/T025/T026 green. Concurrent lock test passes.
---
## Phase 4: User Story 2 — Integrated Banner & Workflow Lifecycle View (P1)
**Goal**: All four document detail pages (RFA, Transmittal, Circulation, Correspondence) display live `workflowState`, `availableActions`, and priority badge with no navigation required for approval.
**Independent Test**: Open each detail page while a workflow instance is in `PENDING_REVIEW` — banner shows correct state + action buttons; Workflow Engine tab shows step timeline with active step highlighted in indigo + pulse animation.
### Implementation — US2: Correspondence Backend Gap-Fill
- [ ] T027 [US2] Update `backend/src/modules/correspondence/correspondence.service.ts` `findOneByUuid()` — call `workflowEngineService.getInstanceByEntity('correspondence', correspondence.uuid)` and expose `workflowInstanceId`, `workflowState`, `availableActions` in the response (same pattern as Transmittal/Circulation per v1.8.7 memory)
- [ ] T028 [US2] Update `backend/src/modules/correspondence/correspondence.module.ts` — import `WorkflowEngineModule` if not already imported
### Implementation — US2: Frontend Module Gap-Fill (all 4 modules)
- [ ] T029 [P] [US2] Gap-fill `frontend/app/(admin)/admin/doc-control/correspondence/[uuid]/page.tsx` — wire live `workflowInstanceId`, `workflowState`, `availableActions`, `workflowPriority` into `<IntegratedBanner>` and `<WorkflowLifecycle>` components; update Correspondence type in `frontend/types/` to include workflow fields
- [ ] T030 [P] [US2] Gap-fill `frontend/app/(admin)/admin/doc-control/rfa/[uuid]/page.tsx` — connect missing `availableActions` and `workflowPriority` props to `<IntegratedBanner>`; ensure `<WorkflowLifecycle>` receives live `instanceId`
- [ ] T031 [P] [US2] Gap-fill `frontend/app/(admin)/admin/doc-control/transmittals/[uuid]/page.tsx` — add step-attachment upload zone props (`canUpload` flag computed from `currentState ∈ {PENDING_REVIEW, PENDING_APPROVAL}` AND user is assigned/org-admin/superadmin)
- [ ] T032 [P] [US2] Gap-fill `frontend/app/(admin)/admin/doc-control/circulation/[uuid]/page.tsx` — same step-attachment upload zone props as T031
- [ ] T033 [US2] Update `frontend/types/correspondence.ts` (or equivalent) — add `workflowInstanceId?: string`, `workflowState?: string`, `availableActions?: string[]`, `workflowPriority?: 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW'` (ADR-019: string UUIDs only, no parseInt)
### Tests — US2
- [ ] T034 [P] [US2] Verify `pnpm tsc --noEmit` in `frontend/` passes after T029T033 — all four detail pages type-check correctly
**Checkpoint**: All four detail pages render `<IntegratedBanner>` with live data. Switch a document to `PENDING_REVIEW` — banner shows correct action buttons without page navigation.
---
## Phase 5: User Story 3 — Step-specific Attachments with Preview (P1)
**Goal**: Users in `PENDING_REVIEW` / `PENDING_APPROVAL` states can upload files via drag-and-drop, attached atomically to the workflow step. All users can preview PDFs/images inline without navigation.
**Independent Test**: Upload a PDF during `PENDING_REVIEW` → click Approve → history timeline shows the file chip → click chip → preview modal opens inline. Force-fail DB transaction → file appears back in temp, permanent storage unchanged.
### Implementation — US3: File Preview Modal (FR-020)
- [ ] T035 [P] [US3] Create `frontend/components/workflow/file-preview-modal.tsx` — shadcn/ui `Dialog` component; accepts `attachment: WorkflowAttachmentSummary | null` and `onClose: () => void` props; renders `<iframe src="/api/files/{publicId}/preview" />` for PDFs; `<img>` for image MIME types; download link fallback for other types (per plan.md Phase F1, quickstart.md "File Preview Modal")
- [ ] T036 [P] [US3] Add `WorkflowAttachmentSummary` interface to `frontend/types/workflow.ts` if not present: `{ publicId: string; originalFilename: string; mimeType: string; fileSize: number; createdAt: string }` (ADR-019: `publicId` only, no `id` or `uuid` alias)
### Implementation — US3: Step-Attachment Upload Zone (FR-014FR-019)
- [ ] T037 [US3] Update `frontend/components/workflow/integrated-banner.tsx` — add conditional upload zone rendered only when `props.currentState ∈ {PENDING_REVIEW, PENDING_APPROVAL}` AND `props.canUpload === true`; upload calls existing Two-Phase upload endpoint; appends returned `publicId` to `pendingAttachmentIds` state; passes `pendingAttachmentIds` to action button handler (per plan.md Phase F2)
- [ ] T038 [US3] Update `frontend/components/workflow/workflow-lifecycle.tsx` — for each history item render `attachments[]` as clickable file chips; on chip click open `<FilePreviewModal>`; import and use `FilePreviewModal` from T035 (per plan.md Phase F2)
- [ ] T039 [US3] Update `frontend/hooks/use-workflow-action.ts` — accept `attachmentPublicIds: string[]` parameter; include in POST body to `/workflow-engine/instances/:id/transition`; include `versionNo` from current instance state; on HTTP 409 show toast "เอกสารถูกอนุมัติโดยผู้อื่นแล้ว กรุณารีเฟรช"; on 503 show toast "ระบบยุ่งชั่วคราว กรุณาลองใหม่" (per quickstart.md "Optimistic Lock — Client Side")
- [ ] T040 [US3] Update `backend/src/modules/workflow-engine/workflow-engine.controller.ts` — ensure `POST /instances/:id/transition` accepts `Idempotency-Key` header and passes `userUuid` (from JWT) and `clientVersionNo` to `processTransition()` (per contracts/workflow-transition.yaml)
- [ ] T041 [US3] Verify `WorkflowHistoryItemDto` exposes `attachments: AttachmentSummaryDto[]` in the history list endpoint response — update `getHistory()` method in `workflow-engine.service.ts` to eagerly load `attachments` relation per `workflow_history_id` (per data-model.md §3, FR-014)
### Tests — US3
- [ ] T042 [P] [US3] Write unit test in `backend/src/modules/workflow-engine/workflow-engine.service.spec.ts` — file rollback: mock `queryRunner.commitTransaction()` to throw; assert `storageService.moveToTemp()` is called with the correct `attachmentPublicIds` (per plan.md Test Plan)
- [ ] T043 [P] [US3] Write Vitest component test in `frontend/components/workflow/__tests__/file-preview-modal.test.tsx` — assert PDF renders `<iframe>`, image MIME type renders `<img>`, unsupported type renders download link, `onClose` called on dialog dismiss
**Checkpoint**: Upload a PDF on a document in `PENDING_REVIEW` → approve → check `workflow_histories` record has matching `workflow_history_id` in `attachments` table. Click the file chip → modal opens inline.
---
## Phase 6: User Story 4 — DSL Versioning & Instance Binding (P2)
**Goal**: Super Admins can activate new DSL versions; in-progress workflow instances continue on their bound definition version; Redis cache invalidates within 1 second of activation (SC-005).
**Independent Test**: Activate DSL v2 while v1 has an in-progress instance → existing instance still uses v1 DSL transitions; new instance created after activation uses v2.
### Implementation — US4: DSL Redis Cache Invalidation (FR-007, SC-005)
- [ ] T044 [US4] In `workflow-engine.service.ts` `createDefinition()` — after `workflowDefRepo.save()`, call `cacheManager.set('wf:def:${code}:${version}', saved, 3600000)` (1h TTL in ms) (per data-model.md §5, research.md Decision 4)
- [ ] T045 [US4] In `workflow-engine.service.ts` `update()` — before save, call `cacheManager.del('wf:def:${code}:${oldVersion}')` when DSL changes; when `is_active` toggles to `true`, call `redis.del('wf:def:${code}:active')` then set updated pointer; when `is_active` toggles to `false`, call `redis.del('wf:def:${code}:active')` (per data-model.md §5 "Invalidation triggers")
- [ ] T046 [US4] Add read-through cache in `getDefinitionById()`: call `cacheManager.get('wf:def:${id}')` first; fall back to `workflowDefRepo.findOne()` on miss; store result in cache before returning (per research.md Decision 4)
- [ ] T047 [US4] Verify `createInstance()` always uses latest active definition from DB (not cache) to prevent stale binding — confirm `findOne({ where: { workflow_code, is_active: true }, order: { version: 'DESC' } })` pattern is authoritative (per FR-010)
### Tests — US4
- [ ] T048 [P] [US4] Write unit test in `workflow-engine.service.spec.ts` — DSL activate cache invalidation: mock `cacheManager.del`, call `update({ is_active: true })`, assert `cacheManager.del` called with correct key within the same tick (per plan.md Test Plan)
**Checkpoint**: Activate DSL v2 via `PATCH /workflow-engine/definitions/:id` → Redis key `wf:def:{code}:active` updated immediately. In-progress v1 instance transitions still resolve against v1 compiled DSL.
---
## Phase 7: User Story 5 — Workflow Definition Authoring (Super Admin) (P2)
**Goal**: Super Admins can list, create, edit (JSON editor with inline validation), activate, and deactivate DSL definitions from an Admin UI page without touching the API directly.
**Independent Test**: Log in as Super Admin → navigate to `/admin/workflows/definitions` → create a new definition with an invalid DSL → see inline validation error before saving → fix → save → new definition appears in list.
### Implementation — US5: Backend `/validate` Endpoint (FR-025)
- [ ] T049 [US5] Add `POST /workflow-engine/definitions/validate` endpoint to `backend/src/modules/workflow-engine/workflow-engine.controller.ts` — accepts `{ dsl: object }`, calls `dslService.compile(dto.dsl)` in try/catch, returns `{ valid: true }` or `{ valid: false, errors: [{ path, message }] }` (per contracts/workflow-definitions.yaml, FR-025)
### Implementation — US5: TanStack Query Hooks
- [ ] T050 [P] [US5] Create `frontend/hooks/use-workflow-definitions.ts``useWorkflowDefinitions()` (GET list), `useWorkflowDefinition(id)` (GET single), `useCreateDefinition()` (POST mutation), `useUpdateDefinition()` (PATCH mutation), `useValidateDsl()` (POST validate mutation) — all using TanStack Query v5 patterns (per quickstart.md)
### Implementation — US5: Admin DSL List Page
- [ ] T051 [US5] Create `frontend/app/(admin)/admin/workflows/definitions/page.tsx` — Server Component shell + Client Component table; columns: `workflow_code`, `version`, `is_active` badge, created date, Actions (Edit link, Activate/Deactivate toggle button); uses `useWorkflowDefinitions()` hook; Activate/Deactivate calls `useUpdateDefinition()` mutation with `{ is_active: true/false }`; requires `system.manage_all` permission (CASL guard on page) (per plan.md Phase F4, FR-024)
### Implementation — US5: Admin DSL Editor Page
- [ ] T052 [US5] Create `frontend/app/(admin)/admin/workflows/definitions/[id]/page.tsx` — loads definition via `useWorkflowDefinition(id)`; renders Monaco Editor via `dynamic(() => import('@monaco-editor/react'), { ssr: false })`; `onChange` handler debounced 800ms calls `useValidateDsl()` mutation; displays validation errors as inline error list below editor; Save button disabled when `validationErrors.length > 0` (FR-025); on Save calls `useUpdateDefinition()` and shows success toast; i18n keys for all UI text (per research.md Decision 6, quickstart.md "Admin DSL Editor")
- [ ] T053 [US5] Create `frontend/app/(admin)/admin/workflows/definitions/new/page.tsx` — same editor as T052 but calls `useCreateDefinition()` mutation; `workflow_code` input field with validation; redirect to list page on success
### Tests — US5
- [ ] T054 [P] [US5] Write Vitest test for `frontend/app/(admin)/admin/workflows/definitions/[id]/page.tsx` — assert Save button is disabled when validation errors present; assert Save button enabled when `validationErrors` is empty; assert `useValidateDsl` is called on editor change (per plan.md Test Plan)
**Checkpoint**: Navigate to `/admin/workflows/definitions` — list renders all definitions. Click Edit → Monaco editor loads definition DSL. Paste invalid DSL → Save button disables and errors display inline. Fix DSL → Save enabled → save succeeds.
---
## Phase 8: Polish & Cross-Cutting Concerns
**Purpose**: i18n coverage, SC-009 verification, and spec compliance checks across all user stories.
- [ ] T055 [P] Audit all new UI text in `frontend/components/workflow/` and `frontend/app/(admin)/admin/workflows/` — replace any hardcoded Thai/English strings with i18n keys; add missing keys to `frontend/public/locales/th/` and `frontend/public/locales/en/` translation files (FR-021)
- [ ] T056 [P] Run full backend test suite: `pnpm test --coverage` in `backend/` — confirm no regressions; coverage ≥ 70% overall, ≥ 80% on `workflow-engine.service.ts` business logic (per plan.md Test Plan)
- [ ] T057 [P] Run full frontend typecheck: `pnpm tsc --noEmit` in `frontend/` — zero errors across all modified files
- [ ] T058 Verify SC-009 observability coverage: trigger one transition of each outcome type (success, conflict, forbidden, validation_error) and confirm structured log entries appear in the NestJS log output with all required fields (`instanceId`, `action`, `fromState`, `toState`, `userUuid`, `durationMs`, `outcome`, `workflowCode`)
- [ ] T059 Update `specs/003-unified-workflow-engine/spec.md` Status field from `Draft` to `Implemented` after all phases complete
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: No dependencies — start immediately
- **Phase 2 (Foundational)**: Depends on Phase 1 DB columns applied — **BLOCKS Phases 37**
- **Phase 3 (US1)**: Depends on Phase 2 — can start as soon as entities compile
- **Phase 4 (US2)**: Depends on Phase 2 — independent of Phase 3 (different files)
- **Phase 5 (US3)**: Depends on Phase 3 (uses updated `processTransition` + `use-workflow-action`) and Phase 4 (upload zone sits inside `IntegratedBanner`)
- **Phase 6 (US4)**: Depends on Phase 2 — independent of US1/US2/US3
- **Phase 7 (US5)**: Depends on Phase 6 (T049 validate endpoint, T044 cache) — `/validate` endpoint needed for editor inline feedback
- **Phase 8 (Polish)**: Depends on all phases complete
### User Story Dependencies
- **US1 (P1)**: Starts after Phase 2 — no US dependencies
- **US2 (P1)**: Starts after Phase 2 — no US dependencies (parallel with US1)
- **US3 (P1)**: Starts after US1 (T039 needs updated hook signature) and US2 (upload zone in banner)
- **US4 (P2)**: Starts after Phase 2 — independent (parallel with US1/US2)
- **US5 (P2)**: Starts after US4 (T049 validate endpoint depends on DSL cache from T044)
### Within Each Phase
- Schema before entities → entities before services → services before controllers → backend before frontend
- [P] tasks within a phase can run in parallel (different files)
---
## Parallel Execution Examples
### Phase 2 Parallel (T005T007 run together)
```
T005: workflow-instance.entity.ts ← add versionNo
T006: workflow-history.entity.ts ← add actionByUserUuid
T007: workflow-history-item.dto.ts ← add actorUuid
```
### Phase 3 Parallel Groups
```
Group A (processTransition core): T010 → T011 → T012 → T013 → T014 (sequential)
Group B (guard): T015 → T016 (sequential, different file from Group A — parallel with Group A)
Group C (observability): T017 → T018 (different file — parallel with Groups A+B)
Group D (BullMQ): T019 → T020 → T021 (different service file — parallel with Groups A+B+C)
Tests: T024, T025, T026 (parallel with each other after Groups A+B+D complete)
```
### Phase 4 + Phase 6 Parallel (different feature areas)
```
Phase 4 (US2): T027T034 — Correspondence backend + frontend gap-fill
Phase 6 (US4): T044T048 — DSL cache invalidation
(Run simultaneously — no shared files)
```
---
## Implementation Strategy
### MVP Scope (US1 + US2 + US3 — all P1)
```
Phase 1 → Phase 2 → Phase 3 (US1) → Phase 4 (US2) → Phase 5 (US3) → Phase 8 Polish
```
Delivers: Race-condition-free transitions, live banner on all 4 modules, step-specific attachments with preview.
### Full Delivery (adds P2 stories)
```
MVP + Phase 6 (US4) + Phase 7 (US5)
```
Adds: Redis cache invalidation, Admin DSL editor.
### Suggested First Commit
After T001T009 (schema + entities compile) → commit:
```
chore(schema): delta-09 version_no, delta-10 action_by_user_uuid (ADR-009)
feat(workflow-engine): add versionNo + actionByUserUuid entities + metrics registration (FR-002/003)
```
---
## Summary
| Phase | User Story | Tasks | Parallel Opportunities |
|-------|-----------|-------|----------------------|
| 1 — Setup | Schema | T001T004 | T001+T002 parallel |
| 2 — Foundational | — | T005T009 | T005+T006+T007 parallel |
| 3 — P1 US1 | Transition Integrity | T010T026 | Guard + observability + BullMQ parallel; tests parallel |
| 4 — P1 US2 | Banner Gap-Fill | T027T034 | T029+T030+T031+T032+T033 parallel |
| 5 — P1 US3 | Step Attachments | T035T043 | T035+T036 parallel; tests parallel |
| 6 — P2 US4 | DSL Versioning | T044T048 | T044+T046+T047 parallel |
| 7 — P2 US5 | Admin DSL Editor | T049T054 | T050+T054 parallel |
| 8 — Polish | Cross-cutting | T055T059 | T055+T056+T057 parallel |
| **Total** | | **59 tasks** | **~22 parallel opportunities** |
**MVP**: T001T043 (43 tasks, Phases 15, all P1 stories)
**Full**: T001T059 (59 tasks, all phases)
+59
View File
@@ -0,0 +1,59 @@
# 200-fullstacks
โฟลเดอร์นี้ใช้เก็บงานที่เกี่ยวกับ **Fullstack Development** (Backend + Frontend) ของระบบ NAP-DMS
## ขอบเขตงาน (Scope)
งานที่ควรอยู่ในโฟลเดอร์นี้ ได้แก่:
- **Feature Modules** - การพัฒนาฟีเจอร์ใหม่ที่มีทั้ง Backend และ Frontend
- **Module Integration** - การเชื่อมต่อระหว่าง Backend NestJS และ Frontend Next.js
- **Workflow Engine** - การพัฒนา Workflow Engine และการเชื่อมต่อกับ Modules
- **API Development** - การสร้าง API endpoints และ Frontend integration
- **UI Components** - การพัฒนา Components ที่ใช้ร่วมกัน
- **Business Logic** - การพัฒนา Business rules ที่ซับซ้อน
## ตัวอย่างงานที่อยู่ในโฟลเดอร์นี้
- `201-transmittals-circulation` - Transmittals + Circulation Integration
- `203-unified-workflow-engine` - Unified Workflow Engine
## การตั้งชื่อโฟลเดอร์
ใช้รูปแบบ: `2XX-feature-name`
- **2** = หลักร้อยของหมวดหมู่ (200-fullstacks)
- **XX** = เลขลำดับงาน (01, 02, 03, ...)
- **feature-name** = ชื่องาน (kebab-case)
ตัวอย่าง:
- `201-transmittals-circulation`
- `202-rfa-integration`
- `203-unified-workflow-engine`
## โครงสร้างไฟล์ในแต่ละงาน
แต่ละโฟลเดอร์งานควรมีไฟล์ต่อไปนี้ (ถ้าเกี่ยวข้อง):
```
2XX-feature-name/
├── spec.md # คำอธิบายงานโดยละเอียด
├── plan.md # แผนการดำเนินงาน
├── tasks.md # รายการงานย่อย
├── test-report.md # รายงานการทดสอบ (ถ้ามี)
├── quickstart.md # คู่มือเริ่มต้น (ถ้ามี)
├── research.md # การวิจัย/ศึกษา (ถ้ามี)
├── data-model.md # โครงสร้างข้อมูล (ถ้ามี)
├── checklists/ # Checklist ตรวจสอบ (ถ้ามี)
└── contracts/ # สัญญา/ข้อตกลง (ถ้ามี)
```
## การเชื่อมโยงกับ Core Specs
งานในโฟลเดอร์นี้ควรอ้างอิง Core Specs ที่เกี่ยวข้อง:
- `01-Requirements/` - Business Requirements และ User Stories
- `02-Architecture/` - System Architecture
- `03-Data-and-Storage/` - Schema และ Data Dictionary
- `05-Engineering-Guidelines/` - Backend/Frontend Guidelines
- `06-Decision-Records/` - ADRs ที่เกี่ยวข้อง (ADR-001, ADR-019, ADR-021)