CoolCMS API

ReservationManager extends Manager
in package
uses SourceLockTransactionTrait, BlacklistGuardTrait

Reservation booking flow, state transitions and reservation e-mails.

Interval model: book() takes date + timeFrom + timeTo and accepts any range from < to that fits entirely into one free interval of the day (windows minus reservations minus blocks, see TimeSlotManager). slot_length_minutes is only the offer step for the UI; the server does not align to the grid. A booking materializes a carrier row (public = 0) with the exact range, so a 9:00-17:00 window with a 10:00-10:45 reservation becomes free time 9:00-10:00 and 10:45-17:00 without splitting any row.

Concurrency: writes are serialized by an advisory lock per calendar and day (TimeSlotManager::dayLockName()). A lock keyed by window start would not serialize overlapping ranges with different starts; both would pass validation against the old state and the calendar would get an overlap. Different days, calendars and companies do not wait for each other. Under the lock the day is read with raw DBAL (TimeSlotManager::findDayRowsRaw()), not Doctrine, because the identity map may hold a stale row and GET_LOCK protects only the write, not data freshness; free time is recomputed (double-check). The lock and the writes must use the same DBAL connection (the same MySQL session), otherwise GET_LOCK protects nothing.

IMPORTANT: uq_company_reservation_active (time_slot_id, active_flag) blocks two active reservations on one carrier and uq_company_calendar_time_slot (calendar_id, date, time_from) catches only an identical start; an overlap of different starts is guarded only by the lock and the validation under it. A UniqueConstraintViolationException closes the EntityManager, so no ORM work may follow it.

Booking intentionally does not run in one wrapping transaction. The only broken intermediate state (a carrier without a reservation) is harmless: a public = 0 carrier without an active reservation behaves like the implicit window and the next booking reuses it. A wrapping transaction would nest with save() and risk a flush error closing the EntityManager midway.

IMPORTANT: do not call inside a foreign open transaction; the reservation would become durable only with the foreign commit while the e-mails have already been sent.

The cancel token (64 hex chars) is single use: one conditional UPDATE whose WHERE carries the token and the allowed states, so two concurrent clicks on one link end with affectedRows 1 and 0 and the second gets "link no longer valid". The same message is returned for an unknown token so the answer does not reveal whether it existed.

Tags
phpstan-import-type

TDay from TimeSlotManager

TCalendarRow from TimeSlotManager

extends

Table of Contents

Constants

EMAIL_CANCELLED  : string = 'CMP_RESERVATION_CANCELLED'
EMAIL_CHANGED  : string = 'CMP_RESERVATION_CHANGED'
Sent to the client when an admin changes the term (carries the new and the original term).
EMAIL_CONFIRMED  : string = 'CMP_RESERVATION_CONFIRMED'
EMAIL_RECEIVED  : string = 'CMP_RESERVATION_RECEIVED'
EMAIL_REQUESTED  : string = 'CMP_RESERVATION_REQUESTED'
RESERVATION_PAGE_SLUG  : string = 'terminy'
Slug of the public reservation page (fixed id 300).
DELETED_IDS_META  : string = '__deletedIds'
Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.
LOCK_MAX_ATTEMPTS  : int = 3
LOCK_SCALE  : int = 3
LOCK_TIMEOUT  : int = \App\UI\Api\Company\Models\Managers\TimeSlotMan...
Seconds to wait for the day advisory lock; longer makes the client stare at a spinner.
RESERVATIONS_TABLE  : string = 'cms_mod_company_reservations'
Own table of the module; raw DBAL is allowed only on it. Calendar rows are read raw in {@see TimeSlotManager::findDayRowsRaw()}, so only their own manager knows that table.
TOKEN_BYTES  : int = 32
Bytes of cancel token entropy; 32 B = 64 hex chars, exactly the `char(64)` column.

Properties

$service  : Service<string|int, TEntity>
Manager service; subclasses narrow the type (`@property` in the Api/Admin/Front manager).
$appContext  : AppContext
$assetFormatRegistry  : AssetFormatRegistry|null
Format processor registry for file uploads, used by {@see \App\Core\Traits\Api\Models\Managers\AssetProcessingTrait}.
$blacklistManager  : BlacklistManager|null
$cacheTagInvalidator  : CacheTagInvalidator|null
Post-commit invalidator of the wire-payload query cache (tag bump per entity class).
$defaultSkipFields  : array<string|int, string>
Server-controlled columns; generic `save()` must not overwrite them on UPDATE with payload values.
$internalCallContext  : InternalCallContext|null
Tells whether the call is an internal bridge call; the single source for reading uploaded items with `path` ({@see readUploadedItem()}).
$languageContext  : LanguageContext|null
$nonInvalidatingColumns  : array<string|int, string>
Columns whose update does not invalidate the cache: view counters (`registerEvent()` bumps the counter on every pageview). Without this every view would flush the query cache of the whole class; counters are therefore stale up to the TTL. Override per manager by redefining the property.
$paramsConfig  : Config|null
Neon parameters (`%params%`: server paths and directories), set by the decorator (`decorators.neon`) like `setAppContext()`.
$presenterContext  : PresenterContext
$settingContext  : SettingContext
$totalCount  : int|null
$useTransactionForDelete  : bool
$useTransactionForSave  : bool
$calendarManager  : CalendarManager
$companyManager  : CompanyManager
$em  : EntityManagerInterface
$emailManager  : EmailManager
$httpRequest  : IRequest
$rejectedUploads  : array<int, array{name: string, reason: string}>
New uploaded items that the Api did not save during the `save()` that is running right now (type, dimensions, unreadable, …). Filled by {@see recordRejectedUpload()} and returned in `saveInfo.rejectedUploads`.
$siteBaseUrl  : SiteBaseUrl|null
$timeSlotManager  : TimeSlotManager

Methods

