# API — Action Plans Action plan module: **Activity → Task → Subtask** hierarchy with progress entries, approvers, prerequisites, members, and a BOD (Board of Directors) escalation flow. ## General - **Base**: `/api/v1/projects/:projectId/action-plans` — all paths below are relative to this base. - **Auth**: JWT, `@Authorize({ membership: true })` — caller must be a project member. - **Envelope**: responses wrapped `{ status, message, data }`; binary endpoints (PDF/XLSX) bypass the envelope. - **Access tiers** (`ProjectActionPlanAccessGuard`): - `INCOMPLETE_DATA` — not accessible. - `NOT_STARTED` / `IN_PROGRESS` — full access. - `NEED_APPROVAL` / `COMPLETED` / `REJECTED` — read-only for management writes; approval & BOD routes **stay writable**. - **Progress input** (submit/respond/feedback) requires project `IN_PROGRESS` (`ProjectInProgressGuard`). ## Status enums ### Derived item `status` `INCOMPLETE_DATA` · `NEED_APPROVAL` · `IN_PROGRESS` · `NOT_STARTED` (also display override for paused/rolled back) · `COMPLETED` ### `approvalStatus` `WAITING_APPROVAL` · `CLOSED` · `REJECTED` · `BOD_APPROVAL` (escalated) > Item without `approverId` (approval optional) never enters this enum — `approvalStatus` stays `null` (§ Approval optional). ### `bodResult` `REJECTED_REVISE` · `REJECTED_ROLLBACK` ### `bodDisplayStatus` | Value | Rule | |---|---| | `BOD_APPROVAL` | while escalated | | `REJECTED_BY_BOD` | `bodResult === REJECTED_REVISE` | | `NOT_STARTED` | `bodResult === REJECTED_ROLLBACK` **or** `rollbackPaused === true` | | `null` | no BOD state | ### Progress row `status` `PENDING` · `APPROVED` · `APPROVED_WITH_NOTES` · `REJECTED` ## BOD escalation flow 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. 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). 3. **Resolution** once all assigned BODs voted — precedence **`ROLLBACK > REVISE > APPROVE`**: - 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*. - else REVISE → item `WAITING_APPROVAL` + `bodResult = REJECTED_REVISE`; data/progress untouched; VP notified *Action Plan Rejected by BOD*. - else all APPROVE → `CLOSED` (handover); VP + admins + members notified *Action Plan Approved by BOD*. - Different ROLLBACK targets → no resolution, round stays `ACTIVE` (defensive; normally prevented by the lock). 4. **Re-escalation**: `…/approval/bod/re-request` + later `ESCALATE` carries previous BOD list over — **add-only**, cannot remove/replace. 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`). 6. **Delete guard**: progress ≥ 100% (derived `COMPLETED`) or `approvalStatus = CLOSED` → delete blocked 400 (also blocks cascade delete of a complete/closed descendant). ## Approval optional (derived from `approverId`) VP approval is **optional per item**, derived from `approverId` itself (no separate flag): - Item **WITH** `approverId` → approval **mandatory**: the full VP/BOD cycle applies — auto `WAITING_APPROVAL` on completion, VP respond, BOD escalation, Closed chip. - 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. **Assign-VP rule** (PATCH `approverId`) once the item's effective progress has reached ≥ 100 %: | Change | Result | |---|---| | assign (`null` → person) | **400** | | remove (person → `null`) | **400** | | replace (person → person) | **allowed — pure swap**: only `approverId` changes; `approvalStatus` / `approvalReason` / `approvedAt` / `bodResult` / `bodReason` / `bodRoundId` / `rollbackPaused` all untouched, no reopen, no late-assignment notification | Below 100 % the pre-existing assignment semantics are unchanged (assignment never mutates `approvalStatus` on an incomplete item). **Prerequisite rule**: - VP-less prerequisite (`approverId = null`) → satisfied by effective progress ≥ 100 **alone** (no `CLOSED` needed). - VP-held prerequisite → still requires `CLOSED` **and** effective progress ≥ 100. **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). **All approval endpoints on a VP-less item return 400**: `…/approval/respond` (incl. `ESCALATE`), `…/approval/re-request`, `…/approval/bod`, `…/approval/bod/re-request`. **Existing-data migration** (applied mid-feature): prior rows with non-null `approvalStatus` were set to `CLOSED`, and ACTIVE BOD rounds were closed. --- # Endpoint catalog **Path legend**: `A` = `:activityId`, `T` = `:taskId`, `S` = `:subtaskId`. Full paths: - Activity level: `BASE/A/…` - Task level: `BASE/A/tasks/T/…` - Subtask level: `BASE/A/tasks/T/subtasks/S/…` ## 1. Activities ### `POST /` (base) **Body** (`CreateActivityDto`): | Field | Req | Notes | |---|---|---| | `name` | ✅ | 1–255 chars | | `activityType` | ✅ | 1–100 chars | | `workWeight` | ✳️ | number ≥0 | | `workVolume` | ✳️ | number ≥0 — *requires `workVolumeUnit`* | | `workVolumeUnit` | ✳️ | ≤100 chars | | `startDate` | ✳️ | ISO date, floored to day | | `endDate` | ✳️ | ISO date | | `tasks[]` | ✳️ | ≤50, each: `name` ✅ (1–255), `workWeight/workVolume/workVolumeUnit/startDate/endDate` ✳️, nested `subtasks[]` ≤50 (same shape, `name` ✅) | ### `POST /bulk` Body: `CreateActivityDto[]` — 1–100 items. → `{ created, activities: [{id,name}] }` ### `GET /` Query: | Param | Req | Options / default | |---|---|---| | `page` | ✳️ | default `1` | | `limit` | ✳️ | default `20`, max `100` | | `search` | ✳️ | string | | `sort_by` | ✳️ | `created_at` (default) · `updated_at` · `name` | | `sort_order` | ✳️ | `asc` · `desc` (default) | ### `GET /tree` Query: | Param | Req | Options / default | |---|---|---| | `status` | ✳️ | derived status string filter | | `startDate` / `endDate` | ✳️ | bound effective date overlap range (one-sided allowed) | | `search` | ✳️ | string | | `sort_by` | ✳️ | `created_at` (default) · `start_date` · `end_date` · `start_date_end_date` | | `sort_order` | ✳️ | `asc` · `desc` (default `asc`) | ### `GET /tree/gantt` Same query as `/tree`; nodes carry `ganttStatus`. ### `GET /:activityId` Detail — item payload (§ Item payload). ### `PATCH /:activityId` Body (`UpdateActivityDto`) — all ✳️: `name`, `activityType`, `approverId`, `workWeight`, `workVolume`, `workVolumeUnit` (*requires workVolume pair rule*), `startDate`, `endDate`, `prerequisiteIds[]`. > `approverId`: assign / remove blocked 400 once complete (≥ 100 %); replace = pure swap (§ Approval optional). ### `DELETE /:activityId` No body. **400** if item or live descendant complete (`≥100%`) or `CLOSED`. ### `DELETE /bulk` Body (`BulkActionPlanDeleteDto`): | Field | Req | Notes | |---|---|---| | `activityIds[]` | ✳️ | ≤100, default `[]` | | `taskIds[]` | ✳️ | ≤100, default `[]` | | `subtaskIds[]` | ✳️ | ≤100, default `[]` | At least one id array non-empty. Parents cascade to descendants. → `{ deleted }`. Same complete/CLOSED guard. ## 2. Approval (3 levels; same routes) Patterns (per level): `…/approval/respond`, `…/approval/re-request`, `…/approval/bod`, `…/approval/bod/re-request`. ### `POST …/approval/respond` — VP (designated approver) Body (`ApprovalRespondDto`): | Field | Req | Notes | |---|---|---| | `verb` | ✅ | `APPROVE` · `REJECT` · `ESCALATE` | | `reason` | ✳️ | ≤500 chars | | `bodUserIds[]` | ✳️ | 1–50, each a valid agenda user — **required when `verb=ESCALATE`** | ### `POST …/approval/re-request` — creator re-requests (only from `REJECTED`/`WAITING_APPROVAL`) No body. ### `POST …/approval/bod` — BOD member votes Body (`BodRespondDto`): | Field | Req | Notes | |---|---|---| | `decision` | ✅ | `APPROVE` · `REVISE` · `ROLLBACK` | | `reason` | ✅ | 1–500 chars | | `rollbackId` | ✳️ | — **required when `decision=ROLLBACK`**; must be a direct prerequisite | Guards: item `BOD_APPROVAL`, caller is assigned BOD with no vote yet, round not resolved. ### `POST …/approval/bod/re-request` — VP resumes BOD cycle after REVISE No body. ### `GET /approval-obligations` — pre-member-removal check (project level) Query: | Param | Req | Notes | |---|---|---| | `userId` | ✅ | uuid — the member whose action-plan responsibilities are checked | Purpose: list the user's outstanding approval responsibilities in the project before member removal (removal itself lives outside this module). → `{ "hasObligation": bool, "obligations": […] }`: - **VP obligations** — every item where the user is the VP, **all states including `CLOSED`**: `{ "type": "VP", "scope": "ACTIVITY" | "TASK" | "SUBTASK", "itemId", "itemName" }` - **BOD obligations** — every `ACTIVE` BOD round the user is an assigned approver, **including rounds they already voted on**: `{ "type": "BOD", "scope": "ACTIVITY" | "TASK" | "SUBTASK", "itemId", "itemName", "roundId", "responded" }` — `responded` = whether the user cast a decision. Empty result → `hasObligation: false`, empty `obligations`. ## 3. Item payload (detail/list/tree) BOD-related fields on every item: `approvalStatus`, `approvalReason`, `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused`, plus `approverId/approverName/approverEmail`, `prerequisites[]`, `prerequisiteIds[]`. Detail-only (`GET /A`, `GET A/tasks/T`, `GET A/tasks/T/subtasks/S`): ```json "bodApprovals": [{ "userId", "name", "decision", "reason", "rollbackItemId", "rollbackTargetName", "respondedAt" }], "rollbackOptions": [{ "id", "name" }] // direct prereqs; locked to single target after first ROLLBACK vote ``` ## 4. Tasks — `BASE/A/tasks` | Method+Path | Body / notes | |---|---| | `POST /` | `CreateTaskDto` — `name` ✅; `workWeight/workVolume/workVolumeUnit/startDate/endDate` ✳️ (volume pair rule) | | `GET /` | list under activity | | `GET /:taskId` | detail (§ Item payload) | | `PATCH /:taskId` | `UpdateTaskDto` — `name/approverId/workWeight/workVolume/workVolumeUnit/startDate/endDate/prerequisiteIds[]` all ✳️ — `approverId`: assign/remove blocked 400 once complete, replace = pure swap (§ Approval optional) | | `DELETE /:taskId` | complete/CLOSED guard 400 | 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). ## 5. Subtasks — `BASE/A/tasks/T/subtasks` | Method+Path | Body / notes | |---|---| | `POST /` | `CreateSubtaskDto` — `name` ✅; `workVolume/workVolumeUnit/workWeight/startDate/endDate` ✳️ (volume pair rule) | | `GET /` | list under task | | `GET /:subtaskId` | detail | | `PATCH /:subtaskId` | `UpdateSubtaskDto` — all ✳️ incl `approverId`, `prerequisiteIds[]` — `approverId`: assign/remove blocked 400 once complete, replace = pure swap (§ Approval optional) | | `DELETE /:subtaskId` | complete/CLOSED guard 400 | 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). ## 6. Members | Method+Path | Body | |---|---| | `POST /A/members` · `DELETE /A/members` (×task/subtask) | `{ "userIds": ["uuid…"] }` — 1–100, required | | `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 }` | | `DELETE /members/bulk` | same body → `{ removed }` | | `GET /A/members` · `GET A/tasks/T/members` · `GET …/subtasks/S/members` | list w/ display names + leaf scopes | ## 7. Progress (multipart) — 3 levels ### `POST …/progress` — submit Multipart form fields (`CreateProgressDto`): | Field | Req | Notes | |---|---|---| | `progress` | ✳️ | coerced number 0–100; omit = carry current floor | | `workVolume` | ✳️ | number ≥0 | | `description` | ✳️ | ≤5000 chars | | `createdAt` | ✳️ | ISO date (backdated work) | | `documentSource` | ✳️ | `upload` · `document` | | `documentRef` | ✳️ | JSON string `{"documentId","versionId","revisionId"}` (when source=document) | | `companyId` | ✳️ | guards cross-company document access | | Files | | `attachments[]` (jpg/png/gif/webp/svg/pdf), `document` (×1) | ### `GET …/progress` Query: `page` (default 1) · `limit` (default 50, max 100). ### `GET …/progress/:progressId` 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`. ### `POST …/progress/:progressId/respond` Body (`RespondProgressDto`): `verb` ✅ (`APPROVE`·`APPROVE_WITH_NOTES`·`REJECT`); `progress` ✳️ (0–100), `workVolume` ✳️, `description` ✳️ (≤5000) — overridable on APPROVE_WITH_NOTES. ## 8. Feedback — `…/progress/:progressId/feedbacks` | Method+Path | Body | |---|---| | `POST …/feedbacks` | `{ "message": "string" }` — 1–5000 chars ✅ | | `GET …/feedbacks` | query: `page` (1) · `limit` (50, max 100) | | `PATCH …/feedbacks/:feedbackId` | `{ "message" }` same validation | | `DELETE …/feedbacks/:feedbackId` | — | ## 9. Plans / S-curve — `…/plans` (3 levels) `GET …/plans` → `{ duration: {days,startDate,endDate}, planning: [{id?,startDate,endDate,targetProgress}], sCurve: { plan:[{date,progress}], actual:[{date,progress,id}] } }` `PUT …/plans` body (`SetProgressPlansDto`): `plans[]` (≥0): `startDate` ✅ (date), `endDate` ✅ (date), `targetProgress` ✅ (0–100). ## 10. Edit requests — member → owner/admin | Method+Path | Body | |---|---| | `POST …/edit-requests` (3 levels + none at project) | `CreateEditRequestDto`: `endDate` ✳️, `workWeight` ✳️, `workVolume` ✳️, `workVolumeUnit` ✳️, `activityType` ✳️ — one or more changed fields; `reason` ✅ (1–500 chars) | | `GET …/edit-requests` · `GET /edit-requests` | query: `page` (1) · `limit` (50, max 100) · `status` ✳️ (`PENDING`·`APPROVED`·`REJECTED`) | | `POST /edit-requests/:requestId/respond` | `{ "verb": "APPROVE" \| "REJECT" }` ✅ | List rows include `requestedData`, `oldData`, `reason`, `status`, requester/responder names, timestamps. ## 11. Analytics & reports | Method+Path | Query | |---|---| | `GET /analytics` | `layout` ✳️ (`detailed` default · `simplified`); `granularity` ✳️ (`daily` · `weekly` default · `monthly`); `search` ✳️ (≤200); `activityName` ✳️ (≤200, case-insensitive contains); `startDate`/`endDate` ✳️ | | `GET /analytics/activity-names` | — → `string[]` | | `GET /analytics/status-counts` | `status` ✳️ (default `IN_PROGRESS`): `INCOMPLETE_DATA`·`NEED_APPROVAL`·`IN_PROGRESS`·`NOT_STARTED`·`COMPLETED` | | `GET /analytics/report/download` | `granularity` ✳️ (`weekly` default · `monthly` · `yearly`); `search`, `activityName`, `startDate`, `endDate` ✳️ → PDF binary | | `GET /analytics/report/history` | `page` (1) · `limit` (20, max 100) · `startDate`/`endDate` ✳️ → records `{seq, docNumber, layout, granularity, windowStart, windowEnd, generatedAt, s3Key}` | | `GET /analytics/report/history/:recordId/download` | — → PDF binary | ## 12. Import & catalogs | Method+Path | Body / notes | |---|---| | `GET /import/template/download` | — XLSX template (binary) | | `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}] }` | | `GET /work-volume-units` | — | | `POST /work-volume-units` | `{ "name" }` ✳️ (≤100), `"symbol"` ✅ (1–50) | | `GET /activity-types` | — | | `POST /activity-types` | `{ "name" }` ✅ (1–100) | --- ## Payload changes from the BOD feature | Context | Added | |---|---| | Item detail/list/tree | `bodRoundId`, `bodResult`, `bodReason`, `bodDisplayStatus`, `rollbackPaused` | | Item detail only | `bodApprovals[]`, `rollbackOptions[]` | | `…/approval/respond` | `verb` extended with `ESCALATE`; new `bodUserIds[]` | | New routes | `…/approval/bod` ×3 levels, `…/approval/bod/re-request` ×3 levels | ## Notification copy (BOD) - BOD requested → each assigned BOD: subject *Action Plan BOD Requested*. - REVISE → VP: **Action Plan Rejected by BOD** — `{ScopeLabel} {scopeName} in {projectName} was rejected by {bodName}. You must make decision` (+ ` Note: {reason}`). - ROLLBACK → VP: **Action Plan Rolled Back by BOD** — `…was rolled back by {bodName}. Its prerequisite {prereqName} was re-opened for re-approval.` (+ note). - All-APPROVE → VP + admins + members: **Action Plan Approved by BOD** — `…was approved by BOD. Handover complete.` (+ note).