Blog, media, announcements and market news

What each publishing screen actually owns — the blog and its separate author approval queue, the media library, sliders, custom-page slugs, site-wide announcements, and the market news feed behind the trading terminal.

10 min readUpdated 6 August 2026blog, media, announcements, news, cms, moderation

Publishing on this platform is five unrelated systems that happen to produce words and pictures. Three of them sit under Content in the admin nav; the other two are filed under System → Communication Tools, which is the first reason they get lost. They have different tables, different permission keys and different failure modes — and two of them, announcements and market news, are routinely mistaken for each other. They are not the same thing and neither one replaces the other.

I want to… Screen Page permission
Publish an article /admin/blog/post access.blog.post
Approve somebody as an author /admin/blog/author access.blog.author
Moderate reader comments /admin/blog/comment access.blog.comment
Turn the blog on, or close author applications /admin/blog/settings access.blog.settings
Find or delete an uploaded image /admin/content/media access.content.media
Change the homepage carousel /admin/content/slider access.content.slider
Build a custom marketing page /admin/builder falls back to access.admin
Edit the built-in Home / About / Contact / Privacy / Terms pages /admin/default-editor falls back to access.admin
Announce maintenance to everybody /admin/system/announcement access.system.announcement
Put a story in the trading terminal /admin/system/news access.market.news

Every page permission comes from frontend/middlewares/permissions.json. The two that say "falls back" have no entry there, which means access.admin alone opens them — see Roles and permissions.

The blog

The blog admin is not part of the dashboard shell. It lives in its own route group with the public site's header and footer, and it carries its own four-item top bar: Dashboard, Content (Posts, Categories, Tags), Community (Authors, Comments) and Settings. There is no link to it from the dashboard sidebar beyond the single Content → Blog System entry pointing at /admin/blog, so everything below is reached from that bar once you are inside.

Two routes deliberately render with no chrome at all, because the editor owns the viewport: /admin/blog/post/create and /admin/blog/post/{id}/edit.

The blog dashboard

Counts, recent posts and the pending-author queue

/admin/blog is a read-only overview: published and draft post counts, approved and pending author counts, the five most recent posts, the three most recent pending author applications, the top five categories and top ten tags by post count.

The Pending Authors card links to /admin/blog/author?status=PENDING. That is the queue nothing else in the platform will remind you about — it is not in the Operations inbox in the admin header, so if nobody opens this page, applications sit.

One figure on this screen is not what it looks like: Total Readers is hardcoded to 0 in the endpoint. Nothing counts readers.

Posts

/admin/blog/post is the list. Create and Edit are full pages, not the inline table dialog — /admin/blog/post/create and /admin/blog/post/{id}/edit, each gated on its own key (create.blog.post, edit.blog.post) — because a post is a document with a rich-text body, a cover image and a tag list.

A post's status column is a two-value enum: PUBLISHED or DRAFT, defaulting to DRAFT. The list is paranoid, so deleting a post soft-deletes it.

PUT /api/admin/blog/post/status declares PUBLISHED | DRAFT | TRASH in its request schema, but post.status is ENUM('PUBLISHED','DRAFT') on the model and the screen offers only those two. TRASH is a leftover. Use Draft to unpublish and Delete to remove.

Categories and tags

/admin/blog/category and /admin/blog/tag are plain CRUD tables (access.blog.category, access.blog.tag). A post has exactly one category — the post row carries a single categoryId — and many tags. Only the tag count is capped, by maxTagsPerPost on the Settings screen below; there is no setting that lets a post hold a second category.

An author is its own record, with its own approval

This is the part that surprises people. A blog author is not a user with a flag. It is a row in the author table pointing at a userId, with its own three-value status:

author.status Meaning
PENDING applied, waiting for you
APPROVED may write posts
REJECTED turned down
Sets one author's status
Sets several authors' status at once

Approving an author is therefore a different action from editing a user. Nothing on /admin/crm/user grants or revokes authorship, and nothing on /admin/blog/author changes the person's account. The two records are joined only by userId.

The author list is configured canCreate: false — you cannot mint an author from this screen. An author row is created by the customer applying:

A signed-in customer applies to become an author

That endpoint checks three things in order, and each one is an operator decision:

  1. The KYC feature author_blog. If your verification levels enforce it, an unverified customer is refused before anything else happens. See KYC levels and features.

  2. enableAuthorApplications. Off means the application is refused with "Author applications are not being accepted at the moment." This is enforced server-side, so closing the programme really closes it.

  3. autoApproveAuthors. On, the row is created APPROVED and there is no queue. Off, it is created PENDING and lands on /admin/blog/author.

A customer who already has an author row — in any status, including REJECTED — gets "Author profile already exists" and cannot re-apply. Deleting the row is the only way to let somebody try again.

