Last active 4 days ago

Revision b10f2aa1c9c5eade96157291845104156db5f09b

action-plan.docs.md Raw

Action Plans API

Base URL: {host}/api/v1

All responses wrapped in:

{ "statusCode": 200, "message": "...", "data": { ... }, "durationMs": 42, "_reference": [] }

Hierarchy & Member Counting

Activity
  └─ Task (many)
      └─ Subtask (many)
Level Has children? Member count source
Activity Tasks with subtasks Union of all subtask members
Activity Tasks without subtasks Union of all task members
Activity No tasks Activity's own members
Task Has subtasks Union of all subtask members
Task No subtasks Task's own members
Subtask Subtask's own members

Example:

Activity1 (10 members) ← Union(Task1, Task2)
  Task1 (5 members) ← Union(Subtask1, Subtask2)
    Subtask1 (3 members)
    Subtask2 (2 members)
  Task2 (5 members) ← Task scope, no subtasks

Leaf-Only Validation

Nodes with children reject direct progress/plans/members operations.

Operation Activity has tasks? Task has subtasks?
Progress ❌ 400 ❌ 400
Plans ❌ 400 ❌ 400
Members ❌ 400 ❌ 400

Subtask always valid.


Route Map

All routes under: api/projects/:projectId/action-plans

GET    /                                           list activities
POST   /                                           create activity
GET    /tree                                       tree view
GET    /tree/gantt                                 gantt
GET    /analytics                                  analytics
GET    /import/template/download                   excel template
POST   /import                                     import
GET    /:activityId                                get activity
PATCH  /:activityId                                update activity
DELETE /:activityId                                delete activity

// Activity leaf
POST   /:activityId/progress                       create progress
GET    /:activityId/progress                       list progress
GET    /:activityId/progress/:progressId           get progress
POST   /:activityId/progress/:progressId/respond   respond
GET    /:activityId/plans                          plans (S-curve)
PUT    /:activityId/plans                          set plans
POST   /:activityId/members                        add member
DELETE /:activityId/members/:userId                remove member
GET    /:activityId/members                        list members

// Activity progress feedbacks
POST   /:activityId/progress/:pid/feedbacks        create feedback
GET    /:activityId/progress/:pid/feedbacks        list feedbacks
PATCH  /:activityId/progress/:pid/feedbacks/:fid   edit feedback
DELETE /:activityId/progress/:pid/feedbacks/:fid   delete feedback

// Task CRUD + leaf
POST   /:activityId/tasks                          create task
GET    /:activityId/tasks                          list tasks
GET    /:activityId/tasks/:taskId                  get task
PATCH  /:activityId/tasks/:taskId                  update task
DELETE /:activityId/tasks/:taskId                  delete task
POST   /:activityId/tasks/:taskId/progress         task progress
GET    /:activityId/tasks/:taskId/progress         list
GET    /:activityId/tasks/:taskId/progress/:pid    get
POST   /:activityId/tasks/:taskId/progress/:pid/respond
GET    /:activityId/tasks/:taskId/plans            plans
PUT    /:activityId/tasks/:taskId/plans
POST   /:activityId/tasks/:taskId/members          add member
DELETE /:activityId/tasks/:taskId/members/:uid     remove member
GET    /:activityId/tasks/:taskId/members          list members

// Task progress feedbacks
POST   /:activityId/tasks/:tid/progress/:pid/feedbacks
GET    /:activityId/tasks/:tid/progress/:pid/feedbacks
PATCH  /:activityId/tasks/:tid/progress/:pid/feedbacks/:fid
DELETE /:activityId/tasks/:tid/progress/:pid/feedbacks/:fid

