Workspace
Projects and tasks — CRUD, the board-position invariant shared by both, task assignees, and task-asset attachment.
ProjectModule and TaskModule back the Workspace board and the Timeline/Gantt view. Both controllers carry the same guard stack as Organizations and Uploads:
@UseGuards(AuthGuard, OrganizationGuard)
@ApiBearerAuth()
@ApiHeader({
name: 'x-organization-id',
required: false,
description: "Target organization id. Defaults to the caller's personal organization when omitted.",
})
TaskController declares an empty @Controller() prefix deliberately — tasks are addressed both as /api/tasks/:id and as /api/projects/:projectId/tasks, and splitting them across two controllers would put one resource’s authorization rules in two files. If you go looking for project-scoped task routes under src/project/, they are not there; both mounts live in src/task/task.controller.ts.
Projects
| Method | Path | Summary |
|---|---|---|
GET |
/api/projects |
List the active organization’s projects, paginated |
POST |
/api/projects |
Create a project |
GET |
/api/projects/:id |
Get one project with counts and members |
PATCH |
/api/projects/:id |
Update a project |
PATCH |
/api/projects/:id/position |
Move a project to a board column and position |
DELETE |
/api/projects/:id |
Soft-delete a project |
GET /api/projects takes the shared paginator (page, perPage, q) plus status, priority, and sort/order:
GET /api/projects?status=IN_PROGRESS&priority=HIGH&sort=dueDate&order=asc&page=0&perPage=20
q matches (case-insensitively) against name and description. sort is one of name, dueDate, status, createdAt — an allow-list, not a free string, because the value is interpolated into an ORDER BY that TypeORM does not parameterise. progress is deliberately not sortable: it is computed from task counts rather than stored, so ordering by it would need a correlated subquery: the UI disables that option rather than pretending it works.
Without an explicit sort, the order is status then position — the same order the board reads. Supplying sort replaces the default entirely rather than layering on top of it, because a secondary status sort would scramble a name-ordered list.
Response shape
{
"message": "Project retrieved successfully",
"data": {
"id": "PRa7Bk9x2Q",
"name": "Q3 rebrand",
"description": "Logo refresh and brand guidelines",
"status": "IN_PROGRESS",
"priority": "HIGH",
"startDate": "2026-07-01",
"dueDate": "2026-09-30",
"position": 2,
"taskCount": 8,
"completedTaskCount": 3,
"progress": 38,
"assetCount": 5,
"members": [{ "id": "USa1B2c3D4", "fullName": "Amoako Owusu", "imageUrl": null }],
"createdBy": { "id": "USa1B2c3D4", "fullName": "Amoako Owusu" },
"createdAt": "2026-07-01T09:00:00.000Z",
"updatedAt": "2026-08-14T16:20:00.000Z"
}
}
status is one of QUEUED, IN_PROGRESS, COMPLETED; priority is one of LOW, MEDIUM, HIGH — the same two enums TaskModule uses. startDate/dueDate are date-only (YYYY-MM-DD), not instants — a project’s dates live on Postgres date columns, unlike a task’s.
progress is round(completedTaskCount / taskCount * 100), 0 when the project has no tasks. members is the project creator followed by everyone assigned to one of the project’s tasks, deduplicated by user id — there is no project-membership table, so this is the only answer the schema can give to “who is on this project”; it is the creator alone until the project has tasks with assignees.
Updating
PATCH /api/projects/:id accepts any subset of name, description, status, priority, startDate, dueDate. name, status, and priority back NOT NULL columns and reject an explicit null with a 400 rather than letting it reach Postgres as a 23502 (a 500). startDate/dueDate are plain nullable columns and follow the ordinary contract: omit the key to leave it, send a value to set it, send null to clear it. Sending both startDate and dueDate where the effective start is after the effective due date is a 400 startDate must be on or before dueDate.
Board-position ordering
Projects and tasks share one ordering invariant, implemented twice (once per entity) with the same shape:
- Positions are contiguous within a column —
(organization, status)for projects,(project, status)for tasks — among live rows. - The next position is
MAX(position) + 1, notcount().count()only agrees with the maximum while the column has no holes, and a soft delete creates exactly one: delete the middle row of three and the survivors are positions0and2;count()answers2, which is already taken. - A move closes the old hole and opens a new gap, under a Postgres advisory lock keyed per column so two concurrent drags in the same column serialize:
await manager.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
`projects:${organizationId}`,
]);
Task moves lock tasks:${projectId} instead of the organization — moving a task only serializes against other moves in the same project, while moving a project serializes against every project move in the organization, since a project’s column spans the whole org.
- Deleting a row shifts everything after it down by one, in the same locked transaction as the soft-delete, so the next created row does not land on an index a sibling already holds.
PATCH /api/projects/:id/position and PATCH /api/tasks/:id/position both require status even for a same-column reorder — a pure reorder resends the current status.
Tasks
| Method | Path | Summary |
|---|---|---|
GET |
/api/tasks |
List the active organization’s tasks, paginated |
GET |
/api/projects/:projectId/tasks |
List one project’s tasks — same contract as GET /tasks, path projectId wins |
POST |
/api/projects/:projectId/tasks |
Create a task in a project |
GET |
/api/tasks/:id |
Get one task |
PATCH |
/api/tasks/:id |
Update a task |
PATCH |
/api/tasks/:id/position |
Move a task to a status and board position |
PUT |
/api/tasks/:id/assignees |
Replace a task’s assignees |
GET |
/api/tasks/:taskId/assets |
List the assets linked to a task |
PUT |
/api/tasks/:taskId/assets |
Replace the assets linked to a task |
DELETE |
/api/tasks/:id |
Soft-delete a task |
GET /api/tasks takes the shared paginator plus projectId, status, priority, assigneeId, sort/order, and a from/to window:
GET /api/tasks?from=2026-09-01T00:00:00.000Z&to=2026-09-30T23:59:59.000Z&status=QUEUED&sort=dueAt
q matches the task title and the parent project’s name, because Timeline’s one search box sits above rows grouped by project. assigneeId is an organization-membership id (OM…), not a user id — the same handle PUT /api/tasks/:id/assignees takes. sort is one of title, startAt, dueAt, status, createdAt; project is deliberately not sortable — ordering by the joined project’s name reads fine but interleaves a project’s own tasks unpredictably, and the Gantt already groups by project client-side. Without sort, the order is startAt then position, which is what the Gantt reads.
from/to bound a window with a specific, easy-to-misread rule:
- A task with both
startAtanddueAtis included when its span overlaps the window. - A task with one date is included only when that single instant falls inside the window — a one-dated task is treated as a one-day item.
- A task with neither date is excluded whenever a window is supplied, because
COALESCEof two nulls compares asNULL.
Response shape
{
"message": "Task retrieved successfully",
"data": {
"id": "TAa7Bk9x2Q",
"title": "Redraw the wordmark",
"description": null,
"status": "IN_PROGRESS",
"priority": "MEDIUM",
"startAt": "2026-09-01T09:00:00.000Z",
"dueAt": "2026-09-05T17:00:00.000Z",
"position": 0,
"project": { "id": "PRa7Bk9x2Q", "name": "Q3 rebrand", "status": "IN_PROGRESS" },
"assignees": [
{ "id": "OMb8Cm0y3R", "user": { "id": "USa1B2c3D4", "fullName": "Amoako Owusu", "imageUrl": null } }
],
"createdBy": { "id": "USa1B2c3D4", "fullName": "Amoako Owusu" },
"createdAt": "2026-08-20T10:00:00.000Z",
"updatedAt": "2026-09-01T09:30:00.000Z"
}
}
startAt/dueAt are full ISO instants — start_at/due_at are timestamp columns, unlike a project’s date-only fields. Each assignees[].id is the membership id (OM…), not the person; the person is assignees[].user.id. Conflating the two is how an avatar stack starts counting memberships instead of people.
Updating
PATCH /api/tasks/:id takes title, description, startAt, dueAt, assigneeIds, status, priority. The two dates use an explicit three-state contract, resolved once in the service before anything is written:
| Value sent | Effect |
|---|---|
| omitted | leave the stored value alone |
| an ISO string | set it |
null |
clear it |
title, status, and priority back NOT NULL columns and, like Project, reject an explicit null with a 400 instead of a 500. As with projects, sending status does not write the column directly — a status change is routed through the same locked applyMove a drag uses, appending the task to the end of the target column.
assigneeIds (create) and assignees replacement work the same way as project members: not an ownership check on the task’s update, but on the assignees themselves — every id must resolve to an ACTIVE OrganizationMember of the target organization, or the whole request is rejected:
{ "statusCode": 400, "message": "Every assignee must be an active organization member" }
Task assignees
PUT /api/tasks/TAa7Bk9x2Q/assignees
Content-Type: application/json
{ "memberIds": ["OMb8Cm0y3R", "OMc9Dn1z4S"] }
Full replacement, not a diff — an empty array clears every assignee. Same active-membership validation as assigneeIds on create/update.
Task-asset attachment
GET /api/tasks/TAa7Bk9x2Q/assets
PUT /api/tasks/TAa7Bk9x2Q/assets
Content-Type: application/json
{ "assetIds": ["ASa7Bk9x2Q", "ASb8Cm0y3R"] }
PUT replaces the whole linked set — organization-owned assets only, validated in full before anything is written, under the same per-project advisory lock the move path uses. The alternative (detach, then attach one at a time) could leave a task with no assets if the third id turned out to belong to another organization. An empty array detaches all; an id that does not resolve to an organization-owned asset is a:
{ "statusCode": 403, "message": "Task assets must belong to the task organization" }
Errors
| Situation | Status | Message |
|---|---|---|
| Project/task in another organization, or unknown id | 404 | Project not found / Task not found |
startDate/startAt after dueDate/dueAt |
400 | startDate must be on or before dueDate / startAt must be on or before dueAt |
| An assignee id is not an active member of the organization | 400 | Every assignee must be an active organization member |
| A task-asset id does not belong to the task’s organization | 403 | Task assets must belong to the task organization |
Delete by someone who is neither the creator nor OWNER/ADMIN |
403 | Only the project creator or an organization manager can delete this project / the task equivalent |
| No active membership in the resolved organization | 403 | You do not belong to an active organization |
