# Site languages, language setup and public URL pattern Contract for `features/site_languages.feature`, `features/language_setup.feature` and `features/site_public_url_pattern.feature`. The SPA mocks `/v1` from this page. Errors are plain text whose body is exactly the code, with the status below. A body that cannot be decoded, or that lacks a required key, is 400 with the body-mismatch message ("The request cannot be processed due to a mismatch in the format of the body."). An anonymous caller is 401, a verified user without `settings.manage_sites` is 403 `missing_permission`. Writes need a real session; API keys are refused. An owner is a verified user holding `settings.manage_sites`. A reader is any other verified user. ## Visibility exception (accepted) `version_count` and `published_count` in `GET /v1/site/{key}/languages` are site-level aggregates of every non-deleted article (soft-deleted articles are not shown but still keep their language in use) of the language, drafts and offline ones included. They are visible to every signed-in reader, whatever that reader may open. This is a deliberate exception to the per-version visibility policy of `docs/design/localization-tables.md` ("Hidden versions"), accepted for slice 2. Later slices that expose titles or links of single versions must still filter by what the caller can read. ## Languages of a site The supported tags are the list of `internal/domain/language` (`en`, `en-GB`, `en-US`, `fr`, `fr-CA`, `pt`, `pt-BR`, `es`, `de`, `ar`). Tags are normalized: `pt-br` is stored as `pt-BR`. ### `GET /v1/site/{key}/languages` Any verified user (session or API key). 404 `site_not_found`. ```json { "revision": "9f2c...", "default": "en", "needs_setup": false, "languages": [ {"lang": "en", "enabled": true, "is_default": true, "version_count": 7, "published_count": 6, "social_locale": null, "social_locale_state": "not_set"}, {"lang": "fr", "enabled": true, "is_default": false, "version_count": 4, "published_count": 2, "social_locale": "fr_FR", "social_locale_state": "set"} ], "available": [ {"lang": "pt", "substitute": null}, {"lang": "pt-PT", "substitute": "pt"} ] } ``` - `revision` is an opaque digest of the rows (lang, default, enabled, social_locale). Counts do not change it. Send it back on `PUT`. - `languages` lists the default first, then the other enabled languages by tag, then the disabled ones by tag. - `social_locale_state` is `set` (an explicit `social_locale`), `derived` (no explicit value and the tag has a region: the effective value is the tag with an underscore, `en-GB` gives `en_GB`) or `not_set`. `social_locale` is only the explicit value, so the client computes the derived one from the tag. - `available` lists the tags an owner can pick. A tag with a non-null `substitute` is not supported itself; only the broader tag is. The server refuses it with `unsupported_language`, so the client sends the substitute once the user confirms. - `needs_setup` is true when the site has no confirmed default language. Then `default` is `null`, `languages` is `[]` and `PUT` answers 409 `setup_required`. Sites created after the migration have no language row and need setup too. ### `PUT /v1/site/{key}/languages` Owner only. The whole set is replaced in one transaction under the site lock. ```json { "revision": "9f2c...", "default": "en", "languages": [ {"lang": "en", "enabled": true, "social_locale": null}, {"lang": "fr", "enabled": true, "social_locale": "fr_CA"} ] } ``` `revision`, `default`, `languages` and each `enabled` are required (400 when absent). `social_locale` is optional and `null` means none. A language left out of the list is removed. The answer is 200 with the new read shape. Sending the state that is already stored changes nothing and keeps the revision. Checks run in this order: | Order | Status | Body | When | | --- | --- | --- | --- | | 1 | 404 | `site_not_found` | unknown key | | 2 | 409 | `setup_required` | the site has no confirmed default (any body) | | 3 | 409 | `languages_changed` | `revision` is not the current one; nothing is applied | | 4 | 422 | `invalid_language` | a tag is not a language tag (`english`, empty) | | 4 | 422 | `unsupported_language` | a well formed tag that is not supported (`xx`, `pt-PT`) | | 4 | 422 | `duplicate_language` | the same tag twice, case ignored | | 4 | 422 | `invalid_social_locale` | not `ll_RR` or `lll_RR` (letters, one underscore), empty string, or the language part differs from the tag's language | | 5 | 422 | `no_enabled_language` | no language is enabled, `languages: []` included | | 6 | 422 | `default_not_enabled` | `default` is not in the list, not enabled, or not a supported tag | | 7 | 409 | `language_in_use` | a removed language still has an article, draft, offline, online or soft-deleted (the display count `version_count` stays non-deleted only) | | any | 409 | `languages_changed` | the database could not lock the site in time because another change is running (lock contention); nothing is applied, reload and retry | Entries are checked in list order for step 4. A rejected request changes nothing. Disabling a language never touches its articles, and no article changes `lang`, status or publication. A `social_locale` is stored as `language_REGION` (`fr_fr` becomes `fr_FR`). The language part rule only compares with the tag; matching `Site.Locale` is deferred to slice 6. ## First-time language setup One global setup for every site with no confirmed default language. Owner only (`GET` as well: a reader is 403). ### `GET /v1/language/setup` ```json { "sites": [ {"key": "journal", "name": "Journal", "locale": "en", "article_count": 8, "suggestion": {"lang": "en", "substituted": false}}, {"key": "notes", "name": "Notes", "locale": null, "article_count": 3, "suggestion": null} ], "available": [{"lang": "en", "substitute": null}] } ``` `sites` lists the pending sites by id. `suggestion` comes from `Site.Locale` and keeps the region (`pt-BR` stays `pt-BR`); `substituted` is true when only the broader tag is supported (`fr-BE` gives `fr`). `suggestion` is `null` without a locale or without a supported language. An empty `sites` list means nothing is pending. ### `POST /v1/language/setup` ```json {"sites": [{"key": "journal", "lang": "en"}, {"key": "notes", "lang": "en"}]} ``` 200 with `{"assigned": 16, "sites": [{"key": "journal", "lang": "en", "assigned": 8}]}`. `assigned` counts the articles that got a language (soft-deleted ones included). `sites` lists the sites confirmed by this call. The default language is created enabled and confirmed, the seeded unconfirmed row is replaced, and every article without a language takes the tag. Nothing is translated, linked or moved. Sites that already have a confirmed default are skipped, whatever tag is asked for, so a retry answers 200 with `assigned: 0`. | Status | Body | When | | --- | --- | --- | | 400 | body-mismatch message | no `sites` key or malformed JSON | | 404 | `site_not_found` | a key does not exist | | 409 | `setup_in_progress` | another setup holds the site locks (nothing applied) | | 422 | `invalid_language` / `unsupported_language` | a bad `lang`, empty included | | 422 | `duplicate_site` | the same key twice | | 422 | `setup_incomplete` | a pending site is missing from the request | | 500 | `Internal server error` | a write failed; everything is rolled back | All sites are applied in one transaction. Site rows are locked in ascending id order without waiting; a busy lock is `setup_in_progress`. The pending list is read again after the locks and any site that became pending in between is locked too, until the list is stable, so a site created while a setup starts is never left half handled. The 200 body also has `skipped`: the keys, sorted, of requested sites that already had a confirmed default and were left untouched (always present, `[]` when none). Their requested `lang` is ignored, never applied. ```json {"assigned": 3, "sites": [{"key": "notes", "lang": "fr", "assigned": 3}], "skipped": ["journal"]} ``` ## Lock contention Every locking path of `PUT /v1/site/{key}/languages` and `POST /v1/language/setup` runs inside the transactor. A lock that cannot be taken in time (SQLite `SQLITE_BUSY`/`SQLITE_LOCKED`, MySQL 3572 and 1205, PostgreSQL `55P03`) is recognised once, by the database driver, and reaches the use case as `transaction.ErrLockContention`. The PUT answers 409 `languages_changed` and the setup answers 409 `setup_in_progress`. Nothing is applied and a retry is safe. It is never a 500. ## SQLite does not lock rows The pinned SQLite driver (`github.com/glebarez/sqlite`) drops the `FOR UPDATE` clause: the `SELECT ... FOR UPDATE [NOWAIT]` that locks a site row on MySQL and PostgreSQL is a plain read on SQLite. SQLite instead serializes writers on the whole database: a second writer waits up to the 5 s busy timeout and a transaction that read before another one committed fails with `SQLITE_BUSY` when it tries to write. Both outcomes keep the data consistent and the busy failure is answered as lock contention above. `test/localization_concurrency_test.go` pins the statuses (200 or 409, never 500) for a PUT racing a site creation and for two simultaneous setups. ## Activity log Both writes are recorded in the `sites` category, once after the commit and never for a read or a no-op: | Action | Target | Changes and params | | --- | --- | --- | | `site.languages_edit` | the site | `default_language` (before, after) when the default changed, `languages` (before, after: tags sorted, `(off)` for disabled, `[social_locale]`). A PUT that changes nothing records nothing. | | `site.language_setup` | one event per site confirmed by the POST | `default_language` ("" to the tag), param `assigned` (articles that got the language) | A refused call is recorded as a failed event with a plain reason (for example "The languages changed since they were read."). An unknown site key is not recorded. ## Public URL pattern `public_url_pattern` is a string or `null` on every site JSON (`GET /v1/site/`, `POST`, `PATCH`). It is read with the existing list and written with the existing `PATCH /v1/site/{id}` (owner). No new route. A `PATCH` without the key leaves it unchanged; `null` or `""` unsets it; surrounding spaces are trimmed. Validation, in this order, answers 422 with the code as plain text: `invalid_pattern` (more than 512 characters), `invalid_scheme` (not `https://` or `http://`), `invalid_host` (no host, for example `https:///{slug}`), `unknown_placeholder` (anything between braces other than `{lang}` and `{slug}`, or a stray brace), `not_encoded` (a space, a non-ASCII character or other character that needs URL encoding; `%XX` sequences are fine), `missing_slug` (no `{slug}`). Repeated placeholders are allowed. Saving the pattern changes no article, canonical URL or public response, and is recorded in the activity log as the `public_url_pattern` field.