Release 3.7 Overview
UAT/staging auto-deploy, Mercure removal, optimistic lock iteration, invoice UX fixes, address canonicalisation
Release 3.7 tagged on 21 April 2026 as v3.7.0, with patches v3.7.1, v3.7.2, and v3.7.3 landing the same day. The cycle delivered a full UAT + staging auto-deploy pipeline, retired the unused Mercure real-time infrastructure (-1,518 lines), rolled back an over-aggressive optimistic lock that was producing 7-9 false-positive errors out of 10 saves, cleaned up invoice UX (layout, redirect, PO field), and pushed address format canonicalisation across every layer (dossier-api, Project entity, address autocomplete JS) after a long-standing Trello request.
Items Included
Deployment Pipeline
Preprod and production remained manual-only by deliberate design (fts1 is IP-allowlisted, so GitHub-hosted runners cannot SSH in), but UAT and staging moved to push-triggered auto-deploys. Engineers can now push to a feature branch, have it deployed to UAT for client sign-off, and track release health through the dev dashboard tabs that accurately describe the 5-environment / 3-host reality for the first time.
- UAT + staging auto-deploy workflows -
.github/workflows/deploy-uat.ymlanddeploy-staging.ymlrunrelease:*on push. Authenticated viaDEPLOY_SSH_KEY+DEPLOY_KNOWN_HOSTSsecrets. - Preprod stays manual - An attempt at auto-deploying preprod via self-hosted runner on fts1 was tried and reverted. fts1's IP allowlist makes manual the right default for preprod + production.
- Env-named symlinks - Release script swaps
~/<env>instead of hard-coded~/liveso each host's symlink name matches its env identity. - post-release.sh restarts - Extended with UAT and staging branches driven by
DEPLOY_ENV(falls back toLIVE_LINK_NAME). - Dev dashboard rewrite - Release Preparation and Deployment tabs in
scripts/dev-dashboard-server.tsrewritten to match current reality (5 envs, 3 hosts, PHP-FPM 8.3,~/livesymlinks, per-env npm scripts). - release: script rename* - Dropped the
:safesuffix now that there's only one release script per env. Deleted the deadscripts/release-prod-symlink.ts.
Data Integrity (Part II)
The optimistic-lock work shipped in v3.6 turned out to be too aggressive for the admin flow. This release keeps the entity-level Doctrine @Version protection but removes the explicit $em->lock() check that was firing on 70-90% of admin saves because background workers and hourly crons were bumping the version between form render and submit. The ordering portal API retains its lock since clients opt in by submitting a version field.
- Optimistic lock extended to ordering portal API (v3.7.0) -
ProjectResourceDTO gains aversionfield;ProjectProcessor::updateProject()calls$em->lock()when the client echoes a version. Returns HTTP 409 on conflict. - Explicit form-side lock removed (v3.7.1) - The aggressive
$em->lock()inProjectCrudController::updateEntity()removed after users reported 7-9 false positives per 10 saves. Entity-level@Versionstill catches true concurrent-save races at the DB layer.
Mercure + ux-notify Removal
Mercure was installed in March 2023 and wired into NotificationService in January 2024 ("add real-time notification"), but no Mercure hub was ever deployed on any environment - every call to $hub->publish() has been silently failing for 16 months. The JS EventSource subscription had also been commented out. This release removes the entire stack cleanly: no user-visible change, one less phantom dependency, 1,518 fewer lines of code.
- Removed
symfony/mercure-bundle+symfony/ux-notify- Transitively removessymfony/mercure,symfony/mercure-notifier,lcobucci/jwt. - Removed
MercureBundle+NotifyBundle- From bothapp/config/bundles.phpandordering/config/bundles.php. - Deleted
mercure.yaml- Both packages. - Refactored
NotificationService-HubInterfacedependency andpublishNotification()/runNotifications()/runOneNotification()/getNotificationPublicUrl()methods removed. The DB-backed notification dropdown still works; only the dead realtime layer is gone. - Removed
runRealTimeNotificationendpoint - Its JS caller was commented out. - Cleaned commented-out EventSource handler - ~60 lines of dead JS in
custom-ea.jsremoved.
Invoice UX
Three targeted fixes to the admin invoice workflow plus a decision about where the PO number belongs in the data model.
- Invoice index page header layout - Template was overriding
content_header_wrapperwholesale and fighting EasyAdmin's default flex layout; heading rendered past the right edge of the action buttons. Switched to overriding just thepage_actionsblock. - Invoice save redirect - After saving an invoice, users were redirected back to the new-invoice form URL instead of the project edit page. Query-string
projectIdwas being dropped on POST; now pulled from the saved entity'sgetProject()relationship. - PO Number field moved off Project - The
purchaseOrderfield exposed on the admin project new/edit forms in v3.6 was removed. PO number lives on the invoice (ProjectInvoice::$purchaseOrderNumber) and is visible on the invoice view modal.
Address Canonicalisation (Trello zH3TlNMp)
A long-standing Trello card (first raised June 2024, re-raised December 2025) asked for a consistent canonical address format across the platform, for example LOT 555 (5) KIELDER CRESCENT CLYDE 3978 rather than the raw LOT 555 5 KIELDER CRESCENT CLYDE 3978. This release applies it at every layer: the dossier-api response, the address autocomplete's input field on selection, the Project entity setter/getter, and the enrichment service.
- dossier-api canonicaliser -
canonicaliseLotAddress()helper in bothapi.tsandparcel-summary.ts. Applied at thelookup_pendingfallback and thestreet_addressfallback paths where raw addresses were being echoed aslot_address. - Address autocomplete selection -
buildCanonicalLotAddress(address, lotNumber)method in both admin and orderingaddress_autocomplete_controller.js. Handles three cases: already canonical, LOT prefix without parens, or no LOT prefix at all. - Project entity canonicaliser -
Project::canonicaliseLotAddress()static helper applied in bothsetKnownAddress()(on write) andgetKnownAddress()(on read) in bothapp/andordering/entities. Legacy DB rows render canonically without a migration. - Enrichment service -
ProjectInsightEnrichmentServicenow prefers dossier-api'slot_addressover the rawezi_addresswhen populatingknownAddress.
Small UX & Feature Work
- Estate-specific title prediction -
Estate::$titlePredictionDayscolumn with per-estate override (falls back toTITLE_PREDICTION_DAYSenv var, then 28-day default). - Order submitted date toggle - Env-gated toggle to hide the order submitted date column by default.
- QA checklist builder disabled - Temporarily rolled back pending further work.
- Google API credentials rotation - Production DA Google API token rotated.
- Messenger worker runs without Symfony debug - Prevented debug-mode state leaking into async workers.
- Purchase Order Number on invoice view modal - Added to the read-only invoice view (between Customer Job Number and Line Items), mirrored across admin and ordering.
How To Read This Section
Each child page covers one deliverable area. The optimistic-locking page cross-references release 3.6's entry; the address-canonicalisation page references the Trello card that drove the work. Items marked with v3.7.x version tags indicate which patch release they shipped in.