Skip to content
TALA
Esc
navigateopen⌘Jpreview
On this page

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, not count(). 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 positions 0 and 2; count() answers 2, 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 startAt and dueAt is 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 COALESCE of two nulls compares as NULL.

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

Was this page helpful?