Feature: Editorial article list by story and language A signed-in editor lists the articles of the back-office either as one row per story (all languages), or per language, with coverage, a "not in this language" view, an author filter and counts. The grouped response is an explicit opt-in on the same endpoint (group=story). Without it, GET /v1/article/ keeps exactly the flat envelope of features/articles_pagination_api.feature. Only versions the caller can read contribute titles, authors, timestamps, coverage, results and counts. Background: Given the storage, translation and lifecycle rules of features/article_localization_storage.feature, features/article_translation_create.feature and features/article_story_lifecycle.feature apply And error responses are plain text, and a malformed query answers 400 with apperr.BodyErrorMessage, validated with validate tags through validator.Validate.Struct And site "journal" has enabled languages en (default), fr, de, ar and disabled languages es, it And the "Journal" dataset holds 8 stories and 17 versions (article id, lang, state, updated_at, byline): | story | article ids and versions | byline | updated_at of the latest version | | 1 | 101 en online, 102 fr draft, 103 de online, 104 ar draft | Marta Alves | 2026-10-30 10:00 | | 2 | 201 en online, 202 fr online, 203 de online | Camille Roche | 2026-10-29 09:00 | | 3 | 301 en online, 302 fr online | Luis Ferrer | 2026-10-28 09:00 | | 4 | 401 en draft | Marta Alves | 2026-10-27 09:00 | | 5 | 501 en online, 502 de draft | Luis Ferrer | 2026-10-14 09:00 | | 6 | 601 en online, 602 fr draft | Camille Roche | 2026-10-13 09:00 | | 7 | 701 en online, 702 de online | Marta Alves | 2026-10-12 09:00 | | 8 | 801 es online | Luis Ferrer | 2026-10-10 09:00 | And an "owner" holds every content permission at scope all And a "reader" holds content.read at scope all only: drafts of other users are hidden and no create_translation capability is held And a "restricted writer" holds content.create and content.read at scope own And a caller is "editorial" when the jwt cookie carries a user_id claim of a verified user whose role is resolved; every other caller is "public" And the new query parameters are: group (only "story"), lang (a supported language tag, normalized as in features/article_localization_storage.feature), tab (all, missing; review is reserved for features/ of release 2 and answers 400 until then), author (a byline id, integer >= 1) And the existing parameters page, size, q, status, site, seo and sort keep their meaning, defaults and validation; filter changes are the client's job to page-reset And a visible version is one the caller may read under the scopes of features/roles_permissions_content_scope.feature; a hidden version is never counted, titled, named in coverage, in missing_langs or in an error And the repository query is portable across SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL: no dialect-only construct, request text never reaches the SQL, and the access filter is applied inside the query, not after paging Rule: Public lists are unchanged Scenario: A bare request is still the flat envelope Given the "Journal" dataset and an owner session When the client sends GET /v1/article/ Then the body keys are exactly items, total, page, size and counts, and each item is the article object of the version, as before this slice And total is 17 versions' worth of visible articles, not 8 Scenario: lang, tab and group are not read unless asked Given the "Journal" dataset When a public client sends GET /v1/article/?size=5 Then the body is identical to what it was before this slice, with lang and story_id on each item as defined in features/article_localization_storage.feature Scenario Outline: group=story needs an editorial caller Given the "Journal" dataset When sends GET /v1/article/?group=story Then the response status is 400 and the body is "The request cannot be processed due to a mismatch in the format of the body." And the repository was not called Examples: | caller | | a client without a jwt cookie | | a client with a malformed or expired cookie | | a client whose user is not verified | Scenario Outline: Invalid new parameters answer 400 Given an owner session When the client sends GET /v1/article/? Then the response status is 400 and the body is "The request cannot be processed due to a mismatch in the format of the body." And the repository was not called Examples: | query | | group=stories | | group= | | group=story&tab=review | | group=story&tab=published | | group=story&tab= | | tab=missing | | group=story&lang=english | | group=story&lang=xx | | group=story&lang= | | group=story&author=0 | | group=story&author=abc | | group=story&author= | | author=-1 | Rule: Grouped rows, one per story Scenario: All languages, page 1 Given the "Journal" dataset and an owner session When the client sends GET /v1/article/?group=story&size=5 Then the response status is 200 and total is 8, page 1, size 5 And the story_id of the items are 1, 2, 3, 4, 5 in that order And counts.all is 8 Scenario: All languages, page 2 is the last When the owner sends GET /v1/article/?group=story&size=5&page=2 Then the story_id of the items are 6, 7, 8 and total is 8 Scenario: A page beyond the last is empty with the real total When the owner sends GET /v1/article/?group=story&size=5&page=99 Then items is [] and total is 8 and counts.all is 8 Scenario: A grouped row describes the representative version and the story When the owner sends GET /v1/article/?group=story Then the item of story 1 carries the fields of article 101 (id 101, lang "en", title, slug, is_online, site_id, story_id 1 and the other list fields of the flat item) And it adds story_updated_at "2026-10-30T10:00:00Z", story_authors (the union of the bylines of the readable versions, without duplicates) and capabilities of article 101 And it adds coverage and missing_langs Scenario Outline: The representative is the default-language version, else the first readable by language code Given the "Journal" dataset and an owner session When the client sends GET /v1/article/?group=story Then the representative id of story is and its lang is "" Examples: | story | id | lang | | 1 | 101 | en | | 4 | 401 | en | | 8 | 801 | es | Scenario: The representative falls back when the default version is unreadable Given story 5 holds a draft en article created by another user When a restricted writer sends GET /v1/article/?group=story Then the representative of story 5 is the first readable version by language code, never the hidden one And no field of the row names the hidden one Scenario: story_updated_at is the latest readable version update Given article 102 (fr draft of story 1, creator another user) was updated at 2026-11-01 08:00 and is hidden from the caller When a restricted writer or a reader sends GET /v1/article/?group=story Then story 1's story_updated_at ignores article 102 When the owner sends the same request Then story 1's story_updated_at is "2026-11-01T08:00:00Z" Scenario Outline: Coverage lists readable versions in the chip order, then the languages that can be created Given the "Journal" dataset and an owner session When the client sends GET /v1/article/?group=story Then the coverage of story is And each entry has id, lang, title, is_online, language_enabled and capabilities, like an entry of "translations" in features/article_story_lifecycle.feature And missing_langs of story is Examples: | story | coverage | missing | | 1 | en, ar, de, fr | [] | | 3 | en, fr | ["ar","de"] | | 4 | en | ["ar","de","fr"] | | 8 | es (language_enabled false) | ["en","ar","de","fr"] | Scenario: Coverage order is default, other enabled by tag, disabled by tag; missing is listed by the same rule Given story 8 gains a readable it version, and story 8's default en version does not exist Then the coverage of story 8 is es then it, both with language_enabled false And missing_langs starts with the default language en Scenario: A caller who cannot create gets no missing_langs When a reader sends GET /v1/article/?group=story Then missing_langs is [] on every row And the coverage lists only the readable versions of each story Scenario: The row of a story whose versions are all hidden does not exist Given story 4's only version is a draft of another user When a restricted writer sends GET /v1/article/?group=story Then story 4 is in no item, in total or in counts And total is the same as if article 401 did not exist Scenario: Hidden versions leave no trace Given the "Journal" dataset and a restricted writer who cannot read the drafts 102, 104, 401, 502 and 602 When the restricted writer sends GET /v1/article/?group=story&size=100 Then the items, total, counts and every coverage and missing_langs equal those of a database in which those articles do not exist And no response field or header names them Scenario: Unassigned articles are their own story with an empty language Given article 30 has lang "" and its own story When the owner sends GET /v1/article/?group=story Then its row has lang "", coverage with its single version, and missing_langs [] And it never appears in the "not in" view of any language Rule: Language selected means one row per version of that language Scenario: French selected When the owner sends GET /v1/article/?group=story&lang=fr&size=5 Then total is 4 and the story_id of the items are 1, 2, 3, 6 And each representative is the fr version: ids 102, 202, 302, 602 And each row still carries the whole story's coverage and story_authors Scenario: The row opens the selected version and sorts by its update Given the fr versions have the updated_at values 2026-10-30, 2026-10-29, 2026-10-28 and 2026-10-13 When the owner sends GET /v1/article/?group=story&lang=fr Then the order is stories 1, 2, 3, 6 Scenario: Disabled languages can be selected When the owner sends GET /v1/article/?group=story&lang=es Then total is 1 and the item is story 8, representative 801 and its coverage entry has language_enabled false Scenario: A language nobody has gives an empty list When the owner sends GET /v1/article/?group=story&lang=it Then items is [] and total is 0 Scenario: lang also narrows the flat list When the owner sends GET /v1/article/?lang=fr Then the flat envelope holds the 4 fr articles, total is 4 and no item has another lang And counts.all, counts.online and counts.draft count the fr versions only and still ignore q and status Rule: The not-in view lists stories without a readable version Scenario: Not in French When the owner sends GET /v1/article/?group=story&lang=fr&tab=missing&size=5 Then total is 4 and the story_id of the items are 4, 5, 7, 8 And each representative is as in the all-languages view (401, 501, 701, 801) And missing_langs of each row contains "fr" Scenario: Needs translation, no language selected When the owner sends GET /v1/article/?group=story&tab=missing&size=5 Then total is 7 and the stories are 2, 3, 4, 5, 6, 7, 8 (story 1 has every enabled language) Scenario: Missing means no readable version, not no stored version Given a hidden fr draft exists on story 4 When a restricted writer sends GET /v1/article/?group=story&lang=fr&tab=missing Then story 4 is listed as not in French And nothing in the response tells whether a fr version exists Scenario: Sites that do not enable the language are excluded Given site "atelier" enables en, en-GB and fr only, with one story that has en and fr versions only When the owner sends GET /v1/article/?group=story&lang=de&tab=missing Then no atelier story is listed When the owner sends GET /v1/article/?group=story&lang=es&tab=missing Then no story is listed, because es is disabled on journal Scenario: Needs translation counts only languages the caller can create When a reader sends GET /v1/article/?group=story&tab=missing Then total is 0 and items is [] And a reader's lang=fr tab=missing still lists the stories without a readable fr version Scenario: Nothing missing Given site "press" with 3 stories, each with en and fr versions, and enabled languages en, fr When the owner sends GET /v1/article/?group=story&tab=missing&site=press Then items is [], total is 0 and counts.missing is 0 Rule: Filters apply to one readable version together Scenario: Status matches a readable version of the story When the owner sends GET /v1/article/?group=story&status=draft Then the stories are 1, 4, 5, 6 and total is 4 Scenario: Combined filters need a single readable version to satisfy them together Given story 1 holds article 101 (en, online, byline Marta Alves) and article 102 (fr, draft, byline Camille Roche) When the owner sends GET /v1/article/?group=story&status=draft&author= Then story 1 is not listed, because no single version is both a draft and by Marta Alves When the owner sends GET /v1/article/?group=story&status=online&author= Then story 1 is listed When the owner sends GET /v1/article/?group=story&status=draft&author= Then story 1 is listed Scenario: story_authors is the union even when the filter matched one version Given the same story 1 When the owner sends GET /v1/article/?group=story&author= Then story 1 is listed and its story_authors holds Marta Alves and Camille Roche Scenario: Search matches any readable title of the story Given article 102 is titled "Comment nous chiffrons une rénovation" and article 101 "How we price a bathroom renovation" When the owner sends GET /v1/article/?group=story&q=RENOVATION Then story 1 is listed with the title of its representative (101) And the search ignores case and accents like features/articles_pagination_api.feature When the owner sends GET /v1/article/?group=story&lang=fr&q=chiffrons Then story 1 is listed with article 102 as representative When the owner sends GET /v1/article/?group=story&lang=en&q=chiffrons Then items is [] Scenario: Search in the not-in view matches readable source titles When the owner sends GET /v1/article/?group=story&lang=fr&tab=missing&q=Spring Then story 4 is listed Scenario: Author in language mode When the owner sends GET /v1/article/?group=story&lang=fr&author= Then the stories are 2 and 6 (the fr version's own byline decides) Scenario: Site filter When the owner sends GET /v1/article/?group=story&site=press Then only press stories are listed, and an unknown site key gives items [] and total 0 as for the flat list Scenario: SEO filter on one version Given article 502 has suggestions and article 501 has none When the owner sends GET /v1/article/?group=story&seo=suggestions Then story 5 is listed once, with its own representative 501 Scenario: All filters combine with paging and stay stable Given 12 stories match When the owner requests pages 1, 2 and 3 of ?group=story&size=5 with the same filters Then the pages hold 5, 5 and 2 stories with no repeat, and total is 12 on each Rule: Sort applies to the whole list at story level Scenario Outline: Sort orders stories, not the page Given the "Journal" dataset and an owner session When the client sends GET /v1/article/?group=story&sort=&size=100 Then the story order is Examples: | sort | order | | updated_at:desc | 1,2,3,4,5,6,7,8 | | updated_at:asc | 8,7,6,5,4,3,2,1 | | title:asc | by representative title, then story_id descending | | title:desc | by representative title descending, then story_id descending | | status:asc | drafts first by representative is_online, then story_id descending | | created_at:asc | by the earliest created_at among readable versions | Scenario: Ties end with story_id descending Given two stories with the same latest update Then the one with the higher story_id comes first on every page Rule: Counts Background: Given the grouped body is {"items","total","page","size","counts"} And counts holds all, online, draft, suggestions and missing, in snake_case And in grouped mode the counts respect lang, q, author, seo and site but never status or tab, so the tab counts stay comparable; they count the unit of the mode (stories, or versions of the selected language) And online and draft count units with a readable online or draft version (the selected version in language mode) And missing counts the units the "not in" view would list under the same lang, q, author, seo and site And suggestions keeps its meaning (units with a readable version whose seo_status is suggestions, ignoring every other parameter) And the flat list counts are unchanged Scenario: Counts of the Journal dataset, all languages When the owner sends GET /v1/article/?group=story Then counts is {"all": 8, "online": 7, "draft": 4, "suggestions": 0, "missing": 7} Scenario: Counts of the Journal dataset, French When the owner sends GET /v1/article/?group=story&lang=fr Then counts.all is 4, counts.online is 2, counts.draft is 2 and counts.missing is 4 Scenario: Counts follow the search When the owner sends GET /v1/article/?group=story&q=zinc Then items is [], total is 0 and counts is {"all": 0, "online": 0, "draft": 0, "suggestions": 0, "missing": 0} And suggestions is the only count that ignores q Scenario: Counts follow the author filter When the owner sends GET /v1/article/?group=story&author= Then total is 3, the stories are 1, 4, 7 and counts.all is 3 Scenario: Counts do not move with status or tab When the owner sends GET /v1/article/?group=story&lang=fr&status=draft&tab=missing Then counts.all is 4, counts.online is 2 and counts.draft is 2, the same as without status and tab Scenario: A reader's counts cover readable versions only When a reader sends GET /v1/article/?group=story Then counts.draft is 0 and counts.all equals the number of stories with an online version Scenario: Total versus counts When the owner sends GET /v1/article/?group=story&tab=missing Then total is counts.missing and total is 7 Rule: Failure Scenario: Repository failure answers 500 Given the repository fails on its next call When the owner sends GET /v1/article/?group=story Then the status is 500 and the body is "Failed to retrieve articles" Scenario: A failure while counting fails the request Given the repository fails while the counts are evaluated Then the status is 500 and the body is "Failed to retrieve articles" Rule: Persistence portability Scenario: The grouped, access-filtered query passes the same cases on every database Given the "Journal" dataset, a hidden-version dataset and the cases above (all languages, lang, tab=missing, status+author together, q with accents, sort whitelist, paging, counts) Then they pass on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL with identical ids, totals and counts And the result does not depend on the database collation for the id sets And no raw SQL uses a dialect-only construct Rule: The SPA, editorial list screen Background: Given /article keeps its route and URL conventions of features/articles_pagination_screen.feature, and the screen follows docs/design/screens/localization-list.md group L7 And ArticlesPage always sends group=story; the URL carries site, lang, tab (omitted when all), q, status, author, seo, page and size, defaults omitted, and any filter change drops page And an invalid value in the URL falls back to its default and is replaced in the URL And the unit label (UnitLabel, aria-live polite) states the unit: "8 articles", "4 French versions", "4 articles not in French", "7 articles need translation" And the tab "Needs review" is not rendered (it ships with release 2); loc.l7.review is not part of this slice And the tabs: with no language "All articles" and "Needs translation"; with a language "All", "Published" (status=online), "Drafts" (status=draft) and "Not in {Language}", each with its count from counts And the row menu on every width lists Open {language}, Open another language, Add translation, Publish or Unpublish {language}, Copy API URL, Delete {language} version, each shown when its capability or condition holds (docs/design/localization-tables.md "Row actions"), and every row has the menu button And coverage chips follow docs/design/localization-tables.md "Coverage chips for many languages": 5 shown at 1440 and 4 at 1024 and 390, then "+N" named "N more languages: ..."; dashed chips only for missing_langs and buttons on desktop only And below md the filters open a sheet (Site, Language, Status, Author, SEO, Clear all, "Show N results") and the tabs scroll with an edge fade and "Scroll tabs left/right" buttons that keep Not in {Language} reachable And copy comes from the design; no revision number, id or HTTP status name is shown Scenario Outline: The design states of group L7 are rendered from the matching response Given the Journal dataset (and Press, Atelier or the limited reader where the state names it) mocked on GET /v1/article/ as above When the user opens Then the screen shows state as drawn: Examples: | state | route | visible | | loc.l7.all | /article | 5 of 8 rows, unit "8 articles", tabs "All articles" "Needs translation", pager page 1 of 2 | | loc.l7.page2 | /article?page=2 | 3 of 8 rows, pager page 2 of 2 | | loc.l7.fr | /article?lang=fr | unit "4 French versions", tabs All, Published, Drafts, Not in French | | loc.l7.frMissing | /article?lang=fr&tab=missing | unit "4 articles not in French" | | loc.l7.emptyMissing | /article?tab=missing&site=press | no summary, no pager, the nothing-missing empty state | | loc.l7.noResults | /article?q=zinc | no-results empty state, every count 0 | | loc.l7.frLoading | /article?lang=fr (request pending) | counts shown as "–", skeleton rows, aria-busy | | loc.l7.listFail | /article?lang=fr (request fails) | failure state with the filters kept and a retry | | loc.l7.limited | /article as a reader without create | no dashed chips, no Add translation item | | loc.l7.author | /article?author= | 3 articles, filter chip and "Clear all" | | loc.l7.langFilter | language filter opened | Enabled and Disabled groups | | loc.l7.rowMenu | a row menu opened | the same actions on desktop and mobile | | loc.l7.filters | /article?lang=fr&status=draft at 390 | filter sheet with Journal, French, Draft: "Show 2 results" | | loc.l7.tabsScroll | tabs scrolled (1440, 390) | Not in French reachable; fade and chevrons | | loc.l7.long | /article?site=atelier (8 languages) | chips collapse to +3, +4, +4; Draft and SEO filters |