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

Inzerce#

Modul Bazaar — inzeráty s dynamickými poli, placené tarify, hlídací pes a odpovědi. Kód: app/UI/Api/Bazaar/, front app/UI/Front/Bazaar/, administrace app/UI/Admin/Bazaar/.

Z čeho se modul skládá#

Oblast Hlavní entity Poznámka
inzerát Advert + relace AdvertParameterRel, AdvertCategoryRel, AdvertAssetRel, AdvertFileRel téměř všechna data jsou v relacích, ne ve sloupcích
kategorie Category, CategoryText, CategoryPrice, CategoryParameterRel nested set
parametry Parameter, ParameterGroup, ParameterRule + ParameterRuleCondition/Source/Target pravidla řídí, které pole se kdy zobrazí
placení Tariff, TariffPrice, TariffQuickPrice, PaymentItem
interakce Response, AdvertFollower, AdvertReport, AdvertStatistic
hlídací pes Watchdog, WatchdogEmail + relace uložené hledání

Vlastní zakázaná slova modul nemá, viz Zakázaná slova.

Inzerát nemá pevná pole#

Název, popis, cena, e-mail i region jsou dynamické parametry (AdvertParameterRel). Sloupce v tabulce jsou jen denormalizované kopie kvůli řazení a filtrování — ty se totiž nad textovou hodnotou parametru dělat nedají.

Klíč parametru Denormalizovaný sloupec Kdo ho plní
price price (číslo, kvůli řazení a rozsahovému filtru) applyPriceColumn() v beforeSave() z kolekce parameterRels
email email zápis inzerátu
regionId region_id zápis inzerátu

Přejmenování vyhrazeného klíče parametru tiše rozbije řazení

Nic to neohlásí — beforeSave() prostě nenajde, co má do sloupce zapsat. Řazení podle ceny přestane fungovat a odpovědi přestanou chodit, protože se nemá kam poslat e-mail. Vypadá to jako chyba výpisu.

Ceny v různých měnách se nepřepočítávají

Do sloupce jde holé číslo bez přepočtu. Řazení podle ceny tak míchá měny dohromady — 100 EUR se seřadí před 200 CZK.

Sloupec price má self-heal cron

/cron/bazaar/advert/recount-prices (à 10–15 minut) srovná nesrovnalosti po importech a ručních zásazích do databáze. Je to pojistka, ne primární plnění. Přeindexuje jen inzeráty, kterým se cena opravdu změnila; od 200 změn najednou spustí úplný reindex.

Sloupec active počítá databáze#

cms_mod_bazaar_adverts.active je generovaný sloupec (VIRTUAL):

active = CASE WHEN approved_at IS NOT NULL AND confirmed_at IS NOT NULL
              AND paid = 1 AND banned = 0 AND deleted = 0
              AND deactivated = 0 AND sold = 0
         THEN 1 ELSE 0 END

active nejde nastavit ani z administrace, ani z API

Mění se jedině zápisem některého z těch příznaků (včetně approved_at a confirmed_at). Entita ho má proto označený insertable: false, updatable: false a navíc #[ExternalReadonly] — bez toho druhého by setActive() přepsal hodnotu v paměti ještě před uložením a odpověď by vrátila klientem vymyšlený stav místo skutečného.

Expirace v tom vzorci NENÍ

Prošlý inzerát zůstává active = 1; z výpisů ho vyřazuje až aplikační filtr. Kdo si napíše vlastní dotaz jen na active = 1, dostane i inzeráty po expiraci.

Postup: přidávám dynamický parametr#

  1. Založ parametr a připoj ho ke kategorii (CategoryParameterRel).
  2. Volby výběru jsou děti parametru — parametr je kořen stromu, jeho potomci jsou jednotlivé volby.
  3. Rozhodni, jestli má mít denormalizovaný sloupec. Potřebuje ho jen to, podle čeho se řadí nebo filtruje rozsahem.
  4. Pravidla zobrazení (ParameterRule) řeší, kdy se pole ukáže — podmínka nad jiným parametrem.
  5. Prokliknij vložení i editaci inzerátu v té kategorii.

Smazání uzlu smaže celý podstrom

U parametru včetně všech jeho voleb — a s nimi hodnoty ve všech inzerátech, které tu volbu měly vyplněnou. Data se nedají vrátit.

Propsání cen nebo parametrů do podstromu je nevratné

Přepíše všechny podkategorie bez ptaní a bez zálohy. Na velkém stromu to znamená stovky přepsaných kategorií jedním kliknutím. S cenami (meta['setPricesToChildren']) se propíše i příznak „Zdarma“ (forFree), takže z placených podkategorií se stanou bezplatné, nebo naopak.

Zápis inzerátu přes Api#

Externí klient (JWT) posílá inzerát na :Api:Bazaar:Advert:save. Externí meta se zahazuje celé, takže vše, co administrace posílá v meta, musí klient poslat vnořené v data. $graphDataKeys manageru to povoluje pro categoryIds, assets a files (viz Klíče mimo sloupce).

Klíč v data Tvar Kdy platí Co se stane
categoryIds int[] jen vložení, při úpravě se tiše ignoruje syncCategoryRels() v afterSave(); paid podle Category::getForFree()
categoryRels [{category:{id:{id:X}}, paid:bool}] vložení i úprava kolekce v objektovém grafu; paid od klienta server přepíše (níž)
parameterRels [{parameter:{id:{id:X}}, value:'…', value2?:'…'}] vložení i úprava kolekce v objektovém grafu, páruje se podle dvojice inzerát + parametr
assets, files [{type:'existing'\|'new', imageId?/fileId?, name?, data?, order?}] vložení i úprava type:'new' nese obsah souboru v base64 v data; [] odebere všechny přílohy
password — nikdy neposílá se ani nečte; e-mail po vložení nese odkaz „Správa inzerátu“ (Odkaz s klíčem)

Ploché tvary {id, value} nebo {categoryId, paid} hydrátor nezná. U obou kolekcí v grafu platí: chybějící klíč kolekci nechá, jak je, [] ji celou smaže.

paid u categoryRels na externí cestě ($meta['__external']) přepíše CategoryRelsPaidSanitizationTrait v beforeSave():