__construct()  : mixed
book()  : Result
Books a term as a client. Called from {@see \App\UI\Api\Company\Presenters\ReservationPresenter}; the presenter handles rate limit and IP and passes the JWT identity in as a parameter, the manager does not read the JWT.
bookByAdmin()  : Result
Writes a reservation as an admin from the slot edit modal ("fill in reservation"). Admin only (`:Api:Company:Reservation:bookByAdmin`).
cancel()  : Result
Cancels a reservation as an admin. Both `pending` and `confirmed` can be canceled; the token is nulled so the e-mail link does not outlive the reservation.
cancelByToken()  : Result
Cancels by the link from the e-mail (token is single use). The token is nulled after canceling, so a second click on the same link ends like a forged token.
cancelOwn()  : Result
Cancels the logged-in client's own reservation.
confirm()  : Result
Confirms a reservation as an admin (a human confirms, not the server). The only allowed input state is `pending`; anything else is `VALIDATION_FAILED`, not a silent no-op.
delete()  : Result
Deletes the entity or entities by id. Optional hooks (opt-in via `method_exists`, like {@see SaveTrait}) allow cleanup of derived or denormalized state during the delete:
deleteAll()  : Result
find()  : T|null
findAll()  : T> : list>)
findBy()  : T|null
findForUser()  : Reservation>, totalCount: int, slots: array}
Returns the logged-in client's reservations, newest first ("my terms").
findRelatedIds()  : array<int, int>|null
Returns the ids of target entities that already belong to the record `$parentId` through the relation collection `$property` (for example `('assetRels', 'asset')`, `('fileRels', 'file')`). Reads straight from the database, not through the Api read path (no `UoW::clear()` in the middle of save hooks).
getAppContext()  : AppContext
getDefaultSkipFields()  : array<string|int, string>
Returns keys that the generic `service->save()` must not copy onto the managed entity.
getEntityClass()  : TEntity>
Returns the root Doctrine entity class managed by this manager.
getExternalMetaAllowlist()  : array<string|int, string>
Returns the meta keys allowed through the external HTTP path ({@see \App\Core\Traits\Api\Presenters\SaveTrait::actionSave()}).
getExternalOwnerData()  : array<string, mixed>
Returns `data` keys that fill the owner from the server when an external save creates a record.
getExternalRemoteIpData()  : array<string, mixed>
Returns `data` keys that fill the caller's IP from the server when an external save creates a record; sibling of {@see getExternalOwnerData()}.
getExternalUpdateColumnsAllowlist()  : array<string|int, string>
Returns the columns the external HTTP path may change through {@see \App\Core\Traits\Api\Presenters\SaveTrait::actionUpdateColumns()}.
getGraphDataKeys()  : array<string|int, string>
Returns the keys an external client may send nested in `data`.
getLanguageContext()  : LanguageContext|null
getPresenterContext()  : PresenterContext
getServiceClass()  : class-string
Returns the fully qualified service class name, the identity for the per-service rollout allowlist of the wire-payload query cache ({@see \App\Core\Cache\Config::isServiceEnabled()}).
getSettingContext()  : SettingContext
getSettings()  : array<int, array<string, mixed>>
getTotalCount()  : int|null
initializeAllContexts()  : void
mergeSlots()  : Result
Merges adjacent terms (reservations, blocks, free time, closed time) into one resulting segment.
registerEvent()  : Result
save()  : Result
setAppContext()  : $this
setAssetFormatRegistry()  : static
setCacheTagInvalidator()  : static
setInternalCallContext()  : static
setLanguageContext()  : $this
setParamsConfig()  : static
setPresenterContext()  : $this
setSettingContext()  : $this
shortenTerm()  : Result
Shortens a reservation term from the admin (slot modal) and e-mails the client about the change ({@see EMAIL_CHANGED}, same pattern as {@see mergeSlots()}).
splitTerm()  : Result
Splits a reservation term (the reservation stays on the first part, {@see TimeSlotManager::split()}) and e-mails the client about the shortened term; same pattern as {@see shortenTerm()}.
updateAllRelations()  : Result
updateByAdmin()  : Result
Edits an existing reservation as an admin from the modal ("save reservation" in reservation mode). Admin only (`:Api:Company:Reservation:updateByAdmin`).
updateColumn()  : Result
updateColumns()  : Result
updateRelations()  : Result
acquireSourceLock()  : bool
Calls `GET_LOCK(name, timeout)` and returns true only on a definite 1 (0 = timed out or busy, null = error). `$timeout` is in seconds; 0 is non-blocking (try-lock, used by the expirer).
beginRejectedUploads()  : array<int, array{name: string, reason: string}>
Starts a save ({@see save()}): the list of rejected items belongs to this save only; the outer one (a nested save of the same manager) is returned to the caller and restored after the save through {@see finishRejectedUploads()}.
bumpCacheTags()  : void
Bumps cache tags for the entity after commit: its class, the owned-cascade classes from metadata and the optional `additionalCacheTags()` of the manager.
bumpCacheTagsExplicit()  : void
Bumps the given classes explicitly (raw-DBAL paths with a cross-entity effect).
bumpCacheTagsForClass()  : void
Bumps cache tags by class name when there is no instance (bulk delete, `updateColumns()`, raw DBAL, tree operations).
classCacheTags()  : array<string|int, string>
Returns extra tags for a bump that has no entity, typically tree operations ({@see \App\Core\Traits\Api\Models\Managers\TreeTrait::treeTransaction()}) that rewrite `lft`/`rgt` on many rows.
connection()  : Connection
finishRejectedUploads()  : void
Ends a save: whatever {@see recordRejectedUpload()} wrote during it is added to the data of a successful result as `saveInfo.rejectedUploads` (the Api presenter passes it on to the caller, the admin builds a warning from it). A rejected item does not fail the save; the accepted files stay.
getDeletedId()  : int|null
Returns the id `$entity` had before the delete, from the snapshot in `$meta` that {@see delete()} hands to every hook. Null for an entity the snapshot does not know (not loaded by this delete, or without a simple id).
getService()  : Service<string|int, TEntity>
Returns the service backing the manager traits.
lockLogChannel()  : string
readUploadedItem()  : string|null
Reads the content of an uploaded item (`{name, data}` base64 or `{name, path}` from the dropzone) through the single reader {@see UploadedItem}.
recordRejectedUpload()  : void
Records a new uploaded item that the save skipped ({@see UploadRejection} `REASON_*`).
releaseSourceLock()  : void
Releases the advisory lock.
runInSourceTransaction()  : T
Runs a closure in one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / errno 1213).
applyMergeSlots()  : Result
Runs the guards, decides the resulting type and writes; already under the day lock.
blacklistRefusal()  : Result|null
buildCancelLink()  : string
Builds the absolute cancel link for a term.
buildEmailParams()  : array<string, string>
Builds the single parameter set for all four `CMP_RESERVATION_*` templates, so a seed need not care which template knows which variable and adding a field to an e-mail is a text change, not a code change.
buildLockName()  : string
Returns `company_calendar_slot_{calendarId}_{date}`, the lock name for one day of one calendar (see the class docblock).
bumpDeleteCacheTags()  : void
Post-commit bump after a delete. With entities it goes through {@see \App\Core\Base\BaseManager::bumpCacheTags()}, which also collects `additionalCacheTags()` (Comment → CommentThread); without them it falls back to bumping the manager class itself, the original behavior.
bumpStateCacheTags()  : void
Invalidates the cache tags of both `Reservation` and `TimeSlot`: a state transition also changes the calendar (slot occupancy derives from active reservations).
cancelInternal()  : Result
Cancels like {@see cancel()} but without an e-mail; absorbs the defeated reservation inside {@see mergeSlots()}. The client gets the e-mail about the merged term another way; sending "canceled" first and then the new term would be confusing and untrue, because the term was absorbed, not dropped.
captureDeletedIds()  : WeakMap<object, int>
Keys the ids by the loaded entity objects, not by the input ids: an input id that loaded nothing must not shift the pairing. A weak map, so the snapshot never keeps an entity alive.
checkAdminState()  : Result|null
Admin actions may work only with the two active states. `Cancelled` is excluded: canceling has its own path ({@see cancel()}) with its own e-mail and token reset, and the generic write would cancel without both.
checkClientFields()  : Result|null
Shared validation of client fields for `book()`, `bookByAdmin()` and `updateByAdmin()`, so messages and limits cannot diverge between paths. Limits match the columns (`name` 100, `email` 255, `phone` 30).
checkCompanyBookable()  : Result|null
Company guards. Each reason has its own message; a bare "not possible" is useless on a form.
checkRangeAvailableForAdmin()  : Result|null
Like {@see checkRangeFree()} but without requiring a window ("create reservation right away" outside the offered windows). Only a real conflict with an existing reservation or block is guarded, also outside windows (`busyOutside`): two reservations outside the offer could otherwise overlap, because `rangeHitsBusy()` reads only `busy` (occupancy trimmed to windows, see {@see TimeSlotManager::computeDay()}) and would miss an outside-window collision.
checkRangeFree()  : Result|null
Checks that the range fits entirely into one free interval of the day. Returns null when it does, otherwise a ready message; "occupied" and "not offered" are two different situations for the client.
columnsInvalidateCache()  : bool
createReservation()  : Result
Booking flow body under the advisory lock: recomputes free time, materializes the carrier and inserts the reservation.
deleteBatch()  : Result
Bulk delete of a list of ids as one transaction. `Service::deleteAll()` flushes per entity; in autocommit the rows deleted before a database failure would stay deleted while the caller got either an escaped `EntityManagerClosed` or an "OK" with an error text. Here the service stops at the technical failure, this wrapper rolls the whole batch back and returns the failure Result with `deletedCount = 0` and `rolledBack = true`: nothing of the batch is durable and nothing is attempted over the closed EntityManager. A domain partial success (missing ids) keeps the existing contract and is committed.
ensureMergedWindow()  : Result
Ensures that after merging `[from, to)` is covered by exactly one window: either a new one is created (and windows inside the selection disappear), or the range is covered by windows that stay outside the selection and the inner ones are only absorbed.
formatCzechDate()  : string
isAssetPathAllowed()  : bool
Checks only entities composing {@see AssetPathTrait}; the trait is the allowlist of "relative file path" columns, other `assetPath`-like fields are untouched.
ladd()  : numeric-string
lcmp()  : int
ldec()  : numeric-string
ldiv()  : numeric-string
Divides with half-up rounding to scale 3 (bcmath itself truncates; same rounding as {@see \App\UI\Api\Eshop\Models\Helpers\OrderCalculator::round3()}).
lInt()  : int
lmul()  : numeric-string
ln()  : numeric-string
loadEntitiesForCacheTags()  : array<int, object>
Loads the entities being deleted only to find out their `additionalCacheTags()`. Fail-soft: what cannot be loaded is simply missing and the bump falls back to the class; it must never stop the delete.
lStr()  : string
lStrN()  : string|null
lsub()  : numeric-string
materializeSlot()  : int|null
Creates the reservation carrier. IMPORTANT: `public = 0` is essential; with 1 the first reservation would flip the day into day-override mode and the other windows would vanish from the calendar (see the {@see TimeSlotManager} docblock).
mergeIntoBlocked()  : Result
mergeIntoFree()  : Result
mergeIntoReservation()  : Result
notifyTermChanged()  : void
Sends the client the term-change e-mail (`EMAIL_CHANGED`). The new term is read from the reservation's current carrier, the original one is passed by the caller. Only live (pending or confirmed) reservations get it.
notifyTermResized()  : void
Sends the term-change e-mail after shortening or splitting carrier `$timeSlotId` (best-effort).
nullIfEmpty()  : string|null
parseMinutes()  : int|null
Converts `H:i` / `H:i:s` to minutes since midnight; anything else is null (and the caller rejects it).
previousTermIfChanged()  : array{oldTimeFrom: string, oldTimeTo: string}|null
Returns the previous term from a range-change result, only when the time really changed (otherwise a change e-mail makes no sense). Contract: `oldTimeFrom`/`oldTimeTo` and `timeFrom`/`timeTo` (`H:i`).
rangeCoveredByRanges()  : bool
Tells whether `[from, to)` lies entirely inside the union of `$ranges` (decides whether a merged reservation needs its own window, see {@see applyMergeSlots()}).
resolveCarrierSlot()  : int|null
Returns the row the reservation will hang on (under the lock). Three paths:
resolveCompanyName()  : string
resolveLangId()  : int
In CLI (harness, cron) the language context is missing; Czech is the installation's default language.
rollbackBatch()  : void
saveWithHooks()  : Result
Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.
sendBookingEmails()  : void
Sends the pair of e-mails after a reservation is received: confirmation to the client, request to the company.
sendEmail()  : void
Sends an e-mail best-effort: a missing template or unavailable SMTP must not break an already saved reservation.
sendStateEmail()  : void
Sends the state-change e-mail to the client; the slot and the company are loaded from the reservation.
usesAssetPathTrait()  : bool

