Last active 4 days ago

135dika revised this gist 4 days ago. Go to revision

1 file changed, 272 insertions, 186 deletions

action-plan.docs.md

@@ -1,273 +1,359 @@
1 - # Flow BOD Reject — Rollback (Action Plans)
1 + # API — Action Plans
2 2
3 - Dokumen ini menjelaskan **secara rinci** apa yang terjadi pada seluruh hierarki Action Plan ketika hasil vote BOD adalah **ROLLBACK** (reject + rollback ke prerequisite). Bahasa Indonesia; contoh payload request/response tetap dalam bahasa Inggris sesuai konvensi API.
3 + Action plan module: **Activity → Task → Subtask** hierarchy with progress entries, approvers, prerequisites, members, and a BOD (Board of Directors) escalation flow.
4 4
5 - Referensi implementasi: `src/features/version/1/action-plans/` — helper utama di `action-plans.helpers.ts` (`restartRollbackSubtree` L173, `pauseSubtreeForRollback` L500, `reprocessReleasedChildren` L992, `markRollbackDownstreamConsumers` L371, `releaseRollbackPausedDependents` L259, `assertNotRollbackPaused` L465, `aggregateBodRound` L2200, `resolveApprovalNotifyTargets` L2659, `resolveActionPlanOwnerAdminUserIds` L2590).
5 + ## General
6 6
7 - ---
7 + - **Base**: `/api/v1/projects/:projectId/action-plans` — all paths below are relative to this base.
8 + - **Auth**: JWT, `@Authorize({ membership: true })` — caller must be a project member.
9 + - **Envelope**: responses wrapped `{ status, message, data }`; binary endpoints (PDF/XLSX) bypass the envelope.
10 + - **Access tiers** (`ProjectActionPlanAccessGuard`):
11 + - `INCOMPLETE_DATA` — not accessible.
12 + - `NOT_STARTED` / `IN_PROGRESS` — full access.
13 + - `NEED_APPROVAL` / `COMPLETED` / `REJECTED` — read-only for management writes; approval & BOD routes **stay writable**.
14 + - **VP/BOD read visibility** — a member assigned as VP (any item `approverId`) or as BOD (`ACTIVE` round approver) gets **full read** of tree/detail regardless of project tier or `ActionPlanMembers` membership. `MEMBER`-only users without any VP/BOD assignment see only their `ActionPlanMembers` scopes (others pruned).
15 + - **Progress input** (submit/respond/feedback) requires project `IN_PROGRESS` (`ProjectInProgressGuard`).
8 16
9 - ## 1. Ikhtisar
17 + ## Status enums
10 18
11 - Rollback dipicu saat **semua BOD yang ditugaskan sudah memberikan vote** dan agregasi menghasilkan **ROLLBACK** (lihat §2). Kronologi:
19 + ### Derived item `status`
12 20
13 - 1. Item yang di-escalate (sebut **B**) di-rollback ke salah satu **prerequisite langsung** yang dipilih BOD (sebut **X**).
14 - 2. **Subtree penuh X** (target) di-restart: progress terakhir leaf dijadikan `PENDING` dan status approval dikembalikan ke `WAITING_APPROVAL` — sesuai posisi masing-masing node (leaf / non-leaf / terhalang prerequisite).
15 - 3. **Subtree penuh B** (item yang di-rollback) **TIDAK di-restart saat itu juga**: semua node live di-set `WAITING_APPROVAL` + `rollbackPaused = true` + `bodResult = REJECTED_ROLLBACK`, **data progress TIDAK diubah** (tetap 100% `APPROVED`), tampil `NOT_STARTED`. B baru "diproses ulang" ketika X di-approve & `CLOSED` lagi (lihat §6c) — node leaf rantai awal di-flip ke `PENDING` saat itu.
16 - 4. **Konsumen downstream** (item same-level lain yang prerequisite-nya = X) ikut di-hold — lihat §6.
17 - 5. Semua perubahan ditulis **dalam satu transaksi database**; setiap item terdampak mendapat baris **activity log** dan (bila aktif) **notifikasi**.
18 - 6. Riwayat selesai ketika rantai prerequisite di-approve ulang berurutan sampai B di-approve/di-close lagi oleh VP & BOD.
21 + `INCOMPLETE_DATA` · `NEED_APPROVAL` · `IN_PROGRESS` · `NOT_STARTED` (also display override for paused/rolled back) · `COMPLETED`
19 22
20 - > Perbedaan kunci dengan **REJECT oleh VP**: VP REJECT hanya membuka **anak langsung** yang tidak memblokir (*non-blocker chain*), root yang direject tetap `REJECTED`. **BOD ROLLBACK me-restart subtree target X secara penuh**; **item B ditahan (hold) tanpa flip** sampai X selesai, baru subtree B dibuka ulang bertahap.
23 + ### `approvalStatus`
21 24
22 - ---
25 + `WAITING_APPROVAL` · `CLOSED` · `REJECTED` · `BOD_APPROVAL` (escalated)
23 26
24 - ## 2. Agregasi vote & prerequisitenya
27 + > Item without `approverId` (approval optional) never enters this enum — `approvalStatus` stays `null` (§ Approval optional).
25 28
26 - BOD vote lewat `POST …/approval/bod`:
29 + ### `bodResult`
27 30
28 - ```json
29 - // Request body (English, sesuai API)
30 - {
31 - "decision": "ROLLBACK", // "APPROVE" | "REVISE" | "ROLLBACK"
32 - "reason": "pekerjaan tidak sesuai spesifikasi", // wajib, min 1 karakter
33 - "rollbackId": "uuid-x" // wajib saat ROLLBACK; harus prereq LANGSUNG item & di-mark
34 - }
35 - ```
31 + `REJECTED_REVISE` · `REJECTED_ROLLBACK`
36 32
37 - Aturan:
33 + ### `bodDisplayStatus`
38 34
39 - - **Precedence hasil akhir** (frozen): `ROLLBACK > REVISE > APPROVE`.
40 - - **One-target lock**: BOD pertama yang vote `ROLLBACK` ke target T "mengunci" target. BOD berikutnya hanya boleh ROLLBACK ke T; target lain → `400 BOD rollback target is locked: all ROLLBACK votes must name the same prerequisite`.
41 - - **ROLLBACK_CONFLICT** (defensif, bila target berbeda tetap lolos): round tetap `ACTIVE`, item tetap `BOD_APPROVAL`, **tidak ada satu pun write** — menunggu desain. Hanya activity log `ACTION_PLAN_BOD_DECISION` yang muncul.
42 - - Semua vote `REVISE` → item `WAITING_APPROVAL` + `bodResult = REJECTED_REVISE`, data/progress **tidak disentuh**; VP harus meng-approve ulang (untuk merevisi, pendekatan re-approve VP).
43 - - Semua `APPROVE` (tanpa ROLLBACK/REVISE) → item `CLOSED` (handover) + map rapikan.
35 + | Value | Rule |
36 + |---|---|
37 + | `BOD_APPROVAL` | while escalated |
38 + | `REJECTED_BY_BOD` | `bodResult === REJECTED_REVISE` |
39 + | `NOT_STARTED` | `bodResult === REJECTED_ROLLBACK` **or** `rollbackPaused === true` |
40 + | `null` | no BOD state |
44 41
45 - Hasil agregasi ROLLBACK single-target:
42 + ### Progress row `status`
46 43
47 - | Nilai item B | Nilai round |
48 - |---|---|
49 - | `approvalStatus = WAITING_APPROVAL` | `status = CLOSED` |
50 - | `bodResult = REJECTED_ROLLBACK` | `result = REJECTED_ROLLBACK` |
51 - | `bodReason = reason` (vote ROLLBACK pertama) | `reason` dari voter pertama |
44 + `PENDING` · `APPROVED` · `APPROVED_WITH_NOTES` · `REJECTED`
52 45
53 - ---
46 + ## BOD escalation flow
54 47
55 - ## 3. Apa yang terjadi pada **B** (item yang di-rollback / direject)
48 + 1. **VP** responds via `…/approval/respond` with `verb`: `APPROVE` | `REJECT` | `ESCALATE` (this is one **2-step form**: step 1 picks the verb, step 2 picks BOD members via `bodUserIds` when `ESCALATE`). `ESCALATE` → `BOD_APPROVAL` + new BOD round; each BOD (project program agenda users) notified.
49 + 2. **BOD votes** via `…/approval/bod`: `APPROVE` | `REVISE` | `ROLLBACK` (reason required; `rollbackId` = direct prerequisite when ROLLBACK). **One-target lock**: after the first ROLLBACK vote to target T, all later BODs may only ROLLBACK to T (else 400).
50 + 3. **Resolution** once all assigned BODs voted — precedence **`ROLLBACK > REVISE > APPROVE`**:
51 + - ROLLBACK (same single target T) → T's **subtree restarts immediately** (leaf last `APPROVED` → `PENDING`, nodes → `WAITING_APPROVAL`, unmet-prereq nodes → `rollbackPaused` until prerequisite closes); the **item's own subtree (B) is NOT flipped** — every live node → `WAITING_APPROVAL` + `rollbackPaused = true` + `bodResult = REJECTED_ROLLBACK` (display `NOT_STARTED`, data/progress untouched, no notification). When T is approved & `CLOSED` again, B's subtree restarts (chain-start leaves → `PENDING` = NEED_APPROVAL, others → `WAITING_APPROVAL`). VP notified *Action Plan Rolled Back by BOD* at resolve.
52 + - else REVISE → item `WAITING_APPROVAL` + `bodResult = REJECTED_REVISE`; data/progress untouched; VP notified *Action Plan Rejected by BOD*.
53 + - else all APPROVE → `CLOSED` (handover); VP + admins + members notified *Action Plan Approved by BOD*.
54 + - Different ROLLBACK targets → no resolution, round stays `ACTIVE` (defensive; normally prevented by the lock).
55 + 4. **Re-escalation**: `…/approval/bod/re-request` + later `ESCALATE` carries previous BOD list over — **add-only**, cannot remove/replace.
56 + 5. **VP REJECT**: walks descendants level by level; startable leaves reopen (`NEED_APPROVAL`, last `APPROVED`→`PENDING`), leaves blocked by unmet live prerequisite pause (`rollbackPaused`) and auto-resume when the prerequisite closes (`releaseRollbackPausedDependents`).
57 + 6. **Delete guard**: progress ≥ 100% (derived `COMPLETED`) or `approvalStatus = CLOSED` → delete blocked 400 (also blocks cascade delete of a complete/closed descendant).
56 58
57 - Subtree B **tidak di-flip saat resolve**. Semua node live dalam subtree B (root + descendants) di-set lewat `pauseSubtreeForRollback(tx, scope, B)`:
59 + ## Approval optional (derived from `approverId`)
58 60
59 - - `approvalStatus = WAITING_APPROVAL`
60 - - `rollbackPaused = true` → tampil `NOT_STARTED` (override display)
61 - - `bodResult = REJECTED_ROLLBACK`
62 - - `approvalReason = null`
63 - - **Data progress TIDAK diubah** — baris progress tetap 100% `APPROVED` (nilai/approvedBy/approvedAt utuh)
64 - - **Tidak ada notifikasi** pada tahap ini (NOT_STARTED + WAITING ⇒ tanpa notif; VP "tidak bisa ngapa-ngapain" sebelum X selesai)
61 + VP approval is **optional per item**, derived from `approverId` itself (no separate flag):
65 62
66 - Node B baru "diproses ulang" ketika X di-approve & `CLOSED` lagi (release, §6c): saat itu seluruh subtree B di-restart — leaf rantai-awal di-flip `APPROVED`→`PENDING` (Need Approval), node lain jadi `WAITING_APPROVAL`, chain yang masih menunggu prereq tetap pause.
63 + - Item **WITH** `approverId` → approval **mandatory**: the full VP/BOD cycle applies — auto `WAITING_APPROVAL` on completion, VP respond, BOD escalation, Closed chip.
64 + - Item **WITHOUT** `approverId` (`null`) → approval **optional**: effective progress ≥ 100 completes the item with `approvalStatus` staying `null` — no `WAITING_APPROVAL`, no *approval-requested* notification, no Closed chip, no BOD possible.
67 65
68 - Kronologi B dalam dua tahap (leaf):
66 + **Assign-VP rule** (PATCH `approverId`) once the item's effective progress has reached ≥ 100 %:
69 67
70 - | Tahap | Status B |
68 + | Change | Result |
71 69 |---|---|
72 - | T1 resolve rollback | `NOT_STARTED` + `WAITING_APPROVAL`, progress tetap 100% `APPROVED` |
73 - | T2 setelah X `CLOSED` | leaf rantai-awal → progress terakhir di-flip `PENDING` (Need Approval) + notif ke Admin/Owner; leaf akar B → `WAITING_APPROVAL` tanpa flip (Completed & Waiting) + notif ke VP — VP approve → CLOSED; VP reject → flip progress akhir (`PENDING`) |
70 + | assign (`null` → person) | **400** |
71 + | remove (person → `null`) | **400** |
72 + | replace (person → person) | **allowed — pure swap**: only `approverId` changes; `approvalStatus` / `approvalReason` / `approvedAt` / `bodResult` / `bodReason` / `bodRoundId` / `rollbackPaused` all untouched, no reopen, no late-assignment notification |
74 73
75 - > Catatan: rollback BOD hanya men-flip baris progress `APPROVED` (strict). Baris `APPROVED_WITH_NOTES` tidak di-flip — berbeda dengan VP REJECT yang juga membuka `APPROVED_WITH_NOTES`.
74 + Below 100 % the pre-existing assignment semantics are unchanged (assignment never mutates `approvalStatus` on an incomplete item).
76 75
77 - ---
76 + **Prerequisite rule**:
78 77
79 - ## 4. Apa yang terjadi pada **X** (prereq yang dipilih sebagai opsi rollback)
78 + - VP-less prerequisite (`approverId = null`) → satisfied by effective progress ≥ 100 **alone** (no `CLOSED` needed).
79 + - VP-held prerequisite → still requires `CLOSED` **and** effective progress ≥ 100.
80 80
81 - Target X juga di-restart penuh, dengan fungsi yang sama: `restartRollbackSubtree(tx, scope, X)`.
81 + **Parent completeness** — a child counts toward the parent's `WAITING_APPROVAL` trigger ("done") when `child.approvalStatus = CLOSED` **or** (child has no VP **and** child's approved rollup ≥ 100).
82 82
83 - - X non-leaf → `WAITING_APPROVAL`, progress miliknya tidak diubah; subtree X di-traversal sama seperti §3b.
84 - - X leaf & semua prereq X terpenuhi → progress terakhir → `PENDING`, X → `WAITING_APPROVAL`.
85 - - X leaf & prereq X belum terpenuhi → `rollbackPaused = true` saja.
83 + **All approval endpoints on a VP-less item return 400**: `…/approval/respond` (incl. `ESCALATE`), `…/approval/re-request`, `…/approval/bod`, `…/approval/bod/re-request`.
86 84
87 - Jadi **X bukan "di-undo ke nol"** — X dibuka ulang untuk approval ulang dengan data progress tetap utuh. Begitu X di-approve & `CLOSED` lagi oleh VP (dan BOD bila wajib), release cascade berjalan (§6).
85 + **Existing-data migration** (applied mid-feature): prior rows with non-null `approvalStatus` were set to `CLOSED`, and ACTIVE BOD rounds were closed.
88 86
89 87 ---
90 88
91 - ## 5. Posisi X dalam rantai prerequisite (awal / tengah / akhir)
89 + # Endpoint catalog
90 +
91 + **Path legend**: `A` = `:activityId`, `T` = `:taskId`, `S` = `:subtaskId`. Full paths:
92 92
93 - Rantai contoh: `… → W → X → A → P → Q → I` (masing-masing `→` = prerequisite langsung; item di kiri adalah prereq item di kanan).
93 + - Activity level: `BASE/A/…`
94 + - Task level: `BASE/A/tasks/T/…`
95 + - Subtask level: `BASE/A/tasks/T/subtasks/S/…`
94 96
95 - ### 5a. X berada di **paling akhir rantai** (X tidak punya prereq lagi; X adalah "akar" dependensi)
97 + ## 1. Activities
96 98
97 - - Restart X + subtree X sesuai §4; subtree B **ditahan** (pause marker) sesuai §3.
98 - - X bisa langsung dibuka (`WAITING_APPROVAL`/flip) karena tidak menunggu prereq apapun.
99 - - Barisan konsumen A→P→Q→I **dan** subtree B baru ikut berjalan ketika X `CLOSED` lagi (release cascade, §6). Ini skenario paling "besar": seluruh rantai di belakang X menunggu, lalu terbuka berurutan.
99 + ### `POST /` (base)
100 + **Body** (`CreateActivityDto`):
101 + | Field | Req | Notes |
102 + |---|---|---|
103 + | `name` | ✅ | 1–255 chars |
104 + | `activityType` | ✅ | 1–100 chars |
105 + | `workWeight` | ✳️ | number ≥0 |
106 + | `workVolume` | ✳️ | number ≥0 — *requires `workVolumeUnit`* |
107 + | `workVolumeUnit` | ✳️ | ≤100 chars |
108 + | `startDate` | ✳️ | ISO date, floored to day |
109 + | `endDate` | ✳️ | ISO date |
110 + | `tasks[]` | ✳️ | ≤50, each: `name` ✅ (1–255), `workWeight/workVolume/workVolumeUnit/startDate/endDate` ✳️, nested `subtasks[]` ≤50 (same shape, `name` ✅) |
111 +
112 + ### `POST /bulk`
113 + Body: `CreateActivityDto[]` — 1–100 items. → `{ created, activities: [{id,name}] }`
114 +
115 + ### `GET /`
116 + Query:
117 + | Param | Req | Options / default |
118 + |---|---|---|
119 + | `page` | ✳️ | default `1` |
120 + | `limit` | ✳️ | default `20`, max `100` |
121 + | `search` | ✳️ | string |
122 + | `sort_by` | ✳️ | `created_at` (default) · `updated_at` · `name` |
123 + | `sort_order` | ✳️ | `asc` · `desc` (default) |
124 +
125 + ### `GET /tree`
126 + Query:
127 + | Param | Req | Options / default |
128 + |---|---|---|
129 + | `status` | ✳️ | derived status string filter |
130 + | `startDate` / `endDate` | ✳️ | bound effective date overlap range (one-sided allowed) |
131 + | `search` | ✳️ | string |
132 + | `sort_by` | ✳️ | `created_at` (default) · `start_date` · `end_date` · `start_date_end_date` |
133 + | `sort_order` | ✳️ | `asc` · `desc` (default `asc`) |
100 134
101 - ### 5b. X berada di **tengah rantai** (X punya prereq sendiri W, dan punya konsumen A di belakangnya)
135 + ### `GET /tree/gantt`
136 + Same query as `/tree`; nodes carry `ganttStatus`.
102 137
103 - - X dibuka ulang **tergantung W**: approb X hanya bisa dibuka penuh jika W `CLOSED`/terpenuhi; jika tidak, X jadi `rollbackPaused = true` (hold) sampai W closes.
104 - - Konsumen A (prereq langsung X) ikut di-hold (marker), P/Q/I menyusul sesuai release cascade; subtree B juga di-hold menunggu X.
105 - - Efek berantai: status ulang dimulai dari W (atau lebih dalam), lalu X, lalu A→P→Q→I (dan B).
138 + ### `GET /:activityId`
139 + Detail — item payload (§ Item payload).
106 140
107 - ### 5c. X berada di **paling awal rantai** (X punya konsumen, tapi X sendiri adalah prereq paling ujung milik item lain, misal X adalah konsumennya W)
141 + ### `PATCH /:activityId`
142 + Body (`UpdateActivityDto`) — all ✳️: `name`, `activityType`, `approverId`, `workWeight`, `workVolume`, `workVolumeUnit` (*requires workVolume pair rule*), `startDate`, `endDate`, `prerequisiteIds[]`.
108 143
109 - Anggap rantai: `W → X → A` dengan X punya prereq W.
144 + > `approverId`: assign / remove blocked 400 once complete (≥ 100 %); replace = pure swap (§ Approval optional).
110 145
111 - - Sama seperti §5b: pembukaan X menunggu W. Konsumen A & rantainya di-hold.
112 - - Karena X bukan akar dependensi, **efeknya tidak bisa "instan"** — semuanya menunggu W di-approve & close dulu, baru X, baru A, dst.
146 + ### `DELETE /:activityId`
147 + No body. **400** if item or live descendant complete (`≥100%`) or `CLOSED`.
113 148
114 - ### Prinsip umum
149 + ### `DELETE /bulk`
150 + Body (`BulkActionPlanDeleteDto`):
151 + | Field | Req | Notes |
152 + |---|---|---|
153 + | `activityIds[]` | ✳️ | ≤100, default `[]` |
154 + | `taskIds[]` | ✳️ | ≤100, default `[]` |
155 + | `subtaskIds[]` | ✳️ | ≤100, default `[]` |
115 156
116 - Rollback **hanya secara langsung** me-restart subtree **X** (target); **subtree B ditahan** (`rollbackPaused`, tanpa flip) sampai X `CLOSED` lagi, lalu di-restart bertahap (leaf rantai-awal di-flip). Item lain di belakang X (A, P, Q, I) **tidak di-restart** saat itu juga — mereka hanya di-*hold* (marker `rollbackPaused`, display `NOT_STARTED`) dan **release bertahap** satu-per-satu setiap kali prereqnya ditutup lagi (lihat §6). Data/progress mereka tidak pernah diubah sampai release.
157 + At least one id array non-empty. Parents cascade to descendants. → `{ deleted }`. Same complete/CLOSED guard.
117 158
118 - ---
159 + ## 2. Approval (3 levels; same routes)
119 160
120 - ## 6. Konsumen downstream & release bertahap
161 + Patterns (per level): `…/approval/respond`, `…/approval/re-request`, `…/approval/bod`, `…/approval/bod/re-request`.
121 162
122 - ### 6a. Marker saat rollback resolve (`markRollbackDownstreamConsumers`)
163 + ### `POST …/approval/respond` — VP (designated approver)
164 + Body (`ApprovalRespondDto`):
165 + | Field | Req | Notes |
166 + |---|---|---|
167 + | `verb` | ✅ | `APPROVE` · `REJECT` · `ESCALATE` |
168 + | `reason` | ✳️ | ≤500 chars |
169 + | `bodUserIds[]` | ✳️ | 1–50, each a valid agenda user — **required when `verb=ESCALATE`** |
123 170
124 - Untuk setiap item same-level **yang prereq LANGSUNG-nya = X** (selain B):
171 + ### `POST …/approval/re-request` — creator re-requests (only from `REJECTED`/`WAITING_APPROVAL`)
172 + No body.
125 173
126 - - Seluruh subtree live konsumen itu di-set `rollbackPaused = true` (**marker only**): **tidak ada** perubahan `approvalStatus` (konsumen yang sudah `CLOSED` tetap `CLOSED`), **tidak ada** flip progress, **tidak ada** notice, **tidak ada** recompute.
127 - - Eksklusi (defensif): item B, item X, dan subtree live keduanya.
128 - - Display: item & subtree itu tampil `NOT_STARTED` (override `rollbackPaused` → `NOT_STARTED`), walau data sebenarnya utuh.
129 - - Setiap node yang di-mark masuk activity log `ACTION_PLAN_ROLLBACK_PAUSED`.
174 + ### `POST …/approval/bod` — BOD member votes
175 + Body (`BodRespondDto`):
176 + | Field | Req | Notes |
177 + |---|---|---|
178 + | `decision` | ✅ | `APPROVE` · `REVISE` · `ROLLBACK` |
179 + | `reason` | ✅ | 1–500 chars |
180 + | `rollbackId` | ✳️ | — **required when `decision=ROLLBACK`**; must be a direct prerequisite |
130 181
131 - ### 6b. Hold (read-only)
182 + Guards: item `BOD_APPROVAL`, caller is assigned BOD with no vote yet, round not resolved.
132 183
133 - Item yang sedang `rollbackPaused` (di dirinya atau ancestor) **tidak bisa diubah** oleh endpoint write:
184 + ### `POST …/approval/bod/re-request` — VP resumes BOD cycle after REVISE
185 + No body.
134 186
135 - ```
136 - 400 <Activity|Task|Subtask> is on hold: waiting for its prerequisite to be approved and closed
137 - ```
187 + ### `GET /approval-obligations` — pre-member-removal check (project level)
188 + Query:
189 + | Param | Req | Notes |
190 + |---|---|---|
191 + | `userId` | ✅ | uuid — the member whose action-plan responsibilities are checked |
138 192
139 - Diblokir: update item, approval respond `/approval/respond`, `/approval/re-request`, `/approval/bod`, submit progress (`…/progress`), respond progress, `PUT …/plans`, member add/remove (per level sesuai guard; subtask progress melalui controller `assertSubtaskNotRollbackPaused`).
193 + Purpose: list the user's outstanding approval responsibilities in the project before member removal (removal itself lives outside this module). → `{ "hasObligation": bool, "obligations": […] }`:
140 194
141 - > Catatan implementasi: `request*BodReApproval` (langsung), `subtask …/plans PUT`, dan subtask member add/remove belum ter-guard langsung (inner plain re-request tetap ter-guard).
195 + - **VP obligations** — every item where the user is the VP, **all states including `CLOSED`**:
196 + `{ "type": "VP", "scope": "ACTIVITY" | "TASK" | "SUBTASK", "itemId", "itemName" }`
197 + - **BOD obligations** — every `ACTIVE` BOD round the user is an assigned approver, **including rounds they already voted on**:
198 + `{ "type": "BOD", "scope": "ACTIVITY" | "TASK" | "SUBTASK", "itemId", "itemName", "roundId", "responded" }` — `responded` = whether the user cast a decision.
142 199
143 - ### 6c. Release (`releaseRollbackPausedDependents`) — dipicu saat item `CLOSED`
200 + Empty result → `hasObligation: false`, empty `obligations`.
144 201
145 - Dipanggil **di dalam transaksi** tepat setelah item di-approve → `CLOSED`, pada 6 titik: VP `respond…Approval` (APPROVE) dan BOD all-approve (APPROVED).
202 + ## 3. Item payload (detail/list/tree)
146 203
147 - - Cari semua dependents **yang prereq langsung-nya = item yang baru close** DAN sedang `rollbackPaused` DAN semua prereq-nya terpenuhi.
148 - - Untuk **konsumen downstream** (tanpa `bodResult = REJECTED_ROLLBACK`): release = `approvalStatus = WAITING_APPROVAL` + `rollbackPaused = false` + `approvalReason = null`. **Tanpa flip progress** — VP tinggal meng-approve ulang pekerjaan yang sudah ada.
149 - - Untuk **subtree B** (dependent dengan `bodResult = REJECTED_ROLLBACK` — artinya item ini adalah bagian dari item yang di-rollback): release dilakukan **bersamaan dengan restart penuh subtree B**:
150 - - Node akar B leaf → `WAITING_APPROVAL` + unpause tanpa flip (Completed & Waiting; notif ke **VP**; flip progress baru terjadi bila VP REJECT).
151 - - Node non-leaf → `WAITING_APPROVAL` + unpause tanpa flip (notif ke **VP**), lalu anaknya di-reprocess:
152 - - **Leaf rantai-awal** (tidak punya prereq live / prereq rantai paling awal) → flip `APPROVED`→`PENDING` + `WAITING_APPROVAL` + unpause (Need Approval; notif ke **Owner/Admin**).
153 - - **Leaf yang masih punya prereq belum terpenuhi** → tetap pause (`rollbackPaused = true`), tanpa flip, tanpa notif — terbuka nanti saat prereqnya selesai.
154 - - **Non-leaf** → `WAITING_APPROVAL` + unpause, lalu turun ke anaknya (notif ke VP).
155 - - **Blocker child** (dipakai sebagai prereq oleh sibling live) → tetap pause tanpa flip/notif.
156 - - Mirip restart X non-leaf, tetapi **dimulai belakangan**: baru berlangsung setelah X `CLOSED`, bukan saat resolve.
157 - - **Cascade bertahap**: anak-anak konsumen tidak di-release dalam gelombang yang sama — mereka release ketika **konsumen itu sendiri** (atau prerequisite terkait) di-approve & close lagi. Dengan begitu: X close → **B**/A dibuka; node lanjutan terbuka berurutan ketika prereqnya ditutup. Ini perilaku "Waiting Approval dari A lalu ke P dst" yang disepakati tim desain.
158 - - Setiap node yang release masuk activity log `ACTION_PLAN_ROLLBACK_RELEASED`; node B yang di-flip masuk notifikasi *Ready for Approval* / *progress reopened* (routing lihat §8).
204 + BOD-related fields on every item: `approvalStatus`, `approvalReason`, `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused`, plus `approverId/approverName/approverEmail`, `prerequisites[]`, `prerequisiteIds[]`.
159 205
160 - Urutan release pada rantai `X → A → P → Q → I` (dan B di sisi lain):
206 + Detail-only (`GET /A`, `GET A/tasks/T`, `GET A/tasks/T/subtasks/S`):
161 207
162 - ```
163 - [X closed] → A → WAITING_APPROVAL + subtree B mulai di-restart (leaf rantai-awal → PENDING)
164 - [A approved]→ A → CLOSED → P → WAITING_APPROVAL
165 - [P approved]→ P → CLOSED → Q → WAITING_APPROVAL
166 - [Q approved]→ Q → CLOSED → I → WAITING_APPROVAL
167 - [I approved]→ I → CLOSED (rantai pulih penuh)
208 + ```json
209 + "bodApprovals": [{ "userId", "name", "decision", "reason", "rollbackItemId", "rollbackTargetName", "respondedAt" }],
210 + "rollbackOptions": [{ "id", "name" }] // direct prereqs; locked to single target after first ROLLBACK vote
168 211 ```
169 212
170 - ---
213 + ## 4. Tasks — `BASE/A/tasks`
171 214
172 - ## 7. Leaf vs children — ringkas
215 + | Method+Path | Body / notes |
216 + |---|---|
217 + | `POST /` | `CreateTaskDto` — `name` ✅; `workWeight/workVolume/workVolumeUnit/startDate/endDate` ✳️ (volume pair rule) |
218 + | `GET /` | list under activity |
219 + | `GET /:taskId` | detail (§ Item payload) |
220 + | `PATCH /:taskId` | `UpdateTaskDto` — `name/approverId/workWeight/workVolume/workVolumeUnit/startDate/endDate/prerequisiteIds[]` all ✳️ — `approverId`: assign/remove blocked 400 once complete, replace = pure swap (§ Approval optional) |
221 + | `DELETE /:taskId` | complete/CLOSED guard 400 |
173 222
174 - | Bentuk item | Saat rollback resolve | Saat X re-`CLOSED` |
175 - |---|---|---|
176 - | **B leaf** | `NOT_STARTED` + `WAITING_APPROVAL` + `rollbackPaused`, progress TIDAK di-flip (tetap 100%) | `WAITING_APPROVAL` + unpause tanpa flip (Completed & Waiting, notif VP); flip progress hanya bila VP REJECT |
177 - | **B non-leaf** | root + seluruh subtree live: `WAITING_APPROVAL` + pause (marker), tanpa flip | root & non-leaf: unpause (notif VP); leaf rantai-awal: flip `APPROVED`→`PENDING` (Need Approval, notif Owner/Admin); leaf ber-prereq belum terpenuhi: tetap pause |
178 - | **X leaf / non-leaf** | restart penuh: leaf di-flip `APPROVED`→`PENDING`, non-leaf `WAITING_APPROVAL`, blocker-subtree di-pause | — (release cascade konsumen) |
179 - | **Konsumen A (prereq langsung = X)** | seluruh subtree `rollbackPaused = true` (marker only; A yang `CLOSED` tetap `CLOSED`); display `NOT_STARTED` | `WAITING_APPROVAL` tanpa flip (notif VP); release bertahap |
180 - | **Item rantai lebih dalam (P, Q, I)** | tidak di-restart; ikut setelah release cascade ketika prereq masing-masing close | — |
223 + Plus per task: progress (`POST/GET /:taskId/progress`, `GET /:taskId/progress/:progressId`, `POST …/respond`), feedbacks, `GET/PUT /:taskId/plans`, members (`POST/DELETE/GET /:taskId/members`), edit-requests (`POST/GET /:taskId/edit-requests`), approval routes (§ 2).
181 224
182 - Data yang **tidak pernah hilang**: seluruh baris progress (termasuk yang `APPROVED`/`APPROVED_WITH_NOTES`), file/lampiran, feedback, plans, dan history activity log. Yang berubah hanya **status** (`status` derived, `approvalStatus`) dan **pointer `rollbackPaused`/`bodResult`** — flip progress memakai mekanisme amends (`amendsId`) sehingga riwayat progress tetap terlihat.
225 + ## 5. Subtasks — `BASE/A/tasks/T/subtasks`
183 226
184 - ---
227 + | Method+Path | Body / notes |
228 + |---|---|
229 + | `POST /` | `CreateSubtaskDto` — `name` ✅; `workVolume/workVolumeUnit/workWeight/startDate/endDate` ✳️ (volume pair rule) |
230 + | `GET /` | list under task |
231 + | `GET /:subtaskId` | detail |
232 + | `PATCH /:subtaskId` | `UpdateSubtaskDto` — all ✳️ incl `approverId`, `prerequisiteIds[]` — `approverId`: assign/remove blocked 400 once complete, replace = pure swap (§ Approval optional) |
233 + | `DELETE /:subtaskId` | complete/CLOSED guard 400 |
185 234
186 - ## 8. Notifikasi & activity log yang dihasilkan (sekali rollback resolve)
235 + Plus: members (`POST/DELETE/GET /:subtaskId/members`), plans (`GET/PUT /:subtaskId/plans`), edit-requests (`POST/GET /:subtaskId/edit-requests`), progress (`POST/GET /:subtaskId/progress`, `GET /:subtaskId/progress/:progressId`, `POST …/respond`), feedbacks (`POST/GET …/feedbacks`, `PATCH/DELETE …/feedbacks/:feedbackId`), approval routes (§ 2).
187 236
188 - **Routing notifikasi (aturan baku, dipakai semua transisi approval):** tujuan penerima ditentukan dari status item setelah transisi (`resolveApprovalNotifyTargets`):
237 + ## 6. Members
189 238
190 - | Status item setelah transisi | Penerima |
239 + | Method+Path | Body |
191 240 |---|---|
192 - | Derived `NEED_APPROVAL` (ada progress `PENDING`, mis. leaf baru di-flip) | **Owner/Admin project** (`resolveActionPlanOwnerAdminUserIds`) |
193 - | Derived `COMPLETED` (≥100%) & `approvalStatus = WAITING_APPROVAL` | **VP** (`approverId`) |
194 - | Derived `COMPLETED` & `approvalStatus = BOD_APPROVAL` | **BOD** (approvers round `bodRoundId`) |
195 - | `rollbackPaused` / `NOT_STARTED` (walau `WAITING_APPROVAL`) | **tidak ada notifikasi** |
241 + | `POST /A/members` · `DELETE /A/members` (×task/subtask) | `{ "userIds": ["uuid…"] }` — 1–100, required |
242 + | `POST /members/bulk` | `BulkActionPlanMembersDto`: `activityIds[]` (≤100, default `[]`), `taskIds[]` (≤100, default `[]`), `subtaskIds[]` (≤100, default `[]`), `userIds[]` (1–100 ✅) — ≥1 id array non-empty; parent scopes cascade to leaves → `{ assigned, skipped }` |
243 + | `DELETE /members/bulk` | same body → `{ removed }` |
244 + | `GET /A/members` · `GET A/tasks/T/members` · `GET …/subtasks/S/members` | list w/ display names + leaf scopes |
196 245
197 - Satu resolusi ROLLBACK menghasilkan (via outbox in-transaksi; fallback legacy fire-and-forget bila writer tidak ada):
246 + ## 7. Progress (multipart) — 3 levels
198 247
199 - | Notifikasi | Penerima (menurut routing di atas) | Isi (ringkas) |
248 + ### `POST …/progress` — submit
249 + Multipart form fields (`CreateProgressDto`):
250 + | Field | Req | Notes |
200 251 |---|---|---|
201 - | `sendBodRolledBack` | Owner/Admin + members + VP | *Action Plan Rolled Back by BOD* — `…was rolled back by {bodName}. Its prerequisite {prereqName} was re-opened for re-approval.` |
202 - | `sendApprovalReopened` | per node non-paused (X-side: flipped→Owner/Admin, WAITING→VP) | approval dibuka ulang (`WAITING_APPROVAL`) |
203 - | `sendProgressReopened` | per leaf yang di-flip (X-side → Owner/Admin) | progress terakhir dibuka ulang (`PENDING`) |
204 - | `sendApprovalRequestedReleased` | per release (B-side: flipped/NEED_APPROVAL→Owner/Admin, WAITING→VP; konsumen→VP) | *Action Plan Ready for Approval* — `…Its prerequisite {prereqName} was rolled back and has been re-approved and closed.` |
252 + | `progress` | ✳️ | coerced number 0–100; omit = carry current floor |
253 + | `workVolume` | ✳️ | number ≥0 |
254 + | `description` | ✳️ | ≤5000 chars |
255 + | `createdAt` | ✳️ | ISO date (backdated work) |
256 + | `documentSource` | ✳️ | `upload` · `document` |
257 + | `documentRef` | ✳️ | JSON string `{"documentId","versionId","revisionId"}` (when source=document) |
258 + | `companyId` | ✳️ | guards cross-company document access |
259 + | Files | | `attachments[]` (jpg/png/gif/webp/svg/pdf), `document` (×1) |
260 +
261 + ### `GET …/progress`
262 + Query: `page` (default 1) · `limit` (default 50, max 100).
205 263
206 - > B-side **tidak menimbulkan notifikasi apa pun saat resolve** — seluruh subtree B pause (NOT_STARTED). Notifikasi untuk B baru muncul ketika X `CLOSED` lagi (tahap release, §6c).
264 + ### `GET …/progress/:progressId`
265 + Single row: `id, scopeType, scopeId, scopeName, progress, workVolume, scopeWorkVolume, scopeWorkVolumeUnit, description, status, amendsId, amendsOriginal, amendedByCount, amendedBy[], approvedBy, approvedByName, approvedAt, createdBy, createdByName, updatedBy, updatedByName, createdAt, updatedAt, attachments[], document`.
207 266
208 - Activity log (namespace `action_plan`, `GET /api/projects/:projectId/activity`) — **satu baris per aksi, per item terdampak**:
267 + ### `POST …/progress/:progressId/respond`
268 + Body (`RespondProgressDto`): `verb` ✅ (`APPROVE`·`APPROVE_WITH_NOTES`·`REJECT`); `progress` ✳️ (0–100), `workVolume` ✳️, `description` ✳️ (≤5000) — overridable on APPROVE_WITH_NOTES.
209 269
210 - | Kategori | Saat |
270 + ## 8. Feedback — `…/progress/:progressId/feedbacks`
271 +
272 + | Method+Path | Body |
211 273 |---|---|
212 - | `ACTION_PLAN_BOD_DECISION` | setiap vote BOD (termasuk yang masih pending/conflict) |
213 - | `ACTION_PLAN_BOD_ROLLBACK` | resolusi rollback (item B; `prerequisiteName`) |
214 - | `ACTION_PLAN_ROLLBACK_REOPENED` | **per node** yang di-restart (subtree X, termasuk paused) |
215 - | `ACTION_PLAN_ROLLBACK_PAUSED` | **per node** yang di-hold (subtree B saat resolve + konsumen) |
216 - | `ACTION_PLAN_ROLLBACK_RELEASED` | **per node** yang release saat prereq closes (termasuk flip leaf B) |
274 + | `POST …/feedbacks` | `{ "message": "string" }` — 1–5000 chars ✅ |
275 + | `GET …/feedbacks` | query: `page` (1) · `limit` (50, max 100) |
276 + | `PATCH …/feedbacks/:feedbackId` | `{ "message" }` same validation |
277 + | `DELETE …/feedbacks/:feedbackId` | — |
217 278
218 - ---
279 + ## 9. Plans / S-curve — `…/plans` (3 levels)
219 280
220 - ## 9. Contoh payload (English, sesuai API)
281 + `GET …/plans` → `{ duration: {days,startDate,endDate}, planning: [{id?,startDate,endDate,targetProgress}], sCurve: { plan:[{date,progress}], actual:[{date,progress,id}] } }`
282 + `PUT …/plans` body (`SetProgressPlansDto`): `plans[]` (≥0): `startDate` ✅ (date), `endDate` ✅ (date), `targetProgress` ✅ (0–100).
221 283
222 - ### BOD vote (request)
284 + ## 10. Edit requests — member → owner/admin
223 285
224 - ```json
225 - POST /api/v1/projects/:projectId/action-plans/:activityId/approval/bod
226 - {
227 - "decision": "ROLLBACK",
228 - "reason": "Volume pekerjaan tidak sesuai progres approved",
229 - "rollbackId": "activity-x-id"
230 - }
231 - ```
286 + | Method+Path | Body |
287 + |---|---|
288 + | `POST …/edit-requests` (3 levels + none at project) | `CreateEditRequestDto`: `endDate` ✳️, `workWeight` ✳️, `workVolume` ✳️, `workVolumeUnit` ✳️, `activityType` ✳️ — one or more changed fields; `reason` ✅ (1–500 chars) |
289 + | `GET …/edit-requests` · `GET /edit-requests` | query: `page` (1) · `limit` (50, max 100) · `status` ✳️ (`PENDING`·`APPROVED`·`REJECTED`) |
290 + | `POST /edit-requests/:requestId/respond` | `{ "verb": "APPROVE" \| "REJECT" }` ✅ |
232 291
233 - ### Item detail (response `data` — potongan field BOD)
292 + List rows include `requestedData`, `oldData`, `reason`, `status`, requester/responder names, timestamps.
234 293
235 - ```json
236 - {
237 - "id": "activity-b-id",
238 - "approvalStatus": "WAITING_APPROVAL",
239 - "approvalReason": null,
240 - "bodRoundId": "round-1",
241 - "bodResult": "REJECTED_ROLLBACK",
242 - "bodReason": "Volume pekerjaan tidak sesuai progres approved",
243 - "bodDisplayStatus": "NOT_STARTED",
244 - "rollbackPaused": true,
245 - "approverId": "user-vp-id",
246 - "bodApprovals": [
247 - {
248 - "userId": "bod-1",
249 - "name": "Hendra",
250 - "decision": "ROLLBACK",
251 - "reason": "Volume pekerjaan tidak sesuai progres approved",
252 - "rollbackItemId": "activity-x-id",
253 - "rollbackTargetName": "Perizinan",
254 - "respondedAt": "2026-09-10T03:00:00.000Z"
255 - }
256 - ],
257 - "rollbackOptions": [{ "id": "activity-x-id", "name": "Perizinan" }]
258 - }
259 - ```
294 + ## 11. Analytics & reports
295 +
296 + | Method+Path | Query |
297 + |---|---|
298 + | `GET /analytics` | `layout` ✳️ (`detailed` default · `simplified`); `granularity` ✳️ (`daily` · `weekly` default · `monthly`); `search` ✳️ (≤200); `activityName` ✳️ (≤200, case-insensitive contains); `startDate`/`endDate` ✳️ |
299 + | `GET /analytics/activity-names` | — → `string[]` |
300 + | `GET /analytics/status-counts` | `status` ✳️ (default `IN_PROGRESS`): `INCOMPLETE_DATA`·`NEED_APPROVAL`·`IN_PROGRESS`·`NOT_STARTED`·`COMPLETED` |
301 + | `GET /analytics/report/download` | `granularity` ✳️ (`weekly` default · `monthly` · `yearly`); `search`, `activityName`, `startDate`, `endDate` ✳️ → PDF binary |
302 + | `GET /analytics/report/history` | `page` (1) · `limit` (20, max 100) · `startDate`/`endDate` ✳️ → records `{seq, docNumber, layout, granularity, windowStart, windowEnd, generatedAt, s3Key}` |
303 + | `GET /analytics/report/history/:recordId/download` | — → PDF binary |
260 304
261 - > Keterangan: `bodDisplayStatus` = `NOT_STARTED` bila `bodResult = REJECTED_ROLLBACK` **atau** `rollbackPaused = true`; `REJECTED_BY_BOD` saat REVISE; `BOD_APPROVAL` saat masih ditunggu vote. `rollbackOptions` terkunci ke 1 target setelah BOD pertama vote ROLLBACK.
305 + ## 12. Import & catalogs
306 +
307 + | Method+Path | Body / notes |
308 + |---|---|
309 + | `GET /import/template/download` | — XLSX template (binary) |
310 + | `POST /import` | multipart `file` (xlsx/xls/csv ≤5 MB). Header: `Activity Name \| Task Name \| Subtask Name \| Start Date \| End Date \| Work Weight \| Work Volume \| Work Volume Unit \| Activity Type` (`-` = empty). Activity Type required; workVolume requires workVolumeUnit → `{ imported, activities: [{id,name}] }` |
311 + | `GET /work-volume-units` | — |
312 + | `POST /work-volume-units` | `{ "name" }` ✳️ (≤100), `"symbol"` ✅ (1–50) |
313 + | `GET /activity-types` | — |
314 + | `POST /activity-types` | `{ "name" }` ✅ (1–100) |
262 315
263 316 ---
264 317
265 - ## 10. Batas & kasus yang masih menunggu desain
318 + ## Payload changes from the BOD feature
319 +
320 + | Context | Added |
321 + |---|---|
322 + | Item detail/list/tree | `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused` |
323 + | Item detail only | `bodApprovals[]`, `rollbackOptions[]` |
324 + | `…/approval/respond` | `verb` extended with `ESCALATE`; new `bodUserIds[]` |
325 + | New routes | `…/approval/bod` ×3 levels, `…/approval/bod/re-request` ×3 levels |
266 326
267 - 1. **ROLLBACK_CONFLICT** (target berbeda dari beberapa BOD): implementasi defensif — round tetap `ACTIVE`, tidak ada write. Bukan jalur normal karena one-target lock.
268 - 2. Konsumen yang **belum `CLOSED`** pada saat rollback: di-hold marker, lanjut dari status terakhir ketika prereq close (`m0349` — "ke tahan aja gitu? Iya mas. Jadi lanjutin status terakhir").
269 - 3. `request*BodReApproval`, subtask `PUT …/plans`, subtask member add/remove belum dirangkul guard hold (lihat §6b).
327 + ## Notification copy (BOD)
270 328
271 - ---
329 + - BOD requested → each assigned BOD: subject *Action Plan BOD Requested*.
330 + - REVISE → VP: **Action Plan Rejected by BOD** — `{ScopeLabel} {scopeName} in {projectName} was rejected by {bodName}. You must make decision` (+ ` Note: {reason}`).
331 + - ROLLBACK → VP: **Action Plan Rolled Back by BOD** — `…was rolled back by {bodName}. Its prerequisite {prereqName} was re-opened for re-approval.` (+ note).
332 + - All-APPROVE → VP + admins + members: **Action Plan Approved by BOD** — `…was approved by BOD. Handover complete.` (+ note).
333 + - Downstream consumer released (prerequisite re-closed) → VP: **Action Plan Ready for Approval** — `{ScopeLabel} {scopeName} in {projectName} is now ready for your approval. Its prerequisite {prereqName} was rolled back and has been re-approved and closed.` (+ ` Note: {reason}`). Sent per item as the prerequisite chain re-closes (A → then P/Q → then I…).
334 +
335 + ## Downstream rollback consumers
272 336
273 - *Dokumen ini menggambarkan perilaku implementasi terkini (rework B-side no-flip + routing notifikasi — pasca commit `489ce94`, belum di-commit). Endpoint & payload tetap bahasa Inggris; ulasan naratif bahasa Indonesia.*
337 + When a rollback resolves (B → target X), **all same-level items whose direct prerequisite is X** (and their full subtrees) are affected:
338 +
339 + | State | Effect |
340 + |---|---|
341 + | Item **not yet closed** | `rollbackPaused = true` marker only — data/progress/approvalStatus **unchanged**; display forced `NOT_STARTED`; **on hold** while X re-opens. All writes blocked 400: approval respond/re-request/BOD → `400 <Scope> is on hold: waiting for its prerequisite to be approved and closed`; also update, progress submit/respond, plans, members. |
342 + | Item **CLOSED** | node → `WAITING_APPROVAL` (no progress flip, `approvalReason` nulled); its children display `NOT_STARTED` (marker) until the chain resumes. |
343 + | Release | when X is re-approved & `CLOSED`, each held consumer releases in chain order (A after X closes; A's children after A closes — no progress flip, just `WAITING_APPROVAL`) + *Action Plan Ready for Approval* per item. |
344 +
345 + ## Activity log (`namespace: action_plan`)
346 +
347 + Every BOD/approval interaction writes an activity-log row (`GET /api/projects/:projectId/activity`, `recordId` = the affected item id):
348 +
349 + | Category | When |
350 + |---|---|
351 + | `ACTION_PLAN_APPROVAL_APPROVED` / `_REJECTED` | VP approve / reject |
352 + | `ACTION_PLAN_BOD_ESCALATED` | VP escalates to BOD (`approverCount`, `roundId`) |
353 + | `ACTION_PLAN_BOD_DECISION` | **each** BOD vote — `{decision, actor, reason, roundId}`, incl. votes that leave the round pending/conflicted |
354 + | `ACTION_PLAN_BOD_APPROVED` / `_REVISED` / `_ROLLBACK` | terminal round resolution (revise copy: *must be re-approved by VP*; rollback: `prerequisiteName`) |
355 + | `ACTION_PLAN_ROLLBACK_REOPENED` | **one row per node** restarted by rollback (target X subtree only): *re-opened for re-approval due to rollback by BOD* |
356 + | `ACTION_PLAN_ROLLBACK_PAUSED` | **one row per held node** (B subtree at resolve + downstream consumers): *on hold — waiting for prerequisite … (rollback effect)* |
357 + | `ACTION_PLAN_ROLLBACK_RELEASED` | **one row per node** released after the prerequisite re-closes (incl. B-subtree chain-start leaf flips): *ready for re-approval — prerequisite … re-approved and closed (rollback effect)* |
358 +
359 + The per-node rows make the multi-item state changes explainable: why each item restarted, paused, or resumed is visible in its own history.

135dika revised this gist 4 days ago. Go to revision

1 file changed, 186 insertions, 272 deletions

action-plan.docs.md

@@ -1,359 +1,273 @@
1 - # API — Action Plans
1 + # Flow BOD Reject — Rollback (Action Plans)
2 2
3 - Action plan module: **Activity → Task → Subtask** hierarchy with progress entries, approvers, prerequisites, members, and a BOD (Board of Directors) escalation flow.
3 + Dokumen ini menjelaskan **secara rinci** apa yang terjadi pada seluruh hierarki Action Plan ketika hasil vote BOD adalah **ROLLBACK** (reject + rollback ke prerequisite). Bahasa Indonesia; contoh payload request/response tetap dalam bahasa Inggris sesuai konvensi API.
4 4
5 - ## General
5 + Referensi implementasi: `src/features/version/1/action-plans/` — helper utama di `action-plans.helpers.ts` (`restartRollbackSubtree` L173, `pauseSubtreeForRollback` L500, `reprocessReleasedChildren` L992, `markRollbackDownstreamConsumers` L371, `releaseRollbackPausedDependents` L259, `assertNotRollbackPaused` L465, `aggregateBodRound` L2200, `resolveApprovalNotifyTargets` L2659, `resolveActionPlanOwnerAdminUserIds` L2590).
6 6
7 - - **Base**: `/api/v1/projects/:projectId/action-plans` — all paths below are relative to this base.
8 - - **Auth**: JWT, `@Authorize({ membership: true })` — caller must be a project member.
9 - - **Envelope**: responses wrapped `{ status, message, data }`; binary endpoints (PDF/XLSX) bypass the envelope.
10 - - **Access tiers** (`ProjectActionPlanAccessGuard`):
11 - - `INCOMPLETE_DATA` — not accessible.
12 - - `NOT_STARTED` / `IN_PROGRESS` — full access.
13 - - `NEED_APPROVAL` / `COMPLETED` / `REJECTED` — read-only for management writes; approval & BOD routes **stay writable**.
14 - - **VP/BOD read visibility** — a member assigned as VP (any item `approverId`) or as BOD (`ACTIVE` round approver) gets **full read** of tree/detail regardless of project tier or `ActionPlanMembers` membership. `MEMBER`-only users without any VP/BOD assignment see only their `ActionPlanMembers` scopes (others pruned).
15 - - **Progress input** (submit/respond/feedback) requires project `IN_PROGRESS` (`ProjectInProgressGuard`).
7 + ---
16 8
17 - ## Status enums
9 + ## 1. Ikhtisar
18 10
19 - ### Derived item `status`
11 + Rollback dipicu saat **semua BOD yang ditugaskan sudah memberikan vote** dan agregasi menghasilkan **ROLLBACK** (lihat §2). Kronologi:
20 12
21 - `INCOMPLETE_DATA` · `NEED_APPROVAL` · `IN_PROGRESS` · `NOT_STARTED` (also display override for paused/rolled back) · `COMPLETED`
13 + 1. Item yang di-escalate (sebut **B**) di-rollback ke salah satu **prerequisite langsung** yang dipilih BOD (sebut **X**).
14 + 2. **Subtree penuh X** (target) di-restart: progress terakhir leaf dijadikan `PENDING` dan status approval dikembalikan ke `WAITING_APPROVAL` — sesuai posisi masing-masing node (leaf / non-leaf / terhalang prerequisite).
15 + 3. **Subtree penuh B** (item yang di-rollback) **TIDAK di-restart saat itu juga**: semua node live di-set `WAITING_APPROVAL` + `rollbackPaused = true` + `bodResult = REJECTED_ROLLBACK`, **data progress TIDAK diubah** (tetap 100% `APPROVED`), tampil `NOT_STARTED`. B baru "diproses ulang" ketika X di-approve & `CLOSED` lagi (lihat §6c) — node leaf rantai awal di-flip ke `PENDING` saat itu.
16 + 4. **Konsumen downstream** (item same-level lain yang prerequisite-nya = X) ikut di-hold — lihat §6.
17 + 5. Semua perubahan ditulis **dalam satu transaksi database**; setiap item terdampak mendapat baris **activity log** dan (bila aktif) **notifikasi**.
18 + 6. Riwayat selesai ketika rantai prerequisite di-approve ulang berurutan sampai B di-approve/di-close lagi oleh VP & BOD.
22 19
23 - ### `approvalStatus`
20 + > Perbedaan kunci dengan **REJECT oleh VP**: VP REJECT hanya membuka **anak langsung** yang tidak memblokir (*non-blocker chain*), root yang direject tetap `REJECTED`. **BOD ROLLBACK me-restart subtree target X secara penuh**; **item B ditahan (hold) tanpa flip** sampai X selesai, baru subtree B dibuka ulang bertahap.
24 21
25 - `WAITING_APPROVAL` · `CLOSED` · `REJECTED` · `BOD_APPROVAL` (escalated)
22 + ---
26 23
27 - > Item without `approverId` (approval optional) never enters this enum — `approvalStatus` stays `null` (§ Approval optional).
24 + ## 2. Agregasi vote & prerequisitenya
28 25
29 - ### `bodResult`
26 + BOD vote lewat `POST …/approval/bod`:
30 27
31 - `REJECTED_REVISE` · `REJECTED_ROLLBACK`
28 + ```json
29 + // Request body (English, sesuai API)
30 + {
31 + "decision": "ROLLBACK", // "APPROVE" | "REVISE" | "ROLLBACK"
32 + "reason": "pekerjaan tidak sesuai spesifikasi", // wajib, min 1 karakter
33 + "rollbackId": "uuid-x" // wajib saat ROLLBACK; harus prereq LANGSUNG item & di-mark
34 + }
35 + ```
32 36
33 - ### `bodDisplayStatus`
37 + Aturan:
34 38
35 - | Value | Rule |
36 - |---|---|
37 - | `BOD_APPROVAL` | while escalated |
38 - | `REJECTED_BY_BOD` | `bodResult === REJECTED_REVISE` |
39 - | `NOT_STARTED` | `bodResult === REJECTED_ROLLBACK` **or** `rollbackPaused === true` |
40 - | `null` | no BOD state |
39 + - **Precedence hasil akhir** (frozen): `ROLLBACK > REVISE > APPROVE`.
40 + - **One-target lock**: BOD pertama yang vote `ROLLBACK` ke target T "mengunci" target. BOD berikutnya hanya boleh ROLLBACK ke T; target lain → `400 BOD rollback target is locked: all ROLLBACK votes must name the same prerequisite`.
41 + - **ROLLBACK_CONFLICT** (defensif, bila target berbeda tetap lolos): round tetap `ACTIVE`, item tetap `BOD_APPROVAL`, **tidak ada satu pun write** — menunggu desain. Hanya activity log `ACTION_PLAN_BOD_DECISION` yang muncul.
42 + - Semua vote `REVISE` → item `WAITING_APPROVAL` + `bodResult = REJECTED_REVISE`, data/progress **tidak disentuh**; VP harus meng-approve ulang (untuk merevisi, pendekatan re-approve VP).
43 + - Semua `APPROVE` (tanpa ROLLBACK/REVISE) → item `CLOSED` (handover) + map rapikan.
41 44
42 - ### Progress row `status`
45 + Hasil agregasi ROLLBACK single-target:
43 46
44 - `PENDING` · `APPROVED` · `APPROVED_WITH_NOTES` · `REJECTED`
47 + | Nilai item B | Nilai round |
48 + |---|---|
49 + | `approvalStatus = WAITING_APPROVAL` | `status = CLOSED` |
50 + | `bodResult = REJECTED_ROLLBACK` | `result = REJECTED_ROLLBACK` |
51 + | `bodReason = reason` (vote ROLLBACK pertama) | `reason` dari voter pertama |
45 52
46 - ## BOD escalation flow
53 + ---
47 54
48 - 1. **VP** responds via `…/approval/respond` with `verb`: `APPROVE` | `REJECT` | `ESCALATE` (this is one **2-step form**: step 1 picks the verb, step 2 picks BOD members via `bodUserIds` when `ESCALATE`). `ESCALATE` → `BOD_APPROVAL` + new BOD round; each BOD (project program agenda users) notified.
49 - 2. **BOD votes** via `…/approval/bod`: `APPROVE` | `REVISE` | `ROLLBACK` (reason required; `rollbackId` = direct prerequisite when ROLLBACK). **One-target lock**: after the first ROLLBACK vote to target T, all later BODs may only ROLLBACK to T (else 400).
50 - 3. **Resolution** once all assigned BODs voted — precedence **`ROLLBACK > REVISE > APPROVE`**:
51 - - ROLLBACK (same single target T) → item `WAITING_APPROVAL` + `bodResult = REJECTED_ROLLBACK`; T's & item's subtrees restart (leaf last `APPROVED` → `PENDING`, nodes → `WAITING_APPROVAL`, unmet-prereq nodes → `rollbackPaused` until prerequisite closes); VP notified *Action Plan Rolled Back by BOD*.
52 - - else REVISE → item `WAITING_APPROVAL` + `bodResult = REJECTED_REVISE`; data/progress untouched; VP notified *Action Plan Rejected by BOD*.
53 - - else all APPROVE → `CLOSED` (handover); VP + admins + members notified *Action Plan Approved by BOD*.
54 - - Different ROLLBACK targets → no resolution, round stays `ACTIVE` (defensive; normally prevented by the lock).
55 - 4. **Re-escalation**: `…/approval/bod/re-request` + later `ESCALATE` carries previous BOD list over — **add-only**, cannot remove/replace.
56 - 5. **VP REJECT**: walks descendants level by level; startable leaves reopen (`NEED_APPROVAL`, last `APPROVED`→`PENDING`), leaves blocked by unmet live prerequisite pause (`rollbackPaused`) and auto-resume when the prerequisite closes (`releaseRollbackPausedDependents`).
57 - 6. **Delete guard**: progress ≥ 100% (derived `COMPLETED`) or `approvalStatus = CLOSED` → delete blocked 400 (also blocks cascade delete of a complete/closed descendant).
55 + ## 3. Apa yang terjadi pada **B** (item yang di-rollback / direject)
58 56
59 - ## Approval optional (derived from `approverId`)
57 + Subtree B **tidak di-flip saat resolve**. Semua node live dalam subtree B (root + descendants) di-set lewat `pauseSubtreeForRollback(tx, scope, B)`:
60 58
61 - VP approval is **optional per item**, derived from `approverId` itself (no separate flag):
59 + - `approvalStatus = WAITING_APPROVAL`
60 + - `rollbackPaused = true` → tampil `NOT_STARTED` (override display)
61 + - `bodResult = REJECTED_ROLLBACK`
62 + - `approvalReason = null`
63 + - **Data progress TIDAK diubah** — baris progress tetap 100% `APPROVED` (nilai/approvedBy/approvedAt utuh)
64 + - **Tidak ada notifikasi** pada tahap ini (NOT_STARTED + WAITING ⇒ tanpa notif; VP "tidak bisa ngapa-ngapain" sebelum X selesai)
62 65
63 - - Item **WITH** `approverId` → approval **mandatory**: the full VP/BOD cycle applies — auto `WAITING_APPROVAL` on completion, VP respond, BOD escalation, Closed chip.
64 - - Item **WITHOUT** `approverId` (`null`) → approval **optional**: effective progress ≥ 100 completes the item with `approvalStatus` staying `null` — no `WAITING_APPROVAL`, no *approval-requested* notification, no Closed chip, no BOD possible.
66 + Node B baru "diproses ulang" ketika X di-approve & `CLOSED` lagi (release, §6c): saat itu seluruh subtree B di-restart — leaf rantai-awal di-flip `APPROVED`→`PENDING` (Need Approval), node lain jadi `WAITING_APPROVAL`, chain yang masih menunggu prereq tetap pause.
65 67
66 - **Assign-VP rule** (PATCH `approverId`) once the item's effective progress has reached ≥ 100 %:
68 + Kronologi B dalam dua tahap (leaf):
67 69
68 - | Change | Result |
70 + | Tahap | Status B |
69 71 |---|---|
70 - | assign (`null` → person) | **400** |
71 - | remove (person → `null`) | **400** |
72 - | replace (person → person) | **allowed — pure swap**: only `approverId` changes; `approvalStatus` / `approvalReason` / `approvedAt` / `bodResult` / `bodReason` / `bodRoundId` / `rollbackPaused` all untouched, no reopen, no late-assignment notification |
72 + | T1 resolve rollback | `NOT_STARTED` + `WAITING_APPROVAL`, progress tetap 100% `APPROVED` |
73 + | T2 setelah X `CLOSED` | leaf rantai-awal → progress terakhir di-flip `PENDING` (Need Approval) + notif ke Admin/Owner; leaf akar B → `WAITING_APPROVAL` tanpa flip (Completed & Waiting) + notif ke VP — VP approve → CLOSED; VP reject → flip progress akhir (`PENDING`) |
73 74
74 - Below 100 % the pre-existing assignment semantics are unchanged (assignment never mutates `approvalStatus` on an incomplete item).
75 + > Catatan: rollback BOD hanya men-flip baris progress `APPROVED` (strict). Baris `APPROVED_WITH_NOTES` tidak di-flip — berbeda dengan VP REJECT yang juga membuka `APPROVED_WITH_NOTES`.
75 76
76 - **Prerequisite rule**:
77 + ---
77 78
78 - - VP-less prerequisite (`approverId = null`) → satisfied by effective progress ≥ 100 **alone** (no `CLOSED` needed).
79 - - VP-held prerequisite → still requires `CLOSED` **and** effective progress ≥ 100.
79 + ## 4. Apa yang terjadi pada **X** (prereq yang dipilih sebagai opsi rollback)
80 80
81 - **Parent completeness** — a child counts toward the parent's `WAITING_APPROVAL` trigger ("done") when `child.approvalStatus = CLOSED` **or** (child has no VP **and** child's approved rollup ≥ 100).
81 + Target X juga di-restart penuh, dengan fungsi yang sama: `restartRollbackSubtree(tx, scope, X)`.
82 82
83 - **All approval endpoints on a VP-less item return 400**: `…/approval/respond` (incl. `ESCALATE`), `…/approval/re-request`, `…/approval/bod`, `…/approval/bod/re-request`.
83 + - X non-leaf → `WAITING_APPROVAL`, progress miliknya tidak diubah; subtree X di-traversal sama seperti §3b.
84 + - X leaf & semua prereq X terpenuhi → progress terakhir → `PENDING`, X → `WAITING_APPROVAL`.
85 + - X leaf & prereq X belum terpenuhi → `rollbackPaused = true` saja.
84 86
85 - **Existing-data migration** (applied mid-feature): prior rows with non-null `approvalStatus` were set to `CLOSED`, and ACTIVE BOD rounds were closed.
87 + Jadi **X bukan "di-undo ke nol"** — X dibuka ulang untuk approval ulang dengan data progress tetap utuh. Begitu X di-approve & `CLOSED` lagi oleh VP (dan BOD bila wajib), release cascade berjalan (§6).
86 88
87 89 ---
88 90
89 - # Endpoint catalog
90 -
91 - **Path legend**: `A` = `:activityId`, `T` = `:taskId`, `S` = `:subtaskId`. Full paths:
91 + ## 5. Posisi X dalam rantai prerequisite (awal / tengah / akhir)
92 92
93 - - Activity level: `BASE/A/…`
94 - - Task level: `BASE/A/tasks/T/…`
95 - - Subtask level: `BASE/A/tasks/T/subtasks/S/…`
93 + Rantai contoh: `… → W → X → A → P → Q → I` (masing-masing `→` = prerequisite langsung; item di kiri adalah prereq item di kanan).
96 94
97 - ## 1. Activities
95 + ### 5a. X berada di **paling akhir rantai** (X tidak punya prereq lagi; X adalah "akar" dependensi)
98 96
99 - ### `POST /` (base)
100 - **Body** (`CreateActivityDto`):
101 - | Field | Req | Notes |
102 - |---|---|---|
103 - | `name` | ✅ | 1–255 chars |
104 - | `activityType` | ✅ | 1–100 chars |
105 - | `workWeight` | ✳️ | number ≥0 |
106 - | `workVolume` | ✳️ | number ≥0 — *requires `workVolumeUnit`* |
107 - | `workVolumeUnit` | ✳️ | ≤100 chars |
108 - | `startDate` | ✳️ | ISO date, floored to day |
109 - | `endDate` | ✳️ | ISO date |
110 - | `tasks[]` | ✳️ | ≤50, each: `name` ✅ (1–255), `workWeight/workVolume/workVolumeUnit/startDate/endDate` ✳️, nested `subtasks[]` ≤50 (same shape, `name` ✅) |
111 -
112 - ### `POST /bulk`
113 - Body: `CreateActivityDto[]` — 1–100 items. → `{ created, activities: [{id,name}] }`
114 -
115 - ### `GET /`
116 - Query:
117 - | Param | Req | Options / default |
118 - |---|---|---|
119 - | `page` | ✳️ | default `1` |
120 - | `limit` | ✳️ | default `20`, max `100` |
121 - | `search` | ✳️ | string |
122 - | `sort_by` | ✳️ | `created_at` (default) · `updated_at` · `name` |
123 - | `sort_order` | ✳️ | `asc` · `desc` (default) |
124 -
125 - ### `GET /tree`
126 - Query:
127 - | Param | Req | Options / default |
128 - |---|---|---|
129 - | `status` | ✳️ | derived status string filter |
130 - | `startDate` / `endDate` | ✳️ | bound effective date overlap range (one-sided allowed) |
131 - | `search` | ✳️ | string |
132 - | `sort_by` | ✳️ | `created_at` (default) · `start_date` · `end_date` · `start_date_end_date` |
133 - | `sort_order` | ✳️ | `asc` · `desc` (default `asc`) |
97 + - Restart X + subtree X sesuai §4; subtree B **ditahan** (pause marker) sesuai §3.
98 + - X bisa langsung dibuka (`WAITING_APPROVAL`/flip) karena tidak menunggu prereq apapun.
99 + - Barisan konsumen A→P→Q→I **dan** subtree B baru ikut berjalan ketika X `CLOSED` lagi (release cascade, §6). Ini skenario paling "besar": seluruh rantai di belakang X menunggu, lalu terbuka berurutan.
134 100
135 - ### `GET /tree/gantt`
136 - Same query as `/tree`; nodes carry `ganttStatus`.
101 + ### 5b. X berada di **tengah rantai** (X punya prereq sendiri W, dan punya konsumen A di belakangnya)
137 102
138 - ### `GET /:activityId`
139 - Detail — item payload (§ Item payload).
103 + - X dibuka ulang **tergantung W**: approb X hanya bisa dibuka penuh jika W `CLOSED`/terpenuhi; jika tidak, X jadi `rollbackPaused = true` (hold) sampai W closes.
104 + - Konsumen A (prereq langsung X) ikut di-hold (marker), P/Q/I menyusul sesuai release cascade; subtree B juga di-hold menunggu X.
105 + - Efek berantai: status ulang dimulai dari W (atau lebih dalam), lalu X, lalu A→P→Q→I (dan B).
140 106
141 - ### `PATCH /:activityId`
142 - Body (`UpdateActivityDto`) — all ✳️: `name`, `activityType`, `approverId`, `workWeight`, `workVolume`, `workVolumeUnit` (*requires workVolume pair rule*), `startDate`, `endDate`, `prerequisiteIds[]`.
107 + ### 5c. X berada di **paling awal rantai** (X punya konsumen, tapi X sendiri adalah prereq paling ujung milik item lain, misal X adalah konsumennya W)
143 108
144 - > `approverId`: assign / remove blocked 400 once complete (≥ 100 %); replace = pure swap (§ Approval optional).
109 + Anggap rantai: `W → X → A` dengan X punya prereq W.
145 110
146 - ### `DELETE /:activityId`
147 - No body. **400** if item or live descendant complete (`≥100%`) or `CLOSED`.
111 + - Sama seperti §5b: pembukaan X menunggu W. Konsumen A & rantainya di-hold.
112 + - Karena X bukan akar dependensi, **efeknya tidak bisa "instan"** — semuanya menunggu W di-approve & close dulu, baru X, baru A, dst.
148 113
149 - ### `DELETE /bulk`
150 - Body (`BulkActionPlanDeleteDto`):
151 - | Field | Req | Notes |
152 - |---|---|---|
153 - | `activityIds[]` | ✳️ | ≤100, default `[]` |
154 - | `taskIds[]` | ✳️ | ≤100, default `[]` |
155 - | `subtaskIds[]` | ✳️ | ≤100, default `[]` |
114 + ### Prinsip umum
156 115
157 - At least one id array non-empty. Parents cascade to descendants. → `{ deleted }`. Same complete/CLOSED guard.
116 + Rollback **hanya secara langsung** me-restart subtree **X** (target); **subtree B ditahan** (`rollbackPaused`, tanpa flip) sampai X `CLOSED` lagi, lalu di-restart bertahap (leaf rantai-awal di-flip). Item lain di belakang X (A, P, Q, I) **tidak di-restart** saat itu juga — mereka hanya di-*hold* (marker `rollbackPaused`, display `NOT_STARTED`) dan **release bertahap** satu-per-satu setiap kali prereqnya ditutup lagi (lihat §6). Data/progress mereka tidak pernah diubah sampai release.
158 117
159 - ## 2. Approval (3 levels; same routes)
118 + ---
160 119
161 - Patterns (per level): `…/approval/respond`, `…/approval/re-request`, `…/approval/bod`, `…/approval/bod/re-request`.
120 + ## 6. Konsumen downstream & release bertahap
162 121
163 - ### `POST …/approval/respond` — VP (designated approver)
164 - Body (`ApprovalRespondDto`):
165 - | Field | Req | Notes |
166 - |---|---|---|
167 - | `verb` | ✅ | `APPROVE` · `REJECT` · `ESCALATE` |
168 - | `reason` | ✳️ | ≤500 chars |
169 - | `bodUserIds[]` | ✳️ | 1–50, each a valid agenda user — **required when `verb=ESCALATE`** |
122 + ### 6a. Marker saat rollback resolve (`markRollbackDownstreamConsumers`)
170 123
171 - ### `POST …/approval/re-request` — creator re-requests (only from `REJECTED`/`WAITING_APPROVAL`)
172 - No body.
124 + Untuk setiap item same-level **yang prereq LANGSUNG-nya = X** (selain B):
173 125
174 - ### `POST …/approval/bod` — BOD member votes
175 - Body (`BodRespondDto`):
176 - | Field | Req | Notes |
177 - |---|---|---|
178 - | `decision` | ✅ | `APPROVE` · `REVISE` · `ROLLBACK` |
179 - | `reason` | ✅ | 1–500 chars |
180 - | `rollbackId` | ✳️ | — **required when `decision=ROLLBACK`**; must be a direct prerequisite |
126 + - Seluruh subtree live konsumen itu di-set `rollbackPaused = true` (**marker only**): **tidak ada** perubahan `approvalStatus` (konsumen yang sudah `CLOSED` tetap `CLOSED`), **tidak ada** flip progress, **tidak ada** notice, **tidak ada** recompute.
127 + - Eksklusi (defensif): item B, item X, dan subtree live keduanya.
128 + - Display: item & subtree itu tampil `NOT_STARTED` (override `rollbackPaused` → `NOT_STARTED`), walau data sebenarnya utuh.
129 + - Setiap node yang di-mark masuk activity log `ACTION_PLAN_ROLLBACK_PAUSED`.
181 130
182 - Guards: item `BOD_APPROVAL`, caller is assigned BOD with no vote yet, round not resolved.
131 + ### 6b. Hold (read-only)
183 132
184 - ### `POST …/approval/bod/re-request` — VP resumes BOD cycle after REVISE
185 - No body.
133 + Item yang sedang `rollbackPaused` (di dirinya atau ancestor) **tidak bisa diubah** oleh endpoint write:
186 134
187 - ### `GET /approval-obligations` — pre-member-removal check (project level)
188 - Query:
189 - | Param | Req | Notes |
190 - |---|---|---|
191 - | `userId` | ✅ | uuid — the member whose action-plan responsibilities are checked |
135 + ```
136 + 400 <Activity|Task|Subtask> is on hold: waiting for its prerequisite to be approved and closed
137 + ```
192 138
193 - Purpose: list the user's outstanding approval responsibilities in the project before member removal (removal itself lives outside this module). → `{ "hasObligation": bool, "obligations": […] }`:
139 + Diblokir: update item, approval respond `/approval/respond`, `/approval/re-request`, `/approval/bod`, submit progress (`…/progress`), respond progress, `PUT …/plans`, member add/remove (per level sesuai guard; subtask progress melalui controller `assertSubtaskNotRollbackPaused`).
194 140
195 - - **VP obligations** — every item where the user is the VP, **all states including `CLOSED`**:
196 - `{ "type": "VP", "scope": "ACTIVITY" | "TASK" | "SUBTASK", "itemId", "itemName" }`
197 - - **BOD obligations** — every `ACTIVE` BOD round the user is an assigned approver, **including rounds they already voted on**:
198 - `{ "type": "BOD", "scope": "ACTIVITY" | "TASK" | "SUBTASK", "itemId", "itemName", "roundId", "responded" }` — `responded` = whether the user cast a decision.
141 + > Catatan implementasi: `request*BodReApproval` (langsung), `subtask …/plans PUT`, dan subtask member add/remove belum ter-guard langsung (inner plain re-request tetap ter-guard).
199 142
200 - Empty result → `hasObligation: false`, empty `obligations`.
143 + ### 6c. Release (`releaseRollbackPausedDependents`) — dipicu saat item `CLOSED`
201 144
202 - ## 3. Item payload (detail/list/tree)
145 + Dipanggil **di dalam transaksi** tepat setelah item di-approve → `CLOSED`, pada 6 titik: VP `respond…Approval` (APPROVE) dan BOD all-approve (APPROVED).
203 146
204 - BOD-related fields on every item: `approvalStatus`, `approvalReason`, `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused`, plus `approverId/approverName/approverEmail`, `prerequisites[]`, `prerequisiteIds[]`.
147 + - Cari semua dependents **yang prereq langsung-nya = item yang baru close** DAN sedang `rollbackPaused` DAN semua prereq-nya terpenuhi.
148 + - Untuk **konsumen downstream** (tanpa `bodResult = REJECTED_ROLLBACK`): release = `approvalStatus = WAITING_APPROVAL` + `rollbackPaused = false` + `approvalReason = null`. **Tanpa flip progress** — VP tinggal meng-approve ulang pekerjaan yang sudah ada.
149 + - Untuk **subtree B** (dependent dengan `bodResult = REJECTED_ROLLBACK` — artinya item ini adalah bagian dari item yang di-rollback): release dilakukan **bersamaan dengan restart penuh subtree B**:
150 + - Node akar B leaf → `WAITING_APPROVAL` + unpause tanpa flip (Completed & Waiting; notif ke **VP**; flip progress baru terjadi bila VP REJECT).
151 + - Node non-leaf → `WAITING_APPROVAL` + unpause tanpa flip (notif ke **VP**), lalu anaknya di-reprocess:
152 + - **Leaf rantai-awal** (tidak punya prereq live / prereq rantai paling awal) → flip `APPROVED`→`PENDING` + `WAITING_APPROVAL` + unpause (Need Approval; notif ke **Owner/Admin**).
153 + - **Leaf yang masih punya prereq belum terpenuhi** → tetap pause (`rollbackPaused = true`), tanpa flip, tanpa notif — terbuka nanti saat prereqnya selesai.
154 + - **Non-leaf** → `WAITING_APPROVAL` + unpause, lalu turun ke anaknya (notif ke VP).
155 + - **Blocker child** (dipakai sebagai prereq oleh sibling live) → tetap pause tanpa flip/notif.
156 + - Mirip restart X non-leaf, tetapi **dimulai belakangan**: baru berlangsung setelah X `CLOSED`, bukan saat resolve.
157 + - **Cascade bertahap**: anak-anak konsumen tidak di-release dalam gelombang yang sama — mereka release ketika **konsumen itu sendiri** (atau prerequisite terkait) di-approve & close lagi. Dengan begitu: X close → **B**/A dibuka; node lanjutan terbuka berurutan ketika prereqnya ditutup. Ini perilaku "Waiting Approval dari A lalu ke P dst" yang disepakati tim desain.
158 + - Setiap node yang release masuk activity log `ACTION_PLAN_ROLLBACK_RELEASED`; node B yang di-flip masuk notifikasi *Ready for Approval* / *progress reopened* (routing lihat §8).
205 159
206 - Detail-only (`GET /A`, `GET A/tasks/T`, `GET A/tasks/T/subtasks/S`):
160 + Urutan release pada rantai `X → A → P → Q → I` (dan B di sisi lain):
207 161
208 - ```json
209 - "bodApprovals": [{ "userId", "name", "decision", "reason", "rollbackItemId", "rollbackTargetName", "respondedAt" }],
210 - "rollbackOptions": [{ "id", "name" }] // direct prereqs; locked to single target after first ROLLBACK vote
162 + ```
163 + [X closed] → A → WAITING_APPROVAL + subtree B mulai di-restart (leaf rantai-awal → PENDING)
164 + [A approved]→ A → CLOSED → P → WAITING_APPROVAL
165 + [P approved]→ P → CLOSED → Q → WAITING_APPROVAL
166 + [Q approved]→ Q → CLOSED → I → WAITING_APPROVAL
167 + [I approved]→ I → CLOSED (rantai pulih penuh)
211 168 ```
212 169
213 - ## 4. Tasks — `BASE/A/tasks`
170 + ---
214 171
215 - | Method+Path | Body / notes |
216 - |---|---|
217 - | `POST /` | `CreateTaskDto` — `name` ✅; `workWeight/workVolume/workVolumeUnit/startDate/endDate` ✳️ (volume pair rule) |
218 - | `GET /` | list under activity |
219 - | `GET /:taskId` | detail (§ Item payload) |
220 - | `PATCH /:taskId` | `UpdateTaskDto` — `name/approverId/workWeight/workVolume/workVolumeUnit/startDate/endDate/prerequisiteIds[]` all ✳️ — `approverId`: assign/remove blocked 400 once complete, replace = pure swap (§ Approval optional) |
221 - | `DELETE /:taskId` | complete/CLOSED guard 400 |
172 + ## 7. Leaf vs children — ringkas
222 173
223 - Plus per task: progress (`POST/GET /:taskId/progress`, `GET /:taskId/progress/:progressId`, `POST …/respond`), feedbacks, `GET/PUT /:taskId/plans`, members (`POST/DELETE/GET /:taskId/members`), edit-requests (`POST/GET /:taskId/edit-requests`), approval routes (§ 2).
174 + | Bentuk item | Saat rollback resolve | Saat X re-`CLOSED` |
175 + |---|---|---|
176 + | **B leaf** | `NOT_STARTED` + `WAITING_APPROVAL` + `rollbackPaused`, progress TIDAK di-flip (tetap 100%) | `WAITING_APPROVAL` + unpause tanpa flip (Completed & Waiting, notif VP); flip progress hanya bila VP REJECT |
177 + | **B non-leaf** | root + seluruh subtree live: `WAITING_APPROVAL` + pause (marker), tanpa flip | root & non-leaf: unpause (notif VP); leaf rantai-awal: flip `APPROVED`→`PENDING` (Need Approval, notif Owner/Admin); leaf ber-prereq belum terpenuhi: tetap pause |
178 + | **X leaf / non-leaf** | restart penuh: leaf di-flip `APPROVED`→`PENDING`, non-leaf `WAITING_APPROVAL`, blocker-subtree di-pause | — (release cascade konsumen) |
179 + | **Konsumen A (prereq langsung = X)** | seluruh subtree `rollbackPaused = true` (marker only; A yang `CLOSED` tetap `CLOSED`); display `NOT_STARTED` | `WAITING_APPROVAL` tanpa flip (notif VP); release bertahap |
180 + | **Item rantai lebih dalam (P, Q, I)** | tidak di-restart; ikut setelah release cascade ketika prereq masing-masing close | — |
224 181
225 - ## 5. Subtasks — `BASE/A/tasks/T/subtasks`
182 + Data yang **tidak pernah hilang**: seluruh baris progress (termasuk yang `APPROVED`/`APPROVED_WITH_NOTES`), file/lampiran, feedback, plans, dan history activity log. Yang berubah hanya **status** (`status` derived, `approvalStatus`) dan **pointer `rollbackPaused`/`bodResult`** — flip progress memakai mekanisme amends (`amendsId`) sehingga riwayat progress tetap terlihat.
226 183
227 - | Method+Path | Body / notes |
228 - |---|---|
229 - | `POST /` | `CreateSubtaskDto` — `name` ✅; `workVolume/workVolumeUnit/workWeight/startDate/endDate` ✳️ (volume pair rule) |
230 - | `GET /` | list under task |
231 - | `GET /:subtaskId` | detail |
232 - | `PATCH /:subtaskId` | `UpdateSubtaskDto` — all ✳️ incl `approverId`, `prerequisiteIds[]` — `approverId`: assign/remove blocked 400 once complete, replace = pure swap (§ Approval optional) |
233 - | `DELETE /:subtaskId` | complete/CLOSED guard 400 |
184 + ---
234 185
235 - Plus: members (`POST/DELETE/GET /:subtaskId/members`), plans (`GET/PUT /:subtaskId/plans`), edit-requests (`POST/GET /:subtaskId/edit-requests`), progress (`POST/GET /:subtaskId/progress`, `GET /:subtaskId/progress/:progressId`, `POST …/respond`), feedbacks (`POST/GET …/feedbacks`, `PATCH/DELETE …/feedbacks/:feedbackId`), approval routes (§ 2).
186 + ## 8. Notifikasi & activity log yang dihasilkan (sekali rollback resolve)
236 187
237 - ## 6. Members
188 + **Routing notifikasi (aturan baku, dipakai semua transisi approval):** tujuan penerima ditentukan dari status item setelah transisi (`resolveApprovalNotifyTargets`):
238 189
239 - | Method+Path | Body |
190 + | Status item setelah transisi | Penerima |
240 191 |---|---|
241 - | `POST /A/members` · `DELETE /A/members` (×task/subtask) | `{ "userIds": ["uuid…"] }` — 1–100, required |
242 - | `POST /members/bulk` | `BulkActionPlanMembersDto`: `activityIds[]` (≤100, default `[]`), `taskIds[]` (≤100, default `[]`), `subtaskIds[]` (≤100, default `[]`), `userIds[]` (1–100 ✅) — ≥1 id array non-empty; parent scopes cascade to leaves → `{ assigned, skipped }` |
243 - | `DELETE /members/bulk` | same body → `{ removed }` |
244 - | `GET /A/members` · `GET A/tasks/T/members` · `GET …/subtasks/S/members` | list w/ display names + leaf scopes |
192 + | Derived `NEED_APPROVAL` (ada progress `PENDING`, mis. leaf baru di-flip) | **Owner/Admin project** (`resolveActionPlanOwnerAdminUserIds`) |
193 + | Derived `COMPLETED` (≥100%) & `approvalStatus = WAITING_APPROVAL` | **VP** (`approverId`) |
194 + | Derived `COMPLETED` & `approvalStatus = BOD_APPROVAL` | **BOD** (approvers round `bodRoundId`) |
195 + | `rollbackPaused` / `NOT_STARTED` (walau `WAITING_APPROVAL`) | **tidak ada notifikasi** |
245 196
246 - ## 7. Progress (multipart) — 3 levels
197 + Satu resolusi ROLLBACK menghasilkan (via outbox in-transaksi; fallback legacy fire-and-forget bila writer tidak ada):
247 198
248 - ### `POST …/progress` — submit
249 - Multipart form fields (`CreateProgressDto`):
250 - | Field | Req | Notes |
199 + | Notifikasi | Penerima (menurut routing di atas) | Isi (ringkas) |
251 200 |---|---|---|
252 - | `progress` | ✳️ | coerced number 0–100; omit = carry current floor |
253 - | `workVolume` | ✳️ | number ≥0 |
254 - | `description` | ✳️ | ≤5000 chars |
255 - | `createdAt` | ✳️ | ISO date (backdated work) |
256 - | `documentSource` | ✳️ | `upload` · `document` |
257 - | `documentRef` | ✳️ | JSON string `{"documentId","versionId","revisionId"}` (when source=document) |
258 - | `companyId` | ✳️ | guards cross-company document access |
259 - | Files | | `attachments[]` (jpg/png/gif/webp/svg/pdf), `document` (×1) |
260 -
261 - ### `GET …/progress`
262 - Query: `page` (default 1) · `limit` (default 50, max 100).
201 + | `sendBodRolledBack` | Owner/Admin + members + VP | *Action Plan Rolled Back by BOD* — `…was rolled back by {bodName}. Its prerequisite {prereqName} was re-opened for re-approval.` |
202 + | `sendApprovalReopened` | per node non-paused (X-side: flipped→Owner/Admin, WAITING→VP) | approval dibuka ulang (`WAITING_APPROVAL`) |
203 + | `sendProgressReopened` | per leaf yang di-flip (X-side → Owner/Admin) | progress terakhir dibuka ulang (`PENDING`) |
204 + | `sendApprovalRequestedReleased` | per release (B-side: flipped/NEED_APPROVAL→Owner/Admin, WAITING→VP; konsumen→VP) | *Action Plan Ready for Approval* — `…Its prerequisite {prereqName} was rolled back and has been re-approved and closed.` |
263 205
264 - ### `GET …/progress/:progressId`
265 - Single row: `id, scopeType, scopeId, scopeName, progress, workVolume, scopeWorkVolume, scopeWorkVolumeUnit, description, status, amendsId, amendsOriginal, amendedByCount, amendedBy[], approvedBy, approvedByName, approvedAt, createdBy, createdByName, updatedBy, updatedByName, createdAt, updatedAt, attachments[], document`.
206 + > B-side **tidak menimbulkan notifikasi apa pun saat resolve** — seluruh subtree B pause (NOT_STARTED). Notifikasi untuk B baru muncul ketika X `CLOSED` lagi (tahap release, §6c).
266 207
267 - ### `POST …/progress/:progressId/respond`
268 - Body (`RespondProgressDto`): `verb` ✅ (`APPROVE`·`APPROVE_WITH_NOTES`·`REJECT`); `progress` ✳️ (0–100), `workVolume` ✳️, `description` ✳️ (≤5000) — overridable on APPROVE_WITH_NOTES.
208 + Activity log (namespace `action_plan`, `GET /api/projects/:projectId/activity`) — **satu baris per aksi, per item terdampak**:
269 209
270 - ## 8. Feedback — `…/progress/:progressId/feedbacks`
271 -
272 - | Method+Path | Body |
210 + | Kategori | Saat |
273 211 |---|---|
274 - | `POST …/feedbacks` | `{ "message": "string" }` — 1–5000 chars ✅ |
275 - | `GET …/feedbacks` | query: `page` (1) · `limit` (50, max 100) |
276 - | `PATCH …/feedbacks/:feedbackId` | `{ "message" }` same validation |
277 - | `DELETE …/feedbacks/:feedbackId` | — |
278 -
279 - ## 9. Plans / S-curve — `…/plans` (3 levels)
280 -
281 - `GET …/plans` → `{ duration: {days,startDate,endDate}, planning: [{id?,startDate,endDate,targetProgress}], sCurve: { plan:[{date,progress}], actual:[{date,progress,id}] } }`
282 - `PUT …/plans` body (`SetProgressPlansDto`): `plans[]` (≥0): `startDate` ✅ (date), `endDate` ✅ (date), `targetProgress` ✅ (0–100).
212 + | `ACTION_PLAN_BOD_DECISION` | setiap vote BOD (termasuk yang masih pending/conflict) |
213 + | `ACTION_PLAN_BOD_ROLLBACK` | resolusi rollback (item B; `prerequisiteName`) |
214 + | `ACTION_PLAN_ROLLBACK_REOPENED` | **per node** yang di-restart (subtree X, termasuk paused) |
215 + | `ACTION_PLAN_ROLLBACK_PAUSED` | **per node** yang di-hold (subtree B saat resolve + konsumen) |
216 + | `ACTION_PLAN_ROLLBACK_RELEASED` | **per node** yang release saat prereq closes (termasuk flip leaf B) |
283 217
284 - ## 10. Edit requests — member → owner/admin
218 + ---
285 219
286 - | Method+Path | Body |
287 - |---|---|
288 - | `POST …/edit-requests` (3 levels + none at project) | `CreateEditRequestDto`: `endDate` ✳️, `workWeight` ✳️, `workVolume` ✳️, `workVolumeUnit` ✳️, `activityType` ✳️ — one or more changed fields; `reason` ✅ (1–500 chars) |
289 - | `GET …/edit-requests` · `GET /edit-requests` | query: `page` (1) · `limit` (50, max 100) · `status` ✳️ (`PENDING`·`APPROVED`·`REJECTED`) |
290 - | `POST /edit-requests/:requestId/respond` | `{ "verb": "APPROVE" \| "REJECT" }` ✅ |
220 + ## 9. Contoh payload (English, sesuai API)
291 221
292 - List rows include `requestedData`, `oldData`, `reason`, `status`, requester/responder names, timestamps.
222 + ### BOD vote (request)
293 223
294 - ## 11. Analytics & reports
224 + ```json
225 + POST /api/v1/projects/:projectId/action-plans/:activityId/approval/bod
226 + {
227 + "decision": "ROLLBACK",
228 + "reason": "Volume pekerjaan tidak sesuai progres approved",
229 + "rollbackId": "activity-x-id"
230 + }
231 + ```
295 232
296 - | Method+Path | Query |
297 - |---|---|
298 - | `GET /analytics` | `layout` ✳️ (`detailed` default · `simplified`); `granularity` ✳️ (`daily` · `weekly` default · `monthly`); `search` ✳️ (≤200); `activityName` ✳️ (≤200, case-insensitive contains); `startDate`/`endDate` ✳️ |
299 - | `GET /analytics/activity-names` | — → `string[]` |
300 - | `GET /analytics/status-counts` | `status` ✳️ (default `IN_PROGRESS`): `INCOMPLETE_DATA`·`NEED_APPROVAL`·`IN_PROGRESS`·`NOT_STARTED`·`COMPLETED` |
301 - | `GET /analytics/report/download` | `granularity` ✳️ (`weekly` default · `monthly` · `yearly`); `search`, `activityName`, `startDate`, `endDate` ✳️ → PDF binary |
302 - | `GET /analytics/report/history` | `page` (1) · `limit` (20, max 100) · `startDate`/`endDate` ✳️ → records `{seq, docNumber, layout, granularity, windowStart, windowEnd, generatedAt, s3Key}` |
303 - | `GET /analytics/report/history/:recordId/download` | — → PDF binary |
233 + ### Item detail (response `data` — potongan field BOD)
304 234
305 - ## 12. Import & catalogs
235 + ```json
236 + {
237 + "id": "activity-b-id",
238 + "approvalStatus": "WAITING_APPROVAL",
239 + "approvalReason": null,
240 + "bodRoundId": "round-1",
241 + "bodResult": "REJECTED_ROLLBACK",
242 + "bodReason": "Volume pekerjaan tidak sesuai progres approved",
243 + "bodDisplayStatus": "NOT_STARTED",
244 + "rollbackPaused": true,
245 + "approverId": "user-vp-id",
246 + "bodApprovals": [
247 + {
248 + "userId": "bod-1",
249 + "name": "Hendra",
250 + "decision": "ROLLBACK",
251 + "reason": "Volume pekerjaan tidak sesuai progres approved",
252 + "rollbackItemId": "activity-x-id",
253 + "rollbackTargetName": "Perizinan",
254 + "respondedAt": "2026-09-10T03:00:00.000Z"
255 + }
256 + ],
257 + "rollbackOptions": [{ "id": "activity-x-id", "name": "Perizinan" }]
258 + }
259 + ```
306 260
307 - | Method+Path | Body / notes |
308 - |---|---|
309 - | `GET /import/template/download` | — XLSX template (binary) |
310 - | `POST /import` | multipart `file` (xlsx/xls/csv ≤5 MB). Header: `Activity Name \| Task Name \| Subtask Name \| Start Date \| End Date \| Work Weight \| Work Volume \| Work Volume Unit \| Activity Type` (`-` = empty). Activity Type required; workVolume requires workVolumeUnit → `{ imported, activities: [{id,name}] }` |
311 - | `GET /work-volume-units` | — |
312 - | `POST /work-volume-units` | `{ "name" }` ✳️ (≤100), `"symbol"` ✅ (1–50) |
313 - | `GET /activity-types` | — |
314 - | `POST /activity-types` | `{ "name" }` ✅ (1–100) |
261 + > Keterangan: `bodDisplayStatus` = `NOT_STARTED` bila `bodResult = REJECTED_ROLLBACK` **atau** `rollbackPaused = true`; `REJECTED_BY_BOD` saat REVISE; `BOD_APPROVAL` saat masih ditunggu vote. `rollbackOptions` terkunci ke 1 target setelah BOD pertama vote ROLLBACK.
315 262
316 263 ---
317 264
318 - ## Payload changes from the BOD feature
319 -
320 - | Context | Added |
321 - |---|---|
322 - | Item detail/list/tree | `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused` |
323 - | Item detail only | `bodApprovals[]`, `rollbackOptions[]` |
324 - | `…/approval/respond` | `verb` extended with `ESCALATE`; new `bodUserIds[]` |
325 - | New routes | `…/approval/bod` ×3 levels, `…/approval/bod/re-request` ×3 levels |
326 -
327 - ## Notification copy (BOD)
328 -
329 - - BOD requested → each assigned BOD: subject *Action Plan BOD Requested*.
330 - - REVISE → VP: **Action Plan Rejected by BOD** — `{ScopeLabel} {scopeName} in {projectName} was rejected by {bodName}. You must make decision` (+ ` Note: {reason}`).
331 - - ROLLBACK → VP: **Action Plan Rolled Back by BOD** — `…was rolled back by {bodName}. Its prerequisite {prereqName} was re-opened for re-approval.` (+ note).
332 - - All-APPROVE → VP + admins + members: **Action Plan Approved by BOD** — `…was approved by BOD. Handover complete.` (+ note).
333 - - Downstream consumer released (prerequisite re-closed) → VP: **Action Plan Ready for Approval** — `{ScopeLabel} {scopeName} in {projectName} is now ready for your approval. Its prerequisite {prereqName} was rolled back and has been re-approved and closed.` (+ ` Note: {reason}`). Sent per item as the prerequisite chain re-closes (A → then P/Q → then I…).
265 + ## 10. Batas & kasus yang masih menunggu desain
334 266
335 - ## Downstream rollback consumers
267 + 1. **ROLLBACK_CONFLICT** (target berbeda dari beberapa BOD): implementasi defensif — round tetap `ACTIVE`, tidak ada write. Bukan jalur normal karena one-target lock.
268 + 2. Konsumen yang **belum `CLOSED`** pada saat rollback: di-hold marker, lanjut dari status terakhir ketika prereq close (`m0349` — "ke tahan aja gitu? Iya mas. Jadi lanjutin status terakhir").
269 + 3. `request*BodReApproval`, subtask `PUT …/plans`, subtask member add/remove belum dirangkul guard hold (lihat §6b).
336 270
337 - When a rollback resolves (B → target X), **all same-level items whose direct prerequisite is X** (and their full subtrees) are affected:
338 -
339 - | State | Effect |
340 - |---|---|
341 - | Item **not yet closed** | `rollbackPaused = true` marker only — data/progress/approvalStatus **unchanged**; display forced `NOT_STARTED`; **on hold** while X re-opens. All writes blocked 400: approval respond/re-request/BOD → `400 <Scope> is on hold: waiting for its prerequisite to be approved and closed`; also update, progress submit/respond, plans, members. |
342 - | Item **CLOSED** | node → `WAITING_APPROVAL` (no progress flip, `approvalReason` nulled); its children display `NOT_STARTED` (marker) until the chain resumes. |
343 - | Release | when X is re-approved & `CLOSED`, each held consumer releases in chain order (A after X closes; A's children after A closes — no progress flip, just `WAITING_APPROVAL`) + *Action Plan Ready for Approval* per item. |
344 -
345 - ## Activity log (`namespace: action_plan`)
346 -
347 - Every BOD/approval interaction writes an activity-log row (`GET /api/projects/:projectId/activity`, `recordId` = the affected item id):
271 + ---
348 272
349 - | Category | When |
350 - |---|---|
351 - | `ACTION_PLAN_APPROVAL_APPROVED` / `_REJECTED` | VP approve / reject |
352 - | `ACTION_PLAN_BOD_ESCALATED` | VP escalates to BOD (`approverCount`, `roundId`) |
353 - | `ACTION_PLAN_BOD_DECISION` | **each** BOD vote — `{decision, actor, reason, roundId}`, incl. votes that leave the round pending/conflicted |
354 - | `ACTION_PLAN_BOD_APPROVED` / `_REVISED` / `_ROLLBACK` | terminal round resolution (revise copy: *must be re-approved by VP*; rollback: `prerequisiteName`) |
355 - | `ACTION_PLAN_ROLLBACK_REOPENED` | **one row per node** restarted by rollback (B subtree + target X subtree): *re-opened for re-approval due to rollback by BOD* |
356 - | `ACTION_PLAN_ROLLBACK_PAUSED` | **one row per held downstream consumer**: *on hold — waiting for prerequisite … (rollback effect)* |
357 - | `ACTION_PLAN_ROLLBACK_RELEASED` | **one row per consumer** released after the prerequisite re-closes: *ready for re-approval — prerequisite … re-approved and closed (rollback effect)* |
358 -
359 - The per-node rows make the multi-item state changes explainable: why each item restarted, paused, or resumed is visible in its own history.
273 + *Dokumen ini menggambarkan perilaku implementasi terkini (rework B-side no-flip + routing notifikasi — pasca commit `489ce94`, belum di-commit). Endpoint & payload tetap bahasa Inggris; ulasan naratif bahasa Indonesia.*

135dika revised this gist 4 days ago. Go to revision

1 file changed, 28 insertions

action-plan.docs.md

@@ -11,6 +11,7 @@ Action plan module: **Activity → Task → Subtask** hierarchy with progress en
11 11 - `INCOMPLETE_DATA` — not accessible.
12 12 - `NOT_STARTED` / `IN_PROGRESS` — full access.
13 13 - `NEED_APPROVAL` / `COMPLETED` / `REJECTED` — read-only for management writes; approval & BOD routes **stay writable**.
14 + - **VP/BOD read visibility** — a member assigned as VP (any item `approverId`) or as BOD (`ACTIVE` round approver) gets **full read** of tree/detail regardless of project tier or `ActionPlanMembers` membership. `MEMBER`-only users without any VP/BOD assignment see only their `ActionPlanMembers` scopes (others pruned).
14 15 - **Progress input** (submit/respond/feedback) requires project `IN_PROGRESS` (`ProjectInProgressGuard`).
15 16
16 17 ## Status enums
@@ -329,3 +330,30 @@ List rows include `requestedData`, `oldData`, `reason`, `status`, requester/resp
329 330 - REVISE → VP: **Action Plan Rejected by BOD** — `{ScopeLabel} {scopeName} in {projectName} was rejected by {bodName}. You must make decision` (+ ` Note: {reason}`).
330 331 - ROLLBACK → VP: **Action Plan Rolled Back by BOD** — `…was rolled back by {bodName}. Its prerequisite {prereqName} was re-opened for re-approval.` (+ note).
331 332 - All-APPROVE → VP + admins + members: **Action Plan Approved by BOD** — `…was approved by BOD. Handover complete.` (+ note).
333 + - Downstream consumer released (prerequisite re-closed) → VP: **Action Plan Ready for Approval** — `{ScopeLabel} {scopeName} in {projectName} is now ready for your approval. Its prerequisite {prereqName} was rolled back and has been re-approved and closed.` (+ ` Note: {reason}`). Sent per item as the prerequisite chain re-closes (A → then P/Q → then I…).
334 +
335 + ## Downstream rollback consumers
336 +
337 + When a rollback resolves (B → target X), **all same-level items whose direct prerequisite is X** (and their full subtrees) are affected:
338 +
339 + | State | Effect |
340 + |---|---|
341 + | Item **not yet closed** | `rollbackPaused = true` marker only — data/progress/approvalStatus **unchanged**; display forced `NOT_STARTED`; **on hold** while X re-opens. All writes blocked 400: approval respond/re-request/BOD → `400 <Scope> is on hold: waiting for its prerequisite to be approved and closed`; also update, progress submit/respond, plans, members. |
342 + | Item **CLOSED** | node → `WAITING_APPROVAL` (no progress flip, `approvalReason` nulled); its children display `NOT_STARTED` (marker) until the chain resumes. |
343 + | Release | when X is re-approved & `CLOSED`, each held consumer releases in chain order (A after X closes; A's children after A closes — no progress flip, just `WAITING_APPROVAL`) + *Action Plan Ready for Approval* per item. |
344 +
345 + ## Activity log (`namespace: action_plan`)
346 +
347 + Every BOD/approval interaction writes an activity-log row (`GET /api/projects/:projectId/activity`, `recordId` = the affected item id):
348 +
349 + | Category | When |
350 + |---|---|
351 + | `ACTION_PLAN_APPROVAL_APPROVED` / `_REJECTED` | VP approve / reject |
352 + | `ACTION_PLAN_BOD_ESCALATED` | VP escalates to BOD (`approverCount`, `roundId`) |
353 + | `ACTION_PLAN_BOD_DECISION` | **each** BOD vote — `{decision, actor, reason, roundId}`, incl. votes that leave the round pending/conflicted |
354 + | `ACTION_PLAN_BOD_APPROVED` / `_REVISED` / `_ROLLBACK` | terminal round resolution (revise copy: *must be re-approved by VP*; rollback: `prerequisiteName`) |
355 + | `ACTION_PLAN_ROLLBACK_REOPENED` | **one row per node** restarted by rollback (B subtree + target X subtree): *re-opened for re-approval due to rollback by BOD* |
356 + | `ACTION_PLAN_ROLLBACK_PAUSED` | **one row per held downstream consumer**: *on hold — waiting for prerequisite … (rollback effect)* |
357 + | `ACTION_PLAN_ROLLBACK_RELEASED` | **one row per consumer** released after the prerequisite re-closes: *ready for re-approval — prerequisite … re-approved and closed (rollback effect)* |
358 +
359 + The per-node rows make the multi-item state changes explainable: why each item restarted, paused, or resumed is visible in its own history.

135dika revised this gist 5 days ago. Go to revision

1 file changed, 49 insertions, 2 deletions

action-plan.docs.md

@@ -23,6 +23,8 @@ Action plan module: **Activity → Task → Subtask** hierarchy with progress en
23 23
24 24 `WAITING_APPROVAL` · `CLOSED` · `REJECTED` · `BOD_APPROVAL` (escalated)
25 25
26 + > Item without `approverId` (approval optional) never enters this enum — `approvalStatus` stays `null` (§ Approval optional).
27 +
26 28 ### `bodResult`
27 29
28 30 `REJECTED_REVISE` · `REJECTED_ROLLBACK`
@@ -53,6 +55,34 @@ Action plan module: **Activity → Task → Subtask** hierarchy with progress en
53 55 5. **VP REJECT**: walks descendants level by level; startable leaves reopen (`NEED_APPROVAL`, last `APPROVED`→`PENDING`), leaves blocked by unmet live prerequisite pause (`rollbackPaused`) and auto-resume when the prerequisite closes (`releaseRollbackPausedDependents`).
54 56 6. **Delete guard**: progress ≥ 100% (derived `COMPLETED`) or `approvalStatus = CLOSED` → delete blocked 400 (also blocks cascade delete of a complete/closed descendant).
55 57
58 + ## Approval optional (derived from `approverId`)
59 +
60 + VP approval is **optional per item**, derived from `approverId` itself (no separate flag):
61 +
62 + - Item **WITH** `approverId` → approval **mandatory**: the full VP/BOD cycle applies — auto `WAITING_APPROVAL` on completion, VP respond, BOD escalation, Closed chip.
63 + - Item **WITHOUT** `approverId` (`null`) → approval **optional**: effective progress ≥ 100 completes the item with `approvalStatus` staying `null` — no `WAITING_APPROVAL`, no *approval-requested* notification, no Closed chip, no BOD possible.
64 +
65 + **Assign-VP rule** (PATCH `approverId`) once the item's effective progress has reached ≥ 100 %:
66 +
67 + | Change | Result |
68 + |---|---|
69 + | assign (`null` → person) | **400** |
70 + | remove (person → `null`) | **400** |
71 + | replace (person → person) | **allowed — pure swap**: only `approverId` changes; `approvalStatus` / `approvalReason` / `approvedAt` / `bodResult` / `bodReason` / `bodRoundId` / `rollbackPaused` all untouched, no reopen, no late-assignment notification |
72 +
73 + Below 100 % the pre-existing assignment semantics are unchanged (assignment never mutates `approvalStatus` on an incomplete item).
74 +
75 + **Prerequisite rule**:
76 +
77 + - VP-less prerequisite (`approverId = null`) → satisfied by effective progress ≥ 100 **alone** (no `CLOSED` needed).
78 + - VP-held prerequisite → still requires `CLOSED` **and** effective progress ≥ 100.
79 +
80 + **Parent completeness** — a child counts toward the parent's `WAITING_APPROVAL` trigger ("done") when `child.approvalStatus = CLOSED` **or** (child has no VP **and** child's approved rollup ≥ 100).
81 +
82 + **All approval endpoints on a VP-less item return 400**: `…/approval/respond` (incl. `ESCALATE`), `…/approval/re-request`, `…/approval/bod`, `…/approval/bod/re-request`.
83 +
84 + **Existing-data migration** (applied mid-feature): prior rows with non-null `approvalStatus` were set to `CLOSED`, and ACTIVE BOD rounds were closed.
85 +
56 86 ---
57 87
58 88 # Endpoint catalog
@@ -110,6 +140,8 @@ Detail — item payload (§ Item payload).
110 140 ### `PATCH /:activityId`
111 141 Body (`UpdateActivityDto`) — all ✳️: `name`, `activityType`, `approverId`, `workWeight`, `workVolume`, `workVolumeUnit` (*requires workVolume pair rule*), `startDate`, `endDate`, `prerequisiteIds[]`.
112 142
143 + > `approverId`: assign / remove blocked 400 once complete (≥ 100 %); replace = pure swap (§ Approval optional).
144 +
113 145 ### `DELETE /:activityId`
114 146 No body. **400** if item or live descendant complete (`≥100%`) or `CLOSED`.
115 147
@@ -151,6 +183,21 @@ Guards: item `BOD_APPROVAL`, caller is assigned BOD with no vote yet, round not
151 183 ### `POST …/approval/bod/re-request` — VP resumes BOD cycle after REVISE
152 184 No body.
153 185
186 + ### `GET /approval-obligations` — pre-member-removal check (project level)
187 + Query:
188 + | Param | Req | Notes |
189 + |---|---|---|
190 + | `userId` | ✅ | uuid — the member whose action-plan responsibilities are checked |
191 +
192 + Purpose: list the user's outstanding approval responsibilities in the project before member removal (removal itself lives outside this module). → `{ "hasObligation": bool, "obligations": […] }`:
193 +
194 + - **VP obligations** — every item where the user is the VP, **all states including `CLOSED`**:
195 + `{ "type": "VP", "scope": "ACTIVITY" | "TASK" | "SUBTASK", "itemId", "itemName" }`
196 + - **BOD obligations** — every `ACTIVE` BOD round the user is an assigned approver, **including rounds they already voted on**:
197 + `{ "type": "BOD", "scope": "ACTIVITY" | "TASK" | "SUBTASK", "itemId", "itemName", "roundId", "responded" }` — `responded` = whether the user cast a decision.
198 +
199 + Empty result → `hasObligation: false`, empty `obligations`.
200 +
154 201 ## 3. Item payload (detail/list/tree)
155 202
156 203 BOD-related fields on every item: `approvalStatus`, `approvalReason`, `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused`, plus `approverId/approverName/approverEmail`, `prerequisites[]`, `prerequisiteIds[]`.
@@ -169,7 +216,7 @@ Detail-only (`GET /A`, `GET A/tasks/T`, `GET A/tasks/T/subtasks/S`):
169 216 | `POST /` | `CreateTaskDto` — `name` ✅; `workWeight/workVolume/workVolumeUnit/startDate/endDate` ✳️ (volume pair rule) |
170 217 | `GET /` | list under activity |
171 218 | `GET /:taskId` | detail (§ Item payload) |
172 - | `PATCH /:taskId` | `UpdateTaskDto` — `name/approverId/workWeight/workVolume/workVolumeUnit/startDate/endDate/prerequisiteIds[]` all ✳️ |
219 + | `PATCH /:taskId` | `UpdateTaskDto` — `name/approverId/workWeight/workVolume/workVolumeUnit/startDate/endDate/prerequisiteIds[]` all ✳️ — `approverId`: assign/remove blocked 400 once complete, replace = pure swap (§ Approval optional) |
173 220 | `DELETE /:taskId` | complete/CLOSED guard 400 |
174 221
175 222 Plus per task: progress (`POST/GET /:taskId/progress`, `GET /:taskId/progress/:progressId`, `POST …/respond`), feedbacks, `GET/PUT /:taskId/plans`, members (`POST/DELETE/GET /:taskId/members`), edit-requests (`POST/GET /:taskId/edit-requests`), approval routes (§ 2).
@@ -181,7 +228,7 @@ Plus per task: progress (`POST/GET /:taskId/progress`, `GET /:taskId/progress/:p
181 228 | `POST /` | `CreateSubtaskDto` — `name` ✅; `workVolume/workVolumeUnit/workWeight/startDate/endDate` ✳️ (volume pair rule) |
182 229 | `GET /` | list under task |
183 230 | `GET /:subtaskId` | detail |
184 - | `PATCH /:subtaskId` | `UpdateSubtaskDto` — all ✳️ incl `approverId`, `prerequisiteIds[]` |
231 + | `PATCH /:subtaskId` | `UpdateSubtaskDto` — all ✳️ incl `approverId`, `prerequisiteIds[]` — `approverId`: assign/remove blocked 400 once complete, replace = pure swap (§ Approval optional) |
185 232 | `DELETE /:subtaskId` | complete/CLOSED guard 400 |
186 233
187 234 Plus: members (`POST/DELETE/GET /:subtaskId/members`), plans (`GET/PUT /:subtaskId/plans`), edit-requests (`POST/GET /:subtaskId/edit-requests`), progress (`POST/GET /:subtaskId/progress`, `GET /:subtaskId/progress/:progressId`, `POST …/respond`), feedbacks (`POST/GET …/feedbacks`, `PATCH/DELETE …/feedbacks/:feedbackId`), approval routes (§ 2).

135dika revised this gist 5 days ago. Go to revision

1 file changed, 199 insertions, 192 deletions

action-plan.docs.md

@@ -1,277 +1,284 @@
1 1 # API — Action Plans
2 2
3 - Action plan module for a project: a three-level hierarchy **Activity → Task → Subtask**, each with progress entries, approvers, prerequisites, members, and a BOD (Board of Directors) escalation flow.
3 + Action plan module: **Activity → Task → Subtask** hierarchy with progress entries, approvers, prerequisites, members, and a BOD (Board of Directors) escalation flow.
4 4
5 5 ## General
6 6
7 - - **Base URL**: `/api/v1/projects/:projectId/action-plans` (versioned controller, v1)
8 - - **Auth**: JWT via `@Authorize({ membership: true })` — caller must be a member of `:projectId`.
9 - - **Response envelope** (`ResponseInterceptor`): responses are wrapped as `{ status, message, data }` where `data` is the payload documented below (endpoints returning raw files — PDF/XLSX — skip the envelope).
7 + - **Base**: `/api/v1/projects/:projectId/action-plans` — all paths below are relative to this base.
8 + - **Auth**: JWT, `@Authorize({ membership: true })` — caller must be a project member.
9 + - **Envelope**: responses wrapped `{ status, message, data }`; binary endpoints (PDF/XLSX) bypass the envelope.
10 10 - **Access tiers** (`ProjectActionPlanAccessGuard`):
11 11 - `INCOMPLETE_DATA` — not accessible.
12 12 - `NOT_STARTED` / `IN_PROGRESS` — full access.
13 - - `NEED_APPROVAL` / `COMPLETED` / `REJECTED` — read-only for management writes (create/update/delete), **but approval respond/re-request and BOD routes stay writable** (project closes only after approvals resolve).
14 - - **Progress input** (submit/respond/feedback) additionally requires project status `IN_PROGRESS` (`ProjectInProgressGuard`); approval routes intentionally bypass it.
13 + - `NEED_APPROVAL` / `COMPLETED` / `REJECTED` — read-only for management writes; approval & BOD routes **stay writable**.
14 + - **Progress input** (submit/respond/feedback) requires project `IN_PROGRESS` (`ProjectInProgressGuard`).
15 15
16 16 ## Status enums
17 17
18 - ### Derived status (item `status` field)
18 + ### Derived item `status`
19 19
20 - Derived from progress rows + approval state. Display overrides applied (see BOD display):
20 + `INCOMPLETE_DATA` · `NEED_APPROVAL` · `IN_PROGRESS` · `NOT_STARTED` (also display override for paused/rolled back) · `COMPLETED`
21 21
22 - | Value | Meaning |
23 - |---|---|
24 - | `INCOMPLETE_DATA` | Leaf has no dates/weight data |
25 - | `NEED_APPROVAL` | Progress submitted, awaiting approval |
26 - | `IN_PROGRESS` | Has approved progress, not yet complete |
27 - | `NOT_STARTED` | No active progress, or paused/rolled back (display override) |
28 - | `COMPLETED` | Effective progress ≥ 100% |
29 -
30 - ### ActionPlanApprovalStatus (`approvalStatus`)
22 + ### `approvalStatus`
31 23
32 - `WAITING_APPROVAL` (item awaiting/queued for a new approval cycle) · `CLOSED` (approved) · `REJECTED` (rejected by VP) · `BOD_APPROVAL` (escalated, awaiting BOD board decision)
24 + `WAITING_APPROVAL` · `CLOSED` · `REJECTED` · `BOD_APPROVAL` (escalated)
33 25
34 - ### ActionPlanBodResult (`bodResult`) — aggregate outcome of latest BOD round
26 + ### `bodResult`
35 27
36 - | Value | Meaning |
37 - |---|---|
38 - | `REJECTED_REVISE` | BOD voted REVISE → item back to VP (`approvalStatus` → `WAITING_APPROVAL`) |
39 - | `REJECTED_ROLLBACK` | BOD voted ROLLBACK → item restarting from a prerequisite |
28 + `REJECTED_REVISE` · `REJECTED_ROLLBACK`
40 29
41 - ### ActionPlanBodDisplayStatus (`bodDisplayStatus`) — chip shown to the user
30 + ### `bodDisplayStatus`
42 31
43 32 | Value | Rule |
44 33 |---|---|
45 - | `BOD_APPROVAL` | while item is escalated (approvalStatus `BOD_APPROVAL`) |
34 + | `BOD_APPROVAL` | while escalated |
46 35 | `REJECTED_BY_BOD` | `bodResult === REJECTED_REVISE` |
47 - | `NOT_STARTED` | `bodResult === REJECTED_ROLLBACK` **or** `rollbackPaused === true` (no "Rolled Back by BOD" chip) |
36 + | `NOT_STARTED` | `bodResult === REJECTED_ROLLBACK` **or** `rollbackPaused === true` |
48 37 | `null` | no BOD state |
49 38
50 - ### ActionPlanProgressStatus (progress rows)
39 + ### Progress row `status`
51 40
52 41 `PENDING` · `APPROVED` · `APPROVED_WITH_NOTES` · `REJECTED`
53 42
54 - ### Other booleans
43 + ## BOD escalation flow
55 44
56 - - `rollbackPaused`: item halted waiting for a prerequisite to close; reads as `NOT_STARTED`.
57 -
58 - ## BOD escalation model
59 -
60 - 1. **VP** (designated approver) responds to an item's approval: `APPROVE` | `REJECT` | `ESCALATE`. `ESCALATE` requires `bodUserIds` (≥1, max 50; each must be a user in the project's program agenda) → approvalStatus `BOD_APPROVAL`, a new `ActionPlanBodRounds` round created, notification sent to each BOD.
61 - 2. **Each assigned BOD** votes on the round: `APPROVE` | `REVISE` | `ROLLBACK` (reason required, 1–500 chars).
62 - - One **rollback target lock** applies: once a BOD votes `ROLLBACK` to target T, all other BODs may only vote `ROLLBACK` to the same T (a different `rollbackId` → 400). `rollbackId` must be a **direct prerequisite** of the item.
63 - 3. **Resolution** when all assigned BODs have voted — precedence `ROLLBACK > REVISE > APPROVE`:
64 - - Any/all `ROLLBACK` votes to the same target T → ROLLBACK wins. Item → `WAITING_APPROVAL`, `bodResult = REJECTED_ROLLBACK`, `bodReason` = earliest voter's reason; T's and item's subtrees restart (leaves: last `APPROVED` progress → `PENDING`; each node → `WAITING_APPROVAL`; nodes whose prerequisites aren't met → `rollbackPaused` until the prerequisite closes). VP notified ("Action Plan Rolled Back by BOD").
65 - - Else any `REVISE` → REVISE wins. Item → `WAITING_APPROVAL`, `bodResult = REJECTED_REVISE`, `bodReason` = earliest voter's reason; data & progress rows untouched; VP notified ("Action Plan Rejected by BOD").
66 - - Else (all `APPROVE`) → item `approvalStatus = CLOSED` (handover); VP + admins + action plan members notified ("Action Plan Approved by BOD").
67 - - Different `ROLLBACK` targets across voters → round stays `ACTIVE`, item stays `BOD_APPROVAL`, no writes (defensive; the single-target lock normally prevents this).
68 - 4. **Re-escalation**: VP `ESCALATE` again after a BOD REVISE carries the previous BOD list over; may *add* members, may not remove/replace.
69 - 5. **VP `REJECT`** (non-escalated flow) does not reopen closed children wholesale: it walks descendants level by level (Activity → Tasks → Subtasks). Leaves that are startable (no unmet live prerequisite) reopen → `NEED_APPROVAL`/`PENDING`; leaves blocked by an unmet prerequisite pause with `rollbackPaused = true` and resume automatically when the prerequisite reaches `CLOSED` (`.releaseRollbackPausedDependents`).
70 - 6. **Delete guard**: an item whose effective progress ≥ 100% (derived `COMPLETED`) or `approvalStatus = CLOSED` (VP- or BOD-approved) cannot be deleted — including as a descendant of a parent delete (`400` naming the offending item).
45 + 1. **VP** responds via `…/approval/respond` with `verb`: `APPROVE` | `REJECT` | `ESCALATE` (this is one **2-step form**: step 1 picks the verb, step 2 picks BOD members via `bodUserIds` when `ESCALATE`). `ESCALATE` → `BOD_APPROVAL` + new BOD round; each BOD (project program agenda users) notified.
46 + 2. **BOD votes** via `…/approval/bod`: `APPROVE` | `REVISE` | `ROLLBACK` (reason required; `rollbackId` = direct prerequisite when ROLLBACK). **One-target lock**: after the first ROLLBACK vote to target T, all later BODs may only ROLLBACK to T (else 400).
47 + 3. **Resolution** once all assigned BODs voted — precedence **`ROLLBACK > REVISE > APPROVE`**:
48 + - ROLLBACK (same single target T) → item `WAITING_APPROVAL` + `bodResult = REJECTED_ROLLBACK`; T's & item's subtrees restart (leaf last `APPROVED` → `PENDING`, nodes → `WAITING_APPROVAL`, unmet-prereq nodes → `rollbackPaused` until prerequisite closes); VP notified *Action Plan Rolled Back by BOD*.
49 + - else REVISE → item `WAITING_APPROVAL` + `bodResult = REJECTED_REVISE`; data/progress untouched; VP notified *Action Plan Rejected by BOD*.
50 + - else all APPROVE → `CLOSED` (handover); VP + admins + members notified *Action Plan Approved by BOD*.
51 + - Different ROLLBACK targets → no resolution, round stays `ACTIVE` (defensive; normally prevented by the lock).
52 + 4. **Re-escalation**: `…/approval/bod/re-request` + later `ESCALATE` carries previous BOD list over — **add-only**, cannot remove/replace.
53 + 5. **VP REJECT**: walks descendants level by level; startable leaves reopen (`NEED_APPROVAL`, last `APPROVED`→`PENDING`), leaves blocked by unmet live prerequisite pause (`rollbackPaused`) and auto-resume when the prerequisite closes (`releaseRollbackPausedDependents`).
54 + 6. **Delete guard**: progress ≥ 100% (derived `COMPLETED`) or `approvalStatus = CLOSED` → delete blocked 400 (also blocks cascade delete of a complete/closed descendant).
71 55
72 56 ---
73 57
74 - ## 1. Activities
75 -
76 - ### `POST /` — create activity
77 -
78 - ```json
79 - {
80 - "name": "string (required)",
81 - "activityType": "string (required)",
82 - "workWeight": "number (optional)",
83 - "workVolume": "number (optional, requires workVolumeUnit)",
84 - "workVolumeUnit": "string (optional)",
85 - "startDate": "ISO date (optional)",
86 - "endDate": "ISO date (optional)",
87 - "approverId": "uuid (optional)",
88 - "prerequisiteIds": ["uuid (optional)"],
89 - "tasks": [{ "name": "string", "workWeight", "workVolume", "workVolumeUnit", "startDate", "endDate", "subtasks": [{ "name", "workWeight", "workVolume", "workVolumeUnit", "startDate", "endDate" }] }]
90 - }
91 - ```
92 -
93 - Response includes the item fields below (§ Item payload) plus: `taskCount`, `subtaskCount`, `memberCount`, `progress`, `pendingProgress`, `weightlessLeaves`, `predecessorCount`-style derived stats, and `status`.
94 -
95 - ### `POST /bulk` — bulk create `{ "activities": [<CreateActivityPayload>…] }` → `{ created, activities: [{id,name}] }`
58 + # Endpoint catalog
96 59
97 - ### `GET /` — list (paginated)
60 + **Path legend**: `A` = `:activityId`, `T` = `:taskId`, `S` = `:subtaskId`. Full paths:
98 61
99 - Query: `page`, `limit`, `search?`, `sort_by` (`created_at` default | `start_date` | `end_date` | `start_date_end_date`), `sort_order` (`asc`|`desc`)
62 + - Activity level: `BASE/A/…`
63 + - Task level: `BASE/A/tasks/T/…`
64 + - Subtask level: `BASE/A/tasks/T/subtasks/S/…`
100 65
101 - ### `GET /tree` — hierarchical tree
102 -
103 - Returns `Activity[]` each with nested `tasks[].subtasks[]`; every level carries the item fields + `accumulatedProgress`, `pendingProgress`, `workWeight`, `workVolume`, `totalUsersAssigned`, `latestProgressStatus`.
104 -
105 - Query: `status?`, `startDate?`, `endDate?`, `search?`, `sort_by?`, `sort_order?`
106 -
107 - ### `GET /tree/gantt` — same tree with `ganttStatus` per node (`LATE` | `ON_TRACK` | `BASELINE` | …)
108 -
109 - ### `GET /:activityId` — detail (§ Item payload)
66 + ## 1. Activities
110 67
111 - ### `PATCH /:activityId` — update
68 + ### `POST /` (base)
69 + **Body** (`CreateActivityDto`):
70 + | Field | Req | Notes |
71 + |---|---|---|
72 + | `name` | ✅ | 1–255 chars |
73 + | `activityType` | ✅ | 1–100 chars |
74 + | `workWeight` | ✳️ | number ≥0 |
75 + | `workVolume` | ✳️ | number ≥0 — *requires `workVolumeUnit`* |
76 + | `workVolumeUnit` | ✳️ | ≤100 chars |
77 + | `startDate` | ✳️ | ISO date, floored to day |
78 + | `endDate` | ✳️ | ISO date |
79 + | `tasks[]` | ✳️ | ≤50, each: `name` ✅ (1–255), `workWeight/workVolume/workVolumeUnit/startDate/endDate` ✳️, nested `subtasks[]` ≤50 (same shape, `name` ✅) |
80 +
81 + ### `POST /bulk`
82 + Body: `CreateActivityDto[]` — 1–100 items. → `{ created, activities: [{id,name}] }`
83 +
84 + ### `GET /`
85 + Query:
86 + | Param | Req | Options / default |
87 + |---|---|---|
88 + | `page` | ✳️ | default `1` |
89 + | `limit` | ✳️ | default `20`, max `100` |
90 + | `search` | ✳️ | string |
91 + | `sort_by` | ✳️ | `created_at` (default) · `updated_at` · `name` |
92 + | `sort_order` | ✳️ | `asc` · `desc` (default) |
93 +
94 + ### `GET /tree`
95 + Query:
96 + | Param | Req | Options / default |
97 + |---|---|---|
98 + | `status` | ✳️ | derived status string filter |
99 + | `startDate` / `endDate` | ✳️ | bound effective date overlap range (one-sided allowed) |
100 + | `search` | ✳️ | string |
101 + | `sort_by` | ✳️ | `created_at` (default) · `start_date` · `end_date` · `start_date_end_date` |
102 + | `sort_order` | ✳️ | `asc` · `desc` (default `asc`) |
112 103
113 - ```json
114 - { "name"?, "activityType"?, "workWeight"?, "workVolume"?, "workVolumeUnit"?, "startDate"?, "endDate"?, "approverId"?, "prerequisiteIds"? }
115 - ```
104 + ### `GET /tree/gantt`
105 + Same query as `/tree`; nodes carry `ganttStatus`.
116 106
117 - ### `DELETE /:activityId` — soft delete. **Blocked (400)** if item or any live descendant is complete (progress ≥ 100%) or `approvalStatus = CLOSED`.
107 + ### `GET /:activityId`
108 + Detail — item payload (§ Item payload).
118 109
119 - ### `POST /bulk` (DELETE) — `DELETE /bulk` bulk delete
110 + ### `PATCH /:activityId`
111 + Body (`UpdateActivityDto`) — all ✳️: `name`, `activityType`, `approverId`, `workWeight`, `workVolume`, `workVolumeUnit` (*requires workVolume pair rule*), `startDate`, `endDate`, `prerequisiteIds[]`.
120 112
121 - ```json
122 - { "activityIds": [], "taskIds": [], "subtaskIds": [] }
123 - ```
124 - → `{ deleted }`. Parent ids cascade to descendants; same complete/CLOSED guard applies.
113 + ### `DELETE /:activityId`
114 + No body. **400** if item or live descendant complete (`≥100%`) or `CLOSED`.
125 115
126 - ---
116 + ### `DELETE /bulk`
117 + Body (`BulkActionPlanDeleteDto`):
118 + | Field | Req | Notes |
119 + |---|---|---|
120 + | `activityIds[]` | ✳️ | ≤100, default `[]` |
121 + | `taskIds[]` | ✳️ | ≤100, default `[]` |
122 + | `subtaskIds[]` | ✳️ | ≤100, default `[]` |
127 123
128 - ## 2. Approval — per item level
124 + At least one id array non-empty. Parents cascade to descendants. → `{ deleted }`. Same complete/CLOSED guard.
129 125
130 - Same three routes per level, substituting `:activityId` / `:activityId/tasks/:taskId` / `:activityId/tasks/:taskId/subtasks/:subtaskId`:
126 + ## 2. Approval (3 levels; same routes)
131 127
132 - ### `POST …/approval/respond` — VP (designated approver) responds
128 + Patterns (per level): `…/approval/respond`, `…/approval/re-request`, `…/approval/bod`, `…/approval/bod/re-request`.
133 129
130 + ### `POST …/approval/respond` — VP (designated approver)
134 131 Body (`ApprovalRespondDto`):
132 + | Field | Req | Notes |
133 + |---|---|---|
134 + | `verb` | ✅ | `APPROVE` · `REJECT` · `ESCALATE` |
135 + | `reason` | ✳️ | ≤500 chars |
136 + | `bodUserIds[]` | ✳️ | 1–50, each a valid agenda user — **required when `verb=ESCALATE`** |
135 137
136 - ```json
137 - {
138 - "verb": "APPROVE | REJECT | ESCALATE",
139 - "reason": "string ≤500 chars (optional)",
140 - "bodUserIds": ["uuid…"] // required when verb = ESCALATE; 1–50
141 - }
142 - ```
138 + ### `POST …/approval/re-request` — creator re-requests (only from `REJECTED`/`WAITING_APPROVAL`)
139 + No body.
143 140
144 - This is a **single 2-step form** in the UI: step 1 picks the verb (`APPROVE`/`REJECT`/`ESCALATE`); when `ESCALATE` is chosen, step 2 picks the BOD members (`bodUserIds`). No separate "escalation" endpoint exists.
141 + ### `POST …/approval/bod` — BOD member votes
142 + Body (`BodRespondDto`):
143 + | Field | Req | Notes |
144 + |---|---|---|
145 + | `decision` | ✅ | `APPROVE` · `REVISE` · `ROLLBACK` |
146 + | `reason` | ✅ | 1–500 chars |
147 + | `rollbackId` | ✳️ | — **required when `decision=ROLLBACK`**; must be a direct prerequisite |
145 148
146 - Effects:
147 - - `APPROVE` → `CLOSED` (item + descendants already closed).
148 - - `REJECT` → `REJECTED`; descendants reopened per the chain rule (§ BOD model point 5).
149 - - `ESCALATE` → `BOD_APPROVAL`; new BOD round; BODs notified.
149 + Guards: item `BOD_APPROVAL`, caller is assigned BOD with no vote yet, round not resolved.
150 150
151 - ### `POST …/approval/re-request` — creator re-requests approval (only when `REJECTED` / `WAITING_APPROVAL`)
151 + ### `POST …/approval/bod/re-request` — VP resumes BOD cycle after REVISE
152 + No body.
152 153
153 - Body: none.
154 + ## 3. Item payload (detail/list/tree)
154 155
155 - ### `POST …/approval/bod` — BOD member votes **(new)**
156 + BOD-related fields on every item: `approvalStatus`, `approvalReason`, `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused`, plus `approverId/approverName/approverEmail`, `prerequisites[]`, `prerequisiteIds[]`.
156 157
157 - Body (`BodRespondDto`):
158 + Detail-only (`GET /A`, `GET A/tasks/T`, `GET A/tasks/T/subtasks/S`):
158 159
159 160 ```json
160 - {
161 - "decision": "APPROVE | REVISE | ROLLBACK",
162 - "reason": "string, 1–500 chars (REQUIRED)",
163 - "rollbackId": "uuid (required when decision = ROLLBACK; must be a direct prerequisite of the item)"
164 - }
161 + "bodApprovals": [{ "userId", "name", "decision", "reason", "rollbackItemId", "rollbackTargetName", "respondedAt" }],
162 + "rollbackOptions": [{ "id", "name" }] // direct prereqs; locked to single target after first ROLLBACK vote
165 163 ```
166 164
167 - Preconditions (400/403 otherwise): item `approvalStatus = BOD_APPROVAL`; caller is an assigned BOD of the active round with no prior vote; not already resolved.
168 -
169 - ### `POST …/approval/bod/re-request` — VP re-requests BOD round **(new)**
165 + ## 4. Tasks — `BASE/A/tasks`
170 166
171 - Body: none. Re-creates the escalation after a BOD REVISE (VP cycle back); carries previous BOD list (add-only on next `ESCALATE`).
172 -
173 - ---
167 + | Method+Path | Body / notes |
168 + |---|---|
169 + | `POST /` | `CreateTaskDto` — `name` ✅; `workWeight/workVolume/workVolumeUnit/startDate/endDate` ✳️ (volume pair rule) |
170 + | `GET /` | list under activity |
171 + | `GET /:taskId` | detail (§ Item payload) |
172 + | `PATCH /:taskId` | `UpdateTaskDto` — `name/approverId/workWeight/workVolume/workVolumeUnit/startDate/endDate/prerequisiteIds[]` all ✳️ |
173 + | `DELETE /:taskId` | complete/CLOSED guard 400 |
174 174
175 - ## 3. Item payload (activity/task/subtask detail & tree nodes)
175 + Plus per task: progress (`POST/GET /:taskId/progress`, `GET /:taskId/progress/:progressId`, `POST …/respond`), feedbacks, `GET/PUT /:taskId/plans`, members (`POST/DELETE/GET /:taskId/members`), edit-requests (`POST/GET /:taskId/edit-requests`), approval routes (§ 2).
176 176
177 - BOD-related fields on every item (detail, list, tree):
177 + ## 5. Subtasks — `BASE/A/tasks/T/subtasks`
178 178
179 - | Field | Type | Notes |
180 - |---|---|---|
181 - | `approvalStatus` | enum | `WAITING_APPROVAL` | `CLOSED` | `REJECTED` | `BOD_APPROVAL` |
182 - | `approvalReason` | string? | VP response reason |
183 - | `bodRoundId` | string? | latest BOD round id |
184 - | `bodResult` | enum? | `REJECTED_REVISE` | `REJECTED_ROLLBACK` |
185 - | `bodReason` | string? | aggregate round reason (earliest voter) |
186 - | `bodDisplayStatus` | enum? | `BOD_APPROVAL` | `REJECTED_BY_BOD` | `NOT_STARTED` | null |
187 - | `rollbackPaused` | boolean | paused awaiting prerequisite close |
188 - | `approverId` / `approverName` / `approverEmail` | string? | designated VP approver |
189 - | `prerequisites` / `prerequisiteIds` | [] | direct prerequisites (same-scope level) |
190 -
191 - **Detail-only additions** (`GET /:activityId`, `GET …/tasks/:taskId`, `GET …/subtasks/:subtaskId`):
179 + | Method+Path | Body / notes |
180 + |---|---|
181 + | `POST /` | `CreateSubtaskDto` — `name` ✅; `workVolume/workVolumeUnit/workWeight/startDate/endDate` ✳️ (volume pair rule) |
182 + | `GET /` | list under task |
183 + | `GET /:subtaskId` | detail |
184 + | `PATCH /:subtaskId` | `UpdateSubtaskDto` — all ✳️ incl `approverId`, `prerequisiteIds[]` |
185 + | `DELETE /:subtaskId` | complete/CLOSED guard 400 |
192 186
193 - ```json
194 - "bodApprovals": [
195 - {
196 - "userId": "uuid",
197 - "name": "string|null",
198 - "decision": "APPROVE | REVISE | ROLLBACK|null",
199 - "reason": "string|null",
200 - "rollbackItemId": "uuid|null",
201 - "rollbackTargetName": "string|null",
202 - "respondedAt": "ISO date"
203 - }
204 - ],
205 - "rollbackOptions": [ { "id": "uuid", "name": "string" } ] // direct prereqs; after first ROLLBACK vote, locked to that single target
206 - ```
187 + Plus: members (`POST/DELETE/GET /:subtaskId/members`), plans (`GET/PUT /:subtaskId/plans`), edit-requests (`POST/GET /:subtaskId/edit-requests`), progress (`POST/GET /:subtaskId/progress`, `GET /:subtaskId/progress/:progressId`, `POST …/respond`), feedbacks (`POST/GET …/feedbacks`, `PATCH/DELETE …/feedbacks/:feedbackId`), approval routes (§ 2).
207 188
208 - ## 4. Tasks & Subtasks
189 + ## 6. Members
209 190
210 - Same shape as activities under the nesting paths:
191 + | Method+Path | Body |
192 + |---|---|
193 + | `POST /A/members` · `DELETE /A/members` (×task/subtask) | `{ "userIds": ["uuid…"] }` — 1–100, required |
194 + | `POST /members/bulk` | `BulkActionPlanMembersDto`: `activityIds[]` (≤100, default `[]`), `taskIds[]` (≤100, default `[]`), `subtaskIds[]` (≤100, default `[]`), `userIds[]` (1–100 ✅) — ≥1 id array non-empty; parent scopes cascade to leaves → `{ assigned, skipped }` |
195 + | `DELETE /members/bulk` | same body → `{ removed }` |
196 + | `GET /A/members` · `GET A/tasks/T/members` · `GET …/subtasks/S/members` | list w/ display names + leaf scopes |
211 197
212 - - `POST :activityId/tasks` / `GET :activityId/tasks` / `GET/PATCH/DELETE :activityId/tasks/:taskId` + progress/plans/members/edit-requests/approval routes.
213 - - `POST :activityId/tasks/:taskId/subtasks` / `GET` list / `GET/PATCH/DELETE :activityId/tasks/:taskId/subtasks/:subtaskId` + same sub-routes.
198 + ## 7. Progress (multipart) — 3 levels
214 199
215 - Member assignment (`…/members` POST/DELETE) body: `{ "userIds": ["uuid…"] }`; bulk variants under `/members/bulk` (POST assign / DELETE remove): `{ "activityIds": [], "taskIds": [], "subtaskIds": [], "userIds": [] }` → `{ assigned, skipped }` / `{ removed }`.
200 + ### `POST …/progress` — submit
201 + Multipart form fields (`CreateProgressDto`):
202 + | Field | Req | Notes |
203 + |---|---|---|
204 + | `progress` | ✳️ | coerced number 0–100; omit = carry current floor |
205 + | `workVolume` | ✳️ | number ≥0 |
206 + | `description` | ✳️ | ≤5000 chars |
207 + | `createdAt` | ✳️ | ISO date (backdated work) |
208 + | `documentSource` | ✳️ | `upload` · `document` |
209 + | `documentRef` | ✳️ | JSON string `{"documentId","versionId","revisionId"}` (when source=document) |
210 + | `companyId` | ✳️ | guards cross-company document access |
211 + | Files | | `attachments[]` (jpg/png/gif/webp/svg/pdf), `document` (×1) |
216 212
217 - ## 5. Progress (leaves: activity/task/subtask)
213 + ### `GET …/progress`
214 + Query: `page` (default 1) · `limit` (default 50, max 100).
218 215
219 - - `POST :itemId/progress` (multipart): fields `progress`, `workVolume?`, `description?`, `documentSource` (`upload`|`document`), `documentRef?`, `companyId?`, `createdAt?`; files `attachments[]` (jpg/png/gif/webp/svg/pdf) and `document` (×1). → progress row payload (see below).
220 - - `GET :itemId/progress` — paginated (`page`, `limit`).
221 - - `GET :itemId/progress/:progressId` — single row.
222 - - `POST :itemId/progress/:progressId/respond` — body:
216 + ### `GET …/progress/:progressId`
217 + Single row: `id, scopeType, scopeId, scopeName, progress, workVolume, scopeWorkVolume, scopeWorkVolumeUnit, description, status, amendsId, amendsOriginal, amendedByCount, amendedBy[], approvedBy, approvedByName, approvedAt, createdBy, createdByName, updatedBy, updatedByName, createdAt, updatedAt, attachments[], document`.
223 218
224 - ```json
225 - { "verb": "APPROVE | APPROVE_WITH_NOTES | REJECT", "progress"?, "workVolume"?, "description"? }
226 - ```
219 + ### `POST …/progress/:progressId/respond`
220 + Body (`RespondProgressDto`): `verb` ✅ (`APPROVE`·`APPROVE_WITH_NOTES`·`REJECT`); `progress` ✳️ (0–100), `workVolume` ✳️, `description` ✳️ (≤5000) — overridable on APPROVE_WITH_NOTES.
227 221
228 - Progress row payload: `id, scopeType, scopeId, scopeName, progress, workVolume, scopeWorkVolume, scopeWorkVolumeUnit, description, status, amendsId, amendsOriginal, amendedByCount, amendedBy[], approvedBy, approvedByName, approvedAt, createdBy, createdByName, updatedBy, updatedByName, createdAt, updatedAt, attachments[{id,key,url}], document`.
222 + ## 8. Feedback — `…/progress/:progressId/feedbacks`
229 223
230 - ### Feedback (per progress row)
224 + | Method+Path | Body |
225 + |---|---|
226 + | `POST …/feedbacks` | `{ "message": "string" }` — 1–5000 chars ✅ |
227 + | `GET …/feedbacks` | query: `page` (1) · `limit` (50, max 100) |
228 + | `PATCH …/feedbacks/:feedbackId` | `{ "message" }` same validation |
229 + | `DELETE …/feedbacks/:feedbackId` | — |
231 230
232 - `POST/GET :itemId/progress/:progressId/feedbacks`, `PATCH/DELETE …/feedbacks/:feedbackId`. Body `{ "message": "string" }` (≤500).
231 + ## 9. Plans / S-curve — `…/plans` (3 levels)
233 232
234 - ## 6. Plans / S-curve (per level)
233 + `GET …/plans` → `{ duration: {days,startDate,endDate}, planning: [{id?,startDate,endDate,targetProgress}], sCurve: { plan:[{date,progress}], actual:[{date,progress,id}] } }`
234 + `PUT …/plans` body (`SetProgressPlansDto`): `plans[]` (≥0): `startDate` ✅ (date), `endDate` ✅ (date), `targetProgress` ✅ (0–100).
235 235
236 - - `GET :itemId/plans` → `{ duration: {days,startDate,endDate}, planning: [{id?,startDate,endDate,targetProgress}], sCurve: { plan: [{date,progress}], actual: [{date,progress,id}] } }`
237 - - `PUT :itemId/plans` body: `{ "plans": [{ "startDate": "ISO", "endDate": "ISO", "targetProgress": 0–100 }] }`
236 + ## 10. Edit requests — member → owner/admin
238 237
239 - ## 7. Edit requests (members → owner/admin)
238 + | Method+Path | Body |
239 + |---|---|
240 + | `POST …/edit-requests` (3 levels + none at project) | `CreateEditRequestDto`: `endDate` ✳️, `workWeight` ✳️, `workVolume` ✳️, `workVolumeUnit` ✳️, `activityType` ✳️ — one or more changed fields; `reason` ✅ (1–500 chars) |
241 + | `GET …/edit-requests` · `GET /edit-requests` | query: `page` (1) · `limit` (50, max 100) · `status` ✳️ (`PENDING`·`APPROVED`·`REJECTED`) |
242 + | `POST /edit-requests/:requestId/respond` | `{ "verb": "APPROVE" \| "REJECT" }` ✅ |
240 243
241 - - `POST/GET :itemId/edit-requests`, project-level `GET /edit-requests`, `POST /edit-requests/:requestId/respond` body `{ "verb": "APPROVE | REJECT" }`.
242 - - Create body: `{ "field": "endDate | workWeight | workVolume | workVolumeUnit", "newValue": …, "reason": "string ≤500" }`.
243 - - List returns `requestedData`, `oldData`, `reason`, `status`, requester/responder names + timestamps.
244 + List rows include `requestedData`, `oldData`, `reason`, `status`, requester/responder names, timestamps.
244 245
245 - ## 8. Analytics & reports
246 + ## 11. Analytics & reports
246 247
247 - - `GET /analytics` — query: `layout`, `granularity`, `search?`, `activityName?`, `startDate?`, `endDate?`.
248 - - `GET /analytics/activity-names` → `string[]`
249 - - `GET /analytics/status-counts?status=…`
250 - - `GET /analytics/report/download?layout&granularity&startDate&endDate` — PDF (binary).
251 - - `GET /analytics/report/history` — paginated record list (`seq`, `docNumber`, `layout`, `granularity`, `windowStart`, `windowEnd`, `generatedAt`, `s3Key`).
252 - - `GET /analytics/report/history/:recordId/download` — PDF (binary).
248 + | Method+Path | Query |
249 + |---|---|
250 + | `GET /analytics` | `layout` ✳️ (`detailed` default · `simplified`); `granularity` ✳️ (`daily` · `weekly` default · `monthly`); `search` ✳️ (≤200); `activityName` ✳️ (≤200, case-insensitive contains); `startDate`/`endDate` ✳️ |
251 + | `GET /analytics/activity-names` | — → `string[]` |
252 + | `GET /analytics/status-counts` | `status` ✳️ (default `IN_PROGRESS`): `INCOMPLETE_DATA`·`NEED_APPROVAL`·`IN_PROGRESS`·`NOT_STARTED`·`COMPLETED` |
253 + | `GET /analytics/report/download` | `granularity` ✳️ (`weekly` default · `monthly` · `yearly`); `search`, `activityName`, `startDate`, `endDate` ✳️ → PDF binary |
254 + | `GET /analytics/report/history` | `page` (1) · `limit` (20, max 100) · `startDate`/`endDate` ✳️ → records `{seq, docNumber, layout, granularity, windowStart, windowEnd, generatedAt, s3Key}` |
255 + | `GET /analytics/report/history/:recordId/download` | — → PDF binary |
253 256
254 - ## 9. Import & catalogs
257 + ## 12. Import & catalogs
255 258
256 - - `GET /import/template/download` — XLSX template (binary).
257 - - `POST /import` — multipart `file` (xlsx/xls/csv, ≤5 MB). Header: `Activity Name | Task Name | Subtask Name | Start Date | End Date | Work Weight | Work Volume | Work Volume Unit | Activity Type` (`-` = empty). Activity Type required; workVolume requires workVolumeUnit. → `{ imported, activities: [{id,name}] }`.
258 - - `GET/POST /work-volume-units` — POST body `{ "name"?, "symbol" }`.
259 - - `GET/POST /activity-types` — POST body `{ "name" }`.
259 + | Method+Path | Body / notes |
260 + |---|---|
261 + | `GET /import/template/download` | — XLSX template (binary) |
262 + | `POST /import` | multipart `file` (xlsx/xls/csv ≤5 MB). Header: `Activity Name \| Task Name \| Subtask Name \| Start Date \| End Date \| Work Weight \| Work Volume \| Work Volume Unit \| Activity Type` (`-` = empty). Activity Type required; workVolume requires workVolumeUnit → `{ imported, activities: [{id,name}] }` |
263 + | `GET /work-volume-units` | — |
264 + | `POST /work-volume-units` | `{ "name" }` ✳️ (≤100), `"symbol"` ✅ (1–50) |
265 + | `GET /activity-types` | — |
266 + | `POST /activity-types` | `{ "name" }` ✅ (1–100) |
260 267
261 268 ---
262 269
263 - ## Payload changes from BOD feature (summary)
270 + ## Payload changes from the BOD feature
264 271
265 - | Response | Added fields |
272 + | Context | Added |
266 273 |---|---|
267 - | Activity/Task/Subtask detail | `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused`, `bodApprovals[]`, `rollbackOptions[]` |
268 - | Tree nodes (tree/gantt/list) | `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused` |
269 - | Request `…/approval/respond` | `verb` extended with `ESCALATE`; new `bodUserIds[]` |
270 - | New endpoints | `…/approval/bod` (3 levels), `…/approval/bod/re-request` (3 levels) |
274 + | Item detail/list/tree | `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused` |
275 + | Item detail only | `bodApprovals[]`, `rollbackOptions[]` |
276 + | `…/approval/respond` | `verb` extended with `ESCALATE`; new `bodUserIds[]` |
277 + | New routes | `…/approval/bod` ×3 levels, `…/approval/bod/re-request` ×3 levels |
271 278
272 - ## BOD notifications (email/push)
279 + ## Notification copy (BOD)
273 280
274 - - BOD requested → assigned BODs — subject `Action Plan BOD Requested`.
275 - - BOD REVISE → VP — `Action Plan Rejected by BOD` — body `{ScopeLabel} {scopeName} in {projectName} was rejected by {bodName}. You must make decision` (+ ` Note: {reason}`).
276 - - BOD ROLLBACK → VP — `Action Plan Rolled Back by BOD` — `…was rolled back by {bodName}. Its prerequisite {prereqName} was re-opened for re-approval.` (+ note).
277 - - BOD APPROVE (all) → VP + admins + members — `Action Plan Approved by BOD` — `…approved by BOD. Handover complete.` (+ note).
281 + - BOD requested → each assigned BOD: subject *Action Plan BOD Requested*.
282 + - REVISE → VP: **Action Plan Rejected by BOD** — `{ScopeLabel} {scopeName} in {projectName} was rejected by {bodName}. You must make decision` (+ ` Note: {reason}`).
283 + - ROLLBACK → VP: **Action Plan Rolled Back by BOD** — `…was rolled back by {bodName}. Its prerequisite {prereqName} was re-opened for re-approval.` (+ note).
284 + - All-APPROVE → VP + admins + members: **Action Plan Approved by BOD** — `…was approved by BOD. Handover complete.` (+ note).

135dika revised this gist 5 days ago. Go to revision

1 file changed, 5 insertions, 4 deletions

action-plan.docs.md

@@ -60,10 +60,11 @@ Derived from progress rows + approval state. Display overrides applied (see BOD
60 60 1. **VP** (designated approver) responds to an item's approval: `APPROVE` | `REJECT` | `ESCALATE`. `ESCALATE` requires `bodUserIds` (≥1, max 50; each must be a user in the project's program agenda) → approvalStatus `BOD_APPROVAL`, a new `ActionPlanBodRounds` round created, notification sent to each BOD.
61 61 2. **Each assigned BOD** votes on the round: `APPROVE` | `REVISE` | `ROLLBACK` (reason required, 1–500 chars).
62 62 - One **rollback target lock** applies: once a BOD votes `ROLLBACK` to target T, all other BODs may only vote `ROLLBACK` to the same T (a different `rollbackId` → 400). `rollbackId` must be a **direct prerequisite** of the item.
63 - 3. **Resolution** when all assigned BODs have voted:
64 - - Any `REVISE` → REVISE wins (precedence: `REVISE > ROLLBACK > APPROVE`). Item → `WAITING_APPROVAL`, `bodResult = REJECTED_REVISE`, `bodReason` = earliest voter's reason; data & progress rows untouched; VP notified ("Action Plan Rejected by BOD").
65 - - All `ROLLBACK` to same target T → item → `WAITING_APPROVAL`, `bodResult = REJECTED_ROLLBACK`; T's and item's subtrees restart (leaves: last `APPROVED` progress → `PENDING`; each node → `WAITING_APPROVAL`; nodes whose prerequisites aren't met → `rollbackPaused` until the prerequisite closes). VP notified ("Action Plan Rolled Back by BOD").
66 - - All `APPROVE` → item `approvalStatus = CLOSED` (handover); VP + admins + action plan members notified ("Action Plan Approved by BOD").
63 + 3. **Resolution** when all assigned BODs have voted — precedence `ROLLBACK > REVISE > APPROVE`:
64 + - Any/all `ROLLBACK` votes to the same target T → ROLLBACK wins. Item → `WAITING_APPROVAL`, `bodResult = REJECTED_ROLLBACK`, `bodReason` = earliest voter's reason; T's and item's subtrees restart (leaves: last `APPROVED` progress → `PENDING`; each node → `WAITING_APPROVAL`; nodes whose prerequisites aren't met → `rollbackPaused` until the prerequisite closes). VP notified ("Action Plan Rolled Back by BOD").
65 + - Else any `REVISE` → REVISE wins. Item → `WAITING_APPROVAL`, `bodResult = REJECTED_REVISE`, `bodReason` = earliest voter's reason; data & progress rows untouched; VP notified ("Action Plan Rejected by BOD").
66 + - Else (all `APPROVE`) → item `approvalStatus = CLOSED` (handover); VP + admins + action plan members notified ("Action Plan Approved by BOD").
67 + - Different `ROLLBACK` targets across voters → round stays `ACTIVE`, item stays `BOD_APPROVAL`, no writes (defensive; the single-target lock normally prevents this).
67 68 4. **Re-escalation**: VP `ESCALATE` again after a BOD REVISE carries the previous BOD list over; may *add* members, may not remove/replace.
68 69 5. **VP `REJECT`** (non-escalated flow) does not reopen closed children wholesale: it walks descendants level by level (Activity → Tasks → Subtasks). Leaves that are startable (no unmet live prerequisite) reopen → `NEED_APPROVAL`/`PENDING`; leaves blocked by an unmet prerequisite pause with `rollbackPaused = true` and resume automatically when the prerequisite reaches `CLOSED` (`.releaseRollbackPausedDependents`).
69 70 6. **Delete guard**: an item whose effective progress ≥ 100% (derived `COMPLETED`) or `approvalStatus = CLOSED` (VP- or BOD-approved) cannot be deleted — including as a descendant of a parent delete (`400` naming the offending item).

135dika revised this gist 5 days ago. Go to revision

1 file changed, 204 insertions, 281 deletions

action-plan.docs.md

@@ -1,353 +1,276 @@
1 - # Action Plans API
1 + # API — Action Plans
2 2
3 - Base URL: `{host}/api/v1`
3 + Action plan module for a project: a three-level hierarchy **Activity → Task → Subtask**, each with progress entries, approvers, prerequisites, members, and a BOD (Board of Directors) escalation flow.
4 4
5 - All responses wrapped in:
6 - ```json
7 - { "statusCode": 200, "message": "...", "data": { ... }, "durationMs": 42, "_reference": [] }
8 - ```
5 + ## General
9 6
10 - ---
7 + - **Base URL**: `/api/v1/projects/:projectId/action-plans` (versioned controller, v1)
8 + - **Auth**: JWT via `@Authorize({ membership: true })` — caller must be a member of `:projectId`.
9 + - **Response envelope** (`ResponseInterceptor`): responses are wrapped as `{ status, message, data }` where `data` is the payload documented below (endpoints returning raw files — PDF/XLSX — skip the envelope).
10 + - **Access tiers** (`ProjectActionPlanAccessGuard`):
11 + - `INCOMPLETE_DATA` — not accessible.
12 + - `NOT_STARTED` / `IN_PROGRESS` — full access.
13 + - `NEED_APPROVAL` / `COMPLETED` / `REJECTED` — read-only for management writes (create/update/delete), **but approval respond/re-request and BOD routes stay writable** (project closes only after approvals resolve).
14 + - **Progress input** (submit/respond/feedback) additionally requires project status `IN_PROGRESS` (`ProjectInProgressGuard`); approval routes intentionally bypass it.
11 15
12 - ## Hierarchy & Member Counting
16 + ## Status enums
13 17
14 - ```
15 - Activity
16 - └─ Task (many)
17 - └─ Subtask (many)
18 - ```
18 + ### Derived status (item `status` field)
19 19
20 - | Level | Has children? | Member count source |
21 - |---|---|---|
22 - | Activity | Tasks with subtasks | Union of all subtask members |
23 - | Activity | Tasks without subtasks | Union of all task members |
24 - | Activity | No tasks | Activity's own members |
25 - | Task | Has subtasks | Union of all subtask members |
26 - | Task | No subtasks | Task's own members |
27 - | Subtask | — | Subtask's own members |
28 -
29 - Example:
30 - ```
31 - Activity1 (10 members) ← Union(Task1, Task2)
32 - Task1 (5 members) ← Union(Subtask1, Subtask2)
33 - Subtask1 (3 members)
34 - Subtask2 (2 members)
35 - Task2 (5 members) ← Task scope, no subtasks
36 - ```
20 + Derived from progress rows + approval state. Display overrides applied (see BOD display):
37 21
38 - ---
22 + | Value | Meaning |
23 + |---|---|
24 + | `INCOMPLETE_DATA` | Leaf has no dates/weight data |
25 + | `NEED_APPROVAL` | Progress submitted, awaiting approval |
26 + | `IN_PROGRESS` | Has approved progress, not yet complete |
27 + | `NOT_STARTED` | No active progress, or paused/rolled back (display override) |
28 + | `COMPLETED` | Effective progress ≥ 100% |
39 29
40 - ## Leaf-Only Validation
30 + ### ActionPlanApprovalStatus (`approvalStatus`)
41 31
42 - Nodes with children reject direct progress/plans/members operations.
32 + `WAITING_APPROVAL` (item awaiting/queued for a new approval cycle) · `CLOSED` (approved) · `REJECTED` (rejected by VP) · `BOD_APPROVAL` (escalated, awaiting BOD board decision)
43 33
44 - | Operation | Activity has tasks? | Task has subtasks? |
45 - |---|---|---|
46 - | Progress | ❌ 400 | ❌ 400 |
47 - | Plans | ❌ 400 | ❌ 400 |
48 - | Members | ❌ 400 | ❌ 400 |
34 + ### ActionPlanBodResult (`bodResult`) — aggregate outcome of latest BOD round
49 35
50 - Subtask always valid.
36 + | Value | Meaning |
37 + |---|---|
38 + | `REJECTED_REVISE` | BOD voted REVISE → item back to VP (`approvalStatus` → `WAITING_APPROVAL`) |
39 + | `REJECTED_ROLLBACK` | BOD voted ROLLBACK → item restarting from a prerequisite |
51 40
52 - ---
41 + ### ActionPlanBodDisplayStatus (`bodDisplayStatus`) — chip shown to the user
53 42
54 - ## Route Map
43 + | Value | Rule |
44 + |---|---|
45 + | `BOD_APPROVAL` | while item is escalated (approvalStatus `BOD_APPROVAL`) |
46 + | `REJECTED_BY_BOD` | `bodResult === REJECTED_REVISE` |
47 + | `NOT_STARTED` | `bodResult === REJECTED_ROLLBACK` **or** `rollbackPaused === true` (no "Rolled Back by BOD" chip) |
48 + | `null` | no BOD state |
55 49
56 - All routes under: `api/projects/:projectId/action-plans`
50 + ### ActionPlanProgressStatus (progress rows)
57 51
58 - ```
59 - GET / list activities
60 - POST / create activity
61 - GET /tree tree view
62 - GET /tree/gantt gantt
63 - GET /analytics analytics
64 - GET /import/template/download excel template
65 - POST /import import
66 - GET /:activityId get activity
67 - PATCH /:activityId update activity
68 - DELETE /:activityId delete activity
69 -
70 - // Activity leaf
71 - POST /:activityId/progress create progress
72 - GET /:activityId/progress list progress
73 - GET /:activityId/progress/:progressId get progress
74 - POST /:activityId/progress/:progressId/respond respond
75 - GET /:activityId/plans plans (S-curve)
76 - PUT /:activityId/plans set plans
77 - POST /:activityId/members add member
78 - DELETE /:activityId/members/:userId remove member
79 - GET /:activityId/members list members
80 -
81 - // Activity progress feedbacks
82 - POST /:activityId/progress/:pid/feedbacks create feedback
83 - GET /:activityId/progress/:pid/feedbacks list feedbacks
84 - PATCH /:activityId/progress/:pid/feedbacks/:fid edit feedback
85 - DELETE /:activityId/progress/:pid/feedbacks/:fid delete feedback
86 -
87 - // Task CRUD + leaf
88 - POST /:activityId/tasks create task
89 - GET /:activityId/tasks list tasks
90 - GET /:activityId/tasks/:taskId get task
91 - PATCH /:activityId/tasks/:taskId update task
92 - DELETE /:activityId/tasks/:taskId delete task
93 - POST /:activityId/tasks/:taskId/progress task progress
94 - GET /:activityId/tasks/:taskId/progress list
95 - GET /:activityId/tasks/:taskId/progress/:pid get
96 - POST /:activityId/tasks/:taskId/progress/:pid/respond
97 - GET /:activityId/tasks/:taskId/plans plans
98 - PUT /:activityId/tasks/:taskId/plans
99 - POST /:activityId/tasks/:taskId/members add member
100 - DELETE /:activityId/tasks/:taskId/members/:uid remove member
101 - GET /:activityId/tasks/:taskId/members list members
102 -
103 - // Task progress feedbacks
104 - POST /:activityId/tasks/:tid/progress/:pid/feedbacks
105 - GET /:activityId/tasks/:tid/progress/:pid/feedbacks
106 - PATCH /:activityId/tasks/:tid/progress/:pid/feedbacks/:fid
107 - DELETE /:activityId/tasks/:tid/progress/:pid/feedbacks/:fid
108 -
109 - // Subtask CRUD + leaf
110 - POST /:activityId/tasks/:taskId/subtasks create subtask
111 - GET /:activityId/tasks/:taskId/subtasks list subtasks
112 - GET /:activityId/tasks/:taskId/subtasks/:subtaskId
113 - PATCH /:activityId/tasks/:taskId/subtasks/:subtaskId
114 - DELETE /:activityId/tasks/:taskId/subtasks/:subtaskId
115 - POST /:activityId/tasks/:taskId/subtasks/:sid/members
116 - DELETE /:activityId/tasks/:taskId/subtasks/:sid/members/:uid
117 - GET /:activityId/tasks/:taskId/subtasks/:sid/members
118 - GET /:activityId/tasks/:taskId/subtasks/:sid/plans
119 - PUT /:activityId/tasks/:taskId/subtasks/:sid/plans
120 - POST /:activityId/tasks/:taskId/subtasks/:sid/progress
121 - GET /:activityId/tasks/:taskId/subtasks/:sid/progress
122 - GET /:activityId/tasks/:taskId/subtasks/:sid/progress/:pid
123 - POST /:activityId/tasks/:taskId/subtasks/:sid/progress/:pid/respond
124 -
125 - // Subtask progress feedbacks
126 - POST /:aId/tasks/:tId/subtasks/:sId/progress/:pId/feedbacks
127 - GET /.../feedbacks
128 - PATCH /.../feedbacks/:fId
129 - DELETE /.../feedbacks/:fId
130 - ```
52 + `PENDING` · `APPROVED` · `APPROVED_WITH_NOTES` · `REJECTED`
53 +
54 + ### Other booleans
55 +
56 + - `rollbackPaused`: item halted waiting for a prerequisite to close; reads as `NOT_STARTED`.
57 +
58 + ## BOD escalation model
59 +
60 + 1. **VP** (designated approver) responds to an item's approval: `APPROVE` | `REJECT` | `ESCALATE`. `ESCALATE` requires `bodUserIds` (≥1, max 50; each must be a user in the project's program agenda) → approvalStatus `BOD_APPROVAL`, a new `ActionPlanBodRounds` round created, notification sent to each BOD.
61 + 2. **Each assigned BOD** votes on the round: `APPROVE` | `REVISE` | `ROLLBACK` (reason required, 1–500 chars).
62 + - One **rollback target lock** applies: once a BOD votes `ROLLBACK` to target T, all other BODs may only vote `ROLLBACK` to the same T (a different `rollbackId` → 400). `rollbackId` must be a **direct prerequisite** of the item.
63 + 3. **Resolution** when all assigned BODs have voted:
64 + - Any `REVISE` → REVISE wins (precedence: `REVISE > ROLLBACK > APPROVE`). Item → `WAITING_APPROVAL`, `bodResult = REJECTED_REVISE`, `bodReason` = earliest voter's reason; data & progress rows untouched; VP notified ("Action Plan Rejected by BOD").
65 + - All `ROLLBACK` to same target T → item → `WAITING_APPROVAL`, `bodResult = REJECTED_ROLLBACK`; T's and item's subtrees restart (leaves: last `APPROVED` progress → `PENDING`; each node → `WAITING_APPROVAL`; nodes whose prerequisites aren't met → `rollbackPaused` until the prerequisite closes). VP notified ("Action Plan Rolled Back by BOD").
66 + - All `APPROVE` → item `approvalStatus = CLOSED` (handover); VP + admins + action plan members notified ("Action Plan Approved by BOD").
67 + 4. **Re-escalation**: VP `ESCALATE` again after a BOD REVISE carries the previous BOD list over; may *add* members, may not remove/replace.
68 + 5. **VP `REJECT`** (non-escalated flow) does not reopen closed children wholesale: it walks descendants level by level (Activity → Tasks → Subtasks). Leaves that are startable (no unmet live prerequisite) reopen → `NEED_APPROVAL`/`PENDING`; leaves blocked by an unmet prerequisite pause with `rollbackPaused = true` and resume automatically when the prerequisite reaches `CLOSED` (`.releaseRollbackPausedDependents`).
69 + 6. **Delete guard**: an item whose effective progress ≥ 100% (derived `COMPLETED`) or `approvalStatus = CLOSED` (VP- or BOD-approved) cannot be deleted — including as a descendant of a parent delete (`400` naming the offending item).
131 70
132 71 ---
133 72
134 - ## Enums
73 + ## 1. Activities
135 74
136 - ### Subtask/Task/Activity Status
137 - `INCOMPLETE_DATA` | `NOT_STARTED` | `IN_PROGRESS` | `NEED_APPROVAL` | `COMPLETED`
75 + ### `POST /` — create activity
138 76
139 - ### Progress Entry Status
140 - `PENDING` | `APPROVED` | `APPROVED_WITH_NOTES` | `REJECTED`
77 + ```json
78 + {
79 + "name": "string (required)",
80 + "activityType": "string (required)",
81 + "workWeight": "number (optional)",
82 + "workVolume": "number (optional, requires workVolumeUnit)",
83 + "workVolumeUnit": "string (optional)",
84 + "startDate": "ISO date (optional)",
85 + "endDate": "ISO date (optional)",
86 + "approverId": "uuid (optional)",
87 + "prerequisiteIds": ["uuid (optional)"],
88 + "tasks": [{ "name": "string", "workWeight", "workVolume", "workVolumeUnit", "startDate", "endDate", "subtasks": [{ "name", "workWeight", "workVolume", "workVolumeUnit", "startDate", "endDate" }] }]
89 + }
90 + ```
141 91
142 - ### ActionPlanMemberScope
143 - `ACTIVITY` | `TASK` | `SUBTASK`
92 + Response includes the item fields below (§ Item payload) plus: `taskCount`, `subtaskCount`, `memberCount`, `progress`, `pendingProgress`, `weightlessLeaves`, `predecessorCount`-style derived stats, and `status`.
144 93
145 - ---
94 + ### `POST /bulk` — bulk create `{ "activities": [<CreateActivityPayload>…] }` → `{ created, activities: [{id,name}] }`
146 95
147 - ## Authorization
96 + ### `GET /` — list (paginated)
148 97
149 - Two-layer authorization at the controller level.
98 + Query: `page`, `limit`, `search?`, `sort_by` (`created_at` default | `start_date` | `end_date` | `start_date_end_date`), `sort_order` (`asc`|`desc`)
150 99
151 - ### Guards
100 + ### `GET /tree` — hierarchical tree
152 101
153 - | Guard | Applies to | Check |
154 - |---|---|---|
155 - | `AccessControlGuard` via `@Authorize({ membership: true })` | All routes | Token validation + workspace membership via `resolveAccess()` gRPC |
156 - | `ProjectInProgressGuard` | All routes | Project status must be `IN_PROGRESS` |
102 + Returns `Activity[]` each with nested `tasks[].subtasks[]`; every level carries the item fields + `accumulatedProgress`, `pendingProgress`, `workWeight`, `workVolume`, `totalUsersAssigned`, `latestProgressStatus`.
157 103
158 - `@Authorize({ membership: true })` runs `AccessControlGuard` which:
159 - 1. Validates bearer token via AuthService gRPC
160 - 2. Resolves scope from `:projectId` route param
161 - 3. Calls `workspaceAccess.resolveAccess()` — checks user has any membership (MEMBER/OWNER/ADMIN) in the workspace/project hierarchy
162 - 4. Stashes validated session so `@CurrentUser()` reuses it without extra gRPC
104 + Query: `status?`, `startDate?`, `endDate?`, `search?`, `sort_by?`, `sort_order?`
163 105
164 - ### Hierarchy Scope
106 + ### `GET /tree/gantt` — same tree with `ganttStatus` per node (`LATE` | `ON_TRACK` | `BASELINE` | …)
165 107
166 - `resolveAccess()` checks membership across the full hierarchy:
108 + ### `GET /:activityId` — detail (§ Item payload)
167 109
110 + ### `PATCH /:activityId` — update
111 +
112 + ```json
113 + { "name"?, "activityType"?, "workWeight"?, "workVolume"?, "workVolumeUnit"?, "startDate"?, "endDate"?, "approverId"?, "prerequisiteIds"? }
168 114 ```
169 - AGENDA
170 - └─ PROGRAM
171 - └─ PROJECT ← projectId from route param
172 - ```
173 115
174 - A user with membership at any ancestor level (WORKSPACE/PROGRAM/AGENDA) can access child projects.
116 + ### `DELETE /:activityId` — soft delete. **Blocked (400)** if item or any live descendant is complete (progress ≥ 100%) or `approvalStatus = CLOSED`.
117 +
118 + ### `POST /bulk` (DELETE) — `DELETE /bulk` bulk delete
175 119
176 - ### Rejection Response — `403 Forbidden`:
177 120 ```json
178 - { "statusCode": 403, "message": "Access denied", "denial": { "type": "...", "details": {...} } }
121 + { "activityIds": [], "taskIds": [], "subtaskIds": [] }
179 122 ```
123 + → `{ deleted }`. Parent ids cascade to descendants; same complete/CLOSED guard applies.
180 124
181 - ### Write Operation — Role Check
125 + ---
182 126
183 - Write operations have an additional inline role check via `validateWorkspaceRole` helper in `action-plans.helpers.ts`. This queries `workspace_member_scopes` directly to enforce exact role requirements (OWNER/ADMIN for admin ops, MEMBER+ for member ops).
127 + ## 2. Approval — per item level
184 128
185 - ### Operation → Role Mapping
129 + Same three routes per level, substituting `:activityId` / `:activityId/tasks/:taskId` / `:activityId/tasks/:taskId/subtasks/:subtaskId`:
186 130
187 - | Operation | Required Role |
188 - |---|---|
189 - | Read (GET any route) | Any workspace member (via guard) |
190 - | Create activity/task/subtask | `OWNER` / `ADMIN` |
191 - | Update activity/task/subtask | `OWNER` / `ADMIN` |
192 - | Delete activity/task/subtask | `OWNER` / `ADMIN` |
193 - | Set plans | `OWNER` / `ADMIN` |
194 - | Add/remove member (any scope) | `OWNER` / `ADMIN` |
195 - | Respond progress (approve/reject) | `OWNER` / `ADMIN` |
196 - | Respond edit request | `OWNER` / `ADMIN` |
197 - | Submit progress | `MEMBER` / `OWNER` / `ADMIN` + `actionPlanMembers` check |
198 - | Create edit request | `MEMBER` / `OWNER` / `ADMIN` + `actionPlanMembers` check |
199 -
200 - ### Rejection Responses
201 -
202 - | Status | Message | Cause |
203 - |---|---|---|
204 - | `401` | `Authorization token required` | Missing/invalid bearer token |
205 - | `403` | `Access denied` (with `denial` payload) | Not a workspace member (AccessControlGuard) |
206 - | `403` | `Action plan is only available for projects with IN_PROGRESS status` | Project status not IN_PROGRESS |
207 - | `403` | `You do not have the required role to perform this action` | Insufficient role for write op |
208 - | `400` | `You are not assigned to this scope...` | Not in `actionPlanMembers` (member ops) |
131 + ### `POST …/approval/respond` — VP (designated approver) responds
209 132
210 - ### Double-Check for Member Operations
133 + Body (`ApprovalRespondDto`):
211 134
212 - Member operations (`createProgress`, `createEditRequest`) have two-tier write validation:
213 - 1. **Tier 1** — `validateWorkspaceRole`: user must have active workspace membership with required role
214 - 2. **Tier 2** — `validateScopeMember`: user must be assigned to the specific scope via `actionPlanMembers`
135 + ```json
136 + {
137 + "verb": "APPROVE | REJECT | ESCALATE",
138 + "reason": "string ≤500 chars (optional)",
139 + "bodUserIds": ["uuid…"] // required when verb = ESCALATE; 1–50
140 + }
141 + ```
215 142
216 - ---
143 + This is a **single 2-step form** in the UI: step 1 picks the verb (`APPROVE`/`REJECT`/`ESCALATE`); when `ESCALATE` is chosen, step 2 picks the BOD members (`bodUserIds`). No separate "escalation" endpoint exists.
217 144
218 - ## Notifications
145 + Effects:
146 + - `APPROVE` → `CLOSED` (item + descendants already closed).
147 + - `REJECT` → `REJECTED`; descendants reopened per the chain rule (§ BOD model point 5).
148 + - `ESCALATE` → `BOD_APPROVAL`; new BOD round; BODs notified.
219 149
220 - Fire-and-forget. Does not block operation on failure. All events are **bidirectional** — both actor (pelaku) and target (korban) receive the notification for audit trail.
150 + ### `POST …/approval/re-request` — creator re-requests approval (only when `REJECTED` / `WAITING_APPROVAL`)
221 151
222 - ### Event Keys
152 + Body: none.
223 153
224 - | Event Key | Category | Description |
225 - |---|---|---|
226 - | `action_plan.member.added` | Targeted (actor + target) | Member assigned to scope |
227 - | `action_plan.member.removed` | Targeted (actor + target) | Member removed from scope |
228 - | `action_plan.progress.submitted` | Broadcast (all approvers) | Member submits progress |
229 - | `action_plan.progress.approved` | Targeted (actor + target) | Admin approves progress |
230 - | `action_plan.progress.rejected` | Targeted (actor + target) | Admin rejects progress |
231 - | `action_plan.edit_request.requested` | Broadcast (all approvers) | Member requests edit |
232 - | `action_plan.edit_request.responded` | Targeted (actor + target) | Admin responds to edit request |
154 + ### `POST …/approval/bod` — BOD member votes **(new)**
233 155
234 - ### Recipient Resolution
156 + Body (`BodRespondDto`):
235 157
236 - **Broadcast events** (`progress.submitted`, `edit_request.requested`):
237 - Resolve all `OWNER`/`ADMIN` users from `workspace_member_scopes` across 4-level hierarchy (PROJECT → WORKSPACE → PROGRAM → AGENDA). Plus the submitter for audit trail.
158 + ```json
159 + {
160 + "decision": "APPROVE | REVISE | ROLLBACK",
161 + "reason": "string, 1–500 chars (REQUIRED)",
162 + "rollbackId": "uuid (required when decision = ROLLBACK; must be a direct prerequisite of the item)"
163 + }
164 + ```
238 165
239 - **Targeted events** (all others):
240 - Notify the specific actor + the specific target user only. No broadcast to hierarchy owners.
166 + Preconditions (400/403 otherwise): item `approvalStatus = BOD_APPROVAL`; caller is an assigned BOD of the active round with no prior vote; not already resolved.
241 167
242 - ### Notification Recipients by Trigger
168 + ### `POST …/approval/bod/re-request` — VP re-requests BOD round **(new)**
243 169
244 - | Trigger | Recipients |
245 - |---|---|
246 - | Submit progress | Submitter + all OWNER/ADMIN in hierarchy |
247 - | Approve/reject progress | Submitter + approving admin |
248 - | Submit edit request | All OWNER/ADMIN in hierarchy |
249 - | Respond edit request | Requester + responding admin |
250 - | Add member | Added user + admin who added |
251 - | Remove member | Removed user + admin who removed |
170 + Body: none. Re-creates the escalation after a BOD REVISE (VP cycle back); carries previous BOD list (add-only on next `ESCALATE`).
252 171
253 172 ---
254 173
255 - ## Request/Response Schemas
174 + ## 3. Item payload (activity/task/subtask detail & tree nodes)
256 175
257 - ### Create Activity
258 - `POST /`
259 - ```jsonc
260 - {
261 - "name": "Activity name",
262 - "tasks": [{ "name": "Task name", "workWeight": 100, "workVolume": 500, "workVolumeUnit": "m2", "startDate": "2026-01-01", "endDate": "2026-03-31",
263 - "subtasks": [{ "name": "Subtask name", "workVolume": 250, "workVolumeUnit": "m2", "workWeight": 50, "startDate": "2026-01-01", "endDate": "2026-02-15" }]
264 - }]
265 - }
266 - ```
176 + BOD-related fields on every item (detail, list, tree):
267 177
268 - ### List Activities
269 - `GET /` — Query: `page`, `limit`, `search`, `sort_by` (`created_at`/`updated_at`/`name`), `sort_order` (`asc`/`desc`)
270 - Response: `{ items: [...], total, page, limit }`
178 + | Field | Type | Notes |
179 + |---|---|---|
180 + | `approvalStatus` | enum | `WAITING_APPROVAL` | `CLOSED` | `REJECTED` | `BOD_APPROVAL` |
181 + | `approvalReason` | string? | VP response reason |
182 + | `bodRoundId` | string? | latest BOD round id |
183 + | `bodResult` | enum? | `REJECTED_REVISE` | `REJECTED_ROLLBACK` |
184 + | `bodReason` | string? | aggregate round reason (earliest voter) |
185 + | `bodDisplayStatus` | enum? | `BOD_APPROVAL` | `REJECTED_BY_BOD` | `NOT_STARTED` | null |
186 + | `rollbackPaused` | boolean | paused awaiting prerequisite close |
187 + | `approverId` / `approverName` / `approverEmail` | string? | designated VP approver |
188 + | `prerequisites` / `prerequisiteIds` | [] | direct prerequisites (same-scope level) |
189 +
190 + **Detail-only additions** (`GET /:activityId`, `GET …/tasks/:taskId`, `GET …/subtasks/:subtaskId`):
271 191
272 - ### Get Single Activity
273 - `GET /:id`
274 - ```jsonc
275 - { "id":"uuid", "projectId":"uuid", "name":"...", "taskCount":2, "subtaskCount":5, "memberCount":10, "status":"IN_PROGRESS", "progress":45.5, "workWeight":150 }
192 + ```json
193 + "bodApprovals": [
194 + {
195 + "userId": "uuid",
196 + "name": "string|null",
197 + "decision": "APPROVE | REVISE | ROLLBACK|null",
198 + "reason": "string|null",
199 + "rollbackItemId": "uuid|null",
200 + "rollbackTargetName": "string|null",
201 + "respondedAt": "ISO date"
202 + }
203 + ],
204 + "rollbackOptions": [ { "id": "uuid", "name": "string" } ] // direct prereqs; after first ROLLBACK vote, locked to that single target
276 205 ```
277 206
278 - ### Tree / Gantt
279 - `GET /tree` — Query: `status` filter. Each node has `totalUsersAssigned`, `accumulatedProgress`.
280 - `GET /tree/gantt` — Same + derived `startDate`/`endDate`.
207 + ## 4. Tasks & Subtasks
281 208
282 - ### Progress Entry
283 - Response shape (all scopes):
284 - ```jsonc
285 - {
286 - "id":"uuid", "scopeType":"SUBTASK|TASK|ACTIVITY", "scopeId":"uuid", "scopeName":"name",
287 - "progress":75, "workVolume":187.5, "scopeWorkVolume":250, "scopeWorkVolumeUnit":"m2",
288 - "description":"...", "status":"APPROVED", "amendsId":null, "amendsOriginal":null,
289 - "amendedByCount":0, "amendedBy":[], "approvedBy":"uuid", "approvedByName":"Admin",
290 - "approvedAt":"ISO", "createdBy":"uuid", "createdByName":"Worker",
291 - "images":[{ "id":"uuid","key":"s3/path","originalName":"photo.jpg","url":"presigned-url" }],
292 - "sitemap":{ "id":"uuid","key":"s3/path","originalName":"site.pdf","url":"presigned-url" }
293 - }
294 - ```
209 + Same shape as activities under the nesting paths:
295 210
296 - ### Progress Create (multipart/form-data)
297 - `POST /:../progress`
298 - | Field | Type |
299 - |---|---|
300 - | `progress` | number (0-100) |
301 - | `workVolume` | number, optional |
302 - | `description` | string, max 5000 |
303 - | `images` | file[], max 10 |
304 - | `sitemap` | file, PDF |
305 -
306 - ### Progress Respond
307 - `POST /:../progress/:id/respond`
308 - ```jsonc
309 - { "verb":"APPROVE|APPROVE_WITH_NOTES|REJECT", "progress":80, "workVolume":200, "description":"Adjusted" }
310 - ```
211 + - `POST :activityId/tasks` / `GET :activityId/tasks` / `GET/PATCH/DELETE :activityId/tasks/:taskId` + progress/plans/members/edit-requests/approval routes.
212 + - `POST :activityId/tasks/:taskId/subtasks` / `GET` list / `GET/PATCH/DELETE :activityId/tasks/:taskId/subtasks/:subtaskId` + same sub-routes.
213 +
214 + Member assignment (`…/members` POST/DELETE) body: `{ "userIds": ["uuid…"] }`; bulk variants under `/members/bulk` (POST assign / DELETE remove): `{ "activityIds": [], "taskIds": [], "subtaskIds": [], "userIds": [] }` → `{ assigned, skipped }` / `{ removed }`.
311 215
312 - ### Plans (S-Curve)
313 - `GET /:../plans`
314 - ```jsonc
315 - { "duration":{ "days":90, "startDate":"...", "endDate":"..." }, "planning":[...], "sCurve":{ "plan":[...], "actual":[...] } }
216 + ## 5. Progress (leaves: activity/task/subtask)
217 +
218 + - `POST :itemId/progress` (multipart): fields `progress`, `workVolume?`, `description?`, `documentSource` (`upload`|`document`), `documentRef?`, `companyId?`, `createdAt?`; files `attachments[]` (jpg/png/gif/webp/svg/pdf) and `document` (×1). → progress row payload (see below).
219 + - `GET :itemId/progress` — paginated (`page`, `limit`).
220 + - `GET :itemId/progress/:progressId` — single row.
221 + - `POST :itemId/progress/:progressId/respond` — body:
222 +
223 + ```json
224 + { "verb": "APPROVE | APPROVE_WITH_NOTES | REJECT", "progress"?, "workVolume"?, "description"? }
316 225 ```
317 - `PUT /:../plans` — `{ "plans":[{ "startDate":"...", "endDate":"...", "targetProgress":50 }] }`
318 226
319 - ### Members
320 - `POST/DELETE/GET /:../members` — `{ "userId":"uuid" }` / response: `[{ "id":"uuid","userId":"uuid","displayName":"John Doe" }]`
227 + Progress row payload: `id, scopeType, scopeId, scopeName, progress, workVolume, scopeWorkVolume, scopeWorkVolumeUnit, description, status, amendsId, amendsOriginal, amendedByCount, amendedBy[], approvedBy, approvedByName, approvedAt, createdBy, createdByName, updatedBy, updatedByName, createdAt, updatedAt, attachments[{id,key,url}], document`.
228 +
229 + ### Feedback (per progress row)
321 230
322 - ### Feedbacks
323 - `POST /:../feedbacks` — `{ "message":"text" }`
324 - `PATCH /:../feedbacks/:id` — `{ "message":"updated" }`
231 + `POST/GET :itemId/progress/:progressId/feedbacks`, `PATCH/DELETE …/feedbacks/:feedbackId`. Body `{ "message": "string" }` (≤500).
325 232
326 - ### Analytics
327 - `GET /analytics` — Query: `layout` (`detailed`/`simplified`), `granularity` (`daily`/`weekly`/`monthly`), `search`, `startDate`, `endDate`
233 + ## 6. Plans / S-curve (per level)
328 234
329 - ### Import
330 - `GET /import/template/download` — Excel file
331 - `POST /import` (multipart) — `file` (.xlsx/.xls/.csv, max 5MB)
235 + - `GET :itemId/plans` → `{ duration: {days,startDate,endDate}, planning: [{id?,startDate,endDate,targetProgress}], sCurve: { plan: [{date,progress}], actual: [{date,progress,id}] } }`
236 + - `PUT :itemId/plans` body: `{ "plans": [{ "startDate": "ISO", "endDate": "ISO", "targetProgress": 0–100 }] }`
237 +
238 + ## 7. Edit requests (members → owner/admin)
239 +
240 + - `POST/GET :itemId/edit-requests`, project-level `GET /edit-requests`, `POST /edit-requests/:requestId/respond` body `{ "verb": "APPROVE | REJECT" }`.
241 + - Create body: `{ "field": "endDate | workWeight | workVolume | workVolumeUnit", "newValue": …, "reason": "string ≤500" }`.
242 + - List returns `requestedData`, `oldData`, `reason`, `status`, requester/responder names + timestamps.
243 +
244 + ## 8. Analytics & reports
245 +
246 + - `GET /analytics` — query: `layout`, `granularity`, `search?`, `activityName?`, `startDate?`, `endDate?`.
247 + - `GET /analytics/activity-names` → `string[]`
248 + - `GET /analytics/status-counts?status=…`
249 + - `GET /analytics/report/download?layout&granularity&startDate&endDate` — PDF (binary).
250 + - `GET /analytics/report/history` — paginated record list (`seq`, `docNumber`, `layout`, `granularity`, `windowStart`, `windowEnd`, `generatedAt`, `s3Key`).
251 + - `GET /analytics/report/history/:recordId/download` — PDF (binary).
252 +
253 + ## 9. Import & catalogs
254 +
255 + - `GET /import/template/download` — XLSX template (binary).
256 + - `POST /import` — multipart `file` (xlsx/xls/csv, ≤5 MB). Header: `Activity Name | Task Name | Subtask Name | Start Date | End Date | Work Weight | Work Volume | Work Volume Unit | Activity Type` (`-` = empty). Activity Type required; workVolume requires workVolumeUnit. → `{ imported, activities: [{id,name}] }`.
257 + - `GET/POST /work-volume-units` — POST body `{ "name"?, "symbol" }`.
258 + - `GET/POST /activity-types` — POST body `{ "name" }`.
332 259
333 260 ---
334 261
335 - ## Constraint Summary
262 + ## Payload changes from BOD feature (summary)
336 263
337 - | Constraint | Value |
264 + | Response | Added fields |
338 265 |---|---|
339 - | Max tasks per activity | 50 |
340 - | Max subtasks per task | 50 |
341 - | Max images per progress | 10 |
342 - | Max feedback/description length | 5000 chars |
343 - | Max import file size | 5MB |
344 - | Progress range | 0-100 |
345 - | Work weight/volume | Number >= 0 |
346 -
347 - ### Editing Rules
348 - - Activity with tasks → only `name` editable
349 - - Task with subtasks → only `name` editable
350 - - Subtask in progress → `workWeight`/`workVolume` locked
351 -
352 - ### Soft Delete
353 - All deletes are soft (`deletedAt`). Cascades: deleting activity → all tasks + subtasks. Deleting task → all subtasks.
266 + | Activity/Task/Subtask detail | `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused`, `bodApprovals[]`, `rollbackOptions[]` |
267 + | Tree nodes (tree/gantt/list) | `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused` |
268 + | Request `…/approval/respond` | `verb` extended with `ESCALATE`; new `bodUserIds[]` |
269 + | New endpoints | `…/approval/bod` (3 levels), `…/approval/bod/re-request` (3 levels) |
270 +
271 + ## BOD notifications (email/push)
272 +
273 + - BOD requested → assigned BODs — subject `Action Plan BOD Requested`.
274 + - BOD REVISE → VP — `Action Plan Rejected by BOD` — body `{ScopeLabel} {scopeName} in {projectName} was rejected by {bodName}. You must make decision` (+ ` Note: {reason}`).
275 + - BOD ROLLBACK → VP — `Action Plan Rolled Back by BOD` — `…was rolled back by {bodName}. Its prerequisite {prereqName} was re-opened for re-approval.` (+ note).
276 + - BOD APPROVE (all) → VP + admins + members — `Action Plan Approved by BOD` — `…approved by BOD. Handover complete.` (+ note).

135dika revised this gist 1 month ago. Go to revision

1 file changed, 17 insertions, 15 deletions

action-plan.docs.md

@@ -146,20 +146,24 @@ DELETE /.../feedbacks/:fId
146 146
147 147 ## Authorization
148 148
149 - Two-layer authorization at the controller level via guards.
149 + Two-layer authorization at the controller level.
150 150
151 151 ### Guards
152 152
153 153 | Guard | Applies to | Check |
154 154 |---|---|---|
155 + | `AccessControlGuard` via `@Authorize({ membership: true })` | All routes | Token validation + workspace membership via `resolveAccess()` gRPC |
155 156 | `ProjectInProgressGuard` | All routes | Project status must be `IN_PROGRESS` |
156 - | `WorkspaceAccessGuard` | All routes | User must have active workspace membership for this project |
157 157
158 - Guards run before any route handler. Authentication is handled upstream by `AccessControlGuard` (validates Bearer token).
158 + `@Authorize({ membership: true })` runs `AccessControlGuard` which:
159 + 1. Validates bearer token via AuthService gRPC
160 + 2. Resolves scope from `:projectId` route param
161 + 3. Calls `workspaceAccess.resolveAccess()` — checks user has any membership (MEMBER/OWNER/ADMIN) in the workspace/project hierarchy
162 + 4. Stashes validated session so `@CurrentUser()` reuses it without extra gRPC
159 163
160 - ### WorkspaceAccessGuard — How It Works
164 + ### Hierarchy Scope
161 165
162 - Every action-plan request requires the user to have an `active` row in `workspace_member_scopes` for the project's hierarchy. The guard resolves the hierarchy from `:projectId`:
166 + `resolveAccess()` checks membership across the full hierarchy:
163 167
164 168 ```
165 169 AGENDA
@@ -167,25 +171,22 @@ AGENDA
167 171 └─ PROJECT ← projectId from route param
168 172 ```
169 173
170 - User passes if they have at least one matching row with:
171 - - `status = 'active'`
172 - - `role IN (MEMBER, OWNER, ADMIN)`
173 - - One of: `scope_type=PROJECT, scope_id=projectId` OR `scope_type=WORKSPACE` OR `scope_type=PROGRAM` OR `scope_type=AGENDA`
174 + A user with membership at any ancestor level (WORKSPACE/PROGRAM/AGENDA) can access child projects.
174 175
175 - Rejection response — `403 Forbidden`:
176 + ### Rejection Response — `403 Forbidden`:
176 177 ```json
177 - { "statusCode": 403, "message": "You do not have access to this project" }
178 + { "statusCode": 403, "message": "Access denied", "denial": { "type": "...", "details": {...} } }
178 179 ```
179 180
180 181 ### Write Operation — Role Check
181 182
182 - Write operations have an additional inline role check via `validateWorkspaceRole` helper in `action-plans.helpers.ts`.
183 + Write operations have an additional inline role check via `validateWorkspaceRole` helper in `action-plans.helpers.ts`. This queries `workspace_member_scopes` directly to enforce exact role requirements (OWNER/ADMIN for admin ops, MEMBER+ for member ops).
183 184
184 185 ### Operation → Role Mapping
185 186
186 187 | Operation | Required Role |
187 188 |---|---|
188 - | Read (GET any route) | `MEMBER` / `OWNER` / `ADMIN` (via guard) |
189 + | Read (GET any route) | Any workspace member (via guard) |
189 190 | Create activity/task/subtask | `OWNER` / `ADMIN` |
190 191 | Update activity/task/subtask | `OWNER` / `ADMIN` |
191 192 | Delete activity/task/subtask | `OWNER` / `ADMIN` |
@@ -200,7 +201,8 @@ Write operations have an additional inline role check via `validateWorkspaceRole
200 201
201 202 | Status | Message | Cause |
202 203 |---|---|---|
203 - | `403` | `You do not have access to this project` | Not a workspace member (guard) |
204 + | `401` | `Authorization token required` | Missing/invalid bearer token |
205 + | `403` | `Access denied` (with `denial` payload) | Not a workspace member (AccessControlGuard) |
204 206 | `403` | `Action plan is only available for projects with IN_PROGRESS status` | Project status not IN_PROGRESS |
205 207 | `403` | `You do not have the required role to perform this action` | Insufficient role for write op |
206 208 | `400` | `You are not assigned to this scope...` | Not in `actionPlanMembers` (member ops) |
@@ -208,7 +210,7 @@ Write operations have an additional inline role check via `validateWorkspaceRole
208 210 ### Double-Check for Member Operations
209 211
210 212 Member operations (`createProgress`, `createEditRequest`) have two-tier write validation:
211 - 1. **Tier 1** — `validateWorkspaceRole`: user must have active membership in workspace
213 + 1. **Tier 1** — `validateWorkspaceRole`: user must have active workspace membership with required role
212 214 2. **Tier 2** — `validateScopeMember`: user must be assigned to the specific scope via `actionPlanMembers`
213 215
214 216 ---

135dika revised this gist 1 month ago. Go to revision

1 file changed, 38 insertions, 10 deletions

action-plan.docs.md

@@ -146,27 +146,46 @@ DELETE /.../feedbacks/:fId
146 146
147 147 ## Authorization
148 148
149 - All write operations require the actor to have an active role in `workspace_member_scopes`. Validation via shared helper `validateWorkspaceRole` in `action-plans.helpers.ts`.
149 + Two-layer authorization at the controller level via guards.
150 150
151 - ### Role Hierarchy Scope
151 + ### Guards
152 152
153 - Authorization checks across 4 scope levels — owner/admin at any level can manage child projects:
153 + | Guard | Applies to | Check |
154 + |---|---|---|
155 + | `ProjectInProgressGuard` | All routes | Project status must be `IN_PROGRESS` |
156 + | `WorkspaceAccessGuard` | All routes | User must have active workspace membership for this project |
157 +
158 + Guards run before any route handler. Authentication is handled upstream by `AccessControlGuard` (validates Bearer token).
159 +
160 + ### WorkspaceAccessGuard — How It Works
161 +
162 + Every action-plan request requires the user to have an `active` row in `workspace_member_scopes` for the project's hierarchy. The guard resolves the hierarchy from `:projectId`:
154 163
155 164 ```
156 165 AGENDA
157 166 └─ PROGRAM
158 - └─ PROJECT ← projectId from request
167 + └─ PROJECT ← projectId from route param
168 + ```
169 +
170 + User passes if they have at least one matching row with:
171 + - `status = 'active'`
172 + - `role IN (MEMBER, OWNER, ADMIN)`
173 + - One of: `scope_type=PROJECT, scope_id=projectId` OR `scope_type=WORKSPACE` OR `scope_type=PROGRAM` OR `scope_type=AGENDA`
174 +
175 + Rejection response — `403 Forbidden`:
176 + ```json
177 + { "statusCode": 403, "message": "You do not have access to this project" }
159 178 ```
160 179
161 - A user must have an `active` row in `workspace_member_scopes` with:
162 - - `role` matching the required role set
163 - - `user_id` = actor
164 - - One of: `scope_type=PROJECT, scope_id=projectId` OR `scope_type=WORKSPACE, workspace_id=project.workspace_id` OR `scope_type=PROGRAM, scope_id=project.program_id` OR `scope_type=AGENDA, scope_id=project.program.agenda_id`
180 + ### Write Operation — Role Check
181 +
182 + Write operations have an additional inline role check via `validateWorkspaceRole` helper in `action-plans.helpers.ts`.
165 183
166 184 ### Operation → Role Mapping
167 185
168 186 | Operation | Required Role |
169 187 |---|---|
188 + | Read (GET any route) | `MEMBER` / `OWNER` / `ADMIN` (via guard) |
170 189 | Create activity/task/subtask | `OWNER` / `ADMIN` |
171 190 | Update activity/task/subtask | `OWNER` / `ADMIN` |
172 191 | Delete activity/task/subtask | `OWNER` / `ADMIN` |
@@ -177,9 +196,18 @@ A user must have an `active` row in `workspace_member_scopes` with:
177 196 | Submit progress | `MEMBER` / `OWNER` / `ADMIN` + `actionPlanMembers` check |
178 197 | Create edit request | `MEMBER` / `OWNER` / `ADMIN` + `actionPlanMembers` check |
179 198
199 + ### Rejection Responses
200 +
201 + | Status | Message | Cause |
202 + |---|---|---|
203 + | `403` | `You do not have access to this project` | Not a workspace member (guard) |
204 + | `403` | `Action plan is only available for projects with IN_PROGRESS status` | Project status not IN_PROGRESS |
205 + | `403` | `You do not have the required role to perform this action` | Insufficient role for write op |
206 + | `400` | `You are not assigned to this scope...` | Not in `actionPlanMembers` (member ops) |
207 +
180 208 ### Double-Check for Member Operations
181 209
182 - Member operations (`createProgress`, `createEditRequest`) have two-tier validation:
210 + Member operations (`createProgress`, `createEditRequest`) have two-tier write validation:
183 211 1. **Tier 1** — `validateWorkspaceRole`: user must have active membership in workspace
184 212 2. **Tier 2** — `validateScopeMember`: user must be assigned to the specific scope via `actionPlanMembers`
185 213
@@ -320,4 +348,4 @@ Response shape (all scopes):
320 348 - Subtask in progress → `workWeight`/`workVolume` locked
321 349
322 350 ### Soft Delete
323 - All deletes are soft (`deletedAt`). Cascades: deleting activity → all tasks + subtasks. Deleting task → all subtasks.
351 + All deletes are soft (`deletedAt`). Cascades: deleting activity → all tasks + subtasks. Deleting task → all subtasks.

135dika revised this gist 1 month ago. Go to revision

1 file changed, 72 insertions, 17 deletions

action-plan.docs.md

@@ -144,26 +144,81 @@ DELETE /.../feedbacks/:fId
144 144
145 145 ---
146 146
147 + ## Authorization
148 +
149 + All write operations require the actor to have an active role in `workspace_member_scopes`. Validation via shared helper `validateWorkspaceRole` in `action-plans.helpers.ts`.
150 +
151 + ### Role Hierarchy Scope
152 +
153 + Authorization checks across 4 scope levels — owner/admin at any level can manage child projects:
154 +
155 + ```
156 + AGENDA
157 + └─ PROGRAM
158 + └─ PROJECT ← projectId from request
159 + ```
160 +
161 + A user must have an `active` row in `workspace_member_scopes` with:
162 + - `role` matching the required role set
163 + - `user_id` = actor
164 + - One of: `scope_type=PROJECT, scope_id=projectId` OR `scope_type=WORKSPACE, workspace_id=project.workspace_id` OR `scope_type=PROGRAM, scope_id=project.program_id` OR `scope_type=AGENDA, scope_id=project.program.agenda_id`
165 +
166 + ### Operation → Role Mapping
167 +
168 + | Operation | Required Role |
169 + |---|---|
170 + | Create activity/task/subtask | `OWNER` / `ADMIN` |
171 + | Update activity/task/subtask | `OWNER` / `ADMIN` |
172 + | Delete activity/task/subtask | `OWNER` / `ADMIN` |
173 + | Set plans | `OWNER` / `ADMIN` |
174 + | Add/remove member (any scope) | `OWNER` / `ADMIN` |
175 + | Respond progress (approve/reject) | `OWNER` / `ADMIN` |
176 + | Respond edit request | `OWNER` / `ADMIN` |
177 + | Submit progress | `MEMBER` / `OWNER` / `ADMIN` + `actionPlanMembers` check |
178 + | Create edit request | `MEMBER` / `OWNER` / `ADMIN` + `actionPlanMembers` check |
179 +
180 + ### Double-Check for Member Operations
181 +
182 + Member operations (`createProgress`, `createEditRequest`) have two-tier validation:
183 + 1. **Tier 1** — `validateWorkspaceRole`: user must have active membership in workspace
184 + 2. **Tier 2** — `validateScopeMember`: user must be assigned to the specific scope via `actionPlanMembers`
185 +
186 + ---
187 +
147 188 ## Notifications
148 189
149 - Fire-and-forget. Does not block operation on failure.
190 + Fire-and-forget. Does not block operation on failure. All events are **bidirectional** — both actor (pelaku) and target (korban) receive the notification for audit trail.
150 191
151 - ### Member Notifications
152 - | Trigger | Type | Recipient |
153 - |---|---|---|
154 - | Add to subtask | `action_plan_member_added` | Added user |
155 - | Remove from subtask | `action_plan_member_removed` | Removed user |
156 - | Add to task | `action_plan_member_added` | Added user |
157 - | Remove from task | `action_plan_member_removed` | Removed user |
158 - | Add to activity | `action_plan_member_added` | Added user |
159 - | Remove from activity | `action_plan_member_removed` | Removed user |
160 -
161 - ### Progress Respond Notifications
162 - | Verb | Type | Recipient |
192 + ### Event Keys
193 +
194 + | Event Key | Category | Description |
163 195 |---|---|---|
164 - | APPROVE | `action_plan_progress_approve` | Submitter |
165 - | APPROVE_WITH_NOTES | `action_plan_progress_approve_with_notes` | Submitter |
166 - | REJECT | `action_plan_progress_reject` | Submitter |
196 + | `action_plan.member.added` | Targeted (actor + target) | Member assigned to scope |
197 + | `action_plan.member.removed` | Targeted (actor + target) | Member removed from scope |
198 + | `action_plan.progress.submitted` | Broadcast (all approvers) | Member submits progress |
199 + | `action_plan.progress.approved` | Targeted (actor + target) | Admin approves progress |
200 + | `action_plan.progress.rejected` | Targeted (actor + target) | Admin rejects progress |
201 + | `action_plan.edit_request.requested` | Broadcast (all approvers) | Member requests edit |
202 + | `action_plan.edit_request.responded` | Targeted (actor + target) | Admin responds to edit request |
203 +
204 + ### Recipient Resolution
205 +
206 + **Broadcast events** (`progress.submitted`, `edit_request.requested`):
207 + Resolve all `OWNER`/`ADMIN` users from `workspace_member_scopes` across 4-level hierarchy (PROJECT → WORKSPACE → PROGRAM → AGENDA). Plus the submitter for audit trail.
208 +
209 + **Targeted events** (all others):
210 + Notify the specific actor + the specific target user only. No broadcast to hierarchy owners.
211 +
212 + ### Notification Recipients by Trigger
213 +
214 + | Trigger | Recipients |
215 + |---|---|
216 + | Submit progress | Submitter + all OWNER/ADMIN in hierarchy |
217 + | Approve/reject progress | Submitter + approving admin |
218 + | Submit edit request | All OWNER/ADMIN in hierarchy |
219 + | Respond edit request | Requester + responding admin |
220 + | Add member | Added user + admin who added |
221 + | Remove member | Removed user + admin who removed |
167 222
168 223 ---
169 224
@@ -265,4 +320,4 @@ Response shape (all scopes):
265 320 - Subtask in progress → `workWeight`/`workVolume` locked
266 321
267 322 ### Soft Delete
268 - All deletes are soft (`deletedAt`). Cascades: deleting activity → all tasks + subtasks. Deleting task → all subtasks.
323 + All deletes are soft (`deletedAt`). Cascades: deleting activity → all tasks + subtasks. Deleting task → all subtasks.
Newer Older