Classifieds#
The Bazaar module — adverts with dynamic fields, paid tariffs, saved-search alerts and
replies. The code lives in app/UI/Api/Bazaar/, the front end in app/UI/Front/Bazaar/,
the administration in app/UI/Admin/Bazaar/.
What the module is made of#
| Area | Main entities | Note |
|---|---|---|
| the advert | Advert plus the relations AdvertParameterRel, AdvertCategoryRel, AdvertAssetRel, AdvertFileRel |
almost all the data lives in relations, not in columns |
| categories | Category, CategoryText, CategoryPrice, CategoryParameterRel |
a nested set |
| parameters | Parameter, ParameterGroup, ParameterRule plus ParameterRuleCondition/Source/Target |
the rules decide which field appears when |
| payment | Tariff, TariffPrice, TariffQuickPrice, PaymentItem |
|
| interaction | Response, AdvertFollower, AdvertReport, AdvertStatistic |
|
| saved-search alerts | Watchdog, WatchdogEmail plus relations |
a stored search |
The module has no blacklisted words of its own, see Blacklisted words.
An advert has no fixed fields#
The title, description, price, email and region are all dynamic parameters
(AdvertParameterRel). The columns in the table are only denormalised copies for
sorting and filtering — neither can be done over a parameter's text value.
| Parameter key | Denormalised column | Who fills it |
|---|---|---|
price |
price (a number, for sorting and range filtering) |
applyPriceColumn() in beforeSave() from the parameterRels collection |
email |
email |
writing the advert |
regionId |
region_id |
writing the advert |
Renaming a reserved parameter key silently breaks sorting
Nothing reports it — beforeSave() simply does not find what to put into the column.
Sorting by price stops working and replies stop arriving, because there is nowhere to
send the email. It looks like a listing bug.
Prices in different currencies are not converted
The column receives the bare number with no conversion. Sorting by price therefore mixes currencies — 100 EUR sorts before 200 CZK.
The price column has a self-heal cron
/cron/bazaar/advert/recount-prices (every 10–15 min) reconciles discrepancies after
imports and manual database edits. It is a safety net, not the primary write path.
It reindexes only the adverts whose price really changed; from 200 changes at once it
starts a full reindex.
The active column is computed by the database#
cms_mod_bazaar_adverts.active is a generated column (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 cannot be set from the administration or from the API
It changes only by writing one of those flags (including approved_at and confirmed_at). The entity therefore marks it
insertable: false, updatable: false and additionally #[ExternalReadonly] — without
the latter, setActive() would overwrite the in-memory value before the save and the
response would return the client's invented state instead of the real one.
Expiry is NOT in that formula
An expired advert stays active = 1; it is removed from listings by an application
filter. Anyone writing their own query on active = 1 alone gets expired adverts too.
Procedure: adding a dynamic parameter#
- Create the parameter and attach it to a category (
CategoryParameterRel). - The select options are the parameter's children — the parameter is the root of a tree, its descendants are the individual options.
- Decide whether it needs a denormalised column. Only what is sorted by or range-filtered needs one.
- Display rules (
ParameterRule) decide when the field appears — a condition over another parameter. - Click through creating and editing an advert in that category.
Deleting a node deletes the whole subtree
For a parameter that includes all its options — and with them the values in every advert that had that option filled in. The data cannot be recovered.
Propagating prices or parameters into a subtree is irreversible
It overwrites every subcategory without asking and without a backup. On a large tree
that is hundreds of overwritten categories from one click. Together with the prices
(meta['setPricesToChildren']) the "Free" flag (forFree) is copied too, so paid
subcategories become free, or the other way round.
Writing an advert through the Api#
An external client (JWT) sends the advert to :Api:Bazaar:Advert:save. External meta is
dropped entirely, so everything the administration sends in meta the client must send nested
in data. The manager's $graphDataKeys allows that for categoryIds, assets and files
(see Keys outside columns).
Key in data |
Shape | When it applies | What happens |
|---|---|---|---|
categoryIds |
int[] |
insert only, silently ignored on update | syncCategoryRels() in afterSave(); paid from Category::getForFree() |
categoryRels |
[{category:{id:{id:X}}, paid:bool}] |
insert and update | collection in the object graph; the server overwrites the client's paid (below) |
parameterRels |
[{parameter:{id:{id:X}}, value:'…', value2?:'…'}] |
insert and update | collection in the object graph, matched by the advert + parameter pair |
assets, files |
[{type:'existing'\|'new', imageId?/fileId?, name?, data?, order?}] |
insert and update | type:'new' carries the base64 file content in data; [] removes all attachments |
password |
— | never | neither sent nor read; the insert e-mail carries a "Manage advert" link (Link with a key) |
The hydrator does not know the flat shapes {id, value} or {categoryId, paid}. For both graph
collections a missing key leaves the collection as it is, [] deletes all of it.
On the external path ($meta['__external']) CategoryRelsPaidSanitizationTrait overwrites
paid of categoryRels in beforeSave():
| Rel | Resulting paid |
|---|---|
| already in the database | the database value; the client can neither change nor clear it |
| new | Category::getForFree(), same as for categoryIds |
The change is logged to the auth log as EXTERNAL_CATEGORY_REL_PAID_SANITIZED. The
administration sends paid from the form and the overwrite does not touch it.
An insert without a single category is refused by beforeSave() with ADVERT_CATEGORY_REQUIRED
(618). Both categoryIds and categoryRels count. An update is not guarded: removing the last
category of an existing advert is valid.
Parameter rules on the Api#
Length, the URL ban, blacklisted words, required values and allowed options of parameters used to
be enforced only by the front form, and a direct Api save bypassed them. Now the Api enforces
them in beforeSave() too: AdvertParameterPolicyTrait::guardParameterValues(), shared with the
company catalogue. Only a non-privileged caller is checked (by
FieldAudienceContext); the administrator, cron and CLI are not.
| Rule | errorCode (VALIDATION_FAILED, the data also carry parameterId) |
When |
|---|---|---|
a value longer than max_length (for an editor the visible text, EditorTextLength::visible()) |
parameterTooLong |
every save |
a URL in the value while the parameter has throw_error_on_url |
parameterUrlNotAllowed |
every save |
a blacklisted word of the page language while the parameter has throw_error_on_blacklisted_word |
parameterBlacklistedWord |
every save |
| an option the form would not offer under the submitted values (option reveal rules) | parameterOptionNotAllowed |
every save |
| an empty required parameter | parameterRequired |
insert only, or when the form sends meta['parametersTouched'] |
Required parameters and offered options are computed by the shared ParameterRuleEvaluator
(app/Core/Form/Parameters/Rules/) with the front's semantics: the target of a display rule is
required only while the conditions hold, an untargeted required parameter always; conditions are
evaluated by Nette validators over the stored value (RuleValueControl). The front calls the same
functions (DependentSelectTrait for revealing options), so the form and the Api cannot drift
apart. Uploaders and hidden fields are not evaluated as required. The context (category
parameters, the default ones or those from the relations by the first category, rules and active
options) is read as scalars by AdvertParameterRuleContextRepositoryTrait::parameterRuleContext(),
because it runs inside the save.
An external update does not evaluate required parameters
External meta is discarded, so parametersTouched never arrives from an external client.
An update through the Api therefore checks only values and options, not missing required
parameters (an update need not carry the whole set).
The save response is extract() of the same in-memory instance. parameterRels and
categoryRels are in it, images and files are not (afterSave() handles them outside the
graph), so the client cannot learn the ids of newly uploaded attachments from the response.
Procedure: writing an external client that creates and edits adverts
- On insert, send the categories in
categoryIds(ids are enough) or incategoryRels. - Send the parameters in
parameterRelsin the hydrator shape, including the title and price. - On update, send only the keys you change. Change categories through
categoryRels;categoryIdsis not used on update. - If you send
assetsorfiles, read the advert again withincludesafter the save, otherwise you will not know the attachment ids. - Check
paidof the categories andexpiration_datein the database.
An empty categoryRels on update deletes all categories
A client that does not want to change categories does not send the key at all. With []
the advert loses its categories and disappears from listings.
categoryIds changes nothing on update
The save succeeds and the categories stay as they were. No error is reported.
The advert form in the administration#
Advert editing is an ordinary admin presenter form (AdvertFormTrait); the dynamic parameters
are built by the shared ParameterFieldBuilder. Each part of the advert reaches the Api by a
different path, and an empty submission means something different for each:
| Part | How it reaches the Api | Empty submission |
|---|---|---|
parameters (parameterRels) |
owned collection in the object graph, applyParameterRels() |
the collection is not overwritten, stored values stay |
categories (categoryRels) |
owned collection in the object graph, applyCategoryRels() on every save |
removes all categories of the advert (orphanRemoval) |
| images and files | meta['assets'], meta['files'], handled by the Api afterSave() |
[] removes all; null (no uploader in the form) omits the key and nothing changes |
assetRels, fileRels on the entity |
read only; the Api strips them from the data (defaultSkipFields) |
— |
Which parameters the form shows depends on the selected categories (same as on the front end):
| Selection | Parameters |
|---|---|
| no category | none; fields appear only after a category is chosen |
| the first selected category has "use default parameters" | the default parameters (default = 1), ordered by the tree |
| otherwise | the union of the targeting of all selected categories: required if any category requires it; the smallest order; the first filled-in group |
When the form is redrawn after a category change, the selected categories are read from the raw POST. The category multiplier is attached to the form only after the parameters, so it could not be read from the form yet.
Empty parameterRels deletes all parameters of the advert
Replace the parameter collection only with non-empty rows. An empty collection in the graph
tells the Api "these rows should no longer exist", and orphanRemoval deletes every value,
including the title and the price. For categories, an empty collection is a valid "remove all".
null and [] for an uploader are not the same
Merge them and either the last image cannot be removed (the empty list is not sent), or a
form without an uploader deletes all attachments on save (the missing list is sent as
empty). The Api hook tells them apart with array_key_exists().
Category parameter targeting#
A category does not save its targeting through the graph. The form sends flat rows in
meta['parameterRels'] (parameter, group, required, searchable, order; options have the group
NULL) and CategoryParameterRelSyncTrait rewrites them in afterSave() (delete and insert again):
| Category state | Save | meta['setParametersToChildren'] |
|---|---|---|
| custom targeting | rows are rewritten from meta['parameterRels'] |
the flag and the rows are copied to subcategories |
| "use default parameters" | stored rows stay; after unchecking the flag the category returns to its own setup | only the flag is copied; the subcategories keep their rows |
A missing key leaves the targeting alone, an empty list deletes it
When meta['parameterRels'] is missing from a save (external Api, import, custom code), the
stored targeting stays unchanged. An empty list that is sent deletes every row, so send it
only when you really clear the targeting. setParametersToChildren without the key pushes
the category's stored targeting to its subcategories. The behaviour is pinned by
tests/Unit/Bazaar/Api/CategoryParameterRelsMetaTriStateTest.php.
Denormalised counters#
| Value | What reconciles it |
|---|---|
advert.price |
the /cron/bazaar/advert/recount-prices cron |
category.advert_count |
the /cron/bazaar/category/recount cron |
advert.email, advert.region_id |
writing the advert |
The count recalculation is a set-based UPDATE over the whole tree — a parent counts the active adverts of its entire subtree, not just its direct children.
The count uses the same conditions as the public listing
It counts active = 1 and expiration_date >= today, the same as the category listing
(AdvertListTrait::prepareFilters()). No setting is mixed in: confirmation by the author
is part of active (confirmed_at IS NOT NULL). "Today" comes from PHP in the
application time zone, not from an SQL function, because the database runs in UTC.
Anyone verifying the counts with hand-written SQL uses both conditions.
An expired advert leaves the listing at once, the count only after a recount
The count is the state as of the last recount. It is triggered by the
/cron/{module}/category/recount cron (every 10–15 minutes), an author confirmation, a
hard delete and an account anonymization, not by an ordinary edit of the advert. Between
recounts the category shows more adverts than the listing lists.
Approval and confirmation in the settings#
Two independent conditions for publication, both evaluated at the moment of posting:
| What | Module setting keys |
|---|---|
| confirmation by the author via an email link (positive meaning: "is required") | advert.confirmation.required.logged, advert.confirmation.required.guest |
| approval by an administrator | advert.approval.auto (the same key in module 6, Company profiles) |
A logged-in author is one whose record has an owner. When confirmation is not required, the
record gets confirmed_at at once; when it is, the record gets an activation key (only a
fingerprint in the database) and waits for the link. Flipping a setting leaves records
already posted unchanged. In the administration the activation key is read-only.
The decision is made by applyInitialApproval() and applyInitialConfirmation() in the Api
manager's beforeSave(), only on insert and on every path (website, external Api,
administration). The logic is shared with discussion posts (InitialApprovalTrait,
InitialConfirmationTrait):
| Situation on insert | approved_at |
confirmed_at |
E-mail after the commit |
|---|---|---|---|
advert.approval.auto on |
now | — | — |
advert.approval.auto off or missing |
NULL, waits for an administrator |
— | — |
| confirmation not required | — | now | insert notice |
| required | — | NULL, a key is issued |
with the confirmation link |
| required, but an administrator ticked Confirmed | — | stays filled | none |
| required, but the record has no e-mail (external Api, import) | — | now | none |
The e-mail is chosen from the state at insert time ($meta), not from the setting read after
the commit. With $meta['suppressNotifications'] (admin edit, import, cron) none is sent. Confirming with the key is then handled by confirmByKey(), see
A link with a key.
The "Approved" and "Deactivated" switches are overwritten when creating in the administration
applyInitialApproval() sets deactivated = 0 and approved_at from the setting even for
a record an administrator creates. To approve a new advert right away, edit it after
creating it.
A record without confirmation must get confirmed_at at once
Visibility rests only on confirmed_at IS NOT NULL (it is also in the active formula). A
new write that bypasses this branch creates an advert that never shows.
Address and coordinates on save#
City, postcode and country are split from the selected municipality by the server
(AdvertRegionAddressTrait, shared by Bazaar and Comcat). The suggest JavaScript fills the
fields only as a preview. Coordinates are computed by GeocodingManager from the parameters
after the split; precision follows the module setting geocoding.precise_address (off by
default in Bazaar, on in Comcat). When no coordinates result, a row is written to
log/geocoding-miss.log after the commit.
| Address | Where the coordinates come from | When nothing is found |
|---|---|---|
| imprecise, or precise without a street | the municipality centre: the selected one, otherwise an exact municipality or postcode match in the region dataset; Google never | GeocodingMiss |
| precise with a street | cms_system_geocoding_cache, on a miss Google and a cache write |
the municipality centre, otherwise GeocodingMiss |
The same GeocodingManager also serves the front-end "within X km" filter (Api action
:Api:System:Region:geocode, resolveSearchText()). Regions are read with raw SQL,
so the computation inside beforeSave() does not touch the identity map of the advert graph
being saved.
Only an advert with a selected municipality (a leaf of the region tree) changes. An old advert placed on a country or county stays as it is; its city is free text.
reattachRelReferences() stays last
applyRegionAddress() → applyCoordinates() → reattachRelReferences(). Both first steps
call findAll() on parameters and categories. A plain read does not clear the identity map,
but were it emptied meanwhile by UnitOfWorkReset::abort() or a foreign operation boundary,
the relation references would point to detached entities and the flush would fail with
A new entity was found through the relationship 'AdvertParameterRel#parameter'.
reattachRelReferences() therefore runs defensively after all loading steps; new loading
belongs before it.
See Identity map.
Deleting an advert#
| Way | What happens |
|---|---|
soft delete (deleted = 1) |
only a column write; the advert keeps its data, images and index entry and can be restored |
hard delete (delete()) |
AdvertHardDeleteTrait through the DeleteTrait hooks; what and in which order is decided by AdvertHardDeletePlan |
The foreign keys to the advert are CASCADE, but the cascade does not cover the watchdog queue
in Bazaar (NO ACTION), the private reply conversations and the rating thread (the link leads
from the advert to the thread, so the thread would be orphaned), the rows and files of images
and attachments, or the ES index and the category counts. That is why the delete has explicit
steps: beforeDelete/afterDelete in the transaction, files and index after the commit in
completedAdvertHardDelete().
The manager calls completedAdvertHardDelete() by hand
Both AdvertManagers have their own completedDelete() (ES), which would silently
override the trait method. A manager that takes the trait must call
completedAdvertHardDelete() from its completedDelete().
Accounting data is not deleted with the advert. Documents (cms_mod_invoicer_*) and
payments (cms_system_payments, cms_system_payment_items, cms_system_payment_transactions)
have no foreign key to the advert; the only link is the module's cms_mod_*_payment_items
(a tariff activation), which is not an accounting document and is deleted by the cascade.
The document and the payment stay complete; the item snapshot carries the name and price.
Before the first write, AdvertHardDeletePlan::uncoveredForeignKeys() walks the foreign-key
graph from information_schema, starting at the advert table and following the cascades:
| Relation rule | Result |
|---|---|
CASCADE |
the child is deleted and its own children are checked; a cascade into an accounting table is an error |
SET NULL |
the child survives with an empty link, fine |
NO ACTION / RESTRICT |
fine only for columns the plan deletes explicitly beforehand (explicitlyClearedColumns()) |
An uncovered relation rejects the delete with ADVERT_DELETE_BLOCKED (624) and a list of the
relations in the delete.advert log; nothing is deleted.
Procedure: adding a table with a foreign key to the advert (or to one of its children)
- Give the relation
ON DELETE CASCADEwhen the rows make no sense without the advert, otherwiseSET NULL. - If the relation needs
NO ACTION(the rows are to be deleted by their own logic), add a step toAdvertHardDeletePlanand the column toexplicitlyClearedColumns(). - Add the relation to the schema snapshot in
tests/Unit/Api/AdvertHardDeletePlanTest.phpand run it. - In the administration, hard-delete a test advert in both modules (Bazaar and Comcat).
A relation without a plan step blocks deleting every advert
A new table with NO ACTION to the advert does not fail on a database exception in the
middle of the cascade; instead the hard delete in the grid starts returning an error for
every advert of the module until the plan covers the relation.
A cascade into an accounting table is refused, not executed
If a new relation led a cascade from the advert into a table with a prefix from
ACCOUNTING_TABLE_PREFIXES, the delete ends with ADVERT_DELETE_BLOCKED. An accounting
row belongs to the document, not to the advert — change the relation to SET NULL or
drop it.
Saved-search alerts#
A stored search that sends an email when a matching advert appears.
It only fires when an advert is CREATED
Not on an edit and not on a move to another category. An advert that enters the category later never reaches the subscribers — and nobody misses anything until the advertiser asks.
On insert, prepareEmails() always renders and stores the alert e-mail (WatchdogEmail with
texts in all languages). Who delivers it is decided by the setting watchdog.email.mode
(group 5):
| Mode | Alert queue | Who sends |
|---|---|---|
| 0 | nothing | nobody |
| 1 | rows in cms_mod_bazaar_watchdog_email_rel |
cron /cron/bazaar/watchdog-email/send (every 10 min), one e-mail per advert |
| 2 | rows in the queue | cron /cron/bazaar/watchdog-email/digest (daily 23:00), one digest per recipient |
| 3 | rows are created but nobody reads them | dispatchImmediateFanout() after the commit, straight into the system e-mail queue |
An advert reaches a watchdog when everything matches at once:
| Condition | Match |
|---|---|
| category | the watchdog's category or any of its subcategories |
| region | the watchdog's region is the advert's region or its ancestor; an advert without a region matches nobody |
type, state (parameter_id, parameter_id2) |
NULL = anything; otherwise the advert has a row of the parent parameter with value = option id |
| watchdog | active = 1, in mode 3 also the same language of the text |
Mode 1 tries to send at most watchdog.email.maxAttempts times (default 5). A row without a
deliverable e-mail (neither the watchdog nor its user has one) is cancelled (cancelled). The
mode 2 digest has a single attempt: a processed row always ends sent or cancelled, so
nobody gets a digest twice.
The digest takes only pending rows of adverts inserted within the last two days
(WatchdogEmailRelRepository::DIGEST_MAX_AGE_DAYS, measured on the advert's insert_datetime).
The first run after a deploy therefore does not send the whole history of pending rows.
A digest that does not run for two days never sends the adverts from that time
Rows of older adverts stay pending and the queue cleanup does not delete them (it never deletes pending rows). A cron outage, or switching to mode 2 with a full queue, means the watchdogs never get those adverts and the queue keeps them.
In mode 3 the database trigger sends while it exists
PHP sending holds back while the database has the trigger
cms_mod_bazaar_watchdog_email_texts_AFTER_INSERT (fanoutTriggerExists()). Once it is
dropped, PHP takes over by itself. Dropping the trigger before the new code is on the
server means the watchdogs receive nothing.
A watchdog option is matched through the parent parameter row
The advert form stores the selected option as the value of the parent parameter
(parameter_id = parameter, value = option id). An import or a custom write that stores
the option differently silently misses watchdogs with a type or state.
The alert queue (cms_mod_bazaar_watchdog_email_rel) grows with every advert a watchdog
catches. It is cleaned by /cron/bazaar/watchdog-email/purge-old (daily): it deletes sent and
cancelled rows older than N days, then the e-mails and their texts no row points to any more.
It never deletes pending rows and runs in every sending mode, even when sending is turned off.
| Retention | Source |
|---|---|
?days=N in the address |
takes precedence |
setting watchdog.email.retentionDays (group 5) |
otherwise |
90 days (WatchdogSendManager::DEFAULT_RETENTION_DAYS) |
default |
The queue in the Company catalogue works the same way (/cron/comcat/watchdog-email/purge-old).
Replies to an advert: receiver and read state#
A response distinguishes the sender (owner) from the receiver (receiver). The
receiver is advert.insertUserId, not a column of the response, so Response does not
implement ReceiverAwareInterface and ResponseReceiverTrait decides by query: privileged
role ∨ the advert's owner ∨ the advert password ∨ a valid read_key (internal only). The
response detail (Response:get) used to be opened only by an administrator, so the owner did
not see their own response.
| Thing | Detail |
|---|---|
| the response detail | Front ResponsePresenter::actionDetail(): only the advert's owner (otherwise 403); read is set only for a request from the site itself (isSameSite()) |
| the button in the email | {$markReadLink} in the templates BZ_ADVERT_RESPONSE, BZ_ADVERT_SUGGEST_PRICE; see A link with a key |
| the public page | Front:Bazaar:Response:markRead, page id MARK_READ_SLUG = 2011; the response list LIST_SLUG = 158, the detail DETAIL_SLUG = 159 |
| the private conversation | openThreadForResponse() on INSERT creates a sender × owner thread; if one is a guest the thread is "email only" |
| a response crash with a price | a currency without translations was loaded lazily on an entity detached by an emptied identity map; sendEmail() now takes find($id, ['translations']) |
In detail about the roles in Conditional roles.
The administration deletes a response softly (deleted = 1): the response grid through
setDeleteMethod('softDelete') (ResponseManager::softDelete()) and the Replies tab of the
advert edit (signal toggleDeleted!). The front end does not list responses with deleted = 1.
A hard delete would remove the response irreversibly together with the link to the private
conversation (message_thread_id) it created.
The category badge on a card did not lead to adverts
The category badge on an advert card (AdvertRenderer Style_1–3) used to lead to
:Front:Blog:Article:list … category: (/clanky/o-1?category=109). It now leads to the
listing of the category's adverts. For the placeholder segment - for the category see
Navigation by level.
The admin Bazaar Advert did not redeclare the collection followers
include(['followers']) fell on TypeError; the collection is in the admin entity as
#[Transient].
Saving a response: gates in beforeSave()#
A response is written by the front form, the administration and the external
:Api:*:Response:save. The external path never meets the form; the payload goes through the
hydrator straight into the entity. The only place every write passes through is
ResponseManager::beforeSave() — in Adverts and in the Company catalogue, both
managers share the same traits. The gates run in this order:
| # | Gate | Rejects (VALIDATION_FAILED, errorCode) |
Adjusts |
|---|---|---|---|
| 1 | guardResponseColumnLimits() |
price is not a number (invalidPrice), is negative or out of the column range (priceOutOfRange); phone, e-mail or subject longer than the column (phoneTooLong, emailTooLong, subjectTooLong) |
an empty price to NULL, a filled one to the dot form rounded to the column scale |
| 2 | guardResponseCurrency() |
price without currency (currencyRequired); a currency that is not a public monetary one, such as credits (currencyNotAllowed) |
a zero price to NULL; without a price it drops the currency |
| 3 | on INSERT | — | fills a random read_key for the link with a key |
Column limits are not copied; the gate reads them by reflection from #[ORM\Column] of the
entity that is actually saved (ColumnLimits). After a schema change they move by themselves.
An empty price and phone pass: they are optional and requiredness is the form's job.
Without price normalization the DB would get something other than what the gate measured
"12,5" passes the gate as 12.5, but the raw string would fail the save with a generic
database error in a strict sql_mode and be stored as 12.000 in a non-strict one.
That is why the gate overwrites the price after the check. Without the gate an overflow
would fail the save in a strict sql_mode; in a non-strict one it would be stored silently
(an offer for zero, a truncated e-mail the reply never reaches) and the advertiser would
see it in the e-mail.
Check the currency only after the column limits
Only the first gate turns an empty price into NULL. In the opposite order the currency
gate could not tell whether the price is filled.
On UPDATE the currency gate applies only when the price and currency pair changes. An
administrator can change the currency classification after a response was inserted
(internal/monetary), and editing the text, read or deleted of an old response must not
fail on today's classification. The stored state is read by findPersistedValues()
(PersistedValuesTrait) with a direct DBAL query: on the save path the entity is already
mutated by hydration and find() would return the same identity-map instance with the new
values, so the gate would never see a change.
Read state of a response and its thread#
The read flag on a response and the unread messages in the private conversation the
response started are kept in sync (ResponseThreadReadSyncTrait):
| Direction | When | What happens |
|---|---|---|
| response → thread | a successful updateColumns() with read = 1 |
the thread is marked read for the advert owner, and only when its only unread message is the first one from the response |
| thread → response | the advert owner reads the thread | the linked response gets read = 1 (directly through the service, so the thread is not marked again) |
Marking is always for the advert owner only, never for whoever triggered the action, so the holder of the key from the e-mail gains nothing extra. For an anonymous advert (owner without an account) the thread is left alone.
A read state written other than through updateColumns() does not mark the thread
The sync hangs on the manager's updateColumns(). read saved through save() or raw SQL
leaves the thread unread and the owner sees a "new" message in the mailbox that they have
already read.
Topping, highlighting and expiry#
Paid visibility has four independent tariffs. Their ids are not in code but in
config/Shared/bazaar.neon (override them in config/Shared/local.neon):
| Tariff | Parameter (default id) | What it does in a listing | How it is recognised |
|---|---|---|---|
| topping | advert.bazaarToppingTariffId, comcatToppingTariffId (1) |
the advert moves up in the listing (toped_until > NOW() first) |
the toped_until column, filled by the payment via AdvertToppingConfig |
| highlighting | advert.bazaarHighlightTariffId, comcatHighlightTariffId, discussionHighlightTariffId (2) |
the card gets the highlighted class (tinted by the --list-item-highlighted-bg token) |
an active, valid payment item of that tariff, one query per listing render (AdvertHighlightConfig) |
| Topbox | the template passes the id ([3]) |
the advert shows in the "TOP" side widget (TopAdvertList, {control frontBazaarTopAdvertList [3], 'Style_2', 10} in aside-left.latte of all themes) |
an active, valid payment item of that tariff |
| show links | advert.bazaarLinksTariffId, comcatLinksTariffId, discussionLinksTariffId (4) |
on the detail, addresses in the description are clickable links (TextLinkifier, filter \|linkify, rel="ugc nofollow") |
an active, valid payment item of that tariff (AdvertLinksConfig); the detail presenter calls Advert::setRenderUrlsAllowed(true) |
Links come back only with the "on render" filter
Without the tariff an address in a parameter with the link filter is replaced by [URL]. The
tariff switches off only the OnRender replacement; with OnSave the database already holds
[URL] and it cannot be restored.
Who recalculates toped_until depends on the path by which the payment item changed
(PaymentItemManager in the Api):
| Path | toped_until recalculation |
|---|---|
activation by a gateway payment (updateStatusForPayment()) |
monotonic: only extends to the furthest validity of the active items |
| saving an item outside the administration | monotonic, only for an active item |
saving in the Payments tab of the advert edit (meta['recalcTopedUntil']) |
absolute: the furthest validity_datetime of the active topping-tariff items, NULL without them; can shorten too |
updateColumns() with active or validityDatetime (switch in the list) |
absolute |
| deleting an item | absolute, inside the delete transaction (afterDelete()) |
| moving an item to another advert | the original advert is recalculated absolutely too |
Discussion works the same way, only over messages (recalcTopedUntilForMessages()).
Writing an item outside these paths does not align topping
Raw SQL or a save without meta['recalcTopedUntil'] leaves toped_until unchanged. A
deactivated or shortened item then keeps the advert on top until the old validity.
Deleting a middle item shortens topping only approximately
Payment activation stacks the validity of a new item on the furthest validity of the
existing active items of the same tariff (GREATEST(MAX(validity_datetime), NOW()) plus the
tariff duration). The absolute recalculation then takes only the maximum of the stored
validity_datetime values and does not recompute the later items. Deleting a middle item
therefore keeps the advert on top longer by the deleted item's duration; only deleting the
last one is exact. This applies to adverts, the company catalogue and the discussion alike.
The tariff id must match the data
When the "Highlighting" tariff gets an id other than the default 2 on a site and the configuration is not overridden, a paid highlight does not show in the listing — and no error appears.
The expiration date is computed by AdvertExpirationTrait:
| Situation | New expiration |
|---|---|
| advert insert | today + the longest expirationInterval of the advert's categories |
extension (Advert:extendExpiration) |
the current expiration if it is in the future, otherwise today, + the longest category interval |
| no category has an interval (all 0) | the setting 5/advert.expiration.days instead of the interval |
payment activation (updateExpirationDateForPayment()) |
moves to the furthest validity_datetime of the paid items of the payment, forward only |
A zero interval everywhere means the advert cannot be inserted
When all categories have interval 0 and advert.expiration.days is 0 or missing, no
expiration is computed. Both an insert and an extension then end with
INVALID_CONFIGURATION (errorCode: noExpirationInterval on insert) before anything is
written; the system does not make up a fallback length. Set an interval on at least one
category, or the default advert.expiration.days.
| Cron | What it does | Interval |
|---|---|---|
/cron/bazaar/advert/notice-expiration |
warns N days before expiry (5/advert.expiration.noticeDays), sets a flag so it does not repeat |
daily |
/cron/bazaar/advert/delete-expired |
silently marks deleted = 1 M days after expiry (5/advert.expiration.deleteDays) |
daily |
"Price up to" steps in search#
The "Price up to" options in the search box are built by
App\Core\Config\AdvertPriceStepsConfig from the website currency (the currency of the
localisation region). The steps live in config/Shared/bazaar.neon under
advert.bazaarPriceSteps as currency key => steps:
bazaarPriceSteps:
CZK: [1000, 5000, 10000, 50000, 100000, 500000, 1000000]
EUR: [50, 200, 500, 1000, 5000, 20000, 50000]
| Website currency | Steps it gets |
|---|---|
| in the map | its own steps |
| not in the map | the steps of the first currency in the map, converted by the manual rate (ApproximatePrice) and rounded to 1 / 2 / 5 × 10ⁿ |
| conversion impossible (rate 0, internal or unknown currency) | the first currency's steps with its own label — the label never claims a currency the numbers are not in |
The price filter does not convert
A step goes into ?priceTo= as a plain number and AdvertListTrait compares it with the
stored advert price regardless of its currency. On a site where adverts are priced in a
currency other than the website's, the filter silently selects by the wrong amounts.
An advert without a price disappears once a bound is chosen
preparePriceFilter() adds price <= priceTo over the denormalised price column.
Adverts with price IS NULL (price by agreement) drop out of the list. That is
intentional: an offer without a price cannot meet a ceiling. Without priceTo in the URL
nothing is added.
Front-end components#
Advert lists on the main page#
Three lists each select adverts differently, but the same AdvertListRenderer draws them, so
the card styles are shared.
| Component | What it selects | Call in a template |
|---|---|---|
LastAdvertList |
newest by insert date | {control frontBazaarLastAdvertList:box, 6}, :row, :table; third argument = option id of the type parameter |
TopAdvertList |
topped by paid tariffs | {control frontBazaarTopAdvertList [3], 'Style_2', 10} (tariff ids, style, count) |
AdvertTabList |
tabs last, visited (viewsDetail), hot, switched by the tab! signal |
{control frontBazaarAdvertTabList:box, 8} |
Visibility is up to the presenter: when it has prepareFilters(), the list uses its filters
(category, region, confirmation). Otherwise a fixed set applies: not deleted, paid, not sold,
not banned, not deactivated, active = 1 and not expired.
The advert type id is data
The LastAdvertList type filter takes the id of an option (a child of the parameter
with the key type), not the parameter id. Options are rows in the parameter table and
differ per installation. An id copied from another installation selects another type or
nothing. Look it up in the administration (Parameters → type). When the type
parameter is missing, the filter is silently left out.
The hot tab counts replies from the last 30 days (HOT_WINDOW_DAYS) in PHP, takes three times
the limit from the adverts with the most replies and only then applies visibility. More
candidates are taken because a deleted or sold advert with many replies would otherwise take
a slot.
The hot tab can come out shorter than the limit
When fewer adverts than the limit pass visibility out of the three-fold candidates, the tab shows fewer. There is no refill from further candidates.
Search box#
SearchBox has three styles: Mini (full text and a button, module headers), Medium (full
text, category, region) and Large (type switch, category, region, price; fulltext: true
adds the text field). Style and StyleInline are aliases of Mini; template headers call StyleInline, so do not remove it without changing them.
A preselected select value must be in the options
Nette SelectBox::setDefaultValue() throws InvalidArgumentException for a value outside
the options. The box sits in the header of the lists, so a URL value the options do not
know would take /inzerce and every list down with a 500. Category and "Price up to"
therefore go through safeDefault(): an unknown value is dropped and the select stays on
the prompt. Route a new select filled from the URL the same way.
Category and region names in the options are localised data, so the box wraps them in
NotTranslate (see Translations).
The front-end advert form#
AdvertForm is built on the shared ParameterForm.
The pre-fill values come from getEntityValues() in LoadingTrait:
| Situation | Where the values come from |
|---|---|
| edit | the advert's parameterRels rows (multi-value as an array, range as value/value2) |
| new advert, signed-in user | account data by parameter key: email, phone, street, city, zip and postcode, country, regionId, firstname, lastname |
| new advert, guest | nothing |
A key is mapped only when the form really has a parameter with that key.
A custom getEntityValues() in AdvertForm turns the pre-fill off
A class method wins over a trait method. Whoever adds their own getEntityValues() to
AdvertForm overrides LoadingTrait, and a new advert stops being filled from the
account, with no error.
Dynamic parameters go to the Api as the parameterRels collection (applyParameterRels()), but
only when the form submitted some rows. An empty array would make orphanRemoval on the
Api side delete all parameters of the advert, so an empty submit leaves the collection alone.
The seller card on the detail page#
The owner's other adverts and their count are looked up by prepareOtherAdverts() by
insertUserId, in one query (active, not expired). The count is all of them, the list the
same without the advert on display. An advert without an owner account has nothing to search
by: the list is empty and the count card is not shown.
Searching by e-mail finds nothing
The Api masks the e-mail on output (PersonalDataPolicy, see
RBAC). A filter by the masked value
matches no row and the list would always be empty.
Showing the phone number#
The showPhone! signal (#[Requires(sameOrigin: true, ajax: true)]) has two branches:
| Advert | Where the number comes from | Limit |
|---|---|---|
| with an account | UserManager::revealPhone() → guard :Api:System:User:showPhone |
ratelimit.showPhone (20 per hour per IP) on the Api layer |
| without an account | $advert->get('phone') |
FrontFormLimiter::isAdvertPhoneRevealAllowed(), same values under its own key |
An empty answer (denied, limit, no number) does not switch the template, and the masked value stays.
Do not print the phone by just switching a template variable
The number is already in the advert data at render time. A signal that only sets
showPhone would bypass the guard and the limit, and the numbers could be harvested by
walking through the adverts.
Notifications#
Followers are notified ONLY when the price tag changes
The triple price, price type and currency is compared. A change of title, description or photos notifies nobody — deliberately, otherwise fixing a typo would send out hundreds of emails.
Editing in the administration sends no emails at all
Not the activation one, not to the watchdogs, not to the followers. That is intentional: an administrator is fixing someone else's advert and should not trigger communication in their name.
Blacklisted words#
Classifieds, the company catalogue and Discussion read one list for
the whole website: the system table cms_system_blacklisted_words (System → Blacklisted words)
through the Api System\BlacklistedWordManager (findActiveValues(?languageId) = active words of
the language, lowercase; null = every language). The module copies cms_mod_*_blacklisted_words
and their entities and managers were removed.
| Where | What happens |
|---|---|
| the advert form and detail | a parameter with throw_error_on_blacklisted_word refuses the word; on display the words are masked (ContentFilterMode::OnRender) |
| the Api advert save | AdvertParameterPolicyTrait, see Parameter rules on the Api |
the reply thread (ResponseManager) |
a text with a blacklisted word opens no mailbox thread; the response is still saved and the e-mail still goes out |
the ES index (AdvertDocumentSource) |
texts are read the same way as on display (setRenderBlacklistedWords() with the words of the target language), so masked words do not leak into the index |
Reporting an advert#
AdvertReport works like reporting a post in Discussion: the
advert's reported flag is kept by the shared ReportedFlagSyncTrait in AdvertReportManager
(also after a bulk deleteAll()), and the administration AdvertReportPresenter uses the shared
ReportModerationPresenterTrait (unsolved by default, Solve, Reopen). Saving an advert
recalculates the flag from the report table, so neither the form nor a stale entity overwrites it.
Elasticsearch#
Classifieds is one of the Elasticsearch sources — the reindex
is /cron/bazaar/elastic/reindex, the incremental sync /cron/bazaar/elastic/sync.
Bulk operations outside the hooks are invisible to the index
A set-based UPDATE (recounts, imports, manual SQL) does not fire the hook, so it does
not propagate into the index. Consistency is only restored by the nightly reindex —
until then search returns stale data and nothing shows it.
Where to look#
| I want | Where |
|---|---|
| the advert logic and the denormalisation | app/UI/Api/Bazaar/Models/Managers/AdvertManager.php |
| approval, confirmation, address and delete shared with Comcat | app/Core/Traits/Api/Models/Managers/Advert*Trait.php, Initial{Approval,Confirmation}Trait.php |
| the count recalculation in categories | app/UI/Api/Bazaar/Models/Managers/CategoryManager.php → recountAdverts() |
| the hard-delete plan and the foreign-key check | app/Core/Utils/ValueObjects/AdvertHardDeletePlan.php |
| response save gates and read-state sync | app/Core/Traits/Api/Models/Managers/Response{ColumnGuard,CurrencyGuard,ThreadReadSync}Trait.php, app/UI/Api/Bazaar/Models/Managers/ResponseManager.php |
| the saved-search alerts | app/UI/Api/Bazaar/Models/Managers/Watchdog*Manager.php, matching queries in Repositories/Db/WatchdogEmailRelRepository.php |
| writing an advert through the Api and the expiration calculation | AdvertManager.php ($graphDataKeys), Traits/AdvertManager/{CategoryRelsPaidSanitization,AdvertExpiration}Trait.php |
| the entity definition and its flags | app/Core/Base/Shared/Bazaar/Models/Entities/Advert.php |
| the cron tasks | app/UI/Cron/Bazaar/Presenters/ |
| topping and highlighting tariffs, "price up to" steps | config/Shared/bazaar.neon, app/Core/Config/Advert{Topping,Highlight,PriceSteps}Config.php |
| the front-end advert form | app/UI/Front/Bazaar/Components/AdvertFormComponent/ |
| the main-page lists, the search box | app/UI/Front/Bazaar/Components/{LastAdvertList,TopAdvertList,AdvertTabList,SearchBox}Component/ |
| the seller card and showing the phone | app/UI/Front/Bazaar/Presenters/Traits/Details/AdvertDetailTrait.php, Presenters/AdvertPresenter.php (handleShowPhone()) |
| the advert and category forms in the administration | app/UI/Admin/Bazaar/Presenters/Traits/Forms/Advert*Trait.php, CategoryFormTrait.php |
| category parameter targeting | app/UI/Api/Bazaar/Models/Managers/Traits/CategoryManager/CategoryParameterRelSyncTrait.php |
| topping recalculation | app/UI/Api/Bazaar/Models/Managers/PaymentItemManager.php |
Follow-up chapters: Company catalogue · Elasticsearch · Filters · Scheduled tasks