// Subtask CRUD + leaf
POST   /:activityId/tasks/:taskId/subtasks         create subtask
GET    /:activityId/tasks/:taskId/subtasks         list subtasks
GET    /:activityId/tasks/:taskId/subtasks/:subtaskId
PATCH  /:activityId/tasks/:taskId/subtasks/:subtaskId
DELETE /:activityId/tasks/:taskId/subtasks/:subtaskId
POST   /:activityId/tasks/:taskId/subtasks/:sid/members
DELETE /:activityId/tasks/:taskId/subtasks/:sid/members/:uid
GET    /:activityId/tasks/:taskId/subtasks/:sid/members
GET    /:activityId/tasks/:taskId/subtasks/:sid/plans
PUT    /:activityId/tasks/:taskId/subtasks/:sid/plans
POST   /:activityId/tasks/:taskId/subtasks/:sid/progress
GET    /:activityId/tasks/:taskId/subtasks/:sid/progress
GET    /:activityId/tasks/:taskId/subtasks/:sid/progress/:pid
POST   /:activityId/tasks/:taskId/subtasks/:sid/progress/:pid/respond

// Subtask progress feedbacks
POST   /:aId/tasks/:tId/subtasks/:sId/progress/:pId/feedbacks
GET    /.../feedbacks
PATCH  /.../feedbacks/:fId
DELETE /.../feedbacks/:fId

Enums

Subtask/Task/Activity Status

INCOMPLETE_DATA | NOT_STARTED | IN_PROGRESS | NEED_APPROVAL | COMPLETED

Progress Entry Status

PENDING | APPROVED | APPROVED_WITH_NOTES | REJECTED

ActionPlanMemberScope

ACTIVITY | TASK | SUBTASK


Authorization

Two-layer authorization at the controller level via guards.

Guards

Guard Applies to Check
ProjectInProgressGuard All routes Project status must be IN_PROGRESS
WorkspaceAccessGuard All routes User must have active workspace membership for this project

Guards run before any route handler. Authentication is handled upstream by AccessControlGuard (validates Bearer token).

WorkspaceAccessGuard — How It Works

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:

AGENDA
  └─ PROGRAM
      └─ PROJECT ← projectId from route param

User passes if they have at least one matching row with:

  • status = 'active'
  • role IN (MEMBER, OWNER, ADMIN)
  • One of: scope_type=PROJECT, scope_id=projectId OR scope_type=WORKSPACE OR scope_type=PROGRAM OR scope_type=AGENDA

Rejection response — 403 Forbidden:

{ "statusCode": 403, "message": "You do not have access to this project" }

Write Operation — Role Check

Write operations have an additional inline role check via validateWorkspaceRole helper in action-plans.helpers.ts.

Operation → Role Mapping

Operation Required Role
Read (GET any route) MEMBER / OWNER / ADMIN (via guard)
Create activity/task/subtask OWNER / ADMIN
Update activity/task/subtask OWNER / ADMIN
Delete activity/task/subtask OWNER / ADMIN
Set plans OWNER / ADMIN
Add/remove member (any scope) OWNER / ADMIN
Respond progress (approve/reject) OWNER / ADMIN
Respond edit request OWNER / ADMIN
Submit progress MEMBER / OWNER / ADMIN + actionPlanMembers check
Create edit request MEMBER / OWNER / ADMIN + actionPlanMembers check

Rejection Responses

Status Message Cause
403 You do not have access to this project Not a workspace member (guard)
403 Action plan is only available for projects with IN_PROGRESS status Project status not IN_PROGRESS
403 You do not have the required role to perform this action Insufficient role for write op
400 You are not assigned to this scope... Not in actionPlanMembers (member ops)

Double-Check for Member Operations

Member operations (createProgress, createEditRequest) have two-tier write validation:

  1. Tier 1validateWorkspaceRole: user must have active membership in workspace
  2. Tier 2validateScopeMember: user must be assigned to the specific scope via actionPlanMembers

Notifications

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.

Event Keys

Event Key Category Description
action_plan.member.added Targeted (actor + target) Member assigned to scope
action_plan.member.removed Targeted (actor + target) Member removed from scope
action_plan.progress.submitted Broadcast (all approvers) Member submits progress
action_plan.progress.approved Targeted (actor + target) Admin approves progress
action_plan.progress.rejected Targeted (actor + target) Admin rejects progress
action_plan.edit_request.requested Broadcast (all approvers) Member requests edit
action_plan.edit_request.responded Targeted (actor + target) Admin responds to edit request

Recipient Resolution

Broadcast events (progress.submitted, edit_request.requested): Resolve all OWNER/ADMIN users from workspace_member_scopes across 4-level hierarchy (PROJECT → WORKSPACE → PROGRAM → AGENDA). Plus the submitter for audit trail.

