Last active 4 days ago

Revision 07b5ff578487b52c02dc29af1449787120894996

action-plan.docs.md Raw

Action Plans API

Base URL: {host}/api/v1

All responses wrapped in:

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

Shown data inline below for brevity.


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 operations. Must use child level instead.

Operation Activity has tasks? Task has subtasks?
Progress ❌ 400 "use task/subtask level" ❌ 400 "use subtask level"
Progress plans ❌ same ❌ same
Members (CRUD) ❌ same ❌ same

Subtask scope always valid — no check needed.


Enums

Subtask/Task/Activity Status

Value Description
INCOMPLETE_DATA Required fields missing
NOT_STARTED Ready but no progress
IN_PROGRESS Progress entries exist
NEED_APPROVAL Pending progress requiring response
COMPLETED 100% complete

Progress Entry Status

Value Description
PENDING Submitted, awaiting response
APPROVED Accepted
APPROVED_WITH_NOTES Accepted with amendments (new version created)
REJECTED Rejected

ActionPlanMemberScope

Value Description
ACTIVITY Member assigned to activity
TASK Member assigned to task
SUBTASK Member assigned to subtask

Notifications

All notifications fire asynchronously (fire-and-forget). Member operation success is not blocked by notification service availability.

Member Notifications

Trigger Type Recipient Message
Add to subtask action_plan_member_added Added user "You have been added to [subtask name]"
Remove from subtask action_plan_member_removed Removed user "You have been removed from [subtask name]"
Add to task action_plan_member_added Added user "You have been added to [task name]"
Remove from task action_plan_member_removed Removed user "You have been removed from [task name]"
Add to activity action_plan_member_added Added user "You have been added to [activity name]"
Remove from activity action_plan_member_removed Removed user "You have been removed from [activity name]"

Each includes projectId and data with relevant scope IDs.

Progress Respond Notifications

Response Type Recipient Message
APPROVE action_plan_progress_approve Submitter "Your progress on [name] was approved"
APPROVE_WITH_NOTES action_plan_progress_approve_with_notes Submitter "Your progress on [name] was approved with notes"
REJECT action_plan_progress_reject Submitter "Your progress on [name] was rejected"

Name resolves from whichever scope the progress belongs to (subtask/task/activity).


Routes

1. Activities

Base: /api/projects/:projectId/action-plans

POST /

Create activity with optional nested tasks & subtasks.

Body:

{
  "name": "Activity name",              // string, 1-255
  "tasks": [                            // optional, max 50
    {
      "name": "Task name",              // string, 1-255
      "workWeight": 100,                // optional, number >= 0
      "workVolume": 500,                // optional, number >= 0
      "workVolumeUnit": "m2",           // optional, string max 100
      "startDate": "2026-01-01",        // optional, ISO date
      "endDate": "2026-03-31",          // optional, ISO date
      "subtasks": [                     // optional, max 50
        {
          "name": "Subtask name",       // string, 1-255
          "workVolume": 250,            // optional, number >= 0
          "workVolumeUnit": "m2",       // optional, string max 100
          "workWeight": 50,             // optional, number >= 0
          "startDate": "2026-01-01",    // optional, ISO date
          "endDate": "2026-02-15"       // optional, ISO date
        }
      ]
    }
  ]
}

Response: Full activity with derived stats (same shape as GET /:id).


GET /

List activities for a project. Paginated, searchable.

Query:

Param Type Default Description
page number 1 Page number
limit number 20 Items per page (max 100)
search string Filter by name
sort_by created_at | updated_at | name created_at Sort field
sort_order asc | desc desc Sort direction

Response:

{
  "items": [
    {
      "id": "uuid",
      "projectId": "uuid",
      "name": "Activity name",
      "createdBy": "uuid",
      "createdAt": "ISO date",
      "updatedAt": "ISO date",
      "deletedAt": null,
      "taskCount": 2,
      "subtaskCount": 5,
      "workWeight": 150
    }
  ],
  "total": 10,
  "page": 1,
  "limit": 20
}

GET /tree

Full Activity > Task > Subtask tree. Each level has accumulated stats.

Query:

Param Type Default Description
status string Filter subtasks by status

Response:

