Skip to content

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

Home | Changelog

Version 4

version 4.119

  • [4.119.0] fix(rest-auth): return a well-formed 500 JSON instead of a bare empty 200 when 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 empty 200 (Content-Type: text/html, 0 bytes) when its authenticated-success path raised an uncaught Throwable while building the response (e.g. a failure inside Tokens::generateAccessToken()/generateRefreshToken()). Every intended response goes through ApiEndpointTrait::sendOutput(), so an empty text/html 200 meant the action threw before sendOutput() ran and the dispatcher emitted nothing. Root cause was the single action-dispatch chokepoint in RouterDispatcher::dispatch() (application/controllers/RouterDispatcher.php): it caught only \Exception (so PHP Errors escaped entirely) and, even when it caught, merely logged and returned without writing a response — leaving PHP's default empty 200.
    • The change. New Advisable\Rest\Support\SafeActionDispatch::invokeControllerAction() (src/Rest/Support/SafeActionDispatch.php) wraps the action call in catch(\Throwable) and emits a well-formed 500 JSON ({"message":"Internal server error."}) via sendError(); because CI3 buffers output and flushes only after dispatch() returns, overwriting the buffered status + body here reaches the client before the flush. RouterDispatcher now dispatches actions through that guard and widens its DI-resolution and middleware catches from \Exception to \Throwable, so PHP Errors in those phases also surface a 500 JSON rather than a bare response. One chokepoint covers login, refresh, register, forgotPassword, resetPassword, and every other REST endpoint without per-action try/catch. The 401 (invalid credentials) and 429 (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.php drives the real trait (Exception, TypeError, and a throwing Tokens mock on the success path) and asserts a 500 JSON response, never a bare 200.
    • No OpenAPI change — the 500 is cross-cutting dispatcher behavior for all REST endpoints, not auth-specific, so no OA\Response was added. No REST API version, DB migration, or language-key changes.
  • [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 stored NULL/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 by id ASC and authenticates against whichever duplicate row actually owns the supplied password; the return contract is unchanged (customer id on success, 0 on failure) and a valid password is still required, so there is no auth bypass. checkPassword() rejects a NULL/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-200 hardening is #462; the phantom-200 refresh-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.
  • [4.119.0] fix(rest-auth): surface a genuine refresh_tokens write failure as a 500 instead of a phantom 200 (Advisable-com/ecommercen#498)

    • Why. Under CI3 db_debug=false (prod default), a failing refresh_tokens write was silently swallowed — the driver returned false with no throw — so Tokens::generateRefreshToken() (src/Rest/Auth/Tokens.php) returned a token that was never persisted, and the login / token-refresh / register endpoints replied a normal 200 carrying that unpersisted "phantom" refresh token (a later /refresh with it then failed). This was the silent-failure sibling of the empty-200 hardened 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 private assertNoDbError(), and throw \RuntimeException on a real DB error (a zero-row DELETE — e.g. a first-ever login with no prior token — is not treated as an error). The throw is caught by #462's SafeActionDispatch, so login/token-refresh/register now surface a well-formed 500 JSON instead of the phantom 200. This is a REST error-path behavior change only — the success contract ({access_token, refresh_token} on a healthy write) is unchanged. The cron-only deleteBy() 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-end 500 JSON with no refresh_token in the body, and the unaffected happy path.
    • Docs. docs/guides/RestCustomerLoginRecovery.md updated: classes 3/4/5 (and §1/§3/§7) now describe a refresh_tokens write error failing loud (500, via #462) instead of the pre-#498 swallowed/phantom-200 behavior.
    • No REST API, DB migration, OpenAPI, or language-key changes.
  • [4.119.0] refactor(customer-message-history): replace message_type channel magic strings with a shared MessageChannel enum (Advisable-com/ecommercen#468)

    • Why. The shop_customer_message_history.message_type delivery-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\MessageChannel string-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 emit MessageChannel::EMAIL->value / MessageChannel::SMS->value instead 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.md and docs/flows/system/SY-24-email-dispatch.md now 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.
  • [4.119.0] chore(build): resolve ecomntag from GitHub Packages, not Bitbucket SSH (Advisable-com/ecommercen#488)

    • Why. The private ecomntag npm dependency was previously pulled via a Bitbucket git+ssh source, 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's ecomntag dependency now points to @advisable-com/ecomntag@^1.5.0 (Internal) served from GitHub Packages, with package-lock.json regenerated so ecomntag resolves from npm.pkg.github.com, and .npmrc scoped to the @advisable-com registry with ${NODE_AUTH_TOKEN} auth. .docker/images/app.dockerfile swaps the SSH mount (--mount=type=ssh) for a secret mount (--mount=type=secret,id=npm_token), sourcing NODE_AUTH_TOKEN from /run/secrets/npm_token for npm run all-production. Both bitbucket-pipelines.yml build steps now write the $GH_PACKAGES_TOKEN workspace variable to a transient npm_token file and pass --secret id=npm_token (removed after each build) instead of --ssh default=$BITBUCKET_SSH_KEY_FILE. assets/vue/store/actions.js and webpack.mix.front.js update 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_token instead of --ssh default; .docker/images/node.dockerfile drops openssh-client and ssh-keyscan bitbucket.org from the node_dev image; .docker/integration/dev.compose.yml's node-cli dev container no longer forwards the host $SSH_AUTH_SOCK and instead reads NODE_AUTH_TOKEN from .env; .env.example gained a NODE_AUTH_TOKEN stub; and .gitignore now ignores the transient npm_token secret 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.
  • [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 the sampleCurrencyId() seam added to the same file in #422.
    • The change. Three new protected seams: sampleEmailData(): array returns the base sample-data payload (today's exact hard-coded defaults); extendSampleEmailData(array $data): array is a no-op hook for forks to add/override sample-data keys; loadSampleDataDependencies(): void is a no-op hook, called at the end of initTestEmailViews(), 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() or initTestEmailViews() directly to customize the email-preview sample data should migrate to overriding sampleEmailData() / extendSampleEmailData() / loadSampleDataDependencies() instead of maintaining a full method copy.
  • [4.119.0] fix(video-stream): wire the missing $storage constructor 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.php registered Advisable\VideoStream\Stream\BunnyStream as a container service but never wired its $storage constructor argument. Advisable\Storage\Storage isn't autowirable — its constructor takes untyped scalar $type/$disk and depends on the CI super-object being booted — so any fresh container compile (merging develop, 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.php now wires $storage as an anonymous inline Symfony service — inline_service(Storage::class)->args(['files', null]) — giving BunnyStream a default Storage (files type, default disk), matching the pre-#383 new BunnyStream($registry, new Storage()) behavior exactly. The service is scoped as an inline (id-less) argument, not a global Storage::class service id, so it can't be picked up as an autowiring candidate for any other Storage-typed constructor in the container. No change to BunnyStream's constructor signature, runtime behavior, or REST contract — purely an internal DI wiring fix.
    • No REST API, DB migration, OpenAPI, or language-key changes.
  • [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_serial defaulting to NULL) and then set order_serial in a separate, non-transactional UPDATE. Any interruption between the two statements (crash, timeout, deploy) permanently committed a PENDING order with order_serial = NULL. Downstream, Adv_order_model::getOrder() built its WHERE clause directly from whatever condition it was given — including an empty/NULL order_serial — so CI3 rendered WHERE 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 + serial UPDATE + basket write in a manual DB transaction (trans_begin/trans_status/trans_rollback/trans_commit); any step failing rolls back and returns false — no row is ever left committed with a NULL serial. (2) getOrder() now rejects an empty/NULL/whitespace-only order_serial condition up front and returns null before building a query, so CI3 can never emit the IS NULL/= '' lookup that matched orphaned rows. (3) create_order() no longer dereferences a failed processOrder() result — it returns the order array with order_serial/id set to null instead of fataling. (4) Adv_order::checkout() (ecommercen/eshop/controllers/Adv_order.php) now guards empty($checkoutData['order_serial']) || empty($checkoutData['id']) before dispatching to the payment gateway: it logs with customer id + payway context, sets an order_error session message, and redirects to preview_order (which renders order_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(), and paypalResponseProcess() (ecommercen/checkout/controllers/Adv_checkout.php) additionally null-check the getOrder() result and fail safe via inactive_payment() instead of null-derefing or acting on the wrong order; the first three also moved the lookup before cart->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_serial keeps its existing DEFAULT 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 four Adv_checkout gateway handlers keeps its own copy of the old logic and does not automatically inherit this fix.
    • Tests. New tests/Legacy/Eshop/AdvOrderModelGetOrderSerialGuardTest.php covers the getOrder() empty/NULL/whitespace-serial guard.
    • Docs. docs/flows/customer/CF-07-order-confirmation.md updated: the checkout() and processOrder() 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.
  • [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 the page URI segment as a raw, uncast string. Under PHP 8, the pagination arithmetic ($page - 1) * $this->limit throws TypeError: Unsupported operand types: string - int whenever $page is non-numeric, so any /search/&lt;term>/&lt;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 of searchPage(), so a non-numeric or non-positive page segment now degrades cleanly to a literal page 1 (keeping currentPage at 1 for correct pagination/canonical output and the AGORA currentPage === 1 path) instead of fatally erroring. Valid numeric pagination is unaffected.
    • Client forks. See the "Check for overrides" note below — searchPage() is protected, 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.
  • [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 raw page URI segment into searchPage() 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's is_numeric() guard can even run — throwing TypeError: Adv_blog::searchPage(): Argument #2 ($pageNumber) must be of type ?int, string given. Any /search/&lt;term>/&lt;non-numeric> URL (bots, stale links, typos, e.g. /search/&lt;term>/abc) returned a customer-facing HTTP 500 instead of a normal blog search results page. Sibling of the #440 fix on the Adv_search controller.
    • The change. Added $pageNumber = max(1, (int) $pageNumber); in search(), 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 inside searchPage() itself — the cast here lives in the caller (search()), because the TypeError fires at the call boundary before searchPage()'s body ever runs.
    • Client forks. See the "Check for overrides" note below — search() is public and searchPage() is protected, 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.
  • [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), but bulkSetStatus() (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 awarded CUSTOMER_REVIEW_REWARD per 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 as setStatus(). A new Adv_product_reviews_model::getCustomerIds(array $reviewIds) resolves one customer id per review, and the bulk handler calls loyalty->savePointsToCustomer($id, $rewardValue) once per approved review — so bulk approval now awards points identically to single approval. The award is still a raw total_points + N update (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 new getCustomerIds() model method) — no signature change, so BC. A fork that fully overrides bulkSetStatus() (rather than inheriting the empty Product_reviews_admin subclass) must port the same loyalty-award block to get consistent bulk/single reward behavior.
    • Docs. docs/flows/admin/AD-52-review-moderation.md updated: 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.
  • [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 NamedLoggerInterface logger each already injects. Eight (Klarna, Viva Wallet, Europharmacy, Shopflix, JCC, IRIS, Cloudflare, Public2P) wrote via file_put_contents(APPPATH . 'logs/...'); that logs/ 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->logger calls carrying the payload as a structured PSR-3 context array (url/request/response), not print_r into 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 at debug, so APP_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->config doesn't exist on that class), IRIS's equivalent guard (always false, same reason), and Cloudflare's getClassName() helper (only ever built the old log path, now removed) — all now correctly gate on the threshold-based debug log 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: the Authorization header (Klarna, Viva Wallet, Europharmacy, Shopflix, Cloudflare, Skroutz, Yuboto), form_params.password (JCC), the JSON-body password field (IRIS), a custom ApiKey header (Public2P), and json.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) were protected; the other eight (Klarna, Shopflix, Cloudflare, Public2P, Manago, Skroutz, BoxNow, Yuboto) were already private, so there is no override surface on those.
    • No REST API, DB migration, OpenAPI, or language-key changes.
  • [4.119.0] fix(mailer): stop corrupting message_type audit 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 the string $messageType argument to customer_message_history_model->addRecord(). !empty() always returns a bool (never null), so the ?? 'EMAIL' null-coalesce was dead code, and a bool was coerced into the string column — silently corrupting shop_customer_message_history.message_type with "1"/"". message_type is a delivery-channel enum (the SMS sibling path writes the literal 'SMS', and the REST layer filters messageType as an exact match), not a subject field — the actual subject text is stored separately in the sibling subject column, 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' as message_type, mirroring the SMS path's literal 'SMS'. The subject column 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, extending AbstractPatcher) that sets message_type = 'EMAIL' for existing rows where message_type IN ('1', ''). Every corrupted row went through the email path, so 'EMAIL' is correct for all of them. Idempotent: after the code fix message_type is never '1'/'', so re-running is a no-op.
    • Client forks. A fork carrying its own override of addEmailToCustomerHistory() (rather than inheriting the base Adv_mailer) must port the same change — pass the literal 'EMAIL' as the message_type argument — to get correct audit data.
    • Docs. docs/flows/system/SY-24-email-dispatch.md was corrected to describe message_type as a channel enum ('EMAIL'/'SMS'), not subject text, matching docs/flows/admin/AD-41-customer-mail-history.md.
    • No REST API, OpenAPI, or language-key changes.
  • [4.119.0] fix(logger): cast APP_LOG_MAX_FILES to 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), setting APP_LOG_MAX_FILES in .env crashed every request with an uncaught TypeError. Monolog's RotatingFileHandler declares int $maxFiles, but under declare(strict_types=1) the raw string read from env() was passed straight through, so PHP refused the implicit string-to-int coercion.
    • The change. application/config/monolog.php now casts the file-mode maxFiles value — 'maxFiles' => (int) env('APP_LOG_MAX_FILES', 15) — and AppLoggerFactory::buildStreamHandler() (src/Logger/AppLoggerFactory.php) defensively casts it again at the handler boundary — (int) ($globalConfig['maxFiles'] ?? 15) — so a stray string value can't reach RotatingFileHandler uncast from either path.
    • No REST API, DB migration, OpenAPI, or language-key changes.
  • [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's submitForm() short-circuited on mailchimpListsExist() before POSTing; with an invalid saved key the backend returned no lists, so the list &lt;select>s stayed empty, checkout.list stayed '', 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() read SUBSCRIBE_AS_PENDING without the (bool) cast its sibling ENABLED read 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-save location.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 by getAllListsWithGroupsAndInterests() into a clean empty mailchimp_lists array plus a new mailchimp_error field on the jsonState payload; the Vue store (assets/admin/js/mailchimp/modules/mailchimp.js) and MailchimpConfiguration.vue surface it as an error toast on load, taking precedence over the generic "no lists" toast. mailchimp() also now casts the SUBSCRIBE_AS_PENDING read to (bool), matching ENABLED, so the toggle reflects the saved value correctly.
    • Client forks. Purely additive — no protected/public base method was removed, renamed, or had its signature changed; mailchimp_error is a new field appended to an internal admin JSON payload, and the SUBSCRIBE_AS_PENDING fix is a (bool) cast on that same internal read. A fork that fully copy-pasted Adv_settings::mailchimp(), mailchimp.js, or MailchimpConfiguration.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.
  • [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->session was 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 calls isAdvisableUser() unconditionally, and a feed controller that fell through to error_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 to isCustomerLoggedIn() in eshop_helper.php but didn't extend it to these role-check helpers in auth_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->session is null. No behavior change on a request where a session is present.
    • No REST API, DB migration, OpenAPI, or language-key changes.
  • [4.119.0] refactor(video-stream): resolve BunnyStream through the DI container (Advisable-com/ecommercen#383)

    • What changed. Advisable\VideoStream\Stream\BunnyStream is now a Symfony DI service registered in src/VideoStream/container.php (wired into application/config/container/modules.php) and resolved via di()->get(BunnyStream::class) at all call sites, replacing the previous manual new 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, and AdvUploadVideoToStream.php, plus ecommercen/eshop/controllers/Adv_home.php and Adv_reels.php, now obtain the instance through di()->get(BunnyStream::class).
    • \Registry stays 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 the Registry id would hijack CI's own library load and recurse at boot. \Registry is therefore resolved lazily at request time via a private registry() helper on the service (same pattern as Apifon\Apifon and Domains\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).
  • [4.119.0] fix(sitemap): emit exclusive-vendor /details pages — loosen is_exclusive compare (Advisable-com/ecommercen#459)

    • Why. The sitemap generator never emitted the {vendor}/details URL for exclusive vendors. AdvGenerateSitemaps::getVendors() (ecommercen/job/libraries/AdvGenerateSitemaps.php:253) gated that URL behind $vendor->is_exclusive === 1 (strict), but Adv_vendors_model::getVendorsForSiteMap() returns is_exclusive from a raw CI3 query as the string "1", so "1" === 1 is always false and the /details URL 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' /details pages are emitted into the sitemap again. This matches the loose == 1 comparison every other is_exclusive reference in the codebase already uses. One-line change; no other behavior affected.
    • Impact. SEO completeness only — exclusive-vendor /details pages are now surfaced to crawlers. Low severity.
    • Client forks. Only getVendors()'s method body changed — no signature change, so BC. A fork that overrides getVendors() (or copied the old strict is_exclusive === 1 compare) must apply the same (int) cast, or it will keep silently dropping the exclusive-vendor /details URL from the sitemap.
    • No REST API, DB migration, OpenAPI, or language-key changes.
  • [4.119.0] fix(eshop): always initialize categoryIds/tagIds on parsed product objects (Advisable-com/ecommercen#436)

    • Why. Adv_product_parser_model only set categoryIds (getProductsCategories()) and tagIds (getProductTags()) on product objects that had at least one matching row in shop_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 fatal TypeError under PHP 8 — including the category-scoped coupon checks in Adv_coupons_model::isValidCoupon() and checkCategories() (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 initializes categoryIds and tagIds to [] on every product object as soon as it is built, before getProductsCategories() / 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 overrode Product_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.

Notes

  • [4.119.0] Check for overrides: the ecomntag GitHub 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.example all changed how the private ecomntag dependency authenticates and resolves, in CI and locally. A client fork with its own copies of these files must reconcile the same GitHub Packages source plus GH_PACKAGES_TOKEN/NODE_AUTH_TOKEN wiring when merging upstream, or its builds will keep trying (and failing) to resolve ecomntag via the retired Bitbucket SSH source.
    • GH_PACKAGES_TOKEN is a Secured Bitbucket workspace variable on devteamadvisable — 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 own GH_PACKAGES_TOKEN (an org-member read:packages PAT for Advisable-com, SSO-authorized), or its Docker build will fail npm auth against GitHub Packages.
    • Local dev: a developer doing a local npm install, running the node-cli dev container, or running a local Docker build now needs a NODE_AUTH_TOKEN (same kind of token — an Advisable-com org-member read:packages PAT, 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 returns false on rollback, so create_order() and create_order_admin()/_proccess_admin_order() (the admin POS/phone + marketplace path) gained fail-safe guards; getRecord() gained the same empty/NULL-serial lookup guard as getOrder()
    • Adv_order::checkout() (ecommercen/eshop/controllers/Adv_order.php)
    • Adv_checkout::_delivery(), _bank_transfer(), paidAtStore(), paypalResponseProcess() (ecommercen/checkout/controllers/Adv_checkout.php)
    • Adv_orders_admin manual order-create/edit/add actions that call create_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 central create_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, and Checkout are 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) is protected. A client fork that subclasses Search extends Adv_search (application/modules/search/controllers/Search.php) and overrides searchPage() 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 $page as 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 inherited Adv_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) is public, and Adv_blog::searchPage() is protected. A client fork that subclasses Adv_blog and 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 override search() or searchPage() — it is an empty subclass, so it inherits this fix automatically. Downstream client forks that do override search() 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\Base
    • Advisable\Europharmacy\Base
    • Advisable\PaymentGateways\Jcc\Jcc
    • Advisable\PaymentGateways\Iris\Iris
    • Klarna's and Shopflix's writeCalls() were already private, 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 — backfills shop_customer_message_history.message_type to 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 the mailchimp() handler on the parent, ecommercen/settings/controllers/Adv_settings.php, which now splits the Mailchimp list-fetch error into a mailchimp_error field on the jsonState payload, and casts the SUBSCRIBE_AS_PENDING read to (bool) (matching the existing ENABLED cast). A client fork that overrides Settings::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.js and MailchimpConfiguration.vue (assets/admin/js/mailchimp/) — the mailchimp_error toast and the credentialsChanged bypass in mailchimpListsExist(). 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). NamedLoggerInterface is now the required first parameter and Registry is de-promoted to a nullable trailing parameter. Any client fork that subclasses Advisable\VideoStream\Stream\BunnyStream or constructs it directly with new BunnyStream(...) positionally must reconcile to the new argument order, or it will pass the wrong types into $logger/$storage/$registry (fatal on the NamedLoggerInterface/Registry type hints, or silently wrong values if a client's own type hints happen to be loose).