diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 00000000..1d74a080 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,164 @@ +# Task API (since 2.2, in progress) + +A REST API shaped like ClickUp API v2, for scripts and agents with a personal token (spec 213). **Audit status: re-verification pending.** The read endpoints are in; writes arrive before 2.2.0 is tagged, along with the OpenAPI document. + +## Read endpoints + +Paths after `api.prefix`. *Level* is the least a token needs; the person's access and where still apply. + +| Endpoint | Level | Notes | +|---|---|---| +| `GET /v2/user`, `GET /v2/team` | My tasks | A team lists no members | +| `GET /v2/team/{team_id}/space`, `GET /v2/space/{id}` | My tasks | Spaces where reaches and whose plan holds the module | +| `GET /v2/space/{id}/folder` | My tasks | Always `{"folders": []}`: every board is a folderless list | +| `GET /v2/space/{id}/list`, `GET /v2/list/{id}` | My tasks | `task_count` counts only what the token may read; columns are `statuses` (first `open`, last `closed`, the rest `custom`) | +| `GET /v2/list/{id}/task`, `GET /v2/team/{team_id}/task` | My tasks (its own) | 100 per page from `page=0`, `last_page`; `archived`, `subtasks`, `include_closed`, `statuses[]`, `assignees[]`, `tags[]`, `due_date_gt`/`_lt`, `date_created_*`, `date_updated_*`, `date_done_*`, `order_by` (`id`, `created`, `updated`, `due_date`), `reverse`, `include_markdown_description`; on a team also `space_ids[]`, `list_ids[]` | +| `GET /v2/task/{id}` | My tasks (its own) | `include_subtasks`, `include_markdown_description` | +| `GET /v2/task/{id}/comment` | My tasks (its own) | 25 per page, newest first; `start` and `start_id` of the oldest one shown give the next page | +| `GET /v2/task/{id}/member` | Read | Everyone attached to the task | +| `GET /v2/space/{id}/tag` | Read | Tags of the open tasks the token reaches | +| `GET /v2/list/{id}/field` | Read | Custom fields a provider sync imported; none otherwise | +| `GET /v2/team/{team_id}/time_entries` | My tasks | The host's time store answers; `start_date`, `end_date`, `assignee`, `task_id` | + +A malformed filter value is 400 `INPUT_002`. Unknown query parameters are ignored on reads. Each list answers in the same number of queries whatever the page holds (8 for a list's tasks, measured at 25 and 100 tasks), the tag filter included. + +**Cost per board.** A team's lists and tasks ask the host's access decision once per board the team reaches (`BOARD_ACCESS_CHECK`, in Beacon a voter), and the plan gate once per space. The bundle's own queries stay the same with 1 board or 10; the host's checks grow with the boards. + +**Time entries.** Without `task_id`, the bundle checks only the team: the host's time hook must keep to what the token's level and where reach, and to the people whose time the person may see. With `task_id`, the task goes through the guard first. + +**What a page shows of other tasks.** Subtasks, dependencies and links are shown only when the token may open the other task too. For *My tasks*, the checklist items of its own tasks are shown whoever created them, as it manages those checklists. + +## Write endpoints + +Every write goes through the services the UI uses: its plan limits, events (notifications, mentions, activity), history and validation. Each answers like ClickUp; a field nothing read is listed in `warnings` (`unknown`, or `not_supported` for a ClickUp field with no equivalent here) and is never saved. + +| Endpoint | Level | Notes | +|---|---|---| +| `POST /v2/list/{id}/task` | Tasks | `name`, `description` / `markdown_content`, `status` (a column's name), `priority` (1 urgent to 4 low), `due_date`, `start_date`, `time_estimate`, `tags`, `assignees`, `parent` (same board). Asks the host's plan limit | +| `PUT /v2/task/{id}` | My tasks (own: `status`, dates, `description` only) | `name`, `priority`, `time_estimate`, `assignees` and `watchers` (`add`, `rem`), `archived` need Tasks. A status change moves the task as the board does: the done column completes it, leaving it reopens it within the host's limit | +| `DELETE /v2/task/{id}` | Tasks | Erases the task and its subtasks; tracked time is kept, detached. The board's manage decision is needed. A task that follows a provider: 409 `TASK_002` | +| `POST /v2/task/{id}/comment` | My tasks (own) | `comment_text`. Mentions work as in the UI's comment box: each candidate written `@Label` in the text is mentioned, so `TASK_COMMENT`, `TASK_MENTION` and the host's notifications follow | +| `POST`, `DELETE /v2/task/{id}/tag/{name}` | Tasks | The task form's rules: a name of 1 to 64 characters, at most 20 tags. `PUT /v2/task/{id}` does not take `tags` (a warning) | +| `POST /v2/task/{id}/field/{field_id}` | Tasks | Always 409 `FIELD_001`: custom fields are a provider's, read-only | +| `POST /v2/task/{id}/dependency`, `DELETE` with `?depends_on=` or `?dependency_of=` | Tasks | Within a board (`LINK_002`); no cycle or duplicate (`LINK_001`) | +| `POST`, `DELETE /v2/task/{id}/link/{links_to}` | Tasks | A `Related` relation | +| `POST /v2/task/{id}/checklist`, `PUT`, `DELETE /v2/checklist/{id}` | My tasks (own) | A checklist with no item is not stored. Deleting archives its items | +| `POST /v2/checklist/{id}/checklist_item`, `PUT`, `DELETE …/checklist_item/{item}` | My tasks (own) | An item is a subtask tagged `checklist:`, counted against the plan's active tasks. `resolved` moves it to the done column or back to the first. *My tasks* assigns an item only to its own person. Deleting archives it. Items are edited under the parent task's right, whoever created them; any editor who tags a subtask `checklist:` makes it an item, visible to a *My tasks* token of the parent | + +| `POST /v2/task/{id}/attachment` | Tasks | Multipart, field `attachment`. The host stores it through `TaskApiAttachmentsInterface`, with all its upload checks (content sniffing, plan storage, sizes); its refusal is the answer. The file's bytes are part of an idempotency key's request | +| `POST /v2/team/{team_id}/time_entries` | My tasks (own task) | `start` and `duration` (1 minute to 7 days) in milliseconds, `tid`, `description`. The token's own person's time only: another `assignee` is 403 `TOKEN_001`. Without `tid`, the host's time store decides; `ManualTaskApiTimeEntries` needs a task | +| `DELETE /v2/team/{team_id}/time_entries/{id}` | — | Always 403 `TIME_002`, before anything is looked up: tracked time is never deleted through the API | + +### Management (Full) + +Every management write needs a *Full* token and the board's manage decision (`BoardAccessCheckEvent::ACTION_MANAGE`; in Beacon `project.tasks.manage`, `organization.tasks.manage`, or a personal board's owner); a new board needs the host's create check for the space's tenant and the board plan limit. + +| Endpoint | Notes | +|---|---| +| `POST /v2/space/{id}/list` | `name`, `content` / `markdown_content`. The token must reach the whole tenant (naming one board of it is not enough). The board gets the default columns, as from the UI | +| `PUT /v2/list/{id}` | `name`, `content`; the slug stays. `status`, `priority`, `assignee`, `due_date` are warned about | +| `DELETE /v2/list/{id}` | Erases every task, tracked time kept and detached, then the columns and the board. Works within a time budget: 200 `{}` when done, otherwise 202 `{"status": "in_progress"}` with `Retry-After`; call again until 200. A 202 is not kept for an idempotency key. A board that follows a provider: 409 `LIST_002`. Two deletes of one board at the same time may both pick the same tasks, so `TASK_ERASED` (with its file refs) and `BOARD_DELETED` can come twice: a host's listeners must delete files idempotently | +| `POST /v2/list/{id}/status` | **Not in ClickUp's API.** A column at the end: `status` (its name), `color` (hex) | +| `PUT /v2/list/{id}/status/{status_id}` | **Not in ClickUp's API.** Renames or recolours a column; what is not given stays | +| `PUT /v2/list/{id}/status/order` | **Not in ClickUp's API.** `order` lists every column id once | +| `DELETE /v2/list/{id}/status/{status_id}` | **Not in ClickUp's API.** Its tasks move to the first remaining column (completed or reopened as the board's last column says, within the host's limit); the last column stays (409 `LIST_001`) | + +Tags exist on tasks only: there is no space tag to create, rename or delete. Board members are not managed through the API. + +**Time store.** `api.hooks.time_entries` lists and logs time; it has no delete. A host without its own may use `Nowo\TaskBoardBundle\Api\Hook\ManualTaskApiTimeEntries`: the bundle's manual entries, logged through `TaskTimeLogger` as the task page logs them, and listed for the token's own person on tasks the token may open (at most 500). + +**Before any write.** A host must listen to `TaskBoardEvents::API_WRITE` (else 503 `AUDIT_001`), and, with `api.quota_listener_required` (the default), to `QUOTA_CHECK` for creates (else 403 `PLAN_002`). A refused limit is 403 `PLAN_002` with the host's reason. + +**Several changes in one request are not one transaction.** They are saved one service at a time, as the UI's separate forms are, and each service's events go out after its save. Everything that can be refused is asked first (people, status, dates, limits), so a refusal changes nothing; a database failure between two saves can leave the first one saved. A client retrying with the same `Idempotency-Key` gets a fresh run after such a failure, since errors are not kept. **Known narrow race:** the limit is asked once before the first save, and the restore and the move ask it again as they save; if another write fills the limit in between, the restore is saved and the move then answers 500. Retrying the same request completes it. + +### Idempotency + +Any write may carry `Idempotency-Key` (1 to 255 characters, else 400 `IDEMP_003`): + +- **Scope:** the token. The same key from another token is another key. +- **Expiry:** 24 hours from the first attempt. +- **Retry of the same request** (method, path and body; JSON compared with its keys sorted): the first answer, its status and body, with `Idempotency-Replayed: true`. The write does not run again. +- **Same key, different request:** 409 `IDEMP_002`. While the first attempt runs: 409 `IDEMP_001`; a claim left for 5 minutes by a process that died is taken over. +- **Refusals and errors are not kept:** a retry is decided afresh. +- **A replay is checked again:** the pipeline, and whether the token still reaches what the write touched (a board that is gone leaves its tenant), else 404. +- **What a replay does not do:** it does not ask the level or the plan again (the first request did, and a replay writes nothing); it does ask the pipeline (token, rate limit) and the reach. +- **Storage:** the bundle's table `task_board_api_idempotency` (hashes of the token id and key, never either; the answer, which can hold task text). Expired rows go on each claim and with `nowo:task-board:api:purge-idempotency`. `forget()` clears one token's answers: text from a task may sit in other tokens' answers for up to 24 hours. On revoking a token or erasing its person, call `TaskApiIdempotencyStoreInterface::forget()` with the scope `sha256('nowo-task-board-api|' . $tokenId)`. A host may replace the store with `api.hooks.idempotency_store`; the table is then not mapped. A store that fails refuses keyed writes with 503 `IDEMP_004`. + +## Differences from ClickUp (so far) + +- Ids are strings: the bundle's UUIDs, and the host's ids for people. +- Dates are Unix milliseconds as strings, at the precision the database keeps (seconds). +- No folders, group assignees, points, task URL or custom task id (`null` or empty). +- Checklists are subtasks tagged `checklist:`; a checklist's id encodes its task and name. +- Tags have no colours (`tag_fg`, `tag_bg` are null). +- `GET /v2/task/{id}/member` lists the task's members, not everyone who can open it. +- Dependencies stay within a board; `Related` and `Duplicates` read as `linked_tasks`. +- Task order defaults to `created` ascending; `reverse=true` flips it. +- `priority: null` reads as normal: every task has a priority. +- Deleting a checklist or an item archives it. +- ClickUp's structured comment fields (`notify_all`, `assignee`, `group_assignee`) are not read; mentions come from the text, as in the UI. +- A task without a column, on a board that has columns, goes to the first column on any update (the task form does the same). Tasks get a column when created, so this concerns only rows made outside the bundle. + +## Off by default + +With `api.enabled: false` a host gets no route, service, listener, parameter or table. + +## What a host provides + +The bundle stores no tokens. `api.hooks.authenticator` is required with `enabled: true`; every other hook has a default, and every default denies. + +| `api.hooks.*` | Interface in `Nowo\TaskBoardBundle\Api\Hook` | Without it | +|---|---|---| +| `authenticator` | `TaskApiAuthenticatorInterface` | The container does not build | +| `rate_limiter` | `TaskApiRateLimiterInterface` | Every request 429 | +| `plan_gate` | `TaskApiPlanGateInterface` | 403 `PLAN_001` | +| `workspace` | `TaskApiWorkspaceInterface` | No teams or spaces, every board 404 | +| `users` | `TaskApiUsersInterface` | Nobody is known | +| `time_entries` | `TaskApiTimeEntriesInterface` (list and log; tracked time is never deleted through the API) | 403 `TIME_001` | +| `attachments` | `TaskApiAttachmentsInterface` | 403 `FILE_001` | +| `idempotency_store` | `TaskApiIdempotencyStoreInterface` | The bundle's store, when writes arrive | + +A listener on `TaskBoardEvents::API_WRITE` (`TaskApiWriteEvent`) audits each write. + +## Firewall + +The API must sit behind a **stateless firewall** whose security user is the token's user, as the access checkers read the security token. `security: false` answers 500 `SERVER_001` on every request. For a bad token the firewall lets the request through with no user, and adds no redirecting `access_control` on the path: the bundle answers, so all refusals share one shape. + +```yaml +nowo_task_board: + api: + enabled: true + prefix: /api/tasks + host_decides_access: true # only with security.resource_scoping: false, when your listeners decide access + hooks: + authenticator: App\Api\TaskApiAuthenticator +``` + +The build refuses `security.allow_unauthenticated: true`, and `security.resource_scoping: false` without `host_decides_access: true`: both would open every board to every token. + +## Access + +Each request: level ∩ the person's access ∩ where. In this order, and the first failure answers: + +| Check | Answer | +|---|---| +| Out of reach: missing, outside where, no space for its tenant, no view access, for My tasks not its own | 404 `ACCESS_001`, always the same | +| The tasks module is not in the plan | 403 `PLAN_001` | +| The level may not | 403 `TOKEN_001` | +| The person may not | 403 `ACCESS_002` | + +Levels are not a chain: *Read* changes nothing, while *My tasks* works on its own tasks. See `TaskApiCapability`. + +**Where.** `TaskApiWhere::limitedTo($tenantKeys, $boardIds)`: each entry grants on its own. A named tenant reaches all its boards; a named board is reached even when its tenant is not listed. Nothing else widens: the person's access, the workspace hook and the tenant check apply to it too. + +**Tenancy.** With `tenancy.enabled`, a board is reached only when the bundle's tenant resolver opens its tenant for the person (the slug being the key), as the UI asks of the tenant in its URL, whatever the workspace hook answers. A board without a tenant is not served then, as in the UI. + +**Notes for hosts.** The security-user check compares ids, not classes: a firewall user of another class with the same id would pass, so use one user class for the API's firewall. An `access_control` refusal on the API's path answers 403 `ACCESS_002`, not 401: add none there. A token whose user the access checker refuses answers 401 like a bad token and counts as a failed token. + +## Errors + +`{"err": "...", "ECODE": "..."}`, with `OAUTH_017` (401, no header), `OAUTH_019` (401, token not valid, one answer for every reason), `API_001` (404, the instance switch), `RATE_001` and `RATE_002` (429), and `SERVER_001` (500, no detail). Answers carry `X-RateLimit-Limit`, `-Remaining`, `-Reset` (Unix seconds) and `Cache-Control: no-store, private`. The credential is read only from the `Authorization` header, with or without `Bearer`, and never logged. + +## Known differences from the UI + +Task text is returned raw. A description or comment that embeds a host file (in Beacon, a `beacon-file:` image) shows an API reader the image's alt text and its UUID, which the UI hides from a viewer who may not open the file. The file's bytes stay behind the host's download check. Wiki and document references are not resolved in task text, so they show nothing more. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5ba2d088..bc602096 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -86,6 +86,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Target: 2.2.0, spec 213 (Task API). Audit status: re-verification pending. Opt-in: with `api.enabled: false` (the default) no route, service, listener, parameter or table changes, and the UI's output stays as it was. See [API](API.md). + +### Added + +- **Task API, management (Full).** Create, rename and delete boards (deleting erases the tasks with time kept, in slices answered 202; a provider-linked board is 409 `LIST_002`), and columns as statuses on four paths of this API's own (`/v2/list/{id}/status…`). Needs the board's manage decision, or the create check and the board plan limit. +- **Task API, files and time.** `POST /v2/task/{id}/attachment` through the host's `TaskApiAttachmentsInterface`; `POST /v2/team/{id}/time_entries` through `TaskApiTimeEntriesInterface`, for the token's own person; `DELETE` of a time entry always 403 `TIME_002`. `Api\Hook\ManualTaskApiTimeEntries` as a time store over the bundle's manual entries. +- **Task API, write endpoints.** Tasks (create, update, delete with time kept), comments, tags, dependencies, links and checklists, through the UI's services. Read writes nothing; My tasks works only on its own tasks and their checklists; custom fields answer 409 `FIELD_001`; a task that follows a provider is not deleted (409 `TASK_002`). Writes need an `API_WRITE` listener (503 `AUDIT_001`) and, for creates, a `QUOTA_CHECK` listener (403 `PLAN_002`). `Idempotency-Key` on every write, 24 hours per token, in `task_board_api_idempotency` or the host's store; `nowo:task-board:api:purge-idempotency`. +- **Task API, read endpoints.** Spaces, folders (none), lists, tasks with ClickUp's filters and 100 per page, a task, comments, members, tags, imported custom fields and time entries (through the host's time hook). Lists hold and count only what the token may read; each list answers in a fixed number of queries. See [API](API.md). +- **Task API, skeleton (`api.*`).** `GET /v2/user`, `GET /v2/team` under `api.prefix`; the request pipeline (failed-token limit, authenticator, security user, rate limit), `Api\Security\TaskApiGuard` (level ∩ access ∩ where), `Api\TaskApiCapability`, one `{err, ECODE}` shape. Hooks in `Api\Hook`, where every default denies; event `TaskBoardEvents::API_WRITE`. The build refuses `security.allow_unauthenticated` and, without `api.host_decides_access`, `security.resource_scoping: false`. +- `Service\TaskManager::restore()` and `archive()`, with their history; `restore()` asks the host's limit and throws `TaskReopenRefusedException` when it refuses. +- `Service\TaskBoardEraser::eraseTask()`: one task and its subtasks, leaf-first through Doctrine, tracked time kept and detached. Needs the board's manage decision; a task that follows a provider, or has a subtask that does, is refused with `TaskLinkedToProviderException`. The files a sync stored for the subtree, kept after an unlink, are named in each `TaskErasedEvent` and their rows removed, so the host can delete them (audit IA-M1). +- `Service\TaskBoardEditor::update()`: name and description of a board, the slug unchanged, `TaskBoardEvents::BOARD_UPDATED` (`BoardUpdatedEvent`). +- `Service\TaskCommentPolicy`: who may write, edit and delete a comment. +- `Validation\TaskConstraints`: the rules of the fields a write takes. +- `Service\TaskDependencyManager::findBetween()`, `Dto\TaskFormData::fromTask()`. + +### Changed + +- `TaskBoardManageController` asks `TaskCommentPolicy` and `TaskManager::restore()`, and the write forms take their rules from `TaskConstraints`. Their output does not change: `tests/Functional/Characterization` pins it. Its `$syncLimits` argument stays in place but is deprecated and ignored: `TaskManager` holds the same limit (audit IA-I1). + ## [2.1.1] - 2026-11-07 Target: 2.1.1. Reported by Beacon on 2.0.2. No schema, configuration or permission change. Audit HZ (2026-11-07): Pass (good) / Low. See [UPGRADING](UPGRADING.md#to-211). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2d3ff911..39da4755 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,6 +6,7 @@ - [Database](#database) - [Routes](#routes) - [Why a route loader instead of `#[Route]` attributes (REQ-SF-004)](#why-a-route-loader-instead-of-route-attributes-req-sf-004) +- [Task API (since 2.2)](#task-api-since-22) - [Security](#security) - [Firewall](#firewall) - [Access checker](#access-checker) @@ -143,6 +144,10 @@ from this configuration, not declared with `#[Route]` attributes on `TaskBoardMa Route names, paths, methods and the prefix are unchanged by 1.21. +## Task API (since 2.2) + +Off by default. `api.enabled`, `api.prefix`, `api.host_decides_access`, `api.quota_listener_required`, `api.max_body_bytes` and `api.hooks.*`: see [API](API.md). + ## Security ### Firewall diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5346359b..759d50e3 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -12,6 +12,7 @@ - [Task types, tag colours and descriptions (since 1.28.0)](#task-types-tag-colours-and-descriptions-since-1280) - [Task numbers and calendar (since 2.1)](#task-numbers-and-calendar-since-21) - [UiKit migration (2.0.0)](#uikit-migration-200) +- [Shared write rules (2.2, re-verification pending)](#shared-write-rules-22-re-verification-pending) - [Logging and personal data](#logging-and-personal-data) - [Personal data in synced tasks (RGPD / LOPDGDD)](#personal-data-in-synced-tasks-rgpd--lopdgdd) - [Dependencies](#dependencies) @@ -27,6 +28,7 @@ | **Task forms** | Symfony forms for board/task creation and updates (title, description, links, members). | | **Configuration** | `nowo_task_board` YAML (routes, table prefix, access checker, team resolver). | | **Provider sync (since 1.23, opt-in)** | Server-side calls to a task provider's API with the host's token, file downloads from the provider's attachment hosts, remote text stored as Markdown. | +| **Task API (since 2.2, opt-in)** | Stateless routes under `api.prefix`, off by default. Token authentication is the host's; `Api\Security\TaskApiRequestListener` and `TaskApiGuard` fail closed. Audit IA: a token whose user the access checker refuses counts as a failed token (L1); with tenancy the guard asks the bundle's tenant resolver too (L2); the security-user check compares ids, not classes, and an `access_control` refusal reads as 403 (I2, I3, documented in [API](API.md)). `TaskManager::archive()` dispatches no domain event, as no UI archive exists; the API's write audit is `API_WRITE` (I5). Writes (2.2, S4): the guard's capability table per write, `API_WRITE` required, the plan limit refused when no `QUOTA_CHECK` listener answers, idempotency keys scoped to the token with hashes only and a replay checked again; the kept answers hold task text for 24 hours (`forget()` on revocation or erasure). Audit status: re-verification pending. | | **TimeTrack bridge** | Optional. When TimeTrack is installed, task provider exposes trackable tasks; access gated by `TaskAccessGuard`. | ## Threats and mitigations @@ -164,6 +166,10 @@ Both opt-in. - **Avatars.** Pictures now pass UiKit's `AvatarUrlPolicy`: https without userinfo, or a same-origin path. - **Subtask nodes.** Titles reach `tree` as text, escaped by the partial, and in `data-*` attributes through `nowo_ui_safe_attrs`. +## Shared write rules (2.2, re-verification pending) + +Spec 213 gives the comment rules, the restore limit and the field constraints one home (`TaskCommentPolicy`, `TaskManager::restore()`, `TaskConstraints`), so a write path other than the forms cannot apply different ones. `TaskBoardEraser::eraseTask()` asks the board's manage decision itself, erases leaf-first so the `PreRemove` that keeps time runs for every subtask (`parent_id` is `ON DELETE CASCADE`, which would skip it), and refuses a task that follows a provider. After an unlink, the files a sync stored for the subtree are named in `TaskErasedEvent::$attachmentRefs` and their rows removed, on every database (audit IA-M1): the host deletes them, as for `deleteBoard()`. `tests/Functional/Characterization` pins the UI's output. + ## Logging and personal data REQ-OBS-001 (since 1.21): every successful board / task write logs one `info` line, an invalid CSRF token one `warning`, diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 4c469b9c..f2ffebd9 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -155,6 +155,14 @@ Hosting FrankenPHP with **kernel not reset** between requests: see [FRANKENPHP-W ## Unreleased +Target: 2.2.0, spec 213. Audit status: re-verification pending. No schema or configuration change is needed, and the UI's output stays as it was. The Task API is off unless you set `api.enabled: true`: see [API](API.md). + +**Board deletes through the API.** `DELETE /v2/list/{id}` erases in slices (202 until done). Two deletes of one board at the same time may both erase the same tasks' rows, so `TASK_ERASED`, with its stored-file refs, and `BOARD_DELETED` can be dispatched twice: make the listeners that delete files or write audit rows idempotent. + +**Schema change with `api.enabled: true` only** (and no `api.hooks.idempotency_store`): the table `task_board_api_idempotency`. Generate it with `doctrine:migrations:diff`; it holds keys for 24 hours, so it needs no data migration. A host with the API off changes nothing. + +`TaskBoardManageController`'s `$syncLimits` argument is deprecated and ignored, kept in its place so positional arguments do not move: a restore asks the limit given to `TaskManager` (`$limits`), which the container wires from the same `sync.limit_checker`. A host that builds either by hand passes the limit to `TaskManager`. + ## To 2.1.1 From **2.1.0**: diff --git a/docs/USAGE.md b/docs/USAGE.md index 84153184..2226431b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -30,6 +30,7 @@ - [No inline styles](#no-inline-styles) - [Look and feel: ClickUp-like views, theming and icons (since 1.12)](#look-and-feel-clickup-like-views-theming-and-icons-since-112) - [Theming with design tokens](#theming-with-design-tokens) +- [Task API (since 2.2)](#task-api-since-22) - [Host hooks (since 2.0.0)](#host-hooks-since-200) - [Icons](#icons) - [Inline new task](#inline-new-task) @@ -76,6 +77,10 @@ Authenticated users open the manage UI (default `/tools/task-board`): | Log time | `task_time` | Since 1.15: minutes, day and note (POST + CSRF) | | Remove time entry | `task_time_remove` | Since 1.15 (POST + CSRF) | +### Deleting and renaming outside the UI (since 2.2) + +The UI has no delete task or rename board. A host calling the services can: `TaskBoardEraser::eraseTask($task, $actor)` deletes a task and its subtasks and keeps tracked time, detached (a task that follows a provider is refused); `TaskBoardEditor::update($board, $name, $description, $actor)` keeps the slug and dispatches `BOARD_UPDATED`. Check access first with `TaskBoardResourceAccessChecker`, and validate with `Validation\TaskConstraints`, the rules the forms use. + ## TimeTrack integration (optional) When TimeTrack is installed and configured with `nowo_task_board.task_provider`: @@ -232,6 +237,10 @@ Overrides and demos must not reintroduce raw `
` / `` / `