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.102.md for this page in Markdown format</div>

Home | Changelog

Version 4

version 4.102

  • [4.102.0] fix(language): guard t() declaration in MY_Lang to fix combined-test-suite flake

    • Symptom. composer test (running Unit + Integration + Database + Legacy suites in one PHP process) fataled at ~46% with Cannot redeclare t() (previously declared in tests/Unit/Upload/AdvUploadValidatorTest.php(46) : eval()'d code:1) in application/core/MY_Lang.php on line 194. The flake was documented as a known limitation; running suites individually was the workaround.
    • Root cause. application/core/MY_Lang.php declared the global t() translation helper unconditionally at file scope. tests/Unit/Upload/AdvUploadValidatorTest.php:46 eval-defines a stub t() for its test harness (correctly function_exists-guarded). In a combined run, the Unit eval went first and the Integration suite's CI bootstrap then redeclare-fataled when it loaded MY_Lang.php.
    • Fix. Wrapped MY_Lang's declaration in the canonical if (!function_exists('t')) guard. Production behavior unchanged — nothing else defines t() before CI bootstrap in production, so the guard always passes and the canonical implementation registers exactly as before. Full suite now passes 5995/5995 with 43 expected RequiresPhpExtension self-skips for Redis/APCu/SHM.
  • [4.102.0] feat(language): move admin translation overrides from per-pod JSON to DB-backed store (Advisable-com/ecommercen#234)

    • Root cause. Admin-edited translation overrides lived in storage/user_langs/_user_langs.json, a single JSON blob mutated by every save. The legacy AdvLanguageStorageModel::readFromFile/writeToFile pair did file_get_contents + decode + array merge + file_put_contents per write. On a multi-pod K8s deployment this is unsafe in two ways: writes from pod A are not visible to pod B until its own write triggers a re-read, and concurrent saves race on the JSON encode/decode without any cross-host lock. The file also did not survive pod restarts when the volume was not persistent.
    • New Advisable\Language\Storage\LanguageOverrideStore (src/Language/Storage/LanguageOverrideStore.php). Single concrete class — no interface, no decorator, no pluggable adapter selection. Owns reads (getOverrides) and writes (applyChanges) against the new language_overrides MySQL table. Per-request memoisation in a private array keyed by "{bundle}:{lang}" coalesces repeat reads of the same slice within one request; the memo for an affected slice is invalidated automatically when applyChanges succeeds. A second per-request memo caches table_exists('language_overrides') so the SaaS wizard (or any caller that fires before migrator.php migrate on a fresh install) returns [] cleanly on reads and logs-and-no-ops on writes — without this guard, CI3's db_debug=true HTML+exit(8) would kill the bootstrap on a missing-table query. loadProxy(__FILE__) static helper is called by the 8 application/language/{idiom}/_user_theme_lang.php proxy files — parses (bundle, lang) from the file path, resolves the store via DI, returns the override slice (returns [] and logs via PSR-3 on any failure so the request still serves base strings).
    • No external cache layer. Earlier drafts of this work included a Redis-backed CachedStore decorator over a DatabaseAdapter. That was removed before merge: the language_overrides table is small and the per-request memo absorbs the repeat-read cost. Eliminating the cache layer also removed an entire class of bugs (write-through races, multi-pod cache poisoning under TTL=0, FileAdapter-default L2 silently re-introducing per-pod staleness, missing-table negative caching surviving a successful migration). Cross-pod consistency is now "free" — every read is the latest committed DB state with no TTL window.
    • language_overrides schema via Phinx migration 20260526120000_create_language_overrides.php. utf8mb4 table charset + ROW_FORMAT=DYNAMIC for the 3072-byte InnoDB index limit. lang_key declared with COLLATE utf8mb4_bin so the (bundle, lang, lang_key) unique constraint is byte-exact — without this override, the table-default utf8mb4_unicode_ci would treat Profile.Name vs profile.name (or cafe.x vs café.x) as the same key and silently collapse them under INSERT IGNORE / ON DUPLICATE KEY UPDATE. VARCHAR(255) for lang_key (project convention) — 1020 bytes per indexed entry, well under the 3072-byte limit. No my.cnf changes required on MariaDB 10.2.2+ / MySQL 5.7.7+ where the relevant settings (innodb_default_row_format=dynamic, innodb_file_per_table, innodb_large_prefix) are defaults or removed; the project pins to MariaDB 11.6.2 (.docker/integration/.env:60). See 20260210120630_convert_db_mb_3_to_mb_4.php for the broader utf8mb4-readiness analysis. lang_value MEDIUMTEXT accommodates long marketing copy. updated_at DATETIME ... ON UPDATE CURRENT_TIMESTAMP is a free audit column.
    • Idempotent one-shot JSON-to-DB patcher. patches/MigrateUserLangsJsonToDb.php decodes any legacy _user_langs.json and bulk-inserts in batches of 200 via INSERT IGNORE INTO language_overrides ... VALUES ... keyed by the unique constraint. Triggered by Phinx migration 20260526120001_migrate_user_langs_json_to_db.php. Hardening over the original draft: (1) skips non-scalar values with a warning instead of casting them to the literal string 'Array'; (2) skips keys longer than 255 chars with a warning instead of silently truncate-and-collide; (3) bails with RuntimeException on any unexpected DB failure so Phinx does NOT mark the migration as applied while the import is partial; (4) renames the source file to _user_langs.json.imported on success so a subsequent rollback + migrate cycle doesn't silently re-import overrides the admin has since reverted; (5) logs every early-return branch (no legacy file, empty content, non-array decode) so an operator can distinguish "no-op by design" from "no-op by misconfiguration".
    • AdvLanguageStorageModel::setMessages / unsetMessages collapse into one delegated call. Both methods now resolve the store via di()->get(LanguageOverrideStore::class) and dispatch a single $store->applyChanges($bundle, $languageUri, $setKV, $unsetKeys). setMessages computes the diff vs the base dataset using array_diff_assoc / array_intersect_assoc (keys + values — fixes a latent bug in the previous values-only comparison that collapsed keys with identical values) and sends additions/changes as $setKV and revert-to-base keys as $unsetKeys in the same atomic call. DB writes wrap the set+unset in trans_start / trans_complete. The legacy readFromFile / writeToFile private methods are gone.
    • 8 application/language/{idiom}/_user_theme_lang.php proxy files rewritten as one-line delegates. Each contains $lang = \Advisable\Language\Storage\LanguageOverrideStore::loadProxy(__FILE__);. Eight idioms affected: chinese, english, french, german, greek, italian, russian, spanish. The legacy proxy that did file_get_contents(FCPATH . USER_LANGS_JSON_PATH . '_user_langs.json') + JSON decode is gone.
    • DI wiring in application/config/container/language.php. Autowires LanguageOverrideStore (constructor takes CI_DB_query_builder — resolved via the DI-registered factory landed in #230) and exposes it as a public service. Module registered in application/config/container/modules.php between project_agora.php and the domain modules.
    • Integration tests. tests/Integration/Language/Storage/LanguageOverrideStoreTest.php (16 tests against the real test DB): getOverrides empty / round-trip / upsert / unset / mixed set+unset / empty-input no-op / bundle+lang scoping / cross-slice non-interference on unset / Unicode for Greek+CJK+Arabic / byte-exact uniqueness on Profile.Name vs profile.name / per-request memoisation (memo returns the previously-fetched value after direct DML on the row) / memo invalidation by applyChanges / 250-row batch upsert / loadProxy happy-path and bad-filename fallback.
    • Flow doc docs/flows/admin/AD-26-language-management.md rewritten — Storage Backend section describes the DB-backed store, the schema, the per-request memo, the rollout patcher, and the rationale for dropping the cache layer. Method table corrected. Tests section added.
    • Deployment doc docs/flows/system/SY-29-deployment-architecture.md:135 updated — notes that storage/user_langs/ is now only the legacy-import entry point and runtime persistence is the language_overrides table.
  • [4.102.0] fix(admin/language): translation editor now shows the saved value immediately

    • Symptom. In the Languages admin editor, pressing Save on a translation override returned 200 OK but the displayed value snapped back to the previous string for ~200ms + the round-trip to /language_admin/api/list before catching up. Long enough that admins thought the save had silently failed and pressed reload.
    • Root cause. assets/admin/js/languages/components/LanguagesListing.vue cleared the local edit buffer (editingMessages.delete(m.key)) on success and then called fetchLanguageData() — a useLazyFetch wrapper with a 200ms default debounce + a full list re-fetch. In the gap, the rows computed fell back to languageData.value.data.messages.user[m.key] (the pre-save value) for the row's message.
    • Fix. Optimistically reflect the save in the local payload before the re-fetch fires. The save handler now mutates languageData.value.data.messages.user[m.key] straight after the PUT returns 2xx, mirroring the server's set-or-revert semantics: a saved value matching the base drops the override entry from the dict, anything else upserts it. The revert handler deletes the entries locally so the row falls back to the base value immediately. fetchLanguageData() is kept as a background reconciliation pass (will correct any divergence if another admin saved concurrently).
    • Build note. Source-only change to the .vue component. The compiled bundle at public/ui/admin/dist/languages.js picks this up on the next npm run admin-dev / admin-production.
  • [4.102.0] fix(config): silence cron-mail flood on staging (ENVIRONMENT=testing)

    • Symptom. Bare-metal Plesk staging servers running ENVIRONMENT=testing had every * * * * * php cli.php job_manager cron invocation flooded with PHP notices/warnings/deprecations from CI3 + vendor code, plus occasional HTML-error dumps from CI3's show_error() on transient DB hiccups (e.g. wait_timeout reconnects during long jobs). Forcing ENVIRONMENT=production silenced the flood but coupled in production-only side effects unwanted on staging — noindex,nofollow meta dropped (application/views/main.php:14, default.php:18); Google Tag Manager / Analytics / Matomo / push-notification scripts emitted (gated on === 'production' across application/views/production/*.php, application/views/main/components/footer/footer_js.php, ecommercen/core/Adv_front_controller.php:729,1402).
    • Two flood routes bypassed Monolog. Both originated from ENVIRONMENT != 'production' and emitted directly to stderr/stdout, around the PSR-3 logger and APP_LOG_THRESHOLD. Route A: public/index.php set error_reporting(E_ALL) for testing — PHP-raised notices/warnings hit _error_handler (application/helpers/log_helper.php:157) → _logger_dispatch_or_fallbackerror_log() fallback during very-early bootstrap (before Container::markBootReady() fires from the pre_controller hook) → stderr → cron mail. Route B: application/config/database.php:15,38 hardcoded db_debug = true for non-production — CI3's MySQL driver called show_error() on any failed query, dumping HTML to stdout and exit()-ing the subprocess. APP_LOG_THRESHOLD=NOTICE does not gate either route — Monolog never sees these records.
    • error_reporting decoupling. public/index.php:71-73 testing branch flipped from error_reporting(E_ALL) to error_reporting(0), matching the production branch. PHPUnit bootstraps via tests/bootstrap.php (per phpunit.xml.dist:14) which never touches error_reporting(), so the Unit/Integration/Database/Legacy test suites are unaffected.
    • db_debug wired up to the existing env vars. application/config/database.php:15,38 db_debug now reads from APP_DB_DEFAULT_DEBUG and APP_INTERMEDIATE_DB_DEFAULT_DEBUG — both declared in .env.example:28,35 since the initial repo layout but never previously wired into any PHP reader. String defaults ('true'/'false') coerce cleanly through env()'s switch arms (public/index.php:35). Default behavior preserved: when the env var is unset the fallback is (env('ENVIRONMENT') !== 'production').
    • Staging operators — no .env change required. .env.example already ships APP_DB_DEFAULT_DEBUG=false / APP_INTERMEDIATE_DB_DEFAULT_DEBUG=false. Deployments that copied the example already have these set; the wiring just makes the values finally take effect on the next deploy. With ENVIRONMENT=testing + the existing =false lines, cron mail goes quiet.
    • Dev operators — opt-back-in for show_error() HTML. Devs whose local .env was copied from .env.example will have APP_DB_DEFAULT_DEBUG=false set explicitly and will lose CI3's show_error() HTML on dev after this commit. To restore the dev-time DB-debug HTML, set APP_DB_DEFAULT_DEBUG=true in your local .env.
    • Out of scope. application/config/database.php:25,48 save_queries follows the same (env('ENVIRONMENT') !== 'production') pattern and accumulates queries in PHP memory for the profiler — relevant for long-running cron jobs but not the reported symptom; deferred. application/config/testing/database.php overrides db_debug to false when TEST_DB_HOSTNAME is set — the var name is not PHPUnit-namespaced, so a stray export on staging silently routes the app to the test DB; flagged separately as a hygiene concern.
    • Files changed. public/index.php (1 line), application/config/database.php (2 lines). No DB migration, no asset rebuild, no env-var renaming, no client-repo override surface affected.
  • [4.102.0] feat(payment): Nexi XPay payment gateway integration

    • Scope. New hosted-payment-page gateway alongside the existing PayPal / Eurobank / Alpha / Ethniki / Viva options. Covers regular checkout, gift cards, server-to-server webhooks with securityToken verification, and pending-order reconciliation via cron. 37 files / 2,602 insertions.
    • Gateway class. src/PaymentGateways/NexiXPay/XPay.php — modern PSR-4 class with HPP order creation (POST api/v1/orders/hpp), order-status lookup (GET api/v1/orders/{id}), notification parsing, status mapping, securityToken verification, and audit logging. Guzzle client and CI_DB_query_builder are injectable for testability; production callers stay on new XPay($config) and get the autowired defaults. Status mapping covers Nexi's full operationResult enum (AUTHORIZED/EXECUTEDPAID, DECLINED/DENIED_BY_RISK/THREEDS_FAILED/VOIDED/FAILED/CANCELEDCANCELED, THREEDS_VALIDATED/PENDINGPENDING, REFUNDEDREFUNDED).
    • Webhook authenticity. Per the Nexi XPay Greece developer portal, the only documented webhook-authenticity mechanism is the securityToken returned at HPP creation and echoed back in every notification body — there is no HMAC. XPay::verifyNotificationSecurityToken() reads the stored token from the most recent successful RESPONSE row in xpay_logging and compares with hash_equals. Fail-closed: rejects when either side is missing or empty. Wired into both webhook handlers before any state mutation; rejected callbacks return HTTP 401 but still leave a forensic CALLBACK row in xpay_logging.
    • Cross-flow defense. xpay_logging carries a flow VARCHAR(20) NOT NULL discriminator ('order' | 'gift_card'). Every read/write filters on it so a gift-card webhook cannot resolve a regular order's stored token even when GIFT_CARDS.ORDER_PREFIX is empty and order serials happen to collide. Composite index idx_token_lookup (order_serial, flow, transaction_type, status, created_at) covers the lookup query path. The discriminator threads through createHostedPaymentOrder, logToDatabase, verifyNotificationSecurityToken, and getStoredSecurityToken — default 'order' keeps the regular-checkout call sites untouched; gift-card callers pass 'gift_card' explicitly.
    • Regular checkout flow. Adv_checkout::xpay() / xPaySuccess() / xPayCancel() / xPayHook() plus dispatch entries in the process_order switch and the get_response action router. Webhook state machine: Pending → PAID/CANCELED, PAID → CANCELED (reversal), CANCELED → PAID (resurrection); REFUNDED ignored with an info log. Cron reconciliation in Cronjob::handlePendingXpayOrders() and the equivalent AdvCancelIncompleteOrders JobCommand path — both call getOrderStatus() to accept-if-PAID / cancel-otherwise for orders stuck in PENDING after the timeout window.
    • Gift card flow. AdvGiftCardPage::xpayFormData() / xPaySuccess() / xPayCancel() / xPayHook() mirror the iris / paypaladvanced precedent, with a separate webhook route (gift-card/xPayHook vs checkout/xPayHook) so the lookup-table concerns don't mix — gift cards land in gift_card_orders, regular orders in orders. State machine extracted to a pure static helper xpayWebhookAction(GiftCardStatus, string): string so the decision logic is unit-testable in isolation. Only (Pending, PAID) ever yields 'accept' — explicit guard against duplicate-coupon issuance from retried webhooks, since acceptGiftCard() is not idempotent (always creates a new coupon row). Reconciliation cron via AdvCancelPendingGiftCards::cancelPendingXpayOrders().
    • Admin / config / i18n. New XPAY registry group with API_KEY (required) and IS_PRODUCTION toggle, surfaced in AdvPaymentsRegistry::providers() and the admin payment-settings view (labels via t('payway.xpay.*')). 'xpay' added to getGiftCardPayWays() so the admin selector picks it up; 'xpay' => [] entry in the gift-card Vue payWayInstallments map (XPay does not carry installments, matches iris / alpha / ethniki / vivawallet). Translation keys (payway.xpay, payway.xpay.api_key, payway.xpay.is_production, checkout.xpay.fail.title, checkout.xpay.error.retry, checkout.xpay.error.creation_failed, checkout.xpay.order_description, eshop.admin.payways.xpay) added to all 8 supported languages.
    • Migration. database/migrations/20251117205429_create_xpay_logging_table.php uses the Phinx fluent API ($this->table()->addColumn()->addIndex()->create()) per .claude/rules/migrations.md. Schema: id (unsigned auto-increment), order_serial, flow, transaction_id, transaction_type (VARCHAR(50), not ENUM, to accommodate the controller's extra log types like PROCESSED, STATUS_CHECK, REQUEST_FAILED), request_data, response_data, status, created_at. Four indexes: idx_order_serial, idx_transaction_id, idx_created_at, plus the composite idx_token_lookup covering the securityToken verification query path.
    • Tests. 69 new tests total. tests/Unit/PaymentGateways/NexiXPay/XPayTest.php — 57 tests covering constructor (sandbox/production URL selection, IS_PRODUCTION truthy-string coercion, default-empty API_KEY), pure helpers (generateCorrelationId, mapOperationResultToStatus data-provider across all enum values, parsePaymentNotification happy and exceptional paths, formatAmount), HTTP-mocked createHostedPaymentOrder and getOrderStatus via GuzzleHttp\Handler\MockHandler (request endpoint, headers, body shape, amount-to-cents conversion, language resolution from ISO639-2, optional customer-data handling, all error paths), API-key redaction in log output (Monolog\TestHandler + DI-container logger swap), and the full verifyNotificationSecurityToken matrix (matching/mismatching tokens, missing fields, empty strings, DB exceptions, query-shape regression including the flow filter, regression guard that gift-card flow does not leak a flow='order' filter). tests/Legacy/GiftCards/AdvGiftCardPageTest.php — 12 tests on xpayWebhookAction (data-driven decision matrix across all (GiftCardStatus, xpayStatus) pairs, exhaustive 3×6 matrix walk asserting only the three allowed action strings ever escape the function, regression guard that 'accept' is never returned outside the Pending + PAID path).
  • [4.102.0] chore(payment/xpay): remove dead CORRELATION_ID_PREFIX config (closes Advisable-com/ecommercen#249)

    • Declared as protected ?string $correlationIdPrefix on XPay and listed in the constructor docblock as a supported config key, but never assigned from registry, never read at runtime, and never wired through admin UI or getXPaySettings(). Closes the follow-up issue filed when the XPay integration shipped.
    • Property dropped from XPay; matching lang keys removed from all 8 languages; docs/flows/integration/IN-23-xpay-nexi-payment-gateway.md Known Issues list renumbered (2-9 → 1-8) so the list stays contiguous after the gap.
  • [4.102.0] fix(admin/sliders): two bugs on the slide edit form — closes Advisable-com/ecommercen#250 and Advisable-com/ecommercen#251

    • #250 — "Show image" preview never rendered on the slide edit form. application/views/admin/sliders/slides/update.php:217 used the pre-refactor slide_image property name in its guard expression after the structured-record refactor (ef5d6771d) renamed the MUI image field to image (matching slide_mui.image and what slide_model::getMasterRecord() returns). The condition was always falsy, so the &lt;p>…show_image…delete_image…&lt;/p> block was skipped for slides with an existing image. One-character path change to $record?->$languageAbbr?->image. create.php is unaffected (no existing image to preview).
    • #251 — ImageSelect Chosen extension paired chips by text equality. public/ui/plugins/image-select/1.8/ImageSelect.jquery.js::getSelectedOptions() paired selected-chip &lt;span>s to underlying &lt;option>s by .text() == .text(), so whenever two or more selected options shared the same display text every chip got paired with the first matching option and prependTemplate injected the same thumbnail into all of them. Visible on the admin slider edit page's Select Slides widget when a slideshow contained several slides with the same slide_mui.title (e.g., all slides for one ship labeled "TERA JET"); any other admin form using .js-image-select with non-unique option text hit the same bug. Rewritten to use Chosen's own data-option-array-indexresults_data[idx].options_index mapping (the same mechanism the plugin's own chosen:showing_dropdown handler uses for dropdown list items), scoped to .chosen-choices .search-choice to guard against unrelated &lt;span> nodes inside .chosen-choices. Single-select branch preserved with original behaviour. Callers (chosen:hiding_dropdown, chosen:ready) consume the returned [{span, option}, …] pairs unchanged.
    • Build note. The JS fix is source-only. Per team convention dist rebuilds happen as a separate build(release) / chore(build) commit at release-cut time (cf. b81e70c46, 00b58869b), so this commit does not touch public/ui/admin/dist/vendors.js. The fix won't reach runtime until the next release-cut npm run admin-production.
    • Provenance. Both bugs originally reported and patched locally in the seajets client repo; ported upstream with the same diffs.
  • [4.102.0] feat(storage): add AdvMigrateStorageToS3 job for cutting tenants over from local to S3 (follow-up to Advisable-com/ecommercen#235)

    • Scope. New JobCommand at ecommercen/job/libraries/AdvMigrateStorageToS3.php. Takes a list of storage type names (storages option), iterates each type's local disk recursively, and streams every object up to the corresponding s3 disk via Flysystem readStream → writeStream. Intended as a scriptable migration step before flipping PRIVATE_STORAGE_DISK=s3 (or similar) on a deployment so the target bucket is populated rather than empty.
    • Options. storages (required — CSV string from CLI or JSON array from the queued-jobs table). Each entry is a storage type name with an optional subpath: private migrates the whole storage type root, private/invoices only the invoices/ subtree, private/vouchers/dhl only the multi-segment vouchers/dhl/ subtree. A single invocation can mix scopes: private/invoices,private/vouchers/dhl,files/products. Three more options: dryRun (bool, optional — counts but doesn't write or delete), deleteSource (bool, optional — removes the local copy after a verified S3 write), progressEvery (int, optional, default 100 — log a progress line every N files seen).
    • Idempotent re-runs. Each file is checked against the destination via $s3->exists($path) before copying. Files already present skip the copy (no overwrite) but still participate in deleteSource if the flag is set. Safe to re-run after partial failure or to mop up a botched cutover.
    • Per-file error resilience. Failures (network blip, bucket throttle, etc.) are logged via NamedLoggerInterface->error() and the loop continues with the next file. A final summary line reports seen / copied / skipped / failed / deleted counts. One bad file does not abort the type; one bad type does not abort the list.
    • deleteSource safety. When the flag is set, the source is only removed after a post-write $s3->exists($path) re-verification — defends against a putStream that silently succeeded against an unwritable bucket prefix. A verification failure logs an error and keeps the source.
    • Streaming semantics. No file is buffered fully in PHP memory — uses getStream / putStream. Source streams are fclose-d in finally to release file handles.
    • CLI invocation. php cli.php job/MigrateStorageToS3 --storages=private --dryRun=true for a safe first pass against the whole private type, then drop --dryRun=true for the live run. Restrict to a subtree: --storages=private/invoices. Combine targets: --storages=private/invoices,private/vouchers/dhl,files/products. The bool options use filter_var(..., FILTER_VALIDATE_BOOL) so --dryRun=true/1/yes all parse the same way.
    • DB-queued invocation. jobs_model->add(['job' => 'MigrateStorageToS3', 'job_arguments' => json_encode(['storages' => ['private'], 'deleteSource' => false])]). The job arguments arrive as a JSON-decoded array, so storages can be a real array there.
    • Out of scope. Bulk transfer at archive scale (millions of files, hundreds of GB) is still better served by aws s3 sync or S3 Transfer Acceleration. This job is for small-to-medium deployments and for tenants that want a scriptable, progress-logged migration path without leaving the application toolchain.
    • Tests. tests/Legacy/Job/AdvMigrateStorageToS3Test.php — 22 unit tests covering option normalisation (string/array/null inputs, bool truthy/falsy variants, type/subpath syntax including multi-segment subpaths, trailing-slash normalisation, leading-slash malformed input), happy-path copy, skip-if-exists idempotency, dry-run write/delete suppression, deleteSource happy path, post-write verification failure path, per-file failure resilience, directory entries ignored, progress-log cadence, local-listing failure, and rootPath-filtered migration (single-segment + multi-segment). Uses an anonymous Storage subclass (same pattern as LocalTempFileTest) — no DB, no CI bootstrap dependency.
  • [4.102.0] refactor(storage): migrate IMPORT_FILES_PATH callers onto the private storage type — closes Advisable-com/ecommercen#235

    • Scope. Seventh and final PR in the #235 migration series. Twelve runtime call sites across four job libraries and the products-admin controller, plus the last InternalMoveFolder hardcode. Closes the parent issue: zero ../storage/* upstream references remain in any constant value or runtime caller (only the on-disk directory comment in .gitignore/.dockerignore/Dockerfile is left, which is correct — those describe the local-disk root layout).
    • ecommercen/job/libraries/AdvInsertProductsFromFile.php:handleProductsInsert, AdvUpdateProductsFromFileByProductCode.php:handleProductsUpdate, AdvUpdateProductsFromFileByBarcode.php:handleProductsUpdate. Three near-identical xlsx-consumer jobs: each downloaded FCPATH . IMPORT_FILES_PATH . $fileName into a local $xlsxPath, opened it with PhpSpreadsheet, processed inside a DB transaction, and unlink-ed on successful processing. Refactored to wrap the DB transaction body in LocalTempFile::withFile(storage('private'), $remotePath, fn ($localPath) => …) — the materialised local path goes to PhpSpreadsheet, and LocalTempFile cleans up the temp file in finally. The callback returns a bool indicating "consumed"; the outer code calls storage('private')->delete($remotePath) only when the original unlink-on-success predicate held.
    • ecommercen/job/libraries/AdvUploadImagesFromZipFile.php. Same shape as the xlsx jobs — callback returns a bool indicating "consumed" and the outer code deletes the source ZIP only when extraction + processing succeeded. This is a deliberate semantic change from the pre-PR original unlink($filePath)-in-finally (unconditional). The new behaviour preserves the source ZIP on download/extraction/processing failure so operators can investigate and retry without re-uploading; the xlsx-job consumed-guard pattern made this the natural shape. The ZIP extraction also needs a second local scratch dir for ZipArchive::extractTo and the iterator-based file copy. The old code used FCPATH . IMPORT_FILES_PATH . 'temp/' for this; replaced with a per-call unique sys_get_temp_dir() . '/zipimport_&lt;uniqid>' so concurrent jobs don't collide. Internal removeDirectory($tempPath) cleanup is wrapped in a nested try/finally so the zip handle always closes even if extraction throws, and guarded by is_dir() to defend against extractZipFile throwing before mkdir ran.
    • ecommercen/eshop/controllers/Adv_products_admin.php:product_prices_csv. Same skip-disk pattern as PR4's signature blob: the CSV is processed inline and never read again, so the disk landing was accidental complexity. Replaced $this->load->library('upload') + do_upload + readCSV(FCPATH . CONST . 'product_prices.csv') + unlink with UploadValidator + readCSV($_FILES['csv_file']['tmp_name']). PHP cleans up tmp_name at request end. Removed the "succeed-but-no-data" branch which was a CI3-specific concern that doesn't apply to UploadValidator's contract.
    • ecommercen/eshop/controllers/Adv_products_admin.php:handleFileUpload. XLS/XLSX upload feeds both an inline PhpSpreadsheet read AND a downstream async job (filename returned to caller). Refactored to: UploadValidator validation → PhpSpreadsheet load from $_FILES[..]['tmp_name'] (still local mid-request) → storage('private')->putStream(IMPORT_FILES_PATH . $fileName, fopen($tmp, 'rb')) to persist for the job. Filename now constructed with explicit extension (pathinfo()->PATHINFO_EXTENSION from the original upload), preserving the previous 'productdata' . date('YmdHis') . uniqid() shape with the correct .xls/.xlsx suffix that Adv_Upload's data['file_ext'] used to provide.
    • ecommercen/eshop/controllers/Adv_products_admin.php:upload_zip_images. ZIP upload + async job enqueue. Replaced $this->load->library('upload') + do_upload + data() with UploadValidator + storage('private')->putStream(IMPORT_FILES_PATH . $fileName, fopen($tmp, 'rb')). The job arguments still carry the same fileName shape.
    • ecommercen/eshop/controllers/Adv_products_admin.php:handleUploadError. Kept the original zero-arg signature to preserve the protected-method contract with client-repo subclasses. Body changed to emit a generic t('eshop.admin.products.import.failed') message — the only remaining caller is the PhpSpreadsheet parse-failure branch in handleFileUpload. Validator-sourced errors are now rendered inline at the validate() failure site rather than dispatched through this helper, which avoided changing the override surface.
    • ecommercen/storageJobs/AdvUpgradeStorage.php:execute. Final hardcode: files/import'../storage/import'. The method-level comment from PR5/PR6 is now simplified ("Destinations are hardcoded to preserve idempotent one-time migration semantics for clients still rolling forward from the pre-#235 layout") since all four lines are now literal strings.
    • Constant flip. application/config/constants.php:204 IMPORT_FILES_PATH = '../storage/import/''import/' with the standard doc comment.
    • Client-repo compatibility — developers, check before / right after upstream sync. All six constants migrated by #235 changed their value's semantics from FCPATH-relative to storage-relative. Client repos that override or extend code consuming these constants via the raw FCPATH . CONST_PATH . $file pattern will silently break on next upstream sync (the .. segment is gone, so the path no longer resolves from CWD). Grep the following in each client repo (application/, custom/, and any client-overridable areas) before merging the next upstream pull, and replace each hit with the storage('private')->... equivalent: git grep -nE 'FCPATH\s*\.\s*(GENERATED_VIEWS_PATH|DHL_VOUCHER_SAVE_PATH|TEMPORARY_STORAGE_PATH|FORMS_ATTACHMENTS_PATH|INVOICE_PDF_BASE_PATH|IMPORT_FILES_PATH)'. Also worth checking: raw ../storage/&lt;subdir>/ string literals (e.g., '../storage/tmp/') that may have been used as shortcuts in custom code. Fix shape: storage('private')->get($path) / ->put($path, $bytes) / ->putStream($path, $stream) / ->exists($path) / ->delete($path); for local-path-only consumers (PHPMailer, GD, PhpSpreadsheet) wrap in LocalTempFile::withFile(...). The grep is intentionally narrow — only flags the stale-pattern call sites; existing storage() consumers are unaffected.
    • Regression bar: full unit suite still green — 2670 tests / 7859 assertions. The Advuploader $storageType plumbing, UploadValidator validation, and LocalTempFile materialisation are all foundation-tested; integration coverage of the seven changed flows (3 admin uploaders, 4 jobs, 1 download endpoint per PR6) requires manual smoke at staging.
  • [4.102.0] refactor(storage): migrate INVOICE_PDF_BASE_PATH callers onto the private storage type (Advisable-com/ecommercen#235)

    • Scope. Sixth PR in the #235 migration series. Six runtime call sites across two src/ domain modules, the legacy mailer, and the orders-admin controller. Reused the PR5 mailer pattern via a new private sendWithStoredAttachments helper to keep the two send()-with-invoice callers identical in shape.
    • src/Transporters/Dhl/Dhl.php:83. Was file_get_contents(FCPATH . INVOICE_PDF_BASE_PATH . $shipmentData['invoice_pdf']) with a !$invoicePdfToString guard. Replaced with storage('private')->get(...) wrapped in try/catch (Flysystem read() throws UnableToReadFile on missing/unreadable, whereas file_get_contents returned false). Existing logger.error + early return contract preserved.
    • src/Skroutz/SkroutzOrders.php:63. Same shape — storage('private')->get(...) replaces the absolute file_get_contents. No try/catch needed here; the original code didn't guard either, and the caller (uploadInvoice) expects the multipart upload to succeed or surface the underlying error.
    • application/models/Adv_mailer.php. Two send(...) callers in order_has_been_updated() and order_is_on_store() were passing getOrderUpdatedAttachments(...) as the seventh positional arg. New flow: getOrderUpdatedAttachments returns storage-relative paths (drops FCPATH .), and the two callers switched to a new private sendWithStoredAttachments helper that wraps send in LocalTempFile::withFiles(storage('private'), $remote, fn ($local) => $this->send(..., $local)). PHPMailer still gets real local paths via Mailer::addAttachments; temp files are unlinked in finally including the failure path. sendContactFormEmail from PR5 continues to wrap doSend directly (different SMTP config, no template/customer).
    • ecommercen/eshop/controllers/Adv_orders_admin.php:315-333 — CI3 raw uploader → Advuploader bridge. The invoice PDF upload is long-lived (filename stored in DB and read back by other code), so this case requires a real storage write, not the skip-disk pattern used for the transient signature blob in PR4. Replaced the inline $this->upload->initialize/do_upload block with $this->load->library('advuploader') + $this->advuploader->uploadFile('invoice_pdf', INVOICE_PDF_BASE_PATH, 'pdf', max_upload_size, 'private'). Uses the $storageType plumbing added to Advuploader::uploadFile in the foundation PR. Error display preserved (now sourced from $this->advuploader->errors() rather than $this->upload->display_errors()).
    • ecommercen/eshop/controllers/Adv_orders_admin.php:3351-3358 — invoice PDF download. Was force_download(... @file_get_contents(FCPATH . INVOICE_PDF_BASE_PATH . $order->invoice_pdf), true). Replaced with try/catch around storage('private')->get(...) + force_download($pdfContents, true). The error-suppressed @ silently served zero-byte downloads on missing files; the new code logs the error and serves 404 — better operator-visible behaviour.
    • ecommercen/storageJobs/AdvUpgradeStorage.php:execute. files/invoices destination hardcoded to '../storage/invoices'. The method-level comment introduced in PR5 covers the rationale; only the literal string changed. Remaining unmigrated constant (IMPORT_FILES_PATH) still uses rtrim(CONST, '/').
    • Constant flip. application/config/constants.php:199 INVOICE_PDF_BASE_PATH = '../storage/invoices/''invoices/' with the standard doc comment.
    • Regression bar: full unit suite still green — 2670 tests / 7859 assertions. The Advuploader $storageType plumbing is the load-bearing piece from the foundation PR; the integration-style verification (real upload through storage('private') to a local-disk root) requires manual smoke at staging.
  • [4.102.0] refactor(storage): migrate FORMS_ATTACHMENTS_PATH callers onto the private storage type (Advisable-com/ecommercen#235)

    • Scope. Fifth PR in the #235 migration series. First use of the foundation PR's LocalTempFile helper for a real mailer attachment flow.
    • application/models/Adv_mailer.php:sendContactFormEmail. Was array_map(fn ($item) => FCPATH . FORMS_ATTACHMENTS_PATH . $item, $attachments) — building a list of filesystem paths that the mailer's doSendMailer::addAttachments → PHPMailer's addAttachment consumed. PHPMailer requires real local filesystem paths; with private on S3, the stored object is not local. Wrapped the doSend call in LocalTempFile::withFiles(storage('private'), $remotePaths, fn ($localPaths) => $this->doSend(..., $localPaths)) — each attachment is downloaded into sys_get_temp_dir() for the duration of the send and unlinked in the finally, including the failure path. Empty $attachments is handled by withFiles' empty-input contract (callback invoked with [], no temp files created).
    • ecommercen/forms/controllers/Adv_forms.php:getUploadConfig. Added 'storageType' => 'private' to the per-field config dict that Advuploader::uploadFilesUsingConfig consumes. The foundation PR taught that method to read this key (defaulting to 'files'), so this is a one-line plumbing change that routes the contact-form attachment upload through the new storage type.
    • ecommercen/storageJobs/AdvUpgradeStorage.php:execute. files/forms destination hardcoded to '../storage/forms' (same pattern as the DHL line from PR3). Consolidated the two existing per-line comments into a single method-level comment explaining the rationale once. The other two unmigrated constants (INVOICE_PDF_BASE_PATH, IMPORT_FILES_PATH) continue to use rtrim(CONST, '/') until their own PRs flip them.
    • Constant flip. application/config/constants.php:208 FORMS_ATTACHMENTS_PATH = '../storage/forms/''forms/' with the standard documentation comment.
    • Regression bar: full unit suite still green — 2670 tests / 7859 assertions. The LocalTempFile::withFiles use is covered by foundation-PR unit tests (key preservation, cleanup-on-throw, cleanup-on-return, empty-input).
  • [4.102.0] refactor(storage): migrate TEMPORARY_STORAGE_PATH + favicon raw literals off ../storage/tmp/ (Advisable-com/ecommercen#235)

    • Scope. Fourth PR in the #235 migration series. Cleans up the TEMPORARY_STORAGE_PATH constant's only upstream caller plus the two bare '../storage/tmp/' literals in the favicon system that the issue body called out.
    • ecommercen/eshop/controllers/Adv_transporters_admin.php:693-717 — disk dance dropped entirely. The signature-image upload flow was uploading the blob via CI3's Adv_Upload library into ../storage/tmp/, immediately reading it back with file_get_contents, base64-encoding for DB persistence, and then unlink-ing the file. The temp landing was accidental complexity — AdvUploadValidator doesn't need an upload_path, and $_FILES['SIGNATUREIMAGE']['tmp_name'] is already a sys-temp file that PHP cleans up at request end. New flow: UploadValidator validation against the $_FILES slot, then file_get_contents($_FILES['SIGNATUREIMAGE']['tmp_name']) direct read, then base64. Three filesystem operations and a TEMPORARY_STORAGE_PATH reference eliminated, S3-portable by construction.
    • src/Favicon/FaviconImageGenerator.php:164-168 — sys_get_temp_dir() + cleanup fix. GD's image{png,jpeg,...} callbacks require a real local path, so this site genuinely needs a filesystem scratch file — but it was using ../storage/tmp/$target.$newImageExt (an application-storage location) and never unlinking afterwards, leaking one file per resize per favicon. Replaced with tempnam(sys_get_temp_dir(), 'fav_') + try/finally unlink. Final destination (storage()->put($faviconDir . ...)) is unchanged.
    • ecommercen/settings/controllers/AdvFavicons.php:38-44 — sys_get_temp_dir() for the cropped blob. Same rationale: FaviconImageGenerator's constructor expects a real local path for its GD pipeline, so the uploaded cropped image needs to land on disk briefly. Was '../storage/tmp/' . uniqid('cropped_', true) . '.png' via move_uploaded_file; now tempnam(sys_get_temp_dir(), 'cropped_'). The existing finally { unlink($filePath); } cleanup remains and now reclaims the OS temp file. The .png extension dropped — GD's imagecreatefromXXX selects format by function name, not extension.
    • Constant flip. application/config/constants.php:201 TEMPORARY_STORAGE_PATH = '../storage/tmp/''tmp/'. Same doc comment as PR2/PR3; additionally notes that short-lived GD/PHPMailer scratch should use sys_get_temp_dir() rather than this constant (a foot-gun the favicon code fell into).
    • No AdvUpgradeStorage change. TEMPORARY_STORAGE_PATH was never referenced in the legacy one-time migration job, so no hardcode is needed there. The other three constants (FORMS, INVOICE, IMPORT) will need it in their own PRs.
    • Regression bar: full unit suite still green — 2670 tests / 7859 assertions. Zero upstream callers of TEMPORARY_STORAGE_PATH remain after this PR (constant retained for client-repo compatibility).
  • [4.102.0] refactor(storage): migrate DHL_VOUCHER_SAVE_PATH callers onto the private storage type (Advisable-com/ecommercen#235)

    • Scope. Third PR in the #235 migration series. Three runtime call sites in the DHL voucher pipeline, plus the one-time migration job's destination path. The migration also closes a latent bug.
    • ecommercen/libraries/vouchers/AdvSetPendingWithVoucher.php:saveVoucher. Was fopen(FCPATH . DHL_VOUCHER_SAVE_PATH . $voucherFilename, 'wb') + fwrite($handler, base64_decode($base64EncodedVoucher)) + fclose. Collapsed to a single storage('private')->put(DHL_VOUCHER_SAVE_PATH . $voucherFilename, base64_decode($base64EncodedVoucher)) — the file is fully buffered before write anyway (single fwrite), so there's no streaming benefit to preserve, and the new form works transparently on local + S3.
    • ecommercen/libraries/vouchers/AdvCancelVoucher.php:295. Was @unlink(FCPATH . DHL_VOUCHER_SAVE_PATH . $dhlVoucher->filename). Replaced with storage('private')->delete(...) wrapped in try/catch + log_message('error', ...). Matches the delete-safe pattern documented in SY-28: failed deletion logs but does not abort the outer DB cleanup, same as the original @-suppressed semantics, but now visible in monolog instead of silently swallowed.
    • ecommercen/libraries/vouchers/AdvPrintVoucher.php:243 — latent bug closed. Was file_get_contents(DHL_VOUCHER_SAVE_PATH . $voucherPdf->filename) (note: no FCPATH . prefix). Worked only because the HTTP entry point's CWD is public/, so ../storage/vouchers/dhl/... resolved correctly by coincidence. CLI consumers of the same library would have read from the wrong directory. The new storage('private')->get(...) is CWD-independent and routes through the same disk as the writer.
    • ecommercen/storageJobs/AdvUpgradeStorage.php:10 — destination hardcoded. The one-time InternalMoveFolder migrator does raw filesystem ops (scandir/rename/mkdir) relative to CWD, not through Storage. After the constant flip, rtrim(DHL_VOUCHER_SAVE_PATH, '/') produces 'vouchers/dhl' — interpreted as a CWD-relative path that would land at public/vouchers/dhl/. Replaced with the literal '../storage/vouchers/dhl' so existing deployments that re-run the upgrade (or run it for the first time on an older version) still place files in the legacy location, which is also where the new private local-disk root sits. Documented with an inline comment.
    • Constant flip. application/config/constants.php:200 DHL_VOUCHER_SAVE_PATH = '../storage/vouchers/dhl/''vouchers/dhl/', with the same documentation comment introduced in PR2 flagging the changed semantics (private-storage-relative, not FCPATH-relative).
    • No tests added. Voucher libraries are not currently unit-tested in this codebase; integration coverage flows through the admin order-cancellation and voucher-pickup flows. Regression bar: full unit suite still green — 2670 tests / 7859 assertions.
  • [4.102.0] refactor(storage): migrate GENERATED_VIEWS_PATH callers onto the private storage type (Advisable-com/ecommercen#235)

    • Scope. Second PR in the #235 migration series. Targets the lowest-blast-radius constant: GENERATED_VIEWS_PATH has only two callers, both one-time data patchers tracked by phinxlog (patches/CreateFaviconHtml.php and patches/MigrateFaviconViewToStorage.php). Existing deployments have already run them; the rewrite ensures they remain correct if re-run on a deployment that flips PRIVATE_STORAGE_DISK=s3.
    • patches/MigrateFaviconViewToStorage.php. Reads the legacy favicons view (was FCPATH . '../storage/views/favicons.php' via file_get_contents) through storage('private')->get('views/favicons.php') with an exists() precheck. The destination side (files/favicons/view.html via storage()) is unchanged. Idempotency contract (skip when source missing/empty or destination already populated) preserved byte-for-byte.
    • patches/CreateFaviconHtml.php. Replaced file_put_contents(..., '', FILE_APPEND) with an exists()-gated storage('private')->put($path, ''). The original FILE_APPEND idiom was a touch-create — leave the file alone if it exists, create it empty otherwise. put() overwrites unconditionally, so the gate is mandatory to keep client-populated content intact across re-runs.
    • Constant flip. application/config/constants.php:205 GENERATED_VIEWS_PATH = '../storage/views/''views/'. Now expresses a path relative to the private storage root (FCPATH . '../storage' on local, S3 prefix private/ otherwise) rather than relative to public/. Client repos that reference the constant in their own code must rewrite to route through storage('private') in the same PR — the .. is gone so raw FCPATH . GENERATED_VIEWS_PATH is no longer resolvable from CWD.
    • No callers needed updating outside patches/. Grep across src/, ecommercen/, application/ shows zero runtime references — FaviconImageGenerator and Favicons_controller write to files/favicons/... via storage(), not to GENERATED_VIEWS_PATH. The .gitignore, .dockerignore, and Dockerfile entries that mention storage/views/ describe the on-disk directory layout (still valid: private's local root is that same storage/ folder), and stay as-is.
    • No new tests. Patchers ship without unit tests (one-time mechanical operations); the regression bar is "full unit suite still green."
  • [4.102.0] feat(storage): private storage type + temp-file helper foundation (Advisable-com/ecommercen#235)

    • Foundation slice for the #235 migration series — eliminates raw FCPATH . CONSTANT_PATH . $file patterns across the codebase in favour of a Flysystem-backed storage abstraction so the platform runs transparently on local disk or S3. The seven follow-up PRs (one per migrated constant + the AdvMigrateStorageToS3 operator job) all build on this slice.
    • New private storage type in application/config/storage.php for non-public assets (invoices, vouchers, imports, generated views, tmp files). Local disk in dev, S3-compatible in prod. The private S3 bucket is physically separate from the public files bucket — never world-readable; credentials, region, endpoint, and disk selector are all independent. Only FILES_S3_PREFIX is reused as the per-tenant identifier so a single client slug tracks the same tenant across both buckets.
    • Advisable\Storage\LocalTempFile helper for local-path-only APIs (PHPMailer addAttachment, GD imagepng/imagejpeg, PhpSpreadsheet Reader::load, ezdompdf). withFile(...) / withFiles(...) materialise stored objects as temp files in sys_get_temp_dir() for the duration of a callback, unlinking in finally even on throw or mid-loop download failure.
    • Upload-pipeline plumbing. UploadFieldConfig, UploadService, and Advuploader::uploadFile() accept a $storageType parameter so HMVC controllers can route uploads to private (or any other registered disk) without bypassing UploadValidator and the Flysystem abstraction.
    • Tests. 8 unit tests for LocalTempFile (happy path, return-value propagation, throw-during-callback, mid-loop download failure cleanup, empty input, associative-key preservation). Subsequent migration PRs all keep the unit suite green as their regression bar.

Notes

  • [4.102.0] Downstream Laravel API coordination — invoice PDFs (#235 storage refactor):
    • Invoice PDFs no longer live at a fixed filesystem path. INVOICE_PDF_BASE_PATH flipped from '../storage/invoices/' (absolute, anchored to the eshop's FCPATH) to 'invoices/' (relative key inside the private Flysystem disk). On a client where PRIVATE_STORAGE_DISK=s3, the PDF is not on the eshop's filesystem at all — it lives in the S3 bucket configured by PRIVATE_STORAGE_* env vars.
    • Heads-up for the Laravel invoice-sending API: if the Laravel app reads invoice PDFs from a shared mount/cross-container filesystem path on the eshop host, that path is now wrong (or empty on S3-flipped clients). Update the Laravel side to either (a) request the PDF over HTTP from the eshop instead of reading the filesystem directly, OR (b) read from the same S3 bucket/key by mirroring the eshop's PRIVATE_STORAGE_* env vars (key shape: invoices/{filename}). The eshop-internal callers (Adv_orders_admin::download_invoice, Dhl::sendShipment, mailer attachments) all already route through storage('private')->get('invoices/' . $filename) and work transparently across local/s3/sftp disks.
    • Same heads-up applies to any other downstream system that reads files from the eshop's storage/* tree — every path that was previously FCPATH . '../storage/X/' has been flipped to 'X/' inside the private disk. Migrated subtrees: forms/, import/, invoices/, tmp/, views/, vouchers/dhl/. Per-client cutover: run AdvMigrateStorageToS3 first to populate the bucket, then flip PRIVATE_STORAGE_DISK=s3.