Private messages#
Conversations between two sides (an interested person × the owner of an advert); an administrator can invite further participants from the administration. They belong to
the Base module (shared), hence they sit in app/UI/*/Base/. For users:
Private messages, for administrators:
Private messages in the administration.
The data#
| Entity | Table | What it carries |
|---|---|---|
MessageThread |
cms_mod_base_message_threads |
subject, type (bazaar_advert_response, comcat_advert_response, admin_direct), reply_channel (system / email_only), message_count, closed, deleted; no user |
MessageParticipant |
…_message_participants |
userId (NULL = guest), guest_*, role sender/recipient, unread_count, last_read_datetime, archived, deleted, notify, removed_datetime (NULL = active, filled = removed) |
Message |
…_messages |
participant_id = the author (NULL = system), text, deleted |
- The role
sender/recipienton a participant says who started the thread and does not change after replies. The Received and Sent tabs follow it. - The "read" state is per participant × thread (
unread_count,last_read_datetime), not per message. The read flag of a single message is derived; there is nomessage_readstable. - A thread arises only from a response to an advert (
ResponseManager::openThreadForResponse) and from the administration (admin_direct). A new conversation cannot be started from the front. openThread()refuses more than one recipient (VALIDATION_FAILED): with three participants "the other side" would stop being unambiguous. The thread, participants and the first message are one transaction.- If one of the participants is a guest (no
userId),reply_channel = email_onlyis set. - A response creates no thread when both sides are guests (nobody would see the thread in
a mailbox) or when the text contains a blacklisted word
(
MessageThreadCreatorTrait::openMessageThreadFor()). In both cases the response is saved and the e-mail goes out; only the conversation is skipped. The module'sResponseManagersupplies the words through theblacklistedWordsForThread()hook, taken from all languages, because spam does not follow the visitor's language. Matching usesStringHelper::replaceWords(), the same function that masks the words on the advert.
Manual creation in the administration is not a transaction
The admin MessageThreadManager::openThread() is a separate counterpart of the Api
openThread(), not a call to it: the administration goes through the ApiBridge and may
call only save and find, not a foreign domain method (just like the Admin and Api
ResponseManager in Bazaar and Comcat). The thread, two participants and the first
message are four separate saves, so when a later step fails the admin version deletes
what has been created. The deletion is best-effort: if it fails too, the caller gets the
original error, the cleanup error goes to the messageThread.compensate log, and the
thread without a message, which the list does not show, has to be cleaned up by hand.
When you change the creation rules, fix both versions.
Where the logic lives#
| Thing | Where | Why |
|---|---|---|
| opening, replies, reads, participants | MessageThreadManager (Api) |
every message recounts message_count and last_message_datetime on the thread and advances the participants' state; doing that in MessageManager would need MessageThreadManager and create a DI cycle. MessageManager is therefore plain CRUD |
| a participant's link to the thread | the scalar MessageParticipant::$threadId, no thread association |
the column is already mapped by the scalar of the shared base and Doctrine must not have two writable mappings; reversing ownership would force a thin reference thread: {id} in the administration. Threads for the mailbox are therefore loaded by MessageThreadPresenter::internalActionMyThreads() |
| reads for the front | internal actions myThreads, myThreadMessages, myThreadParticipants, MessageParticipant:myList |
a thread has no owner column, so a caller filter on getAll would have nothing to anchor to and foreign conversations could be read. The server sets the scope from the identity and the caller's filter may only narrow it |
| "thread read" notice to other modules | the interface MessageThreadReadListener, MessageThreadReadListenerRegistry |
Base must not reference Bazaar or Comcat; ResponseManager implements the interface (the sync is in Classifieds). The registry looks the listeners up lazily in the DI container, because ResponseManager itself depends on MessageThreadManager |
openThread() opens its own transaction. Called from a consumer's afterSave() (through
MessageThreadCreatorTrait::openMessageThreadFor()), it nests inside that transaction and a
failed Result returned from afterSave() rolls back the response save as well.
Send threadId for myThreadMessages as a number
The action reads threadId from the filters only as a scalar. An EqualFilter object
arrives as an empty object and the list comes out empty although the thread exists. The
front sends ['threadId' => $id].
A read listener must not call markRead() with notification
The opposite direction (response → thread) calls markRead() with
$notifyListeners = false (through markReadIfOnlyFirstUnread()). With notification the
two directions would call each other endlessly.
Managing participants (administration only)#
A thread arises with one recipient (the safeguard in openThread() stays); further participants
are added only by MessageThreadManager::addParticipant($threadId, $userId) and removed by
removeParticipant($threadId, $participantId). Both return a Result and run in a transaction,
like openThread().
| Item | Detail |
|---|---|
| removal | the participant row is not deleted, removed_datetime is filled in: messages.participant_id has ON DELETE SET NULL, so deleting would leave the removed person's messages without an author |
| "thread member" | always findParticipant() (without removed ones); findParticipants($threadId, true) and findParticipantViews() also return removed ones (isFormer = true) only for the signatures of messages |
| a new query over participants | must add IsNullFilter('removedDatetime'), otherwise the removed person would still see the thread |
| a new participant | role recipient, sees the whole history, unread_count = the existing messages + 1 system message, last_read_datetime = NULL |
| refused | a non-existent, deleted, closed and email_only thread; a non-existent, not activated and banned user; a duplicate of an active participant; an 11th active participant; removing an inactive one; a removal that would leave fewer than 2 active |
| constants | MessageParticipant::MAX_PER_THREAD = 10, MIN_ACTIVE_PER_THREAD = 2 |
| Api presenter | MessageThread has internal actions addParticipant and removeParticipant (no action*() = 404 from outside), response {success, code} |
| administration | MessageThreadPresenter + Traits/Forms/MessageThreadParticipantTrait (form participantForm, signal removeParticipant!); the gate checkPrivilege(':Admin:Base:MessageThread:edit') in actionEdit() also covers POST and the signal; after installation only the admin role has the Api keys :Api:Base:MessageThread:addParticipant / removeParticipant |
Re-adding a removed person is a reactivation of the same row (the role does not change, unread = all messages).
System messages#
Joining and leaving is written as a message without an author (participant_id = NULL). The text
in the DB is a code [[system:joined:<participantId>]] or [[system:left:<participantId>]]
(Core/Base/Shared/Base/Models/Enums/MessageSystemEvent); the sentence is composed only at
render time from the translation (detail.systemEvents.joined|left, cs/en/sk) and the
participant's current name: the conversation is read by people with different languages, and
a sentence stored in the administrator's language would be foreign to the others. An unrecognised
system message is printed as plain text. System messages are not announced by email
(notifyParticipants() ends when senderParticipantId === null). A new event = a new enum value
+ translations + both render places (admin buildSystemTexts(), front buildSystemEvents()).
Owner and receiver over a thread#
A thread or message has no single owner, so the roles are derived from the authors of the
messages (MessageThreadManager::resolveCallerRoles(), MessageThreadRolesTrait):
| Role | Applies when |
|---|---|
owner |
the caller is a participant and wrote at least one undeleted message |
receiver |
is a participant and the thread has an undeleted message they did not write (a system one too) |
The roles assume no number of participants and are unrelated to it. A fresh thread is only owner for the starter and only receiver for the other side; after a
reply both have both roles. A non-member (a guest, an anonymous caller) never passes and must
not be able to tell whether the thread exists — the answer is byte for byte the same as
for a non-existent thread.
The actions over one thread — myThreadMessages, myThreadReply, myThreadRead,
myThreadHide, MessageParticipant:myThreadParticipants — have registrated = 0,
owner = 1, receiver = 1 and have no action*(): they are internal only. Listings
without a single record (myThreads, myUnreadCount, MessageParticipant:myList) stay
registrated = 1 with a server scope. The thread ID goes only as ownedData['threadId'], the
identity from the JWT.
An unconditional grant would bypass the roles
For actions with owner/receiver, registrated must be 0, otherwise the conditional
roles are never evaluated. The SQL 2026-10-02_14_message_thread_owner_receiver.sql sets
this.
The presenters MessageThread, Message and MessageParticipant have their own isOwner(),
isReceiver() and verifyOwnership() (classification custom in
OwnershipFallbackCoverageTest; verifyOwnership() is a fail-closed false because the
actions have no action*()). Entity interfaces cannot be used here: a message has only a
scalar participantId. The general procedure:
Conditional roles.
Thread visibility#
The rule is in one place, MessageThreadManager::visibleThreadIds(): the thread is not
deleted and has a message with deleted = 0 (one EXISTS query over the thread_id index,
returns only IDs). It is used by findThreadsForUser() (both tabs, the "unread only" filter,
the detail by threadId — empty = 404 as for a non-member; the filter runs before
paging and totalCount does not count the hidden threads) and by
countUnreadThreadsForUser() (the badge).
Do not use message_count for visibility
It only increases in addMessage() and does not learn about a message deleted or restored
another way. After deleting all messages message_count = 3 remained. The actual state of
messages decides. message_count is "how many messages were added"; the administration
still shows it.
A thread in which an administrator deleted all messages has neither an owner nor a receiver (roles are derived from undeleted messages) and could be neither opened nor hidden — so it is not shown at all. The administration still sees it and the detail shows the deleted messages too.
A new message returns the conversation to the box of all participants
(bumpParticipantsAfterMessage() resets archived and deleted, i.e. also "Hide") and the
others get unread +1.
The thread detail#
The detail used to list no message at all: MessageDetailTrait sent threadId as an
EqualFilter object, myThreadMessages reads it as a scalar (is_numeric), the object
arrived as empty and the list came out empty — while markThreadRead had read the thread. A
second bug: participants through MessageParticipant:myList are only the caller's own row, so
the other side was "Registered user" and the guest's contact was missing on email_only. The
fix:
| Part | What |
|---|---|
MessageParticipant:myThreadParticipants |
returns to a thread member {id, isMe, isGuest, name, email, phone}: for a registered other side only a display name (first and last name, otherwise the username), email and phone are carried only by a guest; no userId, no state of the other side |
MessageParticipantView |
the Front DTO |
MessageReadState::unreadMessageIds() |
the Nové badge: a foreign message newer than last_read_datetime (or NULL) ∪ the last unread_count foreign messages (an exact safeguard for a message in the same second as the opening); called in actionDetail() before markThreadRead() |
the MessageBell badge |
counts threads that are not archived, not deleted and visible by visibleThreadIds() — like the "unread only" filter |
The template signs "Vy" (you) / the name / "Nepřihlášený návštěvník" / "Systémová zpráva".
The message text is user input, never |noescape.
Read receipts (optional)#
An operator decision, off by default: a sender sees the Přečteno badge on their own message once the other side has opened it.
| Thing | Detail |
|---|---|
| the switch | cms_system_settings, module 2 (Base), key message.readReceipts, '0', is_public = 0 (read only by the server) |
| administration | Site settings → the section Soukromé zprávy → Potvrzení o přečtení zpráv (SettingPresenter::SECTIONS['messages'], type bool) |
| SQL | 2026-10-02_15_message_read_receipts_setting.sql (+ rollback _85_); ON DUPLICATE KEY UPDATE value = value does not overwrite a set value |
| the decision | in the Api: MessageThreadManager::findParticipantViews() adds lastReadDatetime (ISO 8601, or null = has not read yet) only when the switch is on and only for a foreign registered participant; with the switch off the key is not in the response at all |
| reading the switch | readReceiptsEnabled() through SettingContext → the settings cache (tag Setting); saving in the administration bumps the tag, so the change applies at once |
| front | MessageParticipantView::$lastReadDatetime, MessageReadState::readMessageIds(), the template detail.latte (the badge badge badge--secondary) |
readMessageIds(): an own message is read when at least one other registered participant has
lastReadDatetime ≥ the message time (a non-strict inequality, the same second = read). For
foreign and system messages, for a guest and for a participant with null nothing is
evaluated.
It is decided on the Api side, the front never receives the field
With the switch off, null would mean "has not read" and that would be untrue
information; so the key is not issued. The switch value never reaches the client.
A known imprecision, accepted
The time is to the second and last_read_datetime is the time of the last opening of the
whole thread, not of a single message. A message written in the same second in which
the other side opened the thread may come out as "Přečteno" before the other side actually
saw it. The reverse safeguard through unread_count exists only for "Nové".
The badge does not use <footer>
The global style footer colours the row like the site footer; hence
<div class="message-item__read">.
Email about a new message#
After addMessage(), notifyParticipants() sends the other participants (except the sender
and those with notify = 0 or without a known address) an email with a preview of the
text; the full text is only after signing in (a message may carry personal data and email is
an unencrypted channel). It is best-effort: all inside try, an error goes to
log/messageThread.notify.log and the message is saved. The operator must have 1/email.address
filled in and working SMTP or a queue.
Email cannot be sent from the CLI
In a script outside the web SettingManager::initialize() is not called, so the settings
map is empty and email.address is null. It is neither a code nor a configuration bug;
the pattern for a harness is tests/Manual/Base/private_message_notification.php.
Migrations#
| File | What |
|---|---|
2026-10-02_14_message_thread_owner_receiver.sql |
registrated 1→0, owner 0→1, receiver 0→1 for four actions over a thread; a new key myThreadParticipants (group Base:MessageThread:show) |
2026-10-02_15_message_read_receipts_setting.sql |
the switch message.readReceipts |
2026-10-02_16_message_thread_participants_admin.sql |
the column removed_datetime + Api keys addParticipant, removeParticipant (group Base:MessageThread:show, admin role only); idempotent |
Rollbacks _86_, _85_ and _84_. After applying clear temp/cache (RBAC is cached).
Deployment order for _16_: SQL first, then code
The code reads removed_datetime on every read of participants; deployed before the SQL it
fails on the non-existent column. Order: SQL, code, temp/cache. Before the _84_ rollback
restore the removed participants (UPDATE … SET removed_datetime = NULL), otherwise they would
see the thread again after the column is dropped.
Where to look#
| I want | Where |
|---|---|
| the conversation logic | app/UI/Api/Base/Models/Managers/MessageThreadManager.php |
| read notices to other modules | app/UI/Api/Base/MessageThreadReadSync/ |
| roles over a thread | app/Core/Traits/Api/Presenters/MessageThreadRolesTrait.php, MessageThreadManager::resolveCallerRoles() |
| thread visibility | MessageThreadManager::visibleThreadIds() |
| participants for the detail | MessageThreadManager::findParticipantViews() |
| "Nové" and "Přečteno" | app/UI/Front/Base/Models/Helpers/MessageReadState.php |
| the detail on the front | app/UI/Front/Base/Presenters/Traits/Details/MessageDetailTrait.php, Templates/Message/detail.latte |
| the switch | SettingPresenter (section messages), MessageThreadManager::SETTING_READ_RECEIPTS |