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.- VP/BOD read visibility — a member assigned as VP (any item
approverId) or as BOD (ACTIVEround approver) gets full read of tree/detail regardless of project tier orActionPlanMembersmembership.MEMBER-only users without any VP/BOD assignment see only theirActionPlanMembersscopes (others pruned).
- 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 —approvalStatusstaysnull(§ 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
- VP responds via
…/approval/respondwithverb:APPROVE|REJECT|ESCALATE(this is one 2-step form: step 1 picks the verb, step 2 picks BOD members viabodUserIdswhenESCALATE).ESCALATE→BOD_APPROVAL+ new BOD round; each BOD (project program agenda users) notified. - 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). - Resolution once all assigned BODs voted — precedence
ROLLBACK > REVISE > APPROVE:- ROLLBACK (same single target T) → T's subtree restarts immediately (leaf last
APPROVED→PENDING, nodes →WAITING_APPROVAL, unmet-prereq nodes →rollbackPauseduntil prerequisite closes); the item's own subtree (B) is NOT flipped — every live node →WAITING_APPROVAL+rollbackPaused = true+bodResult = REJECTED_ROLLBACK(displayNOT_STARTED, data/progress untouched, no notification). When T is approved &CLOSEDagain, B's subtree restarts (chain-start leaves →PENDING= NEED_APPROVAL, others →WAITING_APPROVAL). VP notified Action Plan Rolled Back by BOD at resolve. - 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).
- ROLLBACK (same single target T) → T's subtree restarts immediately (leaf last
- Re-escalation:
…/approval/bod/re-request+ laterESCALATEcarries previous BOD list over — add-only, cannot remove/replace. - VP REJECT: walks descendants level by level; startable leaves reopen (
NEED_APPROVAL, lastAPPROVED→PENDING), leaves blocked by unmet live prerequisite pause (rollbackPaused) and auto-resume when the prerequisite closes (releaseRollbackPausedDependents). - Delete guard: progress ≥ 100% (derived
COMPLETED) orapprovalStatus = 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 — autoWAITING_APPROVALon completion, VP respond, BOD escalation, Closed chip. - Item WITHOUT
approverId(null) → approval optional: effective progress ≥ 100 completes the item withapprovalStatusstayingnull— noWAITING_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 (noCLOSEDneeded). - VP-held prerequisite → still requires
CLOSEDand 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
ACTIVEBOD 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):
"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). - 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…).
Downstream rollback consumers
When a rollback resolves (B → target X), all same-level items whose direct prerequisite is X (and their full subtrees) are affected:
| State | Effect |
|---|---|
| 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. |
| Item CLOSED | node → WAITING_APPROVAL (no progress flip, approvalReason nulled); its children display NOT_STARTED (marker) until the chain resumes. |
| 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. |
Activity log (namespace: action_plan)
Every BOD/approval interaction writes an activity-log row (GET /api/projects/:projectId/activity, recordId = the affected item id):
| Category | When |
|---|---|
ACTION_PLAN_APPROVAL_APPROVED / _REJECTED |
VP approve / reject |
ACTION_PLAN_BOD_ESCALATED |
VP escalates to BOD (approverCount, roundId) |
ACTION_PLAN_BOD_DECISION |
each BOD vote — {decision, actor, reason, roundId}, incl. votes that leave the round pending/conflicted |
ACTION_PLAN_BOD_APPROVED / _REVISED / _ROLLBACK |
terminal round resolution (revise copy: must be re-approved by VP; rollback: prerequisiteName) |
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 |
ACTION_PLAN_ROLLBACK_PAUSED |
one row per held node (B subtree at resolve + downstream consumers): on hold — waiting for prerequisite … (rollback effect) |
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) |
The per-node rows make the multi-item state changes explainable: why each item restarted, paused, or resumed is visible in its own history.