# Tasks: boards and cards of the shop's own work

> **Резюме по-русски.** Задачи магазина — доски задач (колонки) и карточки на
> них: название, описание, исполнитель, срок, метки, приоритет, чек-лист,
> ссылки. Это рабочий инструмент команды, а не заявки покупателей: права
> `tasks:read` / `tasks:write`, заявки ими не открываются и наоборот; колонки и
> саму доску задач правит тот же `tasks:write` (с 2026-09-24, W4). Решения
> владельца 2026-09-24: доски задач видят все участники магазина, непустую
> доску удалить нельзя, правила переходов досок заявок к задачам не применяются.

**Status:** draft · **Verified:** 2026-09-24, integration tests on PostgreSQL
(`TestTask*`, handlers and PAT gates) and a wire probe of the real HTTP gateway
(§7) · **Owner:** CRM line (backend-3D#15)
**Serves:** `GET /docs/tasks`

## 1. What you can do here

Keep the shop's own to-do list through a token: list the task boards and their
columns, create a board, create, read, edit, move and delete task cards, filter
them by board, column, assignee or overdue. A card carries a title, a
description, an assignee (a shop member by id or any name as text), a due date
and an `extra` object with labels, priority, a checklist and links — the Trello
vocabulary on purpose.

The OpenAPI of this area alone (eight task operations, nine shared board and
column operations and the key passport, about 42 KB) is
`GET /openapi.json?scope=tasks`; every scope and its operations is
`GET /docs/scopes.json`.

## 2. Decide first (forks)

| If you want… | Take this path | Cost |
|---|---|---|
| to track the team's work | **tasks** — `/task-boards`, `/tasks` with `tasks:*` | no personal data, not tied to buyers |
| to process buyer requests from a form | **leads** — `/sandboxes`, `/form-leads` with `forms:*` / `leads:*` (skill `forms`) | personal data: owner-issued sensitive scopes, dev keys read nothing |
| to add, rename, reorder or delete a **column**, rename or delete a **board** | the board endpoints shared with leads: `/sandboxes/{id}`, `/sandboxes/{sandbox_id}/columns`, `/sandbox-columns/{id}` | **`tasks:write`** on a task board; the same endpoints on a leads board need `forms:write` and refuse a tasks key |
| to assign a task to a person | `assignee_user_id` if you already know a member id (seen on an existing card or given by the owner); otherwise `assignee_name` as text | a token cannot list shop members; an unknown id is refused, a name is never checked |

A task board is a board with `view: "tasks"`. It never appears in the leads
list (`GET /sandboxes` without `view`), a form never drops a lead onto it, and a
task is never created on a leads board (`TASK_BOARD_MISMATCH`).

## 3. Objects, endpoints and the wire

**Board** — the same object as a leads board (`Sandbox`): `id`, `name`,
`view: "tasks"`, `columns[]` (`id`, `label`, `kind: intake|normal`,
`sort_order`, `settings`). A new board gets three columns: the intake
(`kind: intake`) and two normal ones, labelled **in Russian** —
«К выполнению · В работе · Готово» — whatever the shop language; rename them
with `PUT /sandbox-columns/{id}` (`tasks:write`). A new shop has **no** task
board: `GET /task-boards` answering `{"result": []}` is normal, create one.

**Board and column endpoints** are shared with leads boards. The right is chosen
by the **kind of the board** they touch: a task board needs `tasks:*`, a leads
board `forms:*`. A key with only `forms:write` is refused on a task board, a key
with only `tasks:write` on a leads board — `403 PAT_SCOPE_MISSING` naming the
scope that board needs.

| Method and path | Scope on a task board | What it does |
|---|---|---|
| `GET /sandboxes?view=tasks` | `tasks:read` | the same list as `GET /task-boards`; without `view` it is the leads list (`forms:read`) |
| `POST /sandboxes` `{name, view: "tasks"}` | `tasks:write` | the same as `POST /task-boards` |
| `PUT /sandboxes/{id}` `{name}` · `DELETE /sandboxes/{id}` `{}` | `tasks:write` | rename · delete an empty board |
| `POST /sandboxes/reorder` `{ids}` | `tasks:write` | board order; a list mixing both kinds needs both rights |
| `POST /sandboxes/{sandbox_id}/columns` `{label}` | `tasks:write` | a normal column at the end |
| `PUT /sandbox-columns/{id}` `{label}` · `DELETE /sandbox-columns/{id}` `{}` | `tasks:write` | rename · delete (its cards go to the intake; the intake is protected) |
| `POST /sandboxes/{sandbox_id}/columns/reorder` `{ids}` | `tasks:write` | column order, the full list |

**Task** — `id`, `sandbox_id`, `column_id`, `title`, `description`, `position`
(dense order inside the column, 0..n-1), `assignee_user_id` (0 — none),
`assignee_name`, `due_at`, `extra`, `created_at`, `updated_at`, `created_by`,
`updated_by` (0 — unknown; a token write records the user who issued the key).

| Method and path | Scope | What it does |
|---|---|---|
| `GET /task-boards` | `tasks:read` | task boards with their columns |
| `POST /task-boards` `{name}` | `tasks:write` | new board with the three starter columns |
| `GET /tasks?sandbox_id&column_id&assignee_user_id&overdue` | `tasks:read` | tasks by filter, ordered `column_id, position, id`; 0 / `false` — no filter |
| `GET /tasks/{id}` | `tasks:read` | one task |
| `POST /tasks` | `tasks:write` | new task at the **end** of a column; `column_id: 0` — the board's intake |
| `PATCH /tasks/{id}` | `tasks:write` | partial edit of fields; never the column or the order |
| `PUT /tasks/{id}/position` `{column_id, position}` | `tasks:write` | move inside the column or to another column of the **same** board |
| `DELETE /tasks/{id}` with body `{}` | `tasks:write` | hard delete; the column is renumbered |

Every task operation is private to the token's shop: another shop's task
answers exactly like a missing one. Reads work for a dev-contour key too;
writes are live operations, so a dev key gets `PAT_CONTOUR_MISMATCH` — tasks
have no draft. `capabilities.tasks_read` / `tasks_write` in
`GET /v1/account/token` say what this key may call.

### Wire gotchas

- **64-bit ids are JSON strings** on output: `"id": "42"`, `"sandbox_id": "7"`,
  `"column_id": "19"`. Input accepts both `19` and `"19"`. 32-bit fields
  (`position`, `assignee_user_id`, `created_by`) are numbers.
- **`extra` is base64** of a raw JSON object, both ways — it is a `bytes`
  field. Send `"extra": "eyJwcmlvcml0eSI6ImhpZ2gifQ=="` for
  `{"priority":"high"}`. A JSON object in its place is refused with
  `400 INVALID_REQUEST`. An empty card reads `"extra": "e30="` (`{}`).
- **`column_id: 0` means the intake** of the board on create. On move
  `column_id` is required (≥ 1).
- **`due_at` is an RFC3339 string**, `""` means no due date. Any offset is
  accepted; it is returned in UTC (`2026-10-01T18:00:00+03:00` reads back as
  `2026-10-01T15:00:00Z`). Anything else is `TASK_BAD_DUE_AT`.
- **`PATCH` knows presence.** A field you do not send is left alone.
  `assignee_user_id: 0` removes the member, `due_at: ""` removes the due date,
  `assignee_name: ""` clears the name. `extra`: absent or `""` — unchanged,
  `"e30="` (`{}`) — reset.
- **Unknown body fields are refused, not ignored**: `PATCH` with `column_id`
  answers `400 invalid request body: column_id` — moving is
  `PUT /tasks/{id}/position`.
- **`DELETE` needs a body**: `-d '{}'`. Without it the gateway answers
  `400 invalid request body`.
- `extra` is **stored normalised**: empty lists dropped, key order not kept
  (the column is JSONB). Compare meaning, not bytes.

### `extra` whitelist

```json
{
  "labels": ["seo", "urgent-fix"],
  "priority": "high",
  "checklist": [{"text": "Write the brief", "done": true}],
  "links": [{"title": "Spec", "url": "https://example.com/spec"}]
}
```

Any other key, at any depth, is refused with `TASK_BAD_EXTRA` and the reason
after the code. A link must start with `http://` or `https://`. Limits: §6.

### Refusals

| Code | HTTP | When |
|---|---|---|
| `TASK_NOT_FOUND` | 404 | the task does not exist or belongs to another shop |
| `SANDBOX_NOT_FOUND` | 404 | `POST /tasks` with an unknown or foreign `sandbox_id` |
| `TASK_BOARD_MISMATCH` | 400 | the board is a leads board, or the column belongs to another board |
| `TASK_BAD_EXTRA` | 400 | `extra` outside the whitelist or its limits; the reason follows the code |
| `TASK_BAD_ASSIGNEE` | 400 | `assignee_user_id` is not a member of the shop |
| `TASK_BAD_DUE_AT` | 400 | `due_at` is not RFC3339 |
| `SANDBOX_NOT_EMPTY` | 400 | `DELETE /sandboxes/{id}` of a task board that still has tasks |
| `INVALID_REQUEST` | 400 | the body does not parse: unknown field, `extra` not base64, no body on `DELETE` |
| `PAT_SCOPE_MISSING` | 403 | the key lacks `tasks:read` / `tasks:write`, or a board endpoint touched a board of the other kind (the required scope is in the text) |
| `PAT_CONTOUR_MISMATCH` | 403 | a dev-contour key tried to write |

Proto validation (empty `title`, `position` < 0, over-long strings) answers 400
with the field name in the text.

### Webhooks

`task.created`, `task.updated` (fields), `task.moved` (column or position, plus
`from_column_id`) and `task.deleted` (the card before deletion) carry the card
without `description`; ids are numbers there and `extra` is a JSON object. Any
shop member may subscribe in the cabinet — class `operational` (`/docs/webhooks` §6).

## 4. Recipes

`$API` is the API base, `$T` is `Bearer vz_pat_…`.

**4.1 Find or create a board.**

```bash
curl -s "$API/task-boards" -H "Authorization: $T"
curl -s -X POST "$API/task-boards" -H "Authorization: $T" -d '{"name":"Launch"}'
```

Verify: the board is in `GET /task-boards` with `view: "tasks"` and three
columns, the first with `kind: "intake"`.

**4.2 Create a task with a checklist.**

```bash
EXTRA=$(printf '%s' '{"priority":"high","checklist":[{"text":"Draft","done":false}]}' | base64 | tr -d '\n')
curl -s -X POST "$API/tasks" -H "Authorization: $T" -d '{
  "sandbox_id": "7", "column_id": 0, "title": "Prepare the autumn banner",
  "assignee_name": "Anna (designer)", "due_at": "2026-10-01T18:00:00Z",
  "extra": "'"$EXTRA"'"}'
```

Verify: `GET /tasks/{id}` returns the card in the intake column with the last
`position`; `jq -r .result.extra | base64 -d` prints your object.

**4.3 Move a card to "in progress", on top.**

```bash
curl -s -X PUT "$API/tasks/42/position" -H "Authorization: $T" -d '{"column_id":"19","position":0}'
```

Verify: `GET /tasks?column_id=19` lists it first; the source column is
renumbered without a gap.

**4.4 Tick a checklist item.** `extra` is replaced whole: read the task, decode
`extra`, change the item, encode, `PATCH /tasks/{id}` with the new `extra`.
Verify by reading it back.

**4.5 Overdue work of one person.**
`GET /tasks?assignee_user_id=12&overdue=true` — then drop the cards that sit in
your "done" column yourself (§5).

**4.6 Delete.** `curl -s -X DELETE "$API/tasks/42" -H "Authorization: $T" -d '{}'`.
Verify: `GET /tasks/42` answers `TASK_NOT_FOUND`.

## 5. Silently ignored

- **Unknown query parameters of `GET /tasks`.** A misspelled filter
  (`board_id=7`, `page.limit=5`) is dropped and the answer is **every task of
  the shop** with a 200. The filters are exactly `sandbox_id`, `column_id`,
  `assignee_user_id`, `overdue`. Compare the count before and after a filter.
- **No pagination.** `GET /tasks` returns the whole filtered list; `page.*`
  parameters do nothing.
- **Lead-board rules do not apply to tasks.** A task board column can store
  `settings` (`stage`, `no_return`, `require_comment`, `limit`,
  `deadline_days`) and the board a `mode` through the leads endpoints; for
  tasks they are stored and have no effect — any card moves to any column of
  its board.
- **`overdue=true` ignores the column.** It is `due_at < now()`, so a finished
  card in "Готово" with a past due date is still overdue.
- **`position` past the end** puts the card at the tail. The actual position is
  in `result.position`.
- **`extra: ""` on `PATCH`** changes nothing; a reset is `{}` (`"e30="`).
- **`assignee_name` is never checked** and survives removing
  `assignee_user_id`: both are independent fields.

## 6. Limits

```json
{
  "title_max_chars": 255,
  "description_max_chars": 20000,
  "assignee_name_max_chars": 255,
  "board_name_max_chars": 255,
  "extra_max_bytes": 16384,
  "extra_keys": ["labels", "priority", "checklist", "links"],
  "labels_max": 10,
  "label_max_chars": 32,
  "priority": ["low", "normal", "high", "urgent"],
  "checklist_max": 50,
  "checklist_text_max_chars": 200,
  "links_max": 20,
  "link_title_max_chars": 120,
  "link_url_max_bytes": 2000,
  "link_url_schemes": ["http", "https"],
  "list_pagination": false
}
```

`extra` numbers are checked against the domain constants by
`TestTasksAreaLimitsMatchDomain`; string lengths come from the proto
validation rules of `api/catalog/catalog.proto`.

## 7. How this was verified

- `go test -tags=integration ./internal/api/catalog/ -run 'TestTask'` on
  PostgreSQL (`cart_dev`): CRUD, dense renumbering on moves inside and between
  columns, concurrent moves, filters, shop isolation, the leads/tasks board
  boundary, column and board deletion, PAT (`tasks:read` reads, no scope —
  `PAT_SCOPE_MISSING`, dev key writing — `PAT_CONTOUR_MISMATCH`).
- `-run 'TestSandboxScopesByView'`: board and column endpoints by the board's
  kind — `tasks:write` manages a task board and is refused on a leads board,
  `forms:write` the other way round, a foreign board stays `SANDBOX_NOT_FOUND`.
- Wire probe, 2026-09-24: the generated HTTP handlers of the Catalog service
  with the production JSON marshaler and gateway error writer, behind a stub
  service. Measured: int64 fields as strings, `extra` as base64,
  `extra` as an object → 400, `PATCH` with `column_id` → 400,
  `DELETE` without a body → 400 and with `{}` → 200, `assignee_user_id: 0`
  reaches the handler as "present", `GET /tasks?page.limit=5` → 200.
- Domain unit tests of the `extra` whitelist (`internal/core/domain/task_test.go`)
  and this document's §6 against the constants
  (`internal/api/discovery/area_scopes_test.go`).
