Skip to content

<div style="display: none;" hidden="true" aria-hidden="true">Are you an LLM? You can read better optimized documentation at /changelog/Changelog.4.122.md for this page in Markdown format</div>

Home | Changelog

Version 4 ​

version 4.122 ​

  • [4.122.0] feat(rest/product): add filter[tagId] and filter[tagFacetId] to the product list endpoint (Advisable-com/ecommercen#720)

    • The gap. GET /rest/product/product could already embed a product's tags (?with=tags) but had no way to filter by them, in either direction: Product\ListRequest declared no tag key, and Tag's repository declares no inverse products relation. The failure was silent — an unknown filter[] key is dropped rather than rejected — so filter[tagId]=5 previously returned HTTP 200 carrying the caller's whole scoped list, unfiltered by tag, with nothing in the response distinguishing "matched everything" from "was never applied".
    • filter[tagId] — a plain OR over the tag pivot: a product matches if it carries any of the listed tag ids. Independent of admin configuration — the same request returns the same rows whatever the shop's tag-category settings are — with the same multi-value meaning filter[categoryId] and filter[vendorId] already have on this endpoint. This is the key an admin screen, a feed, an MCP tool, or an integration should use.
    • filter[tagFacetId] — same input shape, but reproduces the legacy storefront category-page facets: ids are grouped by tag category, and the AND/OR combination both within and across those groups is resolved from per-tag-category admin configuration (shop_product_tag_categories.tag_category_behavior / .tag_values_behavior). The same request can therefore return different rows after an admin edits that configuration, with nothing in the request itself changing — this is why it is a second, opt-in key rather than tagId's default behaviour.
    • Both keys apply to GET /rest/product/product and GET /rest/product/product/item, plus their locale-prefixed twins. The two may be sent together (ANDed); an empty or unresolvable selection matches no products under either key, never the unfiltered list. Neither key touches the ?with=tags embed or the product resource shape.
    • Full rationale, including the rejected legacy-JOIN query plan and the accepted mixed-configuration divergence, is recorded in docs/decisions/720-rest-product-tag-filter.md.
  • [4.122.0] refactor(rest/webhooks): retire the REST payment-webhook family — never reachable, never called (Advisable-com/ecommercen#721)

    • The finding. Advisable\Rest\Webhooks\Controllers\Webhook and its five routes (POST /rest/webhooks/{stripe,vivawallet,paypal,piraeus,paybybank}) have never had a rest_policies.php entry, so every request has always inherited the global auth => backend default and returned 401 — continuously since 4.99.6, when the routes were first declared behind the APP_REST_API_ENABLED flag, through 4.104.0, the release that extracted them into an unconditionally-reachable route file. Three independent lines of evidence confirm nothing ever called them: no policy entry has ever existed (git log -S"Webhooks\Controllers\Webhook" -- application/config/rest_policies.php returns zero commits); no code path anywhere on the platform emits a /rest/webhooks/ URL to a gateway — every gateway-bound notification/return URL is a legacy one; and #620 recorded production logs showing 0 hits across all five routes in 7 days.
    • What's removed. application/config/webhook_routes.php, src/Rest/Webhooks/ (the Webhook controller and its container.php), the @include('webhook_routes.php') in application/config/routes.php, the Webhooks entry in application/config/container/modules.php, six unit tests under tests/Unit/Rest/Webhooks/, and tests/Integration/Routing/WebhookRoutesAreAlwaysAvailableTest. Two adapter docblocks (PiraeusAdapter, PayByBankAdapter) that cited the retired routes as the confirmation mechanism were corrected to cite the legacy checkout/get_response/{gateway} callback instead.
    • Why this is a cleanup, not a breaking change. Same precedent as the 1.55 / #605 cartContents removal: no client can have been relying on these routes succeeding, because they never succeeded. A caller now gets 404 where it previously got 401 — both are failures, neither ever confirmed an order.
    • Legacy is unaffected and remains the sole payment-webhook surface.Adv_checkout::get_response() and every {payway}Response() handler behind it, plus the string-routed async webhooks in routes.php (vivaWallet/([a-zA-Z]+), jccNotification, handleSkroutzWebhook, klarnaAuthorizationCallback/(:any)), are untouched — they never entered the REST middleware pipeline in the first place, since MY_Router::_parse_routes() rewrites only array routes to RouterDispatcher.
    • Full rationale, rejected alternatives, and the defects tracked separately rather than fixed here (#619, #620, #621) are recorded in docs/decisions/721-retire-rest-webhooks.md.
  • [4.122.0] fix(legacy/storage): keep the 403 directory guard in the web root when UpgradeStorage relocates a folder (Advisable-com/ecommercen#693)

    • The defect. database/migrations/20250514144356_storage_moving.php shells out to php cli.php job/UpgradeStorage, which calls (new InternalMoveFolder())->execute('files/forms', '../storage/forms'). AdvInternalMoveFolder::moveFiles() was an unfiltered array_diff(scandir($source), ['.', '..']) + rename() loop, so the tracked directory guard public/files/forms/index.html was treated as payload and moved out of the web root to storage/forms/index.html, where a directory guard has no effect. Moving the folder contents out of the web root is deliberate and documented; only the guard travelling with them was the defect. On a stock tree it was the migration's only observable effect — public/files/invoices, public/files/vouchers/dhl and public/files/import do not exist, so three of the job's four calls hit the !is_dir($source) early return, and public/files/forms contains nothing but the stub.
    • The fix is at the single chokepoint, so it covers all four pairs and a client's own. AdvInternalMoveFolder gained protected const GUARD_FILES = ['.gitignore', '.gitkeep'], read as static::GUARD_FILES, and protected function isDirectoryIndexGuard(string $path): bool. moveFiles() skips an entry matching either, at every recursion depth. AdvUpgradeStorage is unchanged — its four hardcoded pairs, and any extra pair a client added in its own execute() override, all flow through the same class and inherit the fix with a zero-line diff.
    • The 403 guard is recognised by content, not by name or byte size: basename index.html, a readable regular file of at most 4096 bytes, containing the substring 403 Forbidden. A genuine index.html upload does not match and is still relocated, at any nesting depth — which is why no depth rule was needed. All 22 tracked public/files/*/index.html stubs are md5-identical (114 bytes, 0227cfd904e99656279202032b98d4a7), so the substring holds today, and the size bound keeps the check from reading a large upload.
    • Prevention alone would not have been enough. The 2025-05 migration has already run nearly everywhere, and on such a tree the job is a no-op, so the stray guard would simply stay outside the web root. AdvInternalMoveFolder::execute() now also sweeps the destination, before the is_dir($source) early return (a sweep after it never fires on a tree whose source folder is gone). Three branches: source guard absent + a recognised guard at the destination → it is rename()d back to the source; source guard present + a recognised guard at the destination → the destination stray is unlinked; a destination index.html that is not recognised → left untouched, logged at info. Every branch is a no-op when there is nothing to do. Both destructive calls report failure at error rather than failing silently: a unlink() that cannot remove the stray, and a rename() that cannot repatriate the guard — the latter being exactly the silent outcome this entry exists to eliminate.
    • The skip list and the destructive sweep are two separate lists, deliberately. The sweep considers index.html only — it never iterates GUARD_FILES. Each of the four move destinations ships a tracked .gitkeep (storage/forms/.gitkeep, storage/invoices/.gitkeep, storage/import/.gitkeep, storage/vouchers/dhl/.gitkeep, whitelisted against storage/*/* in .gitignore), so a sweep sharing one list with the skip would delete four tracked files. A regression test pins this on both sweep branches.
    • The rmdir failure log is split by residue, not softened. When @rmdir($source) fails after moveFiles(), AdvInternalMoveFolder keeps its existing error line verbatim if the residue holds any non-guard entry — the partial-migration signal that line exists for — and drops to info only when the residue is guard files it deliberately preserved. Both lines keep the AdvInternalMoveFolder: prefix, so an existing ops grep still matches.
    • Historical trees are remediated once. New patches/RepatriateStorageGuardStubs.php plus its Phinx migration (20260828120000_repatriate_storage_guard_stubs.php) re-invokes the whole UpgradeStorage job through the fixed class. The whole job, not the forms pair alone: every call in it is is_dir()/file_exists()-guarded, and it is the only route that also reaches the extra folder pairs a client added in its own override.
    • docs/guides/Storage.md — the "Prepare code updates for migration" recipe documents the guard handling and the GUARD_FILES tuning seam, and its example no longer contains an unterminated string literal ('../storage/the-folder)) that a client would copy-paste straight into a fatal. docs/flows/system/SY-28-storage-abstraction.md — the AdvUpgradeStorage / AdvInternalMoveFolder rows updated.
    • Tests: new tests/Legacy/Job/AdvInternalMoveFolderTest.php (12 tests, 39 assertions) over real sys_get_temp_dir() trees — guard preserved / payload moved, a genuine index.html still moved (so the content check cannot silently degrade into a name check), both sweep branches, a non-guard destination index.html left alone, .gitkeep/.gitignore fixtures, a nested directory whose source is still removed, idempotence across two runs, a destination .gitkeep surviving both sweep branches, and a subclass redeclaring GUARD_FILES with its control case — which proves the static:: seam rather than assuming it. Confirmed to fail against the pre-fix class before being reported as passing.
  • [4.122.0] fix(api): restore the legacy /api/priceTracking/* endpoints over GraphService, so the price chart survives APP_REST_API_ENABLED=false (Advisable-com/ecommercen#703)

    • The gap. PR #128 ported price tracking to the modern domain layer, repointed ProductPriceChart.vue at GET /rest/product/price-tracking/graph/{id}, and deleted the legacy /api/priceTracking/* surface. Those /rest/* routes are registered only when APP_REST_API_ENABLED is true (application/config/routes.php), and that flag ships off (.env.example) — so on a default-config store the chart fetched a 404 and never rendered.
    • What ships. GET /api/priceTracking/productPrices/{productId} and GET /api/priceTracking/listProductPrices/{id-id-id} (plus their {lang}/ locale variants) are restored in application/config/routes.php, registered unconditionally outside the APP_REST_API_ENABLED block — the same reasoning as webhook_routes.php (#31). ProductPriceChart.vue:232 is repointed back at the legacy endpoint, a straight revert of #128's one-line change.
    • The fix. A new ecommercen/api/controllers/AdvApiPriceTrackingController.php (extends Base_c, uses ApiEndpointTrait) delegates productPrices() and listProductPrices() to the modern Advisable\Domains\Product\PriceTracking\GraphService via di()->get(). application/modules/api/controllers/Api_price_tracking.php is restored as the thin extends AdvApiPriceTrackingController {} shim used by every sibling controller (Api_transporters.php, Api_vat.php). PR #128 deleted ten price-tracking classes, five per layer (each application/modules/ class a thin subclass of its ecommercen/ counterpart); #703 restores only the two controllers — the last row of each layer. The other eight — including AdvPriceTrackingGraphs, AdvPriceTrackingModel (ecommercen/) and Price_tracking_graphs, PriceTrackingOptions (application/modules/) — stay deleted; the /api and /rest surfaces both delegate to the same GraphService and cannot drift apart.
    • listProductPrices preserves the legacy shape, not the modern bulk shape. It emits a list with productId folded into each entry as a string ([{"productId":"1","prices":[…],"referencePrice":…}, …]), unlike GraphService::productPricesBulk()'s id-keyed map — while still using that method's single grouped query instead of the legacy per-product N+1 loop. A dash-delimited id token productPricesBulk() filters out as non-positive still yields an entry, with an empty graph payload, rather than being dropped. This repo has no caller for listProductPrices; it is restored unconditionally because client forks are separate repos and may already consume it.
    • A routing guard test, tests/Integration/Routing/PriceTrackingRoutesAreAlwaysAvailableTest.php (modelled on WebhookRoutesAreAlwaysAvailableTest), asserts both routes stay outside the flag block.
    • Full rationale, including the rejected alternatives, is recorded in docs/decisions/703-restore-legacy-price-tracking-api.md.
  • [4.122.0] fix(checkout): convert the cart item's DATETIME added_at to an epoch instead of casting it (Advisable-com/ecommercen#712)

    • The bug. OrderBasketBuilder::buildRow() applied a bare (int) to $item->added_at, which is a DATETIME string — CartItemEntity::$added_at is declared string (Entity.php:13), written by CartService.php:102 as date('Y-m-d H:i:s'), sourced from shop_cart_item.added_at (DATETIME). The destination, shop_order_basket.added_at, is an epoch int(11) indexed twice. (int)'2026-08-29 15:42:49' therefore stored the literal year — 2026 — not a timestamp. Every basket row written by POST /rest/checkout/place-order carried this defect; the legacy cart path always wrote genuine epochs and is unaffected.
    • The fix. buildRow() now converts with strtotime() into a local above the return array, with an inline comment naming the bug it corrects (OrderBasketBuilder.php:350-370). An unparseable value maps to null (via a strict !== false check) rather than 0 — 0 would read as 1970-01-01 in both indexes, a fresh wrong-but-plausible value in place of an obviously wrong one. A null added_at still writes null, which the gift-row path (buildGiftRow(), passing 'added_at' => null) relies on.
    • Why the suite was green while the column was corrupt. The unit-test fixture supplied a PHP int for added_at, which survives a bare (int) cast unchanged, so tests/Unit/Domains/Checkout/OrderBasketBuilderTest.php never exercised the DATETIME-string shape production actually sends. The fixture now supplies a DATETIME string and asserts against the same strtotime() conversion the code uses, not a hardcoded epoch literal.
    • Two consumers were reading the corrupted value, both verified during triage: ecommercen/iqvia/libraries/AdvIqviaUpload.php treats the corrupt (truthy) value as present, so its !empty() branch replaced a correct fallback with 1970-01-01 00:33:46 in a pharmaceutical regulatory submission; ecommercen/eshop/models/Adv_reporting_model.php takes MIN(added_at) and joins on equality, so the corrupt row was always selected and — because the join matches on equality — returned several rows where one was intended, inflating attributed-revenue counts.
    • Historical rows are NOT remediated by this delivery. Rows already written by REST checkout keep their corrupted added_at, so the IQVIA and reporting consequences above continue for those rows until a separate remediation is decided. This was a deliberate GATE 1 call, not an oversight — full rationale, the rejected alternatives (approximating from shop_order.entry_datetime, mapping to 0), and the recommendation if remediation is later taken (NULL over approximate) are recorded in docs/decisions/712-order-basket-added-at-write.md.
    • Advisable-com/ecommercen#707's read-side cast at src/Rest/Order/Resources/Basket/Resource.php is unchanged and still needed — it stays correct for genuine epoch rows (legacy cart) and now also covers correctly-converted REST-written rows.
  • [4.122.0] fix(rest/marketplace): cast shopflix_orders.timestamp / .last_order_change before formatting (Advisable-com/ecommercen#711)

    • The bug. Both columns are varchar(30) holding a Unix epoch as a numeric string (e.g. "1704067200"). The DB driver returns them as strings, so BaseResource::formatDate() took its is_string branch and new \DateTime("1704067200") threw Failed to parse time string. Every GET /rest/marketplace/shopflix/order carrying a dated row returned 500 — including its /item and /{id} routes and their locale-prefixed twins (rest_routes.php:1249-1254).
    • Scope. GET /rest/marketplace/shopflix/order is auth => backend, restricted to AUTH_ROLE_ADMIN / AUTH_ROLE_ORDERS (application/config/rest_policies.php:866). Not a storefront surface — no guest or customer traffic was affected.
    • The fix. Resource.php gains an (int) cast at the call site on timestamp / lastOrderChange only, using the file's existing !== null ? … : null idiom. voucherCreationDatetime (a real datetime column) is deliberately untouched. This mirrors what Adv_shopflix_orders_model::getLastShopflixOrderCreated() already does with the same column: the legacy readers were correct and REST now follows them. A companion admin-list fix (application/views/admin/shopflix_orders/list.php:126) gives the unguarded date("d-m-Y H:i", $row->created_at) the same falsy guard its neighbour at :128 already had — that value is shopflix_orders.timestamp aliased, and a NULL previously rendered as the current time, a silent wrong answer rather than a 500.
    • Payload shape is unchanged. A field that previously 500'd the whole response now returns a value. The OpenAPI attributes already declared format: 'date-time' on both properties, so the payload now matches the published schema rather than changing it.
    • Full rationale, including the acceptance-criteria evidence trail, is recorded in docs/decisions/711-shopflix-datetime-epoch-cast.md.
  • [4.122.0] fix(rest/order): cast the basket added_at epoch before formatting (Advisable-com/ecommercen#707)

    • The gap. GET /rest/v1/order/order?with=basket returned 500 whenever a basket row's added_at was written by the legacy cart path (AdvApiCartController.php:236 → eshop_helper.php:122 / Adv_order_model.php:587, which writes a genuine 10-digit Unix epoch) — list and single reads alike, so a REST or headless consumer could not render order history for any order placed that way. shop_order_basket.added_at is stored as an int(11) Unix epoch, but the DB driver hands it back to PHP as a numeric string; BaseResource::formatDate() branches on PHP type, so the string fell past its is_int epoch branch into new \DateTime($date), which throws Failed to parse time string. That path is self-poisoning — every order it places breaks its own read-back — so it did not age out with dump-era rows the way a data-quality bug normally would. A basket row written by the modern REST checkout path (OrderBasketBuilder.php:362) never hit this 500: it stores (int) $item->added_at against a CartItemEntity::$added_at that is itself a DATETIME string (Entity.php:13, written by CartService.php:102 as date('Y-m-d H:i:s')), so (int)'2026-08-29 15:42:49' writes the small integer 2026, not an epoch — a separate write-path defect, tracked as Advisable-com/ecommercen#712 and out of scope here (its read-side effect on this fix is covered in the Notes below).
    • The fix casts at the call site, not in the shared formatter.Advisable\Rest\Order\Resources\Basket\Resource::resource() now casts added_at to int before calling formatDate(), inside the same !== null ternary its sibling keys already use. BaseResource::formatDate() is deliberately untouched: content-sniffing there (e.g. ctype_digit()) cannot distinguish an epoch from a bare digit string like "2024" or "20240115", and a change there would alter behaviour at all 54 call sites that share it.
    • The write path is unchanged — the column is int(11) and indexed twice, so writing an int was already correct.
    • Full rationale, including the rejected ctype_digit() alternative, is recorded in docs/decisions/707-basket-added-at-epoch-cast.md.
  • [4.122.0] feat(rest/event): expose four one-sided datetime filters on the event listing (Advisable-com/ecommercen#635)

    • The gap. events.datetime_start and events.datetime_end were already emitted to guest callers and already sortable, but neither was in allowedFilters — so a headless storefront could not build an "upcoming events" widget or an events archive at all. It had to fetch every event, page by page, and filter client-side. Legacy has always been able to filter on the column directly (Adv_events_model.php:309 forces datetime_start > $date for the footer widget), so this is REST catching up to a capability the platform already had — a capability gap, not a security one, and the opposite direction from #626, which removed rows from storefront callers on this same endpoint.
    • Four keys, on GET /rest/event/event and GET /rest/event/event/item.filter[datetimeStartGte] and filter[datetimeStartLt] over events.datetime_start; filter[datetimeEndGte] and filter[datetimeEndLt] over events.datetime_end. Upcoming is ?filter[datetimeStartGte]=&lt;now>; the archive is ?filter[datetimeEndLt]=&lt;now>. Available to every caller — guest, customer and backend alike.
    • The boundary is half-open — [lower, upper) per column — and that is a deliberate choice, not an inherited default. An inclusive lower bound (Gte, >=) and an exclusive upper bound (Lt, &lt;). Half-open is what lets two adjacent windows partition a calendar without either dropping or double-counting an event sitting exactly on the boundary: an event starting exactly now counts as upcoming, and an event ending exactly now is not yet archived. The inclusive-upper (Lte) and exclusive-lower (Gt) variants are deliberately not declared — a second spelling of one boundary is how two clients end up disagreeing about it.
    • The keys compose, so no containment key was added — and the composition approximates legacy's containment, it is not identical to it. A lower and an upper bound on the same column bound a window; filter[datetimeStartLt]=X with filter[datetimeEndGte]=X expresses "events running on date X". Legacy's own containment condition (Adv_events_model::applyConditions(), :143-151) is datetime_start &lt;= X AND datetime_end >= X — inclusive on the start. The composition of these two keys gives datetime_start &lt; X AND datetime_end >= X instead, because these keys deliberately expose the exclusive upper bound (Lt) on datetime_start, not the inclusive one (Lte). The two conditions agree everywhere except an event starting at exactly X: legacy counts it as running on X, the composition excludes it. No dedicated parameter is offered for the exact condition.
    • No new filter machinery. FilterRequestType::Gte/Lt and their FilterOperatorMap arms already existed (#642); Event\Service inherits the shared BuildsFilterSpecifications dispatch with no filterOperator() override, so declaring the keys is the whole change. Event is now the second declarer of these types after Product\Product.
    • No forced date window, deliberately. Legacy applies none to its general or category listings — only the footer widget does — so an unfiltered listing keeps returning past events. This exposes a filter; it does not scope rows.
  • [4.122.0] fix(rest/checkout): make vivawallet reachable in the modern REST checkout layer for the first time, across three surfaces (Advisable-com/ecommercen#706)

    • The gap. Three modern call sites — PaymentInitializerFactory::registerVivaWallet(), Checkout::verifyVivaWalletPayment() and Webhook::handleVivaWalletVerification() — read vivawallet_-prefixed settings keys, but the only reader available to them, getVivaWalletSettings(), has only ever returned unprefixed keys (merchant_id, api_key, source_code, gift_card_source_code, production, installments, minOrderAmount), and none of the three ever loaded the config file where client_id/client_secret actually live. Zero overlap between what was read and what existed, so every credential guard was unconditionally taken — vivawallet could never initialise on any deployment.
    • What ships. GET /rest/checkout/payment-methods starts listing vivawallet once a deployment's VIVAWALLET registry group and viva_wallet/viva_wallet_dev config file are populated — the same shape AdvFactories::vivaWallet() already builds on the legacy checkout path. POST /rest/checkout/place-order stops refusing vivawallet with the #673 422 payway_not_available and actually creates a Viva order. The verification branch of POST /rest/webhooks/vivawallet stops unconditionally returning 500 and returns a real webhook verification key.
    • The fix. A new reader, getVivaWalletMergedSettings() (ecommercen/helpers/registry_helper.php), merges the VIVAWALLET registry group with the viva_wallet/viva_wallet_dev config file — selecting the file off the registry production flag, and degrading to registry-only on a missing config file rather than 500ing. All three call sites resolve credentials through it via two new VivaWallet\Config statics: fromArray() (keyed on the reader's unprefixed names, no remapping) and isConfigured().
    • Two distinct credential guards, deliberately kept separate. The payment-creation and payment-verification paths authenticate against the OAuth2 pair (client_id/client_secret, via VivaWallet::buildHeaders()) and gate on Config::isConfigured(); the webhook verification-key path authenticates against a separate legacy pair (merchant_id/api_key, via VivaWallet::buildWebhookVerificationKeyHeaders()) and gates on that pair directly rather than calling isConfigured(). A deployment can have either pair configured without the other, and unifying the guards would break whichever path lost its check.
    • A performance fix rides along. PaymentInitializerFactory::create() runs on every GET /rest/checkout/payment-methods call and every place-order call, and constructing a VivaWallet client chains into a live OAuth2 HTTP POST (VivaWallet\Authentication::requestAccessToken()) at construction time — previously unreachable because the credential guard always failed first, but a live regression once the guard is fixed. VivaWalletAdapter's constructor now takes a factory closure instead of a constructed client and resolves it lazily on first use inside initializePayment(), so listing payment methods (and any place-order for a different payway) no longer makes an outbound call to Viva.
    • Full rationale, including rejected alternatives, is recorded in docs/decisions/706-vivawallet-headless-credentials.md.
  • [4.122.0] fix(rest/event): scope guest event and event-category reads to their visibility flags, and scope the Event.categories relation (Advisable-com/ecommercen#626)

    • The gap. Two guest-readable endpoints hid (or advertised) a visibility flag while never scoping the rows, leaving the flag client-filterable — so a guest could request precisely the withheld set. GET /rest/event/event had the family's usual three-fact shape: the Resource hid active, nothing removed the rows, ?filter[active]=0 asked for exactly what the projection was withholding. GET /rest/event/event-category was worse — published was not hidden at all but emitted to every caller, so a guest could read each row's editorial state and then ask for the hidden ones. The exposure was never bare row existence: both Mui resources emit the record body with no isBackend() gate (EventMuiResource → description, the mediumtext event body, plus subtitle/short_description/location; EventCategoryMuiResource → name, slug, fulltext and all three meta fields), so one unauthenticated ?filter[...]=0&with=translations returned complete pre-publication copy in bulk.
    • Parity restoration, not a new policy. Every legacy storefront path already forces both flags — Adv_events.php:61 (index), :119/:128 (category listing), Adv_events_model.php:309/:343, and Adv_event_categories_model::getCategoriesFront() (:238, published => 1). REST was the only surface serving these rows. The show() 404 is the one deliberate tightening: both legacy detail paths match on slug + lang with no visibility predicate, the same inversion #618 flagged for the legacy product detail page and declined to port.
    • The existing ScopesStorefrontRows seam (#624), not a hand-rolled scope. Both controllers adopt the trait and implement storefrontRowScope(); enforceStorefrontRowScope() is wired into all three read actions and show() additionally calls denyHiddenStorefrontRow(), because show() fetches by primary key outside the filter pipeline and a forced filter can never reach it.
    • = 1, not &lt;> 0 — and it matters. events.active is int(1) DEFAULT NULL (initial.sql:490), so NULL-active events are now withheld from storefront callers. That is correct parity: legacy's where(['active' => true]) compiles to active = 1, and NULL = 1 is NULL rather than TRUE. Both halves of the seam agree by different mechanisms — the forced filter emits events.active = '1'; the per-row gate casts to int, where (int) null === 0. event_categories.published is tinyint(1) NOT NULL DEFAULT 1 (initial.sql:522) and has no NULL case at all.
    • One sort denial, and it is deliberately not the flag key on the event endpoint — the first adopter of this trait where the two differ. ?sort=dateChanged is denied for storefront callers on /rest/event/event: events.date_changed is sortable, is withheld from a storefront payload, and no forced filter makes it constant, so it was a live ordering oracle over a hidden column. ?sort=active is not denied — under the forced filter it orders by a constant, so a denial would withhold a sort that leaks nothing (#613's reasoning). /rest/event/event-category denies published, which goes constant in the same way: retained as the fail-safe direction and for uniformity with #624's Page and BlogCategory adopters.
    • The relation half, which the endpoint fix does not reach. A new Advisable\Domains\Event\Category\EventCategoryVisibilityScope (stateless, mirroring PageVisibilityScope) is declared on Event.categories as a Relation::$visibilityScope, so ?with=categories no longer returns unpublished categories at any nesting depth, including the depth-2 /rest/product/product?with=events.categories form that no per-endpoint relations allow-list can reach. Hiding a category row takes its translations hop with it.
    • Backend is unaffected throughout: the whole scope is skipped under ResourceContext::isBackend(), both flag keys stay declared in allowedFilters and allowedSorts, show() still returns hidden rows with their bodies, and the relation stays unscoped for admins via the single central Relation::VISIBILITY_EXEMPT_ALL grant — an admin must still be able to attach an unpublished category to an event.
    • Full rationale, including rejected alternatives, is recorded in docs/decisions/626-event-guest-row-scoping.md.
  • [4.122.0] fix(eshop/admin): flash a reason when an admin category delete is refused (Advisable-com/ecommercen#678)

    • The gap. Adv_product_categories_admin::delete() gated the delete on canDeleteRecord() but had no else branch, set no message, and redirected unconditionally. An operator refused for holding subcategories or assigned products saw the page reload with the category still there and nothing explaining why — a legitimate refusal was indistinguishable from a dead button.
    • Decide-then-explain, reused from the sibling. canDeleteRecord() remains the sole DECISION of whether the delete is allowed and is unmodified. A new protected function resolveDeleteRefusalLangKey(int $id): string on the controller consults hasChildren()/hasProducts() purely to phrase the refusal, mirroring the split Advisable\Domains\Product\Category\LegacyDeletionRule::refusalReason() shipped one day earlier for the REST path under #676.
    • The refusal now sets eshop_error via $this->session->set_userdata(), naming which condition blocked: subcategories, products, or both. Three new placeholder-free language keys ship in all eight locale bundles (ecommercen/language/{chinese,english,french,german,greek,italian,russian,spanish}/adv_advisable_lang.php): categories.admin.delete.has_children.error, categories.admin.delete.has_products.error, categories.admin.delete.has_children_and_products.error. A fourth case — canDeleteRecord() refusing while neither predicate reports a blocker, reachable only in a fork that widened the rule — degrades to the existing eshop.admin.error.cannot_delete key.
    • The success branch now also sets eshop_success with the existing eshop.admin.success.entrydelete key, matching Adv_blog_tags_admin::delete() — but only when delete_record() reports success. #677 landed on develop first and made Adv_product_category_model::delete_record() transactional, ending in return (bool) $this->db->trans_status(); its commit message noted that return went unobserved because "the method's sole caller discards the value" — true until this success flash gave that return somewhere to go. delete() now checks it: eshop_success fires only on true; on false it sets eshop_error with the existing eshop.admin.error.cannot_delete key (no new language key), and afterDelete() plus clearCache('product_categories_vendors') now run only in that success-confirmed branch — a deliberate change, since afterDelete()'s contract is "a delete happened" and it must not fire for a delete that rolled back. The redirect is unchanged.
    • No view file changed: application/views/admin/partials/page_heading.php already renders eshop_error as an alert-danger, and the category list view already includes that partial.
    • Third and most operator-visible of the follow-ups split out of #669's triage, and the sibling of #676.
    • Full rationale, including rejected alternatives, is recorded in docs/decisions/678-silent-category-delete-refusal.md.
  • [4.122.0] fix(eshop): delete a product category's dependent link rows with the category, atomically (Advisable-com/ecommercen#677)

    • The leak. Adv_product_category_model::delete_record() (ecommercen/eshop/models/Adv_product_category_model.php) deleted the shop_product_category_mui translations and the shop_product_category row, and nothing else. Five dependent link tables survived their category: shop_product_category_lp (product↔category links), shop_product_category_group_tags_lp, shop_product_category_relative_prod, shop_product_category_lists and product_category_blog (blog-article links, keyed product_category_id). None of those tables is protected by a foreign key — the schema declares zero foreign keys anywhere — so nothing else cleaned up behind it. Because id is an AUTO_INCREMENT that MySQL can reuse, the residue is not merely dead weight: hasProducts() reads shop_product_category_lp alone, so a reused id inherits a dead category's product links and can read as "has products" for a category that never had any.

    • The fix. delete_record() now deletes, inside one trans_start()/trans_complete() transaction, those five dependent tables plus the _mui rows and the category row. Either the category and all five go, or nothing does. Existing sibling methods in the same file already used that idiom (updateCategoryTagGroup(), update_product_category_lists()), so no new transaction style was introduced.

    • What is cleaned is a named set, not "everything that references the category". The tables cleaned are exactly: shop_product_category_lp, shop_product_category_group_tags_lp, shop_product_category_relative_prod (both columns), shop_product_category_lists and product_category_blog. coupon_categories.category_id is deliberately NOT cleaned — it is the remaining reference to shop_product_category.id, living in the coupons module (ecommercen/coupons/models/Adv_coupons_model.php:13, written at :189/:284, read at :51-57 through getChildrenCategories(), which resolves against shop_product_category.parent_id), and it carries no index on category_id. It is excluded as cross-module scope for this patch release, so a deleted category still leaves its coupon-category links behind; a follow-up issue (Advisable-com/ecommercen#694) is filed for it. Read the list above literally — a fork deciding what its own override must adopt cannot infer the set from a phrase like "every dependent link row".

    • product_category_blog is in the set for the same reason as the rest. The same admin screen that deletes categories writes it (ecommercen/eshop/controllers/Adv_product_categories_admin.php:1005-1006, product_category_blog_model->saveBatch($categoryId, $articles), gated on the registry key ESHOP.BLOG_PRODUCT_CATEGORY_ENABLED), and the modern read layer serves it as the category's articles relation — so a reused AUTO_INCREMENT id inherits a dead category's blog links and shows foreign articles on a category that never had any. Its category column is product_category_id, not category_id.

    • shop_product_category_relative_prod is cleaned on BOTH of its category columns. That table holds category_id and relative_category_id; a row pointing at the deleted category through relative_category_id is exactly as orphaned as one pointing at it through category_id, and on the seeded database the inbound side is the larger half (42 rows vs 28). Cleaning only category_id would have left the bigger half of that table's residue behind while reporting the table as handled.

    • The bug is reachable today through the gated admin path, which the issue thought it was not. canDeleteRecord() inspects hasChildren() (parent_id) and hasProducts() (shop_product_category_lp only) — it never looks at group_tags_lp, relative_prod, lists or product_category_blog. Measured on the seeded database, 15 of 40 categories pass the gate — 14 of them real fixtures with dependent rows, the 15th (888001) stray test residue with none — and deleting all 15 would orphan 89 distinct rows across all five cleaned tables:

      tablerows orphaned
      shop_product_category_group_tags_lp14
      shop_product_category_relative_prod44 (distinct — see below)
      shop_product_category_lists0 (empty in this seed)
      product_category_blog31
      shop_product_category_lp0 (by construction — the gate blocks on this table)
      total distinct rows89

      The 44 is a distinct count, not a sum, and must not be "corrected" upward. It is measured as WHERE category_id IN (gate) OR relative_category_id IN (gate). The two per-column counts are 28 on category_id and 42 on relative_category_id — each correct on its own, and together the reason both columns are cleaned — but 26 rows have both sides inside the gate-passing set, so adding them gives 70 and double-counts those 26.

      The figure moved twice, for two different reasons; only one of them was an arithmetic mistake. An earlier draft of this fragment said 84: wrong arithmetic (the relative_prod per-column counts summed) over an incomplete set of four tables. Correcting only the arithmetic gave 58 — right arithmetic, still the incomplete set. 89 is right arithmetic over the complete five-table set. So the count did not merely deflate and should not be reverted toward the older, smaller numbers.

      product_category_blog's 31 rows are all live links. That table holds 72 rows in total with 0 existing orphans, so every one of the 31 rows attached to a gate-passing category is a link that a permitted delete would orphan today — the largest contributor after relative_prod, and the concrete case for pulling it into the cleaned set rather than leaving it as a sixth leak.

      So the gate masks the _lp half of this bug and none of the rest; that is why the fix cleans all five tables rather than only the one the issue names.

    • The always-true success guard is gone. if ($this->db->affected_rows() >= 0) was true for every input — affected_rows() is non-negative and returns 0 for a delete matching nothing — so the method returned true unconditionally, including when the category delete itself failed. The return now comes from trans_status(): false means the transaction failed and nothing was committed. Signature and return type are unchanged (function delete_record($id)), and the method's sole caller in the repo (Adv_product_categories_admin::delete()) discards the value, so no caller can observe the new false. Surfacing a failed delete to the operator is deliberately not in this change — it is tracked as Advisable-com/ecommercen#678, which this fix is what makes implementable.

    • No gate was added. canDeleteRecord() is applied by the caller, and the REST path's missing children/products gate is a distinct defect, owned and fixed by Advisable-com/ecommercen#676; a second copy of that rule inside the model would have directly conflicted with how #676 landed.

    • The historical residue is swept once. New patches/CleanOrphanProductCategoryRows.php plus its Phinx migration (20260826120000_clean_orphan_product_category_rows.php) deletes, from all five tables, only rows whose category provably no longer exists — a multi-table DELETE … LEFT JOIN shop_product_category … WHERE id IS NULL, two-sided on relative_prod. Safe by construction: it cannot match a row whose category still exists. shop_product_category_lists is guarded by an information_schema existence check, because that table is created by a migration rather than by initial.sql and the patcher is also invokable on its own (php cli.php patcher/&lt;base64>), outside Phinx's ordering.

    • On the modern delete path, four of the five references are what actually change. Advisable\Domains\Product\Category\WriteService::delete(), reached by DELETE /rest/product/category/{id}, deleted the category and its _mui rows only; the cleanup is now repository-mediated inside the existing transactional() closure. It runs after the delete gate that Advisable-com/ecommercen#676 adds in the same release — and that gate refuses any category holding shop_product_category_lp rows before the cleanup runs. So what actually changes REST-observable behaviour is the four ungated references (shop_product_category_group_tags_lp, shop_product_category_relative_prod on both columns, shop_product_category_lists, product_category_blog); the _lp half is now unreachable through DELETE /rest/product/category/{id}. _lp cleanup is implemented anyway, deliberately, as defence in depth: deleteDependentRows() is public and reachable directly, and a future narrowing of the gate must not silently reintroduce the leak. No REST contract, response shape or OpenAPI change.

    • Foreign keys with ON DELETE CASCADE were rejected, deliberately: the platform declares zero foreign keys, so adding its first one to fix a bug would be a schema-philosophy change smuggled into a patch release, and it would fail on any install whose residue violates it until swept.

    • Tests: new tests/Integration/Legacy/Eshop/AdvProductCategoryModelDeleteRecordTest.php (2 tests, 19 assertions) — the first pins all five dependent tables plus the two-sided relative_prod delete and asserts a neighbouring category's rows survive, so an over-broad delete fails as loudly as a missing one; the second isolates the inbound-only relative_prod case, so dropping the or_where('relative_category_id', …) clause fails a test by itself. New tests/Integration/Legacy/Eshop/CleanOrphanProductCategoryRowsTest.php (1 test, 16 assertions) manufactures orphans — the seeded database has zero, so an unmodified sweep there is a no-op that proves nothing — then asserts they are swept and the live rows are not. Both were confirmed to fail against the pre-fix code before being reported as passing.

  • [4.122.0] fix(rest/product): apply the legacy delete gate to DELETE /rest/product/category/{id} (Advisable-com/ecommercen#676)

    • The gap. The endpoint applied no delete gate at all, while the legacy admin has always refused a category with child categories or with products assigned to it (Adv_product_category_model::canDeleteRecord()). A backend user holding the ADMIN or PRODUCTS role who was refused in the admin UI could delete the same category through the API, taking out the whole child subtree and orphaning its shop_product_category_lp rows — silent, and recoverable only from backup. Not an unauthenticated hole: application/config/rest_policies.php:349-356 opens only index/show/item to guests, so destroy was backend-only throughout.
    • Why now. #669 (shipped a day earlier) fixed the legacy predicate hasProducts(), so the admin began enforcing the rule correctly while REST kept ignoring it entirely. This closes that divergence.
    • The rule is consulted, not copied. A new Advisable\Domains\Product\Category\LegacyDeletionRule calls canDeleteRecord() on the legacy model, loaded by the fork-overridable name eshop/product_category_model — the same name the admin controller uses — so the composite rule keeps exactly one owner, and a fork that customises it has that customisation honoured on both paths. Category\Validator::validateForDelete() turns a refusal into a ValidationException naming the blocking condition; Category\WriteService::delete() calls it before opening its transaction.
    • Advisable\Rest\Product\Controllers\Category::destroy() gains an OpenAPI 409 response documenting the refusal shape.
    • Full rationale, including rejected alternatives, is recorded in docs/decisions/676-rest-category-delete-gate.md.

Notes

  • [4.122.0] Check for overrides: Advisable\Domains\Product\Product\Service::__construct() gained a required TagFacetResolver $tagFacetResolver parameter at position 3, before $completeDescriptionMinChars. DI is unaffected — the container binds the threshold by name and autowires the new dependency — but a client fork that overrides this constructor and calls parent::__construct() positionally will pass its int into the resolver slot and get a TypeError at instantiation. Forks overriding Product\Service must check that call.

    Deployments must delete cache/container.php — the compiled container is stale against the new constructor signature. This bit twice in-repo during delivery.

  • [4.122.0] filter[tagFacetId]'s result is not a fixed function of its input alone: it depends on live, per-shop tag-category admin configuration. A fork or integrator reading only the entry above could otherwise assume the same ids always resolve to the same rows — they do not, once tag_category_behavior / tag_values_behavior are edited.

  • [4.122.0] Do not treat filter[tagFacetId] as unqualified legacy parity. It matches the legacy storefront exactly on the default configuration (every tag category AND-behaviour, values OR) and on an all-OR configuration. On a mixed configuration (at least one AND-behaviour and one OR-behaviour category) it can differ: legacy's per-tag INNER joins constrain the result set from outside the WHERE group whose OR branches were meant to make them optional, and this implementation deliberately uses correlated subqueries instead of those joins — the join pattern multiplies rows, corrupts count() and every pagination total, and is the exact load pattern behind a past database stampede. Parity of results is what is reproduced; parity of query plan is not. This divergence was accepted by the maintainer and is documented, not a defect to fix.

  • [4.122.0] UI Update: none. No storefront or admin surface changed.

  • [4.122.0] No DB migration, no new route, and no change to the ?with=tags embed or the product resource shape. application/config/rest_api_versions.php carries its own 1.X entry for this change, authored and maintained separately from this fragment.

  • [4.122.0] Check for overrides — two seams, not one:

    • A client fork that aliased or subclassed Advisable\Rest\Webhooks\Controllers\Webhook via its own custom/Rest/container.php DI registration: that alias now points at a class that no longer exists, and the DI container will fail to compile on that fork until the alias is removed or repointed.
    • A client fork that copied application/config/webhook_routes.php into its own application/ tree (the application/** file-shadow seam): that fork's copy keeps routing /rest/webhooks/{...} to Webhook::class, which no longer exists — a request there now fatals (class-not-found) instead of the pre-existing 401. Delete the fork's copy of the route file; the routes were never reachable in a useful way regardless.
  • [4.122.0] Fact: no gateway console needs reconfiguration because of this change — every gateway notification/return URL this platform has ever configured is a legacy one. If a merchant console is nonetheless still set to a /rest/webhooks/ URL, it has been receiving 401s since 4.99.6 (unconditionally so from 4.104.0 onward) and is already non-functional; this change only turns that 401 into a 404. Repoint it at the legacy callback URL.

  • [4.122.0] No schema change, no migration, no composer install, no frontend build. See the (unstamped) 1.Xapplication/config/rest_api_versions.php entry dated 2026-08-31 for the published-surface record.

  • [4.122.0] REQUIRES php migrator.php migrate:

    • 20260828120000_repatriate_storage_guard_stubs.php (Advisable-com/ecommercen#693) — re-invokes job/UpgradeStorage, which now repatriates an already-moved 403 guard stub to its source folder, or removes it from the destination when a deploy has already restored the source copy. It moves at most one small file per folder pair, is idempotent, and is safe to re-run; on a healthy tree it is a no-op. It must land after the code fix — running it against the unfixed AdvInternalMoveFolder would move a restored stub out of the web root a second time.
    • 20260826120000_clean_orphan_product_category_rows.php (Advisable-com/ecommercen#677) — a one-time sweep of orphaned product-category link rows. It deletes only rows whose shop_product_category row no longer exists, so it cannot touch live data, but on a long-lived shop it may remove a large number of rows in five unbounded multi-table DELETEs (a multi-table DELETE cannot take a LIMIT). Expect it to run longer on a big shop_product_category_lp. The sweep is not transactional, unlike the delete it backfills. AbstractPatcher::up() shells the patcher out to a separate php cli.php patcher/&lt;base64> process, so the five DELETEs commit independently rather than inside Phinx's migration transaction. That is harmless here — each statement touches only provably-orphaned rows, so it is idempotent and safe to re-run after an interruption — but do not read the entry's "atomically" as covering the sweep: it describes the delete paths, not this one-time cleanup.
  • [4.122.0] Check for overrides: InternalMoveFolder:: (application/libraries/StorageJobs/InternalMoveFolder.php) — this repo's subclass is an empty pass-through and inherits both the fix and the remediation. A fork that reimplements execute() wholesale, or copies the old moveFiles() body, keeps the defect: its guard stubs keep leaving the web root and no sweep ever repatriates them. A fork that wants extra skip names redeclares the constant with the spread form — protected const GUARD_FILES = [...parent::GUARD_FILES, 'client-marker.txt'];. array_merge(parent::GUARD_FILES, [...]) in a constant expression is a compile-time fatal (Constant expression contains invalid operations) that takes the whole file down, not just the job. The constant must stay protected or wider and must not be final; the base reads it as static::, never self::. A fork with a differently worded stub overrides protected function isDirectoryIndexGuard(string $path): bool rather than adding index.html to GUARD_FILES, which would also stop genuine index.html uploads from moving. A fork's own extra folder pairs — added per docs/guides/Storage.md as parent::execute() plus its own execute() calls — are covered automatically, with no change to its UpgradeStorage override.

  • [4.122.0] UpgradeStorage:: / AdvUpgradeStorage::execute() is unchanged — signature and body — so no fork override breaks.

  • [4.122.0] This is not a live exposure. No autoindex directive exists in the shipped nginx configuration, so nginx's default autoindex off applies and the unguarded directory does not list. The guard is defence in depth: it protects against a server whose configuration enables directory listing. Do not read this entry as a disclosed information leak.

  • [4.122.0] No schema change, no REST contract change, no OpenAPI diff, no composer install, no npm run all-production.

  • [4.122.0] Both surfaces now ship — the released v1.5 note is accurate history, not current behaviour.GET /rest/product/price-tracking/graph/* (application/config/rest_api_versions.php:33, v1.5) is unchanged and still serves its existing headless consumers (Velora, MCP); it was never removed by this change. Only the legacy /api/priceTracking/* surface the storefront chart depends on is restored alongside it. The v1.5 entry's "replacing the legacy /api/priceTracking/* surface" wording described what PR #128 did at the time and is left as a historical record.

  • [4.122.0] Check for overrides: new class ecommercen/api/controllers/AdvApiPriceTrackingController.php — a public, non-final base controller a client fork may override (the same seam every sibling Api_* controller uses via its thin subclass). A fork with its own Api_price_tracking.php override — rather than the plain extends AdvApiPriceTrackingController {} shim restored here — should confirm its override still resolves GraphService equivalently.

  • [4.122.0] Check for overrides — listProductPrices malformed-token behaviour changed: pre-#128, a malformed/non-numeric id token (e.g. GET /api/priceTracking/listProductPrices/1-abc-3) reached the legacy AdvPriceTrackingGraphs::productPrices(int $productId) — a file with no declare(strict_types=1) — where PHP 8's coercive typing threw a TypeError converting 'abc' to int, so the request 500'd. Now that token keeps its slot in the response with an empty graph payload, so the endpoint returns 200. This is deliberate (AC5 requires the slot to survive so a caller can zip the response against its own id list) and correctly implemented — but it is a contract change on the restored surface, and a fork consuming listProductPrices should not assume the old 500-on-malformed-token behaviour.

  • [4.122.0] UI Update: assets/main/vue/product/ProductPriceChart.vue:232 — the chart's apiUrl reverts to `/api/priceTracking/productPrices/${this.productId}`, undoing PR #128's repoint to the REST endpoint. No new props, no config plumbing.

  • [4.122.0] Build: AdvApiPriceTrackingController.php is loaded via the composer classmap (composer.json's "ecommercen" entry) — composer dump-autoload is required on a warm local tree; a fresh composer install (CI) already covers it.

  • [4.122.0] Check for overrides: no protected/public base-class method changed signature — buildRow() is private. But OrderBasketBuilder is DI-registered (src/Domains/Checkout/container.php:21 — $services->set(OrderBasketBuilder::class);), so a client fork can alias it to a Custom\ copy. Such a fork carries its own buildRow() and keeps writing the corrupt value after merging this fix — silently, with no error — and must apply the same strtotime() conversion in its own copy:

    php
    $addedAtEpoch = $item->added_at !== null ? strtotime((string) $item->added_at) : false;
    // ...
    'added_at' => $addedAtEpoch !== false ? $addedAtEpoch : null,

    This is worse than the usual override-drift pattern (see the #707/#711 notes below): an unmerged fork here does not just render a wrong value on read, it keeps writing corrupt data at rest on every order placed, with nothing to signal that it is happening.

  • [4.122.0] Historical data: pre-fix REST-placed shop_order_basket rows keep added_at = &lt;year>. No patcher exists in this delivery and none is planned unless a follow-up remediation issue is opened — see docs/decisions/712-order-basket-added-at-write.md for the full record and the production row count needed to decide between remediation options.

  • [4.122.0] No schema change, no route change, no new field, filter, sort, or auth change. strtotime() failure modes and two currently-unreachable input shapes (an already-epoch string; an out-of-range date past 2038) are recorded in the decisions doc rather than guarded in code, since added_at is not client-settable on any wired path today.

  • [4.122.0] Check for overrides: no base-class method changed, so nothing signature-level. But a client fork that overrode Custom\Rest\Marketplace\Resources\ShopflixOrder\Resource would have copied the whole resource() array, including the raw formatDate() calls on timestamp / last_order_change — such a fork keeps the 500 after merging this fix and must apply the same (int) cast in its own copy.

  • [4.122.0] UI Update: a client fork with its own copy of application/views/admin/shopflix_orders/list.php should apply the same falsy guard at :126 that :128 already had — application/ is the override lane, so a fork's copy shadows this file entirely and gains nothing from the merge. Without it, a NULL created_at (which is shopflix_orders.timestamp aliased) keeps rendering as the current time in that fork's admin order list: date() accepts ?int, and a NULL is a PHP 8.1 deprecation returning "now" rather than a fatal, so the wrong date appears silently rather than failing loudly.

  • [4.122.0] Check for overrides: no base-class method was added, removed, or re-signed — BaseResource::formatDate() is unchanged, so an upstream override of it is unaffected by this change. But the fix lives in the body of Advisable\Rest\Order\Resources\Basket\Resource::resource(), not in a shared helper. A client fork that has copied this class into custom/Rest/Order/Resources/Basket/Resource.php and DI-aliased it keeps the 500 after merging this fix — silently, with no error and no warning — because the fork's copy shadows the fixed line and this merge gives it nothing. The fork owner must re-apply the cast in their own copy:

    php
    'addedAt' => $this->resource->added_at !== null
        ? $this->formatDate((int)$this->resource->added_at)
        : null,

    Do not copy this cast to src/Rest/Cart/Resources/CartItem/Resource.php. It reads an identically named added_at field, but from shop_cart_item, where the column is DATETIME, not an epoch — (int)'2026-08-29 10:00:00' is 2026, an epoch in 1970. The shipped fix carries an inline comment naming this at the call site; a fork owner re-applying the cast by hand needs the same warning.

  • [4.122.0] This fix is not retroactive-only for the legacy cart path. Because that path's defect is self-poisoning, a fork that has not merged this fix is still generating fresh 500s on every order placed through legacy cart checkout today — "our old orders already read fine" is not a reason to defer merging this. (An order placed through modern REST checkout never hit this 500 in the first place — see the entry above and the note below.)

  • [4.122.0] No schema change, no route change, no new field, filter, sort or config key, and no auth change. A request that used to 500 now succeeds — but not every already-succeeding response is byte-identical. A basket row written by the modern REST checkout path (OrderBasketBuilder.php:362) holds the small integer 2026 rather than a real epoch, and its rendered addedAt moves: pre-fix, the raw string "2026" reached new \DateTime() and was parsed as the time 20:26 today (2026-08-29 20:26:00); post-fix, the same 2026 is cast and read as a Unix-epoch second count (1970-01-01 00:33:46). Neither value is a correct timestamp for that row — the write-side defect that produces 2026 is separate from this fix and is tracked as Advisable-com/ecommercen#712. application/config/rest_api_versions.php carries its own 1.X entry for this change, authored and maintained separately from this fragment.

  • [4.122.0] UI Update: four new query parameters on GET /rest/event/event and GET /rest/event/event/item — filter[datetimeStartGte], filter[datetimeStartLt], filter[datetimeEndGte], filter[datetimeEndLt]. Purely additive: no existing parameter, response field, status code or default ordering changes, and a client that sends none of them sees exactly the responses it saw before. ?sort=datetimeStart and ?sort=datetimeEnd continue to work unchanged for every caller, and a bound and a sort over the same column coexist.

  • [4.122.0] UI Update — undated events disappear from a filtered listing. events.datetime_start and events.datetime_end are both timestamp NULL DEFAULT NULL, and in SQL NULL >= x is NULL rather than TRUE. So an event saved with no start date is excluded from any request carrying a datetimeStart* bound while still being returned by an unfiltered listing — and, independently, the same holds for datetime_end. This is intended, and it is parity: legacy's own upcoming-events widget excludes exactly the same rows, while its general listing applies no date predicate and returns them. No OR ... IS NULL is added. Two practical consequences for a headless storefront: an event whose dates an editor never filled in will silently vanish from an "upcoming" widget built on datetimeStartGte, and the two columns are judged independently — a bound on the start column says nothing about a NULL end column. A client that wants undated events keeps that column's bound off.

  • [4.122.0] UI Update — send a well-formed datetime; a malformed bound is coerced, not dropped. This API has no 4xx path for a filter value (an established platform convention), and this endpoint adds no value guard of its own — so an unparseable bound is neither rejected nor ignored: the database coerces it. An unparseable Gte bound widens to "has a date at all"; an unparseable Lt bound matches nothing; a value with a valid datetime prefix is truncated to that prefix. This is the pre-existing behaviour of every comparison filter whose domain service adds no value guard (Product\Product\Service's numeric guard on filter[priceGte] is the only such guard in the platform, and it is a per-endpoint guard, not a shared one) — it is not introduced by these four keys. Callers that build a bound from user input should validate it client-side.

  • [4.122.0] These filters narrow within #626's storefront row scope, they do not bypass it. A guest or authenticated-customer caller still has filter[active] server-forced to 1; the new bounds sit on two different columns, are ANDed with it, and cannot displace it — a bound sent alongside filter[active]=0 still gets the forced 1. Backend callers get the same four filters with no forced scope.

  • [4.122.0] No Check for overrides: note applies — checked against precedent, not just reasoned. No protected or public member is added, removed, renamed or re-signatured on any base class; the change is four array entries in Advisable\Domains\Event\Event\ListRequest::setAllowedFilters() plus OpenAPI annotations. A fork that carries its own copy of Event\ListRequest simply will not gain the four keys — and because an undeclared filter key is dropped silently rather than rejected, that fork's own "upcoming events" widget sending filter[datetimeStartGte]=&lt;now> gets back an unfiltered listing that still includes past events, with an HTTP 200 and no error: a wrong-looking page, not merely a missing feature. Re-adding the four keys is a copy of those four lines. This is not a new judgment call: #642 added priceGte/priceGt/priceLte/ priceLt to Product\Product\ListRequest::setAllowedFilters() in the identical shape — new array entries only, no signature change — and its changelog entry (Changelog.4.121.md) carries a UI Update: note and no Check for overrides: note. The deliveries that do earn one on an unsignatured method (#669's hasProducts(), #674/#679's settings controller) are each correcting a live bug or a security gap, where an unreconciled fork keeps a real defect. A purely additive, backward-compatible capability leaves nothing for a fork to break — it only lags one feature behind, the same as any unported change — so it does not meet that bar.

  • [4.122.0] No DB migration, no route change, no new configuration key and no DI change are involved. application/config/rest_api_versions.php carries its own 1.X entry for this change, authored and maintained separately from this fragment.

  • [4.122.0] Fact: client_id/client_secret are deliberately not added to the VIVAWALLET registry group or the admin panel — they continue to live only in the per-client viva_wallet.php/viva_wallet_dev.php config files, selected by the registry production flag, exactly as the legacy checkout path already does. This was a settled acceptance criterion, not an oversight for a future change to "fix."

  • [4.122.0] Check for overrides: the new reader helper getVivaWalletMergedSettings() (ecommercen/helpers/registry_helper.php) — a fork that shadows getVivaWalletSettings() via the application/helpers/registry_helper.php seam should confirm its override still flows through the merged reader, which delegates to getVivaWalletSettings() internally.

  • [4.122.0] Check for overrides: VivaWallet\Config's two new statics, fromArray(array $settings): self and isConfigured(): bool — a fork with its own Config subclass, or its own settings-to-Config mapping, should adopt these rather than keep a private remap.

  • [4.122.0] Check for overrides: VivaWalletAdapter::__construct's changed signature — was (VivaWallet $vivaWallet, string $sourceCode), now (callable $vivaWalletFactory, string $sourceCode). Any client override or direct instantiation must pass a zero-arg closure returning VivaWallet.

  • [4.122.0] UI Update: published is removed from every EventCategory payload a storefront (guest/customer) caller receives — the key becomes absent, not null. Because Advisable\Rest\Event\Resources\Event\Resource reuses EventCategory\Collection for ?with=categories, it also disappears from that nested embed at every depth. A headless storefront reading category.published must stop: the key will not be there. Nothing else moves in either payload.

  • [4.122.0] UI Update: GET /rest/event/event/{id} and GET /rest/event/event-category/{id} now return 404 to a storefront caller for a row that exists but is hidden (events.active not 1 — including NULL — or event_categories.published = 0). A client that deep-links to an id it expects to resolve must handle that 404 as a normal outcome. /item resolves the same way as show(), not the same way as the index listing. A storefront caller resolving GET /rest/event/event/item or GET /rest/event/event-category/item by filter[slug.{locale}] (or any other filter that narrows to one row) now gets that same 404 for a hidden row, not the row: the forced flag filter excludes it before item() finds a match, the identical outcome to show(). A headless storefront that resolves its detail page by slug through /item — rather than by numeric id through show() — must add 404 handling there too, or it will start losing pages it previously rendered. Only the index listing returns a scoped 200, never an empty-set error: a supplied filter[active] / filter[published] there is ignored rather than combined, and the response is simply the visible subset.

  • [4.122.0] UI Update: ?sort=dateChanged on /rest/event/event and ?sort=published on /rest/event/event-category are silently dropped for storefront callers. Both still work for backend callers. ?sort=active on /rest/event/event is deliberately kept for everyone.

  • [4.122.0] UI Update: NULL-active events stop appearing on REST storefront reads. If a tenant has been relying on REST to surface them (they were already absent from every legacy storefront listing), set active = 1 on those rows.

  • [4.122.0] Check for overrides: Advisable\Domains\Event\Event\Repository\RepositoryConfigurator gains a second constructor argument — EventCategoryVisibilityScope, alongside #637's ProductVisibilityScope. This is the silent one: a fork carrying its own copy of this configurator is autowired by FQCN, constructs fine, boots the container green — and never gains the relation scope, so ?with=categories keeps returning unpublished categories with no error and no warning. Same failure shape as #625's and #676's configurator/validator notes. Preferred remedy: drop the forked copy and let upstream's configurator autowire. If a fork must keep its own copy, add EventCategoryVisibilityScope as a constructor dependency and pass visibilityScope: $this->eventCategoryVisibilityScope->relationScope() as a named trailing argument on the categories relation (positionally it would land in $pivotTable).

  • [4.122.0] Check for overrides: Advisable\Rest\Event\Controllers\Event and Advisable\Rest\Event\Controllers\EventCategory each gain a new protected storefrontRowScope(): StorefrontRowScope member and now call enforceStorefrontRowScope() in index()/show()/item() plus denyHiddenStorefrontRow() in show(). A fork overriding any of those three actions without calling parent:: keeps the unscoped behaviour. A fork with an extra visibility column of its own should override storefrontRowScope() (or isStorefrontVisible()) and only ever narrow what a storefront sees.

  • [4.122.0] Check for overrides: Advisable\Domains\Event\Category\EventCategoryVisibilityScope is new and registered as a public autowired service in src/Domains/Event/container.php. Its table() / column() are protected methods, not constants — deliberately, since a constant is early-bound and a fork subclass redefining one would be silently ignored. A fork whose event-category table or flag column differs overrides those two methods and aliases its subclass over the upstream service.

  • [4.122.0] No DB migration, no route change and no new configuration key are involved. application/config/rest_api_versions.php carries its own 1.X entry for this change, authored and maintained separately from this fragment.

  • [4.122.0] Check for overrides: Product_categories_admin::resolveDeleteRefusalLangKey() is a new protected method on the class a fork subclasses. It is additive, so no existing override breaks — a fork may now override it to reword the refusal without reimplementing the rule.

  • [4.122.0] Check for overrides: the Evripidis fork's hasProducts() override (#669) now also shapes the refusal WORDING, not only the delete decision — the new helper calls that same predicate purely to phrase the message. A fork whose predicate disagrees with upstream gets a refusal message matching its own rule — harmless, but a behaviour surface fork owners should know changed.

  • [4.122.0] Check for overrides: Product_categories_admin::afterDelete() now fires only when the transactional delete it wraps actually succeeded, not unconditionally on every delete attempt. Before #677 made delete_record()'s return meaningful, it always fired; a fork's override that assumed "delete attempted" rather than "delete happened" no longer sees it called for a delete that rolled back.

  • [4.122.0] Check for overrides: Product_category_model::delete_record() (application/modules/eshop/models/Product_category_model.php, extending Adv_product_category_model) — this repo's own subclass is an empty pass-through and inherits the fix. A fork that overrides delete_record(), or copies its body, keeps both defects: the five dependent tables keep leaking on every category delete, and the method keeps reporting success unconditionally. Such a fork must adopt the transaction-wrapped seven-table delete (five dependents + _mui + the category row) and the trans_status() return; re-running the patcher afterwards does not help, since the override will keep minting new orphans. A fork whose override calls parent::delete_record() and then does its own cleanup is safe but now redundant.

  • [4.122.0] Check for overrides: Custom\Domains\Product\Category\WriteService::delete() and Custom\Domains\Product\Category\Repository\WriteRepository::deleteDependentRows() — the modern delete path is the second changed base method, and it is container-bound (src/Domains/Product/container.php:58,61), so a fork can rebind either class to a Custom\ subclass or ship its own copy. WriteService::delete() now delegates the dependent-row cleanup to the new public WriteRepository::deleteDependentRows() inside the existing transactional() closure. A fork whose Custom\ WriteService overrides delete() without calling that method keeps the modern-path leak silently — the container compiles, DELETE /rest/product/category/{id} still returns its usual response, and only the orphan rows show it. A fork with its own WriteRepository copy must add deleteDependentRows() (or it fatals on the call), and must keep the same five tables — including the two-sided relative_prod delete and product_category_blog's product_category_id column. A fork that has not touched src/Domains/Product/Category/ inherits the fix. #676's gate makes this note more load-bearing, not less: a fork that keeps deleteDependentRows() but drops the _lp entry from WriteRepository::DEPENDENT_REFERENCES sees no REST symptom at all — the gate refuses such a category before the cleanup would run, so the omission stays silent until the gate is narrowed or the repository is called directly. And narrowing is a fork's to do: LegacyDeletionRule resolves canDeleteRecord() through the fork-overridable eshop/product_category_model, so a fork that narrows that rule — dropping the hasProducts() half — makes the _lp branch reachable through REST again, at which point a missing _lp entry is a live leak.

  • [4.122.0] Return semantics changed, signature did not. delete_record() can now return false. Any fork caller that treats the return as "always true" (e.g. an unconditional success flash) will start seeing false on a genuinely failed delete — which is the point. The main repo's only caller discards the value.

  • [4.122.0] No schema change, no REST contract change, no OpenAPI diff, no composer install, no npm run all-production.

  • [4.122.0] Check for overrides: Advisable\Domains\Product\Category\Validator gains a constructor where it previously had none — it now takes LegacyDeletionRule. This is the silent one: a fork carrying its own copy of this Validator is autowired by FQCN, constructs fine, boots the container green — and never gains the guard, so its REST endpoint keeps deleting gated categories with no error and no warning. Same failure shape as #625's RepositoryConfigurator note. Preferred remedy: drop the forked copy and let upstream's Validator autowire. If a fork must keep its own copy, add LegacyDeletionRule as a constructor dependency and call validateForDelete($id) before the delete proceeds.

  • [4.122.0] Check for overrides: HandlesUploadActions::doDestroy() (src/Rest/Support/Controllers/) now maps a ValidationException to a 409 ({"success": false, "errors": {"id": "…"&#125;&#125;), mirroring what HandlesWriteActions::doDestroy() already did. A fork carrying its own copy of the trait keeps turning refusals into 500s with errors discarded. This branch is unreachable today for the other 17 controllers using this trait — only Order/Vat guards a delete, and it uses HandlesWriteActions, not this trait — so the change is behaviour-neutral for them.

  • [4.122.0] Check for overrides: HandlesUploadActions::doDestroy() now deletes the entity's physical files AFTER the row delete succeeds, where it previously deleted them first. This is a real behaviour change, in one direction only, for every controller using this trait: files are no longer destroyed when the row delete fails or is refused. Previously, a refused or failed delete left the entity's images already gone with the row still present. A fork carrying its own copy of the trait keeps the old orphaned-file behaviour. The 18 controllers on this trait: Cms/{BlogArticle,BlogAuthor,BlogCategory,OfferCategory,Video}, Event/{Event,EventCategory}, Map/Map, Product/{Badge,Category,Download,Line,ProductList,Promo,Supplier,Tag,TagCategory,Vendor}.

  • [4.122.0] UI Update: DELETE /rest/product/category/{id} can now return 409 where it previously always succeeded. An admin UI or API client that treated delete as infallible should surface errors.id, which names the blocking condition ("it has child categories" / "it has products assigned to it", or both). A category with no children and no products still deletes exactly as before.

  • [4.122.0] No DB migration, no route change and no new configuration key are involved. application/config/rest_api_versions.php carries its own 1.X entry for this change, authored and maintained separately from this fragment.