CoolCMS API

ResponseManager extends Manager
in package
implements MessageThreadReadListener, ContentCreationLimitedInterface uses BlacklistGuardTrait, MessageThreadCreatorTrait, ResponseColumnGuardTrait, ResponseCurrencyGuardTrait, ResponseMarkReadTrait, ResponseThreadReadSyncTrait, ContentCreationLimitTrait

Base class of Api managers whose traits delegate to a typed `Service<TEntity>`.

Tags
extends

Table of Contents

Interfaces

MessageThreadReadListener
Listener notified when a user has read a private conversation.
ContentCreationLimitedInterface
Manager that draws on the {@see ContentCreationLimiter} quota when creating content.

Constants

DELETED_IDS_META  : string = '__deletedIds'
Reserved `$meta` key with the ids of the entities being deleted, captured before the first hook.

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).
$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
$advertManager  : AdvertManager
$blacklistedWordManager  : BlacklistedWordManager
$blacklistedWordsCache  : array<string|int, string>|null
$contentCreationLimiter  : ContentCreationLimiter|null
$currencyManager  : CurrencyManager
$emailManager  : EmailManager
$messageThreadManager  : MessageThreadManager
$rateLimit  : RateLimit
$rateLimitConfig  : RateLimitConfig
$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`.

Methods

__construct()  : mixed
afterSave()  : Result|null
Runs after a response is saved.
beforeSave()  : Result|null
Gate for response writes: column limits (every write) and rate limit (INSERT only).
canUserWrite()  : bool
Tells whether `$userId` may write this response; input of the RBAC gate of the `save` action ({@see \App\UI\Api\Bazaar\Presenters\ResponsePresenter::verifyOwnership()}).
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
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).
generateReadKey()  : string
24 random bytes = 48 hex characters (column `read_key` is `varchar(64)`).
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>
IMPORTANT: the sender is carried by the `$user` association, not by a scalar: `Response::$userId` in the Api leaf has `#[ORM\AttributeOverride]` with `insertable: false, updatable: false` ({@see Response}), so `['userId' => ...]` would be silently not written. See {@see BaseManager::getExternalOwnerData()}.
getExternalRemoteIpData()  : array<string, mixed>
Sets the sender IP on an external INSERT: `insertIp` is `#[ExternalReadonly]` and without the server value an external response would bypass the per-IP limits in {@see self::beforeSave()}.
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
isOwnedBy()  : bool
Tells whether the response to advert `$responseId` belongs to user `$userId`.
markRead()  : Result
Writes `read = 1` after the presenter decided that the caller is the receiver ({@see \App\Core\Traits\Api\Presenters\ResponseReceiverTrait::isResponseReceiver()}).
onMessageThreadRead()  : void
Thread to response: the advert owner read the conversation, so their response is read too.
readKeyMatches()  : bool
Tells whether the given key matches the stored one. A null or empty stored key (older responses) and an empty given key never match; the comparison runs in constant time.
registerEvent()  : Result
save()  : Result
setAppContext()  : $this
setAssetFormatRegistry()  : static
setCacheTagInvalidator()  : static
setContentCreationLimiter()  : void
setInternalCallContext()  : static
setLanguageContext()  : $this
setParamsConfig()  : static
setPresenterContext()  : $this
setSettingContext()  : $this
updateAllRelations()  : Result
updateColumn()  : Result
updateColumns()  : Result
updateRelations()  : Result
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()}.
blacklistedWordsForThread()  : array<string|int, string>
Returns the blacklisted words for the private-message filter; overrides the hook {@see MessageThreadCreatorTrait::blacklistedWordsForThread()}.
buildMessageParticipant()  : MessageParticipant
Builds a participant from a user id, falling back to a guest. When `$userId` is missing (anonymous advert, logged-out asker), the participant carries contact data and the thread switches to e-mail-only mode.
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.
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.
openMessageThreadFor()  : Result|null
Opens a thread with two participants and a root message and writes its id into `$column` of the entity `$entityId` (default `messageThreadId`).
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_*`).
resolveThreadSubject()  : string|null
Resolves the thread subject from a response: the response subject when filled, otherwise the fallback (the advert title).
advertDisplayName()  : string|null
Returns the advert name as a string (parameter `name`), or `null` without it.
blacklistRefusal()  : Result|null
buildMarkReadLink()  : string|null
Returns the absolute URL of the "mark as read" button in the e-mail.
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.
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.
columnsInvalidateCache()  : bool
containsBlacklistedWord()  : bool
Tells whether the text contains a blacklisted word.
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.
guardAnonymousInsert()  : Result|null
Returns `RATE_LIMIT_EXCEEDED` when a guest visitor exceeded the quota of anonymous records of the kind `$scope` (per IP and per normalized `$email`), otherwise `null`. Only an insert by a guest (`$userId` null); a logged-in user is counted by {@see guardContentCreation()}.
guardContentCreation()  : Result|null
Returns `RATE_LIMIT_EXCEEDED` when the quota is exceeded, otherwise `null`. Only an insert by a logged-in user is counted (see {@see ContentCreationLimiter::isAllowed()}).
guardResponseColumnLimits()  : Result|null
Rejects a response whose value does not fit its typed column.
guardResponseCurrency()  : Result|null
hasResponsePriceChanged()  : bool
Tells whether the price or currency differs from the stored row. Without an id or a readable row it counts as changed: the guard then checks rather than let an unverified currency through.
isActivationMailAllowed()  : bool
Tells whether another activation e-mail may be sent to `$email`. Unlike {@see guardContentCreation()} it applies to guests too (it protects the recipient); see {@see ContentCreationLimiter::isActivationMailAllowed()}.
isAssetPathAllowed()  : bool
Checks only entities composing {@see AssetPathTrait}; the trait is the allowlist of "relative file path" columns, other `assetPath`-like fields are untouched.
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.
markThreadReadForResponse()  : void
openThreadForResponse()  : Result|null
Creates a thread between the inquirer and the advert owner and stores its id on the response.
resolveSenderUserId()  : int|null
Returns the response sender: front/admin fill the scalar `userId`, external INSERT the `user` association (the scalar is read-only in the Api leaf, see {@see self::getExternalOwnerData()}).
responseColumnFailure()  : Result
Builds the uniform rejection; `errorCode` is a machine-readable reason.
responseReceiverUserId()  : int|null
Returns the user the response was addressed to (the advert owner), or `null` for an anonymous advert.
rollbackBatch()  : void
saveWithHooks()  : Result
Runs the save itself: transaction, `beforeSave` / `afterSave` / `completedSave`, tree, cache.
sendEmail()  : void
usesAssetPathTrait()  : bool

Constants

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().

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.

$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

$blacklistedWordsCache

private array<string|int, string>|null $blacklistedWordsCache = null

cache of the blacklisted-word code list, see blacklistedWordsForThread()

$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(ResponseService $service, AdvertManager $advertManager, CurrencyManager $currencyManager, EmailManager $emailManager, MessageThreadManager $messageThreadManager, BlacklistedWordManager $blacklistedWordManager, RateLimit $rateLimit, RateLimitConfig $rateLimitConfig[, BlacklistManager|null $blacklistManager = null ]) : mixed
Parameters
$service : ResponseService
$advertManager : AdvertManager
$currencyManager : CurrencyManager
$emailManager : EmailManager
$messageThreadManager : MessageThreadManager
$blacklistedWordManager : BlacklistedWordManager
$rateLimit : RateLimit
$rateLimitConfig : RateLimitConfig
$blacklistManager : BlacklistManager|null = null

afterSave()

Runs after a response is saved.

public afterSave(bool $isInsert, Response &$response[, Result|null $result = null ][, array<string, mixed> &$meta = [] ]) : Result|null

The core SaveTrait::save() calls afterSave($isInsert, $entity, $result, $meta). A signature without bool $isInsert in the first position causes a TypeError, save() catches it and rolls back every response save.

Parameters
$isInsert : bool
$response : Response
$result : Result|null = null
$meta : array<string, mixed> = []
Return values
Result|null

beforeSave()

Gate for response writes: column limits (every write) and rate limit (INSERT only).

public beforeSave(bool $isInsert, Response &$response[, array<string, mixed> &$meta = [] ]) : Result|null

Every response save passes through here (front form via ApiBridge, admin edit via the Admin-to-Api bridge, external :Api:*:Response:save), so the column-limit check (ResponseColumnGuardTrait) lives here too.

IMPORTANT: a guest can send a response, so the only identity of the sender is the IP. Two dimensions, each covering a different scenario:

  1. per IP - one sender mails responses to hundreds of adverts;
  2. per (IP, advert) - the same sender floods one advert. The key is per IP, not per advert globally, otherwise a popular advert would exhaust the quota for honest inquirers.

NOTE: only INSERT is limited; editing a response (admin, correction) does not pass through the limit. NOTE: the rate limit is fail-open (Redis outage gives allowed = true); spam is the lesser evil compared to a broken response form.

Parameters
$isInsert : bool
$response : Response
$meta : array<string, mixed> = []
Return values
Result|null

canUserWrite()

Tells whether `$userId` may write this response; input of the RBAC gate of the `save` action ({@see \App\UI\Api\Bazaar\Presenters\ResponsePresenter::verifyOwnership()}).

public canUserWrite(int|null $responseId, int|null $advertId, int $userId) : bool

INSERT only checks that data.advert.id (or data.advertId) exists, because a response hangs on an advert. Owning the advert is deliberately not required: the point of a response is to write to someone else's advertiser. The sender is set by the server (self::getExternalOwnerData()), so nobody can respond under another name.

UPDATE is self::isOwnedBy().

Parameters
$responseId : int|null

data.id; null = INSERT

$advertId : int|null

data.advert.id; null = not sent

$userId : int
Return values
bool

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

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)

generateReadKey()

24 random bytes = 48 hex characters (column `read_key` is `varchar(64)`).

public static generateReadKey() : string
Return values
string

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

IMPORTANT: the sender is carried by the `$user` association, not by a scalar: `Response::$userId` in the Api leaf has `#[ORM\AttributeOverride]` with `insertable: false, updatable: false` ({@see Response}), so `['userId' => ...]` would be silently not written. See {@see BaseManager::getExternalOwnerData()}.

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