Constants

EMAIL_CHANGED

Sent to the client when an admin changes the term (carries the new and the original term).

public string EMAIL_CHANGED = 'CMP_RESERVATION_CHANGED'

RESERVATION_PAGE_SLUG

Slug of the public reservation page (fixed id 300).

public string RESERVATION_PAGE_SLUG = 'terminy'

A constant instead of link() because the route may not exist yet and LinkGenerator would throw. TODO: align the value with the real page slug (and add the language variant).

DELETED_IDS_META

Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.

protected string DELETED_IDS_META = '__deletedIds'

Read it only through getDeletedId().

LOCK_TIMEOUT

Seconds to wait for the day advisory lock; longer makes the client stare at a spinner.

private int LOCK_TIMEOUT = \App\UI\Api\Company\Models\Managers\TimeSlotManager::DAY_LOCK_TIMEOUT

IMPORTANT: the value is not duplicated. TimeSlotManager holds the single definition for both write paths (client booking and admin write), so the wait over one day is the same for both.

RESERVATIONS_TABLE

Own table of the module; raw DBAL is allowed only on it. Calendar rows are read raw in {@see TimeSlotManager::findDayRowsRaw()}, so only their own manager knows that table.

private string RESERVATIONS_TABLE = 'cms_mod_company_reservations'

TOKEN_BYTES

Bytes of cancel token entropy; 32 B = 64 hex chars, exactly the `char(64)` column.

private int TOKEN_BYTES = 32

Properties

$service

Manager service; subclasses narrow the type (`@property` in the Api/Admin/Front manager).

public Service<string|int, TEntity> $service

$assetFormatRegistry

Format processor registry for file uploads, used by {@see \App\Core\Traits\Api\Models\Managers\AssetProcessingTrait}.

protected AssetFormatRegistry|null $assetFormatRegistry = null

Set by a decorator (decorators.neon) like setParamsConfig(): more than ten managers process assets and each has its own constructor, so constructor injection would touch all of them.

$cacheTagInvalidator

Post-commit invalidator of the wire-payload query cache (tag bump per entity class).

protected CacheTagInvalidator|null $cacheTagInvalidator = null

Set by the decorator (decorators.neon) like setParamsConfig(). Nullable and fail-open: without wiring (or without Redis) a bump is a no-op.

$defaultSkipFields

Server-controlled columns; generic `save()` must not overwrite them on UPDATE with payload values.

protected array<string|int, string> $defaultSkipFields = ['state', 'cancelToken', 'gdprConsentDatetime', 'insertIp', 'userId']

#[ExternalReadonly] covers the external HTTP path, this covers the internal bridge (the admin form edits the note, not the state or the token).

$internalCallContext

Tells whether the call is an internal bridge call; the single source for reading uploaded items with `path` ({@see readUploadedItem()}).

protected InternalCallContext|null $internalCallContext = null

Only ApiBridge::internalCall() sets it, never the payload and not the RBAC guard, so the emergency apiRbac.internalMode: off cannot disable it. Set by a decorator like setAssetFormatRegistry(). Without it (direct manager call from cron or a test) the call counts as external.

$nonInvalidatingColumns

Columns whose update does not invalidate the cache: view counters (`registerEvent()` bumps the counter on every pageview). Without this every view would flush the query cache of the whole class; counters are therefore stale up to the TTL. Override per manager by redefining the property.

protected array<string|int, string> $nonInvalidatingColumns = ['views', 'viewsList', 'viewsDetail']

Covers all view counters across modules: views (Blog Article), viewsList (list impression of Advert/Message) and viewsDetail (detail view of Advert/Message, registerEvent() in the *DetailTraits). IMPORTANT: viewsDetail must stay in the list, otherwise every open of an advert/post detail would invalidate all lists of the class.

$paramsConfig

Neon parameters (`%params%`: server paths and directories), set by the decorator (`decorators.neon`) like `setAppContext()`.

protected Config|null $paramsConfig = null

Write logic (asset directories) reads paths from here, not from getSettings() or the setting context, which may be filled by the client without server paths. The property cannot be named $config: it collides with the promoted $config in some managers.

$useTransactionForDelete

protected bool $useTransactionForDelete = true

$useTransactionForSave

protected bool $useTransactionForSave = true

$rejectedUploads

New uploaded items that the Api did not save during the `save()` that is running right now (type, dimensions, unreadable, …). Filled by {@see recordRejectedUpload()} and returned in `saveInfo.rejectedUploads`.

private array<int, array{name: string, reason: string}> $rejectedUploads = []

Methods

__construct()

public __construct(ReservationService $service, EntityManagerInterface $em, TimeSlotManager $timeSlotManager, CompanyManager $companyManager, CalendarManager $calendarManager, EmailManager $emailManager, IRequest $httpRequest[, SiteBaseUrl|null $siteBaseUrl = null ][, BlacklistManager|null $blacklistManager = null ]) : mixed
Parameters
$service : ReservationService
$em : EntityManagerInterface
$timeSlotManager : TimeSlotManager
$companyManager : CompanyManager
$calendarManager : CalendarManager
$emailManager : EmailManager
$httpRequest : IRequest
$siteBaseUrl : SiteBaseUrl|null = null
$blacklistManager : BlacklistManager|null = null

book()

Books a term as a client. Called from {@see \App\UI\Api\Company\Presenters\ReservationPresenter}; the presenter handles rate limit and IP and passes the JWT identity in as a parameter, the manager does not read the JWT.

public book(int $companyId, string $date, string $timeFrom, string $timeTo, string $name, string $email[, string|null $phone = null ][, string|null $note = null ][, bool $gdprConsent = false ][, int|null $userId = null ][, string|null $ip = null ][, int|null $langId = null ][, int|null $calendarId = null ][, string|null $subject = null ]) : Result
Parameters
$companyId : int
$date : string

Y-m-d

$timeFrom : string

H:i start of the range

$timeTo : string

H:i end of the range, exclusive (11:00 means the term ends at 11:00 and free time starts there again); the range need not align to the grid but must fit entirely into one free interval of the day

$name : string
$email : string
$phone : string|null = null
$note : string|null = null
$gdprConsent : bool = false
$userId : int|null = null

id of the logged-in client, or null (a guest may book)

$ip : string|null = null
$langId : int|null = null
$calendarId : int|null = null

calendar choice; null means the company default (CalendarManager::resolveDefault()); optional and last so positional calls stay valid

$subject : string|null = null

optional reservation subject entered by the client in the modal; last for the same reason as $calendarId

Return values
Result —

success data array{reservationId: int, timeSlotId: int, state: string, timeFrom: string, timeTo: string}; state is pending, or confirmed right away when the chosen calendar has auto_confirm on

bookByAdmin()

Writes a reservation as an admin from the slot edit modal ("fill in reservation"). Admin only (`:Api:Company:Reservation:bookByAdmin`).

public bookByAdmin(int $calendarId, string $date, string $timeFrom, string $timeTo, string $name, string $email, string|null $phone, string|null $note, ReservationState $initialState, bool $notifyClient) : Result

Shares the whole core with book() (createReservation() to resolveCarrierSlot() to materializeSlot()), including the day lock and the double-check under it. It differs only in what separates "client orders" from "admin writes what was agreed with the client":

  • the calendar is required; companyId is derived from it (the calendar is the authoritative owner), so a mismatched pair cannot be sent;
  • no gdprConsent: no web consent is collected, so none is required and no stamp is faked (see $recordGdprConsent in createReservation());
  • no lead time and past trimming, so today and the nearest hours can be managed; overlap with a reservation or block and closed_day are still rejected;
  • a range outside the offered windows is not rejected: a window is first opened there with TimeSlotManager::openWindow() (see $enforceWindow in createReservation());
  • no presented/bookingEnabled guards (checkCompanyBookable() is the gate for the public website): disabled public booking must not stop an admin from writing a term agreed by phone. The company must still exist, be active and not deleted;
  • the admin chooses the state (pending/confirmed): createReservation() always builds Pending, confirmation is a conditional UPDATE under the same lock (no second locking via confirm());
  • the e-mail is optional and off by default: the typical reason for a manual write is a phone agreement and an automatic e-mail would only confuse the client. The params.1.mail.blocked safety applies anyway in sendEmail().
