# Action Plans API Base URL: `{host}/api/v1` All responses wrapped in: ```json { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc [ { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "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:** ```jsonc { "userId": "uuid" } ``` **Response:** ```jsonc { "id": "uuid", "scopeType": "SUBTASK", "scopeId": "uuid", "userId": "uuid" } ``` ##### `GET /members` List members of a subtask. **Response:** ```jsonc [ { "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:** ```jsonc { "message": "Please clarify the numbers" // string, 1-5000 } ``` #### `GET /` List feedbacks. Paginated. **Query:** `page`, `limit`. #### `PATCH /:id` Edit own message. **Body:** ```jsonc { "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:** ```jsonc { "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.