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

My company#

The Company module holds the site operator's own business: its presentation, price list, references, booking calendar and a record of jobs wired into Invoicing. The design and decisions D1–D19 are in improvements/company-module.md.

Company is not Comcat and shares not a single table with it

The Company directory is a listing of other people's businesses, built on Advert with dynamic parameters. Company has its own fixed entities, its own cms_mod_company_* prefix and borrows nothing from Comcat — only the name gets confused (D2). Carry a Comcat pattern over here and you carry it in vain.

Three domains in one module#

The 25 Api entities fall into three groups that have almost nothing to do with each other:

Domain Entities What it is about
presentation Company, CompanyText, CompanyAssetRel, Block, TeamMember, Highlight, Reference, Category, Service, ServiceGroup (+ *Text) what the site says about the company
appointments Calendar, TimeSlot, Reservation the booking calendar
jobs WorkList, WorkSheet, WorkItem, WorkItemComment work, hours, the invoice

There are three layers — app/UI/{Api,Admin,Front}/Company/ over the shared base app/Core/Base/Shared/Company/.

The module has no Cron layer

app/UI/Cron/Company/ does not exist. No expiry, no counter recalculation, no reindex — the module has neither a tree with counters nor a search source. Booking emails are sent synchronously from the Api action, not from a queue.

Nor a Settings screen

The module has no SettingPresenter and no index in the settings table. Everything configurable sits on the Company entity. Anyone looking for companyPerPage or maxImageSize is looking in vain — the gallery limits are in CompanyPresenter, at the dropzone configuration.

Exactly one presented company#

There can be several companies, but the site is rendered from the one with presented = 1 (D1). Exclusivity is enforced by the Api, not the form:

// App\UI\Api\Company\Models\Managers\CompanyManager::updateColumns()
// → clearOtherPresented(): switching presented on clears the flag on all the others

The front end never picks the company — every public action goes through CompanyManager::findPresented() and companyId is never sent from the browser.

Fail-closed: no presented company behaves exactly like bookings switched off

findPresented() returns null and the front end treats that the same as booking_enabled = false. Nothing errors, the content simply disappears — which during debugging is easily blamed on the template.

Three collections that are not saved by the object graph#

Highlight, Block and TeamMember carry a scalar companyId, not a ManyToOne association. They are therefore not owned collections and the company's save() does not take them along. CompanyFormTrait saves them in three separate SYNC passes (saveHighlights(), saveBlocks(), saveTeamMembers()): find the existing ones → build entities from the form → save → delete the removed ones.

A failed SYNC pass leaves the company saved and still reports an error

saveEditForm() returns false and the admin sees Uložení firmy se nezdařilo — but the company and the earlier passes have been saved. Read it as a transaction and you will fix the wrong thing.

The team has to run on a client-side multiplier

Highlights and blocks are a server-side Multiplier with naja add/remove. The team is not: its row carries an <input type=file> and a snippet redraw would throw the chosen file away. That is why addTeamFields() adds no addCreateButton()/addRemoveButton() and JS clones rows from <template data-multiplier-template>. A new field therefore has to be added in two places — in the PHP and in that template; otherwise a new row will not have it.

The company gallery and the reference gallery both follow the pattern from Discussion: the admin collects uploaderItems[] into $meta['orderedAssets'], the Api afterSave() calls AssetProcessingTrait::processOrderedAssets(), which marks the first item as main and writes its imageId into photo_image_id through the getMainAssetColumn() hook.

Themes then reach for specific indices — Profile:hero takes item 0, aboutMedia items 1 and 2, qualityMedia item 3, Gallery the rest from 4 on.

The gallery order is a public interface of the theme, not just cosmetics

Reordering items in the administration changes what appears in the site's hero section. And the other way round: reaching for galleryAssets[3] in a theme signs a contract with the administrator that they cannot see anywhere. Always comment a new index in the template the way AboutMedia.latte does.

orderedAssets is sent even when empty

If it were omitted, emptying the gallery would never be saved. An empty array means "all rels removed = 1, photoImageId = null".

References: public is the master, the front end filters the rest#

Reference has four flags. The narrow Api action publicList enforces only presented + public — that is the security boundary. inList, featured and inLogos are presentational subsets, filtered in PHP by the front end over a single bridge query (ReferenceManager::findListReferences() and friends).