Parameters
$calendarId : int
$date : string

Y-m-d

$timeFrom : string

H:i

$timeTo : string

H:i, exclusive end

$name : string
$email : string
$phone : string|null
$note : string|null
$initialState : ReservationState

only Pending/Confirmed (cancel() handles canceling)

$notifyClient : bool

whether to send the standard pair of e-mails to the client and the company

Return values
Result —

success data array{reservationId: int, timeSlotId: int, state: string, timeFrom: string, timeTo: string}

cancel()

Cancels a reservation as an admin. Both `pending` and `confirmed` can be canceled; the token is nulled so the e-mail link does not outlive the reservation.

public cancel(int $id) : Result
Parameters
$id : int
Return values
Result

cancelByToken()

Cancels by the link from the e-mail (token is single use). The token is nulled after canceling, so a second click on the same link ends like a forged token.

public cancelByToken(string $token) : Result

The answer is intentionally the same for "token does not exist", "token already used" and "reservation not active": a different message would make the link an oracle for reservation existence.

Parameters
$token : string
Return values
Result

cancelOwn()

Cancels the logged-in client's own reservation.

public cancelOwn(int $reservationId, int $userId) : Result

IMPORTANT: ownership is part of the WHERE, not a separate step. One conditional UPDATE (reservation_id x user_id x allowed states x deleted = 0) is gate and write, so there is no check-then-write window to slip in a foreign id. Same pattern as cancelByToken(), with the JWT identity instead of the token.

Two concurrent clicks end with affected 1 and 0, so the cancel e-mail goes out exactly once.

The answer is intentionally the same for "not mine", "does not exist" and "already canceled": a different message would make the action an oracle for the existence of foreign reservations.

IMPORTANT: the reservation is loaded only after a successful UPDATE (just for the e-mail). Loading it before would put foreign personal data in hand before deciding that the caller has no right to it, and returning it by mistake would be enough to leak it.

Parameters
$reservationId : int
$userId : int

identity from the JWT; <= 0 means no identity and fails without a query

Return values
Result

confirm()

Confirms a reservation as an admin (a human confirms, not the server). The only allowed input state is `pending`; anything else is `VALIDATION_FAILED`, not a silent no-op.

public confirm(int $id) : Result
Parameters
$id : int
Return values
Result

delete()

Deletes the entity or entities by id. Optional hooks (opt-in via `method_exists`, like {@see SaveTrait}) allow cleanup of derived or denormalized state during the delete:

public delete(int|array<string|int, mixed> $id[, array<string, mixed> $meta = [] ]) : Result
  • beforeDelete(array $entities, array $meta): ?Result runs before the delete (validation or capturing context while the rows exist). A failed Result means rollback and return. Its data flows into afterDelete under $meta['__beforeDelete'].
  • afterDelete(array $entities, Result $result, array $meta): ?Result runs after the delete, still in the transaction. It is only for recomputing derived state from the remaining rows (toped_until, member_until, county), idempotent and reversible. A failure rolls back the delete itself (atomicity). It is also the place for the mandatory delete of dependents through a foreign manager (comment, rating and message threads): an owner still managed in the unit of work references them, so they must go after its row, never before it in beforeDelete (collect their ids there). Additive or irreversible effects (credits, e-mails, invoices) do not belong here, only into completedDelete.
  • completedDelete(array $entities, Result $result, array $meta): void runs best effort after the commit, outside the transaction; an exception is only logged (the delete stays).
  • completedAssetCleanup(array $entities, Result $result, array $meta): void runs best effort after the commit and releases the Asset rows and files the deleted entity owned. The body is in AssetProcessingTrait; opt-in is ownedAssetIds(object $entity): array on the manager, without it the hook does not run.

IMPORTANT: the cleanup has its own name instead of completedDelete() because many Api managers define completedDelete() themselves and a class method overrides a trait method without a warning, so the cleanup would silently be missing in them.

IMPORTANT: a soft delete (deleted = 1) goes through save(), not here; assets must not be released there because the record can be restored.

$entities are the entities loaded before the delete (after the DELETE the row is gone, but the PHP object keeps the data, such as FKs for the recomputation).

IMPORTANT: the id is the exception. Doctrine nulls the id of a removed instance on flush(), so a hook must not rely on getId() once the delete ran (today only the read UoW::clear() in the service find() hands the preloaded entities over detached and keeps their id). Every hook therefore gets the ids captured before beforeDelete under the reserved key DELETED_IDS_META, keyed by the loaded entity object (an input id that loaded nothing has no entry); read it through getDeletedId(). The key is always set here, so a caller-supplied value never survives.

Without hooks it behaves exactly as before (no preloading, no transaction). NOTE: deleteAll() (bulk) and the tree deleteNode take another path and do not trigger these hooks.

Parameters
$id : int|array<string|int, mixed>
$meta : array<string, mixed> = []
Return values
Result

deleteAll()

public deleteAll([array<string|int, mixed> $filters = [] ]) : Result
Parameters
$filters : array<string|int, mixed> = []
Return values
Result

find()

public find(int $id[, string|array<string|int, mixed> $includes = [] ]) : T|null
Parameters
$id : int
$includes : string|array<string|int, mixed> = []
Return values
T|null

findAll()

public findAll([array<string|int, mixed>|null $includes = [] ][, array<string|int, mixed>|null $filters = [] ][, array<string|int, mixed>|null $orderBys = null ][, int|null $offset = null ][, int|null $limit = null ][, array<string|int, mixed>|null $columns = null ][, bool $computeTotalCount = false ]) : T> : list>)
Parameters
$includes : array<string|int, mixed>|null = []
$filters : array<string|int, mixed>|null = []
$orderBys : array<string|int, mixed>|null = null
$offset : int|null = null
$limit : int|null = null
$columns : array<string|int, mixed>|null = null
$computeTotalCount : bool = false
Return values
T> : list>) —

entities, or raw rows when $columns is set

findBy()

public findBy([array<string|int, mixed>|null $filters = [] ][, string|array<string|int, mixed> $includes = [] ]) : T|null
Parameters
$filters : array<string|int, mixed>|null = []
$includes : string|array<string|int, mixed> = []
Return values
T|null

findForUser()

Returns the logged-in client's reservations, newest first ("my terms").

public findForUser(int $userId[, int|null $offset = null ][, int|null $limit = null ]) : Reservation>, totalCount: int, slots: array}

IMPORTANT: $userId is the identity from the JWT set by the presenter, never a caller's claim. The manager does not touch the JWT but the contract holds (as for book()).

The server applies the userId filter and callers pass no filters of their own, so there is nothing to widen. $userId <= 0 returns empty without a database query: a guest's reservation has user_id IS NULL and must not land in anyone's mailbox.

Dates and ranges are fetched from slots in one query via TimeSlotManager; a reservation carries only the scalar time_slot_id and the foreign table belongs to its manager.

Parameters
$userId : int
$offset : int|null = null
$limit : int|null = null
Return values
Reservation>, totalCount: int, slots: array}

findRelatedIds()

Returns the ids of target entities that already belong to the record `$parentId` through the relation collection `$property` (for example `('assetRels', 'asset')`, `('fileRels', 'file')`). Reads straight from the database, not through the Api read path (no `UoW::clear()` in the middle of save hooks).

public findRelatedIds(int $parentId, string $property, string $targetAssociation) : array<int, int>|null
Parameters
$parentId : int
$property : string
$targetAssociation : string
Return values
array<int, int>|null —

null when it cannot be determined (collection mapping missing / database error)

getDefaultSkipFields()

Returns keys that the generic `service->save()` must not copy onto the managed entity.

public getDefaultSkipFields() : array<string|int, string>

The manager's own afterSave()/beforeSave() sync trait handles them through $meta, not through the object graph. Backed by an optional protected per-manager property. The write Api endpoint (SaveTrait::internalActionSave()) also uses this accessor to strip the same keys from $data before hydration, because defaultSkipFields alone does not protect hydration.

Return values
array<string|int, string>

getEntityClass()

Returns the root Doctrine entity class managed by this manager.

public getEntityClass() : TEntity>

The write Api endpoint (SaveTrait) takes it from here instead of the request, so a client cannot choose the instantiated PHP class.

Return values
TEntity>

getExternalMetaAllowlist()

Returns the meta keys allowed through the external HTTP path ({@see \App\Core\Traits\Api\Presenters\SaveTrait::actionSave()}).

public getExternalMetaAllowlist() : array<string|int, string>

