Skip to main content

Give feedback

Back to all releases
Changelog

1.5.4

Released 2026-06-29

Added

  • Admin user management now shows email activation status and can resend verification emails. The users table includes an email activation column, and the edit-user screen shows activation status with a resend action for members who have not completed email verification. The admin resend endpoint reuses the existing verification-token email flow and skips already verified accounts. Regression tests: tests/Laravel/Feature/Controllers/AdminUsersControllerTest.php, react-frontend/src/admin/modules/users/UserList.test.tsx, react-frontend/src/admin/modules/users/UserEdit.test.tsx, react-frontend/src/admin/api/adminApi.test.ts.
  • The utility bar now has a backend-programmable tenant switcher. Tenant bootstrap exposes active child tenants as switcher options by default, resolving each item to the correct absolute URL: a child with its own custom domain uses that domain, while a child under a custom-domain parent such as uk.timebank.global uses https://uk.timebank.global/{child-slug}. The React utility bar renders the payload as a HeroUI dropdown and navigates cross-domain switches in the same tab. Regression tests: tests/Laravel/Feature/Controllers/TenantBootstrapControllerTest.php, react-frontend/src/components/layout/Navbar.test.tsx.
  • A shadow-mode Next.js public frontend now exists for the hybrid SSR migration. The new isolated next-public-frontend/ app renders the first public/indexable route slice (/, tenant-slug homes, about/help/contact/FAQ/legal pages, blog index/detail, and CMS pages) with server-side HTML, tenant bootstrap via the Laravel public API, canonical metadata, JSON-LD, translated public copy, AGPL attribution, and explicit route ownership that keeps logged-in/gated product routes in the existing Vite SPA. It is disabled by default behind local scripts and a next-public-shadow Compose profile; the current prerender system and production routing are unchanged.
  • Admins can now edit the footer partner-logo label. The Branding & Legal settings screen has a partner-logo label field alongside the existing logo and link controls, and the frontend footer uses that saved label for the bottom-left partner-logo heading and image alternative text. Leaving it blank keeps the translated default "Community Partner" label. Regression tests: react-frontend/src/admin/modules/system/AdminSettings.test.tsx, react-frontend/src/components/layout/Footer.test.tsx, tests/Laravel/Feature/Controllers/AdminSettingsControllerTest.php, tests/Laravel/Feature/TenantBootstrapTest.php.
  • A rerunnable local walkthrough-video pipeline now lives under tools/video-walkthroughs/. It generates British voiceover audio with edge-tts, records the real React hour-timebank onboarding/listing flow with Playwright, and assembles clean and captioned MP4 outputs plus SRT captions with FFmpeg.

