Appearance
<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>
Version 4
version 4.122
[4.122.0] feat(rest/product): add
filter[tagId]andfilter[tagFacetId]to the product list endpoint (Advisable-com/ecommercen#720)- The gap.
GET /rest/product/productcould already embed a product's tags (?with=tags) but had no way to filter by them, in either direction:Product\ListRequestdeclared no tag key, andTag's repository declares no inverseproductsrelation. The failure was silent — an unknownfilter[]key is dropped rather than rejected — sofilter[tagId]=5previously 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 meaningfilter[categoryId]andfilter[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 thantagId's default behaviour.- Both keys apply to
GET /rest/product/productandGET /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=tagsembed 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.
- The gap.
[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\Webhookand its five routes (POST /rest/webhooks/{stripe,vivawallet,paypal,piraeus,paybybank}) have never had arest_policies.phpentry, so every request has always inherited the globalauth => backenddefault and returned 401 — continuously since 4.99.6, when the routes were first declared behind theAPP_REST_API_ENABLEDflag, 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.phpreturns 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/(theWebhookcontroller and itscontainer.php), the@include('webhook_routes.php')inapplication/config/routes.php, the Webhooks entry inapplication/config/container/modules.php, six unit tests undertests/Unit/Rest/Webhooks/, andtests/Integration/Routing/WebhookRoutesAreAlwaysAvailableTest. Two adapter docblocks (PiraeusAdapter,PayByBankAdapter) that cited the retired routes as the confirmation mechanism were corrected to cite the legacycheckout/get_response/{gateway}callback instead. - Why this is a cleanup, not a breaking change. Same precedent as the 1.55 / #605
cartContentsremoval: 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 inroutes.php(vivaWallet/([a-zA-Z]+),jccNotification,handleSkroutzWebhook,klarnaAuthorizationCallback/(:any)), are untouched — they never entered the REST middleware pipeline in the first place, sinceMY_Router::_parse_routes()rewrites only array routes toRouterDispatcher. - 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.
- The finding.
[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.phpshells out tophp cli.php job/UpgradeStorage, which calls(new InternalMoveFolder())->execute('files/forms', '../storage/forms').AdvInternalMoveFolder::moveFiles()was an unfilteredarray_diff(scandir($source), ['.', '..'])+rename()loop, so the tracked directory guardpublic/files/forms/index.htmlwas treated as payload and moved out of the web root tostorage/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/dhlandpublic/files/importdo not exist, so three of the job's four calls hit the!is_dir($source)early return, andpublic/files/formscontains nothing but the stub. - The fix is at the single chokepoint, so it covers all four pairs and a client's own.
AdvInternalMoveFoldergainedprotected const GUARD_FILES = ['.gitignore', '.gitkeep'], read asstatic::GUARD_FILES, andprotected function isDirectoryIndexGuard(string $path): bool.moveFiles()skips an entry matching either, at every recursion depth.AdvUpgradeStorageis unchanged — its four hardcoded pairs, and any extra pair a client added in its ownexecute()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 substring403 Forbidden. A genuineindex.htmlupload does not match and is still relocated, at any nesting depth — which is why no depth rule was needed. All 22 trackedpublic/files/*/index.htmlstubs 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 theis_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 isrename()d back to the source; source guard present + a recognised guard at the destination → the destination stray isunlinked; a destinationindex.htmlthat is not recognised → left untouched, logged atinfo. Every branch is a no-op when there is nothing to do. Both destructive calls report failure aterrorrather than failing silently: aunlink()that cannot remove the stray, and arename()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.htmlonly — it never iteratesGUARD_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 againststorage/*/*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
rmdirfailure log is split by residue, not softened. When@rmdir($source)fails aftermoveFiles(),AdvInternalMoveFolderkeeps its existingerrorline verbatim if the residue holds any non-guard entry — the partial-migration signal that line exists for — and drops toinfoonly when the residue is guard files it deliberately preserved. Both lines keep theAdvInternalMoveFolder:prefix, so an existing ops grep still matches. - Historical trees are remediated once. New
patches/RepatriateStorageGuardStubs.phpplus its Phinx migration (20260828120000_repatriate_storage_guard_stubs.php) re-invokes the wholeUpgradeStoragejob through the fixed class. The whole job, not theformspair alone: every call in it isis_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 theGUARD_FILEStuning 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— theAdvUpgradeStorage/AdvInternalMoveFolderrows updated.- Tests: new
tests/Legacy/Job/AdvInternalMoveFolderTest.php(12 tests, 39 assertions) over realsys_get_temp_dir()trees — guard preserved / payload moved, a genuineindex.htmlstill moved (so the content check cannot silently degrade into a name check), both sweep branches, a non-guard destinationindex.htmlleft alone,.gitkeep/.gitignorefixtures, a nested directory whose source is still removed, idempotence across two runs, a destination.gitkeepsurviving both sweep branches, and a subclass redeclaringGUARD_FILESwith its control case — which proves thestatic::seam rather than assuming it. Confirmed to fail against the pre-fix class before being reported as passing.
- The defect.
[4.122.0] fix(api): restore the legacy
/api/priceTracking/*endpoints overGraphService, so the price chart survivesAPP_REST_API_ENABLED=false(Advisable-com/ecommercen#703)- The gap. PR #128 ported price tracking to the modern domain layer, repointed
ProductPriceChart.vueatGET /rest/product/price-tracking/graph/{id}, and deleted the legacy/api/priceTracking/*surface. Those/rest/*routes are registered only whenAPP_REST_API_ENABLEDis 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}andGET /api/priceTracking/listProductPrices/{id-id-id}(plus their{lang}/locale variants) are restored inapplication/config/routes.php, registered unconditionally outside theAPP_REST_API_ENABLEDblock — the same reasoning aswebhook_routes.php(#31).ProductPriceChart.vue:232is repointed back at the legacy endpoint, a straight revert of #128's one-line change. - The fix. A new
ecommercen/api/controllers/AdvApiPriceTrackingController.php(extendsBase_c, usesApiEndpointTrait) delegatesproductPrices()andlistProductPrices()to the modernAdvisable\Domains\Product\PriceTracking\GraphServiceviadi()->get().application/modules/api/controllers/Api_price_tracking.phpis restored as the thinextends AdvApiPriceTrackingController {}shim used by every sibling controller (Api_transporters.php,Api_vat.php). PR #128 deleted ten price-tracking classes, five per layer (eachapplication/modules/class a thin subclass of itsecommercen/counterpart); #703 restores only the two controllers — the last row of each layer. The other eight — includingAdvPriceTrackingGraphs,AdvPriceTrackingModel(ecommercen/) andPrice_tracking_graphs,PriceTrackingOptions(application/modules/) — stay deleted; the/apiand/restsurfaces both delegate to the sameGraphServiceand cannot drift apart. listProductPricespreserves the legacy shape, not the modern bulk shape. It emits a list withproductIdfolded into each entry as a string ([{"productId":"1","prices":[…],"referencePrice":…}, …]), unlikeGraphService::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 tokenproductPricesBulk()filters out as non-positive still yields an entry, with an empty graph payload, rather than being dropped. This repo has no caller forlistProductPrices; 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 onWebhookRoutesAreAlwaysAvailableTest), 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.
- The gap. PR #128 ported price tracking to the modern domain layer, repointed
[4.122.0] fix(checkout): convert the cart item's DATETIME
added_atto 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_atis declaredstring(Entity.php:13), written byCartService.php:102asdate('Y-m-d H:i:s'), sourced fromshop_cart_item.added_at(DATETIME). The destination,shop_order_basket.added_at, is an epochint(11)indexed twice.(int)'2026-08-29 15:42:49'therefore stored the literal year —2026— not a timestamp. Every basket row written byPOST /rest/checkout/place-ordercarried this defect; the legacy cart path always wrote genuine epochs and is unaffected. - The fix.
buildRow()now converts withstrtotime()into a local above the return array, with an inline comment naming the bug it corrects (OrderBasketBuilder.php:350-370). An unparseable value maps tonull(via a strict!== falsecheck) rather than0—0would read as 1970-01-01 in both indexes, a fresh wrong-but-plausible value in place of an obviously wrong one. Anulladded_atstill writesnull, 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
intforadded_at, which survives a bare(int)cast unchanged, sotests/Unit/Domains/Checkout/OrderBasketBuilderTest.phpnever exercised the DATETIME-string shape production actually sends. The fixture now supplies a DATETIME string and asserts against the samestrtotime()conversion the code uses, not a hardcoded epoch literal. - Two consumers were reading the corrupted value, both verified during triage:
ecommercen/iqvia/libraries/AdvIqviaUpload.phptreats the corrupt (truthy) value as present, so its!empty()branch replaced a correct fallback with1970-01-01 00:33:46in a pharmaceutical regulatory submission;ecommercen/eshop/models/Adv_reporting_model.phptakesMIN(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 fromshop_order.entry_datetime, mapping to0), and the recommendation if remediation is later taken (NULL over approximate) are recorded indocs/decisions/712-order-basket-added-at-write.md. Advisable-com/ecommercen#707's read-side cast atsrc/Rest/Order/Resources/Basket/Resource.phpis unchanged and still needed — it stays correct for genuine epoch rows (legacy cart) and now also covers correctly-converted REST-written rows.
- The bug.
[4.122.0] fix(rest/marketplace): cast
shopflix_orders.timestamp/.last_order_changebefore 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, soBaseResource::formatDate()took itsis_stringbranch andnew \DateTime("1704067200")threwFailed to parse time string. EveryGET /rest/marketplace/shopflix/ordercarrying a dated row returned 500 — including its/itemand/{id}routes and their locale-prefixed twins (rest_routes.php:1249-1254). - Scope.
GET /rest/marketplace/shopflix/orderisauth => backend, restricted toAUTH_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.phpgains an(int)cast at the call site ontimestamp/lastOrderChangeonly, using the file's existing!== null ? … : nullidiom.voucherCreationDatetime(a realdatetimecolumn) is deliberately untouched. This mirrors whatAdv_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 unguardeddate("d-m-Y H:i", $row->created_at)the same falsy guard its neighbour at:128already had — that value isshopflix_orders.timestampaliased, 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.
- The bug. Both columns are
[4.122.0] fix(rest/order): cast the basket
added_atepoch before formatting (Advisable-com/ecommercen#707)- The gap.
GET /rest/v1/order/order?with=basketreturned 500 whenever a basket row'sadded_atwas 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_atis stored as anint(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 itsis_intepoch branch intonew \DateTime($date), which throwsFailed 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_atagainst aCartItemEntity::$added_atthat is itself aDATETIMEstring (Entity.php:13, written byCartService.php:102asdate('Y-m-d H:i:s')), so(int)'2026-08-29 15:42:49'writes the small integer2026, 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 castsadded_attointbefore callingformatDate(), inside the same!== nullternary 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 indocs/decisions/707-basket-added-at-epoch-cast.md.
- The gap.
[4.122.0] feat(rest/event): expose four one-sided datetime filters on the event listing (Advisable-com/ecommercen#635)
- The gap.
events.datetime_startandevents.datetime_endwere already emitted to guest callers and already sortable, but neither was inallowedFilters— 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:309forcesdatetime_start > $datefor 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/eventandGET /rest/event/event/item.filter[datetimeStartGte]andfilter[datetimeStartLt]overevents.datetime_start;filter[datetimeEndGte]andfilter[datetimeEndLt]overevents.datetime_end. Upcoming is?filter[datetimeStartGte]=<now>; the archive is?filter[datetimeEndLt]=<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,<). 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]=Xwithfilter[datetimeEndGte]=Xexpresses "events running on date X". Legacy's own containment condition (Adv_events_model::applyConditions(),:143-151) isdatetime_start <= X AND datetime_end >= X— inclusive on the start. The composition of these two keys givesdatetime_start < X AND datetime_end >= Xinstead, because these keys deliberately expose the exclusive upper bound (Lt) ondatetime_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/Ltand theirFilterOperatorMaparms already existed (#642);Event\Serviceinherits the sharedBuildsFilterSpecificationsdispatch with nofilterOperator()override, so declaring the keys is the whole change. Event is now the second declarer of these types afterProduct\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.
- The gap.
[4.122.0] fix(rest/checkout): make
vivawalletreachable 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()andWebhook::handleVivaWalletVerification()— readvivawallet_-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 whereclient_id/client_secretactually live. Zero overlap between what was read and what existed, so every credential guard was unconditionally taken —vivawalletcould never initialise on any deployment. - What ships.
GET /rest/checkout/payment-methodsstarts listingvivawalletonce a deployment'sVIVAWALLETregistry group andviva_wallet/viva_wallet_devconfig file are populated — the same shapeAdvFactories::vivaWallet()already builds on the legacy checkout path.POST /rest/checkout/place-orderstops refusingvivawalletwith the #673422 payway_not_availableand actually creates a Viva order. The verification branch ofPOST /rest/webhooks/vivawalletstops unconditionally returning 500 and returns a real webhook verification key. - The fix. A new reader,
getVivaWalletMergedSettings()(ecommercen/helpers/registry_helper.php), merges theVIVAWALLETregistry group with theviva_wallet/viva_wallet_devconfig file — selecting the file off the registryproductionflag, and degrading to registry-only on a missing config file rather than 500ing. All three call sites resolve credentials through it via two newVivaWallet\Configstatics:fromArray()(keyed on the reader's unprefixed names, no remapping) andisConfigured(). - Two distinct credential guards, deliberately kept separate. The payment-creation and payment-verification paths authenticate against the OAuth2 pair (
client_id/client_secret, viaVivaWallet::buildHeaders()) and gate onConfig::isConfigured(); the webhook verification-key path authenticates against a separate legacy pair (merchant_id/api_key, viaVivaWallet::buildWebhookVerificationKeyHeaders()) and gates on that pair directly rather than callingisConfigured(). 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 everyGET /rest/checkout/payment-methodscall and every place-order call, and constructing aVivaWalletclient 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 insideinitializePayment(), 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.
- The gap. Three modern call sites —
[4.122.0] fix(rest/event): scope guest event and event-category reads to their visibility flags, and scope the
Event.categoriesrelation (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/eventhad the family's usual three-fact shape: the Resource hidactive, nothing removed the rows,?filter[active]=0asked for exactly what the projection was withholding.GET /rest/event/event-categorywas worse —publishedwas 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 noisBackend()gate (EventMuiResource→description, the mediumtext event body, plus subtitle/short_description/location;EventCategoryMuiResource→name,slug,fulltextand all three meta fields), so one unauthenticated?filter[...]=0&with=translationsreturned 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, andAdv_event_categories_model::getCategoriesFront()(:238,published => 1). REST was the only surface serving these rows. Theshow()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
ScopesStorefrontRowsseam (#624), not a hand-rolled scope. Both controllers adopt the trait and implementstorefrontRowScope();enforceStorefrontRowScope()is wired into all three read actions andshow()additionally callsdenyHiddenStorefrontRow(), becauseshow()fetches by primary key outside the filter pipeline and a forced filter can never reach it. = 1, not<> 0— and it matters.events.activeisint(1) DEFAULT NULL(initial.sql:490), so NULL-activeevents are now withheld from storefront callers. That is correct parity: legacy'swhere(['active' => true])compiles toactive = 1, andNULL = 1is NULL rather than TRUE. Both halves of the seam agree by different mechanisms — the forced filter emitsevents.active = '1'; the per-row gate casts to int, where(int) null === 0.event_categories.publishedistinyint(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=dateChangedis denied for storefront callers on/rest/event/event:events.date_changedis 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=activeis 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-categorydeniespublished, 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, mirroringPageVisibilityScope) is declared onEvent.categoriesas aRelation::$visibilityScope, so?with=categoriesno longer returns unpublished categories at any nesting depth, including the depth-2/rest/product/product?with=events.categoriesform that no per-endpointrelationsallow-list can reach. Hiding a category row takes itstranslationshop with it. - Backend is unaffected throughout: the whole scope is skipped under
ResourceContext::isBackend(), both flag keys stay declared inallowedFiltersandallowedSorts,show()still returns hidden rows with their bodies, and the relation stays unscoped for admins via the single centralRelation::VISIBILITY_EXEMPT_ALLgrant — 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.
- 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.
[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 oncanDeleteRecord()but had noelsebranch, 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 newprotected function resolveDeleteRefusalLangKey(int $id): stringon the controller consultshasChildren()/hasProducts()purely to phrase the refusal, mirroring the splitAdvisable\Domains\Product\Category\LegacyDeletionRule::refusalReason()shipped one day earlier for the REST path under #676. - The refusal now sets
eshop_errorvia$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 existingeshop.admin.error.cannot_deletekey. - The success branch now also sets
eshop_successwith the existingeshop.admin.success.entrydeletekey, matchingAdv_blog_tags_admin::delete()— but only whendelete_record()reports success. #677 landed ondevelopfirst and madeAdv_product_category_model::delete_record()transactional, ending inreturn (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_successfires only ontrue; onfalseit setseshop_errorwith the existingeshop.admin.error.cannot_deletekey (no new language key), andafterDelete()plusclearCache('product_categories_vendors')now run only in that success-confirmed branch — a deliberate change, sinceafterDelete()'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.phpalready renderseshop_erroras analert-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.
- The gap.
[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 theshop_product_category_muitranslations and theshop_product_categoryrow, 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_listsandproduct_category_blog(blog-article links, keyedproduct_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. Becauseidis an AUTO_INCREMENT that MySQL can reuse, the residue is not merely dead weight:hasProducts()readsshop_product_category_lpalone, 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 onetrans_start()/trans_complete()transaction, those five dependent tables plus the_muirows 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_listsandproduct_category_blog.coupon_categories.category_idis deliberately NOT cleaned — it is the remaining reference toshop_product_category.id, living in the coupons module (ecommercen/coupons/models/Adv_coupons_model.php:13, written at:189/:284, read at:51-57throughgetChildrenCategories(), which resolves againstshop_product_category.parent_id), and it carries no index oncategory_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_blogis 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 keyESHOP.BLOG_PRODUCT_CATEGORY_ENABLED), and the modern read layer serves it as the category'sarticlesrelation — 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 isproduct_category_id, notcategory_id.shop_product_category_relative_prodis cleaned on BOTH of its category columns. That table holdscategory_idandrelative_category_id; a row pointing at the deleted category throughrelative_category_idis exactly as orphaned as one pointing at it throughcategory_id, and on the seeded database the inbound side is the larger half (42 rows vs 28). Cleaning onlycategory_idwould 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()inspectshasChildren()(parent_id) andhasProducts()(shop_product_category_lponly) — it never looks atgroup_tags_lp,relative_prod,listsorproduct_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:table rows 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 rows 89 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 oncategory_idand 42 onrelative_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_prodper-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 afterrelative_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
_lphalf 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 returns0for a delete matching nothing — so the method returnedtrueunconditionally, including when the category delete itself failed. The return now comes fromtrans_status():falsemeans 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 newfalse. 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.phpplus 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-tableDELETE … LEFT JOIN shop_product_category … WHERE id IS NULL, two-sided onrelative_prod. Safe by construction: it cannot match a row whose category still exists.shop_product_category_listsis guarded by aninformation_schemaexistence check, because that table is created by a migration rather than byinitial.sqland the patcher is also invokable on its own (php cli.php patcher/<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 byDELETE /rest/product/category/{id}, deleted the category and its_muirows only; the cleanup is now repository-mediated inside the existingtransactional()closure. It runs after the delete gate that Advisable-com/ecommercen#676 adds in the same release — and that gate refuses any category holdingshop_product_category_lprows 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_prodon both columns,shop_product_category_lists,product_category_blog); the_lphalf is now unreachable throughDELETE /rest/product/category/{id}._lpcleanup 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 CASCADEwere 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-sidedrelative_proddelete 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-onlyrelative_prodcase, so dropping theor_where('relative_category_id', …)clause fails a test by itself. Newtests/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 theADMINorPRODUCTSrole who was refused in the admin UI could delete the same category through the API, taking out the whole child subtree and orphaning itsshop_product_category_lprows — silent, and recoverable only from backup. Not an unauthenticated hole:application/config/rest_policies.php:349-356opens onlyindex/show/itemto guests, sodestroywas 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\LegacyDeletionRulecallscanDeleteRecord()on the legacy model, loaded by the fork-overridable nameeshop/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 aValidationExceptionnaming 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.
- 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 (
Notes
[4.122.0] Check for overrides:
Advisable\Domains\Product\Product\Service::__construct()gained a requiredTagFacetResolver $tagFacetResolverparameter 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 callsparent::__construct()positionally will pass itsintinto the resolver slot and get aTypeErrorat instantiation. Forks overridingProduct\Servicemust 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, oncetag_category_behavior/tag_values_behaviorare 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-tagINNERjoins 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, corruptscount()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=tagsembed or the product resource shape.application/config/rest_api_versions.phpcarries its own1.Xentry 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\Webhookvia its owncustom/Rest/container.phpDI 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.phpinto its ownapplication/tree (theapplication/**file-shadow seam): that fork's copy keeps routing/rest/webhooks/{...}toWebhook::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.
- A client fork that aliased or subclassed
[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.phpentry 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-invokesjob/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 unfixedAdvInternalMoveFolderwould 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 whoseshop_product_categoryrow 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-tableDELETEs (a multi-tableDELETEcannot take aLIMIT). Expect it to run longer on a bigshop_product_category_lp. The sweep is not transactional, unlike the delete it backfills.AbstractPatcher::up()shells the patcher out to a separatephp cli.php patcher/<base64>process, so the fiveDELETEs 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 reimplementsexecute()wholesale, or copies the oldmoveFiles()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 stayprotectedor wider and must not befinal; the base reads it asstatic::, neverself::. A fork with a differently worded stub overridesprotected function isDirectoryIndexGuard(string $path): boolrather than addingindex.htmltoGUARD_FILES, which would also stop genuineindex.htmluploads from moving. A fork's own extra folder pairs — added perdocs/guides/Storage.mdasparent::execute()plus its ownexecute()calls — are covered automatically, with no change to itsUpgradeStorageoverride.[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
autoindexdirective exists in the shipped nginx configuration, so nginx's defaultautoindex offapplies 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, nonpm 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— apublic, non-finalbase controller a client fork may override (the same seam every siblingApi_*controller uses via its thin subclass). A fork with its ownApi_price_tracking.phpoverride — rather than the plainextends AdvApiPriceTrackingController {}shim restored here — should confirm its override still resolvesGraphServiceequivalently.[4.122.0] Check for overrides —
listProductPricesmalformed-token behaviour changed: pre-#128, a malformed/non-numeric id token (e.g.GET /api/priceTracking/listProductPrices/1-abc-3) reached the legacyAdvPriceTrackingGraphs::productPrices(int $productId)— a file with nodeclare(strict_types=1)— where PHP 8's coercive typing threw aTypeErrorconverting'abc'toint, 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 consuminglistProductPricesshould not assume the old 500-on-malformed-token behaviour.[4.122.0] UI Update:
assets/main/vue/product/ProductPriceChart.vue:232— the chart'sapiUrlreverts 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.phpis loaded via the composer classmap (composer.json's"ecommercen"entry) —composer dump-autoloadis required on a warm local tree; a freshcomposer install(CI) already covers it.[4.122.0] Check for overrides: no
protected/publicbase-class method changed signature —buildRow()isprivate. ButOrderBasketBuilderis DI-registered (src/Domains/Checkout/container.php:21—$services->set(OrderBasketBuilder::class);), so a client fork can alias it to aCustom\copy. Such a fork carries its ownbuildRow()and keeps writing the corrupt value after merging this fix — silently, with no error — and must apply the samestrtotime()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/#711notes 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_basketrows keepadded_at = <year>. No patcher exists in this delivery and none is planned unless a follow-up remediation issue is opened — seedocs/decisions/712-order-basket-added-at-write.mdfor 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, sinceadded_atis 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\Resourcewould have copied the wholeresource()array, including the rawformatDate()calls ontimestamp/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.phpshould apply the same falsy guard at:126that:128already had —application/is the override lane, so a fork's copy shadows this file entirely and gains nothing from the merge. Without it, a NULLcreated_at(which isshopflix_orders.timestampaliased) 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 ofAdvisable\Rest\Order\Resources\Basket\Resource::resource(), not in a shared helper. A client fork that has copied this class intocustom/Rest/Order/Resources/Basket/Resource.phpand 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 namedadded_atfield, but fromshop_cart_item, where the column isDATETIME, not an epoch —(int)'2026-08-29 10:00:00'is2026, 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 integer2026rather than a real epoch, and its renderedaddedAtmoves: pre-fix, the raw string"2026"reachednew \DateTime()and was parsed as the time20:26today (2026-08-29 20:26:00); post-fix, the same2026is 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 produces2026is separate from this fix and is tracked as Advisable-com/ecommercen#712.application/config/rest_api_versions.phpcarries its own1.Xentry for this change, authored and maintained separately from this fragment.[4.122.0] UI Update: four new query parameters on
GET /rest/event/eventandGET /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=datetimeStartand?sort=datetimeEndcontinue 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_startandevents.datetime_endare bothtimestamp NULL DEFAULT NULL, and in SQLNULL >= xis NULL rather than TRUE. So an event saved with no start date is excluded from any request carrying adatetimeStart*bound while still being returned by an unfiltered listing — and, independently, the same holds fordatetime_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. NoOR ... IS NULLis 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 ondatetimeStartGte, 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
Gtebound widens to "has a date at all"; an unparseableLtbound 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 onfilter[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 alongsidefilter[active]=0still gets the forced1. 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. Noprotectedorpublicmember is added, removed, renamed or re-signatured on any base class; the change is four array entries inAdvisable\Domains\Event\Event\ListRequest::setAllowedFilters()plus OpenAPI annotations. A fork that carries its own copy ofEvent\ListRequestsimply 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 sendingfilter[datetimeStartGte]=<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:#642addedpriceGte/priceGt/priceLte/priceLttoProduct\Product\ListRequest::setAllowedFilters()in the identical shape — new array entries only, no signature change — and its changelog entry (Changelog.4.121.md) carries aUI Update:note and noCheck for overrides:note. The deliveries that do earn one on an unsignatured method (#669'shasProducts(),#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.phpcarries its own1.Xentry for this change, authored and maintained separately from this fragment.[4.122.0] Fact:
client_id/client_secretare deliberately not added to theVIVAWALLETregistry group or the admin panel — they continue to live only in the per-clientviva_wallet.php/viva_wallet_dev.phpconfig files, selected by the registryproductionflag, 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 shadowsgetVivaWalletSettings()via theapplication/helpers/registry_helper.phpseam should confirm its override still flows through the merged reader, which delegates togetVivaWalletSettings()internally.[4.122.0] Check for overrides:
VivaWallet\Config's two new statics,fromArray(array $settings): selfandisConfigured(): bool— a fork with its ownConfigsubclass, or its own settings-to-Configmapping, 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 returningVivaWallet.[4.122.0] UI Update:
publishedis removed from everyEventCategorypayload a storefront (guest/customer) caller receives — the key becomes absent, not null. BecauseAdvisable\Rest\Event\Resources\Event\ResourcereusesEventCategory\Collectionfor?with=categories, it also disappears from that nested embed at every depth. A headless storefront readingcategory.publishedmust stop: the key will not be there. Nothing else moves in either payload.[4.122.0] UI Update:
GET /rest/event/event/{id}andGET /rest/event/event-category/{id}now return 404 to a storefront caller for a row that exists but is hidden (events.activenot 1 — including NULL — orevent_categories.published = 0). A client that deep-links to an id it expects to resolve must handle that 404 as a normal outcome./itemresolves the same way asshow(), not the same way as the index listing. A storefront caller resolvingGET /rest/event/event/itemorGET /rest/event/event-category/itembyfilter[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 beforeitem()finds a match, the identical outcome toshow(). A headless storefront that resolves its detail page by slug through/item— rather than by numeric id throughshow()— 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 suppliedfilter[active]/filter[published]there is ignored rather than combined, and the response is simply the visible subset.[4.122.0] UI Update:
?sort=dateChangedon/rest/event/eventand?sort=publishedon/rest/event/event-categoryare silently dropped for storefront callers. Both still work for backend callers.?sort=activeon/rest/event/eventis deliberately kept for everyone.[4.122.0] UI Update: NULL-
activeevents 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), setactive = 1on those rows.[4.122.0] Check for overrides:
Advisable\Domains\Event\Event\Repository\RepositoryConfiguratorgains a second constructor argument —EventCategoryVisibilityScope, alongside #637'sProductVisibilityScope. 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=categorieskeeps 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, addEventCategoryVisibilityScopeas a constructor dependency and passvisibilityScope: $this->eventCategoryVisibilityScope->relationScope()as a named trailing argument on thecategoriesrelation (positionally it would land in$pivotTable).[4.122.0] Check for overrides:
Advisable\Rest\Event\Controllers\EventandAdvisable\Rest\Event\Controllers\EventCategoryeach gain a newprotected storefrontRowScope(): StorefrontRowScopemember and now callenforceStorefrontRowScope()inindex()/show()/item()plusdenyHiddenStorefrontRow()inshow(). A fork overriding any of those three actions without callingparent::keeps the unscoped behaviour. A fork with an extra visibility column of its own should overridestorefrontRowScope()(orisStorefrontVisible()) and only ever narrow what a storefront sees.[4.122.0] Check for overrides:
Advisable\Domains\Event\Category\EventCategoryVisibilityScopeis new and registered as a public autowired service insrc/Domains/Event/container.php. Itstable()/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.phpcarries its own1.Xentry for this change, authored and maintained separately from this fragment.[4.122.0] Check for overrides:
Product_categories_admin::resolveDeleteRefusalLangKey()is a newprotectedmethod 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 madedelete_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, extendingAdv_product_category_model) — this repo's own subclass is an empty pass-through and inherits the fix. A fork that overridesdelete_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 thetrans_status()return; re-running the patcher afterwards does not help, since the override will keep minting new orphans. A fork whose override callsparent::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()andCustom\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 aCustom\subclass or ship its own copy.WriteService::delete()now delegates the dependent-row cleanup to the new publicWriteRepository::deleteDependentRows()inside the existingtransactional()closure. A fork whoseCustom\WriteServiceoverridesdelete()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 ownWriteRepositorycopy must adddeleteDependentRows()(or it fatals on the call), and must keep the same five tables — including the two-sidedrelative_proddelete andproduct_category_blog'sproduct_category_idcolumn. A fork that has not touchedsrc/Domains/Product/Category/inherits the fix. #676's gate makes this note more load-bearing, not less: a fork that keepsdeleteDependentRows()but drops the_lpentry fromWriteRepository::DEPENDENT_REFERENCESsees 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:LegacyDeletionRuleresolvescanDeleteRecord()through the fork-overridableeshop/product_category_model, so a fork that narrows that rule — dropping thehasProducts()half — makes the_lpbranch reachable through REST again, at which point a missing_lpentry is a live leak.[4.122.0] Return semantics changed, signature did not.
delete_record()can now returnfalse. Any fork caller that treats the return as "always true" (e.g. an unconditional success flash) will start seeingfalseon 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, nonpm run all-production.[4.122.0] Check for overrides:
Advisable\Domains\Product\Category\Validatorgains a constructor where it previously had none — it now takesLegacyDeletionRule. This is the silent one: a fork carrying its own copy of thisValidatoris 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'sRepositoryConfiguratornote. Preferred remedy: drop the forked copy and let upstream'sValidatorautowire. If a fork must keep its own copy, addLegacyDeletionRuleas a constructor dependency and callvalidateForDelete($id)before the delete proceeds.[4.122.0] Check for overrides:
HandlesUploadActions::doDestroy()(src/Rest/Support/Controllers/) now maps aValidationExceptionto a 409 ({"success": false, "errors": {"id": "…"}}), mirroring whatHandlesWriteActions::doDestroy()already did. A fork carrying its own copy of the trait keeps turning refusals into 500s witherrorsdiscarded. This branch is unreachable today for the other 17 controllers using this trait — onlyOrder/Vatguards a delete, and it usesHandlesWriteActions, 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 surfaceerrors.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.phpcarries its own1.Xentry for this change, authored and maintained separately from this fragment.