Skip to content
V
For developers
Architecture, conventions, the core and security
Modules / Classifieds

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#

  1. Create the parameter and attach it to a category (CategoryParameterRel).
  2. The select options are the parameter's children — the parameter is the root of a tree, its descendants are the individual options.
  3. Decide whether it needs a denormalised column. Only what is sorted by or range-filtered needs one.
  4. Display rules (ParameterRule) decide when the field appears — a condition over another parameter.
  5. 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

  1. On insert, send the categories in categoryIds (ids are enough) or in categoryRels.
  2. Send the parameters in parameterRels in the hydrator shape, including the title and price.
  3. On update, send only the keys you change. Change categories through categoryRels; categoryIds is not used on update.
  4. If you send assets or files, read the advert again with includes after the save, otherwise you will not know the attachment ids.
  5. Check paid of the categories and expiration_date in 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)

  1. Give the relation ON DELETE CASCADE when the rows make no sense without the advert, otherwise SET NULL.
  2. If the relation needs NO ACTION (the rows are to be deleted by their own logic), add a step to AdvertHardDeletePlan and the column to explicitlyClearedColumns().
  3. Add the relation to the schema snapshot in tests/Unit/Api/AdvertHardDeletePlanTest.php and run it.
  4. 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

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#

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.

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

Cookie settings