Company catalogue#
The Comcat module was created as a copy of Classifieds, so it shares
almost the whole model: a company profile is an Advert with dynamic parameters, the
category is a nested set, listing prices live on the category. The note about its
creation is in docs/comcat-copy-from-bazaar.md.
Read Classifieds first
Everything said there about dynamic parameters, denormalised counters, display rules and the irreversible propagation into a subtree applies here too. This chapter describes only the differences.
Differences from Classifieds#
| Classifieds | Company catalogue | |
|---|---|---|
denormalised price |
yes | no |
the sold flag |
yes | no |
followers (AdvertFollower) |
yes | no |
| saved-search alerts | yes | yes |
| settings index | 5 |
6 |
The missing price is deliberate, not an omission
A company is not for sale. The prices are for the listing in a category, and those
live on the category (CategoryPrice), not on the profile.
The difference propagates into the generated active column too — it has one condition
fewer:
-- cms_mod_comcat_adverts.active (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
THEN 1 ELSE 0 END
An SQL query copied over from classifieds will hit sold here
The column does not exist. Anyone carrying a finished query across gets an error —
which is still the better outcome; the worse one is carried-over PHP that reaches for
sold through a getter and receives null. A carried-over filter
EqualFilter('sold', false) passes the build and PHPStan and fails only at runtime with
QueryException ("has no field or association named sold"), taking down the whole page
that holds the list. The default visibility set of LastAdvertList therefore has no sold.
Shared with Classifieds#
These parts have no copy of their own in the catalogue, the code is shared (traits in
app/Core/Traits/):
| Part | Shared code | Description |
|---|---|---|
| blacklisted words | the Api System\BlacklistedWordManager, the system table cms_system_blacklisted_words |
the module table cms_mod_comcat_blacklisted_words no longer exists; Classifieds |
| parameter rules on an Api write | AdvertParameterPolicyTrait, ParameterRuleEvaluator, AdvertParameterRuleContextRepositoryTrait |
Classifieds |
| reporting a profile | ReportedFlagSyncTrait (Api), ReportModerationPresenterTrait (administration) |
Classifieds |
| guest profile management | ManageKeyTrait over ManageKeyAwareInterface |
Link with a key |
A profile has no fixed fields#
The name, company number, address, phone, description and opening hours are all parameters, exactly as on an advert.
Without parameters the form is empty
Before the first company can be created, parameters have to exist and be targeted at the category. The form throws no error — it simply has nothing to render, so it looks like an unfinished template.
Targeting parameters at a category, copying them to subcategories
(meta['setParametersToChildren']) and copying prices (meta['setPricesToChildren']) work
like targeting in Classifieds: a missing
meta['parameterRels'] leaves the targeting unchanged, an empty list deletes it. The company
catalogue has its own copy of the traits in
app/UI/Api/Comcat/Models/Managers/Traits/CategoryManager/ and
tests/Unit/Comcat/Api/CategoryParameterRelsMetaTriStateTest.php pins the behaviour.
Opening hours#
A field type of its own: a hidden field holding a structure that JavaScript fills in.
The opening hours can be lost silently
When the field is not rendered on save, the parameter collection is replaced without it — the same mechanism as with any other collection (Hydrators). Nothing reports it, which is why the template carries an explicit comment about it.
Profile form on the website#
The front-end company profile form works like
the front-end advert form: pre-filling from the account by
the parameter key through getEntityValues() from LoadingTrait, parameterRels only with at
least one filled row, categories in meta['categoryIds']. Unlike adverts it also pre-fills a
parameter with the key contactName (first and last name in one field).
An empty parameter field is skipped. When at least one filled row remains, the collection is
replaced as a whole and orphanRemoval deletes the values of emptied fields. Emptying all
parameters at once from the website is not possible: a submit without a single filled parameter
does not send the collection and the stored values stay.
Categories and prices#
A nested set, a price matrix of region × payment gateway, propagation into a subtree.
Ticking "free" only HIDES the matrix, the prices are still saved
The fields are hidden by styling, not disabled. Unticking "free" reveals prices the administrator never knowingly entered.
Membership × a paid listing#
Access to a profile's detail in a paid category can be conditioned on the visitor's membership — which is something entirely different from the company's paid listing.
| Payment | Who pays | What it unlocks |
|---|---|---|
| the listing | the company | that the profile is in the catalogue at all |
| membership | the visitor | that they can open the profile's detail |
Two different payments, two different payers
Confusing them is the most common misunderstanding in this module. "The company paid and it still is not visible" means the visitor's membership is missing — not that the company's payment failed.
Sorting by rating#
The listing's sortbox (Advert/list.latte) offers three options and prepareOrderBy() has its
own branch for each:
/o-N |
Option | Order |
|---|---|---|
| 1 | Newest | insertDatetime ↓ (also the default branch) |
| 2 | Most read | viewsDetail ↓ |
| 3 | Best rated | ratingThread.ratingAvg ↓ |
Topped profiles (topedUntil > NOW() ↓) always come before the option and id follows as a
unique second key (OrderByHelper::withIdTiebreak()); without it equal values would paginate
randomly.
An offered option without its own branch silently sorts as “Newest”
An option the sortbox offers but the switch in prepareOrderBy() does not know falls into
default. The user picks a sorting and nothing changes. Add a new option to both places at once.
The company listing offers the "Best rated" sorting (/o-3, ratingThread.ratingAvg
descending, AdvertListTrait::prepareOrderBy()); Classifieds have nothing like it. From the
scalar ratingThreadId Doctrine can only build a WHERE; sorting by a column of a foreign
table needs an association. The Api entity Advert therefore maps the rating-thread column a
second time, as the association $ratingThread, which carries #[ExternalReadonly] just like
the scalar (Column write attributes). A profile without a
rating does not drop out of the listing, because the association is joined with a LEFT JOIN.
An association without #[ExternalReadonly] lets the rating be re-attached
An external client would send data.ratingThread = {"id": X}, bypass the lock on the
scalar and attach the profile to a foreign rating thread, that is to someone else's
average and reviews.
Replies: receiver and read state#
Replies to a company profile work as in Classifieds:
the template CC_ADVERT_RESPONSE carries the {$markReadLink} button, the public page
Front:Comcat:Response:markRead has the id MARK_READ_SLUG = 2012, the list LIST_SLUG = 208
and the detail DETAIL_SLUG = 209 (before 159).
Settings#
The module has index 6 in the settings table (classifieds has 5). The form
(app/UI/Admin/Comcat/Presenters/SettingPresenter.php) writes these keys:
| Key | Field | Who reads it |
|---|---|---|
advert.perPage, maxImageSize, advert.expiration.days |
number 1–100, 1–100 (MB), 1–3650 | the list; the uploader limit when the image parameter has none; the expiry calculation when the category does not set it |
advert.approval.auto |
switch | approval by the administrator on insert |
advert.confirmation.required.logged, advert.confirmation.required.guest |
switch | confirmation by the author via the email link, only on insert; explained in Classifieds |
geocoding.precise_address |
switch | AdvertRegionAddressTrait, see Classifieds |
advert.statistics.enabled |
switch | Api AdvertManager; off = the daily statistics for the chart in the edit form are not written, the view counters keep running |
advert.expiration.noticeDays, advert.expiration.deleteDays |
0–365, 0–3650 (0 = off) | crons notice-expiration, delete-expired |
watchdog.perPage |
1–100 | the front watchdog list |
watchdog.email.mode |
0 off, 1 immediately by the send cron, 2 daily digest by the digest cron, 3 immediately on insert without a cron |
WatchdogManager, cron WatchdogEmailPresenter |
watchdog.email.maxAttempts, watchdog.email.retentionDays |
1–20, 1–3650 | sending and cleaning the watchdog queue |
The Api reads group 6 hard-coded (getSettingContext()->get(6, …)). The constant MODULE = 6
in the presenter therefore has to match, otherwise the form saves into a group nobody reads and
setting changes silently have no effect.
Keys the code does not read were removed from the form — do not bring them back
Image dimensions, the resize type, watermarkFilename, responsesPerPage and
afterAddAdvertRedirectOn are not in the catalogue's settings, the catalogue does not read them: the variants are read from the DB (usage comcat-adverts, see
Image variants) and the watermark is the global
watermarkPath. The variant assignment is edited in the Obrázky part below the
settings form. If somebody put the old keys back into the form, the first save would
recreate them — saveSettingForm() writes every listed key.
Scheduled tasks#
| Endpoint | What it does | Recommended interval |
|---|---|---|
/cron/comcat/category/recount |
recounts companies per category | every 10–15 min |
/cron/comcat/advert/notice-expiration |
warns before a listing expires | daily |
/cron/comcat/advert/delete-expired |
marks deleted = 1 after expiry |
daily |
/cron/comcat/watchdog-email/send |
sends the saved-search alerts | every 5–10 min |
/cron/comcat/watchdog-email/digest |
the summary digest | daily in the evening |
/cron/comcat/watchdog-email/purge-old |
deletes sent and cancelled watchdog queue rows older than watchdog.email.retentionDays (90 days by default), never pending ones; details in Classifieds |
daily |
/cron/comcat/elastic/sync |
incremental index synchronisation | every 10 min |
/cron/comcat/elastic/reindex |
a full reindex | nightly |
/cron/comcat/sitemap/{category,advert} |
sitemaps | nightly |
An expired profile leaves the listing and the detail at once, regardless of the crons:
active knows nothing about expiry, but both the listing and the detail add the condition
expiration_date >= today. The detail then shows it only to the owner, with an "expired"
notice.
Without the expiry crons the company gets no notice and the expired profile is never cleaned up
Without notice-expiration the company gets no e-mail before the listing expires and
has no chance to extend it in time. Without delete-expired the expired profile stays in
the database and in "My companies" forever. The same happens when
advert.expiration.noticeDays or deleteDays is 0.
Where to look#
| I want | Where |
|---|---|
| the profile logic | app/UI/Api/Comcat/Models/Managers/AdvertManager.php |
| the count recalculation | app/UI/Api/Comcat/Models/Managers/CategoryManager.php |
| sorting and filters of the company listing | app/UI/Front/Comcat/Presenters/Traits/Lists/AdvertListTrait.php |
| the module settings | app/UI/Admin/Comcat/Presenters/SettingPresenter.php |
| the profile form on the website | app/UI/Front/Comcat/Components/AdvertFormComponent/ |
| the cron tasks | app/UI/Cron/Comcat/Presenters/ |
| the note about the module's creation | docs/comcat-copy-from-bazaar.md |
Follow-up chapters: Classifieds · Elasticsearch · Scheduled tasks