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
nameeditable - Task with subtasks → only
nameeditable - Subtask in progress →
workWeightandworkVolumelocked
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.