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 gallery: order is a data contract#
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.
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