Flag Who enforces it Component template
public the server (publicList)
inList the front end Grid, Filmstrip
featured the front end Featured
inLogos the front end Logos

inLogos deliberately does not filter on the logo being filled in

The field's hint in the administration promises Vyžaduje vyplněné logo reference (requires the reference logo), but the manager does not check it. That is on purpose: a freshly created partner would silently vanish from the strip with no way to tell why. A template without a logo prints a text mark from the heading. The field's hint therefore does not match the code — adding the filter "fixes" deliberate behaviour.

Reference categories have no consumer yet

Category is a nested set linked many-to-many through cms_mod_company_reference_category_relations, but the front end neither filters by them nor shows them — Front\CategoryManager is an empty CRUD skeleton. The fields preferred, href and the meta ones are inherited from the Discussion pattern and nothing reads them.

Job → work sheet → work item#

Three levels, not two. WorkItem has the FK work_sheet_id, not work_list_id.

Entity States What it does
WorkList (job) open, archived the client's folder; archiving locks, it does not invoice
WorkSheet (work sheet) open, closing, closed, invoiced the invoicing batch; lockedStates() = the last three
WorkItem (work item) new, in_progress, waiting, done, cancelled a row with hours and a price

The name WorkList is historical and misleading

WorkList is not a list of work — it is the job. The sheet is WorkSheet. The whole closing and invoicing lifecycle moved from WorkList to WorkSheet (stage W2, migration sql/2026-08-29_01_company_work_sheets.sql); closing/closed were removed from WorkListState. Keep the old model in your head and you will be hunting for a "Close" button on the job.

The price of a work item is computed by the server alone, in beforeSave: manual_price ?? round(hourlyRate × actualHours, 2). price is never sent from the form.

The rate is a snapshot, not a join

hourly_rate is copied from the service (otherwise from the company) when the item is created and never changes by itself afterwards. A price-list change does not recalculate old items — which is correct, because they may already be on an invoice.

Closing a sheet → an invoice#

WorkListManager::invoiceSheets() takes N sheets → ONE invoice. An invoice line is one work item (CompanyInvoiceGenerator::buildLine()):

Kind of item quantity unit price
hourly actual_hours (DECIMAL string from the DB) hod the hourly rate
manual_price 1 ks the fixed price

Item selection: billable = 1 AND deleted = 0 AND price != 0. Totals are not sent — the Invoicer's calculator is authoritative.

The closing state is a closing window, not a phase of the work:

open/closed ──(guards + composed, UNSAVED invoice)───▸ closing
closing ──(save OK → write invoice_id → state)───────▸ invoiced
closing ──(save failed, no document created)─────────▸ previous state (state_before_closing)

Idempotence rests on closing + invoicer_invoice_id

A repeated call over a sheet that is in closing and already has an invoice_id finishes the closing and issues no second invoice. The only state that needs a human is closing WITHOUT an invoice_id. Bypass that pair in new code and you manufacture duplicate documents.

Hours go into quantity as a DECIMAL string, not a float

This requires MODIFY … DECIMAL(8,2) on cms_mod_invoicer_invoice_items.quantity (stage I1). Without that migration the closing refuses fractional hours — loudly, not silently.

Deleting a sheet has two independent conditions

It must not be locked and must not hold undeleted work items. The FK is ON DELETE RESTRICT; with a soft delete, RESTRICT would not help and orphaned items counted in the totals would appear.

Work item comments have their own table#

The original design (D8) assumed a shared Base CommentThread in private mode. On 2026-08-30 that was dropped: a thread was created for every work item even without a comment (measured: 9 threads / 0 comments) and nothing from Base was used — no nesting, no moderation, no language. cms_mod_company_work_item_comments was created instead.

The private branch of Base is DELETED — do not bring it back

The CompanyWorkItem case is gone from ThreadType, and with it isPrivate(), PublicThreadsOnlyFilter, PrivateThreadVisibilityTrait and parts of filterReadPayload(). Every thread in that enum is public today and the read paths rely on it. Two security findings from the 2026-08-29/30 review were a direct consequence of that branch.

Unread state is handled by the pair by_client + read_datetime, one mechanism for both sides (CommentSide::Company / Client). It is marked the moment the thread is displayed, not on a click; countUnreadForCompany() is one query for the whole grid, not N+1.

