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

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

Cookie settings