Fixed

  • Newsletter admin links now respect the newsletter module toggle. The admin dashboard no longer shows the "Send Newsletter" quick action when the tenant's newsletter feature is disabled, and regression coverage now locks the newsletter module registry and sidebar gating behavior. Regression tests: react-frontend/src/admin/modules/dashboard/AdminDashboard.test.tsx, react-frontend/src/admin/components/AdminSidebar.test.tsx, react-frontend/src/admin/modules/config/moduleRegistry.test.ts.

  • Admin email-activation translations now pass the release drift gate. The email activation status and resend-verification controls added to admin user management now have matching admin-locale keys across all supported languages, and stale Arabic/Polish listing plural keys that no longer exist in the English source were removed.

  • A community's accessible (GOV.UK) custom domain now works like the React custom domains — slug-less, with the tenant resolved from the host. When a community has its own accessible custom domain (e.g. accessible-uk.timebank.global), the whole accessible site is now served at clean, slug-less paths (/, /login, /listings, …). Previously the bare domain 302-redirected to https://accessible-uk.timebank.global/timebanking-org/alpha and every in-page link carried that internal timebanking-org/alpha prefix. Tenants WITHOUT a custom accessible domain are unchanged — the shared platform domain still uses /{tenantSlug}/alpha/…. Implemented centrally, with no per-page rewrites: TenantContext flags requests that arrived via a tenant's accessible_domain; the accessible route set is also registered at the bare root for those hosts (InjectHostTenantSlug supplies the slug the controllers expect; EnsureAccessibleCustomDomain keeps the bare routes off the shared/API hosts); and StripTenantSlugOnAccessibleDomain removes the /{slug}/alpha prefix from every generated link and redirect on those hosts. Regression test: tests/Laravel/Feature/GovukAlpha/AccessibleCustomDomainRootTest.php (and the existing accessible parity suite stays green).

  • The deploy-time browser-journey safety gate now actually exercises the candidate build instead of false-failing. The gate loads the freshly built blue/green candidate frontend from 127.0.0.1 and runs the @smoke journeys against it before traffic is switched. But the production React bundle hard-codes the live API origin (https://api.project-nexus.ie), so the candidate SPA's own bootstrap/data fetches went cross-origin to the live colour and were CORS-blocked from the 127.0.0.1 gate origin — leaving the SPA stuck on "Loading community" so nearly every page journey timed out (a false failure on the gate's first real run, unrelated to the code being shipped). A new pinSpaApiToCandidate() helper (wired into the gate's beforeEach) intercepts those calls via Playwright request routing and proxies them to the candidate API (reachable through the runner's --network host), fulfilling with CORS headers — so the gate now genuinely tests the candidate's own frontend and API end-to-end. The handler aborts best-effort (never double-handles) when a page navigates mid-request. Verified by running the full @smoke suite against a live candidate: 19 passed, 2 skipped (admin, no creds). Harness only — no runtime/app behaviour change. Files: e2e/helpers/test-utils.ts, e2e/tests/smoke.spec.ts, playwright.deploy.config.ts.

  • "Resend verification email" no longer says it sent when the request was actually rejected. On the email-verification page, clicking "Resend verification email" showed a "we sent a new link — check your inbox" confirmation the moment the request was sent, without checking it worked — so a rejected resend (most often rate-limiting, since resends are throttled) left you waiting for an email that never came. It now only confirms once the server accepts the request, and otherwise shows an error and keeps the resend button so you can try again. Verified live by forcing the resend to be rate-limited: the error appears instead of a false confirmation. Regression test: react-frontend/src/pages/auth/VerifyEmailPage.test.tsx. (The login page's separate resend stays intentionally silent — that endpoint always reports success to avoid revealing whether an email is registered.)

  • Replying to a story no longer says "sent" (and discards your message) when the reply was actually rejected. In the story viewer, sending a reply showed a "reply sent" confirmation and cleared the box the moment the request was sent, without checking it worked — so a rejected reply (e.g. rate-limited or a since-expired story, which comes back as data rather than an error) silently dropped your message with a false confirmation. The reply now only clears and confirms once the server accepts it, and otherwise shows an error and keeps your text so you can retry. The same story viewer also no longer advances past your own story as if deleted when a delete is rejected, and no longer leaves a reaction highlighted when the server rejects it. Regression test (proven to fail before the fix, pass after): react-frontend/src/components/stories/StoryViewer.test.tsx.

  • Hiding, muting, or deleting from a hashtag feed no longer makes posts vanish with a false "done" when the server rejected the action. On a hashtag (#tag) page, "Hide", "Mute user" and "Delete" removed the post(s) from the list and showed a success message the moment the request was sent, without checking whether it worked — so a rejected action (e.g. not yours to delete, already gone, or rate-limited, which come back as data rather than an error) made the post(s) disappear with a fake confirmation, only to reappear on refresh (mute was worst: it wiped every post by that author). These now only remove the post(s) once the server confirms, and show an error otherwise; a rejected poll vote on the same feed now also surfaces an error instead of silently doing nothing. Mirrors the same fix just made to the profile timeline. Regression tests: react-frontend/src/pages/feed/HashtagPage.test.tsx.

  • Hiding or deleting a post from a profile timeline no longer makes it vanish with a false "done" when the server rejected the action. On a profile's activity feed, "Hide" and "Delete" removed the post from the list and showed a success message the moment the request was sent, without checking whether it worked — so a rejected hide/delete (e.g. not yours to delete, already gone, or rate-limited, which come back as data rather than an error) made the post disappear with a fake confirmation, only to reappear on the next refresh. Hide/delete now only remove the post once the server confirms, and show an error otherwise; a rejected poll vote on the same feed now also surfaces an error instead of silently doing nothing. Regression test (proven to fail before the fix, pass after): react-frontend/src/components/profile/ProfileFeed.test.tsx.

  • A reaction on someone's Appreciation Wall no longer changes the count when the reaction was actually rejected. Tapping a reaction (heart/clap/star) on a thank-you note updated the count immediately without checking whether the server accepted it, so a rejected reaction (e.g. rate-limited or a since-removed note, which comes back as data rather than an error) left the count off by one until the page was reloaded. The count now only changes once the server confirms the reaction. Regression test (proven to fail before the fix, pass after): react-frontend/src/pages/profile/AppreciationWallPage.test.tsx.

  • The user hover card no longer shows (and remembers) a "Pending" state when a connection request was actually rejected. Hovering a member and clicking "Connect" immediately marked the card "Pending" without checking whether the server accepted the request — and cached that state, so every later hover of that person showed the false "Pending" too. A rejected request (e.g. you'd already sent one, were blocked, or hit a rate limit, which come back as data rather than an error) therefore left a sticky fake "Pending". The card now only switches to "Pending" once the server confirms the request, and stays on "Connect" (so you can retry) otherwise. Regression test (proven to fail before the fix, pass after): react-frontend/src/components/social/UserHoverCard.test.tsx.

  • Registration emails no longer depend on queue workers. The member welcome/activation email and the admin "new registration" alert now run inline during signup instead of being queued, so a stalled Redis/Horizon worker can no longer leave new members without verification links or hide new registrations from admins. The existing duplicate-delivery guards remain in place, email-log rows now carry registration-specific idempotency keys, and email health now treats missing/failed admin registration alerts as critical incidents.

  • Platform SendGrid fallback now keeps the authenticated From domain. If a tenant SMTP path fails and the app falls back to the platform SendGrid account, the fallback now uses the same category-specific project-nexus.net From addresses as normal platform SendGrid mail instead of reusing a raw configured From address such as noreply@project-nexus.ie. This prevents fallback-only messages from failing SPF/DMARC alignment when the env From domain is not SendGrid-authenticated. Regression test: tests/Laravel/Unit/Core/MailerTest.php.

  • Platform SendGrid Reply-To is no longer hard-coded to a personal mailbox. The platform SendGrid Reply-To now comes from SENDGRID_REPLY_TO / mail.sendgrid.reply_to when configured, and no Reply-To is forced when it is not configured. This removes the hard-coded jasper@hour-timebank.ie header from platform mail and prevents personal-address leakage in message headers. Regression test: tests/Laravel/Unit/Core/MailerTest.php.

  • The "People You May Know" feed widget no longer says a connection request was sent when it was actually rejected. Clicking "Connect" immediately showed the card as "Pending" and a "request sent" confirmation without checking whether the server accepted it — so a rejected request (e.g. you'd already sent one, the person blocked requests, or you hit a rate limit, which come back as data rather than an error and show no automatic message) left the card stuck on "Pending" with a false success message until refresh. The card now reverts to "Connect" and shows the actual reason when the request is rejected. Verified live by forcing the request to fail: the card returns to "Connect" and an error ("You have already sent a request") appears instead of a fake confirmation. Regression test: react-frontend/src/components/feed/ConnectionSuggestionsWidget.test.tsx.

  • The "WCAG 2.2 AA Version" link now uses a community's bare accessible custom domain instead of appending /alpha. When a community has its own accessible (GOV.UK) custom domain configured in admin (e.g. accessible-uk.timebank.global), the utility-bar link to the accessible version appended an /alpha path — so it pointed at https://accessible-uk.timebank.global/alpha rather than the clean domain set in the admin panel. The link now uses the bare custom domain (the host resolves the tenant and the server redirects the root to the canonical accessible home), matching what the administrator configured; deep-link subpaths still target the /alpha route namespace. Affects both the desktop navbar and the mobile drawer. Regression test: react-frontend/src/lib/accessible-frontend.test.ts.

  • The PWA install banner now uses the community's tenant name instead of always saying NEXUS. The banner title is rendered through the existing translation key with a tenant-name placeholder, falling back to NEXUS only when tenant branding is unavailable. Regression test: react-frontend/src/components/pwa/InstallBanner.test.tsx.

  • A group's Analytics tab no longer crashes when a retention-cohort row comes back without a retention figure. The retention table formatted each cohort's percentage directly, so if a cohort arrived without a retention figure (a partial/degraded backend response) the tab threw while rendering that row and blanked the whole Analytics view — even though the other figures on the same tab already guarded against this. Missing retention is now treated as 0%. Locked with a regression test proven to fail before the fix (the render threw) and pass after. Regression test: react-frontend/src/pages/groups/tabs/GroupAnalyticsTab.test.tsx.

  • The Leaderboard no longer crashes to an error screen when a ranking row comes back without a score. Each row formatted its score directly, so if an entry arrived with neither a score nor an XP value (a partial/degraded backend response) the page threw while rendering that row and blanked the entire Leaderboard with a "Something went wrong" message. Missing scores are now treated as zero and the row renders normally. Verified live by forcing an entry with no score or XP: the row shows "0" with no error, and normal entries still show their real scores. Regression test: react-frontend/src/pages/leaderboard/LeaderboardPage.test.tsx.

  • The Achievements page no longer crashes to an error screen when your gamification profile comes back incomplete. The XP profile card read your total XP and level-progress figures directly, so if the server returned a partial profile (missing those fields — which can happen when it falls back after an internal error) the card threw while rendering and blanked the entire Achievements page with a "Something went wrong" message. The card now treats missing figures as zero and renders normally, so a temporary backend hiccup degrades gracefully instead of taking down the page. Verified live by forcing an incomplete profile response: the page renders (Level 5 / 0 XP total) with no error, and a normal response still shows the real figures. Regression test: react-frontend/src/pages/achievements/AchievementsPage.test.tsx.

  • Voting on a poll attached to an event now tells you when the vote is rejected instead of silently doing nothing. On the event detail page, voting on an event poll only updated the results and showed a confirmation when the request succeeded — but if it was rejected (the poll closed, you already voted, or an invalid option — which comes back as data rather than an error and shows no automatic message) the click did nothing at all, with no feedback. It now shows the reason (or a generic "Failed to record vote") on a rejected vote, matching the standalone Polls page. Verified live by forcing a vote to fail: the error ("This poll is closed") now appears where the click previously did nothing.

  • "Download my data" on the job applications page now confirms success and reports failure instead of doing nothing. The GDPR export button fetched your data and triggered a download only when the request succeeded — but if it was rejected (a server or connection problem, which comes back as data rather than an error and shows no automatic message) nothing happened at all: no file, no message, leaving you unsure whether your data request worked. It now shows a success confirmation when the download starts and an error message if the export is rejected. Verified live by forcing the export to fail: the error ("Export is temporarily unavailable") now appears where previously the button did nothing. Regression test: react-frontend/src/pages/jobs/MyApplicationsPage.test.tsx.

  • The check-in History view on a goal now shows a "couldn't load" message (with a Retry button) instead of looking empty when it fails to load. Opening a goal's check-in dialog and switching to History fetched the past check-ins, but if that request failed (a server or connection problem, which comes back as data rather than an error and shows no automatic message) the view fell through to its "no check-ins yet" empty state — so a load failure was indistinguishable from a goal that genuinely has no check-ins. It now shows the error message and a Retry button on a failed load (reusing the same wording as the goal progress timeline). Verified live by forcing the check-ins request to fail: the error + Retry appear in place of the empty state. Regression test: react-frontend/src/pages/goals/components/GoalCheckinModal.test.tsx.

  • An employer's bulk "move applicants to a stage" action no longer reports success when the server rejected it. On the job applications pipeline (Kanban), selecting several applicants, choosing a stage, and pressing Apply showed "Applications updated" and cleared the selection the moment the request was sent, without checking whether it worked — so a rejected bulk update (an invalid stage, a vacancy you don't own, or applicant ids outside that vacancy — which comes back as data rather than an error and shows no automatic message) left every applicant unchanged while the employer was told it succeeded. It now shows the reason and keeps the selection so you can retry, matching the success-gated pattern already used by the page's AI-rank and per-card actions. Verified live by forcing the bulk request to fail: the error ("Some applications could not be updated") appears and the selection is kept (it previously cleared with a false "updated" message).

  • A goal's progress timeline now shows a "couldn't load" message (with a Retry button) instead of looking empty when it fails to load. If the request for a goal's history failed (a server or connection problem, which comes back as data rather than an error and shows no automatic message), the timeline fell through to its "no activity yet" empty state — so a load failure was indistinguishable from a goal that genuinely has no history. It now shows the existing error message and Retry button on a failed load. Verified live by forcing the history request to fail: the error + Retry appear in place of the empty state. Regression test: react-frontend/src/pages/goals/components/GoalProgressHistory.test.tsx.

  • Saved searches no longer disappear or fail silently when the server rejects the action. Deleting a saved search removed it from the list and said "deleted" the moment the request was sent, without checking whether it worked — so a rejected delete (e.g. not-found or rate-limited) made the row vanish and showed a success message while the search was still saved on the server, until the next refresh brought it back. Separately, saving a search that the server rejected (e.g. a duplicate name) gave no feedback at all — the form just sat there. Now a delete only removes the row once the server confirms it (and shows an error otherwise), and a rejected save shows the reason and keeps the form open to retry. The save form also guards against a double-Enter creating two duplicates. Verified live: a forced-to-fail save shows its error and keeps the form open, and a double-Enter sends exactly one request. Regression test: react-frontend/src/components/search/SavedSearches.test.tsx.

  • The club/association invitations page no longer shows an empty "Received" list when it actually failed to load. If the request for your federation invitations failed (a server or connection problem, which comes back as data rather than an error and shows no automatic message), the page fell through to its empty state — so a load failure looked identical to genuinely having no invitations. It now shows a "couldn't load" message instead. Verified live by forcing the request to fail: the error message appears rather than a misleading empty list. Regression test: react-frontend/src/pages/profile/MyVereinInvitationsPage.test.tsx.

  • Creating a collection now tells you why it failed instead of silently doing nothing. On the My Collections page, if the server rejected a new collection (for example a duplicate or invalid name — which comes back as data rather than an error and shows no automatic message), the "New collection" dialog just stayed open with no feedback, so it looked like the button hadn't worked. It now shows the reason and keeps the dialog open so you can fix the name and try again. Verified live by forcing a create to fail: the error ("A collection with that name already exists") appears and the dialog stays open. Regression test: react-frontend/src/pages/profile/MyCollectionsPage.test.tsx.

  • Posting a volunteer opportunity can no longer be submitted twice and create a duplicate. Like the other creation forms, the "Post Opportunity" form submits on Enter (bypassing the disabled button), so a double-Enter or a fast double-click on a slow connection could send the request twice and create two identical opportunities. A synchronous re-entry guard now ensures only the first submit is sent. Verified live (a double-submit now fires exactly one request) and locked with a regression test. Regression test: react-frontend/src/pages/volunteering/CreateOpportunityPage.test.tsx.

  • Registering an organisation can no longer be submitted twice and create a duplicate. Like the event, group, and listing forms, the "Register Organisation" form submits on Enter (bypassing the disabled button), so a double-Enter or a fast double-click on a slow connection could send the registration twice and create two pending organisations. A synchronous re-entry guard now ensures only the first submit is sent. Verified live (a double-submit now fires exactly one request) and locked with a regression test. Regression test: react-frontend/src/pages/organisations/RegisterOrganisationPage.test.tsx.

  • Creating an event can no longer be submitted twice and make a duplicate. On the Create Event form the submit button shows a spinner while saving but, because pressing Enter in a field submits the form directly (bypassing the button), a double-Enter or a fast double-click on a slow connection could fire the create request twice and create two identical events. A synchronous re-entry guard now ensures only the first submit is sent, matching the fix already on the Create Group, Create Listing, and Request Exchange forms. Verified live: a double-submit on a fully filled event form now fires exactly one create request (it was two before).

  • The Leaderboard "Community Impact" tab no longer crashes the whole Leaderboard when its figures come back incomplete. If the server returned a partial community-stats response (missing totals, or this-month figures in an unexpected shape — which can happen when it falls back after an internal error), the tab tried to format a missing number and threw, blanking the entire Leaderboard with a "Something went wrong" message. The tab now treats missing figures as zero and renders normally, so a temporary backend hiccup degrades gracefully instead of taking down the section. Verified live by forcing the incomplete response: the tab renders zeros with no error, and a normal response still shows the real figures. Regression test: react-frontend/src/pages/leaderboard/CommunityImpactTab.test.tsx.

  • The Leaderboard "Spotlight" tab no longer says "No Spotlight Yet" when it actually failed to load. When the featured-members request failed (a server or connection problem, which comes back as data rather than an error and shows no message), the tab fell through to its empty state — so a load failure looked exactly like a community with no members to feature. It now shows a distinct "couldn't load" message instead, so a genuine empty result and a failed load are no longer confused. Verified live by forcing the request to fail: the error message appears in place of the empty state, while a normal response still shows the featured members.

  • AI chat thumbs-up/down no longer stays highlighted when your rating fails to save. Rating an AI answer highlighted the thumb immediately, but if the request to save it was rejected (a permissions or validation problem, or a connection blip — which come back as data rather than an error and show no message) the highlight stuck as though it had saved, because the undo only ran on a thrown error. The rating now reverts the highlight when the server doesn't accept it, so what's highlighted reflects what was actually saved. Verified live by forcing the rating request to fail: the thumb highlights briefly, then reverts when the save is rejected, while a successful rating stays highlighted.

  • Unblocking a member now tells you if it failed instead of silently pretending it worked. On the Blocked Users page (Settings → Privacy → Blocked Users), confirming an unblock closed the dialog and removed the person from the list only when the request succeeded — but if the request was rejected (a permissions or validation problem, or a connection blip, which come back as data rather than an error and show no automatic message) the dialog still closed with no feedback while the member stayed blocked, so it looked done when it wasn't. A rejected unblock now shows an error and leaves the member in the list so you can retry. Verified live by blocking a member and forcing the unblock to fail: the error message appears and the member remains listed. Regression test: react-frontend/src/pages/settings/BlockedUsersPage.test.tsx.

  • The Leaderboard "My Journey" tab no longer crashes to an error screen when its data can't be fully loaded. If the server hit a problem building your personal-journey summary it still replied "success" but with an empty summary, and the tab then tried to format a missing XP number and threw — blanking the entire Leaderboard with a "Something went wrong" message. The tab now treats a missing/empty summary defensively and shows zeros instead of crashing, so a temporary backend hiccup degrades gracefully rather than taking down the whole section. Verified live by forcing the empty-summary response: the tab renders (Level 0 / 0 XP) with no error instead of crashing, and a normal response still shows the real figures. Regression test: react-frontend/src/pages/leaderboard/PersonalJourneyTab.test.tsx.

  • Comments now tell you when posting, editing, or deleting fails instead of pretending it worked. The shared comments component (used under blog posts, resources, listings, events, and the feed) ignored whether the server actually accepted each action — because a rejected request (e.g. a permission or validation problem, rate-limiting, or an already-deleted comment) comes back as data rather than an error and shows no automatic message. So confirming a delete closed the dialog as if the comment were gone while it stayed in the list, and a rejected post, reply, or edit simply did nothing — your text just sat there with no explanation. Each action now confirms success first: a failed delete keeps the confirmation open and shows an error, a failed post/reply keeps your text and shows an error, and a failed edit keeps the editor open and shows an error. Verified live on a real comment by forcing the delete and the post to fail: the dialog stays open / the text is preserved and the matching error message appears. Regression test: react-frontend/src/components/social/CommentsSection.test.tsx.

  • Reordering resources no longer leaves a fake new order on screen when the save fails. On the Resources page, an administrator's "Reorder" up/down controls moved a file immediately and then saved the new order — but if the save was rejected (a permissions or validation problem, or a connection blip, which come back as data rather than an error), the failure path never ran: the list stayed in the new order with no warning, so it looked saved until a later reload silently snapped it back. The move now rolls back to the previous order and shows an error when the save is rejected, so what you see always matches what was actually saved. Verified live as an admin by forcing the reorder save to fail: the list reverts to its original order and shows "Failed to save new order" instead of leaving the fake order in place.

  • Editing, deleting, or unlinking an ideation campaign now reports failures instead of pretending to work. On a campaign's page, saving an edit, deleting the campaign, and unlinking a challenge all showed success regardless of whether the server accepted the request — so a rejected edit closed the dialog and said "updated", a rejected delete navigated away as though the campaign were gone, and a rejected unlink claimed the challenge was removed. Each now confirms the server accepted the action first and shows an error otherwise (the edit dialog stays open on failure so you can retry). Verified live: a save forced to fail now shows the error and keeps the dialog open instead of falsely reporting the campaign was updated.

  • Idea comments and idea admin actions now report failures instead of pretending to work. On an idea's page, posting a comment, deleting a comment, changing an idea's status, and deleting an idea all ran their success path without checking whether the server accepted the request. So a failed comment cleared your text and said "comment added" while nothing was posted, a failed delete still removed the comment from view, a failed status change claimed it updated, and a failed idea delete navigated you away as though the idea were gone. All now confirm the server accepted the action first and show an error otherwise — and a failed comment keeps your text so you can retry. Verified live: a comment forced to fail now shows the error and preserves the text instead of falsely reporting it was posted. Regression test: react-frontend/src/pages/ideation/IdeaDetailPage.test.tsx.

  • Saving or deleting a bookmark collection now reports failures instead of pretending to work. On the Saved Items page, creating, renaming, or deleting a collection showed a success message and closed the dialog regardless of whether the server accepted the request — so a rejected create/rename/delete looked done (the dialog closed, "created"/"deleted" shown) while nothing had actually changed. Each now confirms the server accepted the action first and, on failure, shows an error and keeps the dialog open so you can retry. Verified live by forcing a create to fail: it now shows the error and leaves the dialog open instead of falsely reporting the collection was created.

  • Group Q&A actions no longer report success when they actually failed. In a group's Questions & Answers tab, asking a question, posting an answer, accepting an answer, and voting all ran their success path without checking whether the server accepted the request. So a rejected ask/answer still showed "posted" and closed the form, a rejected accept marked an answer as accepted (which awards the answerer and closes the question) when it hadn't, and a rejected vote left the count visibly changed but wrong with no error. All four now confirm the server accepted the action first and show an error otherwise — and a failed vote no longer applies the optimistic count change. Regression test: react-frontend/src/pages/groups/tabs/GroupQATab.test.tsx.

  • Group-exchange actions no longer report success when they actually failed. On a group time-exchange's page, "Start exchange", "Confirm hours", "Cancel exchange", and adding/removing a participant each fired their request but never checked whether the server accepted it — so a rejected action still showed a success message and reloaded, a failed cancel even navigated you away as though the exchange had been deleted, and a failed "add participant" claimed the member was added (which affects the credit split). All of them now confirm success before showing the confirmation (and the cancel stays on the page, showing the error, on failure), matching how "Complete exchange" already worked. Verified live on a real group exchange: a cancel forced to fail shows the error and keeps you on the exchange, and a failed "add participant" shows the error instead of pretending the member was added. Regression test: react-frontend/src/pages/group-exchanges/GroupExchangeDetailPage.test.tsx.

  • Converting a shortlisted idea into a group now reports failures instead of doing nothing. On an idea's page, the "Convert to group" action (shown on shortlisted/winning ideas) gave no feedback when the request failed: it checked whether data came back rather than whether the request succeeded, and had no failure branch — so a rejected conversion silently did nothing (and an unusual response could even have navigated you to a group that wasn't created). It now confirms success before navigating and shows an error message otherwise. Same silent-failure class as the ideation-vote fix below, in the same file.

  • Voting on an idea now tells you when it fails instead of doing nothing. On the ideation pages — both an idea's own page and the idea list inside a challenge — clicking the upvote button when the request failed (voting closed, not allowed, rate-limited, or a connection blip) did nothing at all: no change, no error, no feedback, leaving you unsure whether your vote registered. Both vote handlers now show an error message when the vote fails (and the idea page also no longer risks applying a phantom vote — it now confirms the request actually succeeded before updating the count). Verified live by forcing a vote to fail and seeing the error message appear. Regression test: react-frontend/src/pages/ideation/IdeaDetailPage.test.tsx.

  • The "forgot password" page no longer sends two reset emails on a fast double-press. The "Send reset instructions" button shows a spinner while submitting but stays active, and pressing Enter submits the form directly — so a double-Enter (or double-click) on a slow connection fired the request twice and emailed two password-reset links. A synchronous re-entry guard now ensures only the first submit is sent. Verified live: a double-submit now fires exactly one request (it was two before). Regression test: react-frontend/src/pages/auth/ForgotPasswordPage.test.tsx.

  • A member's profile page no longer shows someone else's details when the one you opened fails to load. The same bug as the event page (above): navigating straight from one profile to another — from the members list, a hover-card link, or browser back/forward — and then hitting a profile that couldn't be loaded (deleted, private/incomplete, or a connection hiccup) left the previous member's profile on screen under the new address. The page recorded the error but never cleared the already-loaded profile, so the "couldn't load" screen (gated on error && !profile) — and the friendly "incomplete profile" empty state — never appeared. A failed profile load now clears the stale profile and shows the "Unable to Load Profile" screen (Browse Members / Try Again), or the incomplete-profile state where that applies. Verified live by navigating from a loaded profile to one whose request was forced to fail. Regression test: react-frontend/src/pages/profile/ProfilePage.test.tsx.

  • An event page no longer shows a different event's details when the one you opened fails to load. Navigating straight from one event to another (e.g. via a "more in this series" link or browser back/forward) and then hitting an event that couldn't be loaded — deleted, or a server/connection hiccup — left the previous event's full details on screen under the new address, with no error. (The page recorded the error internally but never cleared the already-loaded event, so its "couldn't load" screen — gated on error && !event — never appeared.) A failed event load now clears the stale event and shows the "Unable to Load Event" screen with Browse Events and Try Again. Verified live by navigating from a loaded event to one whose request was forced to fail: the stale event was replaced by the error screen. Regression test: react-frontend/src/pages/events/EventDetailPage.test.tsx.

  • Creating a group or a listing can no longer be submitted twice and create a duplicate. On the "Create Group" and "Create Listing" forms the submit button showed a spinner while saving but stayed active — and, because pressing Enter in a field submits the form directly (bypassing the button), a double-Enter or a double-click on a slow connection fired the create request twice and created two identical groups/listings. (Confirmed live: a double-submit on the group form created two groups with the same name.) Both forms now have a synchronous re-entry guard so only the first submit is sent, matching the fix already on the Request Exchange form. Regression tests: react-frontend/src/pages/groups/CreateGroupPage.test.tsx, react-frontend/src/pages/listings/CreateListingPage.test.tsx.

  • Search: switching to a result category with no matches no longer shows a blank area. After a search, clicking a category tab (Listings / Members / Events / Groups) that had zero matches — while other categories did have matches — showed an empty, unexplained white space (each category section was simply hidden when empty, and the page-wide "no results" message only appears when every category is empty). That tab now shows the same clear "No results found" message with search tips. Regression test: react-frontend/src/pages/search/SearchPage.test.tsx.

  • Group actions no longer claim success when the server rejected them. Inside a group, several actions — inviting members by email, and hiding, deleting, reporting a post or muting a member from the group feed — optimistically updated the screen and showed a success message as soon as the request was sent, without checking whether it actually worked. So a rejected request (e.g. a deletion you aren't allowed to make, or an invalid invite address) still made the post vanish from view / showed "Invites sent" / "Reported" while the server was unchanged, until the next refresh undid it. All five actions now apply their change and show success only once the server confirms it, and show an error otherwise. The shared guard behind them (runConfirmedMutation) is unit-tested. Regression test: react-frontend/src/lib/confirmedMutation.test.ts.

  • "Mark all read" (and per-notification "mark as read") no longer pretend to succeed when the request fails. On the Notifications page, marking notifications read went through a shared handler that quietly swallowed any error and never reported failure back to the page — so even when the server rejected the request, the page still cleared the notifications from view and, for "Mark all read", showed a green "All notifications marked as read" confirmation, leaving the visible list and the unread badge out of sync until the next refresh. The handlers now report whether the server actually confirmed the change, and the page only clears the items / shows the success message on a real success — otherwise it leaves them unread and shows an error. Regression tests: react-frontend/src/pages/notifications/NotificationsPage.test.tsx.

  • Screen readers can now read the recipient matches when sending or donating time credits. In the "Send Credits" and "Donate" dialogs, typing a recipient's name showed a results dropdown marked up as a selection list (role="listbox"), but its items were buttons that didn't carry the matching option role — so assistive technology announced an empty list and the matches were effectively hidden from screen-reader users (a known limitation: the button component doesn't forward that role to the page). The results are now a properly labelled "Search results" group of real, focusable buttons, so every match is announced and selectable, and the invalid empty-list markup is gone. Mouse and keyboard selection are unchanged. Regression tests: react-frontend/src/components/wallet/TransferModal.test.tsx, react-frontend/src/components/wallet/DonateModal.test.tsx.

  • A momentary connection or server hiccup no longer makes a listing look permanently deleted. When a listing's detail page failed to load, it always showed "Listing Not Found" with only a "Browse" link — even when the real cause was a temporary network drop, timeout, or server (5xx) error rather than a genuinely missing listing. (These failures come back as data, not as an exception, so the page couldn't tell a blip apart from a deletion.) A transient/unknown failure now shows an "unable to load — please try again" message with a Try Again button that re-attempts the load, while only an unambiguous 404/403/410 still shows the permanent not-found state. Verified live by forcing the listing request to fail and confirming Try Again both appears and recovers the listing. Regression tests: react-frontend/src/pages/listings/ListingDetailPage.test.tsx.

  • Saving a listing no longer falsely reports "Saved" when the save actually failed. Tapping the bookmark/Save button on a listing filled the heart immediately (optimistically) and then, if the request was declined for an expected reason — already saved, a validation problem, or rate-limiting — still showed a green "Listing saved" confirmation and left the heart filled, because that kind of failure comes back as data rather than an exception and the button only handled exceptions. Un-saving had the same flaw. The button now checks the actual result: on failure it reverts the heart to its previous state and shows an error message, and only confirms when the save (or un-save) genuinely succeeded. Regression test: react-frontend/src/pages/listings/ListingDetailPage.test.tsx.

  • Requesting a time exchange can no longer be sent twice, and a declined request no longer fails silently. On the "Request Exchange" form, the Send Request button showed a spinner while submitting but stayed active, and the submit handler had no guard against running again — so a double-click, or pressing Enter twice on a slow connection (which submits the form directly, bypassing the button), could send two requests and create duplicate pending exchanges. Separately, when a request was declined for an expected reason — you'd already requested that listing, a validation problem, or rate-limiting — nothing was shown at all: the spinner just stopped, because only server (5xx) errors raised a notification while these "your request was rejected" (4xx) responses were handled silently. Submitting is now guarded so only the first request is sent (and the button is genuinely disabled while one is in flight), and a clear error message is shown whenever a request is declined. Regression tests: react-frontend/src/pages/exchanges/RequestExchangePage.test.tsx.

  • The browser tab title now updates correctly when you move between pages. Most pages set their title two ways — a per-page SEO component (which also sets the rich link-preview title) and a small usePageTitle hook — and the two were fighting because the hook's check for "is the SEO component already handling this page?" looked for a marker attribute that the SEO library no longer adds. As a result the hook never stepped aside, and the tab title was left stale or inconsistent — e.g. it still read "Log In" (or a bare "Feed") on the community feed while the page's real/share title was correctly "Community Feed". The hook now reliably detects when the SEO component owns the title and steps aside, so the tab title matches the page (and the share/og:title) on every navigation. Pages that use only the hook are unaffected. Regression test: react-frontend/src/hooks/usePageTitle.test.ts.

  • Deleting a group collection or group custom field can no longer remove another community's data. Both deletions removed the child rows (a collection's group items / a custom field's stored values — neither table carries a community/tenant id) scoped only by the parent id, before the community-scoped delete of the parent itself. So an administrator who passed a parent id belonging to a different community (ids are sequential and easy to guess) would wipe that community's child rows while the parent delete silently did nothing. Both services now verify the parent belongs to the current community before touching any child rows, so a cross-community id is rejected and nothing is deleted. Regression test: tests/Laravel/Feature/Groups/GroupCollectionTenantScopingTest.php.

  • Logging caring-support hours no longer destroys the fractional part of a credit. When an organisation auto-pays a carer/volunteer for logged hours, the worker's time-credit balance is credited in whole hours (rounded down), but the organisation's wallet was being debited the full fractional amount — so a 2.5-hour log took 2.5 from the organisation and gave only 2 to the worker, quietly destroying half a credit, and a log of under an hour debited a fraction while paying the worker nothing at all. The organisation is now debited exactly the same whole-hour amount the worker is credited (the fractional remainder stays in the organisation's wallet, matching how volunteer-hour payments already work), so credits are conserved. This affected both the caring-support relationship and the caring-community review payment paths. Regression test: tests/Laravel/Feature/CaringCommunity/CaringOrgPaymentConservationTest.php.

  • Accepting a timebanking job offer now takes the credits from the employer instead of creating them out of nothing. When a member posts a timebank job (paid in time credits) and the applicant accepts the offer, the applicant is credited the role's time credits — but the employer who posted the role was never debited, so every timebank hire minted brand-new credits into the community and left a transaction record that claimed the employer had paid when their balance hadn't moved. Worse, anyone could post a high-credit timebank job and accept it themselves (or via a second account) to mint unlimited credits, breaking the core timebanking rule that credits only ever move between members. The employer who posted the role is now debited the same amount the applicant is credited (their balance may go negative, exactly like the volunteering org-wallet), so credits are conserved and the transaction ledger matches real balances. Communities not using timebank-type jobs are unaffected. Regression test: tests/Laravel/Feature/Jobs/JobOfferAcceptConservationTest.php (conservation + the self-accept mint case).

  • Non-members can no longer react to (or see who reacted to) a private group's discussions. Reacting to content runs an access check before recording the reaction, but group discussions aren't part of the activity feed, so that check fell through to its "assume visible" default and allowed any signed-in member of the community to add a reaction to — and list the reactors of — a discussion inside a private group they hadn't joined (the discussion's own pages correctly returned "forbidden"). This leaked who was participating in private group conversations and let outsiders ping discussion authors with reaction notifications. The reaction access check now requires active group membership for discussion targets, matching the discussion pages. Regression test: tests/Laravel/Feature/Reactions/DiscussionReactionAccessTest.php.

  • Group exchanges no longer create or destroy fractions of a time credit when the split doesn't divide evenly. A group exchange splits its total hours among providers (who are credited) and receivers (who are debited). Each person's share was rounded to two decimals independently within each role, so when the number of providers differed from the number of receivers and the hours didn't divide cleanly, the two totals drifted apart — e.g. a 10-hour exchange with 2 providers and 3 receivers credited 10.00 but debited 9.99, conjuring a hundredth of a credit from nothing (the opposite split destroyed one). The last person in each role now absorbs the rounding remainder so providers are credited exactly what receivers are debited, and both equal the exchange total. Regression test: tests/Laravel/Feature/Services/GroupExchangeSplitConservationTest.php.

  • Checking the same attendee in twice can no longer double-charge the organizer or double-credit the attendee. Event check-in transfers time credits from the organizer to the attendee. The "already checked in" guard ran before the credit transaction (a read-then-act check), so two rapid/concurrent check-ins of the same attendee — an organizer double-clicking, a retry, or two staff at a busy event — could each mint a transfer, charging the organizer twice and crediting the attendee twice. The credit transfer now sits behind an atomic claim on the attendee's RSVP status inside the transaction, so only the first check-in mints and any repeat is collapsed (the attendee is reported as already checked in). The credit logic moved into EventService::recordCheckInCredit. Regression test: tests/Laravel/Feature/Events/EventCheckInIdempotencyTest.php.

  • Credit-donation error messages now show in the member's own language. When a credit donation (to another member or to the community fund) failed, the wallet returned two hardcoded English error strings — "Donation failed. Check balance and recipient." and "Amount must be greater than 0" — straight to the member, even for members using another language. Both now render through the translation layer (the failure message added across all 11 supported languages). Regression test: tests/Laravel/Feature/Wallet/CreditDonationLocaleTest.php.

  • Reviews can no longer reference a member or transaction from a different community. When leaving a review, the backend checked only that the receiver_id and transaction_id existed somewhere in the database — not that they belonged to the reviewer's own community (tenant). A member could therefore create a review row pointing at a user in another community, or attach a review to a transaction from another community (confirmed: a tenant-2 member referenced a tenant-3 user and a tenant-7 transaction). Both existence checks are now scoped to the reviewer's tenant, so cross-community references are rejected with a validation error; normal within-community reviews are unchanged. Regression test: tests/Laravel/Feature/Reviews/ReviewTenantScopingTest.php.

  • Time credits now move the correct way on "request" listings — the helper is paid, not charged. When a member posts a request listing ("I need help with …") and another member answers it through the exchange workflow, the responder does the work and so should earn the time credits, with the help-seeker paying. The credit transfer always moved credits from the exchange's requester to the listing owner regardless of listing type, so on a request listing it ran backwards: the person who provided the help was debited and the person who received it was credited (confirmed live — a 1-credit exchange moved the credit the wrong way). The transfer now picks payer and payee from the listing type — offer listings still charge the recipient and pay the listing owner; request listings charge the listing owner (help-seeker) and pay the responder. The insufficient-balance check now also applies to the correct payer. Regression test: tests/Laravel/Integration/ExchangeCreditDirectionTest.php (both directions).

  • Internal/test hardening (no user-facing change): the E2E fixture seeder now produces members who can actually log in. E2ETestDataSeeder set is_verified=1 but never set email_verified_at. The login gate keys email verification on email_verified_at (not is_verified), so on any tenant that requires email verification (the fail-closed default) a freshly-seeded secondary member (User B) was rejected at login with AUTH_EMAIL_NOT_VERIFIED — silently breaking every Playwright/journey/deploy-gate flow that authenticates as that actor on a fresh database. The seeder now also stamps email_verified_at, and a new regression test (tests/Laravel/Feature/Seeders/E2ETestDataSeederLoginTest.php) asserts seeded members pass CheckLoginGates. Dev/test only — the seeder still refuses to run in production.

  • Onboarding-generated starter listings now appear in the new member's own language instead of always English. When a community turns on "auto-create listings from onboarding skills" (draft, pending-review, or active mode), the platform writes a starter offer/request for each skill the member picked. Those titles and descriptions ("I can help with …", "Looking for help with …") were hardcoded in English, so a member who joined in French, Irish, German, etc. got English-titled listings published under their name. The copy is now rendered through the translation layer in the creating member's preferred language (all 11 supported locales), matching how a real member-authored listing would read. No effect on communities that leave auto-listing disabled (the default).

  • Smart-matching cache is being populated again — it had silently stopped at the Laravel migration. A background job that pre-computes member matches every 30 minutes was calling a method (SmartMatchingEngine::warmUpCache) that was removed when the matching engine was refactored, so it failed for every community on every run and the match_cache table — which powers the matching analytics dashboards and new-match notifications — had no writer at all and was never being filled. The cache writer has been restored (now correctly scaling the engine's score to the 0–100 range the dashboards expect, with a 7-day entry lifetime), so matching analytics/notifications work again and the scheduler stops logging a fatal error every half hour.

  • Internal hardening (no user-facing change). The frontend focused-smoke CI step is now resilient to a Vitest worker-pool hang (ERR_IPC_CHANNEL_CLOSED): it runs serially in a single forked process and is temporarily non-blocking, while TypeScript and ESLint stay blocking and the same tests still run in the full suite. The email-trigger audit matrix now also lists the GDPR data-request confirmation email, and the non-English locale files were backfilled with the new screen-reader (accessibility) strings.

  • The Privacy settings page no longer shows confusing duplicate data buttons, and the data-rights requests now send the email they promise. The privacy tab had two near-identical "download your data" buttons (an instant export link and a 30-day "access request" ticket) plus a "Request Data Deletion" button that only filed a ticket — easy to mistake for the real, immediate Delete Account action on the Security tab. Downloading your data is now a single link to the instant export page (which also covers data portability), the real Delete Account action (with typed confirmation and password re-authentication) opens directly from both the Privacy and Security tabs, and the remaining data-rights requests (rectification, restriction, objection) now actually send the confirmation email they always promised — in the recipient's preferred language.

  • Federation write requests from approved partner communities are no longer always blocked. The CORS guard that authorizes cross-community writes (Komunitin and Credit Commons endpoints) checked the wrong table using columns that don't exist, so the lookup always errored and every federation write — even from a registered, active partner — was denied with 403 Origin not in federation whitelist. It now matches the request origin against the active remote partners registered in federation_external_partners, so writes from approved partners are allowed while unknown or non-active origins stay blocked.

  • Refreshing a caring-community success story's live "municipal ROI" figure no longer errors. The live-metric refresh counted distinct care recipients against a column that doesn't exist (recipient_user_id), so every municipal-ROI story crashed with a database error the moment its figure was refreshed — regardless of which metric was requested. It now reads the correct recipient_id column, so the hourly-rate, formal-care-offset, prevention-value, hours, and recipients-supported figures all refresh correctly.

  • Bulk club-member (Verein) CSV import no longer aborts the whole batch when a row is already a member. A CSV that included someone who was already an active member of the club was being flagged as a hard validation error, so the entire import failed with "CSV contains invalid rows" and none of the valid rows were imported. Existing members are now treated as a skippable info state: they are reported as already-members in the preview, quietly skipped (and counted as "skipped") on import, and the remaining valid rows are imported as expected. Genuinely bad rows (invalid email, duplicate within the file) still block the import as before.

  • Stale prerender jobs are reaped again instead of getting stuck forever. The prerender:reap-stale maintenance command tried to write an updated_at column that the prerender_jobs table doesn't have, so every real reap threw a SQL "unknown column" error — only the dry-run preview worked. Prerender jobs whose worker died mid-render (host reboot, OOM-kill, deploy SIGTERM) therefore stayed claimed/running indefinitely, distorting queue dashboards. The command now updates only columns that exist, so stuck jobs are correctly failed (or requeued once with --requeue).

  • Federation initial-sync after a partnership is approved could never run. The background job that records the bilateral start-of-sync audit entries when two communities become federation partners crashed the instant it was created, so the audit trail and snapshot counts were silently never written. The job declared its queue with a typed property that clashes with Laravel's queue trait under PHP 8.2+, fataling at class-load time before any work could start; it now sets the queue in its constructor (matching the sibling reconciliation job) so it dispatches and runs correctly.

  • Three Regional Analytics dashboard sections that always showed "data unavailable" now work. The Demographics (age groups), Volunteer breakdown (top organisations), and Help-request analysis sections each queried a column that doesn't exist, so every request errored out. They now read the correct columns (users.date_of_birth, vol_logs.organization_id) and group help requests by contact preference, counting a request as resolved once its status reaches closed.

  • The Caring Community help-request SLA breach dashboard no longer crashes and now reports accurate turnaround. An updated date library returned signed, fractional time differences where the dashboard expected whole positive numbers. As a result it threw an error the moment any help request was pending or in progress — taking the whole SLA dashboard down — and every recently-closed request reported a turnaround of zero, so they all looked "within SLA". The dashboard now loads reliably, ages requests correctly into on-track / at-risk / breached buckets against the tenant's SLA policy, and measures real resolution turnaround. The same signed-time-difference issue was also corrected in cron run-duration logging, the job "days posted" metric (admin and accessible frontends), and the social-feed "scheduled more than a year ahead" guard.

  • Screen-reader users can now follow address suggestions as they arrow through them. The location autocomplete (used wherever you enter a place — listings, events, profile, jobs, search) showed its suggestion list visually but never told assistive technology which suggestion was highlighted, and never announced how many were found. Arrow-key navigation now moves the screen reader to the active suggestion (via aria-activedescendant) and the result count is announced in a polite live region, across all three address providers (Google Places, OpenStreetMap/Nominatim, and Ordnance Survey Places).

  • Message read-receipts and "verified" badges are now described to screen readers. The "read" vs "sent" tick on your own messages, and the verified-member badge in the members directory, were conveyed only by icon shape and colour — with the label hidden in a hover tooltip — so screen-reader users couldn't tell them apart. They now carry explicit text labels, and a member's showcased badges are announced individually instead of collapsing into one unnamed image.

  • Clickable cards can now be operated with the keyboard. Cards that act as a single button were mouse-only — keyboard and switch users couldn't reach or activate them. They are now focusable and activate with Enter or Space (cards that aren't interactive are unaffected).

  • Identity-verification errors are now announced to screen readers. The inline error shown when starting or completing identity verification is now exposed as an assertive alert, and urgent (error/warning) toast notifications announce more reliably across screen readers.

  • The Cmd+K search & command palette announces its result count to screen readers. A polite live region now reports how many results or quick actions matched as you type, so screen-reader users get the same feedback the visible list gives sighted users.

  • @mention suggestions are now proper, announceable options for screen readers. When you type @name in a post or comment, each suggestion now exposes the listbox option role and is linked to the input, so the suggestion you've arrowed to is announced — instead of the dropdown being a list assistive tech couldn't track.

  • The skill-tag and skill-search boxes can now be driven entirely by the keyboard. Adding skill tags to a listing, and searching for a skill to add to your profile, previously only let you click suggestions with a mouse. You can now arrow up/down through the suggestions and press Enter to pick one, the highlighted suggestion is announced to screen readers, and the number of matches is read out.

  • The session-timeout countdown no longer floods screen readers. The "your session is expiring" warning announced a new number every single second; it now announces at sensible intervals (every 10 seconds, then each of the final 5), so the countdown is heard clearly instead of as a stream of interruptions.

  • The accessible (GOV.UK) frontend now localises the screen-reader "Error:" prefix on six form errors. A handful of error messages (exchange request, group announcements, group file upload) hardcoded the English word "Error:" in the visually-hidden screen-reader prefix; they now use the existing translation key, so non-English screen-reader users hear it in their own language like every other form.

  • Links that open a new tab on the accessible (GOV.UK) frontend now warn screen-reader users. Twenty links that open in a new browser tab (external websites, course videos and attachments, calendar links, RSS feeds, image previews, and gallery thumbnails) now include a visually-hidden "(opens in new tab)" note, translated into all 11 supported languages — so people aren't unexpectedly taken to a new tab without warning.

Added

  • Deployments now run a real "act like a user" safety check before going live. Production deploys run the proven @smoke browser journeys against the freshly built blue/green candidate before traffic is switched to it, so a build with a broken core page (home, login, dashboard, listings, messages, wallet, feed, events, groups, admin) aborts the cutover with the live site left untouched — instead of the breakage reaching real users who then report it. The check runs only at deploy time (never on every commit, so it has no effect on CI speed), authenticates read-only so it never writes to the production database, and is enabled by setting a dedicated low-privilege test member's E2E_GATE_USER_EMAIL / E2E_GATE_USER_PASSWORD in the server .env (until both are set it safely no-ops, so it never surprise-blocks a deploy). New: playwright.deploy.config.ts, Dockerfile.e2e, and scripts/deploy/phases/candidate-journeys.sh, wired into bluegreen-deploy.sh immediately after the candidate health check.
  • New emails and notifications are now guarded against shipping in the wrong language. A CI check (scripts/check-notification-locale.mjs) blocks any background sender (service, listener, or job) that builds a member-facing email or notification without rendering it in the recipient's own language (LocaleContext::withLocale). All 104 existing senders already comply; the guard stops future ones from regressing — closing the recurring bug where queued mail silently went out in English to members who chose another language.
  • Earned the OpenSSF Best Practices passing badge. The project meets the OpenSSF Best Practices criteria at the passing level; the badge is shown in the README.
  • Greptile code review configuration now lives in the repository. Automated reviews are guided by the Project NEXUS tenant-isolation, localization, frontend, accessible frontend, security, deployment-boundary and AGPL licensing rules, with tracked context files for lower-noise review comments.
  • Documentation now covers every module and follows the Diátaxis framework. Added maintained, code-verified guides for all remaining modules (marketplace, courses, podcasts, blog & resources, social feed, AI chat, ideation & challenges, organisations, monetization, connections & reviews, identity verification) — 24 module guides in total — plus explanation docs for internationalisation (docs/I18N.md), the database and migrations (docs/DATABASE.md), and the CI pipeline (docs/CI.md), a RELEASES.md versioning policy, a "How the documentation is organised" Diátaxis index, and a markdownlint configuration.
  • A hosted documentation site and documentation CI gates. The docs now build into a searchable MkDocs Material site with an interactive Redoc API reference (deployed to GitHub Pages), and CI gained documentation quality gates: markdownlint (Markdown structure), Redocly (OpenAPI contract validity), and a MkDocs build check.

Fixed

  • Job alert subscriptions could become invisible and unmanageable. Creating a job-alert subscription did not stamp it with the member's community, so the alert could be saved against the wrong tenant — after which it never appeared in the member's alert list and could not be paused, resumed, or deleted. Alerts are now always tagged with the correct community on creation.
  • Approving an AI assistant suggestion to pair two members or route a help request now actually does it. Two caring-community AI agent actions silently failed: approving a "create a support tandem" suggestion marked it approved but never created the relationship, and approving a "route this help request to a coordinator" suggestion never updated the request. Both wrote to database columns that don't exist, so the change was discarded without an error. They now create the support relationship (with the required title and start date) and move the help request into its "matched" state as intended.
  • Renewing a listing that was about to expire no longer errors. One-click renewal of an active listing with a future expiry date was failing with a runtime error instead of extending it; renewal now correctly extends the expiry by 30 days and increments the renewal counter.
  • Marketplace moderation decisions now keep their audit trail. When an admin flagged, approved, or rejected a listing, the rejection reason, the reviewing admin, and the review timestamp were being silently dropped instead of saved — so the record of who reviewed a listing, when, and why it was rejected was lost. Those moderation details are now persisted correctly.
  • The AI assistant can now find job vacancies again. Asking the community assistant about jobs returned nothing because its job-search lookup filtered on a status value that no longer exists on the job board, so every search came back empty. It now matches open, publicly-visible vacancies correctly.

Changed

  • CI now runs the PHP test suite in parallel, cutting the slowest check from ~2 hours to roughly 15 minutes. The ~12,500-test PHPUnit suite previously ran single-threaded and was the long pole every pull request waited on. It is now split into six balanced shards that run as a matrix of parallel jobs — each with its own isolated database — while static analysis (PHPStan, syntax, skip-budget, artisan cache) runs alongside them instead of queueing behind the tests. A PHP Checks aggregate gate still reports a single pass/fail, and per-job timeouts stop a stuck run from dragging on. Fourteen pre-existing order-dependent tests that only pass in the full-suite run order (they rely on state other tests leave behind) are temporarily skipped with a tracked Quarantine [isolation-debt] marker, pending a proper test-isolation fix. No application or user-facing change.
  • The test-skip budget ratchet was tightened from 288 to 286. This internal CI guard freezes the number of schema-driven skipped tests so the count can only fall; lowering the ceiling to the current count locks in recent gains and prevents the slack from silently refilling. No runtime or user-facing impact.
  • Contributor guide now documents the APP_KEY flag required for in-container encryption tests. Running PHP tests via docker exec that exercise Crypt/encryption (e.g. the federation listener tests) failed with Unsupported cipher or incorrect key length because the container .env ships an invalid placeholder APP_KEY that phpunit.xml's <env force="true"> does not reliably override inside the container. The AGENTS.md Testing section now records that the test APP_KEY must be passed explicitly with -e APP_KEY=.... Documentation only — no test or source code changed.
  • The two backends are now named "editions" instead of "V1/V2". This PHP/Laravel platform is the Laravel Edition — the canonical, in-production backend — and the ASP.NET Core backend is the .NET Edition, an experimental next-gen build that shares the same React frontend. The README "Related Projects" section and the About-page "source code" links (across all 11 languages) were updated to the new names; the old "V1/V2" version-number framing (which wrongly implied the .NET build supersedes this one) and the "Strangler Fig / progressively replacing the PHP API" wording were dropped. Documentation and UI-label change only — no application, API, or runtime behaviour changes.

Back to all releases