Appearance
<div style="display: none;" hidden="true" aria-hidden="true">Are you an LLM? You can read better optimized documentation at /changelog/Changelog.4.119.md for this page in Markdown format</div>
Version 4
version 4.119
[4.119.0] fix(rest-auth): return a well-formed
500JSON instead of a bare empty200when a REST action throws (Advisable-com/ecommercen#462)- Why.
POST /rest/v1/auth/customer/login— and, in principle, any REST endpoint — could return a malformed empty200(Content-Type: text/html, 0 bytes) when its authenticated-success path raised an uncaughtThrowablewhile building the response (e.g. a failure insideTokens::generateAccessToken()/generateRefreshToken()). Every intended response goes throughApiEndpointTrait::sendOutput(), so an emptytext/html200meant the action threw beforesendOutput()ran and the dispatcher emitted nothing. Root cause was the single action-dispatch chokepoint inRouterDispatcher::dispatch()(application/controllers/RouterDispatcher.php): it caught only\Exception(so PHPErrors escaped entirely) and, even when it caught, merely logged and returned without writing a response — leaving PHP's default empty200. - The change. New
Advisable\Rest\Support\SafeActionDispatch::invokeControllerAction()(src/Rest/Support/SafeActionDispatch.php) wraps the action call incatch(\Throwable)and emits a well-formed500JSON ({"message":"Internal server error."}) viasendError(); because CI3 buffers output and flushes only afterdispatch()returns, overwriting the buffered status + body here reaches the client before the flush.RouterDispatchernow dispatches actions through that guard and widens its DI-resolution and middleware catches from\Exceptionto\Throwable, so PHPErrors in those phases also surface a500JSON rather than a bare response. One chokepoint covers login, refresh, register, forgotPassword, resetPassword, and every other REST endpoint without per-actiontry/catch. The401(invalid credentials) and429(throttling) paths are unchanged; true uncatchable fatals (OOM/time-limit) are out of scope and stay handled by the existing_shutdown_handler. - Tests. New
tests/Unit/Rest/Support/SafeActionDispatchTest.phpdrives the real trait (Exception,TypeError, and a throwingTokensmock on the success path) and asserts a500JSON response, never a bare200. - No OpenAPI change — the
500is cross-cutting dispatcher behavior for all REST endpoints, not auth-specific, so noOA\Responsewas added. No REST API version, DB migration, or language-key changes.
- Why.
[4.119.0] fix(customer-auth): duplicate-tolerant REST login + null-hash guard (Advisable-com/ecommercen#463)
- Why. Two data-corruption classes could strand a customer at REST login. (1)
Adv_customer_model::checkCustomer()authenticated against an arbitrary first->row()for an email, so when an email had more than one non-guest row the correct credentials could be rejected. (2)Adv_customer_model::checkPassword()hashed a storedNULL/empty hash directly, tripping a PHP 8.1 null-to-string deprecation that a strict error handler could escalate to a fatal. - The change.
checkCustomer()(ecommercen/eshop/models/Adv_customer_model.php) now orders non-guest rows byid ASCand authenticates against whichever duplicate row actually owns the supplied password; the return contract is unchanged (customer id on success,0on failure) and a valid password is still required, so there is no auth bypass.checkPassword()rejects aNULL/empty stored hash up front and casts salt/password to string, removing the deprecation. - Recovery runbook. Adds
docs/guides/RestCustomerLoginRecovery.md— a read-only diagnostic SQL runbook to confirm the affected-account class and fleet-scan for others, plus confirmation-gated remediation proposals (no data-mutation patcher). - Scope. This is the in-repo half of #463; root-cause confirmation for the originally-stranded account needs prod DB access and remains open. The generic empty-
200hardening is #462; the phantom-200refresh-token follow-up is #498. - Tests. New
tests/Legacy/Eshop/AdvCustomerModelLoginTest.php(10 regression tests covering the duplicate-row and null-hash paths). - No REST API, DB migration, OpenAPI, or language-key changes.
- Why. Two data-corruption classes could strand a customer at REST login. (1)
[4.119.0] fix(rest-auth): surface a genuine
refresh_tokenswrite failure as a500instead of a phantom200(Advisable-com/ecommercen#498)- Why. Under CI3
db_debug=false(prod default), a failingrefresh_tokenswrite was silently swallowed — the driver returnedfalsewith no throw — soTokens::generateRefreshToken()(src/Rest/Auth/Tokens.php) returned a token that was never persisted, and the login / token-refresh / register endpoints replied a normal200carrying that unpersisted "phantom" refresh token (a later/refreshwith it then failed). This was the silent-failure sibling of the empty-200hardened in #462, and it made the #463 stranded-login incident hard to diagnose. - The change.
RefreshTokenModel::save()/::delete()(src/Rest/Auth/RefreshTokenModel.php) now inspect the driver-level$this->db->error()after the write via a new privateassertNoDbError(), and throw\RuntimeExceptionon a real DB error (a zero-rowDELETE— e.g. a first-ever login with no prior token — is not treated as an error). The throw is caught by #462'sSafeActionDispatch, so login/token-refresh/register now surface a well-formed500JSON instead of the phantom200. This is a REST error-path behavior change only — the success contract ({access_token, refresh_token}on a healthy write) is unchanged. The cron-onlydeleteBy()is unchanged. - Client forks. Internal to
RefreshTokenModel— public method signatures are unchanged, so no override seam is affected. - Tests. New
tests/Integration/Legacy/Eshop/RestCustomerLoginEmpty200Test.php(DB-backed, real MySQL) covers the missing-table write throwing, the end-to-end500JSON with norefresh_tokenin the body, and the unaffected happy path. - Docs.
docs/guides/RestCustomerLoginRecovery.mdupdated: classes 3/4/5 (and §1/§3/§7) now describe arefresh_tokenswrite error failing loud (500, via #462) instead of the pre-#498 swallowed/phantom-200behavior. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why. Under CI3
[4.119.0] refactor(customer-message-history): replace
message_typechannel magic strings with a sharedMessageChannelenum (Advisable-com/ecommercen#468)- Why. The
shop_customer_message_history.message_typedelivery-channel value was written as a bare'EMAIL'/'SMS'literal at every writer, with no shared source of truth — the same drift that caused #434's casing bug. Nothing tied the writers, the REST exact-match filter, and the docs to one definition. - The change. New
Advisable\Domains\Customer\CustomerMessageHistory\MessageChannelstring-backed enum (case EMAIL = 'EMAIL',case SMS = 'SMS').Adv_mailer::addEmailToCustomerHistory()(application/models/Adv_mailer.php) and both SMS helpers (ecommercen/helpers/sms_helper.php,ecommercen/helpers/shopmodule_helper.php) now emitMessageChannel::EMAIL->value/MessageChannel::SMS->valueinstead of the bare literals. Behavior-preserving — the emitted strings are byte-identical, so the REST exact-match filter and existing historical rows are unaffected. - Docs.
docs/flows/admin/AD-41-customer-mail-history.mdanddocs/flows/system/SY-24-email-dispatch.mdnow cite the enum as the definition of the channel values. - Client forks. A fork carrying its own override of
addEmailToCustomerHistory()or the SMS helpers keeps its own literal and does not inherit the enum reference — behavior is identical either way, so this is a non-breaking, cosmetic-for-forks change. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why. The
[4.119.0] chore(build): resolve
ecomntagfrom GitHub Packages, not Bitbucket SSH (Advisable-com/ecommercen#488)- Why. The private
ecomntagnpm dependency was previously pulled via a Bitbucketgit+sshsource, so both the Docker image build and Bitbucket Pipelines needed access to a Bitbucket SSH deploy key just to install it. That coupled the build to Bitbucket-specific SSH infrastructure ahead of the GitHub migration mirror (#334). - The change.
package.json'secomntagdependency now points to@advisable-com/ecomntag@^1.5.0(Internal) served from GitHub Packages, withpackage-lock.jsonregenerated soecomntagresolves fromnpm.pkg.github.com, and.npmrcscoped to the@advisable-comregistry with${NODE_AUTH_TOKEN}auth..docker/images/app.dockerfileswaps the SSH mount (--mount=type=ssh) for a secret mount (--mount=type=secret,id=npm_token), sourcingNODE_AUTH_TOKENfrom/run/secrets/npm_tokenfornpm run all-production. Bothbitbucket-pipelines.ymlbuild steps now write the$GH_PACKAGES_TOKENworkspace variable to a transientnpm_tokenfile and pass--secret id=npm_token(removed after each build) instead of--ssh default=$BITBUCKET_SSH_KEY_FILE.assets/vue/store/actions.jsandwebpack.mix.front.jsupdate their import/copy paths to the new package name. A follow-up commit completed the same migration for local development:.docker/scripts/build/build-common.sh's local-dev build path now passes--secret id=npm_tokeninstead of--ssh default;.docker/images/node.dockerfiledropsopenssh-clientandssh-keyscan bitbucket.orgfrom thenode_devimage;.docker/integration/dev.compose.yml'snode-clidev container no longer forwards the host$SSH_AUTH_SOCKand instead readsNODE_AUTH_TOKENfrom.env;.env.examplegained aNODE_AUTH_TOKENstub; and.gitignorenow ignores the transientnpm_tokensecret file. Module resolution only — no storefront/admin runtime behavior changed. - Client forks. See the "Check for overrides" note below — this is primarily a build/CI dependency-source change, not an application code change.
- No REST API, DB migration, OpenAPI, or language-key changes.
- Why. The private
[4.119.0] refactor(admin): extract email-preview sample data into overridable seams on
AdvEmailViewer(Advisable-com/ecommercen#426)- Why.
AdvEmailViewer::index()/initTestEmailViews()(ecommercen/settings/controllers/AdvEmailViewer.php) hard-coded the email-preview sample data payload, so a client fork wanting to customize the preview data (e.g. adding fork-specific fields, or loading extra helpers/models) had to patch the whole method in place — causing per-release merge conflicts. Mirrors thesampleCurrencyId()seam added to the same file in #422. - The change. Three new
protectedseams:sampleEmailData(): arrayreturns the base sample-data payload (today's exact hard-coded defaults);extendSampleEmailData(array $data): arrayis a no-op hook for forks to add/override sample-data keys;loadSampleDataDependencies(): voidis a no-op hook, called at the end ofinitTestEmailViews(), for forks to load extra helpers/models. The base seams return exactly today's defaults, so there is no behavior change for the base controller. - Client forks. Forks that previously patched
index()orinitTestEmailViews()directly to customize the email-preview sample data should migrate to overridingsampleEmailData()/extendSampleEmailData()/loadSampleDataDependencies()instead of maintaining a full method copy.
- Why.
[4.119.0] fix(video-stream): wire the missing
$storageconstructor argument on the DI-managed BunnyStream service, restoring container compilation (Advisable-com/ecommercen#491)- Why. Regression from the BunnyStream DI migration (#383):
src/VideoStream/container.phpregisteredAdvisable\VideoStream\Stream\BunnyStreamas a container service but never wired its$storageconstructor argument.Advisable\Storage\Storageisn't autowirable — its constructor takes untyped scalar$type/$diskand depends on the CI super-object being booted — so any fresh container compile (mergingdevelop, a new dev environment, a new branch) failed outright:Cannot autowire service "Advisable\VideoStream\Stream\BunnyStream": argument "$storage" of method "__construct()" references class "Advisable\Storage\Storage" but no such service exists.Already-cached compiled containers kept working until recompiled, which delayed discovery. - The change.
src/VideoStream/container.phpnow wires$storageas an anonymous inline Symfony service —inline_service(Storage::class)->args(['files', null])— givingBunnyStreama defaultStorage(files type, default disk), matching the pre-#383new BunnyStream($registry, new Storage())behavior exactly. The service is scoped as an inline (id-less) argument, not a globalStorage::classservice id, so it can't be picked up as an autowiring candidate for any otherStorage-typed constructor in the container. No change toBunnyStream's constructor signature, runtime behavior, or REST contract — purely an internal DI wiring fix. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why. Regression from the BunnyStream DI migration (#383):
[4.119.0] fix(checkout): make order-serial assignment atomic and reject empty/NULL-serial order lookups to stop orphaned PENDING orders and cross-customer order exposure (Advisable-com/ecommercen#473)
- Why.
Adv_order_model::processOrder()(ecommercen/eshop/models/Adv_order_model.php) INSERTed the order row (order_serialdefaulting to NULL) and then setorder_serialin a separate, non-transactional UPDATE. Any interruption between the two statements (crash, timeout, deploy) permanently committed aPENDINGorder withorder_serial = NULL. Downstream,Adv_order_model::getOrder()built itsWHEREclause directly from whatever condition it was given — including an empty/NULLorder_serial— so CI3 renderedWHERE order_serial IS NULL(or= ''), which matched one of these orphaned rows and returned it as if it were the caller's order: a cross-customer data-exposure risk (a gateway callback or a subsequent request could be handed someone else's order), or a null-dereference 500 when no orphan existed yet. - The change. Defense in depth across four points. (1)
processOrder()now wraps the INSERT + serialUPDATE+ basket write in a manual DB transaction (trans_begin/trans_status/trans_rollback/trans_commit); any step failing rolls back and returnsfalse— no row is ever left committed with a NULL serial. (2)getOrder()now rejects an empty/NULL/whitespace-onlyorder_serialcondition up front and returnsnullbefore building a query, so CI3 can never emit theIS NULL/= ''lookup that matched orphaned rows. (3)create_order()no longer dereferences a failedprocessOrder()result — it returns the order array withorder_serial/idset tonullinstead of fataling. (4)Adv_order::checkout()(ecommercen/eshop/controllers/Adv_order.php) now guardsempty($checkoutData['order_serial']) || empty($checkoutData['id'])before dispatching to the payment gateway: it logs with customer id + payway context, sets anorder_errorsession message, and redirects topreview_order(which rendersorder_error) instead of proceeding — with the transaction in place, a rollback leaves no dangling order and the cart is left intact.Adv_checkout::_delivery(),_bank_transfer(),paidAtStore(), andpaypalResponseProcess()(ecommercen/checkout/controllers/Adv_checkout.php) additionally null-check thegetOrder()result and fail safe viainactive_payment()instead of null-derefing or acting on the wrong order; the first three also moved the lookup beforecart->destroy()so a defensive miss no longer wipes the customer's cart. - Security. Closes a cross-customer data-exposure path: before this fix, an interrupted checkout could leave a NULL-serial order row that a subsequent empty/NULL-serial lookup (e.g. from a gateway callback) would match and return, potentially surfacing one customer's order data on another request. No schema/Phinx change —
order_serialkeeps its existingDEFAULT NULL; the fix is entirely code-level. - Client forks. See the "Check for overrides" note below — a client fork that overrides
processOrder(),getOrder(),create_order(),Adv_order::checkout(), or any of the fourAdv_checkoutgateway handlers keeps its own copy of the old logic and does not automatically inherit this fix. - Tests. New
tests/Legacy/Eshop/AdvOrderModelGetOrderSerialGuardTest.phpcovers thegetOrder()empty/NULL/whitespace-serial guard. - Docs.
docs/flows/customer/CF-07-order-confirmation.mdupdated: thecheckout()andprocessOrder()code-flow steps and Business Rules now describe atomic serial assignment and the empty/NULL-serial lookup guard (no more NULL-serial orphans or orphan-matching lookups) instead of the old insert-then-update sequence. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why.
[4.119.0] fix(search): clamp the page URI segment to >= 1 to stop a 500 on non-numeric search pagination (Advisable-com/ecommercen#440)
- Why.
Adv_search::searchPage()(ecommercen/search/controllers/Adv_search.php) received thepageURI segment as a raw, uncast string. Under PHP 8, the pagination arithmetic($page - 1) * $this->limitthrowsTypeError: Unsupported operand types: string - intwhenever$pageis non-numeric, so any/search/<term>/<non-numeric>URL (bots, stale links, typos) returned a customer-facing HTTP 500 instead of a normal search results page. - The change. Added
$page = max(1, (int) $page);as the first statement ofsearchPage(), so a non-numeric or non-positive page segment now degrades cleanly to a literal page 1 (keepingcurrentPageat 1 for correct pagination/canonical output and the AGORAcurrentPage === 1path) instead of fatally erroring. Valid numeric pagination is unaffected. - Client forks. See the "Check for overrides" note below —
searchPage()isprotected, so a client fork that overrides it keeps its own copy of the pagination logic and does not automatically inherit this fix. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why.
[4.119.0] fix(blog): clamp the page URI segment to >= 1 before
Adv_blog::searchPage()to stop a 500 on non-numeric blog-search pagination (Advisable-com/ecommercen#472)- Why.
Adv_blog::search()(ecommercen/blog/controllers/Adv_blog.php) forwarded the rawpageURI segment intosearchPage()without casting it.searchPage()declares that parameter as?int, so under PHP 8 coercive typing a non-numeric string argument is rejected at the call boundary itself — before the method body'sis_numeric()guard can even run — throwingTypeError: Adv_blog::searchPage(): Argument #2 ($pageNumber) must be of type ?int, string given. Any/search/<term>/<non-numeric>URL (bots, stale links, typos, e.g./search/<term>/abc) returned a customer-facing HTTP 500 instead of a normal blog search results page. Sibling of the #440 fix on theAdv_searchcontroller. - The change. Added
$pageNumber = max(1, (int) $pageNumber);insearch(), immediately before the$this->searchPage($term, $pageNumber);call, so a non-numeric or non-positive page segment now degrades cleanly to a literal page 1 instead of fatally erroring at the call boundary. Valid numeric pagination is unaffected. Unlike #440 — where the cast lives insidesearchPage()itself — the cast here lives in the caller (search()), because theTypeErrorfires at the call boundary beforesearchPage()'s body ever runs. - Client forks. See the "Check for overrides" note below —
search()ispublicandsearchPage()isprotected, so a client fork that overrides either method keeps its own copy of the page-number handling and does not automatically inherit this fix. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why.
[4.119.0] fix(reviews): award loyalty points on bulk review approval, matching single approval (Advisable-com/ecommercen#16)
- Why.
Adv_product_reviews_admin::setStatus()(single-review approval) applied the loyalty-point award branch (POINT_SYSTEM/IS_ENABLED+ECOMMERCEN_PLUS/CUSTOMER_REVIEW_REWARD_ENABLE), butbulkSetStatus()(bulk approval) had no loyalty code at all — approving reviews via the bulk checkbox silently awarded 0 points, while approving the same reviews one-by-one awardedCUSTOMER_REVIEW_REWARDper review. Admins using the bulk UI were unknowingly denying rewards their customers were entitled to. - The change.
bulkSetStatus()(ecommercen/eshop/controllers/Adv_product_reviews_admin.php) now applies the same loyalty branch assetStatus(). A newAdv_product_reviews_model::getCustomerIds(array $reviewIds)resolves one customer id per review, and the bulk handler callsloyalty->savePointsToCustomer($id, $rewardValue)once per approved review — so bulk approval now awards points identically to single approval. The award is still a rawtotal_points + Nupdate (unchanged, pre-existing, tracked separately) — repeated approve/pending/approve cycles still double-award; that non-idempotency is out of scope for this fix. - Client forks. Only
bulkSetStatus()'s method body changed (new call to the newgetCustomerIds()model method) — no signature change, so BC. A fork that fully overridesbulkSetStatus()(rather than inheriting the emptyProduct_reviews_adminsubclass) must port the same loyalty-award block to get consistent bulk/single reward behavior. - Docs.
docs/flows/admin/AD-52-review-moderation.mdupdated: Business Rule #5 and Known Issue #6 now describe both paths as awarding identically; the non-idempotency gap remains tracked as Known Issue #7. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why.
[4.119.0] fix(integrations): route call logs for twelve payment/integration clients through the PSR-3 logger instead of failing file writes (Advisable-com/ecommercen#467)
- Why. Twelve payment/integration clients — Klarna, Viva Wallet, Europharmacy, Shopflix, JCC, IRIS, Cloudflare, Public2P, Manago, Skroutz, BoxNow, and Yuboto — recorded their full request/response call logs with raw file writes instead of the
NamedLoggerInterfacelogger each already injects. Eight (Klarna, Viva Wallet, Europharmacy, Shopflix, JCC, IRIS, Cloudflare, Public2P) wrote viafile_put_contents(APPPATH . 'logs/...'); thatlogs/directory does not exist in the app container image, so every write silently failed with a PHP warning on k8s, discarding the payload — the only full capture of each call (see #465). The other four (Manago, Skroutz, BoxNow, Yuboto) wrote to a bare relative filename (manago_logs.txt,skroutzApi.txt,boxnow.php,yuboto_omni.txt) that lands in an unpredictable, per-pod ephemeral CWD — the same silent-loss / per-pod-divergence failure mode. #235 migrated the../storage/*asset constants but log paths were out of its scope. - The change. The raw file dump on each class was replaced with
$this->loggercalls carrying the payload as a structured PSR-3 context array (url/request/response), notprint_rinto the message string. Error-path payloads (Klarna, Shopflix) are folded into the pre-existing->error()call so the error path stays a single record; success/debug dumps now log atdebug, soAPP_LOG_THRESHOLD/ per-channel thresholds gate the verbose call trace in production. Three pre-existing broken debug guards were fixed in passing: JCC's!empty($this->config->debugCall) || true(unconditional —$this->configdoesn't exist on that class), IRIS's equivalent guard (always false, same reason), and Cloudflare'sgetClassName()helper (only ever built the old log path, now removed) — all now correctly gate on the threshold-baseddebuglog level instead.BoxNow::writeCalls()had no live caller (the only reference sat in a commented-out block); it was removed as dead code without adding a replacement logger call — enabling BoxNow call tracing would need a new config flag and is out of scope here. - Security. Credentials are redacted before logging via a new private
redactRequestOptions()on each class: theAuthorizationheader (Klarna, Viva Wallet, Europharmacy, Shopflix, Cloudflare, Skroutz, Yuboto),form_params.password(JCC), the JSON-bodypasswordfield (IRIS), a customApiKeyheader (Public2P), andjson.apiKey+json.sha(Manago). Customer PII (address/email) is deliberately left un-redacted for diagnostic value. - Client forks. See the "Check for overrides" note below — the now-dead
writeCalls()method was removed from all twelve classes, but only four (Viva Wallet, Europharmacy, JCC, IRIS) wereprotected; the other eight (Klarna, Shopflix, Cloudflare, Public2P, Manago, Skroutz, BoxNow, Yuboto) were alreadyprivate, so there is no override surface on those. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why. Twelve payment/integration clients — Klarna, Viva Wallet, Europharmacy, Shopflix, JCC, IRIS, Cloudflare, Public2P, Manago, Skroutz, BoxNow, and Yuboto — recorded their full request/response call logs with raw file writes instead of the
[4.119.0] fix(mailer): stop corrupting
message_typeaudit column with a bool + backfill historical rows (Advisable-com/ecommercen#434)- Why.
Adv_mailer::addEmailToCustomerHistory()(application/models/Adv_mailer.php) passed!empty($emailSubject) ?? 'EMAIL'as thestring $messageTypeargument tocustomer_message_history_model->addRecord().!empty()always returns a bool (nevernull), so the?? 'EMAIL'null-coalesce was dead code, and a bool was coerced into the string column — silently corruptingshop_customer_message_history.message_typewith"1"/"".message_typeis a delivery-channel enum (the SMS sibling path writes the literal'SMS', and the REST layer filtersmessageTypeas an exact match), not a subject field — the actual subject text is stored separately in the siblingsubjectcolumn, unaffected by this bug. Impact is logging/audit data-quality only — no functional, delivery, or customer-facing effect. - The change.
addEmailToCustomerHistory()now passes the literal'EMAIL'asmessage_type, mirroring the SMS path's literal'SMS'. Thesubjectcolumn continues to receive the real subject text via its own (unchanged) argument. - Backfill. Added a one-time idempotent data patcher (
patches/BackfillCustomerMessageHistoryMessageType.php+database/migrations/20260713142705_backfill_customer_message_history_message_type.php, extendingAbstractPatcher) that setsmessage_type = 'EMAIL'for existing rows wheremessage_type IN ('1', ''). Every corrupted row went through the email path, so'EMAIL'is correct for all of them. Idempotent: after the code fixmessage_typeis never'1'/'', so re-running is a no-op. - Client forks. A fork carrying its own override of
addEmailToCustomerHistory()(rather than inheriting the baseAdv_mailer) must port the same change — pass the literal'EMAIL'as themessage_typeargument — to get correct audit data. - Docs.
docs/flows/system/SY-24-email-dispatch.mdwas corrected to describemessage_typeas a channel enum ('EMAIL'/'SMS'), not subject text, matchingdocs/flows/admin/AD-41-customer-mail-history.md. - No REST API, OpenAPI, or language-key changes.
- Why.
[4.119.0] fix(logger): cast
APP_LOG_MAX_FILESto int to stop file-mode logger installs crashing (Advisable-com/ecommercen#438)- Why. On file-mode logger installs (
MONOLOG_CHANNEL_CONFIGURATION!=STDOUT— traditional VPS/Plesk, not K8s/stdout), settingAPP_LOG_MAX_FILESin.envcrashed every request with an uncaughtTypeError. Monolog'sRotatingFileHandlerdeclaresint $maxFiles, but underdeclare(strict_types=1)the raw string read fromenv()was passed straight through, so PHP refused the implicit string-to-int coercion. - The change.
application/config/monolog.phpnow casts the file-modemaxFilesvalue —'maxFiles' => (int) env('APP_LOG_MAX_FILES', 15)— andAppLoggerFactory::buildStreamHandler()(src/Logger/AppLoggerFactory.php) defensively casts it again at the handler boundary —(int) ($globalConfig['maxFiles'] ?? 15)— so a stray string value can't reachRotatingFileHandleruncast from either path. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why. On file-mode logger installs (
[4.119.0] fix(mailchimp): allow correcting an invalid saved API key from admin (Advisable-com/ecommercen#377)
- Why. A wrong or expired Mailchimp API Key / Server Prefix could never be corrected from the admin Settings → Mailchimp screen.
MailchimpConfiguration.vue'ssubmitForm()short-circuited onmailchimpListsExist()before POSTing; with an invalid saved key the backend returned no lists, so the list<select>s stayed empty,checkout.liststayed'', and the "No checkout newsletter lists found!" toast blocked submission forever — permanently locking the admin out of fixing their own key. The invalid-key error from the Mailchimp API was also silently swallowed, leaving empty, disabled selects with no indication of why. Separately, the "subscribe as pending" toggle appeared not to save when switched OFF:Adv_settings::mailchimp()readSUBSCRIBE_AS_PENDINGwithout the(bool)cast its siblingENABLEDread already had, so the raw"0"/"1"string was emitted to the frontend — and"0"is truthy in JavaScript, so an unchecked toggle re-rendered as checked on reload. The stored DB value was always correct; only the read-back/display was wrong. - The change.
mailchimpListsExist()now also bypasses the list check when the entered API Key or Server Prefix differs from the stored values (credentialsChanged), so a corrected key can be saved — the existing post-savelocation.reload()then re-fetches the lists with the valid key.Adv_settings::mailchimp()(ecommercen/settings/controllers/Adv_settings.php) splits the['error' => msg]shape returned bygetAllListsWithGroupsAndInterests()into a clean emptymailchimp_listsarray plus a newmailchimp_errorfield on thejsonStatepayload; the Vue store (assets/admin/js/mailchimp/modules/mailchimp.js) andMailchimpConfiguration.vuesurface it as an error toast on load, taking precedence over the generic "no lists" toast.mailchimp()also now casts theSUBSCRIBE_AS_PENDINGread to(bool), matchingENABLED, so the toggle reflects the saved value correctly. - Client forks. Purely additive — no
protected/publicbase method was removed, renamed, or had its signature changed;mailchimp_erroris a new field appended to an internal admin JSON payload, and theSUBSCRIBE_AS_PENDINGfix is a(bool)cast on that same internal read. A fork that fully copy-pastedAdv_settings::mailchimp(),mailchimp.js, orMailchimpConfiguration.vue(rather than overriding a hook method) would need to port these changes manually to get the same fixes, but no existing override contract breaks.
- Why. A wrong or expired Mailchimp API Key / Server Prefix could never be corrected from the admin Settings → Mailchimp screen.
[4.119.0] fix(auth): guard session-dependent role-check helpers against a null session (Advisable-com/ecommercen#441)
- Why. The role-check helpers in
ecommercen/helpers/auth_helper.php(isAdvisableUser(),isDeveloper(),isAdmin(),isCmsUser(),isProductsUser(),isReportingUser(),isOrdersUser(),isMarketingUser(),isMediaUser(),allowRole()) dereferenced$this->session->userdata(...)without checking whether$this->sessionwas set, causing a customer-facing 500 ("Call to a member function userdata() on null") whenever one ran on a session-less request path. Concretely: the shared storefront footer view callsisAdvisableUser()unconditionally, and a feed controller that fell through toerror_404()rendered the main layout (including that footer) without ever initializing$this->session, triggering the crash. Follow-up to #358, which applied the identical nullsafe guard toisCustomerLoggedIn()ineshop_helper.phpbut didn't extend it to these role-check helpers inauth_helper.php. - The change. Added the nullsafe operator (
?->) to the session dereference in every role-check helper listed above, so each now returns falsy (no admin/role access) instead of fatal-ing when$this->sessionis null. No behavior change on a request where a session is present. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why. The role-check helpers in
[4.119.0] refactor(video-stream): resolve BunnyStream through the DI container (Advisable-com/ecommercen#383)
- What changed.
Advisable\VideoStream\Stream\BunnyStreamis now a Symfony DI service registered insrc/VideoStream/container.php(wired intoapplication/config/container/modules.php) and resolved viadi()->get(BunnyStream::class)at all call sites, replacing the previous manualnew BunnyStream(...)+ CI-registry construction. The named logger (NamedLoggerInterface) is now constructor-injected into the service. - Call sites updated.
ecommercen/job/libraries/AdvCheckVideoStatusStream.php,AdvDeleteVideoToStream.php, andAdvUploadVideoToStream.php, plusecommercen/eshop/controllers/Adv_home.phpandAdv_reels.php, now obtain the instance throughdi()->get(BunnyStream::class). \Registrystays out of the container — by design. Same constraint as the Apifon migration (#387): CI's library loader (system/core/Loader.php_ci_init_library) resolves libraries through the container by bare class name, so registering a service under theRegistryid would hijack CI's own library load and recurse at boot.\Registryis therefore resolved lazily at request time via a privateregistry()helper on the service (same pattern asApifon\ApifonandDomains\Features\FeatureRegistry), not injected as a constructor dependency.- No externally visible changes — no REST API version bump, no DB migration, no language-key changes.
- Follow-up. The same lazy-DI pattern should be applied to the remaining manually-
new'd integrations: Moosend (#381), Manago (#382), ShopflixOrders (#384).
- What changed.
[4.119.0] fix(sitemap): emit exclusive-vendor
/detailspages — loosenis_exclusivecompare (Advisable-com/ecommercen#459)- Why. The sitemap generator never emitted the
{vendor}/detailsURL for exclusive vendors.AdvGenerateSitemaps::getVendors()(ecommercen/job/libraries/AdvGenerateSitemaps.php:253) gated that URL behind$vendor->is_exclusive === 1(strict), butAdv_vendors_model::getVendorsForSiteMap()returnsis_exclusivefrom a raw CI3 query as the string"1", so"1" === 1is alwaysfalseand the/detailsURL was silently dropped. Pre-existing since 2023-11-20 (not a #399 regression). - The change. Cast before the strict compare —
(int) $vendor->is_exclusive === 1— so exclusive vendors'/detailspages are emitted into the sitemap again. This matches the loose== 1comparison every otheris_exclusivereference in the codebase already uses. One-line change; no other behavior affected. - Impact. SEO completeness only — exclusive-vendor
/detailspages are now surfaced to crawlers. Low severity. - Client forks. Only
getVendors()'s method body changed — no signature change, so BC. A fork that overridesgetVendors()(or copied the old strictis_exclusive === 1compare) must apply the same(int)cast, or it will keep silently dropping the exclusive-vendor/detailsURL from the sitemap. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why. The sitemap generator never emitted the
[4.119.0] fix(eshop): always initialize
categoryIds/tagIdson parsed product objects (Advisable-com/ecommercen#436)- Why.
Adv_product_parser_modelonly setcategoryIds(getProductsCategories()) andtagIds(getProductTags()) on product objects that had at least one matching row inshop_product_category_lp/shop_product_product_tags. A product with no categories or tags kept the property unset, so any consumer reading it without a null guard hit a fatalTypeErrorunder PHP 8 — including the category-scoped coupon checks inAdv_coupons_model::isValidCoupon()andcheckCategories()(array_intersect($liveData[$item['productId']]->categoryIds, $categoryIds)), a money-path crash for a cart containing an uncategorized product under a category-scoped coupon. - The change.
getProductObjects()(ecommercen/eshop/models/Adv_product_parser_model.php) now initializescategoryIdsandtagIdsto[]on every product object as soon as it is built, beforegetProductsCategories()/getProductTags()run. Both methods still unconditionally overwrite these with the real values for products that do have rows, so behavior for categorized/tagged products is unchanged; only the previously-unset case now safely defaults to an empty array. - Client forks. Only
getProductObjects()'s method body changed — signature unchanged, so BC. A fork that fully overrodeProduct_parser_model::getProductObjects()(rather than the default no-op pass-through) must apply the same two-line default-init ($product->categoryIds = []; $product->tagIds = [];), or its parsed product objects will keep leaving those properties unset and the category-scoped coupon path will still crash. - No REST API, DB migration, OpenAPI, or language-key changes.
- Why.
Notes
[4.119.0] Check for overrides: the
ecomntagGitHub Packages migration (Advisable-com/ecommercen#488) touches build/CI files a client fork may maintain its own copy of:.npmrc,package.json,.docker/images/app.dockerfile,bitbucket-pipelines.yml,.docker/scripts/build/build-common.sh,.docker/images/node.dockerfile,.docker/integration/dev.compose.yml,.gitignore, and.env.exampleall changed how the privateecomntagdependency authenticates and resolves, in CI and locally. A client fork with its own copies of these files must reconcile the same GitHub Packages source plusGH_PACKAGES_TOKEN/NODE_AUTH_TOKENwiring when merging upstream, or its builds will keep trying (and failing) to resolveecomntagvia the retired Bitbucket SSH source.GH_PACKAGES_TOKENis a Secured Bitbucket workspace variable ondevteamadvisable— every repo in that workspace inherits it automatically, so no per-repo setup is needed there. A client repo that builds in a different Bitbucket workspace must provision its ownGH_PACKAGES_TOKEN(an org-memberread:packagesPAT forAdvisable-com, SSO-authorized), or its Docker build will fail npm auth against GitHub Packages.- Local dev: a developer doing a local
npm install, running thenode-clidev container, or running a local Docker build now needs aNODE_AUTH_TOKEN(same kind of token — anAdvisable-comorg-memberread:packagesPAT, SSO-authorized) set in their own.env(stub added to.env.example); ssh-agent forwarding no longer works for local builds.
[4.119.0] Check for overrides: the order-serial atomicity + null-serial-lookup guard fix (Advisable-com/ecommercen#473) touches methods a client fork may have overridden — each override needs the same fix re-applied to avoid reintroducing the orphaned-order / cross-customer-lookup bug:
Adv_order_model::processOrder(),::getOrder(),::getRecord(),::create_order(),::create_order_admin(),::_proccess_admin_order()(ecommercen/eshop/models/Adv_order_model.php) —processOrder()now returnsfalseon rollback, socreate_order()andcreate_order_admin()/_proccess_admin_order()(the admin POS/phone + marketplace path) gained fail-safe guards;getRecord()gained the same empty/NULL-serial lookup guard asgetOrder()Adv_order::checkout()(ecommercen/eshop/controllers/Adv_order.php)Adv_checkout::_delivery(),_bank_transfer(),paidAtStore(),paypalResponseProcess()(ecommercen/checkout/controllers/Adv_checkout.php)Adv_orders_adminmanual order-create/edit/add actions that callcreate_order_admin()then look the new order up by serial (ecommercen/eshop/controllers/Adv_orders_admin.php) — now guard on an empty-serial return before proceeding. The marketplace ingestion callers (Adv_skroutz_orders_admin,Adv_shopflix_orders_admin,Adv_public_orders_admin,Adv_skroutz_orders_model) rely on the centralcreate_order_admin()+getRecord()null-serial guards to fail closed; a fork overriding any of them should re-apply the same fail-safe- No overrides of any of these exist in this repo's
application/(Order,Order_model, andCheckoutare all empty pass-through subclasses) — but downstream client forks that copy/override any of them must port the same fix. No migration is required for this fix.
[4.119.0] Check for overrides: the search-page int-cast fix (Advisable-com/ecommercen#440) touches an overridable method:
Adv_search::searchPage()(ecommercen/search/controllers/Adv_search.php) isprotected. A client fork that subclassesSearch extends Adv_search(application/modules/search/controllers/Search.php) and overridessearchPage()keeps its own copy of the pagination logic and does not automatically pick up this fix.- The smile_v4 client already carries a local
Search::searchPage()override that independently casts$pageas a stopgap mitigation for this same bug. Now that the fix is in the base class, smile_v4 should delete its local override and fall back to the inheritedAdv_search::searchPage().
[4.119.0] Check for overrides: the blog-search page int-cast fix (Advisable-com/ecommercen#472) touches overridable methods:
Adv_blog::search()(ecommercen/blog/controllers/Adv_blog.php) ispublic, andAdv_blog::searchPage()isprotected. A client fork that subclassesAdv_blogand overrides either method keeps its own copy of the page-number handling and does not automatically inherit this fix.- In this repo,
application/modules/blog/controllers/Blog.php(class Blog extends Adv_blog) does not overridesearch()orsearchPage()— it is an empty subclass, so it inherits this fix automatically. Downstream client forks that do overridesearch()and keep their own uncast page derivation must be checked and reconciled independently.
[4.119.0] Check for overrides: the integration-log fix (Advisable-com/ecommercen#467) removed the now-dead
protected function writeCalls()method from four classes — a client fork that overrode any of these now has a dead override that is never called:Advisable\VivaWallet\BaseAdvisable\Europharmacy\BaseAdvisable\PaymentGateways\Jcc\JccAdvisable\PaymentGateways\Iris\Iris- Klarna's and Shopflix's
writeCalls()were alreadyprivate, not an overridable surface, so no client-facing change there.
[4.119.0] REQUIRES
php migrator.php migrate:20260713142705_backfill_customer_message_history_message_type.php— backfillsshop_customer_message_history.message_typeto the literal'EMAIL'for existing rows corrupted by the dead null-coalesce bug (#434; all such rows went through the email path); idempotent, safe to re-run.
[4.119.0] Check for overrides: the Mailchimp admin-key fix (Advisable-com/ecommercen#377) changed behavior on an overridable admin-controller method:
Settings::mailchimp()(application/modules/settings/controllers/Settings.php,class Settings extends Adv_settings) — the upstream change itself lands in themailchimp()handler on the parent,ecommercen/settings/controllers/Adv_settings.php, which now splits the Mailchimp list-fetch error into amailchimp_errorfield on thejsonStatepayload, and casts theSUBSCRIBE_AS_PENDINGread to(bool)(matching the existingENABLEDcast). A client fork that overridesSettings::mailchimp()will not surface the invalid-key error to the admin UI, and will keep re-rendering an unchecked "subscribe as pending" toggle as checked on reload, until it ports both changes.- The matching frontend handling lives in
mailchimp.jsandMailchimpConfiguration.vue(assets/admin/js/mailchimp/) — themailchimp_errortoast and thecredentialsChangedbypass inmailchimpListsExist(). Forks with copy-pasted (rather than overridden) copies of either asset need the same manual port to get the fixes.
[4.119.0] Check for overrides: the BunnyStream DI migration (Advisable-com/ecommercen#383) changes its public constructor signature — parameter order is now
(NamedLoggerInterface $logger, Storage $storage, ?Registry $registry = null), previously(Registry $registry, Storage $storage, ?NamedLoggerInterface $logger = null).NamedLoggerInterfaceis now the required first parameter andRegistryis de-promoted to a nullable trailing parameter. Any client fork that subclassesAdvisable\VideoStream\Stream\BunnyStreamor constructs it directly withnew BunnyStream(...)positionally must reconcile to the new argument order, or it will pass the wrong types into$logger/$storage/$registry(fatal on theNamedLoggerInterface/Registrytype hints, or silently wrong values if a client's own type hints happen to be loose).