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.

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 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

  1. 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.
  2. Add an input guard to guardInvoiceEntry() or guardSelectedSheets(). Invoicing and the preview previewInvoiceSheets() share them — otherwise the modal promises an invoice that invoicing refuses.
  3. Change the item selection rule only in WorkItemManager::findBillableForSheets(); the modal, step 3 and the check in step 6 all read it.
  4. Every failure after step 5 must return the sheets with revertClosing(), otherwise they stay locked in closing without 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

  1. Find the intent record of step 7 in the company channel log, and the number of the saved invoice if there is one.
  2. Check in Invoicer whether the document was created.
  3. It was not: repeat the action (the first attempt has already returned the sheets, or will).
  4. It was: do not repeat the action. In the DB set invoicer_invoice_id = <id> and state = '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()):

  1. The job is saved (WorkListManager::save()). If that fails, nothing else runs.
  2. persistSheetsAndItems() walks the POST field sheets[<id>] — a raw POST without any Nette validation. For an archived job it is skipped entirely.
  3. Each sheet, targeted: updateColumns() with only the changed name, bill, order.
  4. 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.
  5. 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:

  1. invoicePreview! (AJAX) receives the ticked sheets as sheetIds[] and fills the modal with a read-only preview. On failure no table is rendered, only the error — isSuccess() decides, not the content of sheets.
  2. The confirmation is a real form invoiceConfirmForm with a full navigation (no ajax), because issuing changes most of the page. The selected sheets travel as plain <input type="hidden" name="sheetIds[]"> and are read from the POST.
  3. 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.

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, _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:

  1. No row at the start of the range → a carrier is created.
  2. A free slot with the same range exists (an override window or a carrier left by a canceled reservation) → it is reused.
  3. 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:

  1. Preliminary availability check through Doctrine (outside the lock, only a quick rejection).
  2. acquireSourceLock(); on failure → DB_LOCKED with a "try again" message.
  3. The day is re-read with raw DBAL (TimeSlotManager::findDayRowsRaw()) and free time is recomputed — the identity map may hold a stale row.
  4. The carrier and the reservation are written over the same DBAL connection that holds the lock.
  5. 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, and SchedulePresenter reads them back via SlotModal::getViewContext(). Without that, renderDay() redraws a different day after the click.
  • No setRequired(). The form has several buttons; HTML5 required on 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. slotForm is built lazily in fill mode while the POST is handled; switching the mode afterwards does not change the form and the template reaches for a missing timeSlotId → 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:

  1. The earliest starting active reservation wins; the others are canceled without an e-mail (cancelInternal()) and their rows are absorbed by TimeSlotManager::absorbRow().
  2. 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.
  3. Names and notes are joined with ' + ', e-mail and phone stay the winner's.
  4. The state is confirmed only when all merged reservations were confirmed, otherwise pending.
  5. After the lock is released, the winner's client gets _CHANGED if 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

  1. Add a value constant to the switch (fillAction or timeAction) and the field to the add*Fields() of the matching mode — without setRequired().
  2. Route the write through the Admin manager → bridge → an Api narrow action that takes the day lock.
  3. In the handler call requirePrivilege(), hydrateContext() and the check that the slot and calendar belong to the company (requireSlotInCompany()/requireCalendarInCompany()).
  4. After the write reload the state and finish with finish() or failAndReopen(), not redirect().

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

  1. Write through TimeSlotManager::save()/delete(), or add a narrow method that wraps the work in withDayLocks([self::dayLockName(…)]).
  2. Build the lock name from the stored row (findRowRaw(), from another manager findLockContext()), not from parameters. Under the lock read the row again and check it is still on the locked day (pattern guardLockedDay()).
  3. If the write switches the day from default mode to override, materialize the default windows first (materializeDefaultWindows(), pattern openWindow()).
  4. After a raw UPDATE call bumpCacheTagsForClass(TimeSlot::class) — the generic cache bump from SaveTrait does not cover this path.
  5. 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.
  • calendarId from the URL is not trusted. Only values from the calendars key of the availability response 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 leadTimeHours are computed in PHP; the template gets the ready bookable flag. A tapped slot (from, to) is checked against starts computed on the server; an invalid pair does not open the modal.
  • Only starts are drawn. Public availability carries no identity of foreign reservations; busy/confirmed/pending blocks 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 calls createComponent*() first, and render() 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 slotLengthMinutes up to maxTo; the (from, to) pair is verified again by book() 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/…) through normalizeResultEnvelope(), not through failSoftRead() — success: false (taken, limit) is the meaning of the call. The form translates code with the ERROR_MESSAGE_KEYS map; 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 own myList result and takes the work items from myItems. 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 with bcadd, not in the template. A work item without its sheet in _references is 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> with data-grid-confirm, not window.confirm.

Procedure: a new client section component

  1. Read data only through a my* action (identity from the JWT) and send no userId.
  2. Render a foreign, a missing and an empty result the same way; do not tell them apart even by a message.
  3. Memoize the data a form needs in the component and read it from createComponent*() as well as from the template preparation.
  4. Write through a POST form with addProtection(); read the result from the flat envelope and build the message from code.

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

  1. Decide whether the client may see it. If not, add it to the presenter's strip constant from the table.
  2. Mark a field the entity does not have yet in the constant's docblock as FUTURE-SAFEGUARD: `field`. tests/Manual/Company/c7_harness.php checks that every field of the constant is an ORM field or a safeguard marked this way; otherwise it reports it as a typo.
  3. If the client needs a readable value instead of an internal id, put it under its own key (pattern invoiceNumber instead of invoicerInvoiceId, 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

Cookie settings