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.
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
/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 |
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:
That endpoint checks three things in order, and each one is an operator decision:
-
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. -
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. -
autoApproveAuthors. On, the row is createdAPPROVEDand there is no queue. Off, it is createdPENDINGand 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,.gifand.webp. A PDF, a video or a zip inuploads/exists, is served, and is invisible here. - Delete is
unlink.DELETE /api/admin/content/media(permissiondelete.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 backendis slower on a large library.
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:
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.
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:
- Story —
headline(required, 500 characters),summary,publishedAt. - Links —
urlandimageUrl.http://andhttps://only; other schemes are rejected. - Tagging —
categoryandrelatedSymbols, a comma-separated list of assets such asBTC, USDT. - Visibility —
status. Set it false to pull a story from the terminal without deleting it; the public feed only ever returnsstatus = 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.