“The company” is a single subject

One admin reading counts for everyone. Per-user tracking would want another ever-growing table — exactly what the comments were detached from Base to avoid.

Client ownership is guarded by WorkItemPresenter alone

The chain item → sheet → job → client lives in the myComments/myCommentAdd actions. WorkItemCommentPresenter is bridge-only for the admin — were the client to go through it, ownership would be checked in two places and one day they would diverge.

Bookings#

TimeSlotType has three cases — slot (an override window), blocked (a block inside the windows) and closed_day. A day is therefore in one of three modes: default (no record, the calendar's working hours apply), daily override (at least one slot exists) and closed.

free = windows − reservations − blocks

The first slot of a day switches off the default working hours for that day

A day in override mode follows only its own windows. That is why the admin's "open a window" action first stores the current working hours as windows on a still-default day — so the offer does not shrink. New code that writes a slot outside this path bypasses that protection and silently trims the day.

E-mails carry the CMP_ prefix: CMP_RESERVATION_RECEIVED, _REQUESTED, _CONFIRMED, _CANCELLED (ReservationManager).

Rate limits (config/Shared/ratelimit.neon):

Key Limit Keyed by
companyReservation 5 / hour, 20 / day; cancel 10 / hour IP (the channel is open to anonymous visitors)
companyWorkComment 30 / 10 minutes hash of the user id

Comments are limited per user, not per IP

Corporate clients sit behind one NAT and would eat each other's quota. The numbers are literally messageReply from private messages — it is the same kind of action.

Front end#

Fixed page ids (D15, range 300–349):

Id Slug Access
300 terminy public
301 moje-zakazky logged in
302 moje-zakazka logged in
303 moje-terminy logged in

The Api surface for the front end consists of narrow actions, not generic CRUD: presented, */public-list, time-slot/availability, reservation/book, cancel-by-token for the guest; my* with an ownership guard for a logged-in user. Generic getAll/save stay with the admin.

The public company profile has no presenter of its own

The public profile is not a page of the module — it is a set of components the theme calls ({control frontCompanyProfile:hero}, frontCompanyBlockList:single, 'about', frontCompanyServiceList:carousel…). Today only template2 and template3 use them. Anyone looking for a "company page" in app/UI/Front/Company/Presenters/ will not find one — there is only TimeSlot, MyWorkList and MyReservation.

The Profile component is rendered several times per page

The topbar, the hero and the reviews are the same instance in different styles. Templates therefore must not print a bare {$uniqueId} — the id would appear three times. Each style appends its own suffix to it.

RBAC#

The whole module has one privilege group, Company:Company:show (the private messages pattern). On top of ordinary CRUD there are separately seeded actions:

Key What it guards
:Admin:Company:WorkList:invoice issuing an invoice (two gates — the modal and the POST)
:Admin:Company:WorkList:archive archiving a job
:Admin:Company:WorkSheet:reopen reopening a closed or invoiced sheet
:Api:Company:WorkSheet:getAll reading sheets; without it a deniedState is printed instead of the grid

A missing key does not fail, it just returns nothing

RBAC v2 is enforce for both the external and the internal path. A new Api action without a seeded key means an empty grid with not a single message. Every new presenter therefore also needs an SQL seed — the pattern is sql/2026-08-23_03_company_rbac.sql.

Where to reach#

I want Where
company logic, gallery, rating thread app/UI/Api/Company/Models/Managers/CompanyManager.php
closing and invoicing sheets app/UI/Api/Company/Models/Managers/WorkListManager.php
composing the invoice app/UI/Api/Company/Models/Generators/CompanyInvoiceGenerator.php
slot availability and bookings app/UI/Api/Company/Models/Managers/ReservationManager.php
the company form (3× SYNC + dropzone) app/UI/Admin/Company/Presenters/Traits/Forms/CompanyFormTrait.php
the work grid app/UI/Admin/Company/Components/WorkGridComponent/
the components for a theme app/UI/Front/Company/Components/
the design and decisions D1–D19 improvements/company-module.md
the work sheets stage improvements/company-worksheets-invoicing.md
detaching the comments from Base improvements/company-work-item-comments-own-table.md
the customer billing profile improvements/company-customer-billing-profile.md

Follow-up chapters: Invoicing · Company directory · Authentication and authorisation · CORS, rate limiting and auditing