Soukromé zprávy#
Konverzace mezi dvěma stranami (zájemce × majitel inzerátu); správce může z administrace přizvat další účastníky. Patří do modulu Base
(sdílené), proto jsou v app/UI/*/Base/. Pro uživatele:
Soukromé zprávy, pro správce:
Soukromé zprávy v administraci.
Data#
| Entita | Tabulka | Co nese |
|---|---|---|
MessageThread |
cms_mod_base_message_threads |
předmět, typ (bazaar_advert_response, comcat_advert_response, admin_direct), reply_channel (system / email_only), message_count, closed, deleted; žádného uživatele |
MessageParticipant |
…_message_participants |
userId (NULL = host), guest_*, role sender/recipient, unread_count, last_read_datetime, archived, deleted, notify, removed_datetime (NULL = aktivní, vyplněno = odebrán) |
Message |
…_messages |
participant_id = autor (NULL = systémová), text, deleted |
- Role
sender/recipientna účastníkovi znamená, kdo vlákno založil, a po odpovědích se nemění. Podle ní se dělí záložky Přijaté a Odeslané. - Stav „přečteno" je za účastníka × vlákno (
unread_count,last_read_datetime), ne za zprávu. Příznak přečteno u jednotlivé zprávy se odvozuje, tabulkamessage_readsneexistuje. - Vlákno vzniká jen z reakce na inzerát (
ResponseManager::openThreadForResponse) a z administrace (admin_direct). Z frontu se nová konverzace založit nedá. openThread()odmítne víc než jednoho příjemce (VALIDATION_FAILED): s třemi účastníky by „druhá strana" přestala být jednoznačná. Vlákno, účastníci a první zpráva jsou jedna transakce.- Je-li jeden z účastníků host (bez
userId), nastaví sereply_channel = email_only. - Reakce vlákno nezaloží, když jsou obě strany hosté (vlákno by nikdo neviděl ve schránce),
nebo když text obsahuje zakázané slovo (
MessageThreadCreatorTrait::openMessageThreadFor()). Reakce se v obou případech uloží a e-mail odejde, odpadne jen konverzace. Slova dodáváResponseManagermodulu přes hookblacklistedWordsForThread(), a to ze všech jazyků, protože spam se neřídí jazykem návštěvníka. Shodu hledáStringHelper::replaceWords(), tatáž funkce, která slova maskuje na inzerátu.
Ruční založení v administraci není transakce
Admin MessageThreadManager::openThread() je samostatný protějšek Api openThread(),
ne jeho volání: administrace jde přes ApiBridge a smí volat jen save a find, ne cizí
doménovou metodu (stejně jako Admin a Api ResponseManager v Bazaar a Comcat). Vlákno,
dva účastníci a první zpráva jsou čtyři samostatná uložení, takže při selhání pozdějšího
kroku admin verze smaže, co už vzniklo. Mazání je best-effort: když selže i ono, volající
dostane původní chybu, chyba úklidu jde do logu messageThread.compensate a vlákno bez
zprávy, které výpis nezobrazí, je potřeba uklidit ručně. Měníš-li pravidla zakládání,
oprav obě verze.
Kde bydlí logika#
| Věc | Kde | Proč |
|---|---|---|
| zakládání, odpovědi, přečtení, účastníci | MessageThreadManager (Api) |
každá zpráva přepočítá message_count a last_message_datetime na vláknu a posune stav účastníků; kdyby to dělal MessageManager, potřeboval by MessageThreadManager a vznikl by cyklus v DI. MessageManager je proto jen CRUD |
| vazba účastníka na vlákno | skalár MessageParticipant::$threadId, bez asociace thread |
sloupec už mapuje skalár ze sdílené báze a Doctrine nesmí mít dvě zapisovatelná mapování; otočení vlastnictví by vynutilo tenkou referenci thread: {id} v administraci. Vlákna ke schránce proto načítá MessageThreadPresenter::internalActionMyThreads() |
| čtení pro front | interní akce myThreads, myThreadMessages, myThreadParticipants, MessageParticipant:myList |
vlákno nemá sloupec vlastníka, takže filtr od volajícího u getAll by nebylo o co opřít a šlo by číst cizí konverzace. Rozsah dosazuje server z identity a filtr volajícího ho smí jen zúžit |
| oznámení „vlákno přečteno“ jiným modulům | rozhraní MessageThreadReadListener, MessageThreadReadListenerRegistry |
Base se na Bazaar ani Comcat odkazovat nesmí; ResponseManager rozhraní implementuje (synchronizace v Inzerci). Registr hledá posluchače líně v DI kontejneru, protože ResponseManager sám závisí na MessageThreadManager |
openThread() otevírá vlastní transakci. Volaný z afterSave() konzumenta (přes
MessageThreadCreatorTrait::openMessageThreadFor()) se vnoří do jeho transakce a neúspěšný
Result vrácený z afterSave() vrátí zpět i uložení reakce.
threadId pro myThreadMessages posílej jako číslo
Akce čte threadId z filtrů jen jako skalár. Objekt EqualFilter dorazí jako prázdný
objekt a výpis vyjde prázdný, přestože vlákno existuje. Front posílá
['threadId' => $id].
Posluchač přečtení nesmí volat markRead() s oznámením
Opačný směr (reakce → vlákno) volá markRead() s $notifyListeners = false (přes
markReadIfOnlyFirstUnread()). S oznámením by se oba směry volaly navzájem dokola.
Správa účastníků (jen z administrace)#
Vlákno vzniká s jedním příjemcem (pojistka v openThread() zůstává); další účastníky přidává jen
MessageThreadManager::addParticipant($threadId, $userId) a odebírá removeParticipant($threadId, $participantId).
Obě vracejí Result a běží v transakci jako openThread().
| Věc | Detail |
|---|---|
| odebrání | řádek účastníka se nemaže, vyplní se removed_datetime: messages.participant_id má ON DELETE SET NULL, smazáním by zprávy odebraného ztratily autora |
| „člen vlákna“ | vždy findParticipant() (bez odebraných); findParticipants($threadId, true) a findParticipantViews() vrací i odebrané (isFormer = true) jen kvůli podpisům zpráv |
| nový dotaz nad účastníky | musí přidat IsNullFilter('removedDatetime'), jinak by odebraný vlákno dál viděl |
| nový účastník | role recipient, vidí celou historii, unread_count = dosavadní zprávy + 1 systémová, last_read_datetime = NULL |
| odmítne se | neexistující, smazané, uzavřené a email_only vlákno; neexistující, neaktivovaný a zabanovaný uživatel; duplicita aktivního účastníka; 11. aktivní účastník; odebrání neaktivního; odebrání, po kterém by zůstal méně než 2 aktivní |
| konstanty | MessageParticipant::MAX_PER_THREAD = 10, MIN_ACTIVE_PER_THREAD = 2 |
| Api presenter | MessageThread má interní akce addParticipant a removeParticipant (bez action*() = zvenku 404), odpověď {success, code} |
| administrace | MessageThreadPresenter + Traits/Forms/MessageThreadParticipantTrait (formulář participantForm, signál removeParticipant!); brána checkPrivilege(':Admin:Base:MessageThread:edit') v actionEdit() platí i pro POST a signál; Api klíče :Api:Base:MessageThread:addParticipant / removeParticipant má po instalaci jen role admin |
Znovupřidání odebraného je reaktivace téhož řádku (role se nemění, nepřečtené = všechny zprávy).
Systémové zprávy#
Příchod a odchod se zapíše jako zpráva bez autora (participant_id = NULL). Text v DB je kód
[[system:joined:<participantId>]] nebo [[system:left:<participantId>]]
(Core/Base/Shared/Base/Models/Enums/MessageSystemEvent), věta se skládá až při vykreslení z překladu
(detail.systemEvents.joined|left, cs/en/sk) a aktuálního jména účastníka: konverzaci čtou
lidé s různým jazykem, věta uložená v jazyce správce by byla pro ostatní cizí. Nerozpoznaná
systémová zpráva se vypíše jako běžný text. Systémové zprávy se neoznamují e-mailem
(notifyParticipants() při senderParticipantId === null končí). Nová událost = nová hodnota
enumu + překlady + obě místa vykreslení (admin buildSystemTexts(), front buildSystemEvents()).
Role vlastník a příjemce nad vláknem#
Vlákno ani zpráva nemají jednoho vlastníka, proto se role odvozují z autorů zpráv
(MessageThreadManager::resolveCallerRoles(), MessageThreadRolesTrait):
| Role | Platí, když |
|---|---|
owner |
volající je účastník a napsal aspoň jednu nesmazanou zprávu |
receiver |
je účastník a ve vlákně je nesmazaná zpráva, kterou nenapsal (i systémová) |
Role nikde nepředpokládají dva účastníky a s počtem účastníků nesouvisejí. Čerstvé vlákno je pro zakladatele jen owner, pro druhou stranu jen receiver; po odpovědi
mají obě role oba. Nečlen (host, anonym) neprojde nikdy a nesmí poznat, zda vlákno existuje —
odpověď je bajt po bajtu stejná jako pro neexistující vlákno.
Akce nad jedním vláknem — myThreadMessages, myThreadReply, myThreadRead,
myThreadHide, MessageParticipant:myThreadParticipants — mají registrated = 0,
owner = 1, receiver = 1 a nemají action*(): jsou jen interní. Výpisy bez jednoho
záznamu (myThreads, myUnreadCount, MessageParticipant:myList) zůstávají registrated = 1
se serverovým rozsahem. ID vlákna jde jen jako ownedData['threadId'], identita z JWT.
Nepodmíněný grant by role obešel
U akcí s owner/receiver musí být registrated = 0, jinak se podmíněné role nikdy
nevyhodnotí. SQL 2026-10-02_14_message_thread_owner_receiver.sql to nastavuje.
Presentery MessageThread, Message a MessageParticipant mají vlastní isOwner(),
isReceiver() a verifyOwnership() (klasifikace custom v OwnershipFallbackCoverageTest;
verifyOwnership() je fail-closed false, protože akce nemají action*()). Rozhraní entit
tu nejde použít: zpráva má jen skalární participantId. Obecný postup:
Podmíněné role.
Viditelnost vlákna#
Pravidlo je na jednom místě, MessageThreadManager::visibleThreadIds(): vlákno není
deleted a existuje v něm zpráva s deleted = 0 (jeden dotaz EXISTS nad indexem
thread_id, vrací jen ID). Používá ho findThreadsForUser() (obě záložky, filtr „jen
nepřečtené", detail podle threadId — prázdné = 404 jako u nečlena; filtr běží před
stránkováním a totalCount skrytá vlákna nepočítá) i countUnreadThreadsForUser() (odznak).
message_count pro viditelnost nepoužívej
Zvyšuje se jen v addMessage(), o smazání nebo obnovení zprávy jinou cestou se
nedozví. Po smazání všech zpráv zůstalo message_count = 3. Rozhoduje skutečný stav
zpráv. message_count je „kolik zpráv bylo přidáno"; administrace ho ukazuje dál.
Vlákno, ve kterém správce smazal všechny zprávy, nemá vlastníka ani příjemce (role se odvozují z nesmazaných zpráv) a nešlo by otevřít ani skrýt — proto se nezobrazuje vůbec. Administrace ho vidí dál a detail ukazuje i smazané zprávy.
Nová zpráva vrací konverzaci do schránky všem účastníkům (bumpParticipantsAfterMessage()
shodí archived i deleted, tedy i „Skrýt") a nepřečtená +1 dostanou ostatní.
Detail vlákna#
Detail dřív nevypsal žádnou zprávu: MessageDetailTrait posílal threadId jako objekt
EqualFilter, myThreadMessages ho čte jako skalár (is_numeric), objekt dorazil jako
prázdný a výpis vyšel prázdný — přitom markThreadRead vlákno přečetlo. Druhá chyba:
účastníci přes MessageParticipant:myList jsou jen vlastní řádek, takže druhá strana byla
„Registrovaný uživatel" a u email_only chyběl kontakt hosta. Oprava:
| Část | Co |
|---|---|
MessageParticipant:myThreadParticipants |
vrací členu vlákna {id, isMe, isGuest, name, email, phone}: registrované druhé straně jen zobrazované jméno (jméno a příjmení, jinak uživatelské jméno), e-mail a telefon nese jen host; žádné userId, žádný stav druhé strany |
MessageParticipantView |
Front DTO |
MessageReadState::unreadMessageIds() |
štítek Nové: cizí zpráva novější než last_read_datetime (nebo NULL) ∪ posledních unread_count cizích zpráv (přesná pojistka pro zprávu ve stejné sekundě jako otevření); volá se v actionDetail() před markThreadRead() |
odznak MessageBell |
počítá vlákna ne archivovaná, ne smazaná a viditelná podle visibleThreadIds() — stejně jako filtr „jen nepřečtené" |
Šablona podepisuje „Vy" / jméno / „Nepřihlášený návštěvník" / „Systémová zpráva". Text zprávy
je uživatelský vstup, nikdy |noescape.
Potvrzení o přečtení (volitelné)#
Rozhodnutí provozovatele, ve výchozím stavu vypnuto: odesílatel vidí u vlastní zprávy štítek Přečteno, když ji druhá strana otevřela.
| Věc | Detail |
|---|---|
| přepínač | cms_system_settings, modul 2 (Base), klíč message.readReceipts, '0', is_public = 0 (čte ho jen server) |
| administrace | Nastavení webu → sekce Soukromé zprávy → Potvrzení o přečtení zpráv (SettingPresenter::SECTIONS['messages'], typ bool) |
| SQL | 2026-10-02_15_message_read_receipts_setting.sql (+ _85_ rollback); ON DUPLICATE KEY UPDATE value = value nepřepíše nastavenou hodnotu |
| rozhodnutí | v Api: MessageThreadManager::findParticipantViews() přidá lastReadDatetime (ISO 8601, nebo null = zatím nečetl) jen když je přepínač zapnutý a jen u cizího registrovaného účastníka; při vypnutém přepínači klíč v odpovědi není vůbec |
| čtení přepínače | readReceiptsEnabled() přes SettingContext → cache nastavení (tag Setting); uložení v administraci tag zvedne, takže změna platí hned |
| front | MessageParticipantView::$lastReadDatetime, MessageReadState::readMessageIds(), šablona detail.latte (štítek badge badge--secondary) |
readMessageIds(): vlastní zpráva je přečtená, když aspoň jeden jiný registrovaný účastník
má lastReadDatetime ≥ čas zprávy (neostrá nerovnost, stejná sekunda = přečteno). U cizích a
systémových zpráv, u hosta a u účastníka s null se nevyhodnotí nic.
Rozhoduje se na straně Api, front pole nikdy nedostane
Při vypnutém přepínači by null znamenalo „nečetl" a to by byla nepravdivá informace;
proto se klíč nevydává. Hodnota přepínače se ke klientovi nikdy nedostává.
Známá nepřesnost, přijatá
Čas je na sekundy a last_read_datetime je čas posledního otevření celého vlákna,
ne jednotlivé zprávy. Zpráva napsaná tutéž sekundu, kdy druhá strana vlákno otevřela, může
vyjít jako „Přečteno" dřív, než ji druhá strana skutečně uviděla. Opačná pojistka přes
unread_count existuje jen u „Nové".
Štítek nepoužívá <footer>
Globální styl footer obarví řádek jako patičku webu; proto <div class="message-item__read">.
E-mail o nové zprávě#
notifyParticipants() po addMessage() pošle ostatním účastníkům (kromě odesílatele a
těch s notify = 0 a bez známé adresy) e-mail s náhledem textu; plné znění je až po
přihlášení (zpráva může nést osobní údaje a e-mail je nešifrovaný kanál). Je to
best-effort: celé v try, chyba jde do log/messageThread.notify.log a zpráva se
uloží. Provozovatel musí mít vyplněné 1/email.address a funkční SMTP nebo frontu.
Z CLI e-mail nejde odeslat
Ve skriptu mimo web se SettingManager::initialize() nezavolá, takže mapa nastavení je
prázdná a email.address je null. Není to chyba kódu ani konfigurace; vzor pro harness
je tests/Manual/Base/private_message_notification.php.
Migrace#
| Soubor | Co |
|---|---|
2026-10-02_14_message_thread_owner_receiver.sql |
registrated 1→0, owner 0→1, receiver 0→1 u čtyř akcí nad vláknem; nový klíč myThreadParticipants (skupina Base:MessageThread:show) |
2026-10-02_15_message_read_receipts_setting.sql |
přepínač message.readReceipts |
2026-10-02_16_message_thread_participants_admin.sql |
sloupec removed_datetime + Api klíče addParticipant, removeParticipant (skupina Base:MessageThread:show, jen role admin); idempotentní |
Rollbacky _86_, _85_ a _84_. Po aplikaci smaž temp/cache (RBAC se cachuje).
Pořadí nasazení u _16_: nejdřív SQL, pak kód
Kód čte removed_datetime při každém čtení účastníků; nasazený před SQL spadne na neexistujícím
sloupci. Pořadí: SQL, kód, temp/cache. Před rollbackem _84_ vrať odebrané účastníky
(UPDATE … SET removed_datetime = NULL), jinak by po zahození sloupce vlákno znovu viděli.
Kam sáhnout#
| Chci | Kde |
|---|---|
| logiku konverzace | app/UI/Api/Base/Models/Managers/MessageThreadManager.php |
| oznámení přečtení jiným modulům | app/UI/Api/Base/MessageThreadReadSync/ |
| role nad vláknem | app/Core/Traits/Api/Presenters/MessageThreadRolesTrait.php, MessageThreadManager::resolveCallerRoles() |
| viditelnost vlákna | MessageThreadManager::visibleThreadIds() |
| účastníky pro detail | MessageThreadManager::findParticipantViews() |
| „Nové" a „Přečteno" | app/UI/Front/Base/Models/Helpers/MessageReadState.php |
| detail ve frontu | app/UI/Front/Base/Presenters/Traits/Details/MessageDetailTrait.php, Templates/Message/detail.latte |
| přepínač | SettingPresenter (sekce messages), MessageThreadManager::SETTING_READ_RECEIPTS |