Last active 4 days ago

Revision a02a26c34039512bad81f7035542c6aac24d6b8b

action-plan.docs.md Raw

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). ESCALATEBOD_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 APPROVEDPENDING, 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 APPROVEDPENDING), 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):

"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 / CreateTaskDtoname ✅; workWeight/workVolume/workVolumeUnit/startDate/endDate ✳️ (volume pair rule)
GET / list under activity
GET /:taskId detail (§ Item payload)
PATCH /:taskId UpdateTaskDtoname/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 / CreateSubtaskDtoname ✅; 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).