Comments

/admin/blog/comment moderates reader replies. comment.status is a three-value enum — APPROVED, PENDING, REJECTED — defaulting to PENDING. The table is configured with no Create button; you edit a comment's status or delete it.

Whether comments appear at all, and whether new ones wait for you, are two separate switches on the Settings screen: enableComments and moderateComments.

PUT /api/admin/blog/comment/status declares status as a boolean in its schema — a leftover from when the column was one. The column is the three-value enum above and the screen writes those strings. Moderate from the screen rather than scripting against that endpoint.

Blog settings

/admin/blog/settings is not a blog-specific table. It is the standard settings form writing to PUT /api/admin/system/settings, so every key below is an ordinary platform setting and the changed-keys-only save contract applies — see Settings reference.

Five tabs, fourteen keys:

Tab Key Type Ships as What it does
General blogStatus switch true Blog on or off
General blogPostLayout select DEFAULT DEFAULT, MODERN or CLASSIC. Hidden while blogStatus is off
Authors enableAuthorApplications switch on Whether customers may apply
Authors autoApproveAuthors switch off Skip the review queue entirely
Authors maxPostsPerAuthor number 0 0 = unlimited; max 50
Content maxTagsPerPost number 5 1–20. Enforced server-side when a post is saved
Content maxCategoriesPerPost number 3 1–5 on the form, but the key does nothing: a post carries a single categoryId and no route or screen reads this setting
Content enableComments switch on Comments on posts
Content moderateComments switch on Hold new comments for review
Display postsPerPage number 10 5–50, step 5
Display showAuthorBio switch on Author bio on post pages
Display showRelatedPosts switch on Related posts at the end
SEO defaultMetaDescription text placeholder Used when a post has none
SEO defaultMetaKeywords text placeholder Comma-separated

The "ships as" column is the form's own default, which is what you see before anybody has saved the page. Nothing seeds these rows, so an untouched install is running on those defaults rather than on stored values.

Media library

Content → Media Library, /admin/content/media.

This screen is not backed by a database table. It is a live view of the filesystem: the backend walks frontend/public/uploads recursively, reads each image's dimensions with sharp, holds the result in an in-process cache, and re-walks the whole tree on any filesystem change through a recursive fs.watch.

Consequences worth knowing before you use it:

  • Images only. The listing filters to .jpg, .jpeg, .png, .gif and .webp. A PDF, a video or a zip in uploads/ exists, is served, and is invisible here.
  • Delete is unlink. DELETE /api/admin/content/media (permission delete.content.media) removes the file from disk. There is no soft delete, no bin and no undo. Anything still referencing that path — a blog post's cover, a slider, a product image — becomes a broken image immediately.
  • The cache is per backend process and rebuilt on first request after a restart, so the first load after pm2 restart backend is slower on a large library.
Lists image files under frontend/public/uploads
Deletes image files by path

The built-in backup at /admin/system/database/backup runs mysqldump and nothing else. Every file this screen lists — plus KYC documents, dispute evidence and ticket attachments, which are in the same tree and not shown here — has to be copied separately. See Backup and restore.

Uploads themselves do not happen on this screen. They arrive through POST /api/upload, which any authenticated user reaches from whichever form they are filling in; it caps a file at 10 MB and writes it under frontend/public/uploads/<dir>/.

Homepage sliders

Content → Homepage Sliders, /admin/content/slider. A slider row is three fields: image (required), link (optional) and status (boolean, default on). Full CRUD, soft-deleted, with create.slider, edit.slider, delete.slider and view.slider behind the buttons and a per-row status toggle at PUT /api/admin/content/slider/{id}/status.

Custom pages, and why a slug is refused

Custom pages live in the page table and are edited by the Page Builder at /admin/builder — which only appears when landingPageType is CUSTOM. See Design, menus, footer and branding for that switch and for the Default Pages editor it is mutually exclusive with.

The API behind the builder is worth knowing on its own, because it enforces a rule the UI does not explain:

Lists CMS pages
Creates a CMS page
Publishes or unpublishes a page
Answers whether a slug can be used, and why not

A CMS page is served at /{locale}/{slug} by the catch-all route, and Next resolves a static segment before a dynamic one. So a page whose slug matches a route the platform already serves saves happily, publishes happily, reports success — and can never be opened, because the real route wins.

backend/src/api/admin/content/page/reserved-routes.ts is the generated list of every first segment already taken. It currently holds 47 entries, including about, admin, blog, contact, login, market, privacy, register, support, terms, trade, user and every addon root (p2p, staking, ico, nft, dex, futures, …). Create or update refuses a colliding slug with a 400 naming the segment.

The slug-availability endpoint answers the same question before you save, with four distinct reasons:

reason What it means What to do
RESERVED a platform route already serves that first segment rename it
TAKEN another page holds the slug rename it
TAKEN_SOFT_DELETED a deleted page still holds it — the unique index ignores deletedAt restore that page, or pick another slug
INVALID not lowercase letters, digits, -, _ and / fix the characters

TAKEN_SOFT_DELETED is the one that wastes an afternoon: a page you deleted last month still owns its slug, and creating a replacement with the same slug fails with a raw 500 rather than a message. Nothing in the shipped frontend calls slug-availability today, so this endpoint is a diagnostic you reach with curl, not a field-level check in the builder.

Site chrome

GET/PUT /api/admin/content/chrome (both access.design) store the navbar variant, the footer variant, the menu override document and the footer content as a single row. There is no screen called "chrome" — that row is what /admin/design, /admin/menus and /admin/footer all write to, and it is covered in Design, menus, footer and branding.

Announcements

System → Communication Tools → System Announcements, /admin/system/announcement.

An announcement is four fields: type (GENERAL, EVENT or UPDATE, defaulting to GENERAL), title, message and an optional link, plus a boolean status that decides whether it is live. Rows are soft-deleted.

Publishes or hides one announcement
Publishes or hides several

There is no scheduled-start, no end date and no expiry column on this table. A "Scheduled maintenance tonight" banner published in March is still published in August unless a human turns it off.

The analytics band on this screen exists for exactly that. Oldest live announcement is the age of the oldest published row, and Live and older than 90 days is the worklist behind it. Check both when you open the screen.

Two more tiles are editorial worklists rather than statistics: Live without a link counts published announcements with a null link — a banner that goes nowhere — and Inactive announcements counts the hidden ones.

The announcement rows are written and managed here, and the analytics on this screen count them, but nothing in the shipped customer-facing frontend fetches them — a repository-wide search finds no public reader. Treat this screen as the record of what you have announced, and do not assume publishing a row puts a banner in front of a customer without checking your own install. If you need to be certain a message reaches customers, use a notification or an email campaign instead.

Market news is a different system entirely

System → Communication Tools → Market News, /admin/system/news.

This is the news feed beside the trading chart, in the market_news table. It has nothing to do with announcements: different table, different permission family (access.market.news, view.market.news, create.market.news, edit.market.news, delete.market.news), different audience, different lifecycle.

Every row carries a source, and it is the field that decides everything else:

source Where it comes from What the sync does to it
PROVIDER fetched from Finnhub by the syncMarketNews cron inserted once by externalId; never updated; pruned after 30 days
MANUAL typed on this screen never read, updated or deleted by the sync; survives forever

Anything you create here is MANUAL — the create endpoint forces source: "MANUAL" with a null externalId — which is what exempts your desk commentary from the provider sync and from the 30-day prune. It is tagged "Desk" in the terminal.

The provider sync

syncMarketNews asks Finnhub for the crypto category, takes up to 60 stories, inserts the ones whose externalId is new, and then deletes PROVIDER rows older than 30 days. It is registered in the scheduler — see Scheduled jobs.

The provider key is APP_FINNHUB_API_KEY in .env. Without it the job logs one info line and returns, doing nothing. That is deliberate: a missing key is a configuration state, not an incident, and an install with no key still serves whatever you have written by hand. If your feed is empty and no cron error is showing, check the key first.

The form, and the fields that matter

Create and edit are grouped into four sections:

  • Storyheadline (required, 500 characters), summary, publishedAt.
  • Linksurl and imageUrl. http:// and https:// only; other schemes are rejected.
  • Taggingcategory and relatedSymbols, a comma-separated list of assets such as BTC, USDT.
  • Visibilitystatus. Set it false to pull a story from the terminal without deleting it; the public feed only ever returns status = true.

relatedSymbols is worth understanding, because it is the only way a desk note reaches a scoped feed. The public feed at GET /api/exchange/news?symbol=BTC/USDT matches on the base asset only, against the story's headline and summary text — because the provider tags nothing — or against the operator tags on the row. A story nobody tagged and whose prose never names the asset does not appear under that market's chip, and the filter never falls back to the unfiltered feed: an asset no story mentions returns an empty list, which the terminal renders as "no stories mention X".

Stories older than 30 days never appear in the terminal regardless of source, and the public route caches its answer in-process for 60 seconds, so a story you publish can take up to a minute to show up on an already-open terminal.

Settings → General → Content carries a News Section switch, key newsStatus, defaulting to "true". A repository-wide search finds that key only in the settings definition — no backend route and no frontend component reads it, and the terminal's news tab is served by the public /api/exchange/news route unconditionally. Turning it off does not hide the feed. To take a story down, clear its status; to empty the feed, delete the rows and leave APP_FINNHUB_API_KEY unset.