The internal path (ApiBridge::internalCall() calling internalActionSave() directly, admin and front) is not limited by this allowlist and reads $meta unchanged. The default is an empty array, so external meta is dropped entirely; actionSave() is reachable through HTTP routing regardless of ApiBridge, hence the strip. Override per manager with the protected property $externalMetaAllowlist, analogous to self::getDefaultSkipFields().

Return values
array<string|int, string>

getExternalOwnerData()

Returns `data` keys that fill the owner from the server when an external save creates a record.

public getExternalOwnerData(int $userId) : array<string, mixed>

The default [] means the domain has no owner or must not be created through external save (Order uses the dedicated checkout action, User registers another way). On INSERT there is nothing to own, so the owner cannot be verified, only determined: the owner field is #[ExternalReadonly] (the strip removes it from the payload) and SaveTrait::applyExternalOwner() then fills it from the JWT, so the client value never reaches hydration.

IMPORTANT: return the key that matches the column mapping, otherwise the owner is silently not written:

  • Watchdog/Comment/Rating/Message: scalar column, ['userId' => $userId];
  • Advert (Bazaar/Comcat): mapped column, ['insertUserId' => $userId];
  • Bazaar Response/AdvertFollower: the scalar userId has an #[ORM\AttributeOverride] with insertable: false, updatable: false in the Api leaf and the column belongs to the association $user, so ['user' => ['id' => $userId]]; Comcat Response has no $user association, so there it is ['userId' => $userId];
  • Complaint: the owner is the association $user, ['user' => ['id' => $userId]].

['id' => X] is a reference that hydrateForPersist() looks up in the DB (OrmEntityHydrator::isReference()). Called only on the external path and only on INSERT; on UPDATE it would make whoever saves a foreign record its owner.

Parameters
$userId : int

identity from the JWT (never from the request)

Return values
array<string, mixed>

getExternalRemoteIpData()

Returns `data` keys that fill the caller's IP from the server when an external save creates a record; sibling of {@see getExternalOwnerData()}.

public getExternalRemoteIpData(string $ip) : array<string, mixed>

The IP field is #[ExternalReadonly], so the external strip removes it and the client cannot forge it. Without filling, an external insert would have no IP and the "anonymous by IP" deduplication and per-IP manager limits would silently stop working. SaveTrait::applyExternalRemoteIp() fills it from IRequest::getRemoteAddress(), never from the request. The default [] means the entity carries no IP; overrides return the entity field name (['insertIp' => $ip]).

Parameters
$ip : string
Return values
array<string, mixed>

getExternalUpdateColumnsAllowlist()

Returns the columns the external HTTP path may change through {@see \App\Core\Traits\Api\Presenters\SaveTrait::actionUpdateColumns()}.

public getExternalUpdateColumnsAllowlist() : array<string|int, string>

updateColumn on a to-many relation is DELETE-all plus INSERT, so without this filter an external POST with values={userRoles: [...]} would bypass the strip/guard of the save path (privilege escalation). The internal path (ApiBridge::internalCall() calling internalActionUpdateColumns() directly) is not limited. The default is an empty array, so external updateColumns is rejected entirely. Override with the protected property $externalUpdateColumnsAllowlist.

Return values
array<string|int, string>

getGraphDataKeys()

Returns the keys an external client may send nested in `data`.

public getGraphDataKeys() : array<string|int, string>

Before hydration the write Api endpoint (SaveTrait::internalActionSave()) moves them from $data to $meta, where the manager's own afterSave()/beforeSave() sync trait reads them (the same mechanism as internal meta). When the client also sends the same key explicitly in meta, meta wins (compatibility with admin, e.g. the categoryRels collision on Advert). Backed by an optional protected per-manager property, like self::getDefaultSkipFields().

IMPORTANT: promoting a key here does not make its content safe to write as received. When the key carries a sensitive field that the server otherwise dictates (typically a payment or approval flag), the manager must overwrite or verify it server-side on the external path in beforeSave()/afterSave() before its sync trait reads it; otherwise the client sets it itself (privilege escalation). The external path is recognized by $meta['__external'] === true, which only SaveTrait::actionSave() sets (never internalActionSave() alone), so internal calls never get the marker. Example: Bazaar Advert.categoryRels is persisted through the object graph (owned collection outside $graphDataKeys); a client could mark paid itself and get a paid advert without payment, so AdvertManager::beforeSave() overwrites it through CategoryRelsPaidSanitizationTrait::sanitizeExternalCategoryRelsPaid() before flush.

Return values
array<string|int, string>

getServiceClass()

Returns the fully qualified service class name, the identity for the per-service rollout allowlist of the wire-payload query cache ({@see \App\Core\Cache\Config::isServiceEnabled()}).

public getServiceClass() : class-string

The cache is controlled at the Api presenter boundary through this identity.

Return values
class-string

getSettings()

public getSettings() : array<int, array<string, mixed>>
Return values
array<int, array<string, mixed>>

getTotalCount()

public getTotalCount() : int|null
Return values
int|null

mergeSlots()

Merges adjacent terms (reservations, blocks, free time, closed time) into one resulting segment.

public mergeSlots(int $calendarId, string $date, string $timeFrom, string $timeTo) : Result

