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.
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. 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. 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.
A team member's portrait has to be carried over by hand
saveTeamMembers() rebuilds the member entities from the form, and save() is a
replace, not a patch. The existing portrait is therefore carried over from the hidden
photoCurrentId field (rendered by the ImageUpload component); the Api
processPhoto() in afterSave sets the FK only for a new image. Drop that carry-over
and every company save wipes the portraits of all members — even when nobody touched
the "Team" tab.
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. portrait also takes item 0
— it is meant for a theme whose hero does not show the photo, so it is not repeated on the
page.
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 the result of
publicList (ReferenceManager::findListReferences() and friends). Each section calls
publicList again; there is no per-request memo.
| 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 (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.
Work item lock and moves between sheets#
Work items of a locked sheet (WorkSheetState::lockedStates(): closing, closed,
invoiced) and of an archived job are read-only. The decision is made by
WorkItemManager::guardSheetWritable(), which reads the sheet and job state with a raw
query — closing runs in another request and the identity map could hold the state from
before it. Every write path bypasses the previous one, so the guard sits in all of them:
| Path | What is checked |
|---|---|
save() → beforeSave() |
the target sheet from the payload; on update also the sheet the item is in now in the DB; a move only within the same job |
updateColumns() |
the sheets of all affected items; with workSheetId also the target sheet and the same job |
delete() → beforeDelete() |
the sheet by the stored state of the item, not by the entity at hand |
deleteAll() |
override: finds the items by the filter and checks their sheets (the parent version runs no hooks) |
A move into a sheet of another job is rejected even for the same client
(guardSheetOfSameList()): money would cross between two invoicing contexts. No path takes
a work item out of a locked sheet.
An inline grid change recomputes the price outside beforeSave()
updateColumns() runs no hooks, so the override calls recomputeStoredPrices() after
success: the same formula, writing only rows whose price changed. Only the fields in
PRICE_INPUT_FIELDS (actualHours, hourlyRate, manualPrice) trigger it. Add any new
field the price is computed from there — otherwise the old price stays in the DB and that
is what goes on the invoice.
findForSheet() and findForSheets() do not check ownership
They return the items of any sheet. The client section may only go through
findOwnedItemRow() and findForOwnedList(): ownership and selection are one query over
item → sheet → job → the user's customers, with deleted = 0 at all three ends. A
foreign, missing and deleted job return the same emptiness; a distinguished answer would
turn the action into an oracle for the existence of foreign jobs.
Write guards of sheets and jobs#
WorkSheetManager and WorkListManager guard the same four paths as work items; a
greyed-out button in the administration is only a mirror. The deleteAll() override is not
a formality: the parent deletes by filter without beforeDelete, yet it is a normal Api
action with its own RBAC key — the guard could be bypassed with a filter instead of ids. The
state is read with a raw query here too, not from the entity that came from the payload.
| Situation | What is refused | errorCode |
|---|---|---|
job archived |
any write to the job, its sheets and work items | workListArchived |
sheet closing / closed / invoiced |
editing and deleting the sheet (work items: see above) | workSheetClosing / workSheetClosed / workSheetInvoiced |
sheet or job state outside the enum (tryFrom() returns null) |
the same as a locked state | unknownState |
a different workListId on an existing sheet |
beforeSave() and updateColumns() |
workSheetListImmutable |
A sheet never moves between jobs: the client, the billing customer and the client account sit on the job, so one form field would hand all the sheet's work items and their conversations to another client.
State transitions do not go through the guards
closeSheet(), reopenSheet(), invoiceSheets(), archive() and unarchive() write
only through private conditional UPDATEs (WHERE state = …), which double as the
serialization gate. The fields they change are in $defaultSkipFields (on the sheet
state, stateBeforeClosing, closedDatetime, invoicerInvoiceId). Write a new
transition the same way — a meta flag that makes save() skip the guard can be sent
by anyone. bill is deliberately not in the skip list: it is a plain checkbox, protected
by #[ExternalReadonly] and the locked-sheet guards.
A job is deleted only when empty, never by cascade
beforeDelete() refuses a job with undeleted sheets (WORK_LIST_NOT_EMPTY) and a job
with a locked or once-invoiced sheet (closing, invoiced or a filled
invoicer_invoice_id) as well (WORK_LIST_HAS_INVOICED_SHEETS) — such a sheet cannot
be deleted, the only way to end the job is archiving. An archived job returns
WORK_LIST_ARCHIVED. The grid translates these codes from the results domain
(explainDeleteFailures() in WorkListPresenter), so the admin sees the reason. Do not
add a cascade in PHP: deletion is hard, the client sees items and comments on the front
end, and ON DELETE RESTRICT on sheets and items is intentional.
WorkItemManager is injected through a factory
WorkListManager and WorkSheetManager get the work item manager through
WorkItemManagerFactory (a lazy Container::getByType()). The sheet manager asks for
work item counts, and the work item manager knows no Work* manager — it reads the
sheet's company with one JOIN (resolveCompanyIdOfSheet()). The container cannot build
constructor injection in both directions. The rules "what is billable" and "what is
counted" live only in WorkItemManager; do not copy them into the caller as a raw query.
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 invoice generator only composes#
CompanyInvoiceGenerator::generate() is a pure builder: it writes nothing, takes no lock
and no number from a billing line, and returns an unsaved invoice. invoiceSheets()
saves it under the job lock only after the sheets have moved to closing — so every
build error happens while the sheets are still open. The generator does not handle
idempotence: two calls = two invoices.
The input is N sheets of one job in the shape [['sheet' => …, 'items' => […]], …].
The sheet a line belongs to is carried by the line's description (the sheet name);
name is the work item title and code stays empty. VAT payer status (useVat) is not
sent — it belongs to the seller and the composer takes it from there.
errorCode |
When |
|---|---|
customerMissing |
the job has no customer |
sellerMissing |
the company has no invoicer_seller_id |
invoiceTypeMissing |
invoice type missing on the job, the customer and the company |
currencyMissing |
currency missing on the job, the customer and the company |
noLines |
empty list of work items (invoiceSheets() reports it as nothingToInvoice) |
invalidWorkItem |
the item has nothing to compute a price from (missing rate or hours) |
+ codes of InvoiceComposer::compose() |
e.g. numberSourceMissing |
All codes are permanent — a configuration or data defect, retrying does not help. The message names what to fill in; for type and currency all three places of the chain.
The caller selects and orders the work items, not the generator
The generator neither filters nor reorders — lines go onto the document exactly in
input order (work_sheets.order, then work_items.order from
WorkItemManager::findBillableForSheets()). The invoice modal shows the same list and
the admin confirms it. Add your own filter or usort() to the generator and the
customer receives a different document than the one a person approved — and nobody
notices.
Hours are not rounded
Without the quantity migration to DECIMAL(8,2), fractional hours are rejected by
the guard in InvoiceManager::beforeSave(). The generator deliberately does not
bypass it by rounding: 2.5 h → 3 h would silently change the invoice amount.
The closing state is a closing window, not a phase of the work:
open/closed ──(guards + composed, UNSAVED invoice)───▸ closing
closing ──(save OK → ONE UPDATE 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
(migration sql/2026-08-23_07_invoicer_quantity_decimal.sql). Without it 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.
The job lock and the order of steps#
The whole of invoiceSheets() runs under the advisory lock
GET_LOCK(WorkListManager::lockName($id), 10) (company_work_list_{id}). The same lock is
taken by archive(), unarchive() and WorkSheetManager::closeSheet() / reopenSheet():
invoicing decides by the states of N sheets at once, so a per-sheet lock would protect
nothing. A caller that does not get the lock within 10 s receives locked. The lock and all
writes use the same DBAL connection — GET_LOCK protects only the session that holds it.
Steps in runInvoiceSheets():
| Step | What | Writes |
|---|---|---|
| 1 | job lock | — |
| 2 | raw read of the job and sheets, guards; a sheet in closing ⇒ recovery; gate "job without a customer" |
— |
| 3 | findBillableForSheets() |
— |
| 4 | the generator composes the invoice | — |
| 5 | open/closed → closing in one conditional UPDATE, all sheets must pass |
sheets |
| 6 | findBillableForSheets() again; a different selection ⇒ revert + billableChanged |
sheets |
| 7 | log the intent to the company channel, InvoiceManager::save() |
document |
| 8 | save failed ⇒ probe invoiceRowExists() ⇒ revert, or closing stays |
sheets |
| 9 | one UPDATE: invoicer_invoice_id, state = invoiced, bill = 0, closed_datetime |
sheets |
| 10 | optionally a follow-up empty sheet | sheet |
Procedure: changing invoicing
- Put a check that can fail because of data into steps 2–4. It fails over sheets that are still open, and the admin clicks again after fixing it.
- Add an input guard to
guardInvoiceEntry()orguardSelectedSheets(). Invoicing and the previewpreviewInvoiceSheets()share them — otherwise the modal promises an invoice that invoicing refuses. - Change the item selection rule only in
WorkItemManager::findBillableForSheets(); the modal, step 3 and the check in step 6 all read it. - Every failure after step 5 must return the sheets with
revertClosing(), otherwise they stay locked inclosingwithout an invoice.
The order in SET is binding
Step 5 is SET state_before_closing = state, invoicer_invoice_id = NULL,
state = 'closing'. MySQL/MariaDB evaluate assignments left to right and the right-hand
side already sees earlier assignments of the same clause. Swap the order and
state_before_closing stores closing, and the revert then opens a manually closed
sheet. The revert (state = state_before_closing, state_before_closing = NULL) has the
opposite order for the same reason.
No wrapping transaction
InvoiceManager::save() opens its own transaction and builds the PDF and QR only after
commit. In a nested transaction a rollback would leave a PDF of a nonexistent invoice on
disk, and the row lock of the number series would be held for the whole PDF generation,
queueing invoice issuing across the whole system. The window between saving the invoice
and step 9 therefore stays, and recovery handles it.
The follow-up sheet does not fail invoicing
In step 10 the invoice is issued and the sheets are invoiced. createFollowUpSheet()
therefore catches exceptions too (a closed EntityManager, a DBAL outage) and returns
them in the data as followUpError; the admin gets success with a warning, not a 500
on a request that has already burned a number from the live series.
Reopening and re-invoicing a sheet#
reopenSheet() (closed/invoiced → open) does not clear invoicer_invoice_id:
the document is issued and with the client, the sheet is still "the one invoice #X came
from". bill stays 0 and closing is never reopened by hand — that is a running or an
interrupted invoicing. An additional charge is issued as another invoice. Step 5 detaches
the old link in the same statement as the transition to closing and logs it to the
company channel beforehand.
A revert does not restore the old link
When re-invoicing a reopened sheet fails, the sheet returns to its original state with
invoicer_invoice_id = NULL. The historical link is only in the log. Without detaching,
step 9 (WHERE invoicer_invoice_id IS NULL OR = :invoiceId) would refuse the sheet only
after the new invoice was saved — an orphan document with a burned number.
Interrupted invoicing#
| Crash between steps | State in the DB | Next invoiceSheets() over the same selection |
|---|---|---|
| 5 and 7 | closing, no invoicer_invoice_id |
revert to state_before_closing + closingWithoutInvoice |
| in step 8 | original state (revert proven by the probe) | invoices normally |
| 7 and 9 | closing, no invoicer_invoice_id |
same as the first row — ⚠️ but the invoice may exist |
| after a manual fix of the link | closing with a filled invoicer_invoice_id |
finishes via step 9, recovered: true, no new invoice |
| 9 and 10 | invoiced |
alreadyInvoiced; the admin creates the follow-up sheet by hand |
Recovery (recoverInterrupted()) finishes a selection only when all its closing sheets
carry the same invoice and the rest of the selection is invoiced with that same invoice
(step 9 passed on part of the sheets only). Anything else is closingMixedState. An invoice
also linked to a sheet outside the selection returns closingInvoiceTaken, a link to a
nonexistent document closingInvoiceMissing. Recovery creates no follow-up sheet and does
not require a customer on the job — the invoice already exists.
Procedure: after a crash the sheets ended up in closing without an invoice
- Find the intent record of step 7 in the
companychannel log, and the number of the saved invoice if there is one. - Check in Invoicer whether the document was created.
- It was not: repeat the action (the first attempt has already returned the sheets, or will).
- It was: do not repeat the action. In the DB set
invoicer_invoice_id = <id>andstate = 'closing'on all affected sheets and only then run it — it finishes them without a new invoice. Neither the administration nor the Api changes these fields.
Repeating after a crash between steps 7 and 9 issues a duplicate
The server cannot tell this crash from one before saving, because the link to the
document does not exist yet and Invoice.order_id must not be used for it. The
closingWithoutInvoice message therefore asks for a check in Invoicer. A duplicate
invoice burns a number from the live series, reaches the customer and can only be
undone by a manual cancellation.
Editing a job in the administration#
The /admin/company/work-list edit screen is one {form editForm} with two tabs:
"Job" (WorkListFormTrait) and "Work sheets" (WorkListSheetsFormTrait). Each sheet has
its own WorkGrid instance via Multiplier; all of them are in the DOM at once and JS only
toggles visibility, so switching sheets never drops rows typed into another one.
One submit saves everything, in this order (saveWorkListForm()):
- The job is saved (
WorkListManager::save()). If that fails, nothing else runs. persistSheetsAndItems()walks the POST fieldsheets[<id>]— a raw POST without any Nette validation. For an archived job it is skipped entirely.- Each sheet, targeted:
updateColumns()with only the changedname,bill,order. - Each work item, targeted: an existing
[id]→updateColumns(),[deleted]=1→delete(), a new row with a title →save(), an empty new row is skipped silently. - Errors of individual sheets and rows are collected and flashed as separate warnings; the job and everything that went through stays saved.
| Field | Where it comes from |
|---|---|
item price |
never from the POST — the server computes it |
[deleted] |
never hydrated, translated to delete() |
job state, closedDatetime |
the form never touches them; only the archive modal changes them |
| a row of a foreign sheet | ignored (the id does not belong to that sheet) |
| a row of a locked sheet | the browser sends only id/order/deleted=0; anything else is reported as a change attempt |
Numbers in the grid are guarded only by itemNumberError()
The grid has no per-cell validation, so hours, rate and manual price are checked
against the column limits (ColumnLimits) by this gate alone. A bad value rejects the
whole row, not with a silent zero. Without it the DB would fail the save with a
generic database error under a strict sql_mode, and under a non-strict one it would
truncate the hours while the price was computed from the untruncated value — the
invoice would then fail the quantity × rate = price check. A new numeric grid field
therefore also belongs in itemNumberError().
is_numeric() is not enough for grid input
It accepts exponents: 1e1000 passes, (float) turns it into INF and the string
inf goes to the DB. parseDecimal() therefore checks the shape with a regular
expression and rounds in bcmath to the column precision. The VAT rate is read by
parseVatPercents(), not intOrNull() — that one would turn 0 % (reverse charge) into
NULL.
Customer and billing data#
The customer (invoicerCustomerId, a picker over the Invoicer card index) is not required
for saving, only for invoicing: the Api enforces it (WorkListManager, code
customerMissing) and the job's invoicing box warns about it up front. Older jobs without a
customer thus stay editable. The portal client account is not on the job — it comes from the
customer (Customer.cms_system_users_user_id).
Invoice type and currency are optional selects on the job. After a customer is picked, the
refresh (applyCustomerRefresh()) overwrites them with the customer's profile values and
a copy is stored, not a reference — a later profile edit does not change an existing job. An
empty value means "next link of the chain":
| Value | Chain (first filled wins) | Where it is resolved |
|---|---|---|
| invoice type, currency | job → customer → company | CompanyInvoiceGenerator |
| currency of the "Work sheets" tab | job → customer → company → first system currency | resolveCurrency() |
| default VAT rate | customer → company (the job has no VAT field) | the generator; in the grid only a placeholder |
The contact select needs its options on submit too
The invoicerCustomerContactId options are filled by the refresh and by the edit
defaults. If addClientFields() did not also fill them on POST from the posted customer,
Nette would silently drop a value outside the (empty) options and the contact would be
saved as NULL.
setRequired() on the customer would hide the message
invoicerCustomerId is a hidden field and HiddenField::addError() always sends the error
to the form level — {inputError invoicerCustomerId} prints nothing. Whoever brings the
requirement back must count on the message being shown only by flashFormOwnErrors().
Summaries and issuing an invoice#
The price under the sheet name, the invoicing box and "uninvoiced" sum only items with
billable = 1 (summarizeItemsRaw()); count and hours are not filtered. A non-billable item
still has its price computed — "free" is expressed by the flag, not by a zero price.
The "Issue invoice" modal (WorkListInvoiceFormTrait) has two steps:
invoicePreview!(AJAX) receives the ticked sheets assheetIds[]and fills the modal with a read-only preview. On failure no table is rendered, only the error —isSuccess()decides, not the content ofsheets.- The confirmation is a real form
invoiceConfirmFormwith a full navigation (noajax), because issuing changes most of the page. The selected sheets travel as plain<input type="hidden" name="sheetIds[]">and are read from the POST. - When issuing fails, the presenter redirects back to the preview over the same selection.
The privilege is checked in both steps
:Admin:Company:WorkList:invoice is checked by both handleInvoicePreview() and
processInvoiceConfirm(). The confirmation is a separate request — whoever removes the
check from it because "the modal is already protected" lets anyone who sends the POST
issue an invoice.
Work item comments have their own table#
The original design assumed a shared Base CommentThread in private mode. 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. That very branch caused two security bugs
in reading threads.
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, _CHANGED (ReservationManager).
The admin bell notification is created only for a web booking
The COMPANY_RESERVATION_NEW notification is created by
ReservationPresenter::internalActionBook() after a successful book(), not by the manager.
bookByAdmin() does not pass through there, so a manual entry by an admin does not
light the bell. It is best effort: an error is only logged to the company channel and
the reservation stands. receiverUserId = null means every admin sees it; a missing or
inactive type in the code list is silently skipped.
The interval model and the reservation carrier#
book() accepts any range timeFrom < timeTo (exclusive end) that fits entirely into
one free interval of the day. slot_length_minutes is only the offer step in the UI; the
server neither aligns to the grid nor enforces a minimum length. The booking then creates
a carrier — a slot row with public = 0 and the exact range. A 9:00–17:00 window
with a 10:00–10:45 reservation thus yields free time 9:00–10:00 and 10:45–17:00 without
splitting any row.
resolveCarrierSlot() finds or creates the carrier in three ways:
- No row at the start of the range → a carrier is created.
- A free
slotwith the same range exists (an override window or a carrier left by a canceled reservation) → it is reused. - A row with a different range sits at the start → the carrier is created at a key
shifted by seconds (
13:00:01), because the unique key(calendar_id, date, time_from)allows no second row in the same minute. All arithmetic runs in minutes, so the shift is invisible.
The carrier must have public = 0
With public = 1 the first reservation would flip the day into override mode and the
other windows would vanish from the calendar.
Times accept only zero seconds
parseMinutes() takes H:i and H:i:s only with seconds 00; 24:00 is a valid end
of day. 10:00:30 is rejected — truncating to the minute would shift the reservation
boundary against what the client sent.
Concurrency: the calendar day lock#
All writes into one day of one calendar (client booking, admin write,
TimeSlotManager::save(), shorten, split, merge) go under the advisory lock
TimeSlotManager::dayLockName() = company_calendar_slot_{calendarId}_{date}, waiting
DAY_LOCK_TIMEOUT = 5 s. The key is the day, not the window start: ranges 10:00–11:00
and 10:30–11:30 have different starts and a lock keyed by start would not serialize
them. Different days and different calendars do not wait for each other.
Procedure of a write under the lock:
- Preliminary availability check through Doctrine (outside the lock, only a quick rejection).
acquireSourceLock(); on failure →DB_LOCKEDwith a "try again" message.- The day is re-read with raw DBAL (
TimeSlotManager::findDayRowsRaw()) and free time is recomputed — the identity map may hold a stale row. - The carrier and the reservation are written over the same DBAL connection that holds the lock.
- The lock is released in
finally, only then e-mails go out (best-effort, a failure does not cancel the reservation).
| Safeguard | What it catches |
|---|---|
uq_company_reservation_active (time_slot_id, active_flag) |
two active reservations on one carrier |
uq_company_calendar_time_slot (calendar_id, date, time_from) |
only an identical start |
| day lock + recomputation under it | overlapping ranges with different starts — nothing else catches them |
Build the lock name only through dayLockName()
An own copy of the format would create a second lock over the same day — and serialization would vanish exactly where the overlap protection depends on it.
Do not call booking inside a foreign transaction
Booking deliberately does not run in a wrapping transaction: the only intermediate state (a carrier without a reservation) is harmless and the next booking reuses the carrier. Inside a foreign transaction the reservation would become durable only with the foreign commit, while the e-mails have already gone out.
Nothing through the ORM after UniqueConstraintViolationException
The exception closes the EntityManager. Code that catches it may only return an error.
Client booking × admin write#
book() (website) and bookByAdmin() (time slot modal) share the core
createReservation() including the lock and the recomputation. They differ only in:
book() |
bookByAdmin() |
|
|---|---|---|
| calendar | optional, otherwise CalendarManager::resolveDefault() |
required; the company is derived from it |
| calendar belongs to the company | checked | the company is taken from the calendar |
| GDPR consent | required, writes gdpr_consent_datetime |
not required, the stamp stays NULL |
| past and lead time | trim free time | do not trim |
| range outside offered windows | rejected | a window is opened first (TimeSlotManager::openWindow()); overlap with a reservation or block and closed_day are rejected |
presented + booking_enabled |
required | not checked (the company must be active and not deleted) |
| initial state | pending, or confirmed with the calendar's auto_confirm |
chosen by the admin (pending/confirmed) |
| e-mails | always | only with notifyClient ticked (off by default) |
auto_confirm is a property of the calendar, not the company. It changes which pair of
e-mails goes out: with automatic confirmation the client gets _CONFIRMED and the
company nothing (the _REQUESTED text asks for a confirmation that has already
happened); otherwise the client gets _RECEIVED and the company _REQUESTED.
The consent stamp is a record
Do not fill gdpr_consent_datetime on a manual write. The admin collected no consent
through the website and a filled stamp would be a false record.
Cancellation and state transitions#
cancelByToken() (e-mail link), cancelOwn() (logged-in client), confirm(),
cancel() and updateByAdmin() are one conditional UPDATE whose WHERE carries the
allowed states and, for the client, the token or user_id. Check and write have no gap
between them: two concurrent clicks end with affectedRows 1 and 0 and the e-mail goes
out once.
The answer to the client does not reveal whether a reservation exists
"Does not exist", "not mine" and "already canceled" return the same message.
cancelOwn() loads the reservation only after a successful UPDATE — before that the
code would hold foreign personal data. Add a distinct message or an earlier load and
the action becomes an oracle for other people's reservations.
The conditional UPDATEs use raw DBAL, so the generic cache bump from SaveTrait does not
see them; bumpStateCacheTags() drops the Reservation and TimeSlot tags manually. A
new state transition must call it too — otherwise, once the query cache is on, the
calendar would show a canceled term as occupied until the TTL expires.
The cancel link needs site.baseUrl outside HTTP
buildCancelLink() takes the site.baseUrl setting (SiteBaseUrl), otherwise the
request address. In cron or CLI the request address is http:/, the link is not built
and the e-mail goes out without it — with only an entry in the company log channel.
Calendar: the company does not change, only an empty one is deleted#
CalendarManager::beforeSave() rejects moving a calendar to another company even from
the internal path (#[ExternalReadonly] blocks only external HTTP). Slots carry a
denormalized company_id that is reconciled only on a slot write — after a move they
would diverge permanently. To have the calendar elsewhere, create a new one.
beforeDelete() rejects deleting a calendar that has any slot (even past or inactive)
with the code CALENDAR_DELETE_BLOCKED. Without it the RESTRICT on
fk_company_time_slot_calendar would end in a raw database error and a closed
EntityManager.
ReservationManagerFactory breaks a DI cycle
TimeSlotManager needs ReservationManager (occupancy, guards) and vice versa.
TimeSlotManager therefore holds the factory and fetches the manager at call time;
the opposite direction is a normal constructor dependency. Inject a new manager that
needs both the same way.
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.
When an attempt is charged differs per action:
| Action | Charging | Why |
|---|---|---|
book |
both windows are only read first (check()), hit() after the outcome; DB_LOCKED is not charged |
DB_LOCKED means someone else's booking under the day lock, not a client attempt |
cancelByToken |
hit() before cancelling |
tokens must not be guessable for free |
myCommentAdd |
hit() before the manager call, failed attempts included |
otherwise foreign workItemId values could be probed for free |
book skips only DB_LOCKED, it does not count only successes
Validation failures are still charged — otherwise a robot could probe windows with
invalid data forever. Rewriting the !== ResultCode::DB_LOCKED condition to "success
only" opens that probing; deleting it locks an honest client out for an hour after a
few races.
Calendar administration#
The calendar has two admin screens with split responsibilities:
| Screen | Presenter | What it does |
|---|---|---|
Schedules (/admin/company/schedule) |
SchedulePresenter |
visual year / month / week / day, the SlotModal time slot modal, merging |
Time slots (/admin/company/time-slot) |
TimeSlotPresenter |
DataGrid of overrides and blocks, add/edit form |
Reservations (/admin/company/reservation) |
ReservationPresenter |
list, detail, Confirm / Cancel |
The month overview reads the Api narrow action availability with a calendarId
(TimeSlotManager::availabilityForCalendar()), not entities. Days without their own row
(source = 'default') would not show up in a raw grid over TimeSlot, and only the Api
knows the resolution closed_day → override → default grid — do not repeat it in the
admin. The year overview, on the other hand, does not call the Api at all: twelve monthly
payloads would be twelve bridge requests, so it runs two queries over Admin managers and
shows only counts of reservations, blocks and closed days.
The time slot modal#
SlotModal opens only on a click on a day timeline block or a week bubble, and the block
type selects the mode:
| Block | Mode | What is possible |
|---|---|---|
| free / closed | fill |
a new reservation (bookByAdmin), a block, or "open window" (openWindow) |
| pending / confirmed | reservation |
client details and state (updateByAdmin), Shorten / Split, cancel |
| blocked | blocked |
cancelling the block only |
The time of an occupied row is changed only by TimeSlotManager::shorten()/split();
in reservation mode the range is static text. The component neither locks nor validates
anything — the Api decides under the per-day calendar lock
(TimeSlotManager::dayLockName()).
Save + Shorten is not one transaction
The "Save" button calls updateByAdmin() and, depending on the timeAction switch,
also shorten() or split(). Each takes and releases the day lock on its own. When the
second step fails (the new range collides), the client details stay saved and the time
does not — the modal reopens over the real, half-saved state.
Pitfalls the modal avoids on purpose — do not bring them back when you change it:
- The context travels explicitly. The component signal link does not carry the page's
companyId/calendarId/date(Nette prefixes arguments with the component name). The modal receives them as signal parameters, keeps them in hidden fields, andSchedulePresenterreads them back viaSlotModal::getViewContext(). Without that,renderDay()redraws a different day after the click. - No
setRequired(). The form has several buttons; HTML5requiredon a field of another button silently blocks the submit in the browser. All validation is in the Api managers. - After a successful create the modal does not switch to editing.
slotFormis built lazily infillmode while the POST is handled; switching the mode afterwards does not change the form and the template reaches for a missingtimeSlotId→ 500. The modal therefore ends in the terminal state ($done), which does not render{form}. - It renders its own messages (
$presenter->flashMessage()is invisible in a component template), does not redirect (that would close it) and reloads state after a write — mutations over the bridge do not change already hydrated Admin objects.
Merging time slots#
SchedulePresenter::handleMergeSlots() is a plain POST signal with PRG; the logic is in
the Api ReservationManager::mergeSlots().
The input is a range, not a list of ids: the server re-reads what lies in it under the day lock. The resulting type follows the priority reservation > block > free > closed — e.g. free + closed = free, block + reservation = reservation. Only closed pieces are rejected ("nothing to merge").
For a "reservation" result:
- The earliest starting active reservation wins; the others are canceled without an
e-mail (
cancelInternal()) and their rows are absorbed byTimeSlotManager::absorbRow(). - Only then is the winner's carrier grown over the whole range (
growSlot()) and turned into a window (public = 1). The opposite order would treat the not-yet-cleared pieces as a collision. - Names and notes are joined with
' + ', e-mail and phone stay the winner's. - The state is
confirmedonly when all merged reservations were confirmed, otherwisepending. - After the lock is released, the winner's client gets
_CHANGEDif their range changed.
A slot with reservation history cannot be deleted
fk_company_reservation_slot blocks DELETE of a slot with any reservation, even a
canceled one. That is why the winner is grown and the defeated rows are absorbed
instead of being deleted and recreated.
Build the merge target URL explicitly
companyId/calendarId/date are not persistent parameters of the day view, so
redirect('this') would silently drop them. And timeFrom/timeTo must have
defaults: the template generates the link without them (merge-slots.js fills them
in) and a missing required parameter breaks rendering the template with
InvalidLinkException.
Time slots and reservations in the table#
The time slot form (TimeSlotFormTrait) serves three modes with one switch: override
(slot), block (blocked) and closed day (closed_day, the time is ignored).
buildTimeSlotEntity() hard-codes public = true; the hidden public field in the POST is
not the source of truth. An empty calendar in the form lets the Api fill in the company's
default calendar (CalendarManager::resolveDefault()).
A reservation carrier (public = 0) is not editable
TimeSlotPresenter::actionEdit() rejects it. Saving would set public = 1 and flip
the day into override mode — the other implicit windows of that day would disappear.
Reservations are not edited by hand: state, cancelToken and friends are Api
#[ExternalReadonly] + defaultSkipFields. The only writes are the confirm/cancel
transitions (narrow actions — updateColumns would bypass the transition guard and the
customer e-mail) and the single subject field via the generic save()
(ReservationFormTrait).
Do not add subject to updateByAdmin()
updateByAdmin() is also called by the time slot modal, which knows nothing about
subject. A widened signature would silently null the subject on every modal save.
Procedure: adding an action to the time slot modal
- Add a value constant to the switch (
fillActionortimeAction) and the field to theadd*Fields()of the matching mode — withoutsetRequired(). - Route the write through the Admin manager → bridge → an Api narrow action that takes the day lock.
- In the handler call
requirePrivilege(),hydrateContext()and the check that the slot and calendar belong to the company (requireSlotInCompany()/requireCalendarInCompany()). - After the write reload the state and finish with
finish()orfailAndReopen(), notredirect().
Writing a time slot in the Api: paths and guards#
A calendar row belongs to the calendar; its company_id is only a derived copy.
TimeSlotManager::save() therefore reconciles the pair first (reconcileCalendarCompany()):
| Input | Result |
|---|---|
calendarId > 0 |
companyId is overwritten by the calendar's company — the calendar wins |
only companyId (time slot form, older callers) |
CalendarManager::resolveDefault() is filled in |
| company without an active calendar | VALIDATION_FAILED asking to create a calendar |
Only the reconciled calendarId is used to compute the day lock name. When a row moves
to another date or calendar, both days are locked — time is freed on one and occupied on
the other. withDayLocks() sorts the names before acquiring them, so two admins working on
the same two days cannot deadlock.
| Path | What it guards |
|---|---|
save() |
lock; a row with an active reservation may change only the note and public (guardActiveReservationRow()); closed_day not on a day with an active reservation; blocked only entirely within free time, without the lead-time trim; a start collision is resolved by shifting the key by seconds |
updateColumns() |
only note and public; date, times, type, calendar or company → VALIDATION_FAILED with errorCode timeSlotStructuralUpdateColumns |
delete() |
locks the days of all deleted rows; does not delete a row with an active reservation or with reservation history |
deleteAll() |
nothing — runs no beforeDelete() and takes no lock |
shorten(), split(), growSlot() |
own guards under the lock, raw UPDATE of the time columns only |
Do not move reconcileCalendarCompany() into beforeSave()
beforeSave() runs inside parent::save(), i.e. already under the lock. An older write
with only companyId would then lock calendar 0 and serialize nothing — a block or a
closed day could overrun a reservation that has just been created.
Delete time slots only through delete()
deleteAll() by filter bypasses the guards and the lock, and a slot with reservation
history is then stopped only by the FK fk_company_reservation_slot
(ON DELETE RESTRICT). Instead of a message the admin gets a raw database error and a
closed EntityManager.
bookingEnabled = false does not empty the availability: availabilityForCalendar() always
computes the days and only attaches the flag. An empty response could not be told from
"closed this month", and the admin overview uses the same path. Occupancy outside the
offered windows (reservations from before working hours were narrowed, a block on a closed
day) goes in the busyOutside key; the public busy is clipped to the windows. An overview
that reads only busy silently hides such an appointment.
Shortening, splitting, growing#
A generic save() does not change the time of an occupied row. The only exception are three
narrow actions:
| Action | Direction | On what | What follows |
|---|---|---|---|
shorten() |
only inward within the original range | slot with an active reservation |
for an override window (public = 1) the offered window shrinks too |
split() |
cut time inside the range, not on its boundary | slot with an active reservation |
the reservation stays on [from, cut); a row for [cut, to) is created only for public = 1 |
growSlot() |
both ways | slot |
must not collide with a reservation or block; with makePublic it first materializes the day's default windows |
For a carrier (public = 0) the rest after a split is freed by itself — free time is
windows minus occupied. A new public = 1 row would switch a day without overrides into
override mode and the other windows would vanish. The change e-mail to the client is sent by
the wrappers ReservationManager::shortenTerm() and splitTerm(), not by TimeSlotManager.
A failed split is not rolled back
When the freed window cannot be created after shortening, the shortening stays and
split() returns success with freedSlotCreated = false; the reason is in the company
log channel. A rollback would stretch the reservation again over time the admin
deliberately freed. Whoever promises "the rest stays on offer" must read
freedSlotCreated.
DB_LOCKED after a row moved
The lock name is built from the row read before the lock. If another write moved it to
another day or calendar in the meantime, the action ends with DB_LOCKED and writes
nothing; a retry locks the right day.
absorbRow() and absorbWindowsWithin() take no lock
Call them only inside withDayLocks() in the same MySQL session, otherwise they run
concurrently with book() over the same day. absorbWindowsWithin() also only after the
resulting window exists: a day without an override would otherwise fall back to working
hours and the offer would silently widen.
Procedure: a new admin write to the calendar
- Write through
TimeSlotManager::save()/delete(), or add a narrow method that wraps the work inwithDayLocks([self::dayLockName(…)]). - Build the lock name from the stored row (
findRowRaw(), from another managerfindLockContext()), not from parameters. Under the lock read the row again and check it is still on the locked day (patternguardLockedDay()). - If the write switches the day from default mode to override, materialize the default
windows first (
materializeDefaultWindows(), patternopenWindow()). - After a raw UPDATE call
bumpCacheTagsForClass(TimeSlot::class)— the generic cache bump fromSaveTraitdoes not cover this path. - Give the action its own RBAC key (pattern
:Api:Company:TimeSlot:shorten) and seed it.
Front end#
Fixed page ids (range 300–349):
| Id | Slug | Access |
|---|---|---|
| 300 | terminy |
public |
| 301 | moje-zakazky |
logged in |
| 302 | moje-zakazka |
logged in |
| 303 | moje-terminy |
logged in |
| 304 | rezervace-terminu |
public |
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…). Which
themes call them is shown by grep -rl frontCompany www/themes/frontend. Anyone
looking for a "company page" in app/UI/Front/Company/Presenters/ will not find one —
there is only TimeSlot, Booking, 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.
Components for a theme#
A theme calls the components by the name frontCompany<Component> and picks the style
through a render<Style>() method, e.g. {control frontCompanyReferenceList:logos}. All
of them read through narrow actions with the presented filter on the server and send no
companyId. An empty result renders Empty.latte or nothing, never an error.
| Component | Styles | What it shows |
|---|---|---|
Profile |
hero, topbar, aboutMedia, qualityMedia, portrait, gallery, footerBrand, contact, copyright, testimonials |
company data, gallery photos by index, reviews (only with ratingEnabled on and an existing thread) |
BlockList |
sections, single |
free HTML blocks; single picks one by its identifier anchor ({control frontCompanyBlockList:single, 'about'}); without an anchor it takes the first public block by order, an unknown anchor gives Empty.latte |
ReferenceList |
grid, featured, logos, filmstrip |
references by their flags (table in the references chapter) |
ServiceList |
list, cards, carousel, links |
the service price list |
TeamList |
cards, carousel |
team members |
HighlightList |
only the default render() (Bar) |
the company's highlights |
OpeningHours |
only the default render() (Table) |
opening hours of the whole week, today highlighted |
Profile:topbar shows today's opening hours only. A summary like "Mon–Fri
7:00–17:00" could be composed from an arbitrary schedule only by guessing (an exception on
Wednesday); the week is shown by OpeningHours.
Block content goes out unescaped
A block's content is finished HTML from the administration and the template prints it
with |noescape. Escaping would show raw tags; so only an administrator may fill it.
Whoever feeds text from another source into blocks opens XSS on the site's home page.
Booking on the website: two pages, two components#
| Page | Presenter | Component | What it does |
|---|---|---|---|
300 terminy |
TimeSlotPresenter |
BookingCalendar |
the month with the chosen day below it, the range from two selects, cancellation through ?cancel=<token> |
304 rezervace-terminu |
BookingPresenter |
BookingWizard |
the day replaces the month, a switcher of public calendars, the form in a modal |
303 moje-terminy |
MyReservationPresenter |
BookingWizard:button + MyReservationList |
a button opens the whole wizard in a modal |
The older calendar on page 300 stays unchanged next to the wizard; the two components share
only _booking_calendar.scss. Cancellation through the e-mail token exists only on page
300 and works even with booking_enabled = false — a link from an earlier e-mail must stay
usable even when the admin has since disabled new bookings. Without a presented company or
with bookings disabled, both pages print a "booking disabled" message instead of the
calendar.
Rules of the wizard (BookingWizard):
- The state lives in the URL, not in the presenter. Month, day, calendar, the tapped
slot and the mode (
inModal) are persistent parameters;render()takes no arguments and the presenter passes nothing to the component. Context passed as an argument would be lost by an AJAX redraw of the snippet, which would render empty. calendarIdfrom the URL is not trusted. Only values from thecalendarskey of theavailabilityresponse are valid (public calendars of the presented company,InitTrait::resolveCalendarId()); any other id falls back to the first public calendar. A company without a public calendar gets no grid of any other calendar — the default calendar may be internal ("Holidays", "Test").- The calendar switcher is rendered only with more than one public calendar.
- Day clickability and the trim of today by
leadTimeHoursare computed in PHP; the template gets the readybookableflag. A tapped slot(from, to)is checked againststartscomputed on the server; an invalid pair does not open the modal. - Only
startsare drawn. Public availability carries no identity of foreign reservations;busy/confirmed/pendingblocks do not belong in the template.
On the front end there is one modal, not two stacked
The front modal runs through ModalRegistry and ModalWindowEvents, not as an admin
modal. ModalWindows.latte has data-naja-snippet-append on the inner
#modals-container, but Naja looks for the flag on the snippet itself, so the whole
container is replaced. A second modal would redraw the first one too and only the new
one would get the show class — the first would disappear. The form therefore
replaces the content of the calendar modal (ModalRegistry::update() +
ModalWindowEvents::UPDATE) and the backToCalendar! arrow leads back. Opening through
ADDED over an already open window would drop its Bootstrap instance.
Common to both forms:
- The form is created before
render(). A form signal POST callscreateComponent*()first, andrender()does not run at all on a redirect or an error. The availability the form builds its time offer from is therefore read memoized from both places (fetchAvailability()), not only while preparing the template. The job detail does the same. - The time offer is not a check. Ends are offered in steps of
slotLengthMinutesup tomaxTo; the(from, to)pair is verified again bybook()on the server. - Honeypot
website: when filled, it pretends success and the Api is not called. - The
book()result is propagated. The repository returns a flat envelope (success/code/reservationId/…) throughnormalizeResultEnvelope(), not throughfailSoftRead()—success: false(taken, limit) is the meaning of the call. The form translatescodewith theERROR_MESSAGE_KEYSmap; an unknown code gives the generic message.
Take a logged-in user's name and e-mail from the identity, not from UserManager
The front UserManager::find() targets :Api:System:User:get, which only the admin has
a grant for — in enforce mode it returns empty. findPublicProfile() gives a masked
e-mail. The form therefore prefills first_name, last_name and email from the JWT
identity data (AuthStorageTrait::createIdentity()) without another query.
The client section on the website#
Pages 301–303 have no redirect of their own for an anonymous visitor: without an identity
the bridge returns nothing, the component shows an empty state and access is guarded by the
@logged.latte layout. A foreign and a missing record give the same empty state.
- The job detail (
MyWorkDetail) looks up the job header in its ownmyListresult and takes the work items frommyItems. It groups them by work sheet in the order of first occurrence and does not sort them again: the Api returns them in invoice order (work_sheets.order,work_items.order,id). Group sums of hours and prices are computed in PHP withbcadd, not in the template. A work item without its sheet in_referencesis shown without a sheet header, not dropped. - Cancelling an own booking (
MyReservationList) is a real POST form with CSRF, not a GET signal — a link or a prefetch would otherwise cancel it. The confirmation goes through the styled<dialog>withdata-grid-confirm, notwindow.confirm.
Procedure: a new client section component
- Read data only through a
my*action (identity from the JWT) and send nouserId. - Render a foreign, a missing and an empty result the same way; do not tell them apart even by a message.
- Memoize the data a form needs in the component and read it from
createComponent*()as well as from the template preparation. - Write through a POST form with
addProtection(); read the result from the flat envelope and build the message fromcode.
Client actions and response stripping#
A logged-in client goes only through the narrow my* actions. All of them take the
identity solely from the JWT, have no userId or filters parameter and without an
identity return empty without a DB query — so a record without an account
(user_id IS NULL, a job without a customer) never matches "user 0". A foreign, missing
and deleted record all give the same answer as an empty one.
| Action | Input | Ownership | Extra keys |
|---|---|---|---|
WorkList:myList |
offset, limit, computeTotalCount |
WorkListManager::findForUser(): user → their customers → jobs |
workItemCounts (missing key = 0) |
WorkItem:myItems |
workListId, offset, limit |
findForOwnedList() |
sheets in _references (+ invoiceNumber), unreadComments |
WorkItem:myDetail |
workItemId |
findOwnedItemRow(), only then find() |
— |
WorkItem:myComments |
workItemId, offset, limit |
findClientComments(); also marks the company comments as read |
— |
WorkItem:myCommentAdd |
workItemId, text |
addClientComment() |
commentId, approved (always true on success), retryAfter |
Reservation:myList |
offset, limit, computeTotalCount |
ReservationManager::findForUser() by user_id |
slots (date and time by timeSlotId) |
Reservation:myCancel |
id |
user_id in the WHERE of the conditional UPDATE (cancelOwn()) |
— |
myItems returns the work items of the whole job, not of one sheet. Paging is cut only
in PHP over the verified, sorted selection (the invoice order) and totalCount is the
count before the cut.
A new entity field reaches the client on its own
extractAll() and extractWithReferences() emit every ORM field and the module's
shared entities have no #[FieldAccess]. Internal fields are therefore stripped from
the finished payload by a constant in the presenter. Whoever adds an internal column
to the entity and does not extend the constant shows it to the client on the website.
| Where | Stripped fields |
|---|---|
WorkListPresenter::MY_LIST_STRIP_FIELDS |
note, invoicerCustomerId, invoicerCustomerContactId, invoicerInvoiceTypeId |
WorkItemPresenter::MY_DETAIL_STRIP_FIELDS |
note (a safeguard, the column does not exist yet) |
WorkItemPresenter::MY_SHEET_STRIP_FIELDS |
bill, invoicerInvoiceId, stateBeforeClosing |
WorkItemPresenter::MY_COMMENT_STRIP_FIELDS |
ip |
ReservationPresenter::MY_LIST_STRIP_FIELDS |
cancelToken, insertIp, gdprConsentDatetime |
CompanyPresenter::PRESENTED_STRIP_FIELDS |
invoicerSellerId, invoicerBillingLineId, invoicerInvoiceTypeId, defaultHourlyRate, defaultVatPercents |
ServicePresenter::internalActionPublicList() |
hourlyRate (inline in the method) |
The work item hourlyRate is deliberately not stripped (the client sees hours and price
and the rate is their ratio), nor is the comment userId (the client thread uses it to
recognise its own messages).
Procedure: a new field on an entity emitted by a client action
- Decide whether the client may see it. If not, add it to the presenter's strip constant from the table.
- Mark a field the entity does not have yet in the constant's docblock as
FUTURE-SAFEGUARD: `field`.tests/Manual/Company/c7_harness.phpchecks that every field of the constant is an ORM field or a safeguard marked this way; otherwise it reports it as a typo. - If the client needs a readable value instead of an internal id, put it under its own
key (pattern
invoiceNumberinstead ofinvoicerInvoiceId,WorkItemPresenter::withInvoiceNumbers()).
cancelToken is a capability, not data
Whoever knows the token cancels the reservation without logging in (cancelByToken
has a guest grant). On the "My reservations" page it would end up in the browser
history, in Referer and in caches; a logged-in client cancels through myCancel.
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 |
The generic Api CRUD actions (get, getAll, getBy, save, updateColumns,
delete, deleteAll) are granted to the admin role only. The registrated role has
only the my* actions of the module; a guest additionally has only the public read
actions (presented, *:publicList, availability) plus book and cancelByToken.
Manager methods without ownership (WorkItemManager::findForSheet(), findForSheets())
are therefore safe only on the admin path — do not call them from a client action.
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 |
| write guards of sheets, manually closing and reopening a sheet | app/UI/Api/Company/Models/Managers/WorkSheetManager.php |
| composing the invoice | app/UI/Api/Company/Models/Generators/CompanyInvoiceGenerator.php |
| slot availability and bookings | app/UI/Api/Company/Models/Managers/ReservationManager.php |
| calendar row writes, guards and the day lock | app/UI/Api/Company/Models/Managers/TimeSlotManager.php |
| work item lock and client ownership of work items | app/UI/Api/Company/Models/Managers/WorkItemManager.php |
| the company form (3× SYNC + dropzone) | app/UI/Admin/Company/Presenters/Traits/Forms/CompanyFormTrait.php |
| the time slot modal and calendar views | app/UI/Admin/Company/Components/SlotModalComponent/SlotModal.php, app/UI/Admin/Company/Presenters/SchedulePresenter.php |
| the work grid | app/UI/Admin/Company/Components/WorkGridComponent/ |
| saving the job, sheets and items from the admin | app/UI/Admin/Company/Presenters/Traits/Forms/WorkListFormTrait.php, WorkListSheetsFormTrait.php |
| the issue invoice modal | app/UI/Admin/Company/Presenters/Traits/Forms/WorkListInvoiceFormTrait.php |
client my* actions and field stripping in responses |
app/UI/Api/Company/Presenters/WorkItemPresenter.php, WorkListPresenter.php, ReservationPresenter.php |
| the components for a theme | app/UI/Front/Company/Components/ |
| the booking wizard and the older calendar | app/UI/Front/Company/Components/BookingWizardComponent/, BookingCalendarComponent/ |
Follow-up chapters: Invoicing · Company directory · Authentication and authorisation · CORS, rate limiting and auditing