Agrofin

News — posts and categories

On this page 11

News

The press section: articles, their categories, and the editorial endpoints behind them. Content was migrated from the retired Yii site — 210 stories spanning 2018–2026, with every image copied onto our own storage.

Reading is public; writing sits behind auth:sanctum and a permission.

Languages

title, intro and content are translatable. The public endpoints return the current locale's string, negotiated from Accept-Language (uz, ru, uz_cyrl; uz when nothing matches). The admin endpoints return the whole translation map, so an edit form arrives filled in.

A public list only returns stories that exist in the requested language. Showing a card with an empty headline would be worse than showing nothing.

Latin and Cyrillic Uzbek are the same language, so a story written in only one of them is converted into the other at import time and flagged (is_machine_translated). Russian is never converted — that would be translation, not transliteration — so 13 Russian-only stories appear in Russian alone.

Send the Cyrillic tag in any casing — uz_cyrl, uz-cyrl, uz-Cyrl and UZ-CYRL all resolve to uz_cyrl, which is what Content-Language echoes back.


Public endpoints

GET /api/v1/posts

Paginated list, newest first. Follows the list contract.

Filter Values Notes
filter[category] category slug tenderlar, statistic, … — several may be comma-separated
filter[search] free text matches title in the current locale
filter[year] e.g. 2026 publication year as the reader sees it — see Dates
filter[is_featured] 1 / 0 the editorially highlighted stories

filter[publication_status] is admin-only: the public endpoint rejects it with 400, because a draft can never be public anyway.

sort: published_at (default -published_at), id. Prefix with - to reverse. per_page defaults to 12, capped at 100.

Only stories that are published, whose published_at has passed, and which exist in the current locale are returned.

json
{
  "id": 42,
  "slug": "paxta-2026-yil-xarajatlari",
  "title": "2026 yil paxta xomashyosi yetishtirish xarajatlari",
  "intro": "Jamg'arma mablag'laridan yetishtiriladigan paxta va g'alla…",
  "published_at": "2026-07-05T19:00:00+00:00",
  "is_featured": false,
  "cover_url": "https://api.agrofin.uz/storage/12/paxta.jpg",
  "is_machine_translated": false,
  "view_count": 4438,
  "categories": [{ "id": 2, "slug": "tenderlar", "title": "Tenderlar", "parent_id": 1 }]
}

is_machine_translated is true when this language's text was produced by Latin↔Cyrillic transliteration rather than written by an editor. Show it or don't — but the flag is never hidden from you.

GET /api/v1/posts/{slug}

One article. Adds two fields to the shape above:

  • content — sanitised HTML (see Body markup);
  • related — up to three published stories sharing a category.

Every read increments view_count. 404 when the slug is unknown, the story is a draft, or it has no text in the requested language.

GET /api/v1/news-categories

Active categories with the number of published posts in the current locale. Unpaginated — there are six. parent_id is null for the root (yangiliklar); every other category hangs off it.

Dates

published_at is an ISO 8601 instant, stored in UTC. It must be rendered in Asia/Tashkent — every imported story sits at exactly 19:00:00Z, which is exactly midnight in Tashkent, so formatting the UTC part shows the whole archive one day early.

filter[year] follows the same rule: it matches the year in the display timezone (config('news.display_timezone'), default Asia/Tashkent), so a story shown as 01.01.2026 is found under 2026 and not under 2025.


Body markup

content is HTML, restricted to a fixed allowlist:

code
p br strong b em i u a[href|title|target] h2 h3 h4
ul ol li table thead tbody tfoot tr td[colspan|rowspan] th[colspan|rowspan]
img[src|alt|title] hr blockquote

Everything else is stripped on write — style, class, <span>, <font>, <script>, on*= handlers and javascript: URLs never reach the client. <h1> is demoted to <h2> so the page keeps a single top-level heading. Tables survive intact: the subsidy figures live in them.

img sources always point at our own storage. No article depends on a host we do not control.


Admin endpoints

All require auth:sanctum. super_admin bypasses every check.

Putting a story on the site is a separate ability from writing it. This is a government site: update posts lets an editor rewrite an article, but only publish posts may move it onto — or off — the public site. The check fires on the transition, so rewriting an already-published article needs no extra right; moving draft → published or published → draft does. A blocked attempt returns 403, never a silent no-op.

Method Path Ability
GET /api/v1/admin/posts view posts
POST /api/v1/admin/posts create posts
GET /api/v1/admin/posts/{post} view posts
PUT /api/v1/admin/posts/{post} update posts
DELETE /api/v1/admin/posts/{post} delete posts
POST /api/v1/admin/posts/{post}/cover update posts
DELETE /api/v1/admin/posts/{post}/cover update posts
GET /api/v1/admin/news-categories view news categories
POST /api/v1/admin/news-categories manage news categories
PUT DELETE /api/v1/admin/news-categories/{newsCategory} manage news categories

Reading categories is split from changing them so a read-only role still sees a complete News screen, filters included.

Full ability list: view posts, create posts, update posts, delete posts, publish posts, view news categories, manage news categories.

The admin list shows every status and accepts filter[publication_status]=draft|published|archived alongside the public filters.

Writing a post

json
{
  "title":   { "uz": "Sarlavha", "ru": "Заголовок", "uz_cyrl": "Сарлавҳа" },
  "intro":   { "uz": "Qisqacha" },
  "content": { "uz": "<p>Matn</p>" },
  "publication_status": "published",
  "published_at": "2026-09-01T00:00:00Z",
  "is_featured": false,
  "category_ids": [2, 5]
}
Field Rule
title required; must include the default locale (uz); unknown locales rejected
publication_status draft, published, archived — moving into or out of published needs publish posts
published_at required when publishing — without it the story can never satisfy published_at <= now() and would silently vanish
slug optional; derived from the title when omitted, de-duplicated with -2, -3
intro optional; an empty locale is filled from that locale's body
content sanitised on write
category_ids replaces the whole set

PUT is a partial update: fields you omit keep their stored value. When an editor rewrites a locale's title or content, that locale is removed from generated_locales — the "unchecked machine text" badge clears itself.

DELETE soft-deletes. The row stays recoverable.

Cover image

POST …/cover takes multipart cover: jpg / jpeg / png / webp, up to 10 MB. The collection holds one file, so an upload replaces the previous image.


Migrated content

php artisan news:import-legacy --dump=<file.sql> reads a mysqldump of the old database directly — no MySQL server needed. It is idempotent: rows are keyed by the legacy lang_hash, so re-running updates instead of duplicating, and nothing is ever deleted or truncated. --dry-run rolls everything back; --skip-media leaves images alone.

php artisan news:restore-legacy-media is the follow-up for images: it downloads the file behind every post media row that still has a source_url but no file on disk (preferring the full-size copy over the _low thumbnail the old site used in listings), then walks every body that still embeds a picture from agrofin.uz or telegra.ph and replaces it with our own copy. Both passes are idempotent; anything that cannot be fetched is listed, not fatal. The old host only answers over plain http://.

Two details worth knowing when reading migrated rows:

  • legacy_slugs keeps the old per-language URLs (paxta_2026/002/ru), so inbound links can be redirected later. The live slug is a single language-independent one.
  • The old lang column is wrong on six rows, so the importer decides each row's language from its script instead. Russian is taken at face value — it has no second alphabet to be confused with.
  • A missing Uzbek alphabet is filled from the other one via App\Support\UzbekTransliterator and recorded in generated_locales, which is what is_machine_translated reports. 29 of the 210 stories carry it. An editor rewriting that locale's title or content clears the flag.