Vazba Výsledné paid
už je v databázi hodnota z databáze, klient ji nezmění ani nevynuluje
nová Category::getForFree(), stejně jako u categoryIds

Změnu zapíše do logu auth jako EXTERNAL_CATEGORY_REL_PAID_SANITIZED. Administrace paid posílá z formuláře a přepis ji nepotká.

Vložení bez jediné kategorie odmítne beforeSave() kódem ADVERT_CATEGORY_REQUIRED (618). Počítá se categoryIds i categoryRels. Úpravu brána nehlídá: odebrat poslední kategorii existujícímu inzerátu je platné.

Pravidla parametrů na Api#

Délku, zákaz URL, zakázaná slova, povinnost a povolené volby parametrů dřív hlídal jen front formulář a přímé Api save je obešlo. Teď je v beforeSave() hlídá i Api: AdvertParameterPolicyTrait::guardParameterValues(), sdílená s Katalogem firem. Kontroluje se jen neprivilegovaný volající (podle FieldAudienceContext); administrátor, cron a CLI ne.

Pravidlo errorCode (VALIDATION_FAILED, v datech i parameterId) Kdy
hodnota delší než max_length (u editoru viditelný text, EditorTextLength::visible()) parameterTooLong každé uložení
URL v hodnotě a parametr má throw_error_on_url parameterUrlNotAllowed každé uložení
zakázané slovo jazyka stránky a parametr má throw_error_on_blacklisted_word parameterBlacklistedWord každé uložení
volba, kterou by formulář za odeslaných hodnot nenabídl (pravidla odhalení voleb) parameterOptionNotAllowed každé uložení
prázdný povinný parametr parameterRequired jen vložení, nebo když formulář pošle meta['parametersTouched']

Povinnost a nabízené volby počítá sdílený ParameterRuleEvaluator (app/Core/Form/Parameters/Rules/) se sémantikou frontu: cíl pravidla zobrazení je povinný jen při splněných podmínkách, necílený povinný parametr vždy; podmínky se vyhodnotí validátory Nette nad uloženou hodnotou (RuleValueControl). Front volá stejné funkce (DependentSelectTrait pro odhalení voleb), takže formulář a Api se nerozejdou. Uploadery a skrytá pole se jako povinné nehodnotí. Kontext (parametry kategorií, výchozí nebo z vazeb podle první kategorie, pravidla a aktivní volby) čte skalárně AdvertParameterRuleContextRepositoryTrait::parameterRuleContext(), protože běží uvnitř ukládání.

Externí úprava povinnost nehodnotí

Externí meta se zahazuje, takže parametersTouched z externího klienta nikdy nepřijde. Při úpravě přes Api se proto kontrolují jen hodnoty a volby, ne chybějící povinné parametry (úprava nemusí nést celou sadu).

Odpověď na save je extract() téže instance v paměti. parameterRels a categoryRels v ní jsou, obrázky a soubory ne (zpracuje je afterSave() mimo graf), takže id nově nahraných příloh klient z odpovědi nezjistí.

Postup: píšu externího klienta, který zakládá a upravuje inzeráty

  1. Při vložení pošli kategorie v categoryIds (stačí id) nebo v categoryRels.
  2. Parametry pošli v parameterRels v tvaru hydrátoru, včetně názvu a ceny.
  3. Při úpravě posílej jen klíče, které měníš. Kategorie měň přes categoryRels, categoryIds se při úpravě nepoužije.
  4. Posíláš-li assets nebo files, načti inzerát po uložení znovu s includes, jinak id příloh nezjistíš.
  5. Ověř v databázi paid u kategorií a expiration_date.

Prázdné categoryRels při úpravě smaže všechny kategorie

Klient, který kategorie měnit nechce, klíč nepošle vůbec. S [] inzerát o kategorie přijde a zmizí z výpisů.

categoryIds při úpravě nic nezmění

Uložení skončí úspěchem a kategorie zůstanou původní. Chyba se neohlásí.

Formulář inzerátu v administraci#

Editace inzerátu je běžný formulář admin presenteru (AdvertFormTrait), dynamické parametry staví sdílený ParameterFieldBuilder. Každá část inzerátu jde do Api jinou cestou a prázdné odeslání u každé znamená něco jiného:

Část Jak jde do Api Prázdné odeslání
parametry (parameterRels) vlastněná kolekce v objektovém grafu, applyParameterRels() kolekce se nepřepíše, uložené hodnoty zůstanou
kategorie (categoryRels) vlastněná kolekce v objektovém grafu, applyCategoryRels() při každém uložení odebere všechny kategorie inzerátu (orphanRemoval)
obrázky a soubory meta['assets'], meta['files'], zpracuje Api afterSave() [] odebere všechny; null (uploader ve formuláři není) klíč vynechá a nic se nezmění
assetRels, fileRels na entitě jen ke čtení; Api je z dat vyřízne (defaultSkipFields) —

Které parametry formulář ukáže, určují vybrané kategorie (stejně jako na frontu):

Výběr Parametry
žádná kategorie žádné, pole se objeví až po výběru kategorie
první vybraná kategorie má „použít výchozí parametry“ výchozí parametry (default = 1), pořadí podle stromu
jinak sjednocení cílení všech vybraných kategorií: povinný, když ho vyžaduje kterákoli; pořadí nejmenší; skupina první vyplněná

Při překreslení po změně kategorie se vybrané kategorie čtou ze surového POST. Multiplier kategorií se k formuláři připojí až po parametrech, takže z formuláře by ještě nešly přečíst.

Prázdné parameterRels smaže všechny parametry inzerátu

Kolekci parametrů nahrazuj jen neprázdnými řádky. Prázdná kolekce v grafu znamená pro Api „tyhle řádky už nemají být“ a orphanRemoval smaže všechny hodnoty včetně názvu a ceny. U kategorií je prázdná kolekce naopak platné „odebrat vše“.

null a [] u uploaderu nejsou totéž

Kdo je sloučí, buď nepůjde odebrat poslední obrázek (prázdný seznam se nepošle), nebo formulář bez uploaderu při uložení smaže všechny přílohy (chybějící seznam se pošle jako prázdný). Api hook je rozlišuje array_key_exists().

Cílení parametrů kategorie#