identity from the JWT (never from the request)

Return values
array<string, mixed>

getExternalRemoteIpData()

Sets the sender IP on an external INSERT: `insertIp` is `#[ExternalReadonly]` and without the server value an external response would bypass the per-IP limits in {@see self::beforeSave()}.

public getExternalRemoteIpData(string $ip) : array<string, mixed>
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

isOwnedBy()

Tells whether the response to advert `$responseId` belongs to user `$userId`.

public isOwnedBy(int $responseId, int $userId) : bool

NOTE: there are two legitimate owners:

  1. the response sender (Response::$userId) may manage their own response;
  2. the advert owner (advert.insertUserId): responses arrive in their mailbox and the front end lists and reads them accordingly (ResponseListTrait::prepareFilters() filters by advert.insertUserId, ResponseDetailTrait::actionDetail() returns 403 on the same condition).

With only branch 1 the advert owner could not delete, through the external Api, spam sent to them, which the front end allows.

NOTE: both are read from the DB by $responseId, never from the payload.

Parameters
$responseId : int
$userId : int
Return values
bool

markRead()

Writes `read = 1` after the presenter decided that the caller is the receiver ({@see \App\Core\Traits\Api\Presenters\ResponseReceiverTrait::isResponseReceiver()}).

public markRead(int $id) : Result

