Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Moduly / Soukromé zprávy

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 / recipient na úč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, tabulka message_reads neexistuje.
  • 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í se reply_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á ResponseManager modulu přes hook blacklistedWordsForThread(), 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

Nastavení cookies