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. | |