[
  {
    "id": "uuid",
    "name": "Activity name",
    "status": "IN_PROGRESS",
    "totalTasks": 2,
    "totalUsersAssigned": 10,           // memberCount (bottom-up)
    "accumulatedProgress": 45.5,        // avg(), percent
    "createdBy": "uuid",
    "createdAt": "ISO date",
    "updatedAt": "ISO date",
    "tasks": [
      {
        "id": "uuid",
        "name": "Task name",
        "status": "IN_PROGRESS",
        "totalSubtasks": 2,
        "totalUsersAssigned": 5,        // memberCount (bottom-up)
        "accumulatedProgress": 60,      // avg(), percent
        "workWeight": 100,
        "workVolume": 500,
        "workVolumeUnit": "m2",
        "startDate": "ISO date",
        "endDate": "ISO date",
        "subtasks": [
          {
            "id": "uuid",
            "name": "Subtask name",
            "status": "IN_PROGRESS",
            "totalUsersAssigned": 3,    // memberCount
            "progress": 75,             // effective (non-rejected), percent
            "workVolume": 250,
            "workVolumeUnit": "m2",
            "workWeight": 50,
            "startDate": "ISO date",
            "endDate": "ISO date"
          }
        ]
      }
    ]
  }
]

GET /tree/gantt

Same tree structure but each node includes startDate/endDate derived from children. Activity startDate = min of all subtasks. Activity endDate = max of all subtasks. Task dates use own stored values. Supports status query filter (same as /tree).


GET /:id

Get single activity with derived stats.

Response:

{
  "id": "uuid",
  "projectId": "uuid",
  "name": "Activity name",
  "createdBy": "uuid",
  "createdAt": "ISO date",
  "updatedAt": "ISO date",
  "deletedAt": null,
  "taskCount": 2,
  "subtaskCount": 5,
  "memberCount": 10,          // bottom-up (see rules above)
  "status": "IN_PROGRESS",
  "progress": 45.5,           // avg(), percent
  "workWeight": 150
}

PATCH /:id

Update activity. If activity has tasks, only name is editable.

Body:

{
  "name": "New name"              // string, 1-255, optional
}

DELETE /:id

Soft-delete activity and all its tasks + subtasks. No body. Returns 204 status code.


2. Tasks

Base: /api/action-plans/activities/:activityId/tasks

POST /

Create task under an activity.

Body:

{
  "name": "Task name",            // string, 1-255
  "workWeight": 100,              // optional, number >= 0
  "workVolume": 500,              // optional, number >= 0
  "workVolumeUnit": "m2",         // optional, string max 100
  "startDate": "2026-01-01",      // optional, ISO date
  "endDate": "2026-03-31"         // optional, ISO date
}

Response: Task object with id, name, workWeight, workVolume, workVolumeUnit, startDate, endDate, createdAt.


GET /

List all tasks under an activity.

Response:

{
  "tasks": [
    {
      "id": "uuid",
      "name": "Task name",
      "workWeight": 100,
      "workVolume": 500,
      "workVolumeUnit": "m2",
      "startDate": "ISO date",
      "endDate": "ISO date",
      "createdAt": "ISO date",
      "updatedAt": "ISO date",
      "subtaskCount": 3
    }
  ]
}

GET /:id

Get task detail with derived stats.

Response:

{
  "id": "uuid",
  "activityId": "uuid",
  "name": "Task name",
  "workWeight": 150,
  "workVolume": 500,
  "workVolumeUnit": "m2",
  "startDate": "ISO date",
  "endDate": "ISO date",
  "createdBy": "uuid",
  "createdAt": "ISO date",
  "updatedAt": "ISO date",
  "subtaskCount": 3,
  "memberCount": 5,           // bottom-up (see rules above)
  "status": "IN_PROGRESS",
  "progress": 60,             // avg(), percent
  "canAddSubtask": false
}

canAddSubtask is false if the task has subtasks and any subtask is beyond INCOMPLETE_DATA status.


PATCH /:id

Update task. If task has subtasks, only name is editable.

Body:

{
  "name": "New name",             // string, 1-255, optional
  "workWeight": 200,              // optional (only when no subtasks)
  "workVolume": 600,              // optional (only when no subtasks)
  "workVolumeUnit": "km2",        // optional (only when no subtasks)
  "startDate": "2026-01-01",     // optional (only when no subtasks)
  "endDate": "2026-06-30"        // optional (only when no subtasks)
}

DELETE /:id

Soft-delete task and all its subtasks. No body.


3. Subtasks

Base: /api/action-plans/tasks/:taskId/subtasks

POST /

Create subtask under a task.

Body:

{
  "name": "Subtask name",         // string, 1-255
  "workVolume": 250,              // optional, number >= 0
  "workVolumeUnit": "m2",         // optional, string max 100
  "workWeight": 50,               // optional, number >= 0
  "startDate": "2026-01-01",      // optional, ISO date
  "endDate": "2026-02-15"         // optional, ISO date
}

Response: Subtask object (see GET /:id).


GET /

List all subtasks under a task.

Response: Array of subtask objects, each with memberCount, members[].{userId, displayName}, status, progress, progressCount.


GET /:id

Get subtask detail.

Response:

{
  "id": "uuid",
  "taskId": "uuid",
  "task": { "id": "uuid", "activityId": "uuid" },
  "name": "Subtask name",
  "workVolume": 250,
  "workVolumeUnit": "m2",
  "workWeight": 50,
  "startDate": "ISO date",
  "endDate": "ISO date",
  "createdBy": "uuid",
  "createdAt": "ISO date",
  "updatedAt": "ISO date",
  "deletedAt": null,
  "members": [
    {
      "id": "uuid",
      "userId": "uuid",
      "displayName": "John Doe"
    }
  ],
  "memberCount": 3,
  "status": "IN_PROGRESS",
  "progress": 75,
  "progressCount": 2,
  "progressPlanCount": 1,
  "plans": [
    {
      "id": "uuid",
      "startDate": "ISO date",
      "endDate": "ISO date",
      "targetProgress": 50
    }
  ]
}

PATCH /:id

Update subtask. Work weight & work volume are locked once subtask is in progress.

Body:

{
  "name": "New name",             // string, 1-255, optional
  "workVolume": 300,              // optional (locked after progress starts)
  "workVolumeUnit": "kg",         // optional (locked after progress starts)
  "workWeight": 60,               // optional (locked after progress starts)
  "startDate": "2026-01-01",     // optional
  "endDate": "2026-02-15"        // optional
}

DELETE /:id

Soft-delete subtask. No body.


4. Progress

Three scopes: subtask, task, activity.


Subtask Progress

Base: /api/action-plans/subtasks/:subtaskId/progress

POST / (multipart/form-data)

Submit progress entry. Supports file uploads.

Form fields:

Field Type Description
progress number (0-100) New cumulative progress %
workVolume number Optional work volume completed
description string Optional note, max 5000 chars
images file[] Image attachments, max 10
sitemap file Single PDF sitemap

Progress auto-accumulates from previous APPROVED entries. If not provided, defaults to current effective progress. When workVolume is provided with totalVolume, progress % is derived from workVolume ratio. New progress must be >= current non-REJECTED floor.

Response: Full progress entry (see GET /:id).

GET /

List progress history for a subtask. Paginated, newest first.

Query: page (default 1), limit (default 50, max 100)

Response:

