Skip to main content

Give feedback

Back to all releases
Changelog

Unreleased

Changes that have landed since the last release.

Added

  • The accessible frontend now publishes the account-deletion and child-safety-standards pages, so both footers offer the same pages. They were the last two links in the React footer with no accessible equivalent — both 404ed. Verified by comparing the two footers' destinations and requesting every one: nothing the React footer links is missing from the accessible site, and all 23 accessible footer links resolve. The accessible footer keeps four extras, each deliberate: Volunteering, Features, the cookie-settings link, and "report a problem" (WCAG 2.2 §3.2.6 Consistent Help, which React covers with its floating reporter instead).
    • 🔴 Both pages have ONE source, shared with the React pages, generated by web-uk/scripts/build-legal-text.js from react-frontend/public/locales/*/legal.json. This is compliance copy: it names the operating company, a charity number, a registered address, the Google Play package id, a designated child-safety contact and an effective date, and the child-safety page is what Google Play is pointed at. Two hand-maintained copies would eventually disagree, and the day they did, one frontend would be telling members something untrue about deleting their data or about how a child-safety report is handled. A test asserts those specific details are identical to React's.
    • They sit at /account-deletion and /child-safety — React's own paths, and not under /legal/, because account_deletion and child_safety are not in LegalController::VALID_TYPES: there is no per-community version to fetch, no version history, and nothing for the /legal/:type/versions routes to answer.
    • Built strictly to the Design System: numbered steps as ordered lists, the zero-tolerance statement in a warning-text component, the reporting and request routes as start buttons via govukButton, and what is deleted versus what may be retained as two separately headed lists rather than one mixed one.

Fixed

  • Every legal page on the accessible frontend showed the wrong name. communityName() in web-uk/src/routes/legal.js read res.locals.tenant, which the shell does not set — it sets tenantName, tenantSlug and communityName — so the legal hub, terms, privacy, cookies, community guidelines, acceptable use and the accessibility statement all captioned themselves "Project NEXUS Accessible" instead of the community's own name, on a site where eleven communities share the templates. It now prefers tenantName and falls back through the older reads, so nothing that does pass a tenant object regresses.

  • The accessible frontend has a changelog, rendered from CHANGELOG.md itself. /changelog lists every release with its date and /changelog/<version> shows that release's notes. It is a list-then-detail pair rather than one page because the file holds 18 releases and the largest single release is over 2,000 lines; the React page renders the whole ~7,900-line document in the browser, which is a poor trade for the members this frontend exists for. The footer's Changelog link, deliberately withheld while the page did not exist, is now live.

    • Markdown is rendered at BUILD time, not per request, by web-uk/scripts/build-changelog.js, and marked is a devDependency for that reason: the internet-facing app ships no markdown parser and parses nothing per request. The output is passed through the existing sanitizeCmsHtml allow-list even though the source is our own file, because marked passes raw HTML through by default — a test asserts no release contains a script tag, an iframe or an on* handler.
    • One generated file per release, plus a small index. The first version wrote a single 2.7 MB JSON; require then pulled the entire rendered history into memory at boot to serve a page needing one release, and every new release produced a 2.7 MB diff in a public repository. The index carries metadata only and is loaded at boot; a body is loaded on demand.
    • The release slug from the URL is matched against the index before it is used to build a path, so only a generated file can ever be read. Pinned by a test that feeds it ../index and ../../package.
  • The accessible frontend's /features page now shows the whole platform catalogue — 8 groups, 119 features — with working search and category filtering. It was a hand-written list of six bullets, which had drifted comprehensively from the React page's 119.

    • The catalogue is now shared. The group order, item order and each feature's maturity moved out of react-frontend/src/pages/public/FeaturesPage.tsx into react-frontend/src/data/featuresCatalogue.json, which both frontends read, so the two pages cannot drift again. The React page imports it directly; the accessible frontend keeps a generated copy (npm --prefix web-uk run build:features, verified by check:features) because its Docker build context is web-uk/, so neither the catalogue nor the React locale files are in its image.
    • Nothing was re-translated. All 119 features and their descriptions already existed in all eleven languages for the React page, so the generated copy carries that text rather than re-keying ~2,600 strings into lang/*.php and creating a second copy to keep in step.
    • 🔴 Search and category filtering run on the SERVER, as a plain GET form. The React page does the same job in JavaScript with a search box and filter chips; doing that here would leave the members this frontend exists for with no filtering at all. Search folds case and accents on both sides of the comparison, an unknown category in the query string is ignored rather than matching nothing, groups with no surviving items are dropped instead of rendering empty headings, and because it is a GET a filtered view is a shareable URL.
  • The accessible frontend's footer now carries the community's partner logo, the source repository and the powered-by badge, each under its own heading, in GOV.UK Design System style. The React footer has shown all three for a long time; the accessible frontend showed none of them, so a community's funder or council partner was invisible to exactly the members most likely to have been referred by one. The bottom of the footer is now a deliberate replica of the React footer's four bands — the community's copyright line with cookie settings opposite it, the release status line, the three brand marks, and the licence strip — which also removes the four stacked sentences it replaced, between them saying "Project NEXUS" three times. Details that are deliberate, each for a reason:

    • The powered-by badge sits in the second govuk-footer__meta-item, the slot GOV.UK's own footer uses for its origin mark, and deliberately does not reuse the link class GOV.UK applies there, because that class renders the royal crest through a ::before background image and this service is forbidden from displaying it. Pinned by web-uk/tests/footer-branding.test.js.
    • The badge uses the light artwork, not a choice between the light/dark pair the admin panel offers, because govuk-footer is light and this frontend has no dark mode — there is not one prefers-color-scheme rule in its stylesheet, the GOV.UK Design System being light-only. Both variants remain in use platform-wide: React picks between them by theme and this frontend's black header already uses each community's dark logo.
    • Every brand mark sits on an explicit white plate. Measured, not assumed: powered-by-nexus-light.png is PNG colour type 2 (RGB, no alpha channel) with a white background baked in, against a footer background of rgb(244, 248, 251) — so without a plate the badge rendered as a stray white rectangle. Community partner logos have the same problem more often than not, arriving as opaque JPEGs.
    • The source-repository link is not configurable per community and must not become so: AGPL-3.0 Section 7(b) requires the running application to link to its own source, and react-frontend/src/config/externalLinks.ts keeps the same URL in a single constant for that reason. The partner logo and the powered-by badge are per-community, reading the same general.* settings the React footer reads, so configuring them once covers both frontends.
    • The release line states the platform version and links Features and Documentation. It does not link Changelog, because the accessible frontend has no changelog page yet and a footer link to a 404 across eleven live community sites is worse than a missing link. It also makes no maturity claim about the accessible service itself, whose GOV.UK phase banner correctly still reads "Beta".
    • The copyright year is literal text in the translations, as the neighbouring attribution key has always carried it, and needs bumping once a year in eleven files. A :year placeholder was tried and reverted the same day; web-uk/src/lib/template-filters.js records the two ways it broke, the second of which failed 30+ test suites.
  • GET /api/v2/tenant/bootstrap now returns config.platform_version. The accessible frontend prints the platform version in its footer and had no source for it: its Docker build context is web-uk/, so the repository's VERSION file is not in its image. The value comes from config('app.version') and nowhere else, because scripts/check-version-consistency.mjs already pins that config value to the root VERSION file — so a release bump carries through and no downstream copy can drift. Note that data.config is now always present in the response, where previously it was omitted for a community with no footer settings configured; nothing read its absence as a signal, and both frontends already access it optionally. Regression tests: TenantBootstrapControllerTest::test_bootstrap_exposes_the_platform_version_for_frontends_without_the_version_file and ::test_bootstrap_platform_version_tracks_the_repository_version_file.

  • A nightly Sentry sweep now tells a human when errors need attention. Until now the only error monitoring ran for 30 minutes after a deploy, so anything that did not coincide with a deploy was invisible: a Redis name-resolution fault ran from 2026-09-06 to 2026-09-16 (1237 events) and ended only because a routine Docker upgrade restarted the daemon, and a daily safeguarding pager fired 17 times from 2026-08-30 naming three real members who could not be contacted. scripts/sentry-triage.mjs had been written as "the collection half of the nightly Sentry loop" and had never once run on a schedule. The new Sentry Nightly workflow runs it every morning at 09:00 UTC, just after the platform's own 08:05 pagers, and alerts over Telegram — the channel the owner already receives — only when the state changes, matching uptime-check.yml rather than nagging daily about a known issue. Because the repository is public, scripts/check-sentry-nightly.mjs prints counts only to the workflow log and writes the issue titles to a file that only the Telegram step reads. The run fails when the sweep or the alert cannot be delivered, never merely because issues exist. sentry-triage.mjs also accepts its credentials from the environment now, since .secrets.local/sentry.env does not exist in CI, and no longer runs its sweep when imported. Regression tests: scripts/test/sentry-nightly-gate.test.mjs (including that no title, culprit, short id or permalink can reach the public log) and scripts/test/sentry-triage-config.test.mjs.

  • The public sales order endpoint now accepts an enquiry with no quote attached. POST /api/v2/sales/orders required a complete quote object (plan name, capacity label, billing cycle, pricing mode and five price labels), so the sales site could only use it from the quote builder on its hidden /hosting page. A plain "tell us about your community" enquiry from a public page had nowhere to go, which left the sales site with mailto: links as its only route to the inbox — and those do nothing for a visitor with no mail app configured. quote is now optional and all-or-nothing: send it complete, or omit it entirely. A half-filled quote is still rejected, because accepting one would put a half-priced estimate in the enquiry email. Quote-less enquiries get their own subject line ("Project NEXUS enquiry" rather than "Project NEXUS order enquiry") and an email body with the pricing rows and line-item table left out instead of rendered as a column of dashes. Existing callers that send a full quote are unaffected. The development-only aspnet-backend/ mirror of this controller had the same hard requirement and has been changed in step, so the two do not drift apart before that migration finishes. Regression tests: SalesOrderApiTest::test_public_sales_order_accepts_a_general_enquiry_with_no_quote, ::test_public_sales_order_still_rejects_a_half_filled_quote, ::test_public_sales_order_still_requires_a_contact_name_without_a_quote, ::test_public_sales_order_honeypot_silently_accepts_without_sending, and SalesOrderControllerUnitTests.Submit_AcceptsGeneralEnquiryWithNoQuote / .Submit_StillRejectsHalfFilledQuote on the ASP.NET side.

Changed

  • The footer's "Powered by Project NEXUS" badge links to project-nexus.net. The marketing site moved from project-nexus.ie to project-nexus.net; the badge's destination is not a per-tenant setting but a constant in Footer.tsx, so one change covers every community that has not set general.powered_by_url of its own. Measured against the live bootstrap API on 2026-09-17: six of the thirteen public communities plus the platform's own master tenant have no powered-by settings at all, and stratford and partner-demo have a custom badge image but no custom link — all nine follow the constant. The five communities that point the badge at timebanking.org (awid, crewkerne-timebank, minehead-and-coast-timebank, ryde, timebanking-org) are untouched. No database change and no per-tenant admin work is involved. Regression tests: Footer.test.tsx — "links the default powered-by badge at the marketing site", "prefers the community's own powered-by link over the default".

  • project-nexus.net is an allowed CORS origin. The sales site's enquiry form posts to this API cross-origin, so moving the site to .net without allowlisting it would have failed every submission at preflight with no server-side error to find afterwards. Added to all three copies of the list — config/cors.php, App\Core\CorsHelper and App\Helpers\CorsHelper (the hot path) — because they have drifted apart before. The .ie pair stays allowed while the old domain still resolves. Regression tests: tests/Laravel/Unit/Helpers/CorsHelperTest.php::test_getAllowedOrigins_includes_the_sales_site_origins and the matching test in tests/Laravel/Unit/Core/CorsHelperTest.php.

  • Gamification milestone emails are paced to one per member per hour. Awards arrive in clusters: on 2026-09-17 one member received four emails in three seconds — a listing confirmation, two badges and a level-up. That is the pattern that earns a "report spam" click, and milestone mail earns no engagement to offset it. GamificationEmailService::sendMilestoneEmail() now claims a per-member, per-tenant slot with Cache::add (atomic, so two awards racing in the same second cannot both win it) before sending, and hands the slot back if the send fails. The award itself is untouched: the in-app notification and push still fire, and the monthly gamification digest still recaps every badge earned, so nothing is lost but the burst. The window is config('mail.gamification_milestone_min_interval_seconds'), default 3600; 0 disables it. If the cache is unreachable the email is sent — a cache outage must never silently stop milestone mail platform-wide. This is deliberately the smaller of the two options: folding milestones into the notification_digest queue would replace a branded milestone email with a plain digest line and would do nothing for members whose digest frequency is instant, so it is a product decision rather than a deliverability fix. Regression tests: GamificationEmailServiceTest::test_only_the_first_milestone_email_in_the_window_claims_the_slot, ::test_the_slot_is_per_member_and_per_tenant, ::test_a_failed_send_hands_the_slot_back, ::test_the_limit_can_be_switched_off, ::test_the_default_window_is_one_hour.

Fixed

  • Erasing an account now also deletes the notifications still queued for that person. GDPR erasure deleted the bell notifications (notifications) but left the queued copies (notification_queue), which carry content_snippet — text describing what that member did — and email_body, a fully rendered email addressed to them. Because the digest and instant runners deliberately skip erased accounts, nothing ever consumed those rows: they sat for up to seven days as pending, were then flipped to failed by the stale-row expiry, and were only deleted thirty days after that. So a member's own content could survive roughly 37 days past the erasure they asked for. Both erasure paths (GdprService and UserService::deleteAccount) now delete the rows outright — nothing there was ever sent, so there is no delivery audit trail to preserve — and the daily cleanup carries a sweep for rows left by accounts erased before this change, using the same predicate the recipient queries use so the two cannot drift apart. The sweep runs in both the scheduled cleanup and the admin-triggered one. Regression tests: MemberSelfServiceTest::test_delete_account_removes_notifications_still_queued_for_the_member, CronDigestAnonymisedRecipientTest::test_cleanup_clears_queue_rows_left_by_an_already_erased_account.

  • Email is no longer sent to addresses that can never receive it. Six of the nine hard bounces on the Postmark transactional stream in the 30 days to 2026-09-17 were to structurally undeliverable addresses: four seeded demo members on @partner-demo.test and two erased users on @anonymized.local. Neither domain resolves — .test is reserved by RFC 2606 and .local by mDNS — so every one of those sends was a guaranteed hard bounce charged against the sending domain's reputation, and the reputation damage is what puts ordinary member mail in the spam folder. EmailDispatchService::send() now refuses a recipient under .test, .local, .invalid, .example or .localhost before it resolves a tenant or touches the mailer. It returns false (the existing "not sent" contract, so no caller is told a send succeeded) and logs at info, not warning, because this is expected behaviour rather than an incident. The guard sits in send(), which sendRaw(), sendWithOptions() and EmailService::send() all funnel into, so one check covers every caller. Seeded demo data is unchanged: @partner-demo.test remains correct for a fixture, it is simply no longer mailed. Regression tests: EmailDispatchServiceTest::test_reserved_suffixes_are_recognised_as_unroutable, ::test_ordinary_domains_are_not_treated_as_unroutable, ::test_send_to_reserved_domain_returns_false_without_reaching_the_mailer, ::test_send_to_anonymised_user_address_is_refused, ::test_guard_covers_every_public_entry_point, ::test_reserved_suffix_list_is_exactly_the_documented_set.

  • Erased accounts are no longer treated as notification recipients. GDPR erasure keeps the users row and rewrites email to deleted_<id>_<hash>@anonymized.local, stamping deleted_at and anonymized_at (GdprService, UserService::deleteAccount). The notification digest and instant-queue recipient queries joined users without testing either stamp, so erased people were selected as recipients, had their queued items claimed, and produced a hard bounce every run. deleted_at IS NULL AND anonymized_at IS NULL is now part of the digest roll-up query, both instant-queue fetches and the hot-match recipient query — the four recipient lists that had no account-state filter at all. The remaining recipient queries in CronJobRunner already filter on status = 'active', which an erased account never is. The send guard above remains the backstop; this is the real fix, because a blocked send still costs a round trip and still means the code believed that person was a recipient. Regression tests: CronDigestAnonymisedRecipientTest (all three cases, including a live-member control).

  • A sales enquiry no longer goes out looking like a community's own billing mail. The sales site's enquiry form posts to POST /api/v2/sales/orders, and the resulting email was indistinguishable from ordinary platform notification mail — a real enquiry from a prospective customer was missed because of it. It arrived as "hOUR TimeBank" <billing@project-nexus.net> with audit category billing. Two causes. The category: submit() passed 'billing', which Mailer::resolveFromPrefix() maps to the billing From-address bucket. There is now a ninth bucket, Mailer::CATEGORY_ENQUIRIES, and a new audit category sales_enquiry mapped to it — tested before the billing branch so the marketplace_ / billing test cannot swallow it. The From name: it was never tenant inference (the route already passes tenant_id => null with allow_missing_tenant, and that correctly resolves to no tenant); it was the platform-wide default From name, which production sets to a single community's name. send() now accepts an optional fromName in its options array, threaded to the new Mailer::withFromName(), and the sales route sets it explicitly. No existing caller passes one, so every other email's From address and name are unchanged, and the new bucket stays on the transactional Postmark stream. Regression tests: MailerTest::test_sales_enquiry_category_routes_to_its_own_from_bucket, ::test_sales_enquiry_bucket_does_not_disturb_the_existing_billing_bucket, ::test_sales_enquiry_stays_on_the_transactional_stream, ::test_withFromName_overrides_the_default_sender_name, ::test_withFromName_ignores_empty_values_and_strips_header_injection, SalesOrderApiTest::test_public_sales_order_sends_as_a_sales_enquiry_not_tenant_billing_mail.

  • The platform master's public front page (app.project-nexus.ie) is now actually prerendered for crawlers. The 2026-09-13 planner fix was not enough: PrerenderService::loadTenantTargets() and the render script's own get_tenants() / static page list still excluded tenant id 1, so every scheduled path (auto-recache, drift detection, the job processor) never planned master and a queued master job was claimed but never ran. Master is now a target everywhere, served at the app host root and never at its own tenants.domain (the sales site). Regression tests: PrerenderServiceTest::test_tenant_targets_include_the_platform_master_at_the_app_host_root, scripts/test/test-prerender-tenant-plan.sh.

  • A queued prerender job no longer kills a deploy's full render. prerender-tenants.sh treated every new invocation as a "newer deploy" and cancelled the in-flight run, so the once-a-minute prerender-job-processor.sh terminated each post-deploy render about 50 seconds in — after the master pages had rendered, before they were published. A targeted run (--tenant / --routes) that finds the lock held now exits 75 with a lock_takeover_refused event instead, and the processor skips its tick while the render lock is held. Regression tests: scripts/test/test-prerender-lock-takeover.sh (scenario 6), scripts/test/test-prerender-processor-render-lock.sh; all three prerender harnesses now run in CI's Migration Safety Gate.

  • The deploy's crawler delivery probe now runs after the post-deploy render has published, instead of seconds after the traffic switch. It measured the previous generation of snapshots and reported the master tenant's front page blank on two consecutive deploys where the render then succeeded minutes later. scripts/deploy.sh now starts the 30-minute error watch in the background at the switch (its window is timestamped from when it starts), waits for the server's detached render log to record its end via the new scripts/wait-for-prerender-publish.sh (bounded, never fails the deploy, never signals the server), then probes, then collects the watch result. The waiter is a child process and deploy.sh holds the SSH host and key as plain shell variables, so the invocation passes both explicitly — without that it refused to start and the probe fired early anyway. Regression test: scripts/test/test-deploy-probe-after-prerender.sh, run in CI; it pins the ordering, the waiter's exit codes, and the credential handover itself.


Back to all releases