Appearance
<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>
Version 4
version 4.102
[4.102.0] fix(language): guard
t()declaration inMY_Langto fix combined-test-suite flake- Symptom.
composer test(running Unit + Integration + Database + Legacy suites in one PHP process) fataled at ~46% withCannot 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.phpdeclared the globalt()translation helper unconditionally at file scope.tests/Unit/Upload/AdvUploadValidatorTest.php:46eval-defines a stubt()for its test harness (correctlyfunction_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 definest()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 expectedRequiresPhpExtensionself-skips for Redis/APCu/SHM.
- Symptom.
[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 legacyAdvLanguageStorageModel::readFromFile/writeToFilepair didfile_get_contents+ decode + array merge +file_put_contentsper 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 newlanguage_overridesMySQL 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 whenapplyChangessucceeds. A second per-request memo cachestable_exists('language_overrides')so the SaaS wizard (or any caller that fires beforemigrator.php migrateon a fresh install) returns[]cleanly on reads and logs-and-no-ops on writes — without this guard, CI3'sdb_debug=trueHTML+exit(8)would kill the bootstrap on a missing-table query.loadProxy(__FILE__)static helper is called by the 8application/language/{idiom}/_user_theme_lang.phpproxy 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
CachedStoredecorator over aDatabaseAdapter. That was removed before merge: thelanguage_overridestable 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_overridesschema via Phinx migration20260526120000_create_language_overrides.php.utf8mb4table charset +ROW_FORMAT=DYNAMICfor the 3072-byte InnoDB index limit.lang_keydeclared withCOLLATE utf8mb4_binso the(bundle, lang, lang_key)unique constraint is byte-exact — without this override, the table-defaultutf8mb4_unicode_ciwould treatProfile.Namevsprofile.name(orcafe.xvscafé.x) as the same key and silently collapse them underINSERT IGNORE/ON DUPLICATE KEY UPDATE.VARCHAR(255)forlang_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). See20260210120630_convert_db_mb_3_to_mb_4.phpfor the broader utf8mb4-readiness analysis.lang_value MEDIUMTEXTaccommodates long marketing copy.updated_at DATETIME ... ON UPDATE CURRENT_TIMESTAMPis a free audit column.- Idempotent one-shot JSON-to-DB patcher.
patches/MigrateUserLangsJsonToDb.phpdecodes any legacy_user_langs.jsonand bulk-inserts in batches of 200 viaINSERT IGNORE INTO language_overrides ... VALUES ...keyed by the unique constraint. Triggered by Phinx migration20260526120001_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 withRuntimeExceptionon 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.importedon success so a subsequentrollback+migratecycle 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/unsetMessagescollapse into one delegated call. Both methods now resolve the store viadi()->get(LanguageOverrideStore::class)and dispatch a single$store->applyChanges($bundle, $languageUri, $setKV, $unsetKeys).setMessagescomputes the diff vs the base dataset usingarray_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$setKVand revert-to-base keys as$unsetKeysin the same atomic call. DB writes wrap the set+unset intrans_start/trans_complete. The legacyreadFromFile/writeToFileprivate methods are gone.- 8
application/language/{idiom}/_user_theme_lang.phpproxy 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 didfile_get_contents(FCPATH . USER_LANGS_JSON_PATH . '_user_langs.json')+ JSON decode is gone. - DI wiring in
application/config/container/language.php. AutowiresLanguageOverrideStore(constructor takesCI_DB_query_builder— resolved via the DI-registered factory landed in #230) and exposes it as a public service. Module registered inapplication/config/container/modules.phpbetweenproject_agora.phpand the domain modules. - Integration tests.
tests/Integration/Language/Storage/LanguageOverrideStoreTest.php(16 tests against the real test DB):getOverridesempty / 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 onProfile.Namevsprofile.name/ per-request memoisation (memo returns the previously-fetched value after direct DML on the row) / memo invalidation byapplyChanges/ 250-row batch upsert /loadProxyhappy-path and bad-filename fallback. - Flow doc
docs/flows/admin/AD-26-language-management.mdrewritten — 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:135updated — notes thatstorage/user_langs/is now only the legacy-import entry point and runtime persistence is thelanguage_overridestable.
- Root cause. Admin-edited translation overrides lived in
[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/listbefore catching up. Long enough that admins thought the save had silently failed and pressed reload. - Root cause.
assets/admin/js/languages/components/LanguagesListing.vuecleared the local edit buffer (editingMessages.delete(m.key)) on success and then calledfetchLanguageData()— auseLazyFetchwrapper with a 200ms default debounce + a full list re-fetch. In the gap, therowscomputed fell back tolanguageData.value.data.messages.user[m.key](the pre-save value) for the row'smessage. - 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
.vuecomponent. The compiled bundle atpublic/ui/admin/dist/languages.jspicks this up on the nextnpm run admin-dev/admin-production.
- 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
[4.102.0] fix(config): silence cron-mail flood on staging (
ENVIRONMENT=testing)- Symptom. Bare-metal Plesk staging servers running
ENVIRONMENT=testinghad every* * * * * php cli.php job_managercron invocation flooded with PHP notices/warnings/deprecations from CI3 + vendor code, plus occasional HTML-error dumps from CI3'sshow_error()on transient DB hiccups (e.g.wait_timeoutreconnects during long jobs). ForcingENVIRONMENT=productionsilenced the flood but coupled in production-only side effects unwanted on staging —noindex,nofollowmeta dropped (application/views/main.php:14,default.php:18); Google Tag Manager / Analytics / Matomo / push-notification scripts emitted (gated on=== 'production'acrossapplication/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 andAPP_LOG_THRESHOLD. Route A:public/index.phpseterror_reporting(E_ALL)for testing — PHP-raised notices/warnings hit_error_handler(application/helpers/log_helper.php:157) →_logger_dispatch_or_fallback→error_log()fallback during very-early bootstrap (beforeContainer::markBootReady()fires from thepre_controllerhook) → stderr → cron mail. Route B:application/config/database.php:15,38hardcodeddb_debug = truefor non-production — CI3's MySQL driver calledshow_error()on any failed query, dumping HTML to stdout andexit()-ing the subprocess.APP_LOG_THRESHOLD=NOTICEdoes not gate either route — Monolog never sees these records. error_reportingdecoupling.public/index.php:71-73testing branch flipped fromerror_reporting(E_ALL)toerror_reporting(0), matching the production branch. PHPUnit bootstraps viatests/bootstrap.php(perphpunit.xml.dist:14) which never toucheserror_reporting(), so the Unit/Integration/Database/Legacy test suites are unaffected.db_debugwired up to the existing env vars.application/config/database.php:15,38db_debugnow reads fromAPP_DB_DEFAULT_DEBUGandAPP_INTERMEDIATE_DB_DEFAULT_DEBUG— both declared in.env.example:28,35since the initial repo layout but never previously wired into any PHP reader. String defaults ('true'/'false') coerce cleanly throughenv()'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
.envchange required..env.examplealready shipsAPP_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. WithENVIRONMENT=testing+ the existing=falselines, cron mail goes quiet. - Dev operators — opt-back-in for
show_error()HTML. Devs whose local.envwas copied from.env.examplewill haveAPP_DB_DEFAULT_DEBUG=falseset explicitly and will lose CI3'sshow_error()HTML on dev after this commit. To restore the dev-time DB-debug HTML, setAPP_DB_DEFAULT_DEBUG=truein your local.env. - Out of scope.
application/config/database.php:25,48save_queriesfollows 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.phpoverridesdb_debugtofalsewhenTEST_DB_HOSTNAMEis 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.
- Symptom. Bare-metal Plesk staging servers running
[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
securityTokenverification, 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 andCI_DB_query_builderare injectable for testability; production callers stay onnew XPay($config)and get the autowired defaults. Status mapping covers Nexi's fulloperationResultenum (AUTHORIZED/EXECUTED→PAID,DECLINED/DENIED_BY_RISK/THREEDS_FAILED/VOIDED/FAILED/CANCELED→CANCELED,THREEDS_VALIDATED/PENDING→PENDING,REFUNDED→REFUNDED). - Webhook authenticity. Per the Nexi XPay Greece developer portal, the only documented webhook-authenticity mechanism is the
securityTokenreturned 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 inxpay_loggingand compares withhash_equals. Fail-closed: rejects when either side is missing or empty. Wired into both webhook handlers before any state mutation; rejected callbacks returnHTTP 401but still leave a forensicCALLBACKrow inxpay_logging. - Cross-flow defense.
xpay_loggingcarries aflow VARCHAR(20) NOT NULLdiscriminator ('order'|'gift_card'). Every read/write filters on it so a gift-card webhook cannot resolve a regular order's stored token even whenGIFT_CARDS.ORDER_PREFIXis empty and order serials happen to collide. Composite indexidx_token_lookup (order_serial, flow, transaction_type, status, created_at)covers the lookup query path. The discriminator threads throughcreateHostedPaymentOrder,logToDatabase,verifyNotificationSecurityToken, andgetStoredSecurityToken— 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 theprocess_orderswitch and theget_responseaction router. Webhook state machine: Pending → PAID/CANCELED, PAID → CANCELED (reversal), CANCELED → PAID (resurrection); REFUNDED ignored with an info log. Cron reconciliation inCronjob::handlePendingXpayOrders()and the equivalentAdvCancelIncompleteOrdersJobCommand path — both callgetOrderStatus()to accept-if-PAID / cancel-otherwise for orders stuck in PENDING after the timeout window. - Gift card flow.
AdvGiftCardPage::xpayFormData()/xPaySuccess()/xPayCancel()/xPayHook()mirror theiris/paypaladvancedprecedent, with a separate webhook route (gift-card/xPayHookvscheckout/xPayHook) so the lookup-table concerns don't mix — gift cards land ingift_card_orders, regular orders inorders. State machine extracted to a pure static helperxpayWebhookAction(GiftCardStatus, string): stringso the decision logic is unit-testable in isolation. Only(Pending, PAID)ever yields'accept'— explicit guard against duplicate-coupon issuance from retried webhooks, sinceacceptGiftCard()is not idempotent (always creates a new coupon row). Reconciliation cron viaAdvCancelPendingGiftCards::cancelPendingXpayOrders(). - Admin / config / i18n. New
XPAYregistry group withAPI_KEY(required) andIS_PRODUCTIONtoggle, surfaced inAdvPaymentsRegistry::providers()and the admin payment-settings view (labels viat('payway.xpay.*')).'xpay'added togetGiftCardPayWays()so the admin selector picks it up;'xpay' => []entry in the gift-card VuepayWayInstallmentsmap (XPay does not carry installments, matchesiris/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.phpuses 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 likePROCESSED,STATUS_CHECK,REQUEST_FAILED),request_data,response_data,status,created_at. Four indexes:idx_order_serial,idx_transaction_id,idx_created_at, plus the compositeidx_token_lookupcovering 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,mapOperationResultToStatusdata-provider across all enum values,parsePaymentNotificationhappy and exceptional paths,formatAmount), HTTP-mockedcreateHostedPaymentOrderandgetOrderStatusviaGuzzleHttp\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 fullverifyNotificationSecurityTokenmatrix (matching/mismatching tokens, missing fields, empty strings, DB exceptions, query-shape regression including theflowfilter, regression guard that gift-card flow does not leak aflow='order'filter).tests/Legacy/GiftCards/AdvGiftCardPageTest.php— 12 tests onxpayWebhookAction(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 thePending + PAIDpath).
- 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
[4.102.0] chore(payment/xpay): remove dead
CORRELATION_ID_PREFIXconfig (closes Advisable-com/ecommercen#249)- Declared as
protected ?string $correlationIdPrefixonXPayand 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 orgetXPaySettings(). 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.mdKnown Issues list renumbered (2-9 → 1-8) so the list stays contiguous after the gap.
- Declared as
[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:217used the pre-refactorslide_imageproperty name in its guard expression after the structured-record refactor (ef5d6771d) renamed the MUI image field toimage(matchingslide_mui.imageand whatslide_model::getMasterRecord()returns). The condition was always falsy, so the<p>…show_image…delete_image…</p>block was skipped for slides with an existing image. One-character path change to$record?->$languageAbbr?->image.create.phpis 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<span>s to underlying<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 andprependTemplateinjected 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 sameslide_mui.title(e.g., all slides for one ship labeled "TERA JET"); any other admin form using.js-image-selectwith non-unique option text hit the same bug. Rewritten to use Chosen's owndata-option-array-index→results_data[idx].options_indexmapping (the same mechanism the plugin's ownchosen:showing_dropdownhandler uses for dropdown list items), scoped to.chosen-choices .search-choiceto guard against unrelated<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 touchpublic/ui/admin/dist/vendors.js. The fix won't reach runtime until the next release-cutnpm run admin-production. - Provenance. Both bugs originally reported and patched locally in the seajets client repo; ported upstream with the same diffs.
- #250 — "Show image" preview never rendered on the slide edit form.
[4.102.0] feat(storage): add
AdvMigrateStorageToS3job 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 (storagesoption), iterates each type'slocaldisk recursively, and streams every object up to the correspondings3disk viaFlysystem readStream → writeStream. Intended as a scriptable migration step before flippingPRIVATE_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:privatemigrates the whole storage type root,private/invoicesonly theinvoices/subtree,private/vouchers/dhlonly the multi-segmentvouchers/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, default100— 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 indeleteSourceif 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 reportsseen / copied / skipped / failed / deletedcounts. One bad file does not abort the type; one bad type does not abort the list. deleteSourcesafety. When the flag is set, the source is only removed after a post-write$s3->exists($path)re-verification — defends against aputStreamthat 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 arefclose-d infinallyto release file handles. - CLI invocation.
php cli.php job/MigrateStorageToS3 --storages=private --dryRun=truefor a safe first pass against the wholeprivatetype, then drop--dryRun=truefor the live run. Restrict to a subtree:--storages=private/invoices. Combine targets:--storages=private/invoices,private/vouchers/dhl,files/products. The bool options usefilter_var(..., FILTER_VALIDATE_BOOL)so--dryRun=true/1/yesall 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, sostoragescan 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 syncor 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/subpathsyntax 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 asLocalTempFileTest) — no DB, no CI bootstrap dependency.
- Scope. New JobCommand at
[4.102.0] refactor(storage): migrate
IMPORT_FILES_PATHcallers onto theprivatestorage 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
InternalMoveFolderhardcode. 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/Dockerfileis 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 downloadedFCPATH . IMPORT_FILES_PATH . $fileNameinto a local$xlsxPath, opened it with PhpSpreadsheet, processed inside a DB transaction, andunlink-ed on successful processing. Refactored to wrap the DB transaction body inLocalTempFile::withFile(storage('private'), $remotePath, fn ($localPath) => …)— the materialised local path goes to PhpSpreadsheet, andLocalTempFilecleans up the temp file infinally. The callback returns aboolindicating "consumed"; the outer code callsstorage('private')->delete($remotePath)only when the originalunlink-on-successpredicate held.ecommercen/job/libraries/AdvUploadImagesFromZipFile.php. Same shape as the xlsx jobs — callback returns aboolindicating "consumed" and the outer code deletes the source ZIP only when extraction + processing succeeded. This is a deliberate semantic change from the pre-PR originalunlink($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 forZipArchive::extractToand the iterator-based file copy. The old code usedFCPATH . IMPORT_FILES_PATH . 'temp/'for this; replaced with a per-call uniquesys_get_temp_dir() . '/zipimport_<uniqid>'so concurrent jobs don't collide. InternalremoveDirectory($tempPath)cleanup is wrapped in a nested try/finally so the zip handle always closes even if extraction throws, and guarded byis_dir()to defend againstextractZipFilethrowing beforemkdirran.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') + unlinkwithUploadValidator + readCSV($_FILES['csv_file']['tmp_name']). PHP cleans uptmp_nameat request end. Removed the "succeed-but-no-data" branch which was a CI3-specific concern that doesn't apply toUploadValidator'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:UploadValidatorvalidation →PhpSpreadsheetload 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_EXTENSIONfrom the original upload), preserving the previous'productdata' . date('YmdHis') . uniqid()shape with the correct.xls/.xlsxsuffix thatAdv_Upload'sdata['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()withUploadValidator + storage('private')->putStream(IMPORT_FILES_PATH . $fileName, fopen($tmp, 'rb')). The job arguments still carry the samefileNameshape.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 generict('eshop.admin.products.import.failed')message — the only remaining caller is the PhpSpreadsheet parse-failure branch inhandleFileUpload. Validator-sourced errors are now rendered inline at thevalidate()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:204IMPORT_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 . $filepattern 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 thestorage('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/<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 inLocalTempFile::withFile(...). The grep is intentionally narrow — only flags the stale-pattern call sites; existingstorage()consumers are unaffected. - Regression bar: full unit suite still green — 2670 tests / 7859 assertions. The Advuploader
$storageTypeplumbing,UploadValidatorvalidation, andLocalTempFilematerialisation 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.
- 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
[4.102.0] refactor(storage): migrate
INVOICE_PDF_BASE_PATHcallers onto theprivatestorage 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 privatesendWithStoredAttachmentshelper to keep the twosend()-with-invoice callers identical in shape. src/Transporters/Dhl/Dhl.php:83. Wasfile_get_contents(FCPATH . INVOICE_PDF_BASE_PATH . $shipmentData['invoice_pdf'])with a!$invoicePdfToStringguard. Replaced withstorage('private')->get(...)wrapped in try/catch (Flysystemread()throwsUnableToReadFileon missing/unreadable, whereasfile_get_contentsreturnedfalse). Existing logger.error + early return contract preserved.src/Skroutz/SkroutzOrders.php:63. Same shape —storage('private')->get(...)replaces the absolutefile_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. Twosend(...)callers inorder_has_been_updated()andorder_is_on_store()were passinggetOrderUpdatedAttachments(...)as the seventh positional arg. New flow:getOrderUpdatedAttachmentsreturns storage-relative paths (dropsFCPATH .), and the two callers switched to a new privatesendWithStoredAttachmentshelper that wrapssendinLocalTempFile::withFiles(storage('private'), $remote, fn ($local) => $this->send(..., $local)). PHPMailer still gets real local paths viaMailer::addAttachments; temp files are unlinked infinallyincluding the failure path.sendContactFormEmailfrom PR5 continues to wrapdoSenddirectly (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_uploadblock with$this->load->library('advuploader')+$this->advuploader->uploadFile('invoice_pdf', INVOICE_PDF_BASE_PATH, 'pdf', max_upload_size, 'private'). Uses the$storageTypeplumbing added toAdvuploader::uploadFilein 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. Wasforce_download(... @file_get_contents(FCPATH . INVOICE_PDF_BASE_PATH . $order->invoice_pdf), true). Replaced with try/catch aroundstorage('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/invoicesdestination 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 usesrtrim(CONST, '/').- Constant flip.
application/config/constants.php:199INVOICE_PDF_BASE_PATH = '../storage/invoices/'→'invoices/'with the standard doc comment. - Regression bar: full unit suite still green — 2670 tests / 7859 assertions. The Advuploader
$storageTypeplumbing is the load-bearing piece from the foundation PR; the integration-style verification (real upload throughstorage('private')to a local-disk root) requires manual smoke at staging.
- Scope. Sixth PR in the #235 migration series. Six runtime call sites across two
[4.102.0] refactor(storage): migrate
FORMS_ATTACHMENTS_PATHcallers onto theprivatestorage type (Advisable-com/ecommercen#235)- Scope. Fifth PR in the #235 migration series. First use of the foundation PR's
LocalTempFilehelper for a real mailer attachment flow. application/models/Adv_mailer.php:sendContactFormEmail. Wasarray_map(fn ($item) => FCPATH . FORMS_ATTACHMENTS_PATH . $item, $attachments)— building a list of filesystem paths that the mailer'sdoSend→Mailer::addAttachments→ PHPMailer'saddAttachmentconsumed. PHPMailer requires real local filesystem paths; withprivateon S3, the stored object is not local. Wrapped thedoSendcall inLocalTempFile::withFiles(storage('private'), $remotePaths, fn ($localPaths) => $this->doSend(..., $localPaths))— each attachment is downloaded intosys_get_temp_dir()for the duration of the send and unlinked in thefinally, including the failure path. Empty$attachmentsis handled bywithFiles' 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 thatAdvuploader::uploadFilesUsingConfigconsumes. 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/formsdestination 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 usertrim(CONST, '/')until their own PRs flip them.- Constant flip.
application/config/constants.php:208FORMS_ATTACHMENTS_PATH = '../storage/forms/'→'forms/'with the standard documentation comment. - Regression bar: full unit suite still green — 2670 tests / 7859 assertions. The
LocalTempFile::withFilesuse is covered by foundation-PR unit tests (key preservation, cleanup-on-throw, cleanup-on-return, empty-input).
- Scope. Fifth PR in the #235 migration series. First use of the foundation PR's
[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_PATHconstant'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'sAdv_Uploadlibrary into../storage/tmp/, immediately reading it back withfile_get_contents, base64-encoding for DB persistence, and thenunlink-ing the file. The temp landing was accidental complexity —AdvUploadValidatordoesn't need anupload_path, and$_FILES['SIGNATUREIMAGE']['tmp_name']is already a sys-temp file that PHP cleans up at request end. New flow:UploadValidatorvalidation against the$_FILESslot, thenfile_get_contents($_FILES['SIGNATUREIMAGE']['tmp_name'])direct read, then base64. Three filesystem operations and aTEMPORARY_STORAGE_PATHreference eliminated, S3-portable by construction.src/Favicon/FaviconImageGenerator.php:164-168— sys_get_temp_dir() + cleanup fix. GD'simage{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 withtempnam(sys_get_temp_dir(), 'fav_')+ try/finallyunlink. 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'viamove_uploaded_file; nowtempnam(sys_get_temp_dir(), 'cropped_'). The existingfinally { unlink($filePath); }cleanup remains and now reclaims the OS temp file. The.pngextension dropped — GD'simagecreatefromXXXselects format by function name, not extension.- Constant flip.
application/config/constants.php:201TEMPORARY_STORAGE_PATH = '../storage/tmp/'→'tmp/'. Same doc comment as PR2/PR3; additionally notes that short-lived GD/PHPMailer scratch should usesys_get_temp_dir()rather than this constant (a foot-gun the favicon code fell into). - No
AdvUpgradeStoragechange.TEMPORARY_STORAGE_PATHwas 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_PATHremain after this PR (constant retained for client-repo compatibility).
- Scope. Fourth PR in the #235 migration series. Cleans up the
[4.102.0] refactor(storage): migrate
DHL_VOUCHER_SAVE_PATHcallers onto theprivatestorage 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. Wasfopen(FCPATH . DHL_VOUCHER_SAVE_PATH . $voucherFilename, 'wb')+fwrite($handler, base64_decode($base64EncodedVoucher))+fclose. Collapsed to a singlestorage('private')->put(DHL_VOUCHER_SAVE_PATH . $voucherFilename, base64_decode($base64EncodedVoucher))— the file is fully buffered before write anyway (singlefwrite), 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 withstorage('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. Wasfile_get_contents(DHL_VOUCHER_SAVE_PATH . $voucherPdf->filename)(note: noFCPATH .prefix). Worked only because the HTTP entry point's CWD ispublic/, so../storage/vouchers/dhl/...resolved correctly by coincidence. CLI consumers of the same library would have read from the wrong directory. The newstorage('private')->get(...)is CWD-independent and routes through the same disk as the writer.ecommercen/storageJobs/AdvUpgradeStorage.php:10— destination hardcoded. The one-timeInternalMoveFoldermigrator 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 atpublic/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 newprivatelocal-disk root sits. Documented with an inline comment.- Constant flip.
application/config/constants.php:200DHL_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_PATHcallers onto theprivatestorage type (Advisable-com/ecommercen#235)- Scope. Second PR in the #235 migration series. Targets the lowest-blast-radius constant:
GENERATED_VIEWS_PATHhas only two callers, both one-time data patchers tracked byphinxlog(patches/CreateFaviconHtml.phpandpatches/MigrateFaviconViewToStorage.php). Existing deployments have already run them; the rewrite ensures they remain correct if re-run on a deployment that flipsPRIVATE_STORAGE_DISK=s3. patches/MigrateFaviconViewToStorage.php. Reads the legacy favicons view (wasFCPATH . '../storage/views/favicons.php'viafile_get_contents) throughstorage('private')->get('views/favicons.php')with anexists()precheck. The destination side (files/favicons/view.htmlviastorage()) is unchanged. Idempotency contract (skip when source missing/empty or destination already populated) preserved byte-for-byte.patches/CreateFaviconHtml.php. Replacedfile_put_contents(..., '', FILE_APPEND)with anexists()-gatedstorage('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:205GENERATED_VIEWS_PATH = '../storage/views/'→'views/'. Now expresses a path relative to theprivatestorage root (FCPATH . '../storage'on local, S3 prefixprivate/otherwise) rather than relative topublic/. Client repos that reference the constant in their own code must rewrite to route throughstorage('private')in the same PR — the..is gone so rawFCPATH . GENERATED_VIEWS_PATHis no longer resolvable from CWD. - No callers needed updating outside
patches/. Grep acrosssrc/,ecommercen/,application/shows zero runtime references —FaviconImageGeneratorandFavicons_controllerwrite tofiles/favicons/...viastorage(), not toGENERATED_VIEWS_PATH. The.gitignore,.dockerignore, and Dockerfile entries that mentionstorage/views/describe the on-disk directory layout (still valid:private's local root is that samestorage/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."
- Scope. Second PR in the #235 migration series. Targets the lowest-blast-radius constant:
[4.102.0] feat(storage):
privatestorage type + temp-file helper foundation (Advisable-com/ecommercen#235)- Foundation slice for the #235 migration series — eliminates raw
FCPATH . CONSTANT_PATH . $filepatterns 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 + theAdvMigrateStorageToS3operator job) all build on this slice. - New
privatestorage type inapplication/config/storage.phpfor 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 publicfilesbucket — never world-readable; credentials, region, endpoint, and disk selector are all independent. OnlyFILES_S3_PREFIXis reused as the per-tenant identifier so a single client slug tracks the same tenant across both buckets. Advisable\Storage\LocalTempFilehelper for local-path-only APIs (PHPMaileraddAttachment, GDimagepng/imagejpeg, PhpSpreadsheetReader::load, ezdompdf).withFile(...)/withFiles(...)materialise stored objects as temp files insys_get_temp_dir()for the duration of a callback, unlinking infinallyeven on throw or mid-loop download failure.- Upload-pipeline plumbing.
UploadFieldConfig,UploadService, andAdvuploader::uploadFile()accept a$storageTypeparameter so HMVC controllers can route uploads toprivate(or any other registered disk) without bypassingUploadValidatorand 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.
- Foundation slice for the #235 migration series — eliminates raw
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_PATHflipped from'../storage/invoices/'(absolute, anchored to the eshop'sFCPATH) to'invoices/'(relative key inside theprivateFlysystem disk). On a client wherePRIVATE_STORAGE_DISK=s3, the PDF is not on the eshop's filesystem at all — it lives in the S3 bucket configured byPRIVATE_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 throughstorage('private')->get('invoices/' . $filename)and work transparently acrosslocal/s3/sftpdisks. - Same heads-up applies to any other downstream system that reads files from the eshop's
storage/*tree — every path that was previouslyFCPATH . '../storage/X/'has been flipped to'X/'inside theprivatedisk. Migrated subtrees:forms/,import/,invoices/,tmp/,views/,vouchers/dhl/. Per-client cutover: runAdvMigrateStorageToS3first to populate the bucket, then flipPRIVATE_STORAGE_DISK=s3.
- Invoice PDFs no longer live at a fixed filesystem path.