Kategorie neukládá cílení grafem. Formulář pošle ploché řádky v meta['parameterRels'] (parametr, skupina, povinný, hledání, pořadí; volby mají skupinu NULL) a CategoryParameterRelSyncTrait je v afterSave() přepíše (smaže a vloží znovu):

Stav kategorie Uložení meta['setParametersToChildren']
vlastní cílení řádky se přepíšou podle meta['parameterRels'] podkategoriím se propíše příznak i řádky
„použít výchozí parametry“ uložené řádky zůstanou, po vypnutí příznaku se kategorie vrátí ke svému nastavení propíše se jen příznak, řádky podkategorií zůstanou

Chybějící klíč cílení nemění, prázdný seznam ho smaže

Když meta['parameterRels'] v uložení chybí (externí Api, import, vlastní kód), uložené cílení zůstane beze změny. Poslaný prázdný seznam naopak všechny řádky smaže, takže ho posílej jen tehdy, když cílení opravdu rušíš. setParametersToChildren bez klíče propíše podkategoriím uložené cílení kategorie. Chování drží tests/Unit/Bazaar/Api/CategoryParameterRelsMetaTriStateTest.php.

Denormalizovaná počítadla#

Hodnota Kdo ji srovná
advert.price cron /cron/bazaar/advert/recount-prices
category.advert_count cron /cron/bazaar/category/recount
advert.email, advert.region_id zápis inzerátu

Přepočet počtů je set-based UPDATE nad celým stromem — rodič počítá aktivní inzeráty ze svého celého podstromu, ne jen z přímých dětí.

Počet má stejné podmínky jako veřejný výpis

Počítá se active = 1 a expiration_date >= dnes, tedy totéž co ve výpisu kategorie (AdvertListTrait::prepareFilters()). Žádné nastavení se do toho nemíchá: potvrzení autorem je součástí active (confirmed_at IS NOT NULL). „Dnes“ dodá PHP v časové zóně aplikace, ne SQL funkce, protože databáze běží v UTC. Kdo počty ověřuje ručním SQL, použije obě podmínky.

Inzerát po expiraci zmizí z výpisu hned, z počtu až po přepočtu

Počet je stav k poslednímu přepočtu. Ten spustí cron /cron/{modul}/category/recount (à 10–15 minut), potvrzení autorem, tvrdé smazání a anonymizace účtu, běžná úprava inzerátu ne. Mezi přepočty ukazuje kategorie víc inzerátů, než výpis vypíše.

Schválení a potvrzení v nastavení#

Dvě nezávislé podmínky zveřejnění, obě se vyhodnocují při vložení:

Co Klíče v nastavení modulu
potvrzení autorem odkazem z e-mailu (kladná sémantika „vyžaduje se") advert.confirmation.required.logged, advert.confirmation.required.guest
schválení správcem advert.approval.auto (u Firemních profilů v modulu 6 totéž)

Přihlášený autor je ten, kdo má u záznamu vlastníka. Nevyžaduje-li se potvrzení, záznam dostane confirmed_at hned; vyžaduje-li, dostane aktivační klíč (v DB jen otisk) a čeká na odkaz. Přepnutí nastavení už vložené záznamy nemění. Aktivační klíč je v administraci jen ke čtení.

Rozhoduje applyInitialApproval() a applyInitialConfirmation() v beforeSave() Api manageru, jen při insertu a na každé cestě (web, externí Api, administrace). Logika je sdílená s příspěvky diskuze (InitialApprovalTrait, InitialConfirmationTrait):

Situace při vložení approved_at confirmed_at E-mail po commitu
advert.approval.auto zapnuté teď — —
advert.approval.auto vypnuté nebo chybí NULL, čeká na správce — —
potvrzení se nevyžaduje — teď oznámení o vložení
vyžaduje se — NULL, vydá se klíč s odkazem na potvrzení
vyžaduje se, ale správce zaškrtl Potvrzeno — ponechá vyplněné žádný
vyžaduje se, ale záznam nemá e-mail (externí Api, import) — teď žádný

Volba e-mailu se bere ze stavu při vložení ($meta), ne z nastavení přečteného po commitu. S $meta['suppressNotifications'] (úprava v administraci, import, cron) neodejde žádný. Potvrzení klíčem pak řeší confirmByKey(), viz Odkaz s klíčem.

Přepínače „Schváleno“ a „Deaktivováno“ při zakládání v administraci se přepíšou

applyInitialApproval() nastaví deactivated = 0 a approved_at podle nastavení i u záznamu, který zakládá správce. Kdo chce nový inzerát rovnou schválit, upraví ho po založení.

Záznam bez potvrzení musí dostat confirmed_at hned

Viditelnost stojí jen na confirmed_at IS NOT NULL (je i ve vzorci active). Nový zápis, který tuhle větev obejde, vytvoří inzerát, který se nikdy nezobrazí.

Adresa a souřadnice při uložení#

Město, PSČ a stát rozkládá z vybrané obce server (AdvertRegionAddressTrait, sdílí ho Bazaar i Comcat). JavaScript našeptávače pole plní jen jako náhled. Souřadnice počítá GeocodingManager z parametrů až po rozkladu; přesnost řídí nastavení modulu geocoding.precise_address (v Bazaaru výchozí vypnuto, v Comcatu zapnuto). Když souřadnice nevzniknou, po commitu se zapíše řádek do log/geocoding-miss.log.

Adresa Odkud souřadnice Když se nenajdou
nepřesná, nebo přesná bez ulice střed obce: vybrané, jinak přesná shoda obce nebo PSČ v datasetu regionů; Google nikdy GeocodingMiss
přesná s ulicí cms_system_geocoding_cache, při chybění Google a zápis do cache střed obce, jinak GeocodingMiss

Tentýž GeocodingManager obsluhuje i filtr „do X km“ na frontu (Api akce :Api:System:Region:geocode, resolveSearchText()). Čtení regionů jde raw SQL, takže výpočet uvnitř beforeSave() nesahá na identity mapu rozpracovaného grafu inzerátu.

Mění se jen inzerát s vybranou obcí (list stromu regionů). Starý inzerát zařazený na stát nebo kraj zůstane, jak je, město je u něj volný text.

reattachRelReferences() zůstává poslední

applyRegionAddress() → applyCoordinates() → reattachRelReferences(). Oba první kroky volají findAll() parametrů a kategorií. Prosté čtení identity mapu nečistí, ale kdyby ji mezitím vyprázdnilo UnitOfWorkReset::abort() nebo hranice cizí operace, vazby relací by ukazovaly na odpojené entity a flush by spadl na A new entity was found through the relationship 'AdvertParameterRel#parameter'. reattachRelReferences() je proto obranně za všemi načítacími kroky a nové načítání patří před něj. Viz Identity mapa.

Smazání inzerátu#

Způsob Co se stane
měkké smazání (deleted = 1) jen zápis sloupce; inzerát zůstane i s daty, obrázky i indexem a jde vrátit
smazání nadobro (delete()) AdvertHardDeleteTrait přes hooky DeleteTrait; co a v jakém pořadí, určuje AdvertHardDeletePlan

Cizí klíče na inzerát jsou CASCADE, ale kaskáda nepokryje frontu hlídacího psa v Bazaaru (NO ACTION), soukromé konverzace odpovědí a hodnoticí vlákno (vazba vede od inzerátu k vláknu, vlákno by osiřelo), řádky a soubory obrázků a příloh ani ES index a počty v kategoriích. Proto má mazání explicitní kroky: beforeDelete/afterDelete v transakci, soubory a index po commitu v completedAdvertHardDelete().

completedAdvertHardDelete() volá manager ručně

Oba AdvertManager mají vlastní completedDelete() (ES) a ta by metodu traitu tiše přebila. Manager, který trait přebírá, musí completedAdvertHardDelete() zavolat ze své completedDelete().

Účetní data se s inzerátem nemažou. Doklady (cms_mod_invoicer_*) a platby (cms_system_payments, cms_system_payment_items, cms_system_payment_transactions) na inzerát cizí klíč nemají; jediné pojítko je modulová cms_mod_*_payment_items (aktivace tarifu), která účetním dokladem není a smaže se kaskádou. Doklad i platba zůstanou celé, snapshot položek nese název i cenu.

Před prvním zápisem projde AdvertHardDeletePlan::uncoveredForeignKeys() graf cizích klíčů z information_schema od tabulky inzerátů po kaskádách:

Pravidlo vazby Výsledek
CASCADE dítě se smaže, kontroluje se dál do jeho dětí; kaskáda do účetní tabulky je chyba
SET NULL dítě přežije s prázdnou vazbou, v pořádku
NO ACTION / RESTRICT v pořádku jen pro sloupce, které plán maže explicitně dřív (explicitlyClearedColumns())

Nepokrytá vazba mazání odmítne s ADVERT_DELETE_BLOCKED (624) a seznamem vazeb v logu delete.advert, nic se nesmaže.

Postup: přidávám tabulku s cizím klíčem na inzerát (nebo na jeho dítě)

  1. Dej vazbě ON DELETE CASCADE, když řádky nemají smysl bez inzerátu, jinak SET NULL.
  2. Potřebuje-li vazba NO ACTION (řádky se mají mazat vlastní logikou), přidej krok do AdvertHardDeletePlan a sloupec do explicitlyClearedColumns().
  3. Doplň vazbu do snímku schématu v tests/Unit/Api/AdvertHardDeletePlanTest.php a spusť ho.
  4. Smaž v administraci testovací inzerát nadobro v obou modulech (Bazaar i Comcat).

Vazba bez kroku v plánu zablokuje mazání všech inzerátů

Nová tabulka s NO ACTION na inzerát nespadne na databázové výjimce uprostřed kaskády, ale tvrdé mazání v mřížce začne vracet chybu pro každý inzerát modulu, dokud vazbu plán nepokryje.

Kaskáda do účetní tabulky se odmítne, ne provede

Kdyby nová vazba vedla kaskádu z inzerátu do tabulky s prefixem z ACCOUNTING_TABLE_PREFIXES, mazání skončí ADVERT_DELETE_BLOCKED. Účetní řádek patří k dokladu, ne k inzerátu — vazbu přepiš na SET NULL nebo ji zruš.

Hlídací pes#

Uložené hledání, které při novém inzerátu pošle e-mail.

Spouští se JEN při vložení inzerátu

Ne při editaci ani při přesunu do jiné kategorie. Inzerát, který se do kategorie dostane až později, hlídačům nikdy nepřijde — a nikomu nic nechybí, dokud se nezeptá inzerent.

Při vložení prepareEmails() vždy vyrenderuje a uloží e-mail hlídače (WatchdogEmail s texty ve všech jazycích). Kdo ho doručí, určuje nastavení watchdog.email.mode (skupina 5):

Režim Fronta hlídače Kdo posílá
0 nic nikdo
1 řádky v cms_mod_bazaar_watchdog_email_rel cron /cron/bazaar/watchdog-email/send (à 10 min), e-mail na každý inzerát
2 řádky ve frontě cron /cron/bazaar/watchdog-email/digest (denně 23:00), jeden souhrn na příjemce
3 řádky vzniknou, ale nikdo je nečte dispatchImmediateFanout() po commitu rovnou do systémové fronty e-mailů

Inzerát hlídači padne, když sedí všechno najednou:

Podmínka Shoda
kategorie kategorie hlídače nebo kterákoli její podkategorie
region region hlídače je region inzerátu nebo jeho předek; inzerát bez regionu nepadne nikomu
typ, stav (parameter_id, parameter_id2) NULL = cokoli; jinak má inzerát řádek rodičovského parametru s value = id volby
hlídač active = 1, u režimu 3 i shodný jazyk textu

Režim 1 zkouší odeslat nejvýš watchdog.email.maxAttempts× (výchozí 5). Řádek bez doručitelného e-mailu (hlídač ani jeho uživatel ho nemají) se zruší (cancelled). Souhrn v režimu 2 má jen jeden pokus: zpracovaný řádek je vždy sent nebo cancelled, aby nikdo nedostal souhrn dvakrát.

Souhrn bere jen čekající řádky k inzerátům vloženým za poslední dva dny (WatchdogEmailRelRepository::DIGEST_MAX_AGE_DAYS, měří se insert_datetime inzerátu). První běh po nasazení tak nerozešle celou historii čekajících řádků.

Souhrn, který dva dny neběží, inzeráty z té doby už nepošle

Řádky starších inzerátů zůstanou ve stavu „čeká“ a úklid fronty je nesmaže (čekající řádky nemaže nikdy). Výpadek cronu nebo přepnutí do režimu 2 s plnou frontou znamená, že hlídači ty inzeráty nedostanou a fronta s nimi poroste.

V režimu 3 posílá databázový trigger, dokud existuje

PHP rozesílání se drží zpátky, dokud v databázi je trigger cms_mod_bazaar_watchdog_email_texts_AFTER_INSERT (fanoutTriggerExists()). Po jeho smazání převezme rozesílání PHP samo. Smazat trigger dřív, než je na serveru nový kód, znamená, že hlídači nedostanou nic.

Volba hlídače se páruje přes řádek rodičovského parametru

Formulář inzerátu ukládá vybranou volbu jako hodnotu rodičovského parametru (parameter_id = parametr, value = id volby). Import nebo vlastní zápis, který uloží volbu jinak, hlídači s typem nebo stavem tiše nezachytí.

Fronta upozornění (cms_mod_bazaar_watchdog_email_rel) roste s každým inzerátem, který hlídač zachytí. Úklid dělá /cron/bazaar/watchdog-email/purge-old (denně): smaže odeslané a zrušené řádky starší než N dní a pak e-maily a jejich texty, na které už žádný řádek neukazuje. Čekající řádky nemaže nikdy a běží v každém režimu rozesílání, i když je rozesílání vypnuté.

Retence Odkud
?days=N v adrese přednost
nastavení watchdog.email.retentionDays (skupina 5) jinak
90 dní (WatchdogSendManager::DEFAULT_RETENTION_DAYS) výchozí

Stejně funguje fronta v Katalogu firem (/cron/comcat/watchdog-email/purge-old).

Odpovědi na inzerát: příjemce a přečtení#

Reakce rozlišuje odesílatele (owner) a příjemce (receiver). Příjemce je advert.insertUserId, ne sloupec reakce, proto Response rozhraní ReceiverAwareInterface neimplementuje a o příjemci rozhoduje ResponseReceiverTrait dotazem: privilegovaná role ∨ majitel inzerátu ∨ heslo k inzerátu ∨ platný read_key (jen interně). Detail reakce (Response:get) dřív otevřel jen administrátor, takže majitel svou reakci neviděl.

Věc Detail
detail reakce Front ResponsePresenter::actionDetail(): jen majitel inzerátu (jinak 403); read se nastaví jen při požadavku z vlastního webu (isSameSite())
tlačítko v e-mailu {$markReadLink} v šablonách BZ_ADVERT_RESPONSE, BZ_ADVERT_SUGGEST_PRICE; viz Odkaz s klíčem
veřejná stránka Front:Bazaar:Response:markRead, id stránky MARK_READ_SLUG = 2011; výpis reakcí LIST_SLUG = 158, detail DETAIL_SLUG = 159
soukromá konverzace openThreadForResponse() při INSERTu založí vlákno odesílatel × majitel; je-li jeden host, je vlákno „jen e-mailem"
pád reakce s cenou měna bez překladů se načítala líně na entitě odpojené vyprázdněnou identity mapou; sendEmail() teď bere find($id, ['translations'])

Podrobně o rolích v Podmíněných rolích.

V administraci se reakce maže měkce (deleted = 1): mřížka reakcí přes setDeleteMethod('softDelete') (ResponseManager::softDelete()) i záložka Odpovědi v editaci inzerátu (signál toggleDeleted!). Front reakce s deleted = 1 nevypisuje. Tvrdé smazání by reakci odstranilo nevratně i s vazbou na soukromou konverzaci (message_thread_id), kterou založila.

Štítek kategorie na kartě nevedl na inzeráty

Štítek kategorie na kartě inzerátu (AdvertRenderer Style_1–3) dřív vedl na :Front:Blog:Article:list … category: (/clanky/o-1?category=109). Vede na výpis inzerátů kategorie. Zástupný segment - pro kategorii viz Navigace po úrovních.

Admin Bazaar Advert neredeklaroval kolekci followers

include(['followers']) padal na TypeError; kolekce je v admin entitě jako #[Transient].

Uložení reakce: brány v beforeSave()#

Reakci zapisuje frontový formulář, administrace i externí :Api:*:Response:save. Externí cesta formulář nepotká vůbec, payload jde hydrátorem rovnou do entity. Jediné místo, kudy projde každý zápis, je ResponseManager::beforeSave() — v Inzerci i v Katalogu firem, oba managery sdílejí tytéž traity. Brány jdou v tomto pořadí:

# Brána Odmítne (VALIDATION_FAILED, errorCode) Upraví
1 guardResponseColumnLimits() cena není číslo (invalidPrice), je záporná nebo mimo rozsah sloupce (priceOutOfRange); telefon, e-mail nebo předmět delší než sloupec (phoneTooLong, emailTooLong, subjectTooLong) prázdnou cenu na NULL, vyplněnou na tečkový tvar zaokrouhlený na scale sloupce
2 guardResponseCurrency() cena bez měny (currencyRequired); měna, která není veřejná peněžní, třeba kredity (currencyNotAllowed) nulovou cenu na NULL; bez ceny měnu zahodí
3 při INSERTu — dosadí náhodný read_key pro odkaz s klíčem

Meze sloupců se neopisují, brána je čte reflexí z #[ORM\Column] entity, která se opravdu ukládá (ColumnLimits). Po změně schématu se posunou samy. Prázdná cena a telefon projdou: jsou nepovinné a povinnost řeší formulář.

Bez normalizace ceny by se do DB zapsalo něco jiného, než brána změřila

"12,5" projde bránou jako 12.5, ale syrový řetězec by ve striktním sql_mode shodil uložení obecnou chybou databáze a v nestriktním by se uložil jako 12.000. Proto brána cenu po kontrole přepíše. Bez brány by přetečení ve striktním sql_mode shodilo uložení; v nestriktním by se uložilo tiše (nabídka za nulu, uříznutý e-mail, na který odpověď nedojde) a inzerent by ho viděl v e-mailu.

Měnu kontroluj až po mezích sloupců

Teprve první brána převede prázdnou cenu na NULL. V opačném pořadí by měnová brána nepoznala, jestli je cena vyplněná.

Na UPDATE měnová brána zasáhne jen když se dvojice cena a měna mění. Klasifikaci měny může administrátor změnit i po vložení reakce (internal/monetary), a úprava textu, read nebo deleted staré reakce nesmí padnout na dnešní klasifikaci. Uložený stav čte findPersistedValues() (PersistedValuesTrait) přímým DBAL dotazem: na cestě uložení je entita už zmutovaná hydratací a find() by z identity mapy vrátil tutéž instanci s novými hodnotami, takže by brána změnu nikdy neviděla.

Přečtení reakce a vlákna#

Příznak read na reakci a nepřečtené zprávy v soukromé konverzaci, kterou reakce založila, se drží v souladu (ResponseThreadReadSyncTrait):

Směr Kdy Co se stane
reakce → vlákno úspěšné updateColumns() s read = 1 vlákno se označí za přečtené majiteli inzerátu, a jen když je jeho jediná nepřečtená zpráva ta první z reakce
vlákno → reakce majitel inzerátu přečte vlákno navázaná reakce dostane read = 1 (přímo přes service, aby se vlákno neoznačovalo znovu)

Označuje se vždy jen pro majitele inzerátu, nikdy pro toho, kdo akci vyvolal, takže držitel klíče z e-mailu nezíská nic navíc. U anonymního inzerátu (majitel bez účtu) se vlákno neřeší.

Přečtení zapsané jinak než přes updateColumns() vlákno neoznačí

Synchronizace visí na updateColumns() manageru. read uložený přes save() nebo raw SQL nechá vlákno nepřečtené a majitel uvidí v poště „novou“ zprávu, kterou už četl.

Topování, zvýraznění a expirace#

Placené zviditelnění má čtyři nezávislé tarify. Jejich id nejsou v kódu, ale v config/Shared/bazaar.neon (přepsat jdou v config/Shared/local.neon):

Tarif Parametr (výchozí id) Co udělá ve výpisu Odkud se to pozná
topování advert.bazaarToppingTariffId, comcatToppingTariffId (1) inzerát jde ve výpisu nahoru (toped_until > NOW() první) sloupec toped_until, plní ho platba přes AdvertToppingConfig
zvýraznění advert.bazaarHighlightTariffId, comcatHighlightTariffId, discussionHighlightTariffId (2) karta dostane třídu highlighted (podbarvení tokenem --list-item-highlighted-bg) aktivní platná položka platby toho tarifu, jeden dotaz na vykreslení výpisu (AdvertHighlightConfig)
Topbox id předává šablona ([3]) inzerát se ukáže v bočním widgetu „TOP“ (TopAdvertList, {control frontBazaarTopAdvertList [3], 'Style_2', 10} v aside-left.latte všech šablon) aktivní platná položka platby toho tarifu
zobrazení odkazů advert.bazaarLinksTariffId, comcatLinksTariffId, discussionLinksTariffId (4) na detailu jsou adresy v popisu klikací odkazy (TextLinkifier, filtr \|linkify, rel="ugc nofollow") aktivní platná položka platby toho tarifu (AdvertLinksConfig); presenter detailu volá Advert::setRenderUrlsAllowed(true)

Odkazy obnoví jen filtr v režimu „při zobrazení“

Bez tarifu se adresa v parametru s filtrem odkazů nahradí [URL]. Tarif vypne jen nahrazení v režimu OnRender; v režimu OnSave je v databázi už [URL] a vrátit nejde.

Kdo toped_until přepočítá, závisí na cestě, kterou se platební položka změnila (PaymentItemManager v Api):

Cesta Přepočet toped_until
aktivace platbou z brány (updateStatusForPayment()) monotonní: jen prodlouží na nejzazší platnost aktivních položek
uložení položky mimo administraci monotonní, jen u aktivní položky
uložení v záložce Platby editace inzerátu (meta['recalcTopedUntil']) absolutní: nejzazší validity_datetime aktivních položek tarifu topování, bez nich NULL; umí i zkrátit
updateColumns() s active nebo validityDatetime (přepínač ve výpisu) absolutní
smazání položky absolutní, v transakci mazání (afterDelete())
přesun položky k jinému inzerátu absolutně se přepočítá i původní inzerát

Diskuze to má stejně, jen nad příspěvky (recalcTopedUntilForMessages()).

Zápis položky mimo tyhle cesty topování nesrovná

Ruční SQL nebo uložení bez meta['recalcTopedUntil'] nechá toped_until beze změny. Deaktivovaná nebo zkrácená položka pak dál drží inzerát nahoře až do staré platnosti.

Smazání prostřední položky topování zkrátí jen přibližně

Aktivace platbou staví platnost nové položky na nejzazší platnost dosavadních aktivních položek téhož tarifu (GREATEST(MAX(validity_datetime), NOW()) + trvání tarifu). Absolutní přepočet pak bere jen maximum uložených validity_datetime a pozdější položky nepřepočítává. Smazáním prostřední položky tak inzerát zůstane nahoře o trvání smazané položky déle; přesné je jen smazání poslední. Platí to pro inzerci, katalog firem i diskuzi.

Id tarifu musí sedět s daty

Když na webu tarif „Zvýraznění“ dostane jiné id než výchozí 2 a konfigurace se nepřepíše, zaplacené zvýraznění se ve výpisu neukáže — a žádná chyba nevznikne.

Datum expirace počítá AdvertExpirationTrait:

Situace Nová expirace
vložení inzerátu dnes + nejdelší expirationInterval z kategorií inzerátu
prodloužení (Advert:extendExpiration) současná expirace, je-li v budoucnu, jinak dnes, + nejdelší interval kategorií
žádná kategorie nemá interval (všechny 0) místo intervalu nastavení 5/advert.expiration.days
aktivace platby (updateExpirationDateForPayment()) posune na nejzazší validity_datetime zaplacených položek platby, jen dopředu

Nulový interval všude znamená, že inzerát nejde vložit

Když mají všechny kategorie interval 0 a advert.expiration.days je 0 nebo chybí, expirace se nespočítá. Vložení i prodloužení pak skončí INVALID_CONFIGURATION (errorCode: noExpirationInterval u vložení) dřív, než se cokoli zapíše; žádnou náhradní délku si systém nevymýšlí. Nastav interval aspoň u jedné kategorie, nebo výchozí advert.expiration.days.

Cron Co dělá Interval
/cron/bazaar/advert/notice-expiration upozorní N dní před expirací (5/advert.expiration.noticeDays), nastaví příznak, aby se neopakovalo denně
/cron/bazaar/advert/delete-expired tiše označí deleted = 1 po M dnech od expirace (5/advert.expiration.deleteDays) denně

Kroky „Cena do“ ve vyhledávání#

Nabídku „Cena do“ ve vyhledávacím boxu staví App\Core\Config\AdvertPriceStepsConfig podle měny webu (měna regionu lokalizace). Kroky jsou v config/Shared/bazaar.neon pod advert.bazaarPriceSteps jako klíč měny => kroky:

bazaarPriceSteps:
    CZK: [1000, 5000, 10000, 50000, 100000, 500000, 1000000]
    EUR: [50, 200, 500, 1000, 5000, 20000, 50000]
Měna webu Jaké kroky dostane
je v mapě své kroky
v mapě není kroky první měny v mapě přepočtené ručním kurzem (ApproximatePrice) a zaokrouhlené na 1 / 2 / 5 × 10ⁿ
přepočet nejde (kurz 0, interní nebo neznámá měna) kroky první měny i s jejím označením — popisek nikdy netvrdí jinou měnu, než v jaké jsou čísla

Filtr ceny se nepřepočítává

Krok jde do ?priceTo= jako holé číslo a AdvertListTrait ho porovná s uloženou cenou inzerátu bez ohledu na její měnu. Na webu, kde mají inzeráty jinou měnu než web, filtr tiše vybírá podle špatných částek.

Inzerát bez ceny při zvolené hranici zmizí

preparePriceFilter() přidá price <= priceTo nad denormalizovaným sloupcem price. Inzeráty s price IS NULL (cena dohodou) tím z výpisu vypadnou. Je to záměr: nabídka bez ceny strop nesplní. Bez priceTo v adrese se nepřidá nic.

Komponenty frontu#

Výpisy inzerátů na hlavní straně#

Tři výpisy vybírají inzeráty každý jinak, kreslí je ale stejný AdvertListRenderer, takže styly karet jsou sdílené.

Komponenta Co vybírá Volání v šabloně
LastAdvertList nejnovější podle data vložení {control frontBazaarLastAdvertList:box, 6}, :row, :table; třetí argument = id volby parametru type
TopAdvertList topované podle zaplacených tarifů {control frontBazaarTopAdvertList [3], 'Style_2', 10} (id tarifů, styl, počet)
AdvertTabList taby last, visited (viewsDetail), hot přepínané signálem tab! {control frontBazaarAdvertTabList:box, 8}

Viditelnost řídí presenter: má-li prepareFilters(), výpis použije jeho filtry (kategorie, region, potvrzení). Jinak nastoupí pevná sada: nesmazaný, zaplacený, neprodaný, nezabanovaný, neaktivovaný, active = 1 a neprošlá expirace.

Id typu inzerátu je datové

Filtr LastAdvertList podle typu bere id volby (dítě parametru s klíčem type), ne id parametru. Volby jsou řádky v tabulce parametrů a liší se instalaci od instalace. Id opsané z jiné instalace vybere jiný typ nebo nic. Zjisti ho v administraci (Parametry → type). Když parametr type chybí, filtr se tiše vynechá.

Tab hot počítá odpovědi za posledních 30 dní (HOT_WINDOW_DAYS) v PHP, z inzerátů s nejvíc odpověďmi vezme trojnásobek limitu a teprve na ně pustí viditelnost. Kandidátů je víc, protože smazaný nebo prodaný inzerát s mnoha odpověďmi by jinak zabral místo.

Tab hot může vyjít kratší než limit

Když z trojnásobku kandidátů projde viditelností méně inzerátů, než je limit, tab jich ukáže méně. Doplňování dalšími kandidáty neexistuje.

Vyhledávací box#

SearchBox má tři styly: Mini (fulltext a tlačítko, hlavičky modulů), Medium (fulltext, kategorie, region) a Large (přepínač typu, kategorie, region, cena; fulltext: true přidá textové pole). Style a StyleInline jsou aliasy Mini; StyleInline volají hlavičky šablon, proto ho neruš bez jejich úpravy.

Předvybraná hodnota selectu musí být v nabídce

Nette SelectBox::setDefaultValue() na hodnotu mimo nabídku vyhodí InvalidArgumentException. Box je v hlavičce výpisů, takže hodnota z adresy, kterou nabídka nezná, by shodila na 500 /inzerce i všechny výpisy. Kategorie a „Cena do“ proto jdou přes safeDefault(): neznámou hodnotu zahodí a select zůstane na promptu. Nový select plněný z adresy veď stejnou cestou.

Názvy kategorií a regionů v nabídce jsou lokalizovaná data, proto je box obaluje NotTranslate (viz Překlady).

Formulář inzerátu na frontu#

AdvertForm stojí na sdíleném ParameterForm. Hodnoty pro předvyplnění dává getEntityValues() z LoadingTrait:

Situace Odkud hodnoty
editace řádky parameterRels inzerátu (vícehodnotové jako pole, rozsah jako value/value2)
nový inzerát, přihlášený uživatel údaje z účtu přes klíč parametru: email, phone, street, city, zip a postcode, country, regionId, firstname, lastname
nový inzerát, host nic

Klíč se mapuje jen tehdy, když formulář parametr s tím klíčem opravdu má.

Vlastní getEntityValues() v AdvertForm vypne předvyplnění

Metoda třídy má přednost před metodou traitu. Kdo do AdvertForm dopíše vlastní getEntityValues(), přebije LoadingTrait a nový inzerát se přestane plnit z účtu, bez chyby.

Dynamické parametry jdou do Api jako kolekce parameterRels (applyParameterRels()), ale jen když formulář nějaké řádky odeslal. Prázdné pole by na Api straně orphanRemoval smazalo všechny parametry inzerátu, proto se při prázdném odeslání kolekce nepřepisuje.

Karta prodejce na detailu#

Další inzeráty majitele a jejich počet hledá prepareOtherAdverts() podle insertUserId, jedním dotazem (active, neprošlá expirace). Počet jsou všechny nalezené, výpis totéž bez zobrazeného inzerátu. Inzerát bez účtu majitele nemá podle čeho hledat: výpis je prázdný a karta s počtem se nezobrazí.

Hledání podle e-mailu nenajde nic

Api e-mail na výstupu maskuje (PersonalDataPolicy, viz RBAC). Filtr podle maskované hodnoty nesedí na žádný řádek a výpis by byl vždy prázdný.

Zobrazení telefonu#

Signál showPhone! (#[Requires(sameOrigin: true, ajax: true)]) má dvě větve:

Inzerát Odkud číslo Limit
s účtem UserManager::revealPhone() → guard :Api:System:User:showPhone ratelimit.showPhone (20 za hodinu na IP) na Api vrstvě
bez účtu $advert->get('phone') FrontFormLimiter::isAdvertPhoneRevealAllowed(), stejné hodnoty pod vlastním klíčem

Prázdná odpověď (zamítnuto, limit, bez čísla) šablonu nepřepne a zůstane maskovaná hodnota.

Telefon nevypisuj jen přepnutím proměnné šablony

Číslo je v datech inzerátu už při vykreslení. Signál, který jen nastaví showPhone, by obešel guard i limit a čísla by šla posbírat projetím inzerátů.

Notifikace#

Sledující dostanou upozornění POUZE při změně cenovky

Porovnává se trojice cena, typ ceny a měna. Změna názvu, popisu ani fotek neupozorní nikoho — a je to schválně, jinak by úprava překlepu rozeslala stovky e-mailů.

Editace v administraci neposílá žádné e-maily

Ani aktivační, ani hlídačům, ani sledujícím. Je to záměr: administrátor opravuje cizí inzerát a nemá tím spouštět komunikaci jeho jménem.

Zakázaná slova#

Inzerce, Katalog firem i Diskuze čtou jeden seznam pro celý web: systémovou tabulku cms_system_blacklisted_words (Systém → Zakázaná slova) přes Api System\BlacklistedWordManager (findActiveValues(?languageId) = aktivní slova jazyka, malými písmeny; null = všechny jazyky). Modulové kopie cms_mod_*_blacklisted_words i jejich entity a managery byly odstraněny.

Kde Co se stane
formulář a detail inzerátu parametr s throw_error_on_blacklisted_word slovo odmítne, při zobrazení se slova maskují (ContentFilterMode::OnRender)
Api uložení inzerátu AdvertParameterPolicyTrait, viz Pravidla parametrů na Api
vlákno odpovědi (ResponseManager) text se zakázaným slovem vlákno v poště nezaloží; reakce se uloží a e-mail odejde
index ES (AdvertDocumentSource) texty se čtou stejně jako při zobrazení (setRenderBlacklistedWords() se slovy cílového jazyka), takže maskovaná slova do indexu neprosáknou

Nahlášení inzerátu#

AdvertReport funguje stejně jako nahlášení příspěvku v Diskuzi: příznak reported inzerátu dorovnává sdílená ReportedFlagSyncTrait v AdvertReportManager (i po hromadném deleteAll()) a administrace AdvertReportPresenter používá sdílenou ReportModerationPresenterTrait (výchozí nevyřešená, Vyřešit, Znovu otevřít). Uložení inzerátu příznak přepočítá z tabulky hlášení, takže ho formulář ani zastaralá entita nepřepíše.

Elasticsearch#

Inzerce je jeden ze zdrojů Elasticsearch — reindex /cron/bazaar/elastic/reindex, průběžná synchronizace /cron/bazaar/elastic/sync.

Hromadné operace mimo hooky index nevidí

Set-based UPDATE (přepočty, importy, ruční SQL) hook nespustí, takže se do indexu nepropíše. Konzistenci drží až noční reindex — do té doby hledání vrací stará data a nikde to není vidět.

Kam sáhnout#

Chci Kde
logiku inzerátu a denormalizace app/UI/Api/Bazaar/Models/Managers/AdvertManager.php
schválení, potvrzení, adresu a mazání sdílené s Comcatem app/Core/Traits/Api/Models/Managers/Advert*Trait.php, Initial{Approval,Confirmation}Trait.php
přepočet počtů v kategoriích app/UI/Api/Bazaar/Models/Managers/CategoryManager.php → recountAdverts()
plán tvrdého smazání a kontrolu cizích klíčů app/Core/Utils/ValueObjects/AdvertHardDeletePlan.php
brány uložení reakce a synchronizaci přečtení app/Core/Traits/Api/Models/Managers/Response{ColumnGuard,CurrencyGuard,ThreadReadSync}Trait.php, app/UI/Api/Bazaar/Models/Managers/ResponseManager.php
hlídací pes app/UI/Api/Bazaar/Models/Managers/Watchdog*Manager.php, dotazy shody v Repositories/Db/WatchdogEmailRelRepository.php
zápis inzerátu přes Api a výpočet expirace AdvertManager.php ($graphDataKeys), Traits/AdvertManager/{CategoryRelsPaidSanitization,AdvertExpiration}Trait.php
definici entity a jejích příznaků app/Core/Base/Shared/Bazaar/Models/Entities/Advert.php
cron úlohy app/UI/Cron/Bazaar/Presenters/
tarify topování a zvýraznění, kroky „Cena do“ config/Shared/bazaar.neon, app/Core/Config/Advert{Topping,Highlight,PriceSteps}Config.php
formulář inzerátu na frontu app/UI/Front/Bazaar/Components/AdvertFormComponent/
výpisy na hlavní straně, vyhledávací box app/UI/Front/Bazaar/Components/{LastAdvertList,TopAdvertList,AdvertTabList,SearchBox}Component/
kartu prodejce a zobrazení telefonu app/UI/Front/Bazaar/Presenters/Traits/Details/AdvertDetailTrait.php, Presenters/AdvertPresenter.php (handleShowPhone())
formulář inzerátu a kategorie v administraci app/UI/Admin/Bazaar/Presenters/Traits/Forms/Advert*Trait.php, CategoryFormTrait.php
cílení parametrů kategorie app/UI/Api/Bazaar/Models/Managers/Traits/CategoryManager/CategoryParameterRelSyncTrait.php
přepočet topování app/UI/Api/Bazaar/Models/Managers/PaymentItemManager.php

Navazující kapitoly: Katalog firem · Elasticsearch · Filtry · Naplánované úlohy

Nastavení cookies