The manager decides nothing itself, so there is no second copy of the check. An already read response is a success again without another write; the key is not deleted.

Parameters
$id : int
Return values
Result

onMessageThreadRead()

Thread to response: the advert owner read the conversation, so their response is read too.

public onMessageThreadRead(int $threadId, int $userId) : void
Parameters
$threadId : int
$userId : int

readKeyMatches()

Tells whether the given key matches the stored one. A null or empty stored key (older responses) and an empty given key never match; the comparison runs in constant time.

public static readKeyMatches(string|null $stored, string $given) : bool
Parameters
$stored : string|null
$given : string
Return values
bool

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

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

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<int, int> $entityId, array<string, mixed> $values) : Result
Parameters
$entityId : int|array<int, int>
$values : array<string, mixed>
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

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

blacklistedWordsForThread()

Returns the blacklisted words for the private-message filter; overrides the hook {@see MessageThreadCreatorTrait::blacklistedWordsForThread()}.

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

NOTE: takes words from all languages, not only the current one. Spam ignores the visitor's locale and the filter exists to catch it, not to be language-precise.

Cached in a property: afterSave runs once per response, but batch processing would otherwise reload the code list over and over.

Return values
array<string|int, string>

buildMessageParticipant()

Builds a participant from a user id, falling back to a guest. When `$userId` is missing (anonymous advert, logged-out asker), the participant carries contact data and the thread switches to e-mail-only mode.

protected buildMessageParticipant(int|null $userId[, string|null $guestName = null ][, string|null $guestEmail = null ][, string|null $guestPhone = null ]) : MessageParticipant
Parameters
$userId : int|null
$guestName : string|null = null
$guestEmail : string|null = null
$guestPhone : string|null = null
Return values
MessageParticipant

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>

openMessageThreadFor()

Opens a thread with two participants and a root message and writes its id into `$column` of the entity `$entityId` (default `messageThreadId`).

protected openMessageThreadFor(int|null $entityId, MessageThreadType $type, string|null $subject, MessageParticipant $sender, MessageParticipant $recipient, string $text[, string|null $ip = null ][, string $column = 'messageThreadId' ]) : Result|null

Pass $sender and $recipient without threadId and role; both are filled in by MessageThreadManager::openThread(). A participant without userId is a guest; the reply channel is derived from that.

Returns a failed Result (the caller may return it from afterSave to roll back), or null when there is nothing to do or everything succeeded.

Parameters
$entityId : int|null
$type : MessageThreadType
$subject : string|null
$sender : MessageParticipant
$recipient : MessageParticipant
$text : string
$ip : string|null = null
$column : string = 'messageThreadId'
Return values
Result|null

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

resolveThreadSubject()