The input is a range, not a list of ids: the server derives fresh, under the lock, what actually lies in it (the client's idea is only a proposal of the range).

Result type priority: reservation > blocked > free > closed. So 2+ free become 1 free; free and/or closed plus 1+ active reservations become 1 reservation; free plus closed becomes free; blocked plus reservation becomes reservation; blocked plus free/closed becomes blocked. Merging only closed pieces is rejected ("nothing to merge"): two separate selectable closed pieces without a window between them cannot exist in a real day timeline, so such a request is stale or invalid.

A slot with any reservation history (even canceled) cannot be deleted: fk_company_reservation_slot forbids it at the database level. The winning carrier (the earliest starting active reservation) is therefore grown over the whole resulting range (TimeSlotManager::growSlot()) and promoted to an override (makePublic: true; a merged term is always an offered window). Defeated reservations are canceled by cancelInternal() without an e-mail and their rows are removed or hidden by TimeSlotManager::absorbRow(). The survivor's text joins all merged names and notes with ' + '; contacts (e-mail, phone) stay the survivor's own. The resulting state is confirmed only when all merged reservations were confirmed, otherwise pending: the client must not see a term as confirmed until an admin rechecks it.

IMPORTANT: defeated pieces are cleared first and only then is the window grown or opened; rangeHitsAnyBusy() (inside growSlot()/openWindow()) would otherwise treat not-yet-cleared pieces as a false collision.

Parameters
$calendarId : int
$date : string

Y-m-d

$timeFrom : string

H:i start of the merged range (start of the first selected piece)

$timeTo : string

H:i end of the merged range, exclusive (end of the last selected piece)

Return values
Result —

success data array{resultType: string, timeFrom: string, timeTo: string, timeSlotId: ?int, reservationId: ?int}; timeSlotId is set for resultType reservation/blocked, reservationId (plus oldTimeFrom/oldTimeTo of the surviving carrier) only for reservation

registerEvent()

public registerEvent(array<string|int, int> $ids, class-string $entityClass, string $property) : Result
Parameters
$ids : array<string|int, int>
$entityClass : class-string

entity used to translate the property into a column in the expression

$property : string
Return values
Result

save()

public save(TEntity $entity[, array<string, mixed> $meta = [] ][, array<int, string> $skipFields = [] ]) : Result
Parameters
$entity : TEntity
$meta : array<string, mixed> = []
$skipFields : array<int, string> = []
Return values
Result

setParamsConfig()

public setParamsConfig(Config $paramsConfig) : static
Parameters
$paramsConfig : Config
Return values
static

shortenTerm()

Shortens a reservation term from the admin (slot modal) and e-mails the client about the change ({@see EMAIL_CHANGED}, same pattern as {@see mergeSlots()}).

public shortenTerm(int $timeSlotId, string $timeFrom, string $timeTo, bool $notifyClient) : Result

TimeSlotManager::shorten() does the change (under the day lock, with its own guards); the e-mail goes after success and outside the lock, only when the range really changed and the carrier has a live reservation with an e-mail. It lives here, not in TimeSlotManager, because that one cannot send reservation e-mails and ReservationManager depends on it (the reverse dependency would be a DI cycle).

Parameters
$timeSlotId : int
$timeFrom : string
$timeTo : string
$notifyClient : bool

the "send the client an e-mail" switch from the modal

Return values
Result —

unchanged result of TimeSlotManager::shorten()

splitTerm()

Splits a reservation term (the reservation stays on the first part, {@see TimeSlotManager::split()}) and e-mails the client about the shortened term; same pattern as {@see shortenTerm()}.

public splitTerm(int $timeSlotId, string $cutTime, bool $notifyClient) : Result
Parameters
$timeSlotId : int
$cutTime : string
$notifyClient : bool
Return values
Result —

unchanged result of TimeSlotManager::split()

updateAllRelations()

public updateAllRelations(int|array<string|int, mixed> $entityId, string $property, array<string, mixed> $values) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$property : string
$values : array<string, mixed>

relation property name => value set on every row

Return values
Result

updateByAdmin()

Edits an existing reservation as an admin from the modal ("save reservation" in reservation mode). Admin only (`:Api:Company:Reservation:updateByAdmin`).

public updateByAdmin(int $reservationId, string $name, string $email, string|null $phone, string|null $note, ReservationState $state) : Result

Changes only client data and the pending / confirmed state, never the time or the slot: that is the job of TimeSlotManager::shorten() and TimeSlotManager::split(), the only ones that can bypass guardActiveReservationRow() safely.

IMPORTANT: gate and write are one conditional UPDATE (as in confirm(), cancel(), cancelOwn()), so there is no window to revive a reservation canceled meanwhile by another process. For the same reason generic save() is not used: it would drop state ($defaultSkipFields) and write the data without a state condition.

A canceled reservation can be neither edited nor revived by a state change; only a new write (bookByAdmin()) touches that slot.

The day lock is taken even though the slot is not changed structurally: all modal operations run under it and a state change alters day occupancy. Calendar and day come from the reservation's slot (TimeSlotManager::findLockContext()), not from the caller.

Parameters
$reservationId : int
$name : string
$email : string
$phone : string|null
$note : string|null
$state : ReservationState

only Pending/Confirmed

Return values
Result —

success data array{reservationId: int, state: string, changed: bool}

updateColumn()

public updateColumn(int|array<string|int, mixed> $entityId, string $property, mixed $value) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$property : string
$value : mixed
Return values
Result

updateColumns()

public updateColumns(int|array<string|int, mixed> $entityId, array<string, mixed> $values) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$values : array<string, mixed>

property name => new value

Return values
Result

updateRelations()

public updateRelations(int|array<string|int, mixed> $entityId, string $property, array<string|int, object> $relationEntities) : Result
Parameters
$entityId : int|array<string|int, mixed>

id, or a list of ids / composite-id arrays

$property : string
$relationEntities : array<string|int, object>

relation entities with updated values

Return values
Result

acquireSourceLock()

Calls `GET_LOCK(name, timeout)` and returns true only on a definite 1 (0 = timed out or busy, null = error). `$timeout` is in seconds; 0 is non-blocking (try-lock, used by the expirer).

protected acquireSourceLock(string $name, int $timeout) : bool
Parameters
$name : string
$timeout : int
Return values
bool

beginRejectedUploads()

Starts a save ({@see save()}): the list of rejected items belongs to this save only; the outer one (a nested save of the same manager) is returned to the caller and restored after the save through {@see finishRejectedUploads()}.

protected beginRejectedUploads() : array<int, array{name: string, reason: string}>
Return values
array<int, array{name: string, reason: string}> —

the outer save's list

bumpCacheTags()

Bumps cache tags for the entity after commit: its class, the owned-cascade classes from metadata and the optional `additionalCacheTags()` of the manager.

protected bumpCacheTags(object $entity) : void

Fail-open (the invalidator swallows exceptions). Called from the manager Save/Delete/Tree traits after the commit.

Parameters
$entity : object

bumpCacheTagsExplicit()

Bumps the given classes explicitly (raw-DBAL paths with a cross-entity effect).

protected bumpCacheTagsExplicit(array<string|int, string> $classes) : void
Parameters
$classes : array<string|int, string>

bumpCacheTagsForClass()

Bumps cache tags by class name when there is no instance (bulk delete, `updateColumns()`, raw DBAL, tree operations).

protected bumpCacheTagsForClass(class-string $rootClass[, array<string|int, string> $extraTags = [] ]) : void

Bumps the root class and its owned-cascade classes.

Parameters
$rootClass : class-string
$extraTags : array<string|int, string> = []

classCacheTags()

Returns extra tags for a bump that has no entity, typically tree operations ({@see \App\Core\Traits\Api\Models\Managers\TreeTrait::treeTransaction()}) that rewrite `lft`/`rgt` on many rows.

protected classCacheTags() : array<string|int, string>

IMPORTANT: additionalCacheTags(object) receives an entity, so a class-level bump cannot use it; without this hook a delete would lose the cross-entity tag (Comment to CommentThread). A manager whose cross-entity tag does not depend on a particular entity returns it here.

NOTE: unlike additionalCacheTags(), this hook is declared (with an empty default) instead of being an opt-in through method_exists(); method_exists() is always true in a manager that has the method, which phpstan reports as alreadyNarrowedType.

Return values
array<string|int, string>

finishRejectedUploads()

Ends a save: whatever {@see recordRejectedUpload()} wrote during it is added to the data of a successful result as `saveInfo.rejectedUploads` (the Api presenter passes it on to the caller, the admin builds a warning from it). A rejected item does not fail the save; the accepted files stay.

protected finishRejectedUploads(Result|null $result, array<int, array{name: string, reason: string}> $outer) : void
Parameters
$result : Result|null
$outer : array<int, array{name: string, reason: string}>

list from beginRejectedUploads()

getDeletedId()

Returns the id `$entity` had before the delete, from the snapshot in `$meta` that {@see delete()} hands to every hook. Null for an entity the snapshot does not know (not loaded by this delete, or without a simple id).

protected getDeletedId(array<string, mixed> $meta, object $entity) : int|null
Parameters
$meta : array<string, mixed>
$entity : object
Return values
int|null

getService()

Returns the service backing the manager traits.

protected getService() : Service<string|int, TEntity>
Return values
Service<string|int, TEntity>

readUploadedItem()

Reads the content of an uploaded item (`{name, data}` base64 or `{name, path}` from the dropzone) through the single reader {@see UploadedItem}.

protected readUploadedItem(array<string|int, mixed> $item[, bool $strictBase64 = true ][, int|null $maxBytes = null ]) : string|null

Call it right before processing and release the result before the next item, so that files never meet in memory. A path is accepted only during an internal bridge call ($internalCallContext) and only inside the dropzone root. A rejected path is logged to the auth channel and null is returned; callers treat it like unreadable data (skip the item or refuse the save).

Parameters
$item : array<string|int, mixed>
$strictBase64 : bool = true

false for callers that decode loosely (base64_decode($x))

$maxBytes : int|null = null

size limit; for path it is checked before reading

Return values
string|null

recordRejectedUpload()

Records a new uploaded item that the save skipped ({@see UploadRejection} `REASON_*`).

protected recordRejectedUpload(mixed $name, string $reason) : void

Called by the places that drop an item (processOrderedAssets(), advert attachments, product set gallery).

Parameters
$name : mixed
$reason : string

releaseSourceLock()

Releases the advisory lock.

protected releaseSourceLock(string $name) : void

IMPORTANT: it is called from a finally, so a failure here (a gone-away connection after a fatal query) is swallowed and logged; rethrowing would mask the original exception being unwound. MySQL drops session locks when the connection dies anyway, so nothing leaks past the process.

Parameters
$name : string

runInSourceTransaction()

Runs a closure in one raw-DBAL transaction with a deadlock retry (`SQLSTATE` 40001 / errno 1213).

protected runInSourceTransaction(callable(Connection): T $fn) : T

The advisory lock (held by the caller across this call) is not released by a rollback, so every attempt runs under the same serialization.

Parameters
$fn : callable(Connection): T
Tags
template
Return values
T

applyMergeSlots()

Runs the guards, decides the resulting type and writes; already under the day lock.

private applyMergeSlots(Calendar $calendar, int $calendarId, string $date, int $from, int $to) : Result
Parameters
$calendar : Calendar
$calendarId : int
$date : string
$from : int
$to : int
Return values
Result

blacklistRefusal()

private blacklistRefusal(string|null $email[, string|null $phone = null ][, string|null $ip = null ]) : Result|null
Parameters
$email : string|null
$phone : string|null = null
$ip : string|null = null
Return values
Result|null

Builds the absolute cancel link for a term.

private buildCancelLink(string $token) : string

IMPORTANT: outside an HTTP request (cron, CLI harness) Nette\Http\Request returns the base http:/, an unusable shape; Latte drops such a link in href as unsafe and the e-mail would carry an empty link with no trace of why. So the base is validated and an unusable one returns empty plus a log entry. A filled site.baseUrl setting (SiteBaseUrl) takes precedence over the request address, so the link also exists in cron and does not depend on the Host header.

Parameters
$token : string
Return values
string

buildEmailParams()

Builds the single parameter set for all four `CMP_RESERVATION_*` templates, so a seed need not care which template knows which variable and adding a field to an e-mail is a text change, not a code change.

private buildEmailParams(Reservation $reservation, Company $company, string $date, string $timeFrom, string $timeTo) : array<string, string>
Parameters
$reservation : Reservation
$company : Company
$date : string
$timeFrom : string
$timeTo : string
Return values
array<string, string>

buildLockName()

Returns `company_calendar_slot_{calendarId}_{date}`, the lock name for one day of one calendar (see the class docblock).

private buildLockName(int $calendarId, string $date) : string

IMPORTANT: the name is no longer built here. The single source of truth is TimeSlotManager::dayLockName(), because admin writes take the same lock (TimeSlotManager::save()); two own copies of the format would drift apart and produce two different locks over the same day, i.e. no serialization exactly where everything depends on it. The method stays a thin passthrough.

Parameters
$calendarId : int
$date : string
Return values
string

bumpDeleteCacheTags()

Post-commit bump after a delete. With entities it goes through {@see \App\Core\Base\BaseManager::bumpCacheTags()}, which also collects `additionalCacheTags()` (Comment → CommentThread); without them it falls back to bumping the manager class itself, the original behavior.

private bumpDeleteCacheTags(array<int, object> $entities) : void
Parameters
$entities : array<int, object>

bumpStateCacheTags()

Invalidates the cache tags of both `Reservation` and `TimeSlot`: a state transition also changes the calendar (slot occupancy derives from active reservations).

private bumpStateCacheTags() : void

IMPORTANT: the conditional UPDATEs in confirm(), cancel() and cancelByToken() use raw DBAL, so the generic post-commit bump from SaveTrait does not see them and the bump must be manual. Query cache is currently off for Admin, so this is preventive; once it is on, the calendar would otherwise show a canceled term as occupied until the TTL expires.

cancelInternal()

Cancels like {@see cancel()} but without an e-mail; absorbs the defeated reservation inside {@see mergeSlots()}. The client gets the e-mail about the merged term another way; sending "canceled" first and then the new term would be confusing and untrue, because the term was absorbed, not dropped.

private cancelInternal(int $reservationId) : Result

Touches only the row, not the entity; the caller in mergeSlots() does not use it afterwards.

Parameters
$reservationId : int
Return values
Result

captureDeletedIds()

Keys the ids by the loaded entity objects, not by the input ids: an input id that loaded nothing must not shift the pairing. A weak map, so the snapshot never keeps an entity alive.

private captureDeletedIds(array<int, object> $entities) : WeakMap<object, int>
Parameters
$entities : array<int, object>
Return values
WeakMap<object, int>

checkAdminState()

Admin actions may work only with the two active states. `Cancelled` is excluded: canceling has its own path ({@see cancel()}) with its own e-mail and token reset, and the generic write would cancel without both.

private checkAdminState(ReservationState $state) : Result|null
Parameters
$state : ReservationState
Return values
Result|null

checkClientFields()

Shared validation of client fields for `book()`, `bookByAdmin()` and `updateByAdmin()`, so messages and limits cannot diverge between paths. Limits match the columns (`name` 100, `email` 255, `phone` 30).

private checkClientFields(string $name, string $email, string|null $phone) : Result|null
Parameters
$name : string
$email : string
$phone : string|null
Return values
Result|null

checkCompanyBookable()

Company guards. Each reason has its own message; a bare "not possible" is useless on a form.

private checkCompanyBookable(object|null $company) : Result|null
Parameters
$company : object|null
Return values
Result|null

checkRangeAvailableForAdmin()

Like {@see checkRangeFree()} but without requiring a window ("create reservation right away" outside the offered windows). Only a real conflict with an existing reservation or block is guarded, also outside windows (`busyOutside`): two reservations outside the offer could otherwise overlap, because `rangeHitsBusy()` reads only `busy` (occupancy trimmed to windows, see {@see TimeSlotManager::computeDay()}) and would miss an outside-window collision.

private checkRangeAvailableForAdmin(TDay $day, int $fromMinutes, int $toMinutes) : Result|null
Parameters
$day : TDay
$fromMinutes : int
$toMinutes : int
Return values
Result|null

checkRangeFree()

Checks that the range fits entirely into one free interval of the day. Returns null when it does, otherwise a ready message; "occupied" and "not offered" are two different situations for the client.

private checkRangeFree(TDay $day, int $fromMinutes, int $toMinutes) : Result|null
Parameters
$day : TDay
$fromMinutes : int
$toMinutes : int
Return values
Result|null

columnsInvalidateCache()

private columnsInvalidateCache(array<int, string> $columns) : bool
Parameters
$columns : array<int, string>
Return values
bool

createReservation()

Booking flow body under the advisory lock: recomputes free time, materializes the carrier and inserts the reservation.

private createReservation(Company $company, Calendar $calendar, string $date, int $fromMinutes, int $toMinutes, string $name, string $email, string|null $phone, string|null $note, int|null $userId, string|null $ip[, bool $enforceLeadTime = true ][, bool $recordGdprConsent = true ][, bool $enforceWindow = true ][, string|null $subject = null ][, ReservationState $initialState = ReservationState::Pending ]) : Result

$company serves only as the company identity for the denormalized carrier column; everything that decides free time (working hours, step, lead time, row range) comes from $calendar.

Two trailing switches serve the admin path (bookByAdmin()); their defaults preserve the client behavior of book() exactly:

  • $enforceLeadTime = false: free time is computed without trimming the past and lead time (computeDay() with $earliest = null). An admin writes a term already agreed by phone, often for today and inside lead time, where the client path would refuse. Other guards (day windows, overlap with a reservation or block, closed_day) apply unchanged.
  • $recordGdprConsent = false: gdpr_consent_datetime stays null. The column is proof of the client's consent; a manual admin write has no web consent, so filling it would fabricate proof.
Parameters
$company : Company
$calendar : Calendar
$date : string
$fromMinutes : int
$toMinutes : int
$name : string
$email : string
$phone : string|null
$note : string|null
$userId : int|null
$ip : string|null
$enforceLeadTime : bool = true
$recordGdprConsent : bool = true
$enforceWindow : bool = true
$subject : string|null = null

optional reservation subject

$initialState : ReservationState = ReservationState::Pending

book() passes Confirmed when the calendar has auto_confirm on; bookByAdmin() keeps the default (Pending) and confirms by its own UPDATE under the same lock, so the parameter changes nothing there

Return values
Result —

success data array{reservationId: int, timeSlotId: int, reservation: Reservation}

deleteBatch()

Bulk delete of a list of ids as one transaction. `Service::deleteAll()` flushes per entity; in autocommit the rows deleted before a database failure would stay deleted while the caller got either an escaped `EntityManagerClosed` or an "OK" with an error text. Here the service stops at the technical failure, this wrapper rolls the whole batch back and returns the failure Result with `deletedCount = 0` and `rolledBack = true`: nothing of the batch is durable and nothing is attempted over the closed EntityManager. A domain partial success (missing ids) keeps the existing contract and is committed.

private deleteBatch(array<int, int|string|array<string|int, mixed>> $ids) : Result

Transaction ownership: the wrapper only opens its own level; inside a caller's transaction a rollback here marks the caller's transaction rollback-only, so its final commit rolls the whole scope back (TransactionManager counter contract, the same as the hooked delete() path).

Parameters
$ids : array<int, int|string|array<string|int, mixed>>
Return values
Result

ensureMergedWindow()

Ensures that after merging `[from, to)` is covered by exactly one window: either a new one is created (and windows inside the selection disappear), or the range is covered by windows that stay outside the selection and the inner ones are only absorbed.

private ensureMergedWindow(int $calendarId, string $date, int $from, int $to, bool $needsWindow) : Result
Parameters
$calendarId : int
$date : string
$from : int
$to : int
$needsWindow : bool
Return values
Result

formatCzechDate()

private formatCzechDate(string $date) : string
Parameters
$date : string
Return values
string

isAssetPathAllowed()

Checks only entities composing {@see AssetPathTrait}; the trait is the allowlist of "relative file path" columns, other `assetPath`-like fields are untouched.

private isAssetPathAllowed(BaseEntity $entity) : bool
Parameters
$entity : BaseEntity
Return values
bool

ladd()

private ladd(string $a, string $b) : numeric-string
Parameters
$a : string
$b : string
Return values
numeric-string

ldiv()

Divides with half-up rounding to scale 3 (bcmath itself truncates; same rounding as {@see \App\UI\Api\Eshop\Models\Helpers\OrderCalculator::round3()}).

private ldiv(string $a, string $b) : numeric-string

IMPORTANT: truncation would systematically drop up to 0.001 from every unit on large quantities, so the invariant sum(unitPrice x quantity) ~ sum(rowBase) would drift in one direction (an issue document undervalued against the order).

Parameters
$a : string
$b : string
Return values
numeric-string

lmul()

private lmul(string $a, string $b) : numeric-string
Parameters
$a : string
$b : string
Return values
numeric-string

loadEntitiesForCacheTags()

Loads the entities being deleted only to find out their `additionalCacheTags()`. Fail-soft: what cannot be loaded is simply missing and the bump falls back to the class; it must never stop the delete.

private loadEntitiesForCacheTags(int|array<string|int, mixed> $id) : array<int, object>
Parameters
$id : int|array<string|int, mixed>
Return values
array<int, object>

lsub()

private lsub(string $a, string $b) : numeric-string
Parameters
$a : string
$b : string
Return values
numeric-string

materializeSlot()

Creates the reservation carrier. IMPORTANT: `public = 0` is essential; with 1 the first reservation would flip the day into day-override mode and the other windows would vanish from the calendar (see the {@see TimeSlotManager} docblock).

private materializeSlot(int $companyId, int $calendarId, string $date, string $timeFrom, int $toMinutes) : int|null
Parameters
$companyId : int
$calendarId : int
$date : string
$timeFrom : string

key HH:MM:SS (may carry seconds disambiguation)

$toMinutes : int
Return values
int|null —

null means someone else took the key meanwhile (lost race for the unique key)

mergeIntoBlocked()

private mergeIntoBlocked(int $calendarId, string $date, int $from, int $to, array<int, TCalendarRow> $blockedRows, array<int, TCalendarRow> $absorbableRows, bool $needsWindow) : Result
Parameters
$calendarId : int
$date : string
$from : int
$to : int
$blockedRows : array<int, TCalendarRow>
$absorbableRows : array<int, TCalendarRow>

windows inside the selection plus invisible spent carriers

$needsWindow : bool
Return values
Result

mergeIntoFree()

private mergeIntoFree(int $calendarId, string $date, int $from, int $to, array<int, TCalendarRow> $absorbableRows, bool $needsWindow) : Result
Parameters
$calendarId : int
$date : string
$from : int
$to : int
$absorbableRows : array<int, TCalendarRow>

windows inside the selection plus invisible spent carriers

$needsWindow : bool
Return values
Result

mergeIntoReservation()

private mergeIntoReservation(int $calendarId, string $date, int $from, int $to, array<int, TCalendarRow> $occupiedRows, array<int, TCalendarRow> $blockedRows, array<int, TCalendarRow> $absorbableRows, bool $needsWindow) : Result
Parameters
$calendarId : int
$date : string
$from : int
$to : int
$occupiedRows : array<int, TCalendarRow>
$blockedRows : array<int, TCalendarRow>
$absorbableRows : array<int, TCalendarRow>

windows inside the selection plus invisible spent carriers

$needsWindow : bool

the result reaches outside the offer, so the surviving carrier is promoted to an override

Return values
Result

notifyTermChanged()

Sends the client the term-change e-mail (`EMAIL_CHANGED`). The new term is read from the reservation's current carrier, the original one is passed by the caller. Only live (pending or confirmed) reservations get it.

private notifyTermChanged(int $reservationId, string $oldDate, string $oldTimeFrom, string $oldTimeTo) : void
Parameters
$reservationId : int
$oldDate : string
$oldTimeFrom : string
$oldTimeTo : string

notifyTermResized()

Sends the term-change e-mail after shortening or splitting carrier `$timeSlotId` (best-effort).

private notifyTermResized(int $timeSlotId, Result $result) : void
Parameters
$timeSlotId : int
$result : Result

nullIfEmpty()

private nullIfEmpty(string|null $value) : string|null
Parameters
$value : string|null
Return values
string|null

parseMinutes()

Converts `H:i` / `H:i:s` to minutes since midnight; anything else is null (and the caller rejects it).

private parseMinutes(string $time) : int|null

Seconds are captured and checked:

  • range 00-59 (:60 and above is an invalid time, not one minute more),
  • nonzero seconds are rejected. The model runs on a minute grid, so 10:00:30 has nowhere to be stored; it would silently be truncated to 10:00 and shift the reservation boundary by up to 59 s against what the client sent. The public contract is H:i (ReservationPresenter); H:i:s is tolerated only for zero seconds from the database or format('H:i:s').

24:00 (also 24:00:00) stays a valid end of day.

Parameters
$time : string
Return values
int|null

previousTermIfChanged()

Returns the previous term from a range-change result, only when the time really changed (otherwise a change e-mail makes no sense). Contract: `oldTimeFrom`/`oldTimeTo` and `timeFrom`/`timeTo` (`H:i`).

private static previousTermIfChanged(array<string|int, mixed> $data) : array{oldTimeFrom: string, oldTimeTo: string}|null
Parameters
$data : array<string|int, mixed>
Return values
array{oldTimeFrom: string, oldTimeTo: string}|null

rangeCoveredByRanges()

Tells whether `[from, to)` lies entirely inside the union of `$ranges` (decides whether a merged reservation needs its own window, see {@see applyMergeSlots()}).

private rangeCoveredByRanges(array<int, array{0: int, 1: int}> $ranges, int $from, int $to) : bool
Parameters
$ranges : array<int, array{0: int, 1: int}>

need not be sorted or disjoint

$from : int
$to : int
Return values
bool

resolveCarrierSlot()

Returns the row the reservation will hang on (under the lock). Three paths:

private resolveCarrierSlot(int $companyId, int $calendarId, string $date, int $fromMinutes, int $toMinutes, array<int, TCalendarRow> $rows) : int|null
  1. no row at the range start yet: a carrier with public = 0 and the exact range is materialized;
  2. a free slot row with the same range exists: it is reused. This avoids creating the same thing twice and covers both booking a whole override window (public = 1 stays an override and becomes occupied) and re-occupying a carrier after a canceled reservation. The search spans the whole minute (any seconds disambiguation), not only key :00; otherwise a spent carrier at :01 would not be found again, another would be created at :02, and each reserve/cancel cycle would eat one of the 60 seconds of the minute. The exhaustion is irreversible because rows with history cannot be deleted;
  3. a row with a different range sits at the start (typically an override window 13:00-14:30 and a reservation of only 13:00-13:30, or a spent carrier of another length): 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. The shift is invisible in minute arithmetic (see the TimeSlotManager docblock).

A foreign range is not shortened or overwritten: that would rewrite the admin's offer and, for a carrier with history, retroactively change the time of a canceled reservation.

$rows arrive already calendar-scoped (from TimeSlotManager::findDayRowsRaw() keyed by calendarId), so they are not filtered again; $calendarId is only passed on to materialization.

Parameters
$companyId : int
$calendarId : int
$date : string
$fromMinutes : int
$toMinutes : int
$rows : array<int, TCalendarRow>
Return values
int|null —

null means the row could not be created (lost race or exhausted minute)

resolveLangId()

In CLI (harness, cron) the language context is missing; Czech is the installation's default language.

private resolveLangId() : int
Return values
int

saveWithHooks()

Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.

private saveWithHooks(TEntity $entity, array<string, mixed> $meta, array<int, string> $skipFields) : Result
Parameters
$entity : TEntity
$meta : array<string, mixed>
$skipFields : array<int, string>
Return values
Result

sendBookingEmails()

Sends the pair of e-mails after a reservation is received: confirmation to the client, request to the company.

private sendBookingEmails(Reservation $reservation, Company $company, string $date, string $timeFrom, string $timeTo, int $langId[, bool $autoConfirmed = false ]) : void

auto_confirm of the calendar changes which pair goes out, not whether an e-mail goes out (the module does not grow a new template):

  • $autoConfirmed === true (created as Confirmed): the client gets EMAIL_CONFIRMED (same template as manual confirmation from admin, confirm(), including the cancel link); "waiting for approval" for an already confirmed term would mislead. The company gets no EMAIL_REQUESTED: its text asks to confirm or cancel the reservation in admin, which does not apply to an auto-confirmed one, and the company enabled auto-confirm itself.
  • $autoConfirmed === false (default): unchanged. The client gets EMAIL_RECEIVED ("wait for confirmation"), the company EMAIL_REQUESTED ("confirm or cancel").
Parameters
$reservation : Reservation
$company : Company
$date : string
$timeFrom : string
$timeTo : string
$langId : int
$autoConfirmed : bool = false

sendEmail()

Sends an e-mail best-effort: a missing template or unavailable SMTP must not break an already saved reservation.

private sendEmail(int $langId, string $emailKey, string $to, array<string, string> $params) : void

IMPORTANT: the params.1.mail.blocked safety is evaluated only in EmailManager::initializeMailer(), so with it on the message is still queued (setting email.sending.mode) and just never leaves.

Parameters
$langId : int
$emailKey : string
$to : string
$params : array<string, string>

sendStateEmail()

Sends the state-change e-mail to the client; the slot and the company are loaded from the reservation.

private sendStateEmail(Reservation $reservation, string $emailKey, bool $withCancelLink[, array<string, string> $extraParams = [] ]) : void
Parameters
$reservation : Reservation
$emailKey : string
$withCancelLink : bool
$extraParams : array<string, string> = []

extra variables for a specific template (e.g. the original term)

usesAssetPathTrait()

private static usesAssetPathTrait(string $class) : bool
Parameters
$class : string
Return values
bool
On this page

Search results