{
  "items": [
    {
      "id": "uuid",
      "subtaskId": "uuid",
      "subtaskName": "Subtask name",
      "progress": 75,
      "workVolume": 187.5,
      "subtaskWorkVolume": 250,
      "subtaskWorkVolumeUnit": "m2",
      "description": "Completed foundation",
      "status": "APPROVED",
      "amendsId": null,
      "amendsOriginal": null,
      "amendedByCount": 0,
      "amendedBy": [],
      "approvedBy": "uuid",
      "approvedByName": "Admin",
      "approvedAt": "ISO date",
      "createdBy": "uuid",
      "createdByName": "Worker",
      "updatedBy": null,
      "updatedByName": null,
      "createdAt": "ISO date",
      "updatedAt": "ISO date",
      "images": [{ "id": "uuid", "key": "s3/path", "originalName": "photo.jpg", "url": "presigned-url" }],
      "sitemap": { "id": "uuid", "key": "s3/path", "originalName": "site.pdf", "url": "presigned-url" }
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 50,
    "total": 2,
    "pages": 1,
    "hasNext": false,
    "hasPrev": false
  }
}
GET /:id

Get single progress entry with full detail including images and amendment chain.

Response: Same shape as one item in the list above.

POST /:id/respond

Admin responds to a PENDING progress entry. Sends notification to submitter.

Body:

{
  "verb": "APPROVE",              // "APPROVE" | "APPROVE_WITH_NOTES" | "REJECT"
  "progress": 80,                // optional, 0-100 (for APPROVE_WITH_NOTES)
  "workVolume": 200,             // optional (for APPROVE_WITH_NOTES)
  "description": "Adjusted"      // optional (for APPROVE_WITH_NOTES)
}

Verbs:

  • APPROVE — accepted as-is. Notifies submitter: action_plan_progress_approve.
  • APPROVE_WITH_NOTES — original marked APPROVED_WITH_NOTES + new amended entry created with admin values. Notifies submitter: action_plan_progress_approve_with_notes.
  • REJECT — rejected, doesn't count toward effective progress. Notifies submitter: action_plan_progress_reject.

Task Progress

Base: /api/action-plans/tasks/:taskId

POST /progress
GET /progress
GET /progress/:id
POST /progress/:id/respond

Same as subtask. Scope = TASK. Leaf-only: blocked if task has subtasks.


Activity Progress

Base: /api/action-plans/activities/:activityId

POST /progress
GET /progress
GET /progress/:id
POST /progress/:id/respond

Same as subtask. Scope = ACTIVITY. Leaf-only: blocked if activity has tasks.


5. Progress Plans (S-Curve)

Set target progress over time. All levels (subtask/task/activity) share same shape.


Subtask Plans

Base: /api/action-plans/tasks/:taskId/subtasks/:id

GET /plans

Response:

{
  "duration": {
    "days": 90,
    "startDate": "2026-01-01",
    "endDate": "2026-03-31"
  },
  "planning": [
    {
      "id": "uuid",
      "startDate": "2026-01-01",
      "endDate": "2026-02-15",
      "targetProgress": 50
    },
    {
      "id": "uuid",
      "startDate": "2026-02-15",
      "endDate": "2026-03-31",
      "targetProgress": 50
    }
  ],
  "sCurve": {
    "plan": [
      { "date": 1704067200000, "progress": 0 },
      { "date": 1709251200000, "progress": 50 },
      { "date": 1711929600000, "progress": 100 }
    ],
    "actual": [
      { "date": 1705276800000, "progress": 25, "id": "uuid" },
      { "date": 1708300800000, "progress": 60, "id": "uuid" }
    ]
  }
}

sCurve.plan — cumulative target over time (derived from plan segments).
sCurve.actual — APPROVED entries sorted by approvedAt. Latest version per amendment chain.

If no plans stored and subtask has dates, a default segment 0%→100% is returned.


PUT /plans

Set (replace) all plans. Validates: continuous dates, within subtask date range, sum to 100%.

Body:

{
  "plans": [
    {
      "startDate": "2026-01-01",
      "endDate": "2026-02-15",
      "targetProgress": 50
    },
    {
      "startDate": "2026-02-15",
      "endDate": "2026-03-31",
      "targetProgress": 50
    }
  ]
}

Response: Same as GET /plans.


Task Plans

Base: /api/action-plans/tasks/:taskId

GET /plans
PUT /plans

Same shape as subtask plans. Scope = TASK. Leaf-only: blocked if task has subtasks.


Activity Plans

Base: /api/action-plans/activities/:activityId

GET /plans
PUT /plans

Same shape as subtask plans. Scope = ACTIVITY. Leaf-only: blocked if activity has tasks.


6. Members

Member CRUD across all three hierarchy levels. Sends notification on add/remove.


Subtask Members

Base: /api/action-plans/tasks/:taskId/subtasks/:id

POST /members

Add member to subtask. Sends action_plan_member_added notification.

Body:

{
  "userId": "uuid"
}

Response:

{
  "id": "uuid",
  "scopeType": "SUBTASK",
  "scopeId": "uuid",
  "userId": "uuid"
}
GET /members

List members of a subtask.

Response:

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

Remove member from subtask. Sends action_plan_member_removed notification. No body. Returns deleted member object.


Task Members

Base: /api/action-plans/tasks/:taskId

POST /members
GET /members
DELETE /members/:userId

Same pattern as subtask members. scopeType = TASK. Leaf-only: blocked if task has subtasks.


Activity Members

Base: /api/action-plans/activities/:activityId

POST /members
GET /members
DELETE /members/:userId

Same pattern as subtask members. scopeType = ACTIVITY. Leaf-only: blocked if activity has tasks.


7. Feedbacks

Discussion thread per progress entry. Sender can edit own message.

Base: /api/action-plans/progress/:progressId/feedbacks

POST /

Body:

{
  "message": "Please clarify the numbers"   // string, 1-5000
}

GET /

List feedbacks. Paginated. Query: page, limit.

PATCH /:id

Edit own message. Body:

{
  "message": "Updated message"              // string, 1-5000
}

DELETE /:id

Delete own message. No body.


8. Analytics

Base: /api/projects/:projectId/action-plans/analytics

GET /

Query:

Param Type Values Default
layout string detailed | simplified detailed
granularity string daily | weekly | monthly daily
search string
startDate ISO date
endDate ISO date

9. Import

Base: /api/projects/:projectId/action-plans/import

GET /template/download

Download Excel template file. Returns .xlsx binary.

POST / (multipart/form-data)

Import activities from spreadsheet.

Form field:

Field Type Description
file file .xlsx, .xls, or .csv, max 5MB

Response:

{
  "imported": 3,
  "activities": [
    { "id": "uuid", "name": "Activity 1" },
    { "id": "uuid", "name": "Activity 2" }
  ]
}

Constraint Summary

Constraint Value
Max tasks per activity 50
Max subtasks per task 50
Max plans per scope Unlimited
Max images per progress 10
Max feedback length 5000 chars
Max 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 and workVolume locked

Leaf-Only Blocks

  • Activity with tasks → progress, plans, members rejected (400)
  • Task with subtasks → progress, plans, members rejected (400)
  • Subtask always allowed

Soft Delete

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