---
title: Workspace
description: 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](/api/organizations) and [Uploads](/api/uploads):

```ts title="backend/src/project/project.controller.ts"
@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`:

```http
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

```json title="GET /api/projects/PRa7Bk9x2Q"
{
  "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`**.

:::note[A status change on `PATCH /api/projects/:id` is routed through the same lock as a drag]
Sending `status` in a plain update does not write the column directly. `ProjectService.update` pulls `status` out of the patch and, if it differs from the stored value, runs the identical locked `applyMove` path that `PATCH /api/projects/:id/position` uses — appending the project to the end of the target column. Writing status straight onto the row would leave it in a new column still carrying its old column's index, colliding with whatever project already held that slot.
:::

## 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:

```ts title="backend/src/project/project.service.ts"
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.

:::danger[Soft-deleting a project does not soft-delete its tasks]
`ProjectService.remove()` calls `softRemove(project)`. That does not cascade to the project's tasks — TypeORM's soft-delete filter applies to the query's root entity only, and a plain join to `tasks` carries no `deleted_at IS NULL`. Every read that could otherwise resurrect a deleted project's tasks adds the predicate by hand: `TaskService.list`, `TaskService.findOwnedWithManager`, `DashboardService.orgTasks`, and `ProfileService.stats` all do. A new query joined to `tasks` or `projects` needs the same predicate added explicitly — see [Modules](/architecture/modules#soft-deletes-do-not-cascade-through-joins).
:::

## 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:

```http
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

```json title="GET /api/tasks/TAa7Bk9x2Q"
{
  "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:

```json title="400 — an assignee id does not resolve"
{ "statusCode": 400, "message": "Every assignee must be an active organization member" }
```

:::note[Only delete is creator-or-manager gated]
`TaskService.update` (and `ProjectService.update`) carry no author/role check — any active member can edit any task's fields. `remove` is the one mutation restricted to the creator or an org `OWNER`/`ADMIN`, on both `Task` and `Project`, because deleting a task also takes its comments and asset links with it. Do not assume update inherits delete's restriction.
:::

## Task assignees

```http
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

```http
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:

```json title="403 — an asset id is not this organization's"
{ "statusCode": 403, "message": "Task assets must belong to the task organization" }
```

:::note[`task_assets` is not `task_comment_assets`]
A task's own asset attachments (`task_assets`, this section) are a different grain from a task **comment's** attachments (`task_comment_assets`, see [Comments](/api/comments)) — "this task is about these files" versus "this remark came with these files". Neither cascades into the other; detaching an asset from the task does not touch any comment that also references it.
:::

## 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` |