Resolves the thread subject from a response: the response subject when filled, otherwise the fallback (the advert title).

protected resolveThreadSubject(string|null $preferred, string|null $fallback) : string|null

The raw text is stored without a prefix. The display layer composes the prefix by thread type (MessageThreadType) in the reader's language, so both sides see the subject in their own language. The text is cut to the column length (on overflow the database would otherwise fail the save in strict sql_mode or truncate silently in non-strict).

Parameters
$preferred : string|null
$fallback : string|null
Return values
string|null

advertDisplayName()

Returns the advert name as a string (parameter `name`), or `null` without it.

private advertDisplayName(object $advert) : string|null
Parameters
$advert : object
Return values
string|null

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

Returns the absolute URL of the "mark as read" button in the e-mail.

private buildMarkReadLink(Presenter $presenter, Response $response) : string|null

It is a front page that, without login, sets read = 1 by id and the key stored on the response (ResponseMarkReadTrait::markRead()).

Parameters
$presenter : Presenter
$response : Response
Return values
string|null

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>

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>

columnsInvalidateCache()

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

containsBlacklistedWord()

Tells whether the text contains a blacklisted word.

private containsBlacklistedWord(string $text) : bool

Detection goes through StringHelper::replaceWords(), the same function that masks blacklisted words elsewhere. A custom search would drift in normalization (diacritics, word boundaries) from what the user sees on the advert.

Parameters
$text : string
Return values
bool

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

guardAnonymousInsert()

Returns `RATE_LIMIT_EXCEEDED` when a guest visitor exceeded the quota of anonymous records of the kind `$scope` (per IP and per normalized `$email`), otherwise `null`. Only an insert by a guest (`$userId` null); a logged-in user is counted by {@see guardContentCreation()}.

private guardAnonymousInsert(bool $isInsert, string $scope, int|null $userId, string $email) : Result|null

See ContentCreationLimiter::isAnonymousInsertAllowed().

Parameters
$isInsert : bool
$scope : string
$userId : int|null
$email : string
Return values
Result|null

guardContentCreation()

Returns `RATE_LIMIT_EXCEEDED` when the quota is exceeded, otherwise `null`. Only an insert by a logged-in user is counted (see {@see ContentCreationLimiter::isAllowed()}).

private guardContentCreation(bool $isInsert, string $scope, int|null $userId) : Result|null
Parameters
$isInsert : bool
$scope : string
$userId : int|null
Return values
Result|null

guardResponseColumnLimits()

Rejects a response whose value does not fit its typed column.

private guardResponseColumnLimits(Response|Response $response) : Result|null

Returns null when everything is valid (the price is then normalized to the form that gets written).

The entity is typed by the shared ancestors of both modules: the same beforeSave() also handles \App\UI\Admin\{Bazaar,Comcat}\Models\Entities\Response (the Admin to Api bridge does not rewrite \Admin\ to \Api\), so a narrower type would make an admin edit fail with a TypeError.

Parameters
$response : Response|Response
Return values
Result|null

hasResponsePriceChanged()

Tells whether the price or currency differs from the stored row. Without an id or a readable row it counts as changed: the guard then checks rather than let an unverified currency through.

private hasResponsePriceChanged(Response|Response $response) : bool
Parameters
$response : Response|Response
Return values
bool

isActivationMailAllowed()

Tells whether another activation e-mail may be sent to `$email`. Unlike {@see guardContentCreation()} it applies to guests too (it protects the recipient); see {@see ContentCreationLimiter::isActivationMailAllowed()}.

private isActivationMailAllowed(string $scope, string $email) : bool
Parameters
$scope : string
$email : string
Return values
bool

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

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>

openThreadForResponse()

Creates a thread between the inquirer and the advert owner and stores its id on the response.

private openThreadForResponse(Response $response) : Result|null

NOTE: a participant without a user id is a guest, and one is enough to switch the thread to "e-mail only" mode. It happens in two legitimate situations: the inquirer is not logged in, or the advert is anonymous (edited by password, advert.insertUserId === null), so the addressee has no account.

Parameters
$response : Response
Return values
Result|null

resolveSenderUserId()

Returns the response sender: front/admin fill the scalar `userId`, external INSERT the `user` association (the scalar is read-only in the Api leaf, see {@see self::getExternalOwnerData()}).

private static resolveSenderUserId(Response $response) : int|null
Parameters
$response : Response
Return values
int|null

responseColumnFailure()

Builds the uniform rejection; `errorCode` is a machine-readable reason.

private responseColumnFailure(string $message, string $errorCode) : Result
Parameters
$message : string
$errorCode : string
Return values
Result

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

sendEmail()

private sendEmail(Response $response, array<string, mixed> $meta, string $emailKey) : void
Parameters
$response : Response
$meta : array<string, mixed>
$emailKey : string

usesAssetPathTrait()

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

Search results