feat: editor write path

This commit is contained in:
Florian Sylvain committed 2026-11-02 03:24:30 +01:00
1 parent d9158025d4
commit a0b3c58c52
88 files changed
+5738 -263

No files matched your search

+6 -6
View File
@@ -3,7 +3,7 @@
The full, searchable reference is the Integration page of the running app (`/integration`). It
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
readers without the UI. Reading articles (pagination, `format`, body schemas) has its own page,
[api/articles.md](api/articles.md), site languages and the one-time language setup are in [api/site-languages.md](api/site-languages.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
[api/articles.md](api/articles.md), versions, translations and the story move have [api/article-localization.md](api/article-localization.md), site languages and the one-time language setup are in [api/site-languages.md](api/site-languages.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
## Base URL
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
@@ -28,10 +28,10 @@ for example `https://cms.example/v1/article/`.
`Authorization` header answers 403 "Cross-origin requests are not allowed." This blocks
cross-site request forgery with the cookie; scripts on other origins must send their key.
## Routes a key can call (30)
## Routes a key can call (32)
| Group | Routes |
| --- | --- |
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id}, POST /v1/article/{id}/translations, POST /v1/story/{story_id}/move |
| Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
| Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, GET /v1/user/me/export, DELETE /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) |
| Settings | GET /v1/settings |
@@ -161,8 +161,8 @@ A site also carries `site_name` (at most 100 characters, `""` by default), `titl
Articles carry `site_id`. A new article starts on the default site. `GET /v1/article/?site=<key>`
keeps only the articles of that site and composes with `status`, `q`, `sort`, `size` and `page`;
`counts` stay global, an unknown key answers 200 with no item and an empty `site=` is 400 with the
body-mismatch message. `PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` and the SEO
fields below, and nothing else, such as `title`: absent fields are untouched, an empty object, an object without
body-mismatch message. `PATCH /v1/article/{id}` takes `title`, `body`, `site_id`, `author_ids`, the SEO
fields below and `if_revision`, and nothing else: absent fields are untouched, an empty object, an object without
those fields, a `null` value (except on `cover_image_id` and `social_image_id`) or a malformed value is 400 with the body-mismatch message, `site_id: 0` is
400 with the validator text and an unknown site is 404 `site_not_found`. Moving an article never
changes `is_online`. `{"body": ""}` still sets an empty body, but `{}` and `{"body": null}` no longer do: they are 400.
@@ -580,7 +580,7 @@ caller's role, which is read on every request (a key follows its creator's role)
| `GET /v1/article/`, `GET /v1/article/{id}`, `GET /v1/article/{id}/seo` | public, `content.read` widens what drafts show | online articles, plus drafts when `content.read` is `all`, or the caller's own drafts when it is `own` |
| `POST /v1/article/{id}/seo/check` | `content.read` | the article must be visible, else 404 |
| `POST /v1/article/` | `content.create` | none; `created_by` is the caller and the article is always a draft |
| `PATCH /v1/article/{id}` | `content.update` | `own` needs `created_by` to be the caller |
| `PATCH /v1/article/{id}` | `content.update` | `own` needs `created_by` to be the caller, then the article must be readable by the caller, else 404 |
| `DELETE /v1/article/{id}` | `content.delete` | same |
| `PATCH /v1/article/{id}/publish`, `/unpublish` | `content.publish` | same; `PATCH` never changes `is_online` |
| `GET /v1/content-type/{key}/entry`, `GET .../entry/{id}` | `content.read` for `{key}` | `own` lists only the caller's entries (total, page, size and `q` follow) and answers 404 `entry_not_found` for another's entry |
+196
View File
@@ -0,0 +1,196 @@
# Article versions, translations, revisions and story move
Contract for `features/article_translation_create.feature`, `features/article_edit_revision.feature` and `features/article_story_lifecycle.feature`. The SPA mocks `/v1` from this page. The article object itself is described in [articles.md](articles.md), the languages of a site in [site-languages.md](site-languages.md).
## Vocabulary
- A **story** groups the language versions of one article inside one site. A **version** is one article row: it has its own id, `lang`, title, slug, content, publication and `created_by`. A story holds at most one live version per language.
- `revision` is the edit counter of a version, starting at `1`, raised by `1` per saved change (see [What raises revision](#what-raises-revision)). An internal content counter is also kept; it is never exposed on a version, only as `source.revision`.
- A **reader** of a version is a caller who passes the existing read rule: an online version is readable by everyone, an offline one by a verified caller whose `content.read` covers it (`all`, or `own` and the caller created it).
- **Missing means no readable version.** Everything an article read says about sibling versions (translations, source, error bodies, headers, counts) is limited to the versions the caller can read. No count, placeholder, total, header or error text ever names, counts or hints at a version the caller cannot read.
## Errors
Errors are plain text. The body is the code (or an existing message) followed by a line feed, and extra data travels in response headers. A refused request changes nothing.
| Header | Set on | Value |
| --- | --- | --- |
| `X-Current-Revision` | `409 revision_conflict` | the current `revision` of the version, an integer |
| `X-Existing-Article-Id` | `409 language_exists` | id of the readable version that already holds the language |
| `X-Conflicting-Articles` | `409 slug_conflict` | ids of the readable versions whose address is taken at the destination, comma separated, in creation order. Absent when none of them is readable |
| `X-Missing-Languages` | `422 language_not_enabled` on a move or on `PATCH site_id` | tags missing or disabled at the destination, comma separated, sorted, readable versions only. Absent when empty |
| `X-Slug-Suggestion` | `409 slug_taken` | first free `<slug>-N` (existing) |
| `X-Missing-Permission` | `403 missing_permission` | the permission key (existing) |
The headers are set on those responses only. Same-origin SPA code reads them directly; cross-origin scripts do not see them (`Access-Control-Expose-Headers` is unchanged).
| Status | Body | Meaning |
| --- | --- | --- |
| 400 | `The request cannot be processed due to a mismatch in the format of the body.` | undecodable or invalid body, missing `copy`, `copy: false` with a source, `if_revision` invalid, an unknown or empty update |
| 400 | `The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).` | invalid id in the path |
| 400 | the validator text (for example `Key: 'PostTranslation.Title' Error:Field validation for 'Title' failed on the 'min' tag`) | `lang` or `title` missing, title shorter than 3 or longer than 200 characters on a create |
| 400 | `Article title must be between 3 and 200 characters.` | a `PATCH` title outside 3 to 200 runes |
| 400 | `invalid_slug` | a `PATCH` slug that is not a slug |
| 401 | `You must be logged in to access this resource.` | no valid session or key |
| 403 | `missing_permission` | the caller lacks the permission (`X-Missing-Permission`) |
| 404 | `Article not found` | the version, or the story, does not exist or the caller cannot read it |
| 404 | `source_unavailable` | the chosen source does not exist or is not readable; the same answer for both |
| 404 | `site_not_found` | `POST /v1/story/{story_id}/move` with an unknown site key |
| 409 | `revision_conflict` | `if_revision` is not the current `revision` (`X-Current-Revision`) |
| 409 | `language_exists` | the language already has a readable version (`X-Existing-Article-Id`) |
| 409 | `language_unavailable` | one neutral answer: the language is disabled on the site, or its version exists but is not readable by the caller, or a concurrent request created it first |
| 409 | `setup_required` | the version has no language yet (`lang` `""`): the site default is not confirmed |
| 409 | `slug_taken` | a `PATCH` slug used by another article of the same site and language (`X-Slug-Suggestion`) |
| 409 | `slug_conflict` | a move would put an address that already exists at the destination (`X-Conflicting-Articles`) |
| 409 | `story_move_required` | `PATCH site_id` on a version whose story holds several versions |
| 409 | `story_busy` | the database could not lock the rows in time (lock timeout, deadlock or serialization failure); retry |
| 422 | `invalid_language` | the tag is not a language tag |
| 422 | `unsupported_language` | a valid tag outside the supported list |
| 422 | `language_not_enabled` | the site has no language row for the tag (creation), or a destination site lacks or has disabled a language of the story (move, `X-Missing-Languages`) |
| 422 | `language_disabled` | publishing an offline version whose language is disabled on its site |
| 422 | `source_other_story` | the source belongs to another story |
## Fields added to an article read
Added to the article object of `GET /v1/article/{id}`, `POST`, `PATCH`, `publish`, `unpublish`, and for `revision` also to each item of `GET /v1/article/`. Every other key keeps its name, value and type.
```json
{
"id": 10,
"lang": "en",
"story_id": 5,
"revision": 3,
"capabilities": ["read", "edit", "publish", "delete", "create_translation"],
"translations": [
{"id": 10, "lang": "en", "title": "Renovation costs", "is_online": true, "language_enabled": true, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]},
{"id": 13, "lang": "ar", "title": "Takalif", "is_online": true, "language_enabled": true, "capabilities": ["read", "create_translation"]},
{"id": 12, "lang": "fr", "title": "Couts", "is_online": false, "language_enabled": false, "capabilities": ["read", "edit", "publish", "delete", "create_translation"]}
],
"source": {"article_id": 10, "revision": 4}
}
```
- `revision` is a positive integer on every read and write, public reads included.
- `capabilities` lists what the caller may do with this version, always in this order and always starting with `read`: `edit` (`content.update` covers it), `publish` (`content.publish`), `delete` (`content.delete`), `create_translation` (the caller holds `content.create`). An anonymous caller gets `["read"]`.
- `translations` lists the readable versions of the story, the version being read included, with `id`, `lang`, `title`, `is_online`, `language_enabled` and their own `capabilities`. A version without language (`lang` `""`) reports `language_enabled: true`. The order is the default language of the site first, then the other tags in ascending order (`en`, `ar`, `de`, `fr`); a disabled language is still listed with `language_enabled: false`. A single-version article lists itself. An anonymous caller gets `[]`.
- `source` is `{"article_id", "revision"}` when the version was created by copying another version and that source still exists and is readable by the caller, otherwise `null` (a version created empty, a deleted source, an unreadable source, an anonymous caller). `source.revision` is the content revision of the source at the moment of the copy.
## What raises revision
A `PATCH` raises `revision` by exactly `1` when it changes at least one stored value, whatever the number of fields, and not at all when it changes nothing. Publishing and unpublishing never raise it.
| Changed field | `revision` | internal content revision |
| --- | --- | --- |
| `title`, `body`, `excerpt`, `slug`, `cover_image_alt`, `seo_title`, `seo_description`, `social_title`, `social_description` | +1 | +1 |
| `author_ids`, `cover_image_id`, `social_image_id`, `canonical_url`, `noindex`, `seo_dismissed`, `site_id` | +1 | unchanged |
Existing rows start at revision `1`. A story move raises each moved version by `1`.
## Editing: `PATCH /v1/article/{id}`
Verified session or API key with `content.update` on the version (`403 missing_permission` otherwise, checked first) and able to read it (`404 Article not found` otherwise, checked second), so a stale `if_revision` never reveals anything to a caller who cannot edit or read the version.
- `title`: 3 to 200 runes (a 4-byte emoji counts as one). `""`, 2 and 201 runes answer `400 Article title must be between 3 and 200 characters.`; `null` answers `400` with the body-mismatch message. Editing the title never changes the slug and never changes the publication: an online version shows the new title publicly at once.
- `slug`: changed only when sent. `""` regenerates it from the current title. There is no redirect, the old address stops resolving. Unique per site and language: the same slug in two languages of a story is allowed. A slug already used by another article of the same site and language answers `409 slug_taken` with `X-Slug-Suggestion`. Sending the current slug changes nothing.
- `if_revision`: optional integer `>= 1`. A value that is `0`, negative, a string, a fraction or `null` answers `400` (body mismatch). `if_revision` alone is not an update (`400`). When it differs from the current `revision`, nothing is stored and the answer is `409 revision_conflict` with `X-Current-Revision`, even when the rest of the request would change nothing. The compare and the write are one atomic step: of two concurrent saves with the same `if_revision`, one answers `200` and the other `409`. Without `if_revision` the last write wins. To overwrite after a conflict, send `if_revision` equal to the `X-Current-Revision` the conflict showed; if somebody saved in between, the answer is `409` again with the newer revision.
- `site_id` on a version whose story holds several versions the caller can read answers `409 story_move_required` and changes nothing (hidden versions are not counted, so the answer never reveals them). When the caller reads only this version of a larger story, `content.update` is required on every version of the story, readable or not (`403 missing_permission` otherwise, naming no version) and the request runs [the story move](#moving-a-story-post-v1storystory_idmove) rules: every version moves in one transaction, language and slug refusals roll everything back, and headers name readable versions only. The other fields of the same request (`title`, `author_ids`, `slug`, SEO fields) are part of that transaction: a refusal of any of them, an unknown author or an invalid SEO value included, leaves every version on its original site and every `revision` unchanged, and the edited version's `revision` rises by exactly 1 for the whole request. To move a story with several readable versions, use [the story move](#moving-a-story-post-v1storystory_idmove). On a story with one version it moves the version when its language is enabled at the destination (`422 language_not_enabled`, with `X-Missing-Languages`, otherwise); a version without language moves without a language check. The slug rule of the destination still applies (`409 slug_taken`).
- `lang`, `story_id`, `source_article_id` and `copy` in the body are ignored.
The `200` body is the full article object with the new `revision`.
## Publishing
`PATCH /v1/article/{id}/publish` on an offline version whose language is disabled on its site answers `422 language_disabled`. Publishing an already online version is idempotent (`200`, unchanged) even when its language is disabled; a version without language publishes as before. `unpublish` and `PATCH` stay available in a disabled language. Publishing or unpublishing one version never touches its siblings.
## Creating a translation
Two entry points share one implementation: the same result, errors, headers and stored data.
### `POST /v1/article/{id}/translations`
Verified session or API key with `content.create`; the version `{id}` must be readable by the caller.
```json
{"lang": "fr", "title": "Couts de renovation", "copy": true, "source_article_id": 11}
```
| Field | Rule |
| --- | --- |
| `lang` | required; normalized (`FR` is stored as `fr`) |
| `title` | required, 3 to 200 characters |
| `copy` | required boolean (`400` when absent) |
| `source_article_id` | optional, only with `copy: true` (`400` with `copy: false`); default is `{id}`; must be a readable version of the same story |
The new version joins the story of `{id}`, in the same site, as an offline draft created by the caller, with `revision` `1`. The slug is generated from the new title in (site, language) and gets the next free `-N` suffix when taken.
- `copy: true` copies `body`, `excerpt`, `seo_title`, `seo_description`, `social_title`, `social_description`, `cover_image_id`, `cover_image_alt`, `social_image_id` and the authors from the source, and never `slug`, `canonical_url` (empty), `noindex` (false) or `seo_dismissed` (empty). No image or file row is duplicated. The source and its content revision are captured in the same transaction as the copy: the read shows `"source": {"article_id": <source>, "revision": <content revision then>}`.
- `copy: false` starts empty: empty text, no images, `authors` is the creating user's byline, `"source": null`.
Answers `201` with the created article object. Order of checks: permission, path id, body, version readable, `copy` rules, language tag, `setup_required`, source, language configuration. A story deleted in the meantime answers `404 Article not found`. Both entry points leave the stored `lang` normalized.
### `POST /v1/article/` with `story_id`
```json
{"title": "Couts", "lang": "fr", "story_id": 5, "copy": true, "source_article_id": 10}
```
`story_id` selects the story (`404 Article not found` when it has no readable version, the same answer as a missing story) and the first readable version, oldest first, plays the role of `{id}`. `lang` is then required (`400`). `source_article_id` and `copy` are only valid with `story_id` (`400` otherwise).
### `POST /v1/article/` without `story_id`
Unchanged: it creates a new story with one offline version. `lang` is now optional: absent, the version gets the starting language of the site as before; present, it is normalized and must be an enabled language of the default site (`422 invalid_language`, `unsupported_language`, `language_not_enabled`; `409 language_unavailable` when disabled; `409 setup_required` before the site default is confirmed).
## Deleting
`DELETE /v1/article/{id}` removes one version (`204`, empty body) and checks `content.delete` per version (`403 missing_permission`). Other versions and their story remain. Deleting the last version of a story removes the story. Nothing in the answer mentions a sibling. A deleted language can be created again in the same story; a language with a deleted version still counts as in use for removing the language from the site.
## Moving a story: `POST /v1/story/{story_id}/move`
```json
{"site": "atelier"}
```
Verified session or API key. The caller needs `content.update` on every version of the story, readable or not, and must read at least one (`404 Article not found` otherwise, also for an unknown story). `site` is the key of the destination (`400` when absent or empty, `404 site_not_found` when unknown). Answers `200`:
```json
{"story_id": 5, "site": "atelier", "site_id": 2, "article_ids": [10, 12, 11, 13]}
```
`article_ids` lists the readable versions only, oldest first. Every version moves in one transaction: `site_id` changes, slugs, languages, publication, content and authors are unchanged, `revision` of each moved version rises by 1 and its content revision does not. Moving to the current site answers `200` and changes nothing.
Everything is checked inside the transaction, under the locks of both sites, and nothing moves when something fails: every language of every version (readable or not) must be enabled at the destination (`422 language_not_enabled`, `X-Missing-Languages` limited to languages of readable versions, a language disabled at the destination counts as missing, a language disabled at the source does not matter), and no address may exist at the destination in the same language (`409 slug_conflict`, `X-Conflicting-Articles` limited to readable versions). A conflict that appears between the check and the write answers `409 slug_conflict` without the header.
## Mock-ready examples
Statuses of the new and changed calls. Bodies are plain text unless shown as JSON; headers listed are the only extra ones.
| Call | Status | Headers | Body |
| --- | --- | --- | --- |
| `GET /v1/article/10` (see above) | 200 | | article object with `revision`, `capabilities`, `translations`, `source` |
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts","copy":true}` | 201 | | article object, `lang` `"fr"`, `"is_online": false`, `"revision": 1`, `"source": {"article_id": 10, "revision": 4}` |
| `DELETE /v1/article/12` | 204 | | empty |
| `POST /v1/story/5/move` `{"site":"atelier"}` | 200 | | `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}` |
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts"}` | 400 | | `The request cannot be processed due to a mismatch in the format of the body.` |
| `PATCH /v1/article/10` `{"title":"ab"}` | 400 | | `Article title must be between 3 and 200 characters.` |
| `POST /v1/article/10/translations` with a cookie of an anonymous client | 401 | | `You must be logged in to access this resource.` |
| `POST /v1/story/5/move` by a user holding `content.update` at `own` | 403 | `X-Missing-Permission: content.update` | `missing_permission` |
| `POST /v1/article/9999/translations` | 404 | | `Article not found` |
| `POST /v1/article/10/translations` with `"source_article_id":9999` | 404 | | `source_unavailable` |
| `POST /v1/story/5/move` `{"site":"nowhere"}` | 404 | | `site_not_found` |
| `PATCH /v1/article/10` `{"body":"x","if_revision":3}` after somebody saved | 409 | `X-Current-Revision: 4` | `revision_conflict` |
| `POST /v1/article/10/translations` `{"lang":"de","title":"Kosten","copy":true}` | 409 | `X-Existing-Article-Id: 11` | `language_exists` |
| `POST /v1/article/10/translations` `{"lang":"fr","title":"Couts","copy":true}` with fr disabled, unreadable or raced | 409 | none | `language_unavailable` |
| `POST /v1/article/30/translations` on an article without language | 409 | | `setup_required` |
| `PATCH /v1/article/10` `{"slug":"taken-en"}` | 409 | `X-Slug-Suggestion: taken-en-2` | `slug_taken` |
| `POST /v1/story/5/move` `{"site":"atelier"}` with an address taken at the destination | 409 | `X-Conflicting-Articles: 12` | `slug_conflict` |
| `PATCH /v1/article/10` `{"site_id":2}` on a story with several readable versions | 409 | | `story_move_required` |
| `POST /v1/article/10/translations` `{"lang":"pt","title":"Custos","copy":true}` with no `pt` row | 422 | | `language_not_enabled` |
| `POST /v1/story/5/move` `{"site":"atelier"}` with no `ar` at the destination | 422 | `X-Missing-Languages: ar` | `language_not_enabled` |
| `PATCH /v1/article/15/publish` with `es` disabled | 422 | | `language_disabled` |
| `POST /v1/article/10/translations` `{"lang":"xx","title":"Couts","copy":true}` | 422 | | `unsupported_language` |
| `POST /v1/article/10/translations` `{"lang":"english","title":"Couts","copy":true}` | 422 | | `invalid_language` |
| `POST /v1/article/10/translations` with `"source_article_id":20` of another story | 422 | | `source_other_story` |
## Activity
No new action code. The existing events carry `params.lang` (the language of the version, omitted for a version without language): `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete`. A translation creation records `article.create` on the new version with `params.lang`, `params.story_id` and `params.source_article_id` (only when copied). A story move records one `article.edit` per moved version, with a `site` change (site names before and after) and `params.lang`; moving to the current site records nothing. A refused creation or move records one failed event with a plain reason that names no version; refusals answered `404` are not recorded.
+4 -2
View File
@@ -1,8 +1,10 @@
# Reading articles
Versions, translations, `revision`, `if_revision`, capabilities and the story move: [article-localization.md](article-localization.md).
Every route that returns an article accepts the optional `format` query parameter and returns the article body in that format. Articles are stored as HTML (Tiptap output). Storage and the request bodies of `POST /v1/article/` and `PATCH /v1/article/{id}` stay HTML.
`PATCH /v1/article/{id}` takes `body`, `site_id`, `author_ids` (the whole ordered list of author ids, see `docs/api.md`) and the SEO fields listed below, and nothing else. The request must contain at least one of them, and `null` answers `400` except for `cover_image_id` and `social_image_id`. Behaviour change: `{}` and `{"body": null}` used to set an empty body and now answer `400`; send `{"body": ""}` to empty a body.
`PATCH /v1/article/{id}` takes `title`, `body`, `site_id`, `author_ids` (the whole ordered list of author ids, see `docs/api.md`), the SEO fields listed below and the optional `if_revision`, and nothing else. The request must contain at least one of them (`if_revision` alone is not an update), and `null` answers `400` except for `cover_image_id` and `social_image_id`. Behaviour change: `{}` and `{"body": null}` used to set an empty body and now answer `400`; send `{"body": ""}` to empty a body.
## Routes that honour `format`
@@ -93,7 +95,7 @@ Breaking change: the default used to be HTML. Clients that need HTML must send `
}
```
`lang` is the full BCP 47 language tag of the article (`""` while the site default language is not confirmed) and `story_id` is the story the article belongs to. Both are present on every article object (list items, reads, create and update responses, public reads) as additive keys, and every other key is unchanged. They are read-only: `lang` and `story_id` sent in a `POST` or `PATCH` body are ignored. Supported tags: `en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`. Existing articles each get their own story and `lang` `""` until the site default is confirmed.
`lang` is the full BCP 47 language tag of the article (`""` while the site default language is not confirmed) and `story_id` is the story the article belongs to. Both are present on every article object (list items, reads, create and update responses, public reads) as additive keys, and every other key is unchanged. They are read-only on `PATCH`, where `lang` and `story_id` are ignored. `POST /v1/article/` takes `lang`, `story_id`, `source_article_id` and `copy` only as described in [article-localization.md](article-localization.md), which also documents `revision`, `capabilities`, `translations` and `source`, the additive keys of every article read. Supported tags: `en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`. Existing articles each get their own story and `lang` `""` until the site default is confirmed.
`created_by` is the id of the user who created the article (`0` when it is attributed to nobody). It is present on every article object (list, read, create and update responses) only when the caller is signed in with a verified session or authenticates with an API key; the key acts as its creator. Anonymous callers, and unverified or stale sessions, get the same object without the field. The SPA reads it to decide which rows a role limited to `own` can edit, delete or publish.
+1 -1
View File
@@ -94,7 +94,7 @@ The use cases `ListAuthors`, `CreateAuthor`, `CreateMyAuthor`, `UpdateAuthor`, `
`internal/domain/slug` holds the one slug rule (`Generate`, `WithSuffix`, `IsValid`: lowercase, accents folded, runs of other characters become one dash, 100 characters, a caller-chosen fallback). `internal/domain/article` uses it with the fallback `article` and holds the twelve SEO fields of `Article`, the `Update` fields for them (`Nullable` for the two image ids), `ValidateCanonicalURL`, the eleven dismiss codes and `NormalizeDismissed`, and the sentinels `ErrInvalidSlug`, `ErrSlugTaken` and `ErrInvalidCanonicalURL`. `*SlugTakenError` wraps `ErrSlugTaken` and carries the suggested free slug; the handler turns it into the `X-Slug-Suggestion` header of the 409. `author.GenerateSlug` delegates to the same package.
Slugs are unique per site and language, enforced by the unique index `idx_articles_site_slug` on `(site_id, lang, slug)` (the name is kept from the earlier `(site_id, slug)` index, which the migration replaces once, tracked by the `article_slug_index_per_language` marker). Each article belongs to a story (`stories` table) and a second unique index `idx_articles_story_lang` on `(story_id, lang)` allows one version per language per story; both indexes are created after the `article_stories` backfill marker, which gives every pre-existing article its own story. `site_languages` holds one default language per site, seeded once from `Site.Locale` as an unconfirmed suggestion (`site_language_suggestions` marker). `CreateArticle` owns the rule that a new article takes the confirmed default: it runs through `transaction.Transactor`, asks `language.Repository.LockStartingLanguage` (which locks the site row and returns the confirmed default tag, or `""` while it is unconfirmed), picks the free slug for that language and calls `Create` with the resulting `Lang`, all in one transaction. `ArticleRepository.Create` only stores what it receives and opens the new story in the same transaction (the story rolls back with the article, `content_revision` starts at 1). `SiteLanguageRepository.ConfirmDefault` (lock, read the default, guard, flag it confirmed, assign unassigned articles) holds the same site lock, so no article is left unassigned once the confirmation commits. The "default and confirmed" predicate lives in one repository helper shared by both. `article.Repository.SlugTaken` takes the language, matching the index. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row, checks the slug inside the transaction and re-explains a failed write as `ErrSlugTaken`. Deleting an article replaces its slug with the tombstone `#deleted-<id>` (it can never match a valid slug) before the soft delete, so the slug is free again while the unique index keeps covering soft-deleted rows.
Slugs are unique per site and language, enforced by the unique index `idx_articles_site_slug` on `(site_id, lang, slug)` (the name is kept from the earlier `(site_id, slug)` index, which the migration replaces once, tracked by the `article_slug_index_per_language` marker). Each article belongs to a story (`stories` table) and a second unique index `idx_articles_story_lang` on `(story_id, lang)` allows one version per language per story; both indexes are created after the `article_stories` backfill marker, which gives every pre-existing article its own story. `site_languages` holds one default language per site, seeded once from `Site.Locale` as an unconfirmed suggestion (`site_language_suggestions` marker). `CreateArticle` owns the rule that a new article takes the confirmed default: it runs through `transaction.Transactor`, asks `language.Repository.LockStartingLanguage` (which locks the site row and returns the confirmed default tag, or `""` while it is unconfirmed), picks the free slug for that language and calls `Create` with the resulting `Lang`, all in one transaction. `ArticleRepository.Create` only stores what it receives and opens the new story in the same transaction (the story rolls back with the article, `content_revision` starts at 1). `SiteLanguageRepository.ConfirmDefault` (lock, read the default, guard, flag it confirmed, assign unassigned articles) holds the same site lock, so no article is left unassigned once the confirmation commits. The "default and confirmed" predicate lives in one repository helper shared by both. `article.Repository.SlugTaken` takes the language, matching the index. The use cases decide first: `CreateArticle` generates the slug from the title and takes the first free `-N` on the default site (retrying when the repository reports a race), `UpdateArticle` re-checks the slug on the target site for an edited slug, an empty slug (regenerated from the title) and a move, and computes the suggestion as the first free `-N` from 2, fitting 100 characters. `ArticleRepository` locks the site row, checks the slug inside the transaction and re-explains a failed write as `ErrSlugTaken`. `PATCH /v1/article/{id}` is one atomic edit: when it moves a story (the caller reads one version of a larger story), `UpdateArticleUseCase` depends on the `StoryMover` port and runs the move of the other versions and its own field edit in the single transaction of `MoveStoryUseCase.MoveStoryThen`, so a refusal after the move step moves nobody and changes no revision. Validation that needs no lock (authors, SEO fields, slug rules and the slug of the target site) runs before the transaction. Row locks are taken site first (ascending ids), then article rows (`ArticleRepository.Update` finds the site with a read outside its transaction, because a read inside would open the snapshot that MariaDB 11 then holds against the locked row, and re-checks the site after locking the article), in `ArticleRepository.Update`, `Create`, `Delete`, `CreateTranslation` and the story move, so a slug edit cannot deadlock a move; the move re-reads and re-locks until the set of sites of the story is stable. Publish and `UpdateBody` are targeted `UPDATE`s of their own columns (plus `updated_at`), so they never overwrite a concurrent edit and never touch the revisions. Driver lock errors (PostgreSQL `55P03`, `40P01` and `40001`, MySQL `3572`, `1205` and `1213`, MariaDB `1020` raised by its snapshot isolation) all become `transaction.ErrLockContention` (`409 story_busy`). SQLite ignores `FOR UPDATE`: there the single writer plus the compare-and-set on `edit_revision` in `writeArticleColumns` are what keep concurrent edits correct, not the row locks. Deleting an article replaces its slug with the tombstone `#deleted-<id>` (it can never match a valid slug) before the soft delete, so the slug is free again while the unique index keeps covering soft-deleted rows.
`database.Migrate` adds the columns with `AutoMigrate`, then runs `BackfillSiteTitleTemplates` and `BackfillArticleSlugs` (only rows with an empty slug or a duplicate within the same site and language, in id order, per site and language, applying the same rule and the same suffixes, with the tombstone for soft-deleted rows) and only then creates the unique index, so it is idempotent and completes a partly backfilled table. The default `title_template` is written by the backfill and the seed, not by a column default, because the SQLite migrator rebuilds the table on every start when the default contains `%` and `|`.
+2 -2
View File
@@ -58,8 +58,8 @@ Sleep for `Retry-After` seconds, then retry once. Do not retry in a tight loop.
## Writes
- A title cannot be changed after creation. Create a new article instead.
- `PATCH /v1/article/{id}` accepts `body`, `site_id`, `author_ids` and the SEO fields only. `{}` and `null` values answer `400`, except `cover_image_id` and `social_image_id`, where `null` removes the image.
- A title can be changed with `PATCH /v1/article/{id}` (3 to 200 characters); the slug is not regenerated. Send `if_revision` to refuse overwriting a newer save (`409 revision_conflict`).
- `PATCH /v1/article/{id}` accepts `title`, `body`, `site_id`, `author_ids`, `if_revision` and the SEO fields only. `{}` and `null` values answer `400`, except `cover_image_id` and `social_image_id`, where `null` removes the image.
- Request bodies are HTML even though responses default to markdown. Send `<p>` and `<h2>`, not markdown syntax, or it is stored literally.
- Ids in paths are unsigned integers. Anything else answers `400`.
+1 -1
View File
@@ -10,7 +10,7 @@ export RENEWCMS_API_KEY=rcms_...
## 1. Create a draft
Titles are 3 to 200 characters and cannot be changed later. The request body is HTML; the response body is markdown unless you pass `?format=html` or `?format=json`.
Titles are 3 to 200 characters and can be edited later with `PATCH`. The request body is HTML; the response body is markdown unless you pass `?format=html` or `?format=json`.
```sh
curl -X POST "{{BASE_URL}}/v1/article/" \