Discussion#
The smallest module in the system — 12 entities, no dynamic parameters, no saved-search
alerts, no expiry. The code lives in app/UI/Api/Discussion/, the front end in
app/UI/Front/Discussion/.
What the module is made of#
| Group | Entities |
|---|---|
| content | Message (a nested set), MessageAssetRel |
| taxonomy | Category, CategoryText, CategoryPrice (the category price list) |
| interaction | MessageVote, MessageReport |
| payment | Tariff, TariffText, TariffPrice, TariffQuickPrice, PaymentItem |
The module has no blacklisted words of its own. It reads one list for the whole website, the
system table cms_system_blacklisted_words (System → Blacklisted words), through the Api
System\BlacklistedWordManager. MessageManager::beforeSave() refuses a visitor's subject or
text containing a blacklisted word of the page language (textRefusal()), and CommentManager
does the same for comments in threads of a type that asks for it
(ThreadType::checksBlacklistedWords(), today only DiscussionMessage). The administrator,
cron and internal jobs are not checked. The refusal carries the errorCode blacklistedWord
(BlacklistedWordRefusal) and the front shows the message front.general.blacklistedWord.
The module tables of blacklisted words no longer exist
The former cms_mod_discussion_blacklisted_words (and the copies in classifieds and the
company catalogue) were dropped by a migration together with their entities, managers and
repositories. New code uses only the system BlacklistedWordManager
(findActiveValues(?languageId), textRefusal()).
A thread and a post are one entity#
Message is a nested set (lft, rgt, level). A thread is a root node, a reply is
its descendant — there is no type difference between "topic" and "post".
Moving a post is not constrained in any way
An administrator can drag any node anywhere and the system does not check whether it makes sense. Dragging a reply under a different topic makes the discussion incoherent, and there is no undo button.
Deleting a node deletes the whole subtree
Deleting the opening post removes the entire thread including every reply. In the listing it looks like deleting a single row.
The post text is sanitized on the server#
The author writes in the reduced editor (addSimpleEditor() in MessageFormComponent), and
both the post detail and the style 3 list print text with |noescape. That is why
MessageManager::beforeSave() runs it through EditorHtmlSanitizer::purify() on every save
— from the website, the administration and the external API alike. The allowed tags are the
same as for advert parameters and in the client editor (EditorHtmlSanitizer::ALLOWED_HTML).
subject is not sanitized: it is plain text and the templates always escape it. Purifying it
would only mangle legitimate text containing <.
Sanitization belongs in the manager, not in the form
The editor whitelist in rich-editor.js lives only in the browser. A request outside the
form bypasses it, and without the check in beforeSave() the result would be stored XSS.
In detail: Forms.
What the module does not have#
More useful than a feature list is knowing what is not here — it saves you hunting for a setting that does not exist:
| Missing | How it is worked around |
|---|---|
| locking a thread | turn off "allow comments", or deactivate the post |
| paid read access to a category | only founding a topic in a paid category and post tariffs are paid for |
| per-category permissions | only the global "signed-in users only" switches |
A category has an active flag. A disabled category, or a category under a disabled parent,
returns 404 on the website (CategoryTrait::isCategoryBranchActive()).
Thumb voting#
Votes are MessageVote rows with the value 1 = thumbs up, 0 = thumbs down. The
counters on the post (votesUp, votesDown) are recounted by Api\MessageVoteManager from
the vote rows (cms_mod_discussion_message_votes) in one UPDATE inside the vote's save
transaction — after a save, a delete, a bulk deleteAll() and an updateColumns of
vote/messageId. As with ratings (RatingVoteManager), the vote path writes the counter
itself; the vote table is the source of truth.
Vote counters do not belong in registerEvent
The front used to call :Api:Discussion:Message:registerEvent with votesUp/votesDown
separately after saving the vote. That endpoint is granted to guests and bypasses
MessageVoteManager::beforeSave(), so a script could raise a counter without a vote as
often as it liked, and deleting a vote never lowered it. The registerEvent allowlist now
holds only the view counters.
A drifted counter is corrected by the first vote
The recount is absolute, so the first new vote aligns the counter with the real votes (on DEV post 21 had three thumbs-down votes and a counter of 0).
Everyone votes once per message. MessageVoteManager::beforeSave() enforces it (on insert
only, through hasVoted()):
| Voter | Identified by | What holds it in the database |
|---|---|---|
| logged in | the account (userId), the IP is ignored |
the unique index uq_message_user (message × user) |
| anonymous | the IP only (insertIp) |
nothing — the check lives only in the manager |
After a vote, raise the displayed value too
votesUp and votesDown are in MessageManager::$nonInvalidatingColumns, so the write
does not invalidate the query cache (see Cache). The entity the row is
redrawn from therefore holds a value one lower and the voter would not see their vote.
VoteTrait::bumpDisplayedVotes() raises the number in memory after a successful write,
without another DB read, and redrawControl() sends it into the snippet. It is not called
on DB_DUPLICATE_ENTRY.
A second vote returns DB_DUPLICATE_ENTRY; for that code the front end does not raise the
counter and says the visitor has already voted. On the external path the server fills in the voter from the JWT and the IP from the
request; otherwise de-duplication would have nothing to compare.
Do not replace the manager check with an index
The index does not catch anonymous voters (a row with a NULL user never collides), and an
index on the IP would block two different people behind one company network. A logged-in
voter caught only by the index makes the save fail with a unique violation, which closes the
EntityManager for the rest of the request.
Post reports#
A visitor reports a post (MessageInfoLine::handleReport()); a MessageReport is created
(user, IP, time, solved = false). One reporter has at most one unresolved report per post
(a logged-in user by account, a guest by IP); another one returns DB_DUPLICATE_ENTRY and the
front shows it as a success. After resolution the reporter may report again.
| Rule | Implementation |
|---|---|
| a write only from AJAX | #[Requires(ajax: true)] — a bare GET (a robot) is 403 |
| the ID from the address is compared | a report is created only for the message of this component; a planted ID creates nothing |
the Message::$reported flag is derived |
a post is "reported" while it has at least one unresolved report |
the external :Api:Discussion:MessageReport:save |
the target post must exist (otherwise ITEM_NOT_FOUND) and a logged-in user draws on the creation quota; this path never sees the front-end ID check |
The flag is recalculated by the shared trait ReportedFlagSyncTrait (syncReportedFlags()),
used by Api\MessageReportManager and by the AdvertReportManager of classifieds and the company
catalogue. The manager only tells what the target is and how the flag is read and written
(reportTargetField(), reportTargetIdOf(), reportTargetIdsOf(), targetHasUnsolvedReports(),
writeTargetReported()). The recalculation runs after every change: a save, updateColumns on
solved (resolve / reopen) or on the target column messageId (moving a report; both the old
and the new post are recalculated), a delete and a bulk deleteAll() (it bypasses the delete
hooks, so the targets are collected beforehand). Discussion counts the unresolved reports by a
direct DQL query, not through findAll, so the result is not from a stale query cache. A failed
recalculation goes to the log, not to the user. The administration does not touch the flag.
The reported flag is not set by hand
updateColumns($id, ['reported' => …]) on a post is only for MessageReportManager. A
manual write drifts away from the report overview.
The report administration: Admin\Discussion\Presenters\MessageReportPresenter
(/admin/diskuze-nahlasene-prispevky/prehled), SQL
sql/2026-10-02_07_admin_discussion_message_reports.sql (an admin page, keys
:Admin:Discussion:MessageReport:{show,edit,del}, a menu item under Discussion). The signals
The list, solving and reopening come from the shared ReportModerationPresenterTrait (the same for
the AdvertReportPresenter of classifieds and the company catalogue); the presenter only names
the target and the privileges. The signals solve! and reopen! (handleSolve(),
handleReopen(), POST only) have a server-side gate checkPrivilege(…:edit) — a hidden button
is not protection. The default view is the filter solved = 0; rows are decorated by
decorateReportRows() with two queries (targets and reporters), without N+1.
Operator guide: Reported posts.
Payments#
Two things are paid for: founding a topic in a paid category and post tariffs. The payment
callback is DISCUSSION_PAYMENT (PaymentCallbackRegistry), SMS services DISK
(DiscussionExpressServiceProvider).
Paid categories#
A discussion category has a price list like a classifieds category: CategoryPrice
per payment region, gateway and currency, a supply kind, the "Free" flag (for_free) and the
option "Set prices for subcategories too". The entity implements PricedCategoryInterface, and
copying the price list down the subtree is done by the shared CategoryPriceSyncTrait (the same
as in classifieds and the company catalogue). The external Api cannot overwrite for_free
(#[ExternalReadonly]).
| Step | What happens |
|---|---|
| inserting a topic | MessageManager writes the category relations with their own paid (free = 1, paid = 0) and the topic gets messages.paid: it is paid when at least one category is free; otherwise it is saved unpaid |
| a reply, a topic without a category | always paid |
| editing a topic | a kept category keeps its paid, a new one gets paid from for_free |
| payment | the payment page offers an item (PaymentItem with categoryId) for every paid, not yet paid category of the topic |
settling DISCUSSION_PAYMENT |
after activating the items, PaymentItemManager calls MessageManager::markCategoriesPaid(): it marks the paid relations and the topic, credits the content rewards and reindexes |
An unpaid topic is left out of lists, TOP, the sitemap and content credits (paid = 1 belongs to
the "published" condition next to approved_at and confirmed_at). The author sees it on the
detail with a notice and a Pay button, and in "My posts" with a badge leading to the payment.
Post tariffs#
| Tariff | Effect | Where the ID comes from |
|---|---|---|
| topping | fills toped_until; the list sorts topedUntil > NOW() first and the TOP badge reads Message::isToped() |
discussionToppingTariffId (default 1) in config/Shared/bazaar.neon → AdvertToppingConfig |
| highlighting | an active valid item gives the list card the highlighted class |
discussionHighlightTariffId (default 2) → AdvertHighlightConfig |
| Topbox | a post with an active, valid item of the tariff shows in the "TOP" side widget (TopMessageList, in aside-left.latte of all themes) |
the template passes the tariff id: {control frontDiscussionTopMessageList [3], 'Style_2', 10} |
| show links | web addresses in the text are clickable links on the detail (TextLinkifier, filter \|linkify, rel="ugc nofollow"); without the tariff they stay plain text |
discussionLinksTariffId (default 4) → AdvertLinksConfig |
The TOP badge reads toped_until, not payment items
The list loads only items of the highlighting tariff, so the former template test on
tariff 1 was never true and the badge never showed. It is now driven by
Message::isToped() — the same value as the sort order.
Classifieds and the company catalog have Topbox and links too
There Topbox is TopAdvertList with tariff 3 and links are tariff 4 (bazaarLinksTariffId,
comcatLinksTariffId). Without the tariff an address there is also hidden behind [URL]
(the parameter filter in the "on render" mode), see Bazaar.
A payment item in the administration must have a post (form.messages.missingMessage); the
column is nullable in the DB, so the guard is in the form. A bulk deleteAll() of items
recalculates toped_until the same way as delete().
Which path recalculates toped_until only upwards and which absolutely (the Payments tab of
the message edit sends meta['recalcTopedUntil']) is shown in the table in
Classifieds; discussion has the same, only over messages.
Deleting an active payment item shortens the highlight
An extension from a payment only moves upwards (it is monotonic); a manual change in the administration can go both ways — and deleting the item behaves as a manual shortening.
Settings#
The module has index 8 in the settings table. The form today writes:
| Key | Type |
|---|---|
message.perPage, comment.perPage, maxImageSize |
number |
message.approval.auto, message.confirmation.required.logged, message.confirmation.required.guest, message.onlyForLoggedIn, comment.onlyForLoggedIn, vote.onlyForLoggedIn |
switch |
Approval and confirmation of a post#
A post has two independent conditions for publication, both evaluated at the moment of posting (thread or reply, from the web, the API or the administration):
| Condition | Column | What controls it |
|---|---|---|
| approval by an administrator | approved_at |
message.approval.auto; an administrator approves with the Schváleno (approved) switch |
| confirmation by the author via an email link | confirmed_at (+ key fingerprint in activation_key) |
message.confirmation.required.logged and .guest — depending on whether the post has an owner (userId) |
When confirmation is not required, the post gets confirmed_at at once. When it is, the
post gets a key and an email with the link goes to the author; if there is no email address,
it is confirmed at once (the link would have nowhere to go). The public read paths
(listing, search, favourites, sitemap, express SMS payment) always require both
approved_at and confirmed_at; the detail of a waiting post is seen only by the logged-in
author, with a message, without comments and recommending. "My posts" does not filter out
waiting posts and labels them. Flipping a setting leaves posts already posted unchanged.
Image dimensions and the watermark are not in the settings — do not bring them back
The variants are read from the DB (usages discussion-messages and
discussion-categories, see Image variants), the
watermark is the global watermarkPath; the assignment is edited in the Obrázky
part below the settings form. The old keys were deleted on 2026-08-13; if somebody put
them back into the form, the first save would recreate them, because
saveSettingForm() writes every listed key.
Images on a post#
Saving replaces the whole list of images
If the upload field does not come up, the attachments disappear — the same mechanism as with any other collection. In detail: Forms, the section on file uploads.
Deleting a thread (deleteNode(), and also the bulk deleteAll(), which deletes message by message
through deleteNode()) deletes its comment and rating threads, the images with their files and the
ES document.
The main image (Message::$asset) is derived from the image relations. On the external
:Api:Discussion:Message:save the owner cannot set it (#[FieldAccess] with Privileged on
the Api entity, the same as Advert::$asset); otherwise they could point the post at someone
else's image.
Guest post management#
The same mechanism as for adverts (shared ManageKeyTrait over ManageKeyAwareInterface):
- A post without an account gets a random secret in the
passwordcolumn on insert (a value from the data is ignored); nobody knows it, the link and the edit capability are bound to it. - The emails after posting (
DS_MESSAGE_INSERT,DS_MESSAGE_ACTIVATION_REQUEST) carry the linkdiskuze-editace/<id>?manageKey=…. The DB holds only the hash (manage_key_hash) and the validity (manage_key_expires, 7 days); using the key does not consume it. :Api:Discussion:Message:redeemManageKeyverifies the key and issues a capability with the scopediscussion.message.edit(CapabilityTokenIssuer) — a 2-hour edit session and a redirect to the edit page without the key in the URL.- Only
userIdcarries the owner; the ApiMessagePresenteraccepts the owner by identity, or by a capability carrying the hash of the secret from the DB. Changing the secret invalidates the link and the session. - Migration
2026-10-27_09_discussion_message_manage_link.sql(columns, RBAC key, the{if $manageLink}paragraph in the email templates).
Scheduled tasks#
| Endpoint | What it does | Recommended interval |
|---|---|---|
/cron/discussion/elastic/sync |
incremental index synchronisation | every 10 min |
/cron/discussion/elastic/reindex |
a full reindex | nightly |
/cron/discussion/sitemap/{message,category} |
sitemaps | nightly |
Where to look#
| I want | Where |
|---|---|
| the post and tree logic | app/UI/Api/Discussion/Models/Managers/MessageManager.php |
| voting | app/UI/Front/Discussion/Components/CommentListRowComponent/Traits/VoteTrait.php |
| the incremental counter write | app/Core/Traits/Api/Models/Managers/RegisterEventTrait.php |
| the module settings | app/UI/Admin/Discussion/Presenters/SettingPresenter.php |
| the cron tasks | app/UI/Cron/Discussion/Presenters/ |
Follow-up chapters: Blog · Company catalogue · Elasticsearch · Forms