API — Action Plans
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.
General
- Base URL:
/api/v1/projects/:projectId/action-plans(versioned controller, v1) - Auth: JWT via
@Authorize({ membership: true })— caller must be a member of:projectId. - Response envelope (
ResponseInterceptor): responses are wrapped as{ status, message, data }wheredatais the payload documented below (endpoints returning raw files — PDF/XLSX — skip the envelope). - Access tiers (
ProjectActionPlanAccessGuard):INCOMPLETE_DATA— not accessible.NOT_STARTED/IN_PROGRESS— full access.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).
- Progress input (submit/respond/feedback) additionally requires project status
IN_PROGRESS(ProjectInProgressGuard); approval routes intentionally bypass it.
Status enums
Derived status (item status field)
Derived from progress rows + approval state. Display overrides applied (see BOD display):
| Value | Meaning |
|---|---|
INCOMPLETE_DATA |
Leaf has no dates/weight data |
NEED_APPROVAL |
Progress submitted, awaiting approval |
IN_PROGRESS |
Has approved progress, not yet complete |
NOT_STARTED |
No active progress, or paused/rolled back (display override) |
COMPLETED |
Effective progress ≥ 100% |
ActionPlanApprovalStatus (approvalStatus)
WAITING_APPROVAL (item awaiting/queued for a new approval cycle) · CLOSED (approved) · REJECTED (rejected by VP) · BOD_APPROVAL (escalated, awaiting BOD board decision)
ActionPlanBodResult (bodResult) — aggregate outcome of latest BOD round
| Value | Meaning |
|---|---|
REJECTED_REVISE |
BOD voted REVISE → item back to VP (approvalStatus → WAITING_APPROVAL) |
REJECTED_ROLLBACK |
BOD voted ROLLBACK → item restarting from a prerequisite |
ActionPlanBodDisplayStatus (bodDisplayStatus) — chip shown to the user
| Value | Rule |
|---|---|
BOD_APPROVAL |
while item is escalated (approvalStatus BOD_APPROVAL) |
REJECTED_BY_BOD |
bodResult === REJECTED_REVISE |
NOT_STARTED |
bodResult === REJECTED_ROLLBACK or rollbackPaused === true (no "Rolled Back by BOD" chip) |
null |
no BOD state |
ActionPlanProgressStatus (progress rows)
PENDING · APPROVED · APPROVED_WITH_NOTES · REJECTED
Other booleans
rollbackPaused: item halted waiting for a prerequisite to close; reads asNOT_STARTED.
BOD escalation model
- VP (designated approver) responds to an item's approval:
APPROVE|REJECT|ESCALATE.ESCALATErequiresbodUserIds(≥1, max 50; each must be a user in the project's program agenda) → approvalStatusBOD_APPROVAL, a newActionPlanBodRoundsround created, notification sent to each BOD. - Each assigned BOD votes on the round:
APPROVE|REVISE|ROLLBACK(reason required, 1–500 chars).- One rollback target lock applies: once a BOD votes
ROLLBACKto target T, all other BODs may only voteROLLBACKto the same T (a differentrollbackId→ 400).rollbackIdmust be a direct prerequisite of the item.
- One rollback target lock applies: once a BOD votes
- Resolution when all assigned BODs have voted — precedence
ROLLBACK > REVISE > APPROVE:- Any/all
ROLLBACKvotes 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: lastAPPROVEDprogress →PENDING; each node →WAITING_APPROVAL; nodes whose prerequisites aren't met →rollbackPauseduntil the prerequisite closes). VP notified ("Action Plan Rolled Back by BOD"). - 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"). - Else (all
APPROVE) → itemapprovalStatus = CLOSED(handover); VP + admins + action plan members notified ("Action Plan Approved by BOD"). - Different
ROLLBACKtargets across voters → round staysACTIVE, item staysBOD_APPROVAL, no writes (defensive; the single-target lock normally prevents this).
- Any/all
- Re-escalation: VP
ESCALATEagain after a BOD REVISE carries the previous BOD list over; may add members, may not remove/replace. - 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 withrollbackPaused = trueand resume automatically when the prerequisite reachesCLOSED(.releaseRollbackPausedDependents). - Delete guard: an item whose effective progress ≥ 100% (derived
COMPLETED) orapprovalStatus = CLOSED(VP- or BOD-approved) cannot be deleted — including as a descendant of a parent delete (400naming the offending item).
1. Activities
POST / — create activity
{
"name": "string (required)",
"activityType": "string (required)",
"workWeight": "number (optional)",
"workVolume": "number (optional, requires workVolumeUnit)",
"workVolumeUnit": "string (optional)",
"startDate": "ISO date (optional)",
"endDate": "ISO date (optional)",
"approverId": "uuid (optional)",
"prerequisiteIds": ["uuid (optional)"],
"tasks": [{ "name": "string", "workWeight", "workVolume", "workVolumeUnit", "startDate", "endDate", "subtasks": [{ "name", "workWeight", "workVolume", "workVolumeUnit", "startDate", "endDate" }] }]
}
Response includes the item fields below (§ Item payload) plus: taskCount, subtaskCount, memberCount, progress, pendingProgress, weightlessLeaves, predecessorCount-style derived stats, and status.
POST /bulk — bulk create { "activities": [<CreateActivityPayload>…] } → { created, activities: [{id,name}] }
GET / — list (paginated)
Query: page, limit, search?, sort_by (created_at default | start_date | end_date | start_date_end_date), sort_order (asc|desc)
GET /tree — hierarchical tree
Returns Activity[] each with nested tasks[].subtasks[]; every level carries the item fields + accumulatedProgress, pendingProgress, workWeight, workVolume, totalUsersAssigned, latestProgressStatus.
Query: status?, startDate?, endDate?, search?, sort_by?, sort_order?
GET /tree/gantt — same tree with ganttStatus per node (LATE | ON_TRACK | BASELINE | …)
GET /:activityId — detail (§ Item payload)
PATCH /:activityId — update
{ "name"?, "activityType"?, "workWeight"?, "workVolume"?, "workVolumeUnit"?, "startDate"?, "endDate"?, "approverId"?, "prerequisiteIds"? }
DELETE /:activityId — soft delete. Blocked (400) if item or any live descendant is complete (progress ≥ 100%) or approvalStatus = CLOSED.
POST /bulk (DELETE) — DELETE /bulk bulk delete
{ "activityIds": [], "taskIds": [], "subtaskIds": [] }
→ { deleted }. Parent ids cascade to descendants; same complete/CLOSED guard applies.
2. Approval — per item level
Same three routes per level, substituting :activityId / :activityId/tasks/:taskId / :activityId/tasks/:taskId/subtasks/:subtaskId:
POST …/approval/respond — VP (designated approver) responds
Body (ApprovalRespondDto):
{
"verb": "APPROVE | REJECT | ESCALATE",
"reason": "string ≤500 chars (optional)",
"bodUserIds": ["uuid…"] // required when verb = ESCALATE; 1–50
}
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.
Effects:
APPROVE→CLOSED(item + descendants already closed).REJECT→REJECTED; descendants reopened per the chain rule (§ BOD model point 5).ESCALATE→BOD_APPROVAL; new BOD round; BODs notified.
POST …/approval/re-request — creator re-requests approval (only when REJECTED / WAITING_APPROVAL)
Body: none.
POST …/approval/bod — BOD member votes (new)
Body (BodRespondDto):
{
"decision": "APPROVE | REVISE | ROLLBACK",
"reason": "string, 1–500 chars (REQUIRED)",
"rollbackId": "uuid (required when decision = ROLLBACK; must be a direct prerequisite of the item)"
}
Preconditions (400/403 otherwise): item approvalStatus = BOD_APPROVAL; caller is an assigned BOD of the active round with no prior vote; not already resolved.
POST …/approval/bod/re-request — VP re-requests BOD round (new)
Body: none. Re-creates the escalation after a BOD REVISE (VP cycle back); carries previous BOD list (add-only on next ESCALATE).
3. Item payload (activity/task/subtask detail & tree nodes)
BOD-related fields on every item (detail, list, tree):
| Field | Type | Notes |
|---|---|---|
approvalStatus |
enum | WAITING_APPROVAL |
approvalReason |
string? | VP response reason |
bodRoundId |
string? | latest BOD round id |
bodResult |
enum? | REJECTED_REVISE |
bodReason |
string? | aggregate round reason (earliest voter) |
bodDisplayStatus |
enum? | BOD_APPROVAL |
rollbackPaused |
boolean | paused awaiting prerequisite close |
approverId / approverName / approverEmail |
string? | designated VP approver |
prerequisites / prerequisiteIds |
[] | direct prerequisites (same-scope level) |
Detail-only additions (GET /:activityId, GET …/tasks/:taskId, GET …/subtasks/:subtaskId):
"bodApprovals": [
{
"userId": "uuid",
"name": "string|null",
"decision": "APPROVE | REVISE | ROLLBACK|null",
"reason": "string|null",
"rollbackItemId": "uuid|null",
"rollbackTargetName": "string|null",
"respondedAt": "ISO date"
}
],
"rollbackOptions": [ { "id": "uuid", "name": "string" } ] // direct prereqs; after first ROLLBACK vote, locked to that single target
4. Tasks & Subtasks
Same shape as activities under the nesting paths:
POST :activityId/tasks/GET :activityId/tasks/GET/PATCH/DELETE :activityId/tasks/:taskId+ progress/plans/members/edit-requests/approval routes.POST :activityId/tasks/:taskId/subtasks/GETlist /GET/PATCH/DELETE :activityId/tasks/:taskId/subtasks/:subtaskId+ same sub-routes.
Member assignment (…/members POST/DELETE) body: { "userIds": ["uuid…"] }; bulk variants under /members/bulk (POST assign / DELETE remove): { "activityIds": [], "taskIds": [], "subtaskIds": [], "userIds": [] } → { assigned, skipped } / { removed }.
5. Progress (leaves: activity/task/subtask)
POST :itemId/progress(multipart): fieldsprogress,workVolume?,description?,documentSource(upload|document),documentRef?,companyId?,createdAt?; filesattachments[](jpg/png/gif/webp/svg/pdf) anddocument(×1). → progress row payload (see below).GET :itemId/progress— paginated (page,limit).GET :itemId/progress/:progressId— single row.POST :itemId/progress/:progressId/respond— body:
{ "verb": "APPROVE | APPROVE_WITH_NOTES | REJECT", "progress"?, "workVolume"?, "description"? }
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.
Feedback (per progress row)
POST/GET :itemId/progress/:progressId/feedbacks, PATCH/DELETE …/feedbacks/:feedbackId. Body { "message": "string" } (≤500).
6. Plans / S-curve (per level)
GET :itemId/plans→{ duration: {days,startDate,endDate}, planning: [{id?,startDate,endDate,targetProgress}], sCurve: { plan: [{date,progress}], actual: [{date,progress,id}] } }PUT :itemId/plansbody:{ "plans": [{ "startDate": "ISO", "endDate": "ISO", "targetProgress": 0–100 }] }
7. Edit requests (members → owner/admin)
POST/GET :itemId/edit-requests, project-levelGET /edit-requests,POST /edit-requests/:requestId/respondbody{ "verb": "APPROVE | REJECT" }.- Create body:
{ "field": "endDate | workWeight | workVolume | workVolumeUnit", "newValue": …, "reason": "string ≤500" }. - List returns
requestedData,oldData,reason,status, requester/responder names + timestamps.
8. Analytics & reports
GET /analytics— query:layout,granularity,search?,activityName?,startDate?,endDate?.GET /analytics/activity-names→string[]GET /analytics/status-counts?status=…GET /analytics/report/download?layout&granularity&startDate&endDate— PDF (binary).GET /analytics/report/history— paginated record list (seq,docNumber,layout,granularity,windowStart,windowEnd,generatedAt,s3Key).GET /analytics/report/history/:recordId/download— PDF (binary).
9. Import & catalogs
GET /import/template/download— XLSX template (binary).POST /import— multipartfile(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/POST /work-volume-units— POST body{ "name"?, "symbol" }.GET/POST /activity-types— POST body{ "name" }.
Payload changes from BOD feature (summary)
| Response | Added fields |
|---|---|
| Activity/Task/Subtask detail | bodRoundId, bodResult, bodReason, bodDisplayStatus, rollbackPaused, bodApprovals[], rollbackOptions[] |
| Tree nodes (tree/gantt/list) | bodRoundId, bodResult, bodReason, bodDisplayStatus, rollbackPaused |
Request …/approval/respond |
verb extended with ESCALATE; new bodUserIds[] |
| New endpoints | …/approval/bod (3 levels), …/approval/bod/re-request (3 levels) |
BOD notifications (email/push)
- BOD requested → assigned BODs — subject
Action Plan BOD Requested. - BOD REVISE → VP —
Action Plan Rejected by BOD— body{ScopeLabel} {scopeName} in {projectName} was rejected by {bodName}. You must make decision(+Note: {reason}). - BOD ROLLBACK → VP —
Action Plan Rolled Back by BOD—…was rolled back by {bodName}. Its prerequisite {prereqName} was re-opened for re-approval.(+ note). - BOD APPROVE (all) → VP + admins + members —
Action Plan Approved by BOD—…approved by BOD. Handover complete.(+ note).