Targeted events (all others): Notify the specific actor + the specific target user only. No broadcast to hierarchy owners.

Notification Recipients by Trigger

Trigger Recipients
Submit progress Submitter + all OWNER/ADMIN in hierarchy
Approve/reject progress Submitter + approving admin
Submit edit request All OWNER/ADMIN in hierarchy
Respond edit request Requester + responding admin
Add member Added user + admin who added
Remove member Removed user + admin who removed

Request/Response Schemas

Create Activity

POST /

{
  "name": "Activity name",
  "tasks": [{ "name": "Task name", "workWeight": 100, "workVolume": 500, "workVolumeUnit": "m2", "startDate": "2026-01-01", "endDate": "2026-03-31",
    "subtasks": [{ "name": "Subtask name", "workVolume": 250, "workVolumeUnit": "m2", "workWeight": 50, "startDate": "2026-01-01", "endDate": "2026-02-15" }]
  }]
}

List Activities

GET / — Query: page, limit, search, sort_by (created_at/updated_at/name), sort_order (asc/desc) Response: { items: [...], total, page, limit }

Get Single Activity

GET /:id

{ "id":"uuid", "projectId":"uuid", "name":"...", "taskCount":2, "subtaskCount":5, "memberCount":10, "status":"IN_PROGRESS", "progress":45.5, "workWeight":150 }

Tree / Gantt

GET /tree — Query: status filter. Each node has totalUsersAssigned, accumulatedProgress. GET /tree/gantt — Same + derived startDate/endDate.

Progress Entry

Response shape (all scopes):

{
  "id":"uuid", "scopeType":"SUBTASK|TASK|ACTIVITY", "scopeId":"uuid", "scopeName":"name",
  "progress":75, "workVolume":187.5, "scopeWorkVolume":250, "scopeWorkVolumeUnit":"m2",
  "description":"...", "status":"APPROVED", "amendsId":null, "amendsOriginal":null,
  "amendedByCount":0, "amendedBy":[], "approvedBy":"uuid", "approvedByName":"Admin",
  "approvedAt":"ISO", "createdBy":"uuid", "createdByName":"Worker",
  "images":[{ "id":"uuid","key":"s3/path","originalName":"photo.jpg","url":"presigned-url" }],
  "sitemap":{ "id":"uuid","key":"s3/path","originalName":"site.pdf","url":"presigned-url" }
}

Progress Create (multipart/form-data)

POST /:../progress

Field Type
progress number (0-100)
workVolume number, optional
description string, max 5000
images file[], max 10
sitemap file, PDF

Progress Respond

POST /:../progress/:id/respond

{ "verb":"APPROVE|APPROVE_WITH_NOTES|REJECT", "progress":80, "workVolume":200, "description":"Adjusted" }

Plans (S-Curve)

GET /:../plans

{ "duration":{ "days":90, "startDate":"...", "endDate":"..." }, "planning":[...], "sCurve":{ "plan":[...], "actual":[...] } }

PUT /:../plans{ "plans":[{ "startDate":"...", "endDate":"...", "targetProgress":50 }] }

Members

POST/DELETE/GET /:../members{ "userId":"uuid" } / response: [{ "id":"uuid","userId":"uuid","displayName":"John Doe" }]

Feedbacks

POST /:../feedbacks{ "message":"text" } PATCH /:../feedbacks/:id{ "message":"updated" }

Analytics

GET /analytics — Query: layout (detailed/simplified), granularity (daily/weekly/monthly), search, startDate, endDate

Import

GET /import/template/download — Excel file POST /import (multipart) — file (.xlsx/.xls/.csv, max 5MB)


Constraint Summary

Constraint Value
Max tasks per activity 50
Max subtasks per task 50
Max images per progress 10
Max feedback/description length 5000 chars
Max import file size 5MB
Progress range 0-100
Work weight/volume Number >= 0

Editing Rules

  • Activity with tasks → only name editable
  • Task with subtasks → only name editable
  • Subtask in progress → workWeight/workVolume locked

Soft Delete

All deletes are soft (deletedAt). Cascades: deleting activity → all tasks + subtasks. Deleting task → all subtasks.