---
title: Changelog
description: Every notable change shipped in Project NEXUS, in Keep-a-Changelog format, following Semantic Versioning. Sourced from CHANGELOG.md in the public source repository.
canonical: https://hour-timebank.ie/changelog
generated: 2026-06-17T14:51:01.440Z
---# Changelog

Generally Available (v1.5.2)

Every notable change to Project NEXUS, in Keep-a-Changelog format. The platform follows Semantic Versioning.

[View on GitHub](https://github.com/jasperfordesq-ai/nexus-v1/blob/main/CHANGELOG.md)

# Changelog

All notable changes to Project NEXUS will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) , and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) .

---

## Unreleased

### Fixed

- Recurring events now show their cover image on every date, not just one. When you create a repeating event with a photo, the photo now appears on all of its dates. Previously it only attached to a single date in the series (which showed up last in the list), leaving the rest with no image.

### Changed

- The Events page now shows one card per repeating series instead of every single date. A repeating event now appears once — as its next upcoming date — with a "Repeats weekly · N dates" label, so the page no longer fills up with the same event over and over. Open the event to see the full list of its upcoming dates.
- Clearer limit when choosing how many times an event repeats. The "number of occurrences" field now states the allowed range (2–52). Entering a number outside it shows a clear error, flags the field in red and jumps you straight to it, instead of the form silently doing nothing.

### Changed

- Accessibility and visual polish across every page of the accessible site. A platform-wide pass to the GOV.UK Design System standard: status labels are now colour-coded by state (so an approved application looks different from a declined one at a glance), the keyboard now jumps straight to errors and confirmations, the current section is announced to screen readers, images reserve their space so the page doesn't jump as it loads, and the sign-out control and message-count badge are cleaner and fully keyboard-accessible.

### Changed

- A clearer Polls page on the accessible site. Polls are now split into "Open polls" (which you can vote on) and "Closed polls" (results), with a short note explaining that you vote once and results stay hidden until a poll closes to keep things fair. Open polls show their closing date; closed polls show each option's share of the vote with the leading option and your own choice clearly flagged.
- A clearer, less crowded header on the accessible site. Your personal things now live together in a top-right "My account" area: a single "My account" link opens a hub for your wallet, messages, connections, matches, group exchanges, achievements, leaderboard, NEXUS score, profile and settings (with your unread-message count shown alongside). The main navigation bar is now a lean community bar — Dashboard, Feed, Listings, Members, Events, Volunteering and Explore — with Exchanges and Polls moved onto the Explore page alongside the other discovery features, so the bar stays uncluttered as more is added.

### Added

- An Explore area on the accessible site: search, groups, goals, a skills directory and organisations. A new Explore link in the main menu gathers the community's discovery features in one tidy place: a global search across members, listings, events and groups; groups you can browse and join or leave; goals you can set and track with a progress bar; a skills directory to find members offering a particular skill; and an organisations directory where you can also register a new organisation for approval.
- Do more with your goals on the accessible site. Goals went from a simple list to a full feature: you can now edit a goal's name, target, deadline, check-in reminders and whether it's shared, or delete it behind a clear warning. Each goal page shows a progress timeline of what's happened so far (created, progress updates, milestones reached, completed). You can start a goal from a ready-made template — pick one, give it your own name if you like, and it's set up for you. And there's a new buddy system: offer to support another member's public goal to help keep them on track, and see all the goals you're buddying in one place. Private goals stay private — only the owner (and their buddy) can see them.
- Edit and delete your own messages on the accessible site. In a conversation you can now correct a message you sent — within 24 hours of sending it — or remove one, choosing whether to delete it just for yourself or for both people. Messages that have been changed are marked "Edited", and deleted ones show a short placeholder. This matches what the main app allows.
- Confirm your email, unsubscribe, and review members straight from the accessible site. The links in your verification and newsletter emails now open proper pages on the accessible site: one confirms your email address and sends you on to sign in; the other unsubscribes you from newsletters (you will still receive essential account and security emails). And the reviews page now lets you leave a star rating and a few words for someone directly from your "still to review" list, instead of sending you elsewhere.
- Event waitlists, event polls and recurring-series dates on the accessible site. When an event is full you can now join its waitlist and see your place in the queue (or leave it again). Events with an attached poll let you vote, with the running totals kept hidden until the poll closes so voting stays fair. And an event that is part of a repeating series now lists its other dates, with the one you are looking at clearly marked, so you can move between them.
- Volunteer certificates, shift waitlists and shift swaps on the accessible site. You can now download a certificate for the volunteering hours you have given; join or leave the waitlist for a shift that is already full; and request to swap one of your shifts with another volunteer, accepting or declining swap requests that others send you.
- Richer organisation pages on the accessible site. An organisation's page now shows its open volunteering opportunities — which you can apply to right there — alongside the reviews members have left it and a short summary of its impact: how many volunteers it has, the total hours given and its average rating.
- Choose how often you get an activity digest on the accessible site. Your notification settings now include an "activity digest" choice — off, as it happens, daily or monthly — so you control how often the community emails you a round-up, matching the option in the main app.
- Subscribe to the community blog on the accessible site. The blog now has a standard RSS feed, so you can follow new posts in any feed reader.
- Block a member on the accessible site. You can now block someone from their profile — once blocked, they can no longer see your profile or contact you, and any connection between you is removed. A new "Blocked members" page in your settings shows everyone you have blocked and lets you unblock them.
- Turn on authenticator-app sign-in on the accessible site. Your security settings now let you set up two-step verification with an authenticator app — scan a QR code (or type the key in by hand), confirm with a 6-digit code, and save your one-time backup codes in case you lose your phone. You can turn it off again at any time with your password, and you will get an email whenever it is switched on or off.
- Filter listings and events by distance on the accessible site. Both pages now have a "Distance" option — within 5, 10, 25 or 50 km — that uses the location saved on your profile to show only what is near you. No map or location pop-up needed; if you have not set your location yet, the page tells you how.
- React to posts, share and save them on the accessible feed. You can now react to a post or a comment (like, love or celebrate), share a post to your own feed, and save a post to read later — saved posts show up under the feed's "Saved" filter. Every post also has its own page now, so links to a post from a notification or email open the right place.
- Donate time credits and see your community fund on the accessible site. Your wallet now lets you donate credits to the community fund (or to another member), shows the community fund's balance, and lets you filter your transaction history (all, earned, spent), page through older entries, and download it as a CSV file.
- Group pages now show events and a group feed on the accessible site. A group's page now lists that group's upcoming events and — for members — shows the group's posts and lets you add your own, so each community group is a place to talk and not just a member list.
- Partner communities are now a real, browsable network on the accessible site. Federation went from a single read-only list to a full area: a hub with your network stats and an opt-in prompt, opt-in/opt-out and privacy settings you control, a page for each partner community, and the ability to browse members, listings and events across the communities yours is connected to.
- Connect, message and share hours with partner communities on the accessible site. The partner-communities area is now fully interactive: you can send and respond to connection requests with members of communities yours is linked to, exchange private messages across communities, and send some of your time credits to a member of a partner community. Everything respects the same privacy choices and safeguards as the main app — you only appear to, and can only reach, members who have opted in — and each alert is sent in the recipient's own language.
- A guided sign-up on the accessible site that helps keep everyone safe. New members now get a short step-by-step setup — add a photo and a few words about yourself, pick your interests, say what skills you can offer and what help you're looking for, answer any optional safeguarding questions your community asks, then confirm. The safeguarding step is fully private and opt-in (nothing is ticked for you, and you can choose "none of these apply"), and it's wired straight into the same protections the main app uses. You can change any of it later from your profile.
- A fuller notifications inbox on the accessible site — and the emails that go with your actions. Your notifications page now tags each update by kind (message, connection, event, credits, safeguarding and so on), marks an unread item read with one click, links straight through to whatever it's about, and lets you clear them all at once. And the things you do on the accessible site now send the same alerts the main app does: applying for an opportunity emails you a confirmation and alerts the employer; RSVPing, applying to volunteer, signing up for a shift or editing an event now notifies the organiser and everyone affected — each in the recipient's own language.
- A news & blog area on the accessible site, now in Explore. The community blog now appears in Explore, shows each post's publish date, links the author to their profile, and — for signed-in members — lets you read and post comments on an article. Posts also carry proper search-engine and social-share information.
- Edit and delete your own listings on the accessible site. A listing's owner now sees an Edit listing button on its page, opening a pre-filled form to change the title, description, category, hours, delivery method, location and photo — with the same field-by-field validation as creating one. The edit page also lets you delete the listing, behind a clear warning. Previously the accessible site could create listings but never change or remove them.
- Opportunities, ideas, resources and saved items on the accessible site. Four more areas, reached from Explore or your "My account": an Opportunities board listing the community's roles and volunteer openings — each tagged by type (volunteer, paid or time-credit) with its closing date — that you can open and apply to with an optional note; an Ideas area where you can read the community's challenges, submit your own idea and vote on others'; a Resources library of shared guides and materials you can search and download; and a Saved items list gathering everything you've bookmarked.
- Marketplace, courses, podcasts and more on the accessible site. When your community has them switched on, the accessible site now offers: a Marketplace to browse items for sale, swap or free (with photos, prices and a full item page); Courses you can browse and enrol on, paying with time credits where a course has a cost (with a clear message if you do not have enough); Podcasts with an episode list you can play in the page; a Coupons page of local merchant offers; a Premium page to subscribe and support your community; a Clubs directory; and a Partner communities page showing the communities yours is federated with. Each appears in Explore only when your community has the feature enabled.
- More of the platform on the accessible site: notifications, your activity, a reviews page, and Features/FAQ. A notifications inbox (in My account) lists your updates with an all/unread filter, mark-all-as-read and delete; an activity page summarises your hours given and received, connections, a month-by-month chart and recent activity; a reviews page gathers the reviews you've received and given plus any you still need to write; and plain-language Features and FAQ pages explain how the community works.
- Achievements, Leaderboard and NEXUS score on the accessible site. Three new pages, reached from your "My account" area: Achievements shows your level, experience and the badges you've earned (with a nudge towards the ones you're close to unlocking); the Leaderboard ranks members by a metric you choose — time credits, volunteer hours, badges and more — over all time, this month or this week, with your own place highlighted; and your NEXUS score shows your community reputation out of 1000, how it breaks down across engagement, quality, volunteering, activity, badges and impact, and tips on how to improve. All HTML-first and accessible.
- A polls page on the accessible site. A new Polls page (in the main navigation) lists the community's polls so you can vote and see results in one place, instead of only coming across them in the feed. While a poll is open the running totals stay hidden to keep voting fair; once it closes, each option shows its share of the vote with your own choice highlighted.
- Group exchanges on the accessible site. A new Group exchanges section (in the main navigation) lets a member organise an exchange of time between several people at once: create one with a title and a total number of hours, add people as either giving or receiving time, set how the hours are shared (equally or set per person), and see who has confirmed. Once everyone has confirmed, the organiser completes it and the time credits move between everyone automatically. Each person confirms their own part, and only the organiser can add or remove people, complete or cancel.
- See your matches on the accessible site. A new Matches page (in the main navigation) shows members whose offers and requests fit yours, ranked by how well they match, with the reasons for each match and a link to their listing — powered by the same matching engine as the main app. Previously the accessible site could save your match preferences but never showed you any matches.
- A "How timebanking works" guide on the accessible site. A plain-language page explaining the idea — give an hour, earn a credit, spend it on help from anyone — with the principle that everyone's hour is equal, and clear next steps. Linked from the community home page and open to everyone, including people not yet signed up.
- A connections inbox on the accessible site. A new Connections page (in your "My account" area) gathers everything in one place: requests waiting for your response (with Accept and Decline), the members you're already connected with (with Remove), and requests you've sent that are still pending (with Cancel) — each linking through to the member's profile. Previously you could only send a connection request from someone's profile, with nowhere to manage incoming ones.
- Type-ahead search for sending credits. The wallet's recipient search now offers suggestions as you type — each showing the member's location and how long they've been a member, so you can pick the right person (even two members called "Mary") without leaving the box. It's a progressive enhancement: with no JavaScript, the original search-and-choose list still works exactly as before.
- Tell apart members with the same name when sending credits. The wallet's recipient search now shows each person's location and how long they've been a member (e.g. "Cork · Member since March 2024") beneath their name, so two members called "Mary" are no longer indistinguishable. Works with no JavaScript.
- A time wallet on the accessible site. Signed-in members now have a Wallet page showing their time-credit balance, total hours earned and spent, and a full history of who they exchanged with and for what. You can also send credits to another member of your community: search for them by name, choose an amount, add an optional note, and send — with clear errors if you don't have enough credits or pick someone outside your community. Wallet now appears in the main navigation and on the home page.
- Manage your own events on the accessible site. An event's organiser can now edit it (with the same field-by-field validation as creating), cancel it with an optional reason (which notifies everyone who RSVP'd), or delete it — each with a clear confirmation step.
- A "For you" tab on the accessible volunteering page. Signed-in members now see volunteer shifts recommended for them — matched to their skills and availability, with a match score, the organisation, location, time and spaces left.
- Sort listings by Newest or Recommended on the accessible site. The listings page now has a "Sort by" control, with Recommended surfacing featured listings first.
- See who's going to an event on the accessible site, with clearer create errors. An event page now lists the people going and interested (with their photos), and the "Create an event" form now highlights each field that needs fixing with a message beside it, instead of one vague error.

### Fixed

- Maps are now OFF by default for every community — turn them on per-community when you want them. Previously maps defaulted to ON, so any community that hadn't been explicitly configured kept showing maps even after you'd disabled them. Only hOUR Timebank had ever been set to off in the database, which is exactly why it behaved differently from every other community. Maps are now uniformly off across the whole platform and only appear on a community when a super-admin explicitly switches them on for it — so no community silently differs from another.
- Maps can be turned on or off per community again, and the map provider is switchable. The admin "Maps & location" controls were locked, so the maps on/off switch only ever took effect on one community and nobody could change the map provider. Super-admins can now toggle maps for each community individually — turning it off reliably removes every map across listings, members, events, groups and the marketplace — and can choose a different map provider (Google, OpenStreetMap or Ordnance Survey) per community.
- Google maps now show their pins even when no Map ID is configured. Google's newer "advanced" map pins silently render nothing unless a Google Map ID is set, which made maps look empty or broken. Pins now fall back to classic markers when no Map ID is present, so locations always appear; and when a map genuinely can't load (for example a billing or key problem) a clear "map view is not available" message is shown instead of a blank gap.
- The accessible home page no longer mislabels sign-in-only sections as "not enabled". When you're signed out, sections that simply need you to log in (Dashboard, My Profile, Messages, Exchanges) now say "Sign in to use this" and link to the login page, instead of wrongly saying "This module is not enabled for this community". Sections the community has genuinely turned off still say so.
- Skill endorsements now work again across the whole platform. Endorsing a member's skill was silently failing with a server error (the endorsements table has no "updated at" column, but the code tried to write one). Endorsing and un-endorsing now work everywhere.

### Added

- Endorse a member's skills on the accessible site. A member's profile on the accessibility-first site now shows how many endorsements each of their skills has, and lets you endorse (or remove your endorsement of) any skill.
- The accessible community home page now shows live stats and the community's tagline. The landing page displays the community's own one-line tagline and live totals (members, hours exchanged, active listings and communities), matching the main site.
- A more visual accessible dashboard. The dashboard's recent-activity and recent-listings cards now show author photos and images, and the quick links now include Messages and Members.
- More detail on the accessible volunteering pages. Your applications now show the organiser's note when one was added on an approved or declined application, and your hours page now shows a progress bar towards your next round-number hours goal.
- Listing pages on the accessible site now show expiry and renewal details. A listing's page now tells you when it expires (or expired), and how many times it has been renewed, when that information applies.
- Manage your own feed posts and comments on the accessible site. You can now reply to a comment, edit or delete your own posts, and edit or delete your own comments — each with a plain confirmation step for deletions, and all working without JavaScript. Previously replies could be read but not written, and you could not change or remove anything you posted.
- Richer feed cards on the accessible site. Posts and comments in the feed now show the author's profile photo, and feed cards for events and volunteer opportunities now link through to their full page (previously only listings did, leaving other cards as dead ends).
- Friendlier messages on the accessible site. The Messages page now has a "Message a member" button (so you can start a brand-new conversation, not only reply to existing ones), shows each person's profile photo next to their conversation, and shows photos next to each message in a thread.
- Change language anywhere on the accessible site — and have it stick. Every page of the accessibility-first site now has a language chooser in the header offering all 11 supported languages, and your choice now actually takes effect and is remembered as you move between pages (previously changing language had no effect at all). Arabic now also displays right-to-left. The Messages link in the navigation shows a count of your unread messages.
- Clearer exchange pages on the accessible site. A time-bank exchange now explains its current status in plain English (for example, "Waiting for the service provider to accept or decline this request"), shows the star ratings and comments both members have left once it's completed, and notes who made each change in the activity timeline.
- Add a cover image when creating an event on the accessible site. The "Create an event" page now lets you upload an optional cover image (JPG, PNG, GIF or WEBP, up to 8MB), which appears at the top of the event page — matching the cover image the listing form already offered and the event page already displayed. Previously an event created from the accessible site could never have an image.
- Connect with other members on the accessible site. A member's profile on the accessibility-first site now has a "Connect" button. You can send a connection request, see when one is pending, cancel a request you sent, accept or decline a request someone sent you, and remove an existing connection — each with a clear confirmation message. Previously connections could not be managed from the accessible site at all.
- Vote in polls on the accessible feed. Polls in the feed on the accessibility-first site can now be voted on — pick an option and submit (no JavaScript needed). Once you've voted, or once a poll closes, you see the results, and you can't vote twice.
- Rate an exchange after it's completed on the accessible site. Once an exchange is finished, its page now invites you to leave a star rating (1–5) and an optional comment, and shows a thank-you once you've rated. If an exchange ends up disputed (because the two members confirmed different hours), the page now explains clearly what's happening.
- More context when requesting an exchange on the accessible site. The "Request an exchange" page now shows a fuller summary of the listing (including the time estimate and who posted it) and your current time-credit balance, so you can see what the exchange is likely to cost before you send the request.
- Message a member and see their badges on the accessible site. A member's profile on the accessible site now has a "Send message" button (so you can start a conversation directly, not only from a listing) and shows the badges that member has earned.
- A fuller accessible dashboard: time-bank balance, progress and upcoming events. The accessible dashboard now shows your time-credit balance (the core figure, previously missing entirely), your level, XP and progress towards the next level with a progress bar, the badges you've earned, and a list of upcoming community events — alongside the existing recent activity and quick links.
- Communities can now upload their own header logo. A community admin can upload a custom logo (PNG, JPEG, WebP, GIF or SVG, up to 2 MB) under Settings → Header Logo, and it replaces the default initials/name in the site header — on both the main app and the accessible site. You can provide a separate dark-mode version for viewers using dark themes (the main app swaps automatically; the accessible site, which has a dark header, uses the dark version when supplied), and remove either at any time to revert to the default. Uploaded SVGs are sanitised on the server so they are safe to display.
- Communities can now set their own header colour on the accessible site. A community admin can choose the background colour of the accessibility-first site's header — and the colour of the thin line beneath it — under Settings → Accessible header colour, with a live preview. Leave it unset to keep the standard black header with the blue line; pick a brand colour (for example a council's blue) to match your community. The header text automatically switches between white and dark to stay readable on whatever colour is chosen, and if you set a background but no line colour, the line takes the background colour so two near-but-different shades never clash.
- Change your email, password and language on the accessible site. The accessible "Profile settings" page gained a "Sign-in and security" section to change your email address (confirmed with your current password) and your password (with the same length, breach-reuse protections as the main site), plus a "Language" section to switch the interface to any of the 11 supported languages. Previously none of these could be changed from the accessible site.
- Two-factor authentication now works on the accessible site. If your account has two-factor authentication turned on, signing in now takes you to a page to enter your authenticator code (or a backup code) instead of stopping with an unusable message. Previously two-factor sign-in was a dead end on the accessible site.
- You can now reset a forgotten password from the accessible site. The accessible sign-in page now has a "Forgot your password?" link that takes you to a page where you can request a reset link by email, plus a "Choose a new password" page for setting it — with clear inline guidance (minimum length, breached-password and reuse checks) and a friendly message when a link has expired. Previously a locked-out member had no way to recover their account from the accessible site.
- You can now manage volunteer applications on the accessible site. The volunteering "My applications" tab now lets you filter by status (pending, approved, declined, withdrawn), page through a longer history, and withdraw a pending application — none of which was possible before.
- Volunteer hours breakdown and organisation logos on the accessible site. The volunteering hours page now shows your approved hours broken down by organisation and by month, and a volunteer opportunity now shows the organisation's logo.
- Sign up for and cancel volunteer shifts on the accessible site. If your application to an opportunity has been approved, each available shift now has a "Sign up for this shift" button (and a "Cancel signup" once you're on it), all without JavaScript.
- Record your accessibility needs for volunteering on the accessible site. A new "Manage your accessibility needs" page lets you tell organisers which categories of need apply (mobility, visual, hearing, cognitive, dietary, language, other), describe them and the adjustments that would help, and add an emergency contact — all controlled by you and removable at any time.
- You can now load older messages in a conversation on the accessible site. Long conversations previously only showed the most recent messages with no way to reach earlier ones; there's now a "Show older messages" control that works without JavaScript.
- You can now post a listing on the accessible site. The accessibility-first version of the platform gained a "Create a listing" page, so members can offer a skill or ask for help without switching to the main site (previously you could only browse and request exchanges). It's a clear, standard form — offer or request, title, description, category, estimated hours, how it's delivered (in person, remote, or either), location, and an optional photo — with proper inline error messages that link to the field that needs fixing, and it keeps what you typed if something needs correcting.
- Event photos and the join-online link now show on the accessible site. Events on the accessibility-first version of the platform now display their cover photo — a thumbnail in the events list and a large cover image on the event's own page (with a proper text description for screen-reader users, and nothing shown when there's no photo). Online events now also show their "Join online" link, and shared event links preview with the event's own photo.
- Listing photos now show on the accessible site. Listings on the accessibility-first version of the platform now display their photos — a thumbnail on each listing in the browse list, and a large cover photo plus a photo gallery on the listing's own page. Every photo has a proper text description for screen-reader users, and listings without a photo simply show no image rather than a broken one. The listing page also now shows the delivery method (in person, remote, or both), a "Featured" badge where relevant, the listing's status, any skills, and a richer "About the member" panel with the member's photo, rating, reviews and completed-exchange counts, and a link to their profile. Shared listing links now preview with the listing's own photo.
- You can now add a profile photo on the accessible site. The accessibility-first version of the platform gained a photo upload on the Edit-your-profile page — pick a JPG, PNG, GIF or WEBP and it's automatically cropped to a neat 400×400 square, with an option to remove it again. It uses the same image pipeline (and the same size and safety checks) as the main site.
- You can now attach a photo to a post on the accessible site. The accessible feed's "Write a post" box now takes an optional photo, with its own field to describe the image for people using a screen reader.
- Privacy and data controls on the accessible site (GDPR). The accessible Edit-your-profile page now has a "Your data and privacy" section: choose whether to receive newsletters, request a downloadable copy of your personal data, and delete your account through a clear, password-confirmed step. These use exactly the same data-request and erasure process as the main site.
- A full footer — and all the pages it links to — on the accessible site. The accessibility-first site now has a complete footer, organised into Platform, Support and Legal columns that mirror the links on the main site, along with the open-source licence notice and a link to the source code. Every page those links point to now exists as a clear, standard page: an About page (how it works, our values, live community statistics and credits), a Legal hub, Terms of service, Privacy policy, Cookie policy, Community guidelines and Acceptable use (showing the same community-managed wording as the main site, with a plain-English fallback when none has been published), a single Accessibility statement covering WCAG 2.2 AA, a Trust & safety page, a Help centre, a Knowledge base, and a Blog. Previously the accessible site had only a minimal footer and none of these pages.

### Changed

- The accessible version of the platform has moved from Alpha to Beta. Having been tested and rounded out, the accessibility-first site now shows a "Beta" label instead of "Alpha", and the remaining "alpha" wording has been removed.

### Fixed

- Clearer registration errors on the accessible site. When something is wrong while signing up — a mismatched password, an email that isn't accepted, a missing invite code, or unticked terms — the accessible registration form now highlights the specific field and shows the message next to it, as well as in the summary at the top, instead of only a single summary message.
- Safer sign-out on the accessible site. Signing out is now a proper protected button rather than a plain link, so it can't be triggered unintentionally.
- Clearer exchange and volunteering statuses on the accessible site. Status labels now always show readable text (never an internal code), and exchange statuses are colour-coded so their state is easier to scan at a glance.

---

## 1.5.2 - 2026-06-13

### Added

- The admin Impact Report can now calculate a real, defensible SROI ratio. Until now the page's "SROI Ratio" was simply the configured multiplier echoed back (×3.5 always showed "3.5:1") — not a return on investment at all. The page now has a proper Social Return on Investment section following the international SROI methodology used in the 2023 Timebank Ireland study: you enter your total investment and your verified outcomes (each valued with a financial proxy, e.g. from the HACT Social Value Bank), and the platform applies the standard deductions for deadweight, displacement and attribution, projects future years with drop-off and discounting, and divides by your investment. Every coefficient used is shown alongside the result so the figure is auditable. A one-click template pre-loads the four Timebank Ireland outcome categories with their calibrated proxy values. The calculation engine reproduces the published TBI study to the euro (€50,000 in → €803,184 of social value → 16.06:1), and that check is now a permanent automated test. The old hours-based figures remain, relabelled honestly as "Exchange Activity Value" with a "Social Value Multiplier".
- Automatic sign-out after a period of inactivity. Communities can now set an inactivity timeout, so a member is signed out automatically after a chosen idle period — useful on shared, public, or kiosk computers. It stays off unless a community turns it on.
- Recent passwords can no longer be reused. When you change or reset your password, the platform now remembers your recent passwords and won't let you set one you've used lately — nudging everyone toward a genuinely fresh, stronger password.
- UK address lookup and Ordnance Survey maps. Communities can now opt into Ordnance Survey for two things: precise UK address autocomplete (type a postcode or street and pick a UPRN-backed address) and OS Maps basemap tiles as an alternative to the default map provider. Both are configured per community, the OS key never reaches the browser, and each falls back gracefully when no key is set.
- More admin reporting tools. Admins can now export reports as Excel (.xlsx) files in addition to CSV, download a community-wide audit log as CSV, and find people faster with a new smart member-search panel.
- A "Report a problem" button on every page. Signed-in members get a floating reporter to send feedback or flag an issue without leaving the page they're on.
- Mobile app: more of the platform, natively. The mobile app gained native Polls and Connections workflows and advanced filters on the Exchanges screen, bringing it closer to full parity with the web app.

### Fixed

- Impact figures no longer count system credits as exchanged hours. Starting balances, admin credit grants, community-fund movements and credit gifts were all being counted as "hours exchanged" and monetised as social impact, across the Impact Report page, the CSV export, and community-health metrics (a new member's starting balance even counted them as an "active trader" and marked them "activated"). Only genuine completed service exchanges count now.
- The Impact Report no longer counts pending or cancelled exchanges. One of the page's two data sources included transactions in any state; only completed exchanges count now.
- Impact date filters now include the final day. Filtering "to" a date used to silently exclude everything after midnight of that day.
- The two customisable footer logos no longer bleed off the edge of the page. The community-set partner logo (left) and "Powered By" image (right) used a fixed size, so a wide logo could overflow its column and spill past the edge of the screen as the window narrowed. Both images now scale down to fit their column at any width.
- The community emergency-alert banner no longer logs errors when a check is interrupted. The banner checks for new emergency alerts every 30 seconds; if a check failed or was interrupted (for example while you were still signing in) it logged an error and could briefly clear the banner. A failed check now quietly keeps the alerts already on screen.
- The admin "active members" report shows real data again. Members' last-sign-in times stopped being recorded after a back-end migration, so reports of recently-active members came up empty. Sign-in times are recorded again (and backfilled from existing sessions), so activity reports are accurate.
- Guardian (parental) consent for young volunteers is now a complete, enforced feature. Members under 18 can no longer apply for a volunteering opportunity, sign up for a shift, or join a shift waitlist until a parent or guardian has approved. When an under-18 member tries, a friendly dialog asks for their guardian's name, email and relationship, and emails the guardian a secure approval link. The guardian lands on a dedicated approval page and confirms with one tap (the page deliberately requires that tap — automated email scanners that pre-open links can't accidentally grant consent). Once approved, the young person can volunteer immediately; admins see the consent (with dates and expiry warnings) on the existing Guardian Consents screen. Adults and members who haven't given a date of birth are completely unaffected. Fully translated across all 11 languages and verified end-to-end in the browser.

### Fixed

- Approving a new member now actually lets them sign in. On communities where new sign-ups need admin approval, clicking "Approve" sent the welcome email (with its time-credit bonus) but left the account itself locked — the member still saw "pending admin approval" every time they tried to sign in, with no hint anything was wrong. Approval (single or bulk) now fully unlocks the account, and re-approving anyone stuck in that half-approved state repairs them without sending duplicate welcome credits.
- The stray "Sign in with a passkey" Windows prompt is gone. Opening the sign-in page started a passkey request in the background that was never cleaned up — it outlived the page, stacked up again on every visit, and fired twice per page load. On some Windows machines this could surface the system passkey dialog out of nowhere, even for accounts with no passkey set up. The sign-in page now makes exactly one silent request per visit (passkey suggestions only ever appear in the email field's autofill dropdown), cancels it the moment you leave the page, and never opens a dialog unless you press the "Sign in with a passkey" button yourself.
- Email verification links no longer hang when many sign-ups are pending. Clicking the "verify your email" link could spin for over half a minute and time out whenever a community had a backlog of unverified registrations — the system was checking the link against every outstanding verification token one by one. It now finds the right token instantly, however many sign-ups are waiting. Existing links keep working.
- The "resend stuck activation emails" admin tool now works through the whole backlog. Running it repeatedly used to email the same oldest batch of people again and again and never reach anyone beyond the first batch. Each run now skips people who already received their email and moves on to the next group.
- The community name no longer vanishes from the web address on the Events page. Opening Events (or filtering it) silently rewrote the address from /your-community/events to just /events — so refreshing the page or sharing that link could land in the wrong community (the platform's default one) instead of yours. The address now keeps your community's name at all times.
- Screen readers now announce the reaction-filter tabs properly. The tab list in the "who reacted to this post" dialog was reading out a raw internal code instead of "Reaction type filter", in every language.
- Downloading a CV attached to a job application works again. Opening an applicant's CV (as the applicant, the job poster, or an admin) always failed with a server error — the download response was built in a way the system rejected at the very last step, so the error pages worked but the actual file never arrived. CVs now download correctly, byte-for-byte.
- Small images attached to feed posts no longer show a broken thumbnail. For images already small enough to not need a separate thumbnail, the post still pointed its thumbnail at a file that was never created — so feeds showed a broken image for every small picture. Small images now serve as their own thumbnail.
- Daily and monthly community digests now arrive on schedule. The civic digest's "have I already sent this recently?" check was slightly too strict, so daily digest readers were silently skipped every other day, and the monthly digest skipped entire months (every March, plus most months with 30 days). The timing check now has a sensible margin.
- A big newsletter send can no longer delay the day's other scheduled jobs. Sending a large newsletter used to occupy the scheduler until the whole send finished, which could silently skip that day's daily digests, reminders, and the midnight leaderboard snapshot. Newsletter sending now works in timed slices, finishing across the next minutes without blocking anything.
- Login-streak badges (Week Warrior, Monthly Dedication, etc.) are now actually awarded. The nightly badge job was looking up badge names that don't exist, so it had never handed out a single streak badge; it also only matched people on the exact milestone day, permanently skipping anyone it missed once. Both fixed — anyone whose streak already passed a milestone receives the badge on the next nightly run.
- Voice messages work again. Sending a voice message had been failing with an "upload failed" error for everyone since late March — the recording uploaded fine, but saving the message itself was rejected due to a missing database field. The field is now added and voice messages send, play, and notify correctly.
- Your match-notification preferences are now actually respected — and mutual-match alerts are back. Saved matching preferences (how often to be notified, opting out) were being read back as the defaults, and the "you have a mutual match" notification had been silently broken since March. Both fixed.
- AI assistant feedback and usage metrics work again. Giving a thumbs-up/down on an assistant reply failed behind the scenes (the button appeared to "not stick"), and the admin AI metrics page wouldn't load — the table recording assistant activity was never created in production. It's created automatically on the next release.
- Several admin actions that silently failed or corrupted records now work correctly: approving a flagged event no longer makes the event vanish from listings (it was saved with a broken status); approving an AI-proposed care tandem no longer creates an invisible pairing; the blog "bulk publish" button actually publishes; federation neighbourhoods can have communities added/removed and can be deleted; a failed transfer to a partner community is now recorded as "cancelled" rather than a corrupt blank status; and job application status history is recorded again (its viewing pages had also crashed, and the jobs-data erasure request it blocked now completes).
- Email polish across all languages. Membership-dues, federation-invitation and new-community welcome emails no longer show raw placeholder text like "{organization}" instead of the real name; names with apostrophes (O'Brien) no longer appear garbled in subject lines and info boxes; password-reset and verification emails no longer greet you with a doubled "Hi Hi John,,"; the "connection declined" email's button now links to the right website; time-credit emails say "hours" in your own language instead of always English; an admin-created account's emailed starting password can no longer be displayed corrupted; and a single malformed bounce report can no longer make the email-status feed double-count bounces.
- Repeating volunteer shifts now appear on the right days. Two scheduling faults meant a shift set to repeat "monthly" would instead be created every single day once its start date had passed, and a shift set to repeat "every two weeks" fired on the wrong weeks. Both now follow the schedule that was actually set up.
- Monthly repeating events no longer slip off their date. An event set to repeat monthly on the 29th, 30th or 31st used to drift to the 1st of the wrong month after a few repeats (and could skip a month entirely). The series now stays anchored to its day of the month, moving to the last day in shorter months.
- The monthly leaderboard season no longer disappears on its final day — and rewards can't be multiplied. On the last day of each month the "current season" looked missing, and every visit to the leaderboard quietly created a duplicate season behind the scenes; at month-end the nightly results job then handed out the season's prize XP once per duplicate. (This genuinely happened in March — top members received the season rewards six times over.) The season now stays visible to the end and duplicates can no longer be created.
- Partner-network credit transfers are now protected against double-processing. In the Credit Commons federation protocol (used to exchange credits with partner networks), a transfer confirmation or cancellation delivered twice at the same moment could move the credits twice; a repeated transfer proposal also piled up duplicate pending entries. All of these now detect the repeat and process the credits exactly once. No NEXUS community exchanges credits over this protocol yet, so no real balances were affected — fixed before first use.
- Card payments no longer get stuck "retrying" when a confirmation email can't be delivered. If a donor's, buyer's, or premium member's email address had previously bounced, the payment itself went through fine but the system treated the undeliverable confirmation email as a payment-processing failure — causing the payment provider to retry the same notification for up to three days and risking the whole payment-notification channel being suspended. Undeliverable confirmation emails are now simply logged; the payment records were always correct and are untouched.
- Profile changes now reach member search reliably. When a member joined, updated their profile, or left, the background task that updates the search index was being placed in a work queue that no worker was ever watching — so those updates silently piled up and search results could show stale or missing members until a full manual re-sync. The queue is now properly watched, and the stuck updates will be processed automatically on the next release.
- Account erasure now also removes identity and compliance copies. Following up on this week's erasure work: background-check records (DBS/Garda vetting references and uploaded documents), insurance certificates (policy numbers and certificate files), and identity-verification session results are now deleted when an account is erased — previously they survived because the database's automatic clean-up never triggers (accounts are anonymised rather than deleted). Safeguarding reports remain deliberately retained (legal duty).
- GDPR erasure messages to partner timebanks are no longer fire-and-forget. When a member of a federated community deletes their account, the "please erase this person's mirrored profile" message to each partner timebank used to be attempted exactly once — if the partner's server happened to be down, the request was silently lost. It now retries automatically (after 5 minutes, 30 minutes, then 2 hours) and raises a loud operator alert if it still can't get through.
- A marketplace order can no longer end up both refunded and paid out. If a refund and a payout release for the same escrowed order happened at the same moment (for example an admin refund racing the automatic release timer), the refund could overwrite the already-completed payout — leaving the buyer refunded and the seller paid for one order. Exactly one of the two outcomes now wins, and the loser is told the order has already moved on.
- Submitting the same review twice at once no longer counts it twice. A double-click (or a flaky mobile connection retrying) on the review submit button could create two identical reviews — double-counting the star rating and awarding the reviewer double points. The database now enforces one review per exchange, and the duplicate attempt gets the normal "already reviewed" message.
- Double-tapping "Apply" on a volunteering opportunity no longer creates two applications. Two identical applications submitted at the same instant could both go through, showing the volunteer twice in the organiser's list and taking up two spots on a shift. The duplicate is now rejected.
- Leaving partner timebanks no longer leave their content behind. When a federation partner is removed, all of the listings, members, events, groups and volunteering entries imported from that partner are now cleaned up, and imported volunteer opportunities are deactivated (kept for history, hidden from browsing). Message history and the credit ledger are deliberately retained.
- Single sign-on (SSO) engine — sign in with your organisation's account. Communities can now plug in any standards-based (OpenID Connect) identity provider — Microsoft Entra ID for councils and workplaces, Hivebrite, Google Workspace and others — as configuration, with no code change. Members of the connected organisation see a "Sign in with …" button on the login and registration screens and use their existing work account; no separate password to manage. Administrators get a new Admin → Single Sign-On page to add providers (with a Microsoft Entra ID preset), restrict sign-in to approved email domains (e.g. only @coventry.gov.uk addresses), choose whether new accounts are created automatically on first sign-in, and test the connection before enabling it. Security: standards-based flow (Authorization Code + PKCE), identity-provider signatures verified cryptographically, provider secrets stored encrypted and never shown again, and each community's SSO is completely isolated from every other community's. Communities without SSO configured see no change at all. Fully translated across all 11 languages.
- Permissions-Policy security header. API responses now also restrict which browser features embedded third-party content may use (camera, microphone and location are limited to the platform itself; payment and USB access are denied outright) — closing a gap in the security-header suite alongside the existing CSP, HSTS and frame protections.

### Changed

- Feed page polish — social-network-grade feel. A micro-interaction and consistency pass across the whole community feed. The Like heart and Bookmark icons now "pop" with a satisfying spring when tapped (respecting reduced-motion settings); the For You / Recent toggle clearly highlights the active mode; image carousels respond to lighter swipes and their arrows fade in smoothly (and now appear for keyboard users too); hover states across the stories bar, sidebar widgets, link previews, and quoted posts all transition smoothly instead of snapping. If stories fail to load you now get a quiet "Couldn't load stories — Retry" instead of a blank space, and a failed connection request properly rolls back the "Pending" button state. Also fixed two missing screen-reader labels (feed sidebar region and mobile composer) — all new text translated across the 11 languages.

### Added

- Podcasts module (Alpha) — community audio shows. A new self-contained, tenant-scoped podcasting module. Members can create shows and publish episodes — uploading hosted audio (with a live upload progress bar and clear, specific errors if a file is the wrong type, too large, or fails to save) or linking an external audio URL — with cover art, categories, visibility (public / members-only / private), transcripts, and chapters. Listeners get a built-in player with 15s-back / 30s-forward skip, variable speed, a draggable keyboard- and screen-reader-accessible progress bar, chapter jump-links, and a clear message when an episode's audio can't be loaded (instead of a silently broken player). Members can follow shows, react to episodes (the button now correctly reflects whether you've already reacted), download transcripts, and report episodes to moderators. A Podcast Studio lets members manage their own shows and episodes with a directory-readiness checklist, per-episode media status, and on-brand confirmation dialogs. Public shows expose an Apple/Spotify-compatible RSS feed. Tenant admins get a moderation queue (approve / reject / flag shows and episodes), a member-report queue (resolve / dismiss / escalate, with reasons shown in plain language), RSS feed validation, and listen analytics (completion rate, unique listeners, retention, client breakdown, top episodes). Privacy-preserving listen analytics, optional media scanning/processing hooks, and local-or-cloud media storage are configurable per tenant. The module ships marked "Alpha" and off by default — each community opts in. A single report can never hide a creator's episode on its own (it takes several independent reports, or a community switching moderation on, to auto-flag), so reporting can't be weaponised against a creator. Fully translated across all 11 languages.
- Mobile app now supports Light and Dark mode. The Timebank Global mobile app was previously locked to a dark theme. It now has a proper appearance setting — System (follow your phone), Light, or Dark — chosen in Settings and remembered between sessions. The whole app (backgrounds, cards, text, status bar, and navigation) switches instantly and stays consistent, with each community's brand colour preserved in every mode. Available in all 7 mobile languages.
- Mobile app feedback now feels native and on-brand. Across the entire mobile app, the old operating-system pop-up alerts — for "saved", "couldn't connect", form-validation messages, and "are you sure?" confirmations — have been replaced with the app's own branded toast notifications and confirmation dialogs (consistent styling, haptic feedback, and the community's colours) instead of generic grey system boxes. This spans every screen: wallet, messages, marketplace, groups, events, volunteering, jobs, settings, profile, federation, and more (≈359 prompts across 62 screens), with no change to the wording you see.
- Courses module (Alpha) — community learning. A new self-contained, tenant-scoped learning module: courses organised into sections and lessons (video, rich text, PDF, and external embeds), free and members-only enrollment, per-lesson progress tracking with automatic course completion, and auto-graded multiple-choice quizzes. Any member can author courses through a course builder by default (a tenant can restrict authoring to instructors/admins); admins get a moderation queue, instructor-grant management, categories, and tenant analytics. The module ships marked "Alpha" in module configuration and is off by default — each community opts in per tenant. An "Alpha" badge is shown on the member-facing Courses pages. Learners can discuss each lesson in threaded comments and leave star ratings + written reviews. Lessons support drip scheduling (release a set number of days after enrolment or on a fixed date), enforced server-side and shown as locked-with-unlock-date in the player. Instructors get a per-course analytics page (enrollment funnel, completion rate, average quiz score, and a per-lesson completion chart) and a grading queue for quiz attempts with short-answer/essay questions (set score, pass/fail, and feedback). Course completion awards gamification XP and a graduate badge and issues a printable completion certificate (download from My Learning). Learners get enrolment and completion notifications (in-app + completion email with a certificate link), each rendered in the recipient's preferred language. All courses are free to enrol in. Courses can be linked to community groups, and a group page surfaces its "Recommended courses". Courses support prerequisites (enrolment is blocked until the required courses are completed, shown on the course page) and cohorts (cohort-paced course groupings). Course content is indexed into the AI semantic-search embedding store (via a model observer), so the assistant and recommendations can surface relevant courses. Learning paths, feed celebration posts, and full keyword (Meilisearch) search-results integration are scaffolded for later phases. Fully translated across all 11 languages.
- AI assistant ships fully trained out of the box. Every new tenant is now auto-seeded with 38 comprehensive AI module docs covering the platform overview, timebanking philosophy, every module and feature (listings, wallet, messages, feed, dashboard, profile, notifications, settings, events, groups, volunteering, jobs, marketplace, blog, resources, polls, ideation, organisations, group exchanges, federation, gamification, goals, connections, reviews, AI chat, search, caring community, newsletter), account security, GDPR/privacy, accessibility, mobile/PWA, troubleshooting, and admin workflows. Each doc has 6–21 trigger keywords (including natural-language phrases like "how does it work" and "new here") and a body sized to fit the prompt-injection limit. All 12 existing tenants were backfilled (456 docs inserted). Tenant admins can still edit, disable, or add their own custom docs on top — the seed is idempotent and never overwrites customisations. Relevance ranking improved to score by keyword-hit count × match length and inject the top 4 most relevant docs per turn.
- Community Fund administration. New admin module under Timebanking for administering a shared community time-credit fund, with its own service, API endpoints, sidebar/breadcrumb navigation, a schema fix migration, and full translations in all 11 languages.
- Configurable "Powered By" footer branding and partner logo. Tenants can now show a "Powered By" slot in the footer (label, light/dark logo images, and a click-through URL) and a separate partner-logo slot with its own link, all configurable by the platform owner with upload endpoints. NEXUS branding ships as the default. The footer attribution panel was redesigned to accommodate this.
- Mobile app migrated to HeroUI Native v3 + NativeWind. The Expo/Capacitor mobile app was rebuilt on HeroUI Native v3 with NativeWind across its UI primitives, auth screens, tab screens, and modal screens, with deep-link, image, offline-detection, and "More" menu fixes and updated EAS build configuration.
- Safeguarding staff are alerted the moment a report escalates or is assigned. In the Caring Community safeguarding workflow, a report that breached its review deadline used to escalate silently — no one was told, and staff only noticed by checking the dashboard. Now the assigned reviewer and everyone with safeguarding view permission get a bell, device push, and email (in their own language) when a report escalates (covering both the automatic SLA breach and a manual escalation), and the assigned reviewer is notified the instant a report is assigned to them. These alerts are staff-only and contain just the report's reference, severity, category and deadline — never the case details — and they never reach the person the report is about.
- Moderators are alerted when content is reported or auto-flagged. Reports on feed posts, social content, listings and marketplace items — and job postings automatically flagged as possible spam — used to be written to a queue with no alert, so moderators only found them by manually checking. Admins, brokers and coordinators now receive a bell, device push and email (each in their own language) the moment any of these arrive, so nothing sits unseen. The person who reported is never identified in the alert.
- Sellers and content owners are now told when moderation acts on them (transparency). Previously, if a marketplace listing was removed, a seller account was suspended, or a post/comment was taken down, the affected person often learned nothing — no reason, no way to contest. Now sellers are notified at each step of a marketplace report (under review → outcome → appeal result) and whenever an admin rejects a listing or suspends an account, and post/comment authors receive a clear email explaining that their content was removed and how to contest it. Every notice is in the recipient's own language, states what happened and how to appeal or contact support, and never reveals who reported them.
- Admins can now see device-push delivery health. A new "Device push" panel on the admin Email Deliverability page shows, for the selected time window, how many push notifications were delivered, partially delivered, or failed across web and mobile (FCM), the overall success rate, and the most recent failures with their reason — so a community can confirm push is actually reaching members' phones and browsers, not just being attempted. Backed by a new push delivery log; push send failures now also surface in error monitoring instead of being silently dropped.
- Job moderation decisions now arrive by email too. When an admin approves or rejects a job posting, the poster now also receives a durable email in their own language — for rejections, including the reason and how to edit and resubmit — in addition to the existing in-app bell and push notifications (which previously left no lasting record). Listing approvals now also send a device push for channel parity, and use the tenant-safe notification writer.
- Volunteer opportunities now have a "Share with partner communities" choice. Until now, every active volunteer opportunity was automatically shared with federated partner communities, with no way to keep one local. Organisers now choose per opportunity — a switch on the create form and on the opportunity page (owner only). Existing shared opportunities stay shared; new ones start private. This also fixed a subtle bug where opportunities imported from a partner could be re-broadcast back out to the network.
- Five admin tools that existed "under the hood" now have actual screens. The platform had working back-ends with no way to use them; admins now get: Help FAQ editing (write, reorder, publish/unpublish and delete the questions shown in the Help Centre), Search analytics (what members search for, trending queries, and searches that return nothing — a goldmine for spotting missing content), Donation refunds (see all donations and issue a Stripe refund with a clear are-you-sure step stating the amount), Group tags & collections (organise groups with tags, curated collections, and auto-assign rules), and Residency verification (review members' residency declarations and approve, or reject with a reason). All five are fully translated in the 11 languages.
- You can now see and delete the reviews you've written. The Reviews page has a new "Given" tab listing every review you've left for others — who it's about, your rating and comment — with the option to delete one (after a confirmation). Previously there was no way to see your own written reviews at all. Along the way, two glitches on the existing tab were fixed: the "load more" control on received reviews didn't work, and a failed delete showed nothing instead of an error message.
- Welcome credits now default to 5 everywhere. Communities that never configured a welcome balance behaved inconsistently: members approved by an admin received 5 credits, while members who joined a self-serve community received nothing. Both now default to 5 credits, matching the long-standing approval behaviour. Communities that don't want welcome credits can set the amount to 0.
- Security: all known vulnerable components updated. The daily security scan had been flagging outdated third-party components: the server container's operating-system packages (including the web server, where patched versions existed for a remote-code-execution and a denial-of-service issue) and one critical package in the mobile app. The container now installs all security patches every time it's built, the mobile package is updated, and the scan's reporting was fixed so findings always reach the GitHub Security tab.
- Welcome credits are now actually granted to new members. Communities could configure a starting time-credit balance for newcomers, but on self-serve communities it was never paid out — the setting did nothing. New members now receive it the moment their account becomes active (at email verification, or immediately for admin-created accounts; communities with admin approval already granted it at approval). Strong safeguards ensure nobody can ever receive it twice, even across the different signup routes, and the setting is honoured whichever admin page it was configured on.
- Accepting a municipal copilot proposal now actually publishes it. Previously, accepting an AI-polished communication proposal only recorded the decision — nothing went out, and the admin had to re-create the text by hand in another screen. Accepting now broadcasts the polished text as a community announcement (banner + push notification) in the same step, records which announcement it became, and offers a Publish retry button if the broadcast fails. Re-accepting can never publish twice.
- The platform now notices within minutes if background processing stops. The June outage went undetected for 5 days because every health indicator only checked that the queue manager was running — not that work was actually being done. Two new safeguards close that gap: a tiny "heartbeat" task is sent through the real queue every 5 minutes and an independent watchdog raises an alarm (error log + monitoring alert, at most one alert per 6 hours) if heartbeats stop coming back; and the container health status itself now requires a live worker process, not just the manager.

### Fixed

- Guardian (parental) consent for young volunteers now actually works. The consent system for under-18 volunteers was broken at every step without anyone noticing: when a parent clicked "give consent" in the email, the approval silently failed; withdrawing consent silently failed; the nightly job that expires year-old consents crashed every night (confirmed in production logs); and the admin "Guardian Consents" page always showed an empty list even when records existed. All four are fixed and covered by new tests that use the real database (the old tests used stand-ins, which is exactly how the wrong database column names slipped through). The admin page also no longer exposes the secret consent link — previously a community admin could have approved consent on a parent's behalf.
- Stopped a runaway email loop wasting resources every half hour. When a reminder email can never be delivered (the address has hard-bounced or marked us as spam), the volunteer shift-reminder system kept retrying the same dead addresses every 30 minutes, forever — in production it was attempting 36 impossible sends twice an hour, around the clock. Such addresses are now marked as handled once and never retried, while genuinely temporary email hiccups still retry as before.
- Quieter, healthier nightly maintenance. The nightly clean-up job logged a warning every single night while trying to tidy a database table that has never existed; it now checks first and skips silently.
- "Delete my account" now erases much more of your personal data. A deep audit found that account erasure — while already covering profile, messages, volunteering, connections and more — left several things behind: job application CVs and cover letters (including the CV files themselves), your stories, marketplace seller business details (address, VAT number, payment account link), delivery addresses and notes on marketplace orders, poll votes, personal goals and their check-in notes, course learning history, comments on feed posts, and voice-message recordings on disk. All of these are now deleted or scrubbed when an account is erased, and a permanent automatic test guards the full list so future features can't quietly fall out of it. Three categories are deliberately kept and were confirmed correct: safeguarding reports and vetting records (legal retention duties) and time-credit transaction amounts (the community ledger, with your name already anonymised).
- Event reminder emails now show the event time in your community's timezone. Event times are stored internally in universal time (UTC); the website and app already convert them back to your local clock, but the reminder emails printed the raw UTC time — so an Irish community's 7pm summer event read "6pm" in the email. Reminder emails now use the community's configured timezone setting.
- Event reminders no longer endlessly retry dead email addresses. The same retry-storm fix applied to volunteer shift reminders also applies to event reminders (it was attempting dozens of impossible sends per day in production) — and members with an undeliverable email address still get their in-app bell reminder.
- Mobile app: voice messages now play back. Recorded voice messages sent but showed a red "Failed" badge when you pressed play — the app was handing the audio player a server-relative path (e.g. /uploads/…) instead of a full web address, which the player can't load. Voice (and any other) media is now resolved to an absolute URL the same way images already were, so playback works — including for voice messages that were already sent.
- Mobile app: comment windows and every other slide-up panel now actually open. A deep timing flaw meant the component library could silently ignore the 'open' command in production builds — comment windows, apply forms and pickers fetched their content but never appeared on screen (in development builds, which run slower, it always worked — which is why it survived testing). The open command is now issued with a proper delay and re-asserted automatically, verified end-to-end on a real device build: the comment sheet opens first tap, comments post and appear.
- Mobile app: see who reacted, Facebook-style. Feed cards now show the familiar summary line — overlapping reaction emojis plus 'Anna and 3 others' — and tapping it opens a panel listing everyone who reacted, filterable by emoji, with each name linking to their profile.
- Mobile app: comments catch up with the web. You can now reply to comments, edit or delete your own (press and hold for the menu), and like comments — none of which the app offered before. Timestamps throughout the feed now appear in your chosen language, reaction and counter labels are properly translated in all 7 app languages, and failed likes/saves now tell you instead of silently undoing themselves.
- Mobile app: smoother feed scrolling. Feed cards no longer all re-render when one changes, loading the next page shows placeholder cards instead of a spinner, images recycle correctly during fast scrolling, and the next page starts loading earlier so you rarely hit the bottom.
- Mobile app: the Like button now responds to every single tap. A component-library quirk meant that adding long-press support to the Like button silently broke ordinary taps — a quick tap did nothing at all, while a long hold could register a stray like. The press handling was rebuilt (verified live on a device against the running API): one tap likes instantly, holding the button slides out the emoji picker while your finger is still down — just like Instagram — and releasing after the picker opens never fires an accidental like.
- Mobile app: emoji reactions arrive, and the Like button finally behaves. The feed's Like button now matches the web app: a quick tap likes (and stays highlighted — previously the highlight vanished the instant the server replied, because the app read a field the server never sent), and a long-press opens the full emoji picker (👍 ❤️ 😂 😮 😢 🎉 👏 ⏰) — the same eight reactions as the web, on every reactable feed card.
- Mobile app: slide-up panels open on the first tap. Comment sheets and other slide-up panels sometimes needed two or three taps to open — a quirk in the sheet library could fire a phantom "close" signal the moment a sheet was created, instantly cancelling it. Phantom closes are now filtered out everywhere, so every sheet opens first time.
- Account deletion now properly erases volunteering data. Deleting an account anonymised the member's profile but left their volunteering records untouched — vetting/credential records, wellbeing check-ins, accessibility needs (including emergency contacts), guardian consent details, training records, certificates and donor names all survived a GDPR erasure request. Deletion now removes those sensitive records outright and scrubs personal text and donor details from the records that must remain for organisation accounting (hours and donation amounts are kept, with no name attached).
- Volunteering: expense decision notifications now land on the right page. The "your expense was approved/rejected" email linked to a page that doesn't exist, silently dropping members on the volunteering home tab. It now opens the Expenses tab directly.
- Volunteering: the shift waitlist now actually works. Joining a waitlist looked fine, but the machinery behind it was never connected — when a spot opened up, nobody was told, and the "next in line" could never be moved onto the shift. Now, when someone cancels a shift signup (or an organisation declines a previously approved volunteer), the first person on the waitlist instantly gets a notification in their own language, sees a highlighted "Spot available — claim it" card on their Waitlist tab, and one tap signs them up (with a safety re-check so a claim can never overfill the shift). Unclaimed offers automatically pass to the next person after 48 hours, and leaving the queue while holding an offer hands it on immediately. Translated across all 11 languages.
- Volunteering: shift swaps can no longer be completed after the shift has already happened. A swap accepted (or admin-approved) late used to go through even if the shift had started, corrupting attendance records. It's now politely refused.
- Volunteering: admin CSV exports are now safe to open in Excel. Volunteer names or messages crafted to look like spreadsheet formulas can no longer execute when an admin opens an exported approvals or hours file.
- Volunteering: the log-hours form now stops impossible entries before submitting. The hours field accepts 0.25–24 in the form itself instead of letting the server reject it afterwards.
- Volunteering: cash and bank-transfer donations no longer vanish into a permanent "pending" state. Donating through the Donations tab without paying by card recorded the donation, but nothing could ever confirm it — it never counted toward the giving-day campaign total or donor count, and admins had no way to mark the money as received. Admins now get a "Mark completed" button on the Donation Refunds page for offline donations (cash, bank transfer, PayPal); confirming one adds it to the campaign total exactly once, even if clicked twice. Card payments are unaffected — they still confirm automatically. Translated across all 11 languages.
- Volunteering: a removed group-shift member could never be re-added. Removing someone from a group shift reservation and then adding them back always failed with a generic server error. Re-adding now works. The same fix closed two quieter problems: group member records were being saved under the wrong community, and two leaders adding members at the same moment could overfill the reserved slots — capacity is now checked atomically.
- Volunteering: shift waitlist positions could develop gaps when two people left at once. Queue reordering now locks each entry before renumbering, so positions stay contiguous.
- Volunteering: opportunity pages with many shifts loaded slower than needed. Spots-remaining counts for all shifts are now fetched in one query instead of one query per shift.
- Mobile app: typing anywhere now uses proper full-height composers. Starting a group discussion, requesting an exchange from a listing, reporting a listing, adding a skill, and creating a goal all squeezed your typing into tiny inline boxes that the keyboard covered. Each of these now opens a proper slide-up panel with room to write, and the keyboard never hides what you're typing.
- Volunteering: admins no longer see owner controls on everyone else's opportunities. A flag that was meant to mean "this is your post" actually meant "you have admin powers", so admins saw Edit buttons and an approve/decline applications panel on other people's volunteer opportunities — and their own "Apply" button silently refused to work on every post. Applying now responds instantly with clear feedback, applying to your own opportunity is politely refused with a proper message (in all 11 languages), and owner controls only appear on posts you created. Organisation admins still manage everything from the organisation dashboard.
- Organisations list no longer shows 0 members / 0 listings / 0 opportunities for everything. The server never sent the counts; it now calculates real opportunity, volunteer, hours and rating figures for each organisation — on web and mobile alike.
- Mobile app: "Read in app" on the Support & legal page looked dead. Tapping it actually worked, but the document appeared at the very top of the page — off-screen if you'd scrolled down. Documents now open in a slide-up reader you can't miss.
- Mobile app: profile tidy-up. The achievements section no longer arrives pre-expanded, and the two confusing full-width cards beneath it ("appreciations" and "collections" — both real features that also exist on the web) are now one compact, clearly-labelled pair of rows.
- Mobile app: live updates were silently broken — now fixed. The mobile app asked the server for its real-time connection details at an address that didn't exist, and the failure was swallowed silently — so new-message badges, live chat updates and other instant notifications never worked in the mobile app. The app now uses the correct address, and the server also answers at the old one, so phones with the current version installed start receiving live updates again as soon as the server is updated — no app-store update needed.
- Mobile app: your language choice now sticks. Picking a language in the mobile app's settings worked until you closed the app — on the next launch it silently reverted to your phone's language. The choice is now remembered. Dates and times throughout the app (56 places across 36 screens) also now follow the language you chose instead of the phone's region setting.
- Mobile app: buttons no longer turn invisible on light community colours. For communities with a light brand colour, selected filter chips, tag pickers, the floating "+" button icon and button loading spinners painted white-on-light — unreadable. Text and icons on brand-coloured buttons now automatically switch between black and white for proper contrast.
- Changing your password in-app now requires the same strength as everywhere else. Registration and the email reset flow required 12 characters, but the in-app "change password" screen (and its server check) only required 8 — a weaker back door. All three flows now require 12, and the mobile reset screen no longer accepts a password the server would reject anyway.
- Mobile app: small polish and safety fixes. Five screens that could show a blank page if something went wrong (edit profile, change password, image viewer, new message, quick create) now show a proper "something went wrong" recovery screen; a handful of unlabeled icon-only buttons (delete, image thumbnails, star ratings) are now announced correctly by screen readers; and the crash reporter now strips login credentials from anything it sends.
- Deleting a recurring event series now tells the people who'd signed up. Cancelling a series already notified attendees, but deleting it removed every future occurrence silently — people could show up to an event that no longer existed. Deletion now sends the same cancellation notice (bell, email and push, each in the recipient's own language) to everyone going, interested, invited or waitlisted on a future occurrence — exactly once per person, and never for occurrences that were already cancelled (those attendees were told at the time).
- Roughly 1,850 missing translations filled in across all 10 non-English languages. A series of recent features (support reports, volunteer alerts, guardian notifications, partner management, data retention, password history, UK address lookup, CRM admin, comment-reply emails, and more) had shipped with English-only text, so members using Irish, German, French, Italian, Portuguese, Spanish, Dutch, Polish, Japanese or Arabic saw English in those places. All are now properly translated (not machine-copied English). A new automated check blocks any future code change that adds English text without the other 10 languages — closing the gap that let these sit unnoticed for weeks.
- A review you delete can no longer be brought back by an admin. Previously, a review deleted by its author looked identical to one a moderator had rejected, so an admin working the moderation queue could accidentally restore it. Author-deleted reviews are now marked distinctly: they no longer appear in any moderation queue, and the restore/hide actions refuse them outright.
- Background job processing restored (queued work had been silently stuck since 6 June). A version mismatch between two framework components meant every background worker crashed the instant it started — while the system still reported itself "healthy". Anything handled by the background queue (federation syncing between communities and some queued notifications) quietly piled up instead of being processed; nothing was lost, and the backlog is worked off automatically once this fix is deployed. The mismatched component has been updated to the release that fixes the incompatibility.
- Joining a group twice by double-click is now impossible at the database level. A fast double-click on "Join" could create two membership records (double member counts, duplicate welcome messages). The database now enforces one membership per person per group — existing duplicates are cleaned up automatically — and the same hard guarantee was added for marketplace escrow records. The join action treats a lost race as "already a member" instead of an error.
- Marketplace payouts can no longer be released twice. A buyer clicking "confirm received" at the same moment the automatic timed release ran (or a double-click) could complete the same order twice — doubling the seller's recorded sales and revenue and sending duplicate payout notifications. Completion and escrow release are now claimed atomically; exactly one path wins.
- Volunteer-organisation wallets can no longer deadlock. A member depositing into an organisation at the same moment the organisation paid a volunteer could lock both records in opposite orders, failing one action with a server error. Both paths now lock in the same order.
- Transaction XP can't be double-awarded on a queue retry, and search results show listing category names again (a leftover legacy column was shadowing the real category link, so the name was always blank).
- Group exchange completion can no longer pay people twice — or short-change them. Two clicks of "Complete" in quick succession could run the whole credit distribution twice; completion is now claimed atomically so the second click is harmless. Separately, each participant's share was being rounded down to whole hours (three providers splitting 10 hours got 3+3+3 credited while the receiver paid 10 — a credit vanished; shares under one hour paid nothing at all): exact 2-decimal shares now flow through. Receivers also can no longer be driven into negative balance — completion fails cleanly if a receiver lacks the credits, matching every other payment path.
- Admin panel: around 70 more actions no longer claim success when the server refused. The same false-success flaw fixed in the member app last week was swept from the admin panel: safeguarding escalations, tenant provisioning approvals, push-campaign dispatch, paid analytics subscriptions, identity-provider credentials, compliance registers, group management, menu building, translation settings, and ~30 more screens now report failures honestly and stop discarding what you typed when a save fails.
- Duplicate-notification protection extended to nine more background senders. Job-alert fanouts, connection requests/acceptances, group joins, onboarding emails, safeguarding staff alerts, and two admin background jobs lacked the platform's standard guard against a queue retry re-sending every email; all now send at most once.
- Public knowledge-base article links no longer error. Opening an article by its web address failed every time due to a misnamed counter column (the in-app route was unaffected).
- Goal reminders respect privacy. A member could attach a reminder to someone else's private goal and the reminder email would reveal that goal's title; reminders are now limited to your own goals (or public ones).
- Deleted accounts no longer appear in member search, transfer recipients, or @mentions. With account deletion now working, anonymised "Deleted User" entries could surface in pickers — and credits sent to one would be unrecoverable. All people-pickers now exclude them.
- Help FAQ category filter no longer errors (wrong column name on a public endpoint).
- Recurring events: "only this event" edits now stick. Editing a single occurrence didn't detach it from the series, so a later "all future events" edit would silently overwrite it. Also, deleting a series is now all-or-nothing (a mid-way failure can't leave it half-deleted).
- Deleting your account works now — and properly anonymises your data. Closing an account failed with an error because it tried to set an account state the database doesn't allow, which also meant the personal-data anonymisation step never ran. Account deletion now completes: the account is deactivated, sign-in is blocked, and name/email/phone/location are anonymised with deletion timestamps recorded.
- Deleting your own review works now. It failed for the same kind of reason (an invalid state value); deleted reviews are now properly hidden everywhere.
- Marketplace bulk "deactivate" works now. It also wrote an invalid state; deactivated listings now return to draft (and can be re-activated).
- Super-admin "move user to another community" no longer reports failure after succeeding. The screen always showed an error even though the member had actually been moved — a confusing half-state. It now reports honestly (and the log no longer claims the member's content moved with them, which it never did).
- Onboarding settings changes take effect immediately. Saving onboarding configuration tried to clear the cache through a service name that doesn't exist, failed silently, and changes only appeared when the cache happened to expire.
- Cookie-consent changes are recorded in the audit trail again. Since April these were written to a table that had been removed (replaced by its correctly-named twin), so the GDPR consent history was silently lost.
- Also hardened in this pass: the general file-upload endpoint stored files under a folder named after the user's account number and ignored the requested category (now fixed); and the optional Vault secrets integration, whose client class didn't survive the Laravel migration, now fails softly to normal configuration instead of crashing if ever enabled.
- Group challenge rewards actually arrive now. Completing a group challenge wrote the XP reward into each member's activity history but never added it to their actual XP balance — so it couldn't be spent in the XP shop and didn't count on the leaderboard. Challenge rewards now go through the standard XP pipeline (balance, leaderboard, level-up check, live update), the same as every other XP award.
- Donation receipts now appear in the recipient's language. The wallet-history line for a credit donation was permanently stored in whatever language the donor was using. It's now stored in the recipient's language, since it's their wallet history that shows it.
- Deleting or cancelling a recurring event now covers the whole series. Previously, deleting a recurring event removed only the first one — up to 52 future occurrences stayed live with no way to remove them except one at a time — and cancelling it cancelled only the first occurrence, so people signed up for later dates were never told. Deleting now also removes all future occurrences (past ones are kept for attendance history), and cancelling now cancels every future occurrence and notifies everyone affected, with a clear warning in both dialogs that the whole series is included.
- Editing a recurring event now asks "only this event, or the whole series?" Edits to a series previously applied silently to just the one occurrence. Organisers now get a choice; series-wide edits update details like title, description, and location on every future occurrence (date/time changes deliberately stay per-event so the schedule can't be collapsed onto a single timestamp — a server-side guard now enforces this too).
- Around 200 messages that always appeared in English are now fully translated. Across volunteering (shifts, swaps, waitlists, group reservations), group conversations and chatrooms, private messages, sub-accounts, skills, the XP shop, and polls, the platform's responses — errors like "You are not signed up for the source shift" or "Not enough XP" — were written directly into the code in English and shown to every member regardless of their language. All of them now go through the translation system, with real translations (not English placeholders) in all 11 languages. Separately, ~190 "Loading" labels in the admin panel that screen readers announced in English are now translated too.
- Polish round from the June platform audit. Admin confirmation dialogs for deleting groups, group types, AI assistant docs, and bulk actions now use the platform's styled dialog instead of the browser's plain pop-up; "Total" count chips in the Polls, Ideation, and Algorithm admin pages show the actual number again; the volunteering opportunity search no longer fires a request on every keystroke; Caring Community transfer notifications can no longer be sent before the transfer is final; and a developer verification script broken since the Laravel migration was repaired, along with stale references in the README and contributor docs (React 19, removed animation library, decommissioned legacy admin) and missing example environment keys.
- Match emails greet you by name again — and no longer invent a match score. The hot-match, mutual-match, and digest emails always fell back to a generic greeting because the recipient's identity was never passed to the email builder. Mutual-match emails also asserted a fabricated "75% match" when no score had been computed — the score badge now only appears when a real score exists.
- Match and digest emails are now fully translated. Several English fragments leaked into every language: the "View" link and frequency word in the activity digest, "(Xkm away)" in hot-match alerts, the Offer/Request badge, day/week/fortnight period words, and fallback phrases like "a skill you need". All now render in the recipient's language (new translations added for all 11 languages), digest timestamps use localised month names, and the digest email finally carries the community's name and a proper footer like every other email.
- Accessible (GOV.UK-style) frontend hardening. Pages for disabled modules are no longer reachable by direct URL (feed, messages, member directory, and exchanges now respect each community's module settings, matching the main app); the registration page's terms/privacy links no longer point at a dead address on the accessible domain; posting to the feed is rate-limited like every other form; the page no longer pretends JavaScript is available before it loads (restoring the progressive-enhancement guarantee); the contact form's fallback subject is translated; and the feed's type-filter hint is now announced by screen readers.
- The app no longer claims success when the server said no. A platform-wide flaw meant that when the server rejected an action (for example confirming exchange hours, hiding a feed post, saving a marketplace coupon, deleting a goal, dismissing a match, or saving an item), the app often showed a green "success" message and updated the screen anyway — the change silently never happened and reappeared on reload. Around 40 such spots across exchanges, the feed, marketplace, goals, messages, notifications, wallet transfers, and volunteer applications now check the server's actual answer: real failures show a clear error message, optimistic changes are rolled back, and wizards no longer advance with nothing saved. Affected flows included all six exchange actions (accept/decline/start/complete/confirm-hours/cancel — credit-affecting), merchant onboarding steps, and coupon saving.
- Failed page loads no longer masquerade as empty pages. When loading search results, connections, listings, wallet transactions, exchanges, or the notifications flyout failed, the page showed a friendly "nothing here yet" empty state with no hint anything went wrong — and the exchanges page could get stuck on a permanent loading skeleton. These now show a proper error message with a retry option.
- Feed: a deleted poll no longer hammers the server. A poll card whose data could not be fetched (e.g. the poll was deleted) refetched in an infinite loop; it now shows the poll error state once.
- Messaging someone for the first time works from "Message" buttons. For members with no existing conversations, clicking "Message" on a listing, profile card, or seller page landed on an empty inbox and did nothing. It now opens the new-conversation screen as intended.
- Conversations: messages arrive without a refresh on communities without live updates. The fallback that checks for new messages every few seconds never started when opening an existing conversation, so incoming messages only appeared after switching tabs or refreshing. Also fixed: attaching a second file no longer breaks the first attachment's preview, edit/delete failures now show an error instead of nothing, and reaction counts no longer drift on unexpected server replies.
- Wallet: the "Load more" button works after using the filter tabs. Touching any transaction filter permanently disabled further pagination.
- Events: changing filters while older events were still loading could mix results from the old filter into the new list. Stale responses are now discarded; failures while loading more events show an error. Also, RSVPing no longer needlessly refetches the event-series list, and two redirects (profile login, conversation not-found) now keep you on your community's address.
- Matches: volunteering suggestions no longer link to a "page not found".
- Recurring events actually recur now. Ticking "recurring" when creating an event silently created a single, one-off event — the recurrence settings (weekly/monthly, days, end date/count) were sent to an endpoint that ignores them. Event creation now uses the recurrence-aware endpoint, which creates the series template plus all its occurrences.
- The platform-owner Billing Control panel works again. Every action on the super-admin billing dashboard (loading the tenant snapshot, assigning plans, pause/resume, grace periods, CSV export) called a malformed API address and failed. All actions now work, failures show a real error message instead of being silently ignored, and the CSV export goes through the authenticated download path.
- The activity-digest email frequency setting saves correctly now. Choosing how often you receive the digest email in Settings → Notifications always showed a save error (and never loaded your current choice) because the setting was sent to an address the server didn't recognise. It now loads and saves properly.
- Two admin CSV exports repaired. "Hours by category" and "Inactive members" report exports always failed with a validation error due to a report-type name mismatch between the export button and the server.
- The public municipality events calendar page loads now. The page existed and the server logic existed, but the API route connecting them was never registered, so the page always showed an error.
- Requesting a plan upgrade from the admin Billing page actually sends the request now. The "request upgrade" button posted without authentication, was always rejected, and still showed "Upgrade sent". It now sends authenticated, reports failure honestly (falling back to a pre-filled email), and the email subject is translated.
- XP Shop purchases work again. Buying an item with XP always failed with "Purchase failed" (and no XP was deducted) because the purchase record was written with the wrong column names for the purchases table. Purchases now record correctly.
- Scheduled group posts now actually publish. Two problems meant a group post scheduled for later never appeared: the background job that publishes due posts was never added to the platform's schedule, and discussion-type posts were written with a column the posts table doesn't have — which also quietly created an empty duplicate discussion shell on every attempt. Scheduled group posts (announcements and discussions, including recurring ones) now publish every five minutes as intended.
- Match suggestions learn from your activity again. Saving, dismissing, viewing, or contacting a match suggestion is meant to teach the matching engine your preferences, but every one of those signals was silently discarded due to a column mismatch in the match-history table. The engine now records them, so "Top matches" personalisation improves with use.
- Group recommendation feedback is recorded again. Clicks, joins, and dismissals on "Recommended groups" were silently dropped (the write was missing its community identifier), so the recommendations admin analytics always showed zero activity.
- Auto-translation now honours the community's default language. When translating member-written content, the platform looked up the community's default language in the wrong place and always fell back to auto-detection. It now reads the community's configured default correctly.
- Newly provisioned communities get their federation defaults. Communities created through the self-service application/approval flow were silently skipping all federation setup (wrong column names on all three federation tables), leaving them invisible to the federation network. They now get the same default-on federation features as communities created by an admin, and are auto-whitelisted by the approving reviewer.
- Federation activity logging and group achievements repaired. Incoming federation events from partner networks failed to write their audit-log entries, and the group achievements engine ("First Steps", "Community Builders", …) could never award or list achievements — both due to column mismatches. Both now write correctly.
- Cancelling an event works again. A code-placement slip in the recent security hardening pass put an attendee-roster privacy check inside the event-cancellation routine, where it crashed every cancel attempt (organisers and admins saw a generic error and the event stayed live, with no notifications sent). Cancellation now works as before, and the roster privacy check sits where it was meant to — limiting who can browse an event's attendee list to its organiser, admins, and fellow attendees.
- The accessible frontend's community-chooser page now shows the right feedback link. The "feedback" link in the footer of the accessible (GOV.UK-style) community chooser pointed at one specific community's contact form instead of the general platform feedback email address. Tenant pages keep their own contact form link.
- Leftover English text translated in the Italian, German, and French interfaces. An i18n sweep across all 11 languages found 14 strings still showing in English: the Italian Jobs module (loading message, close/reopen-vacancy confirmation dialogs, hiring-pipeline screen-reader labels, and the bias-audit job search) and Italian blog (loading messages and the category filter), plus the course grading form's "Feedback" label in German and "Score" label in French. All now display properly translated.
- The Members directory (and other pages) no longer break with "Something went wrong" after the app has been used across several updates. Over time, as new versions of the app shipped, the browser quietly kept a copy of every past version's translation files — these piled up until the browser's local storage filled completely, at which point even saving a tiny setting (like whether you prefer the Members list in grid or list view) would fail and take the whole page down with it. The app now clears out old, leftover translation copies on startup so storage stays tidy, treats a storage failure as harmless instead of crashing the page, and its built-in storage clean-up now correctly targets those translation caches when space runs low. As a safeguard, every place in the app that saves a setting to local storage now goes through this same self-healing path, so a full browser store can no longer crash any page — it quietly frees up space and carries on.
- New communities again launch with default categories, member attributes, and navigation menus — however they're created. A regression introduced during the Laravel migration meant newly created tenants were no longer seeded with their default member attributes (offer/request filters such as "Tools Provided", "Wheelchair Accessible", "References Available") or their default navigation menus (the main header menu and footer menu). New communities now start with the full default set again — eight categories, nine member attributes, and both navigation menus — so admins and members aren't faced with an empty filter list or blank navigation on day one. The defaults now come from a single shared seeder used by both the admin "create community" path and the self-service approval/provisioning path, which previously seeded neither (so communities created through approval still launched empty). The seeded navigation's auth/feature visibility rules are now also honoured by the server-side menu renderer (mobile and search-crawler views), not just the React app, so members-only and feature-gated links no longer show to signed-out visitors on those views. (The one Ireland-specific legacy attribute, "Garda Vetted", is seeded as the globally-neutral "Background Checked" in keeping with the platform being a worldwide product.)
- Jobs: a candidate can no longer be paid twice — or a single role filled twice — when an offer is accepted. For timebank jobs, two near-simultaneous "accept" clicks (or two finalists both accepting offers for the same one-position vacancy) could mint the time-credit reward twice and mark the role filled twice. Accepting an offer is now a single, atomic, all-or-nothing action: the credit is awarded exactly once, the vacancy is filled once, and any other outstanding offers for that role are automatically withdrawn. Interview self-scheduling was hardened the same way — two candidates can no longer grab the same interview slot.
- Jobs: hiring-team members can now use the tools they were given access to. People added to a vacancy's hiring team (and community/tenant admins) were wrongly blocked from viewing interviews, the team list, referral stats, AI candidate ranking, analytics, the audit trail, predictions, CSV export, and from posting scorecards or running bulk actions — only the original poster could. Access now consistently follows "owner, admin, or hiring-team manager" everywhere.
- Jobs: bulk candidate-status changes now behave exactly like single ones. Changing several applicants' status at once previously skipped the safeguards that single updates have: it could silently move an already-hired or withdrawn candidate backwards, wrote no history, and sent no notification. Bulk changes now respect finished states, record history, and notify each affected candidate in their own language.
- Jobs: "right to be forgotten" now clears all of a member's job data. A data-erasure request used to leave behind interview notes, offer details, reviewer scorecards, status-change history, referral links, and view history. Erasure now scrubs personal data across the whole jobs module, and a problem deleting one CV file no longer aborts the rest of the erasure.
- Jobs: offers and interviews can't be sent to the wrong person or a closed role. Employers can no longer send an offer or propose an interview to a candidate who has withdrawn or been rejected, or make a new offer on a role that's already filled.
- Jobs: the applicant count shown on community pages is now accurate. Some pages and admin email tools were reading job-application figures from the wrong (near-empty) table, so counts and candidate lookups could be wrong; they now read the live applications data.
- Jobs: abuse and runaway-cost protection. The AI job advisor, AI candidate ranking, hiring predictions, employer reviews, and the public job feeds (RSS/JSON/Indeed) are now rate-limited, and employer-review scores are validated, closing off ways to drive up AI costs, scrape data, or post malformed reviews. Internal error messages in the jobs module are now translated instead of occasionally showing English.
- Jobs: a more polished, safer, fully-translated interface. Closing or reopening a vacancy and declining an interview or offer now ask for confirmation first; choosing a CV file checks the file type immediately (not only on drag-and-drop); the My Applications page reliably shows interviews and offers as you page through; browse results no longer briefly flash stale matches when you change filters quickly; the AI chat panel can be closed with Escape and works with screen readers; share text and the offer "per month" label are translated; and assorted focus-ring, reduced-motion, and empty-state details were tidied across the module.
- Jobs: the employer rating control and CV downloads now work for everyone. The star-rating used to leave a review on an employer's brand page could only be set with a mouse — keyboard and screen-reader users could not rate at all; it is now a proper keyboard-operable, screen-reader-labelled control. And on the My Applications page, the "Download CV" button pointed at a private file path that returned nothing; it now downloads your CV through the authenticated endpoint, with a clear error message if it fails.
- Courses: clearer feedback and a safer paid-enrolment path. Marking a lesson complete now shows an error if it does not save (instead of silently doing nothing), and the instructor dashboard's empty state reads as guidance instead of a stray "My courses" heading. Paid (time-credit) enrolment was hardened internally: the charge reads the freshest course price under the row lock, and the enrolment notification is now sent after the wallet transaction commits (shorter lock hold, no change to the charge itself).
- Podcasts: stuck media uploads are now visible instead of retrying forever. If an episode's optional media-processing step kept failing, it would retry indefinitely while the episode sat silently in "pending"; it now retries a bounded number of times and is then marked "failed" (and logged) so admins can see and act on it.
- Mobile: saved/unsaved state on the Exchanges screen no longer goes stale. Tapping save/unsave applied an optimistic change that was never reconciled, so after a refresh or filter change a listing could keep showing the wrong saved state (for example if it was changed on another device). The optimistic state is now cleared once the server confirms it.
- Audit follow-ups: podcast audio seeking, a duplicate-certificate guard, and smoother mobile lists. Podcast episode audio now streams with correct HTTP range support, so seeking and scrubbing in the player are reliable. A database guard (plus a race-safe issue path) ensures a learner can never receive two certificates for the same course. And the mobile Exchanges list got virtualization tuning for smoother scrolling through long result sets.
- Volunteering: a volunteer can no longer be accidentally paid twice for the same hours. When an organisation approved logged hours (or a member self-logged hours with auto-pay enabled), two near-simultaneous approvals — or an accidental double-click — could pay the volunteer twice and debit the organisation's wallet twice for a single entry. Approval and payment are now strictly one-time per hours entry, guarded both in the application logic and by a database safeguard, so a duplicate payment can't happen no matter how the action is triggered.
- Volunteering: organisations are now told the truth when their wallet can't cover approved hours. With auto-pay on, approving hours always said "approved and paid" — even when the wallet was empty and the volunteer was not actually paid. The confirmation now accurately reflects whether the volunteer was paid or the wallet needs topping up, and the organisation's pending list and balance stay consistent.
- Volunteering: switching between two organisation dashboards no longer shows the wrong organisation's data. A coordinator who manages more than one organisation could briefly see one org's pending hours or wallet history under another org's name (and risk approving or paying against the wrong one). Each dashboard tab now always reflects the organisation you're viewing.
- Volunteering: a glitch in one tab can no longer blank the whole page, and a few smaller rough edges are fixed. An unexpected value from the server could crash the Emergency Alerts tab and take the entire Volunteering page down; every tab is now isolated so a problem in one shows a contained, retryable error instead of a blank screen. The wallet deposit limit shown in the form now matches what the server actually accepts (a clear message instead of a confusing rejection), the Safeguarding and Donations toggle buttons now visibly reflect their on/off state, Safeguarding no longer shows an empty list with no explanation when data fails to load, and bulk approve/decline on applications is protected against accidental double-submission.
- Volunteering: an organisation's private wallet balance is no longer exposed on its public page. The public organisation endpoint was including the internal wallet balance and auto-pay setting; these are now kept private to the organisation's own dashboard.
- Volunteering: federation and rewards reliability. Federated volunteering opportunities can no longer be re-sent in duplicate to partner communities if a background job is retried, and volunteers now reliably receive the correct XP for approved hours (a matching bug could silently skip the reward for some entries).
- Broker panel exchange history is now fully translated. The broker exchange-detail timeline labels ("Request created", "Provider confirmed hours", "Requester confirmed hours", "Status changed") were only ever shown in English for the ten non-English languages. They are now translated across German, French, Spanish, Italian, Portuguese, Dutch, Irish, Polish, Japanese and Arabic — restoring a fully-localized broker panel and unblocking the translation-completeness CI gate.
- Podcasts: scheduled episodes stay private until their publish time, and listen stats are more honest. A future-scheduled episode could be opened early via a direct link or the audio/listen route (it was correctly hidden from listings and the RSS feed, but those routes didn't enforce the embargo); the embargo is now applied everywhere, while show owners and admins still preview as before. Separately, an episode whose related show record was missing could return a server error instead of a clean "not found"; listen analytics now clamp the reported listening time to the episode's real length so completion/retention figures can't be inflated by a client; and the per-user show-limit check no longer loads every episode and chapter just to count shows.
- Scheduled podcast episodes now notify subscribers when they go live, not when they're queued. Scheduling an episode for a future time used to notify subscribers and post a feed card immediately — pointing people at an episode that stayed hidden from listings and the RSS feed until its scheduled time. A future-scheduled episode is now held back and released by a background task the moment its scheduled time arrives, which posts the feed activity and notifies subscribers exactly once (re-runs never duplicate). This completes the previously half-built scheduling feature.
- Partner time-credit notifications now arrive in your language. When an external partner integration credits time to your wallet, the in-app bell and push notification were rendered in the system default language rather than yours. They now render in the recipient's preferred language, matching the accompanying email.
- Landing-page builder no longer loses your place when you reorder or remove a block. In the admin landing-page builder, reordering or deleting an audience card, feature, step, or value could jump keyboard focus and in-progress edits to the wrong row, because the lists were tracked by position rather than identity. Each item now keeps a stable identity, so focus and field state stay with the right block. Saved content is unaffected.
- Smaller robustness and accessibility fixes. Disconnecting a social-login provider no longer surfaces a raw internal error message on failure (it now shows a clear, localized message and logs the detail server-side); the group branding colour-picker's hue slider now has a translated screen-reader label; a shared data-loading hook now ignores a slow earlier response after its input changes, preventing brief stale data on fast tab/filter switches; and a group team-chat screen now releases its real-time subscription when you leave it.
- Site search no longer fails with a server error. On communities using the fast search engine (Meilisearch), the global search bar and its type-ahead suggestions returned a 500 server error for every query — because the listings search index was queried with an "approved listings only" filter the index had never been told to allow, and the error was left unhandled. The index now permits that filter, and as a safety net any search-engine error now quietly falls back to a database search instead of failing the whole request, so search can never hard-fail this way again. (Operators: re-run the search-index sync after deploying so the live index picks up the new filterable attribute — php scripts/sync_search_index.php --all-tenants.)
- The "Leave a review" email link no longer hits a 404, and the Reviews "Pending" list works again. The review-request email's "Leave Review" button pointed at a page (/reviews/create) that didn't exist, so it landed on a Not Found page. That route now exists and opens the review form for the right person straight from the email — and it survives signing in first, so the link works even when you're logged out when you click it. Separately, the Reviews page "Pending" tab and the dashboard's pending-reviews card were being fed the wrong data (the reviews you'd received instead of the completed exchanges you still need to review), so they never listed the right items; they now correctly show each completed exchange awaiting your review, the person to review, and a working "Write review" button.
- Courses module hardening (Alpha follow-up). A round of safety and robustness fixes to the new Courses module: the course builder now shows an error and rolls back the on-screen change if a section/lesson delete, reorder, or rename fails to save (previously these failures were silent, leaving the displayed curriculum out of step with what was actually stored); quiz answer keys and explanations are now excluded from course data sent to the browser at the model level as defence-in-depth (the learner quiz view already omitted them); the star-rating control now has a proper accessible label for screen-reader users; a failed review submission now shows a review-specific error message instead of an unrelated "couldn't enrol" one; and course creation now sets author identity and moderation status strictly server-side so they can never be influenced by the submitted form. New labels translated across all 11 languages.
- Courses module polish (Alpha follow-up, round 2). Course counts — lessons, enrolments, and review counts — now pluralise correctly in every language (they previously used a hand-rolled scheme that left some languages, including Polish and Arabic, stuck in a single form regardless of the number); they now use proper per-language plural rules. A learner who has dropped a course can no longer post or overwrite its rating/review (only active or completed enrolments may review, so the public rating reflects genuine participants). And enrolling now ignores a cohort selection that doesn't belong to the course, instead of recording it, keeping cohort rosters and analytics clean.
- Instructors can now actually read what they're grading. The course quiz grading queue previously showed each learner's submission as a raw JSON blob (e.g. {"q12":["b"]}) with no question text — effectively unusable for grading short-answer and essay questions. It now lists each question's prompt with the learner's answer beneath it, mapping multiple-choice selections back to their option labels. The answer key is never exposed to this view. Two new labels translated across all 11 languages.
- Quiz attempt limits can no longer be bypassed by rapid resubmission. A quiz's "maximum attempts" cap was checked and then recorded in two separate steps, so two submissions sent at almost the same instant could both slip past the limit (a check-then-write race). The cap is now enforced atomically inside a row-locked transaction, so a learner can never exceed the configured number of attempts even with concurrent submissions.
- The Prerender Engine admin page no longer crashes on the Inventory and Analytics tabs. Opening the Inventory tab threw an error ("A slot prop is required") and the Analytics tab threw "Cannot convert undefined or null to object" — both white-screened the whole admin page via the error boundary. The Inventory crash was the row-selection checkboxes being misread as a data table's built-in selection control; the Analytics crash was the backend omitting two fields from its empty response when no crawler-traffic log exists yet (always the case before any bots have visited). Both tabs now load correctly, and the Analytics view is hardened so a partial response can never blank the page again.
- Device push notifications now reach every important alert. Many notifications — new messages, connection requests, likes, comments, replies, mentions, matches, achievements, job and goal updates, volunteering alerts, marketplace payouts, exchange and admin alerts and more — previously only appeared as an in-app bell or email and never arrived on your phone or browser as a push, because push delivery was incorrectly tied to the email-digest setting (which is off by default). Push is now its own channel, controlled solely by your push toggle, and fires for every notification type that warrants it on both web and mobile. It is de-duplicated, so a burst of activity on the same item (e.g. many likes on one post) can't flood your device.
- "Powered By" / partner-logo image removal and freshness in admin settings now work. Removing a "Powered By" light/dark image (the × button) is now persisted on Save instead of silently reverting with "no changes saved" — the image fields are tracked in the save diff and the remove button no longer pre-clears the baseline the diff compares against. Uploading or replacing a "Powered By" or partner-logo image now also busts the cached tenant bootstrap server-side, so the footer shows the new image immediately instead of the old one until the 10-minute cache expired. (Uploads already persisted to the database; removal had no working code path, and uploads left a stale bootstrap cache.)
- Footer branding now updates immediately after saving admin settings. Saving footer text, the "Powered By" label/URL, or the partner logo on the admin System Settings page now refreshes the live tenant context, so the footer reflects the new values right away instead of falling back to the default NEXUS branding until a hard page reload. (The backend already persisted the change and busted its cache; the SPA simply wasn't re-fetching its in-memory tenant bootstrap after the save.)

### Changed

- Module Configuration is now visible only to super-admins. The "Module Configuration" entry in the admin sidebar (under Platform Operations) — which toggles a community's core and feature modules — is now shown only to tenant super-admins (and platform/god admins). Regular admins no longer see it in the navigation.
- HeroUI v3 component-usage sweep across core pages. Audited the post-login pages — Feed, Explore, Listings, Exchanges, Group Exchanges (list + create wizard), Wallet, Messages, and Dashboard — to use HeroUI v3's dedicated components instead of hand-rolled equivalents. Filter, view-mode, and split-type selectors now use ToggleButtonGroup/RadioGroup/TagGroup; numeric fields use NumberField; member search uses SearchField; status pills use Chip; the listings load-more bar uses Progress; dividers use Separator. Also fixed several invalid interactive-element-inside-link cards (the listings save button, Explore "View Group"/"Vote Now" CTAs, and the feed composer's nested action buttons) for correct, accessible markup. No behavioural changes.
- HeroUI v3 component-usage sweep — round 2 (Members, Connections, Events, Groups, Volunteering). Continued the sweep across the remaining post-login modules. Quick-filter / view-mode / day-range / mood / RSVP selectors now use ToggleButtonGroup (or standalone ToggleButton) instead of Button+aria-pressed; the group branding picker uses the v3 ColorPicker (area + hue slider + hex field) instead of native <input type="color">; the wiki parent-page picker uses Select; remaining hand-rolled status pills use Chip; and several more interactive-element-inside-link cards/CTAs were converted to Button as={Link} for valid, accessible markup. No behavioural changes.
- HeroUI v3 component-usage sweep — round 7 (Help, About). Converted the remaining navigation CTAs on the Help Centre and public About page from <Link><Button> to Button as={Link} for valid, accessible markup. No behavioural changes.
- HeroUI v3 component-usage sweep — round 6 (Federation members/listings, Achievements). Federation member service-reach filter and listing type filter → ToggleButtonGroup (were Chip-as-button with manual role="button"/aria-pressed). Clickable cards on Federation listings and Achievements dropped their redundant manual role="button"/tabIndex/onKeyDown — GlassCard's onClick already makes the underlying v3 Card isPressable, which supplies button role + keyboard activation natively. No behavioural changes.
- HeroUI v3 component-usage sweep — round 5 (Notifications, Marketplace map search). Notifications all/unread filter → ToggleButtonGroup; settings CTA → Button as={Link}. Marketplace map/list view toggle → ToggleButtonGroup (the previous buttons conveyed active state only visually — no aria-pressed — so this also fixes a screen-reader gap); added a map.view_toggle aria-label across all 11 languages. No behavioural changes.
- HeroUI v3 component-usage sweep — round 4 (Profile, Settings, Goals, Polls, Ideation, Matches, Skills). Continued the sweep across more post-login pages. Profile/Matches/Settings navigation CTAs and back-links → Button as={Link} (removing invalid button-inside-link markup); the Settings theme picker, Goals view-switcher, Polls category filter, and the Skills per-category selector → ToggleButtonGroup; Goal deadline fields → DatePicker; the Ideation tag filter → TagGroup; the Polls ranked-result bar → Progress. No behavioural changes.
- HeroUI v3 component-usage sweep — round 3 (Resources, Onboarding, Jobs, Courses, Caring Community). Resources reorder/category controls → ToggleButton/ToggleButtonGroup; the onboarding interests/skills multi-select clouds → TagGroup; Jobs and Courses/Caring-Community navigation CTAs → Button as={Link} (removing invalid button-inside-link markup); the courses catalog and lesson-nav controls → SearchField and Button; remaining source/status pills → Chip. No behavioural changes.
- "Report a problem" is now logged-in only, and de-duplicated. The floating problem-reporter (bottom-right) no longer renders for anonymous visitors, and the duplicate "Report a problem" link in the desktop footer was removed since the floating reporter already covers desktop. This stops logged-out traffic from cluttering support reports / Sentry. (Submissions were already auth-gated server-side via requireAuth and rate-limited at 10/min; this closes the visible entry point too.)
- Proprietary brand names removed from the Caring Community module. All visible mentions of "KISS", "AGORIS/Agoris", "Age-Stiftung", and "Koordination und Innovation für Soziales" have been replaced with "Caring Community" throughout the frontend, admin panel, and all 11 language files. The Agoris node option was removed from the pilot inquiry form. Database table and column names, the agoris tenant slug, and internal service class names are unchanged.
- React upgraded from 18 to 19. Full production upgrade including React 19 concurrent features, updated type definitions (@types/react 19.x), and Vitest/testing-library compatibility fixes. All 223 usages of deprecated APIs resolved. Build, type-check, and smoke tests pass.
- framer-motion removed. As part of the React 19 modernization, the framer-motion dependency was dropped and replaced with a local CSS-transition-backed shim at @/lib/motion, repointing every animation import site. Smaller bundle and one fewer heavy dependency, with no visible change to animations.
- HeroUI v3 migration — complete. The frontend component library migration from v2 to v3 (@heroui/react) is finished; the v2 npm alias has been removed. All remaining components were migrated to v3 (Dropdown, Select, Accordion, Tabs, Modal, Drawer, Table, Badge, Switch, Checkbox, Radio, Tooltip, Skeleton, Slider, Popover, Pagination, DatePicker, ScrollShadow, ButtonGroup), the useDisclosure → useOverlayState hook adapter landed, the @heroui/styles internal dependency was removed, and a final wrapper/test/visual audit pass confirmed the app is clean on v3. (Supersedes the earlier "phases 1–5 of 10" status.)
- Admin panel is now fully translated in all 11 languages. The previous admin-English-only policy was reversed; admin UI strings now resolve through the same i18n system as the rest of the platform, and PHP locale namespaces were filled across all 10 non-English languages.
- Module Configuration cleaned up. Five unimplemented orphan modules (merchant coupons, member premium, AI agents, partner API, regional analytics) were removed from the admin Module Configuration screen so admins no longer see dead toggles. The Help Centre link was also removed from the utility navbar and a duplicate AGPL footer notice was removed.
- Caring Community marked as Alpha. The Caring Community module on the admin Module Configuration screen is now labelled "Caring Community Alpha" and shows an Alpha development-stage badge. Module cards support an optional stage (alpha/beta) marker, translated in all 11 languages.

### Accessibility

- Search boxes upgraded to HeroUI v3 SearchField across all browse/list pages. Filter inputs on Federation (listings, groups, events, members, messages), Organisations, Groups, Ideation, Marketplace (search, category, map), Messages (conversation list and in-conversation search), Talent search, Volunteering (opportunity detail and org applications), the group Files and Q&A tabs, and the Explore search entry now use the v3 SearchField primitive instead of a generic text input with a search icon — gaining a built-in clear (×) button, role="searchbox" semantics, and the "Search" key on mobile keyboards. (The shared SearchField wrapper now also forwards a plain className; inputs that need an autocomplete results dropdown were intentionally left on Input.)
- Marketplace category breadcrumb consolidated onto the shared Breadcrumbs component. The category page's hand-rolled inline <nav> breadcrumb now uses the same Breadcrumbs component as the rest of the app, gaining aria-current="page", consistent label truncation, and 44px touch targets.
- Platform-wide WCAG 2.1 AA audit (four rounds). A multi-round accessibility campaign brought the React frontend and admin panel toward WCAG 2.1 AA: colour-contrast fixes, semantic landmarks and role attributes (e.g. feed cards as article), ARIA labels on all Tabs and Input elements, accessible names on icon-only controls, aria-expanded on menus, aria-live/live regions for chat and search results, aria-busy on skeletons, keyboard support for sortable table-column headers, focus indicators, and usePageTitle on pages that were missing a browser/screen-reader title. Round 4 alone covered 24 pages and 10 admin modules. The accessible-frontend link was relabelled to reference "WCAG 2.2 AA" across all 11 languages.

### Security

- Member surnames are now hidden from non-admin viewers, platform-wide. Surnames were previously returned in all user-facing API responses. They are now gated behind an admin check at every exposure point (public profile, member directory listing, and member search), so non-admins see only the first name while admins continue to see full names.

### Fixed

- Admin "Inactive members" report no longer crashes on load. Visiting /admin/reports/inactive-members rendered the "Something went wrong" error boundary fallback with the message A slot prop is required. Valid slot names are "selection". The page wrapped a bare <Checkbox> inside a <TableColumn> for its "select all" affordance, but HeroUI v3 Table (React Aria Components) only permits checkboxes in the header when they use the built-in slot="selection" — i.e. when the Table itself is in selectionMode. Replaced the manual checkbox column and per-row checkbox cell with the Table's native multi-selection (selectionMode="multiple", selectedKeys/onSelectionChange bound to the existing selectedIds state, rows keyed by user_id). Also fixed a separate latent issue spotted in the same pass: the flag-type filter Select had an id="" "All types" option (React Aria Collections reject empty-string ids), now 'all' (omitted from the API request so backend behaviour is unchanged), and <TableColumn width={40}> (not a valid v3 prop) is gone with the column it sized. Reported via support NXR-260528-L6WQSE.
- HeroUI v3: profile hover-cards no longer flicker. Hovering an avatar on a feed card (and an @mention in post text) made the profile preview popover rapidly flash open and closed. Both are controlled HeroUI v3 Popovers (React Aria); their open state was being driven in a way that diverged from React Aria's internal trigger state — UserHoverCard delayed the close inside onOpenChange, and MentionRenderer flipped the open state instantly from raw mouse handlers — causing an open/close oscillation. Both now use a single hover-intent timer as the only opener and apply React Aria's close requests immediately, so the card opens and closes smoothly and still dismisses on Esc / click-outside.
- HeroUI v3: dropdown menus with selection now show a checkmark. Selectable dropdown menus (e.g. the language switcher, admin sidebar sections, feed filter menus) highlighted the chosen option internally but rendered no visible tick, because the shared Dropdown wrapper never emitted the v3 selection indicator. The indicator is now rendered for single/multiple-selection menus only — plain action menus are unchanged (no empty indicator gutter).
- Welcome credits now granted when admin approves via status change. Admins were approving members by editing their status to "active" (the user detail edit page) rather than using the dedicated Approve button. The generic update() endpoint set is_approved=1 but never called grantWelcomeCredits, so no starting balance was applied. Fix: detect the pending → active transition in update() and run the same credit-grant + welcome-email + in-app-notification flow as the dedicated /approve endpoint. Also fixed: grantWelcomeCredits was reading the welcome_credits tenant-settings key (which doesn't exist) instead of wallet.starting_balance (the key the admin Settings page actually writes). The code now reads wallet.starting_balance with a fallback chain so every tenant resolves the correct value. Backfilled 5 credits to the one user who had been approved but received nothing.
- Member activity reports now show real data. AuthController::login() never stamped last_login_at on successful login after the Laravel migration, so /admin/reports/members showed "No active members found" for all tenants. Fixed by adding DB::table('users')->update(['last_login_at' => now()]) immediately after token creation. A backfill migration approximates past login dates from personal_access_tokens.created_at for all active users so reports are immediately useful.
- Wallet transfer/donation UX and a 404. The wallet DonateModal exposed an unusable numeric "Recipient ID" field, now replaced with an avatar/member search picker. A missing GET /v2/wallet/config endpoint that caused the TransferModal to 404 every time it opened was added.
- Member profile tabs no longer collapse. A HeroUI v3 Tabs CSS issue hid all but the selected tab on member profiles; all tabs render correctly again.
- Dashboard and Explore layout fixes. Restored the dashboard quick-action tiles and stopped the Explore page tabs from wrapping onto a second line.
- Admin search box no longer triggers browser autofill. The browser was autofilling a saved admin email into the admin search field; this is now suppressed.
- Build & infrastructure reliability. Raised the Workbox precache size limit to 5 MB (large bundles were being skipped), cast GD image dimensions to integers and guarded against localStorage quota errors, unpinned the Redis PECL extension to fix the Docker build, added a queue watchdog cron to recover dead Horizon workers, and hardened container storage permissions.

---

## 1.5.1 - 2026-05-20

### Fixed

- Proximity regression guard tests added for listings, events, and members. Three new integration tests assert that a listing/event/member at Cork coordinates (~258 km from Dublin) is excluded from a 10 km Dublin-centred radius search. These would have caught every recurrence of the proximity filter being silently ignored. Also fixed: ListingService::countAll() proximity subquery had a binding-order bug — mergeBindings() placed WHERE-clause values before SELECT bindings, causing MariaDB to evaluate cos(radians('active')) as the latitude. Replaced with a raw DB::selectOne() query using explicit ordered bindings.
- Security: cross-tenant data access hardened in ExchangeService and MarketplaceListingService. ExchangeService::accept() and decline() refetched exchange records for notification dispatch without a tenant_id constraint — a defence-in-depth gap that could pass cross-tenant metadata to the notification system. Both refetches now include ->where('tenant_id', TenantContext::getId()), consistent with complete() which was already correct. MarketplaceListingService::saveListing() had no ownership check before creating a saved-listing record — a user on Tenant A could bookmark a listing from Tenant B by crafting a direct API call. A tenant guard via HasTenantScope is now applied before the firstOrCreate.
- Events proximity pagination returns correct results on Load More. EventService::getAll() applied distance ordering (ORDER BY distance_km ASC) when proximity was active, but still built the cursor from last_id and decoded it as WHERE id < cursor on subsequent requests — a keyset/sort mismatch that caused Load More to skip or repeat events. Proximity path now uses offset-based pagination (cursor format nearby:N) consistent with ListingService.getNearby().
- Proximity "Near me" filter broken end-to-end — five separate issues fixed across listings and events. Full audit revealed: (1) ListingService::getAll() received near_lat/near_lng/radius_km from the controller but never applied them — all listings returned regardless of distance (root cause of Dublin user seeing Cork listings at 2 km). Fixed by delegating to the existing getNearby() haversine query when coordinates are present. (2) ListingService::countAll() also ignored proximity, showing a wrong total count in the results badge; fixed with a subquery-based haversine count. (3) Both getAll() and countAll() were missing near_lat from the $hasFacetedFilters guard, so search + proximity queries incorrectly used Meilisearch totals. (4) ListingsController applied the personalisation re-ranker and smart-match ranker after proximity results, destroying the nearest-first order; both ranking passes now skip when proximity is active. (5) EventService::getAll() had the identical structural bug (proximity params ignored); fixed by applying the haversine filter inline, consistent with how VolunteerService already correctly handles it. Also fixed in the React UI: activeFilterCount now includes proximity so the "Filters" badge reflects it, and the "Clear filters" button now resets the proximity pill correctly using a remount key.
- GDPR: account deletion now scrubs the email_log audit trail and clears the user's row from email_suppression. The original email address is captured before the user record is anonymised so the platform-wide suppression cache (keyed on email, not user id) can be cleaned up. Recipient address in email_log is anonymised in place rather than deleted, preserving tenant-level aggregate deliverability metrics.
- Stuck notification_queue rows from before the daily-digest opt-in flip are now expired. Cleanup task now also marks status='pending' rows older than 7 days as failed so the digest cron doesn't send a member a "what happened in March" digest after the deploy. The 30-day cleanup also now sweeps failed rows along with sent (was only sent before).
- Dropped dead users.email_preferences JSON column. Never read, never written by application code (verified by grep); removed via guarded hasColumn migration so it's safe to re-run.
- DKIM / SPF / DMARC verified healthy on production — project-nexus.net has strict SPF (include:sendgrid.net -all), both s1._domainkey and s2._domainkey CNAMEs validly point at SendGrid, SendGrid reports the domain as valid=True. DMARC is at p=none (monitor-only); recommended to escalate to p=quarantine after 30 days of clean aggregate reports.

### Added

- Hierarchical domain inheritance for sub-tenants. Slug-only sub-tenants whose immediate parent has a custom domain are now accessible at parent.domain/child-slug (e.g. timebanking.uk/cardiff) in addition to app.project-nexus.ie/cardiff. The backend resolves the child tenant from the first path segment after locking on the parent's custom domain, and the bootstrap API returns a parent_domain field so the SPA automatically uses path-prefixed routing on the parent's domain. Email and notification links for these sub-tenants now correctly emit timebanking.uk/cardiff/... URLs (including queued/background jobs via a DB parent-domain lookup fallback). The sitemap layer is fully wired: timebanking.uk/sitemap.xml returns a sitemap index listing both the parent's and each sub-tenant's sitemap; /sitemap-{childslug}.xml generates URLs with the correct parentdomain/childslug/... base. The prerender pipeline (prerender:plan-routes and prerender-tenants.sh) prerenders sub-tenant pages at timebanking.uk/cardiff/... instead of app.project-nexus.ie/cardiff/.... SitemapService::generateForAppDomain() excludes sub-tenants that belong under a parent domain so they don't appear with wrong canonical URLs in the shared-host sitemap. Redis bootstrap cache is invalidated automatically on hierarchy moves and domain changes. Moving a tenant in the hierarchy tree is safe at any time — domain associations update immediately with no stale cached state. No DNS or nginx changes are required beyond pointing the parent domain at the platform.
- SEO: organization type, geo meta tags, and structured data enhancements. Tenant super-admins can now set a seo_organization_type (e.g. LocalBusiness, EducationalOrganization, NonprofitOrganization) via the admin tenant form, overriding the global Schema.org @type default. Geo meta tags (geo.region, geo.country, geo.placename, ICBM lat/long) are emitted in <head> when the tenant has location coordinates — helps Google/Bing assign geographic context and reduces multi-tenant duplicate-content risk. LocalBusiness tenants get an areaServed block in the org schema that maps service_area scope to Schema.org types (City, AdministrativeArea, Country, Place). Added @id anchor to org schema for cross-referencing. Lat/lng and service_area are now included in the bootstrap API contact payload.
- Admin panel: Registration Security card on /admin/settings/registration-policy. Front-and-centre status card for the per-tenant circuit breaker. Polls every 30s. Green chip when signups are flowing normally; red border + alarm-banner + one-click "Resume signups now" button when the breaker has tripped. Includes the live signup count vs threshold so admins can see "we're at 18 of 20 this hour" before the breaker actually fires. Additive — sits above the existing registration-policy form, doesn't touch any existing components.

### Added

- Existing SendGrid event webhook (POST /api/v2/webhooks/sendgrid/events) extended to populate email_log + email_suppression in real time. The webhook was already wired into NewsletterBounce and EmailMonitorService for legacy bounce / complaint tracking; now also updates the new deliverability tables: matches the row by recipient + sg_message_id prefix, advances status to delivered / bounced / failed (never regresses a terminal state), populates delivered_at / bounced_at / opened_at, upserts email_suppression on bounce / dropped / spamreport / unsubscribe. Adds support for open / click / unsubscribe event types that the legacy handler ignored. Uses the existing ECDSA verification via SENDGRID_WEBHOOK_VERIFICATION_KEY — no new env vars or routes. The Mailer captures the X-Message-Id header from SendGrid on send and writes it to email_log.provider_message_id so webhook events match back to log rows.
- Admin email deliverability dashboard at /admin/email-deliverability. Per-tenant headline metrics (delivered %, bounced %, status breakdown over 1/7/30/90 days), filterable email_log feed (recipient + status + date range), platform-wide suppression-list view with one-click remove (clears locally AND in SendGrid), and a per-user history endpoint. Operators can now answer "did Joe Bloggs get his welcome email?" with a click instead of a SSH session.
- Mobile push (FCM) fan-out from NotificationDispatcher. Previously the dispatcher's instant path only fired web push and silently skipped the Capacitor mobile app — direct messages, connection requests, volunteer-application status, mentions etc. were invisible on mobile. Dispatcher now fans out web push + FCM push in parallel, failure-isolated so one provider's outage doesn't suppress the other. FCMPushService::sendToUser/sendToUsers also now honours notification_preferences.push_enabled so members can actually turn mobile push off (previously the preference existed but had no effect on FCM).
- emails:reconcile-transient-failures artisan command (every 15 min). Cross-checks recent email_log rows with status=failed against SendGrid's /v3/messages activity feed. If SendGrid actually accepted the send despite a transient 5xx on our side, the log row is repaired to delivered so the audit trail reflects reality. Genuine failures stay flagged.
- Per-recipient email rate limit. Redis-backed rolling-hour counter (default 30/hour/recipient, configurable via MAILER_PER_RECIPIENT_HOURLY_LIMIT, 0 disables). Catches runaway loops / buggy listeners that would otherwise flood a single member with dozens of emails. Logged as status=failed, error="per-recipient rate limit exceeded" so admins see what tripped the limit.
- expireOverdueJobs() + expireFeaturedJobs() re-implemented. Both methods were removed in an earlier refactor; the cron entries were throwing undefined method once per tenant per day. Now: featured jobs lose is_featured once featured_until is past; open jobs older than 180 days with no edit in the last 60 days are auto-closed. AchievementCampaignService::processRecurringCampaigns() is also re-implemented (ticks last_run_at for due recurring campaigns; award logic stubbed pending product decision on missed-runs). BrokerMessageVisibilityService::expireMonitoringBatch() stays no-op'd — the underlying schema (broker_monitoring table) does not exist on production.
- Integration test WelcomeEmailCrossTenantTest — asserts the welcome listener still works when TenantContext is pre-leaked from a tenant-2 job. Directly guards the original incident from regressing.
- Email observability: email_log audit table + email_suppression cache. Every Mailer::send() now writes a row capturing tenant, user, recipient, subject, status (queued/sent/failed/suppressed/bounced/delivered), provider message id, and error. Operators can finally answer "did Joe Bloggs get his welcome email?" without grepping log files. The companion email_suppression table is hydrated hourly by a new sendgrid:sync-suppressions artisan command that pulls SendGrid's bounce / block / invalid / spam-report lists; the Mailer checks suppression before every send and refuses to mail addresses SendGrid has already told us are dead (saves quota, protects sender reputation, surfaces invalid member emails to admins).
- One-click unsubscribe (Gmail/Yahoo Feb-2024 bulk-sender compliance). New NotificationUnsubscribeController plus /api/v2/notifications/unsubscribe route (GET for browser visits, POST for List-Unsubscribe-Post). Token format is HMAC-signed userId.tenantId.category.sig; categories map to notification preference keys (all/messages/connections/transactions/reviews/listings/digest/gamification/org/federation). The Mailer auto-attaches the header on every send by looking up the recipient in the current tenant — no caller changes needed for 30+ existing email-sending services. Confirmation page is locale-aware, tenant-branded, no-indexed.
- One-shot recovery: php artisan emails:resend-stuck-activations. Re-sends welcome/verification emails to members who registered while the earlier TenantContext leak / Mailer bypass bugs were live and never got their activation email. Defaults to --dry-run so you can sanity-check the recipient list. --since=60days, --tenant=N, --limit=200 flags for scoping. Reuses the canonical Mailer path so the email_log and suppression checks still apply.
- Notification settings: caring_smart_nudges and federation_notifications_enabled UI toggles. Both were backend-supported but had no UI control — members had no way to opt out. Now exposed in /settings → Notifications. federation_notifications_enabled is a column (not part of the JSON), so the GET/PUT endpoints were extended to read/write it alongside the rest of the preferences.
- Members can opt INTO the activity digest from notification settings. New "Activity digest frequency" selector in /settings → Notifications lets members pick off (default) / instant / daily / weekly. Backed by a new GET /api/v2/notifications/settings endpoint that returns the user's notification_settings.global row plus per-group and per-thread overrides; the existing POST endpoint upserts. Critical user-facing events (direct messages, connection requests/accepts, volunteer-application status, volunteer-hours approval) are forced to 'instant' regardless of this setting so disabling the digest never silences them.
- Inbound federation event ingestion wired up for review / connection / listing / community-event / member-updated. The federation webhook controller had been dispatching these 5 events for months with no listener registered, so the event(...) calls were dropped on the floor (the controller's local DB persistence still ran, so no data was lost). Five new listeners: HandleFederatedReviewReceived — notifies the local reviewee with an in-app bell + email (anonymous "Someone left you a 5-star review" so we don't dox the partner-side reviewer name). HandleFederatedConnectionReceived — notifies the local user of an inbound partner connection request / accept. HandleFederatedListingReceived, HandleFederatedCommunityEventReceived, HandleFederatedMemberUpdated — observability-only structured audit logs; persistence is already complete in the controller, and these inbound bulk-content events don't map to a specific local user to notify. Future extension points kept in the listener for search-index sync.
- In-app bell notifications for group chatroom messages. GroupChatroomMessagePosted had a Pusher broadcast for online members but no listener — members who weren't online missed the message entirely. New NotifyGroupChatroomMessage listener creates an in-app bell row for every active group member (excluding the sender and anyone who muted them), with a 5-minute dedup window so a burst of chat messages doesn't produce a wall of bell rows. No email — chat volume is too high to safely email every message; users who want email coverage of group chatter can opt into the daily digest.

### Changed

- Daily activity digest is now OFF by default. Members reported the previous daily-email default felt like spam. NotificationDispatcher::dispatch() and NotificationDispatcher::getFrequencySetting() and EventNotificationService::resolveFrequency() now fall back to 'off' when the member has no row in notification_settings, replacing the previous 'daily' fallback. The new opt-in selector in /settings → Notifications lets members turn it on if they want it. Six critical activity types still force 'instant' regardless: new_message, connection_request, connection_accepted, vol_application_approved, vol_application_declined, vol_hours_approved.

### Fixed

- Five notification-preference enforcement bugs. An audit of every email-sending listener found preferences silently ignored in 5 paths: NotificationDispatcher::sendReviewEmail did not check email_reviews — local + federated review emails ignored the pref. NotificationDispatcher::sendReviewRequestEmail checked the WRONG key (email_transactions instead of email_reviews). NotificationDispatcher::sendCreditEmail / sendCreditSentEmail had no defence-in-depth pref check (only the caller did, so any future caller would bypass). HandleFederatedReviewReceived did not honour federation_notifications_enabled (the per-user federation opt-out had no effect on federation review emails). CronJobRunner::processDigest sent the digest to every user with queued items regardless of their email_digest preference. All five now respect the matching preference; the digest path also marks the queued rows as sent when the pref is off so they don't pile up indefinitely.
- CI guard against Laravel Mail:: facade. Extended EmailMailerRoutingTest::test_no_mail_facade_usage_anywhere_in_app to scan every file under app/ (excluding app/Mail/ Mailable definitions and comments) for Mail::raw|Mail::to|Mail::send|Mail::queue|Mail::later|Mail::mailer. Any future regression that bypasses Mailer::forCurrentTenant() fails CI instead of silently dropping production emails. The platform .env has SendGrid configured and intentionally NO SMTP credentials — facade usage routes through Laravel's default SMTP mailer and silently drops the message.
- Structural defence: SerializesModels removed from all 30 events. Closes the deserialization trap permanently: even if a queued job's finally { reset(); } is missed for any reason, no Eloquent re-fetch happens at job-pickup time, so a stale TenantContext can no longer poison a model lookup. Event payloads are now snapshotted by PHP's default serializer with the full in-memory model state. Slightly larger queue payload for negligible savings before; effectively zero risk of the cross-tenant filter ever firing against a wrong-tenant id again.
- Newsletter template backfill: every tenant now has the starter newsletter templates. Re-engagement / nurture / onboarding starter templates were originally seeded for tenant 2 only via January-2026 migrations. Other tenants saw an empty admin "New newsletter" page and any code path looking up a template by category returned nothing. New idempotent migration copies every category='starter' row from tenant 2 into every active tenant; per-tenant edits made later are preserved (skip on (tenant_id, name, category) collision).
- Critical: daily / weekly digest emails were silently dropped for every user across every tenant due to a TenantContext leak inside the cron loop. CronJobRunner::processDigest() called User::findById($userId) BEFORE setting TenantContext::setById($user['tenant_id']) — so the Eloquent HasTenantScope filter applied a WHERE tenant_id = <previous-iteration's-tenant> clause and returned null for every user whose tenant didn't match the leaked context. Result: every user was logged as "Skipping User ID X (No email/Invalid)" — 31 skipped users on the most recent run, and 52 pending notifications stuck in notification_queue for 7 weeks (oldest from 2026-03-30). Fixed by calling TenantContext::reset() at the start of each iteration so findById runs with a clean baseline. The same pattern now also defends runSubTask() (resets before+after every cron sub-task) and forEachTenant() (resets between tenant iterations + final reset on exit) — closes the leak surface across the entire cron pipeline.
- Critical: BalanceAlertService::checkAllBalances() and ListingExpiryService::processAllTenants() were called statically every day but are instance methods. The cron threw "Non-static method ... cannot be called statically" 12 times per day for each (once per tenant), silently dropping every low-organisation-wallet alert email and every listing-expiry email across every tenant. Both now resolved via app(...) container resolution. Also no-op'd four other cron tasks that referenced services whose methods were removed in a refactor (JobVacancyService::expireOverdueJobs/expireFeaturedJobs, BrokerMessageVisibilityService::expireMonitoringBatch, AchievementCampaignService::processRecurringCampaigns) — these were spamming 12-48 "undefined method" errors per cron run; now print a single "skipped" line until the service replacements ship.
- Critical: four email-sending paths bypassed the platform Mailer and silently failed in production. Production .env configures SendGrid (SENDGRID_API_KEY set, FROM noreply@project-nexus.net) and does NOT configure SMTP (MAIL_USERNAME and MAIL_PASSWORD are intentionally unset). Four call sites used Laravel's Mail::to(...)->send(...) or Mail::raw(...) which routes through config('mail.default') (= smtp) — sends were attempted via an unconfigured SMTP server and dropped silently. Rerouted through \App\Core\Mailer::forCurrentTenant() so they now use SendGrid like everything else: (1) SafeguardingService.php:630 — critical safeguarding alerts were not being delivered; the SafeguardingCriticalMail Mailable is now rendered to HTML and sent via Mailer. (2) AdminBillingController.php:249 — billing-upgrade-request notifications to the platform owner. (3) GenerateMonthlyReports.php:127 — regional analytics report-ready emails. (4) AdminEmailController::test() — admin "send test email" now uses Mailer::forCurrentTenant() (matching testProvider()) so it respects per-tenant email_settings instead of only the platform .env.
- Critical follow-up: structural defence against the same trap firing during job deserialization. Per-listener finally { reset(); } only fires after handle() runs — a job that throws during its payload's deserialization (e.g. when SerializesModels::restoreModel() calls User::findOrFail() with a stale TenantContext and ModelNotFoundException blows up) never reaches that finally, so the stale context persists into the next job. Registered global Queue::before / Queue::after / Queue::failing hooks in AppServiceProvider::boot() that call TenantContext::reset() around every queued job. Queue::before fires BEFORE deserialization, guaranteeing every job starts with a clean null context regardless of what the previous job did. Combined with the per-listener finally pattern this is true defence-in-depth. Also removed SerializesModels from the UserRegistered event so its User model is snapshotted in-memory instead of re-fetched from the DB on dequeue — eliminates the deserialization round-trip for the most user-facing event (welcome / activation email).
- Critical: TenantContext static state leaked between Horizon queue jobs, silently dropping welcome emails, cron notifications, and federation pushes for all tenants except the first one processed by a worker. All 18 queued event listeners were missing TenantContext::reset() in a finally block. Horizon runs up to 4 long-lived worker processes; without the reset, static TenantContext state from one job leaked into the next job the same worker picked up. When Laravel's SerializesModels trait re-fetched the User Eloquent model during job deserialization, TenantScope applied a WHERE tenant_id = <stale> clause — causing ModelNotFoundException to silently swallow the job and drop the email. Now all queued listeners call TenantContext::reset() in finally, matching the pattern already used by NotifyAdminOfNewRegistration. Affected listeners: SendWelcomeNotification, SendOnboardingCompletionEmail, NotifyJobAlertSubscribers, UpdateWalletBalance, UpdateFeedOnListingCreated, CopyMessageForBrokerReview, and all 12 Push*ToFederated* federation listeners. Also fixed FederationInitialSyncJob (two setById calls, no reset) and RunAdminCronJob (delegates to CronJobRunner which calls setById across 7 code paths for multi-tenant cron processing).

### Security

- Registration form: multi-field honeypot. Three additional decoy inputs with realistic names (confirm_email, address_line_2, referral_code) sit alongside the existing website honeypot — all hidden via off-screen CSS positioning (catches more bots than display:none). Server silent-no-ops if ANY of the four come back non-empty. React form also checks all four refs client-side. Catches sophisticated bots that filter on the legacy website field name but can't tell which other inputs to skip.
- Email verification is now required for every tenant (current and future) and can only be disabled by God (platform super-admin). TenantSettingsService::requiresEmailVerification() now defaults to TRUE (fail-closed), reads the bare email_verification key (plus general.email_verification fallback for legacy rows), and the login gate calls requiresEmailVerification() consistently. A backfill migration (migrations/2026_05_16_enforce_email_verification_all_tenants.sql) writes email_verification=true to every existing tenant row and syncs tenant_registration_policies.require_email_verify=1. New tenant seeding was corrected from the orphaned general.email_verification key to the bare email_verification key. Both the Admin Settings and Registration Policy pages now lock the toggle with a "God only" chip for non-platform-super-admins. AdminConfigController::updateSettings() and RegistrationPolicyController::updatePolicy() enforce this server-side with a 403 for non-platform-super-admins.
- Admin approval and email verification toggles are now God-only in the admin UI. Both the "Require email verification" and "Admin approval required" switches on /admin/settings and /admin/settings/registration-policy are locked with a "God only" chip for tenant admins and tenant super-admins. The backend enforces this via requirePlatformSuperAdmin() — only platform super-admins (role god/super_admin or is_super_admin=true) may change these settings.
- Admin approval is now required for every tenant (current and future). TenantSettingsService::requiresAdminApproval() now defaults to TRUE (fail-closed) so any tenant without an explicit setting still enforces the gate. A backfill migration (migrations/2026_05_16_enforce_admin_approval_all_tenants.sql) writes admin_approval=true to every existing tenant row so the policy is explicit in the database. Tenant seeding (TenantHierarchyService::seedTenantDefaults) now writes the bare admin_approval key the reader actually checks — the previous general.admin_approval row was orphaned (reader never looked it up), which meant new tenants silently ran with admin approval disabled. Already-approved members are unaffected; only new registrations (and accounts still in status='pending') require an admin to approve them before login.
- Registration form: per-tenant hourly circuit breaker (default 20/h). Last-line containment when everything else fails. If a single tenant gets a flood of signups in one hour, account creation is automatically paused for that tenant for an hour and the next signup attempt returns HTTP 503 REGISTRATION_TENANT_PAUSED. Auto-resumes after 1h; tenant admin can clear manually via POST /api/v2/admin/registration/resume-signups. Status visible at GET /api/v2/admin/registration/breaker. Worst-case outcome: a tenant loses 1 hour of legitimate signups — much better than waking up to 10,000 fake accounts. Configurable via env REGISTRATION_TENANT_HOURLY_CAP (set to 0 to disable).
- Registration form: per-IP daily cap on successful signups (default 5/24h). Stacks on top of the existing 3/5min route throttle. The route throttle caps raw request volume; this caps how many accounts a single IP can actually create in a 24-hour window — closes the "patient bot grinding 1 signup every 6 minutes" hole that the short rate-window leaves wide open. Counter increments ONLY on successful registration, so a user typing wrong passwords doesn't burn quota. Configurable via env REGISTRATION_DAILY_CAP_PER_IP (set to 0 to disable). Returns new REGISTRATION_DAILY_LIMIT (HTTP 429) with retry-after.
- Registration form: MX-record check on the email domain. Rejects signups where the email domain has no MX record AND no A record — catches typos like user@gmial.com, made-up domains bots fall back to, and freshly-registered burner domains without mail wiring. Returns new EMAIL_DOMAIN_INVALID error code with a "check for typos" hint that doubles as a UX win. Results cached 24h (positive) / 1h (negative); fails open on DNS errors so an outage doesn't block legitimate users. RFC-reserved .invalid TLD rejected without a DNS round-trip.
- Registration form: disposable / throwaway email-domain blocklist. Rejects signups from ~200 known temp-email providers (mailinator, 10minutemail, guerrillamail, tempmail, yopmail, etc., plus their sub-domains). New DisposableEmailService loads the curated list from resources/security/disposable-email-domains.txt; refresh from the canonical upstream list via scripts/update-disposable-emails.sh. Returns the new EMAIL_DISPOSABLE error code with a clear "use a permanent email address" message. Kills the cheapest bot-signup path — no inbox to pay for = no per-account cost.
- Registration form: verified-location gate (anti-fraud). The location field on both forms is now hard-gated to require lat/lng coordinates that came back from the place-autocomplete API (Google Places or Nominatim). Free-text gibberish like "555" — the exact bypass a recent attacker used — is rejected server-side with a new LOCATION_NOT_VERIFIED error code. Null Island (lat=0,lng=0) is also rejected as the obvious signature of a default-zero coordinate. The bar is now: an attacker must call a Geocoding API themselves to forge believable coordinates. Both forms show inline guidance to "pick a suggestion from the list".
- Registration form: server-side enforcement closes three React-side-only bypasses. The React frontend has long sent terms_accepted, password_confirmation, and invite_code in the registration payload, but RegistrationService::register() ignored them — meaning a scripted submission could skip the terms checkbox, mismatch passwords, or register on an invite_only tenant with no code at all. All three are now enforced server-side with distinct error codes (TERMS_REQUIRED, PASSWORD_MISMATCH, INVITE_REQUIRED, INVITE_INVALID). Invite codes are validated against InviteCodeService and redeemed atomically after the user row is created; if the redeem races out (concurrent registration consumed the last use), the new account is marked rejected so the code stays the gating signal.
- Min-form-time bot gate is now server-enforced. Both the React form and the new Blade form send a form_started_at timestamp; the service silently no-ops (success-shaped response, like the honeypot) when the elapsed time is < 5 seconds. Previously the 5-second check only ran in the React UI and a scripted POST would skip it.

### Fixed

- Bulk user approval now sends welcome emails, in-app notifications, and grants welcome credits. Previously bulkApprove() silently flipped is_approved=1 with no further action — users approved in bulk received no welcome email, no in-app notification, and no welcome credits. Fixed: after each successful updateAdminFields() call the same grantWelcomeCredits() / sendApprovalWelcomeEmail() / sendApprovalInAppNotification() helpers that single-user approve() already calls are now called per user. grantWelcomeCredits() is idempotent, so double-approval is still safe.
- FederationInitialSyncJob: TenantContext always reset even when an exception is thrown. The previous fix added a bare TenantContext::reset() on the success path only, leaving the Horizon worker's static TenantContext stale if the audit-log write threw. Wrapped the entire handle() body in try/finally { TenantContext::reset(); } so cleanup is guaranteed on every exit path.
- Mailer::forCurrentTenant() now logs a warning when called without a TenantContext. Calling this method with no active tenant context silently fell back to platform SMTP credentials, making cross-tenant email delivery failures invisible. A Log::warning() with a 5-frame backtrace is now emitted so these cases appear in the Laravel log.
- All 18 queued listeners now have finally { TenantContext::reset(); } in handle(). The previous listener audit commit was orphaned on a branch that diverged from main and was never merged — the fixes existed in git history but not on disk. Re-applied to all 18 listeners: CopyMessageForBrokerReview, NotifyJobAlertSubscribers, PushCommunityEventToFederatedPartners, PushConnectionAcceptedToFederatedPartner, PushFederationDataRetraction, PushGroupMembershipToFederatedPartners, PushGroupRetractionToFederatedPartners, PushGroupToFederatedPartners, PushListingToFederatedPartners, PushMemberProfileUpdateToFederatedPartners, PushMessageToFederatedPartner, PushReviewToFederatedPartner, PushTransactionToFederatedPartner, PushVolunteerOpportunityToFederatedPartners, SendOnboardingCompletionEmail, SendWelcomeNotification, UpdateFeedOnListingCreated, UpdateWalletBalance. Verified by grep: zero ShouldQueue classes that call setById() are now missing a reset.
- Module Configuration: browser autofill permanently blocked on the search input. Chrome was storing the user's previously-typed value (the admin email address) as "search history" for the type="search" input and restoring it on every page load — including after the Refresh button, because the loading spinner was unmounting and remounting the input, triggering a fresh autofill injection cycle. Fixed by: (1) changing the input to type="text" so Chrome's search-history persistence doesn't apply, (2) setting autoComplete="new-password" which browsers actually honour (unlike "off"), and (3) rendering the loading spinner inline instead of replacing the whole page so the input is never unmounted.

### Changed

- Accessible (GovUK Alpha) registration form: feature parity with the React form. Added profile-type radios (individual / organisation), conditional organisation-name field, conditional invite-code field (shown only when the tenant's effective registration policy is invite_only), password-confirmation field with live-match indicator, mandatory terms-of-service + privacy-policy checkbox, and Google Places autocomplete on the location field (progressive enhancement — form works without JS). The newsletter checkbox is preserved. New register-enhancements.js handles all client-side interactions; the existing password-strength.js continues to provide live NIST-aligned length + HIBP breach feedback.
- GovUK Alpha storeRegister controller maps six new service error codes to distinct Blade page statuses so users see specific messages ("you must accept the terms" / "this invite code is invalid") instead of the generic "check the form and try again" fallback.
- React RegisterPage now sends form_started_at to the server so the min-form-time gate enforces on this path too.

### Removed

- Cloudflare Turnstile removed from login, password-reset, and registration forms (2026-05-16). Both the React SPA and the GovUK Alpha accessible Blade frontend. Member feedback found the widget too confusing and the false-positive rate unacceptable on account-recovery and sign-in flows. Turnstile is retained on contact forms where the cost of a small amount of user friction is acceptable as spam defence. Bot/brute-force defence on auth endpoints is now: the DB-backed per-email + per-IP brute-force limiter, route-level throttle (login 30/min, password-reset 5/15min, register 3/5min), the registration honeypot, the registration admin-approval gate, and the email-enumeration safety on the password-reset response. Removed TurnstileService injection from AuthController, PasswordResetController, and RegistrationService. Removed useTurnstile() and widget JSX from LoginPage, ForgotPasswordPage, RegisterPage (desktop + mobile mounts). Removed cf-turnstile divs and api.js loader from accessible-frontend/views/login.blade.php and register.blade.php. Dead turnstile_token request types and dead register-turnstile-failed / turnstile-failed Blade status branches dropped.

### Fixed

- Cloudflare Turnstile rollout UX + silent-failure regressions (emergency). Same-day hotfix to today's Turnstile/bot-defence rollout. Two valuable members reported real problems: one found the visible "Verify you are human" widget confusing and suspicious, another could not get a password reset email no matter how many times he tried. Widget is now invisible for legitimate users. Switched the Turnstile widget to appearance: 'interaction-only' (Cloudflare's silent-pass mode). The widget only renders visibly when Cloudflare actually decides a human challenge is needed — roughly 1% of legitimate sessions. The other 99% never see a widget at all. Forgot-password no longer silently swallows errors. The page previously caught every error and showed a fake "we've sent you an email" success message — including when a Turnstile failure or rate limit blocked the request. It now distinguishes Turnstile failures, rate-limit hits, and generic errors with distinct messages so users know to retry. Per-email reset rate limit raised from 3/hr to 10/hr. Legitimate users hitting the 3/hr ceiling silently got "we sent you an email" with no email ever sent. The cap now matches realistic usage; bots are still blocked by per-IP throttle (5/min) + Turnstile. The endpoint now returns a real 429 instead of fake success. Single-use Turnstile tokens are reset on every failed submit across login, register, forgot-password, and contact pages. Previously a failed validation locked the form because the consumed token couldn't be re-used until full page reload. Backend uses a dedicated TURNSTILE_FAILED error code (was wrongly reusing VALIDATION_REQUIRED_FIELD / VALIDATION_INVALID_FORMAT). All four API call sites updated. Registration controller now passes specific error codes through so the React UI can show "this password appears in known breaches" vs "an account already exists" vs "the security check failed" — instead of a single catch-all message. GovUK Alpha Blade flows get the same treatment. storeLogin and storeRegister now map API error codes to distinct page statuses (turnstile-failed, rate-limited, email-not-verified, account-suspended, register-duplicate, register-password-pwned, etc.) so the accessible frontend shows useful messages too. Diagnostic logging added to the password-reset flow: per-email rate hits, unknown-email reset requests, and successful email dispatches are now logged with masked email + IP. Distinguishes "wrong email" from "mailer broken" when investigating future complaints. New optional useTurnstile().status + useTurnstile().reset() for callers that need to react to widget load failures or reset after a failed submit.

### Added

- Prerender engine — Round 4: tests + retry + sitemap explorer. 11 new tests covering the Round 2+3 logic: circuit breaker trip + claim-suppression, per-tenant concurrency cap, route validation rejecting shell metacharacters, audit secret-redaction, health check transitions, snapshot integrity (ok/mismatch/missing), TTL-pattern specificity resolution, safeCachePath accepting route special characters, observer-storm coalescing to a tenant-wide row. Without these, one refactor breaks the safety net. Job retry button. Failed / partial / cancelled jobs now have a "Retry" button on the Jobs tab that clones their parameters into a new queued row. Original job is preserved for history. New audit row links the two via retried_from_job_id. Sitemap explorer. New Overview card lets you punch in a tenant slug and see the exact route list the engine plans to render — static floor (feature/module gated) + dynamic URLs from SitemapService (capped at 1,000). Answers "what does the engine think this tenant has?" without grepping logs. react-frontend/CLAUDE.md updated with the full Round 2+3+4 architecture so future contributors don't have to re-derive it from the code.
- Prerender engine — Round 3: defense in depth + operator superpowers. Scheduler liveness tracking. Every prerender scheduled task (detect-drift, auto-recache, reap-stale) now stamps a cache key on success. The health endpoint checks the age of each stamp against 2×/3× the expected interval and surfaces a yellow/red check if the Laravel scheduler has stopped firing — catches the "supervisord nexus-scheduler died" failure mode that would otherwise be silent. Webhook nonce one-time-use. HMAC /invalidate already had a 5-min timestamp window; now each (timestamp, signature) pair can only be used once. The nonce is keyed by sha256(ts:sig) and persisted for 600s. Replay attempts are bounced AND audited with outcome=denied, reason=webhook_replay for forensics. Snapshot integrity verification. The Playwright worker now writes a .sha256 sidecar next to every index.html it renders. The Inspect drawer shows an integrity: ok|missing|mismatch|unreadable chip — mismatch is highlighted in danger color and the tooltip shows the expected vs actual prefixes. Catches filesystem corruption, bit rot, and hand-edits that would otherwise look like a valid snapshot. CSV export for the three operator-facing tables: GET /api/v2/admin/prerender/export/{audit,inventory,jobs}.csv. Streamed, capped at 5,000 rows. "Export CSV" button on the History tab; the same URLs work for cron-scraped exports. TTL inspector card on the Overview tab. Type a route, see which config/prerender.php pattern owns it, what TTL it gets, and what other patterns also match (with their specificities). No more grepping config to understand the freshness policy.
- Prerender engine — Round 2: self-healing, audit, observability artefacts. Building on the P0/P1/P2 audit, the engine now self-recovers from worker outages and ships first-class ops artefacts. Per-tenant concurrency cap. claimNextJob now skips rows whose tenant already has a job in flight. Stops a single slow tenant homepage from starving the queue. Circuit breaker. Five failed jobs inside a 10-minute window auto-pauses the queue for 15 minutes. Saves CPU on a wedged host and gives operators time to investigate. Closes automatically on cooldown; can be reset manually via POST /api/v2/admin/prerender/reset-breaker or the new admin UI button. Health endpoint. GET /api/v2/admin/prerender/health returns a traffic-light JSON (green/yellow/red) with per-check details (cache filesystem, breaker, queue age, failure rate, stuck rows) and an actionable action string on every failing check. Rendered into a banner at the top of the admin module. Emergency "Reset stuck queue" button in the health banner. Requeues every claimed/running row older than 30 min AND clears the breaker — one click. Rate-limited (2/5min per user) and audited. Audit log. New prerender_audit_log table persists every mutating action (enqueue, cancel, purge, invalidate, auto_recache, detect_drift, purge_unexpected, reset_breaker, reset_queue) with actor, IP, UA, outcome, sanitised details. New History tab in the admin UI surfaces it with an action filter. Secrets are scrubbed before persistence (password/token/secret/api_key keys redacted). Per-user per-action rate limiting on every mutating endpoint. Denied attempts are themselves audited so abuse leaves a trail. New Prometheus metrics: nexus_prerender_breaker_tripped, nexus_prerender_breaker_until_seconds, nexus_prerender_queue_oldest_age_seconds, nexus_prerender_health_status (0/1/2 enum). Grafana dashboard committed at docs-public/observability/prerender-grafana-dashboard.json — health + breaker + coverage + queue age + outcomes + per-tenant missing-route bargauge. Prometheus alerting rules committed at docs-public/observability/prerender-alerts.yml — 7 alerts (4 critical, 3 warning) covering RED health, breaker, cache, queue jam, coverage, recent failures, asset invalidation. Operator runbook at docs-public/observability/prerender-runbook.md — alert-by-alert response steps + emergency procedures + forensics index. Jobs tab gains a PRIORITY column showing HIGH/NORMAL/LOW with a tooltip explaining the numeric value. The lifecycle was already priority-aware (claim order is priority ASC, queued_at ASC); now you can see it at a glance.

### Fixed

- Prerender engine — admin module audit, full P0→P2 sweep. Following the new admin panel's introduction, prerender jobs were piling up in queued state forever and tenant admins reported all action buttons greyed out. Full audit + 13 fixes: 🔴 P0 — Host cron for the job processor was never installed. scripts/prerender-job-processor.sh documented a * * * * * cron entry in its header but nothing in the repo actually wrote it to /etc/cron.d/. The in-container Laravel scheduler can run prerender:detect-drift and prerender:auto-recache, but the processor MUST run on the host because it calls docker exec. Result: every job — observer-triggered, drift-triggered, TTL, manual — sat queued forever, and observer-deleted snapshots were never regenerated. New phase: scripts/deploy/phases/install-prerender-cron.sh writes /etc/cron.d/nexus-prerender-processor idempotently on every deploy. 🔴 P0 — No stale-job reaper. If the worker was OOM-killed, a deploy SIGTERMed it mid-flight, or the host rebooted, the row stayed claimed/running forever, blocking dashboards and distorting metrics. New prerender:reap-stale artisan command (also installed in the host cron, runs every 5 minutes) plus scheduler registration in bootstrap/app.php. 🔴 P0 — Frontend "buttons greyed out" for tenant admins. PrerenderAdmin.tsx gated buttons on is_super_admin || is_god || role==='super_admin' while the backend's requireSuperAdmin also accepted is_tenant_super_admin. A tenant super-admin saw disabled buttons but could have called the API directly via curl — worst of both worlds, AND a cross-tenant operation surface a tenant admin shouldn't reach. Fixed by tightening the controller to requirePlatformSuperAdmin on every mutating endpoint (enqueue, purge, cancel, invalidate, auto-recache, detect-drift, purge-unexpected), hiding the sidebar entry from non-platform-super-admins, and adding an explicit read-only banner so anyone landing on the page understands why actions are disabled. Sign in as platform super-admin to drive the engine. P1 — Race in enqueueJob dedup. SELECT-then-INSERT outside a transaction let concurrent observer callbacks both insert. Wrapped in DB::transaction with lockForUpdate so MariaDB serializes them. Routes now also validated against the canonical regex inside enqueueJob — defence in depth for the host shell eval consumer. P1 — HMAC replay protection on /invalidate. Captured signatures were replayable indefinitely. Now requires X-Nexus-Timestamp header within ±300 s and signs "<ts>.<body>". P1 — safeCachePath regex too narrow. Omitted : @ ~ ( ) + , ; = ! $ * so inspecting any snapshot whose route contained those characters silently 404'd the drawer. Widened to match the canonical route regex; .. block + /index.html suffix check preserved. P1 — Observer storm backpressure. Bulk imports (e.g. seeding 5k blog posts) would enqueue 5k distinct queued rows because each post has a unique routes value. Per-tenant burst counter in a 60s cache window — over 50 invalidations/min collapses subsequent enqueues onto a single tenant-wide row. P2 — Overview tab double-fetched when realtime worked (Pusher reload + 30s poll). Poll now disabled when live === true. P2 — KPI grid layout was ragged on desktop (11 cards in grid-cols-2 md:grid-cols-4). Rebreakpointed grid-cols-2 sm:grid-cols-3 md:grid-cols-3 xl:grid-cols-4. P2 — inventory() unbounded scan. A misbehaving Playwright could write thousands of files into one host directory and hang the admin summary. Hard cap at 50k rows with a __truncated sentinel surfaced to the UI. P2 — URL state sync for the prerender admin tab + tenant filter. Refresh / back / forward now preserve view state (?tab=coverage&tenant=hour-timebank). P2 — .bot-access.jsonl logrotate. New install-prerender-logrotate.sh deploy phase writes /etc/logrotate.d/nexus-prerender-bot-access (daily, 14 days, compressed, copytruncate) so the bot-only access log doesn't grow unbounded.
- Cross-tenant login bug — app.project-nexus.ie/ no longer silently boots into a stale tenant. Logging into one community and arriving on another is fully resolved. Root cause. TenantContext had a storedSlug fallback that read nexus_tenant_slug from localStorage whenever a user had auth tokens. On app.project-nexus.ie/ (the platform root, no slug in the URL), this silently booted the SPA into whichever tenant the user had last visited — e.g. Agoris. The login page then saw a "resolved" tenant slug and hid the community chooser, letting users authenticate against the wrong community. Fix. TenantContext.tsx — removed the storedSlug fallback entirely. Effective tenant slug is now tenantSlug prop (from TenantShell, URL-derived) OR detectTenantFromUrl() only. This matches the 2026-05-08 policy already documented in TenantShell.tsx: URL is respected as typed; master tenant renders at /, tenant-scoped pages require the slug in the URL. Defence in depth. AuthContext.logout() now clears nexus_tenant_id and nexus_tenant_slug from localStorage (previously preserved as a UX nicety, which contributed to the leak). Cross-tab logout already did this; the same-tab logout path now matches.

### Changed

- Sales site (project-nexus.ie) — GA messaging and audit-driven fixes. Hero badge updated from "V1.5 Now Open Source — AGPL-3.0" to "V1.5 Generally Available · Open Source · AGPL-3.0" so the public marketing site matches the actual v1.5 GA status promoted in CHANGELOG.md. Broken Documentation link fixed. The Get Started panel linked to github.com/jasperfordesq-ai/nexus-v1/tree/main/docs, which 404s — that path doesn't exist (the repo has docs-public/, not docs/). Repointed to the repo README anchor (#readme) with a sublabel referencing docs-public/. WCAG claim softened. "WCAG 2.1 AA — full accessibility compliance" was an unsupported blanket claim. Now reads "built to WCAG 2.1 AA targets with ongoing audit." Prerender.io reference removed from the SEO feature card. The platform is fully self-hosted on Playwright-rendered snapshots; the old "Prerender.io fallback" wording was stale. New copy describes the actual three-layer freshness model (observer + sitemap-drift + TTL) and HTTP status propagation. Sitemap lastmod bumped to 2026-05-14.
- README — v1.5 status promoted to Generally Available. The top-of-file blurb and the "Project Status" section both said "Release Candidate / in active production use while undergoing final pre-release validation." Updated both to "Generally Available, in active production use" with a pointer to the in-app /features page and CHANGELOG for per-module maturity. Historical RC entries in CHANGELOG.md and the v1.5.0-rc.1 release marker in .github/RELEASE_PROCESS.md are left untouched (historical record).
- Sales-site nginx — security headers hardened. Added Content-Security-Policy (allowing only Google Fonts and Ahrefs analytics, which are the only third-party origins the page actually loads), Strict-Transport-Security (max-age=31536000; includeSubDomains; preload), and Permissions-Policy (deny accelerometer/camera/geo/gyro/mic/payment/usb). Dropped the now-deprecated X-XSS-Protection header — modern browsers ignore it and CSP supersedes it. Headers repeated in the static-asset and HTML location blocks because nginx add_header is replace-not-merge.

### Fixed

- Admin panel — raw translation keys no longer leak. The Algorithm Settings and AI Settings pages (and 19 other admin pages, mostly in Caring Community) were rendering raw t() keys like algo.feed_label, advanced.provider_openai, admin.providers.title because their translation keys were never added to the locale files. Algorithm Settings and AI Settings — stripped useTranslation / t() entirely and inlined literal English (per the admin-is-English-only convention). 236 missing keys added to en/admin.json under admin.*, panel.*, billing.*, tenant_features.*, federation.*, groups.*, moderation.*, resources.*, super.*, and volunteering.*. Covers Care Providers, Loyalty Program, Warmth Pass, Hour Transfers, Municipality Feedback, Trust Tier, and the volunteer admin tooling. All 10 non-English locale files filled with English fallbacks; node scripts/check-i18n-drift.mjs now passes with 0 drift. All 2,552 admin-side t() calls now resolve.

### Changed

- Prerender engine — Round 5 (the full polish, "better than the big names"). Closes every remaining gap from both audits and adds three things no competitor ships. Three-layer freshness defence (the headline change). Stale public pages now have three independent mechanisms trying to keep them fresh: Observer hook (millisecond layer). Eloquent model observers for every public content type — Post, Listing, Event, JobVacancy, Group, MarketplaceListing, MarketplaceCategory, VolOpportunity, IdeationChallenge, Page (CMS), ResourceItem. On save/delete, the affected snapshot is deleted and a NORMAL-priority recache enqueued. Failures are logged, never thrown. Sitemap drift detector (minute layer). New prerender:detect-drift cron walks every tenant's sitemap, parses <lastmod>, compares against snapshot mtimes, enqueues HIGH-priority recaches for any drift. Catches code paths that bypass Eloquent (raw DB writes, migrations, queue jobs). 2-minute cadence; bounded fan-out. TTL auto-recache (hour/day floor). Existing Phase 2 cron, still the backstop for content that doesn't appear in either sitemap or model events. External invalidation webhook. POST /api/v2/admin/prerender/invalidate with Bearer token or HMAC signature. Lets headless CMS, marketing automation, or external integrations invalidate routes directly. Sets PRERENDER_WEBHOOK_TOKEN env var to enable. AI-friendly Markdown rendering. Worker now extracts a clean Markdown body (index.md) alongside the HTML snapshot. nginx detects AI crawlers (GPTBot, ClaudeBot, Perplexity, ByteSpider, Common Crawl, Google-Extended, Amazonbot, etc.) and serves the .md variant first via try_files. Falls back to HTML if markdown isn't available. No competitor (Prerender.io, Netlify, Cloudflare Pages) ships this — DataJelly was the only player doing it. Admin UI overhaul — six tabs, full polish. Overview — new Freshness automation card (one-click auto-recache + drift detect with dry-run / apply); new Wildcard cache purge form with pattern, tenant scope, dry-run, and auto-recache toggle. Inventory — adds HTTP status column, search/filter, status-code filter, bulk selection checkboxes, and bulk-recache button (groups selections by tenant, dispatches via the invalidate API). Inspect drawer — front-and-centre SEO score card (0-100 + A–F grade) with must-fix issues list and tips list. HTTP status chip. Reflects the new seo field on the inspect response. Coverage — new "Refresh all stale (N)" bulk button that enqueues per-tenant recaches for everything missing / stale / asset-broken in one click. Analytics — new tab. Bot traffic over 1d / 7d / 30d windows: KPIs (total hits, IP-verified %, spoofed count, unique URIs), hits-by-crawler + hits-by-status breakdowns, top-50 URIs table, recent-activity feed. Tests. New PrerenderServiceTest cases for purgePattern (glob single segment, ** recursive, host scoping, actual deletion), ttlForRoute (specificity + default fallback), seoScore (high / low grade scenarios), _status sidecar reading, priority promotion on duplicate enqueue, priority-ordered claim, and the JSONL crawler analytics aggregator. Docs. react-frontend/CLAUDE.md "Prerender Pipeline" section rewritten to describe the three-layer freshness model, priority lanes, status-code propagation, AI Markdown variant, and the six admin tabs.
- Prerender engine — Phase 4 (hardening). Crawler IP-range verification. New scripts/refresh-bot-ip-ranges.sh pulls Google/Bing/DuckDuckGo/Apple's published IP-range JSON feeds and ships them into the nginx container as a geo include. $nexus_bot_ip_verified is logged on every bot hit; analytics surface verified_hits and spoofed_by_crawler so admins can spot User-Agent spoofing without blocking (alternative crawlers / IPv6 transitions cause false positives if you block on verification alone). Designed for a weekly cron. Bot User-Agent refresh helper. scripts/refresh-bot-ua-list.sh diffs Matomo's actively-maintained bot regex list against the names we already cover and writes candidates to logs/bot-ua-suggestions.txt for human review. Stops the curated regex in nginx.bluegreen.conf from drifting into obsolescence. Viewport variant flag. Worker honours PRERENDER_VIEWPORT=mobile (414×896 + iPhone Safari UA) for tenants/routes that need a mobile-specific snapshot. nginx routing for the variant is a deferred follow-up — current platform is single-DOM responsive so the desktop snapshot serves both audiences correctly.
- Prerender engine — Phase 3 (visibility & SEO scoring). SEO score per snapshot (0–100, A–F grade). Synthesised from existing inspect() flags — title length, meta description length, canonical, OG completeness, h1 count, JSON-LD validity, asset issues, noscript fallback, body text volume. Surfaced as seo on the inspect API response with issues (must-fix) and tips (suggestions) arrays. Crawler analytics. nginx now writes a bot-only JSONL access log ($status, prerender override status, crawler label, verified flag, UA, IP, referer, bytes, request time) to the shared prerender volume. GET /api/v2/admin/prerender/analytics?since=ISO&limit=200 aggregates hits by status, crawler, host, top URIs, recent rows. Default window: 7 days. Manual auto-recache trigger. POST /api/v2/admin/prerender/auto-recache { apply: bool } runs one immediate pass of the freshness loop (dry-run by default) for operators who don't want to wait for the cron tick. Inventory/Coverage filter, bulk recache from Coverage tab, admin UI polish: backend supports ?tenant= filtering on inventory + the new analytics endpoint; frontend PrerenderAdmin.tsx polish deferred to a focused UI change.
- Prerender engine — Phase 2 (freshness automation). Snapshots now refresh themselves; deploy-time renders are no longer the only freshness mechanism. TTL rules per route pattern. New config/prerender.php maps route globs to max snapshot ages (homepage 6h, content index 6–24h, individual items 1–7d, static pages 30d). PrerenderService::ttlForRoute() resolves the most-specific pattern. Auto-recache cron. New prerender:auto-recache artisan command walks the deep inventory, identifies TTL-expired and content-drifted snapshots, and enqueues low-priority recache jobs grouped by tenant. Bounded by max_tenants_per_run / max_routes_per_tenant so a single tick can't flood the queue. Designed for a 15–30 min cron cadence. Content-change hooks. Model observers (Post, Listing, Event) now invalidate the affected snapshots (/blog, /blog/{slug}, /listings, /listings/{id}, /events, /events/{id}) on save/delete and auto-enqueue a low-priority recache. Failures are logged, never thrown — model writes never block on the prerender side-channel. The base PrerenderInvalidationObserver makes it a few lines to wire up additional content types. window.prerenderReady signal. Worker now waits for window.prerenderReady === true before snapshotting; falls back to the DOM-content heuristic when the signal is never set. initPrerenderReady() in main.tsx ensures the variable always exists; usePrerenderReady(isLoaded) is a one-line hook for data-driven routes to control snapshot timing.
- Prerender engine — Phase 1 (coverage & correctness). Lifts the engine from "render hardcoded routes on deploy" to "render every public URL Google can discover, with correct HTTP status codes." Addresses the highest-impact gaps from both prerender audits. Sitemap-driven URL discovery. New prerender:plan-routes artisan command unions the static-page floor (/, /about, …) with every URL SitemapService publishes — blog posts, listings, events, jobs, KB articles, marketplace listings/categories, CMS pages, organisations, ideation challenges. scripts/prerender-tenants.sh consumes the per-tenant plan; the hardcoded PUBLIC_ROUTES list remains as a fallback when the PHP container is unavailable. --no-sitemap flag and NEXUS_PRERENDER_NO_SITEMAP=1 env var disable it for emergencies. Closes the long-tail coverage gap flagged by both audits. HTTP status code propagation. Worker now extracts <meta name="prerender-status-code"> from rendered DOM and writes a _status sidecar next to index.html. Bash aggregates non-200 routes into /etc/nginx/prerender-status-overrides.list; nginx uses a map + error_page/return flow to serve 404/410/503 with the prerendered body. Soft-404s on community-not-found, deleted listings, and maintenance mode now emit the right status to crawlers. Validated with nginx -t before reload; reverts atomically if the new map is malformed. Inspect API and Inventory rows now expose http_status. Job priority lane. New priority TINYINT column on prerender_jobs (3 = high, 5 = normal, 7 = low). Claim ordering is (priority, queued_at, id) so auto-recache jobs can't starve urgent user-initiated runs. Enqueue API accepts an optional priority field; duplicate enqueues at a higher priority promote the existing queued row. Wildcard cache purge. POST /api/v2/admin/prerender/purge { pattern: "/blog/*" } removes matching snapshots (and _status sidecars). Supports * (single segment), ** (recursive), ? (single char), optional tenant_slug scoping, dry_run, and an optional recache flag that auto-enqueues a low-priority re-render. Dashboard summary now truthful. summary() was reporting content_stale_count and asset_invalid_count from a shallow inventory pass (deep=false), so the overview tab silently under-reported drift. Now uses the deep inventory under a 60-second cache.
- Partner Communities moved to the left column of the "More" mega menu, sitting directly under the Tools section. Previously placed beneath Impact in the right column, the federation submenu is now more discoverable to reflect its importance.

### Added

- In-app /changelog page rendering this file via react-markdown. The markdown source is copied from the repo root into react-frontend/public/changelog.md at prebuild/predev time by scripts/copy-changelog.mjs, so the in-app changelog is always in sync with the file in git. Footer Changelog link is now internal.
- Features link in the public Navbar and Mobile drawer (About section, alongside About / Blog / FAQ).
- nav.features and nav_desc.features translation keys in all 11 languages.

### Removed

- Dead dev_banner.* and dev_status.* translation keys swept from all 11 locale files (22 key blocks total). All code references were already gone when the platform moved to GA.
- "Dev Notice" amber button in the MobileDrawer bottom bar — redundant post-GA; Features is now reachable via the About accordion. FlaskConical icon import removed.

### Fixed

- Trust & Safety "Garda vetting" section made jurisdiction-neutral. This is a multi-tenant global platform; the Ireland-specific "Garda vetting" wording was inappropriate for tenants outside Ireland. Section retitled to "Background checks and vetting" and the body rewritten to cover background checks generally, mentioning Garda vetting (Ireland) and DBS (UK) as examples rather than the canonical regime. Applied across all 11 locale files.
- 🔴 Trust & Safety "Insurance and liability" section rewritten to match the actual platform-provider position in the Terms. Aligns the Trust & Safety page wording with the corrected Terms of Service Section 13 (see database/migrations/2026_04_15_000002_fix_terms_insurance_section.php, 2026-04-15): the organisation is a connection platform, not a service provider; members exchange services entirely at their own risk; members are solely responsible for ensuring they hold appropriate cover for any activities they undertake. Updated trust_safety.insurance_items across all 11 locale files and added a pointer to the Terms for the full liability and indemnity language.
- {{name}} literal placeholder rendered on the public Trust & Safety page. TrustSafetyPage.tsx was calling t(section.introKey) and t(\${section.itemsKey}.${i}`)without the{ name: branding.name }interpolation context, so strings like"By using {{name}} you agree to:"rendered with the raw{{name}}` placeholder visible. Both intros and list items now pass the tenant brand name. Title interpolation also added defensively.
- Build commit hash visible in the public footer. The footer was rendering __BUILD_COMMIT__ as a monospace string at the bottom of every page (and bleeding into Google snippets). The commit + build time are now exposed only as data-* attributes on a hidden element so the same diagnostics remain available via DOM inspection / Sentry tags without being part of the indexable page text.
- Blog post dates rendered with locale-dependent and ambiguous formatting. BlogPage.tsx was using bare toLocaleDateString() (no locale), so the same post showed as 12/9/2025 to some visitors and 9/12/2025 to others — unreadable for an Irish/UK audience. BlogPostPage.tsx was using toLocaleDateString(undefined, …) with the same issue. Both now pin to en-GB (12 September 2025).
- American "neighbors" in public marketing copy. Standardised to neighbours in the Stay Local landing card (public.json + CoreValuesSection.tsx fallback) and the "Local Hubs" mega-menu description in NavigationConfig.php, for consistency with the rest of the Irish/UK English copy.
- UnexpectedValueException: chmod(): Operation not permitted on every request that triggers a Laravel log write (Sentry NEXUS-PHP-7). The daily log channel in config/logging.php set 'permission' => 0664, which made Monolog call chmod() on the file on every write. When the existing day's log file is owned by a different user — e.g. left behind on a mounted volume from a prior container run — the chmod() fails and bubbles up as a 500. Removed the explicit permission so Monolog skips the chmod step entirely; new files are created with the default 0644 and existing files are left untouched.
- CHANGELOG.md cleaned up. Removed a block of fabricated legacy entries (a fake [2.0.0] - 2024-02-13, a duplicate [1.5.0] - 2024-02-12, and [1.4.0] through [1.0.0] with 2023–2024 dates) that were left over from a template — Project NEXUS development only began in mid-December 2025, so none of those releases ever existed. Also removed an incorrect "Hour Timebank (Crewkerne)" attribution (Crewkerne is an unrelated UK timebank) and the changelog's own contributors list, which conflicted with the canonical CONTRIBUTORS.md. Footer compare links pruned to the versions that actually exist (v1.5.0, v1.5.0-rc.1).

### Added

- Goals module: accountability and check-ins enhanced. New GoalInsightsPanel component surfaces trend analysis, streak tracking, milestone progress, and next-step recommendations on the goal detail page. GoalCheckinModal extended with cadence-aware prompts and partner-accountability nudges. Backend: GoalCheckinService and GoalProgressService rewritten to compute velocity, predict completion, and surface at-risk goals; new goal_insights and goal_accountability_partners columns added via migration. New GET /api/v2/goals/{id}/insights endpoint. Unit tests cover the insights panel.
- Security: users:purge-undeliverable artisan command. Retroactive cleanup for accounts registered with undeliverable email addresses (e.g. testing@example.com accounts created during the May 2026 cyber-attack). Re-runs the same DisposableEmailService + MxRecordValidator validators the registration form uses, restricted to email_verified_at IS NULL non-admin users. Defaults to --dry-run; --soft sets deleted_at, --hard issues a real DELETE. Scoped with --since=90days, --tenant=N, --limit=200.
- Registration: reserved-domain MX gap closed. MxRecordValidator previously only rejected .invalid TLD (RFC 6761) but passed example.com, example.net, example.org, *.test, *.example, *.localhost — all have real DNS records but are guaranteed undeliverable (RFC 2606/6761). Now rejects the full reserved-domain and reserved-TLD lists before any DNS round-trip.
- SocialInteractionPanel shared component. Likes, comments, shares, reactions, and poll voting are now handled by a single SocialInteractionPanel component used across the Feed, Blog, Events, Goal detail, and Group Discussion tab — replacing five separate per-page implementations. Backed by a new POST /api/v2/social/interactions endpoint. Tests cover the panel in all five contexts.
- Per-category From addresses on platform SendGrid. When the platform SENDGRID_API_KEY driver is active, outgoing emails now use purpose-specific From addresses on project-nexus.net instead of the single generic address: notifications@ (member alerts), newsletters@ (digests), messages@ (DMs, cross-community invites), noreply@ (password reset, verification, security), admin@ (moderation, ban, vetting), events@ (event reminders), safeguarding@ (staff alerts), billing@ (payments, marketplace, subscriptions). Mapping is derived from the existing EmailDispatchService audit category strings — no call-site changes needed. A default Reply-To of the platform owner address is attached to all platform SendGrid emails when the caller supplies none. Tenant-specific SMTP/Gmail/SendGrid accounts are unaffected.
- Password change email notifications. Users now receive a security notification at their current email address whenever their password is changed — whether self-initiated or by an admin. Wired through Mailer::forCurrentTenant() so it honours tenant email settings, suppression lists, and the email_log audit trail.
- Admin email deliverability dashboard polished. The /admin/email-deliverability dashboard (introduced in the email observability rollout) received a focused UX pass: clearer metric card labels, a time-range selector that persists in URL state, improved empty-state messaging, and a per-recipient search that highlights suppressed addresses. No new API endpoints — data comes from the existing email_log and email_suppression endpoints.

### Fixed

- Email delivery reliability — exhaustive multi-pass audit. Following the email observability rollout, a systematic audit of every email-sending path across the codebase identified and fixed ~80 reliability gaps spanning four themes: Tenant context leaks: ~20 service classes and listeners were not resetting or explicitly binding TenantContext before dispatch, so cron / queue jobs sent emails in the wrong tenant's locale or from the wrong sender. Affected paths: newsletter cron batches, federation notification listeners, Verein webhook handler, volunteering reminders, marketplace dispute handler, async notification queue. Missing send evidence guards: ~15 paths updated a state machine (registration token, activation token, billing reminder sent-at, digest status) before confirming the email was actually delivered, so a transient failure left the record in an "already sent" state with no email sent. Tokens are now only updated / marked as sent after Mailer::send() returns a provider message id. Deduplication gaps: duplicate event reminder bell rows, duplicate federated connection notifications, duplicate review notifications, duplicate event cancellation recipient lookups, and re-delivered newsletter queue rows. Each fixed with Cache::add() idempotency guards, unique index constraints, or atomic claiming. Broken delivery paths: federated message / connection / transaction delivery was silently no-op'd (missing tenant resolution on inbound payloads). Event reminders were blocked by a stale status=failed guard that prevented retries. Marketplace dispute notifications used the wrong tenant scope. Stripe webhook handler did not restore tenant context after processing. All repaired.
- All member-facing email links are now tenant- and domain-aware. Nine files were using config('app.frontend_url') or getFrontendUrl(path) with a silent path-discard bug, producing links that always pointed to the shared platform host or to the tenant homepage rather than the specific resource: AdminUsersController — admin-initiated password reset was also broken at the token layer: the controller was storing tokens hashed with bcrypt but PasswordResetController validates with hash('sha256'). Fixed both the hash algorithm and added tenant_id to the INSERT/DELETE so the token passes the ownership check. JobAlertEmailService and JobExpiryNotificationService — getFrontendUrl(path) silently discarded the path argument (method signature takes no params). Every job link pointed to the tenant homepage. GuardianConsentService — produced double-slug URLs like app.project-nexus.ie/hour-timebank/hour-timebank/... for path-based tenants. NewsletterService — unsubscribe and manage-preferences links always used the platform host. AppreciationReceived, VereinCrossInvitationReceived, CivicDigestMail — same config() pattern. SafeguardingService — admin alert linked to app.project-nexus.ie/admin/... even for custom-domain tenants. All nine now use TenantContext::getFrontendUrl() . TenantContext::getSlugPrefix().
- Browser geolocation obliterated — all proximity uses profile location. useProximity and useGeolocation hooks (both called navigator.geolocation) removed. No browser geolocation popup appears anywhere on the platform. The ProximityFilter shared component (Listings, Events, Volunteering, Marketplace map) now reads lat/lng from the authenticated user's profile via useAuth(), matching the pattern already used by the Members page.
- Listings location locked to user profile with automatic sync. Both listing creation forms (feed compose + full create/edit page) now show a disabled, pre-populated location field sourced from the user's profile. Users can no longer enter a custom listing location — the coordinates always reflect their profile. UserService::updateProfile() now propagates lat/lng changes to all non-deleted listings owned by the user. A backfill migration fills missing coordinates on existing listings from the owning user's profile.
- Proximity filter dropdown replaces pill buttons. The shared ProximityFilter component now offers a dropdown of 5 / 10 / 25 / 50 / 100 km radii (up from the 1 / 2 / 5 / 10 km pills), consistent with the Members page.
- Explore: trending posts sorted by velocity-weighted score (not raw engagement count). The trendingScore (velocity × 60% + volume × 40%) was already computed but then discarded — two sequential usort() calls left the final order sorted by raw engagement, so the same high-engagement posts always appeared regardless of recency. Replaced with a single sort on trendingScore so recently-active posts surface correctly.
- Explore: trending posts and popular listings windows widened from 90 → 365 days. The tight 90-day window returned near-empty results for early-stage communities. 365 days is appropriate because engagement-weighted ordering already surfaces the best content without an artificial hard cutoff.
- Explore: popular listings — collation crash fixed. A utf8mb4_unicode_ci vs utf8mb4_general_ci mismatch between listings.title and categories.name caused MariaDB ERROR 1267 on the title/category filter; the try/catch swallowed it silently, returning an empty list. Fixed by normalising the category name to unicode_ci via CONVERT.
- Docker: storage/logs permission denied on cross-user appends fixed (Sentry NEXUS-PHP-5, -6, -16). When artisan commands ran as root (image CMD, docker exec) before apache (www-data) did its first write, root created the day's log file with 0644 owner root — subsequent www-data writes threw UnexpectedValueException: Permission denied. Fixed in Dockerfile.bluegreen and Dockerfile.prod: container boot now sets umask 0002, applies setgid on all storage directories so new files inherit group www-data with group-write, and re-chowns after artisan optimize as defence-in-depth.
- Boot: URL::forceScheme('https') deferred to avoid null Request in console (NEXUS-PHP-17). Calling URL::forceScheme eagerly in AppServiceProvider::boot() resolved the url container binding immediately, which injected a null $request in non-HTTP contexts (queue workers, scheduler, artisan), throwing TypeError on every cron tick. Fixed with a resolving('url', ...) callback so forceScheme only runs when the url service is actually constructed inside an HTTP request.
- UI: transparent modals and popovers replaced with opaque surface token. 20 components including dropdowns, hover cards, command palettes, and sheet panels were using --glass-bg (rgba(255,255,255,0.05)) as their background — nearly invisible in dark mode, allowing background content to bleed through. All switched to --surface-dropdown (#16162a dark / #ffffff light), the correct opaque surface token for floating UI. UserHoverCard also has an !important override to win against HeroUI defaults.
- Listings: comments open inline on detail page. Previously, the comments section on a listing detail page required a separate tap/click to expand. Comments now render immediately below the listing content, consistent with blog posts and events.
- Feed: disable sharing for content you own. The share button on feed posts, listings, events, and blog posts is now hidden when the current user is the author — sharing your own content to your own feed was a no-op that cluttered the share count.
- Social: comment parity gaps closed. Several content types (Goals, Marketplace listings, Volunteer opportunities) were missing comment threading, nested replies, or the delete-own-comment permission. Brought to parity with Feed posts.
- Tenant: five correctness + one polish fix. (1) PrerenderPlanRoutes now excludes master tenant (id=1) from the parent-domain map so a misconfigured platform root can never pollute child routing. (2) prerender-tenants.sh re-validates FILTER_TENANT inside get_tenants() at the SQL use site. (3) moveTenant() fails safely on NULL path fields instead of using a fallback that could allow circular hierarchy moves. (4) TenantContext::getReservedPaths() adds 'platform' to sync with the TypeScript RESERVED_PATHS set. (5) SitemapController::tenant() replaces two sequential DB queries with a single JOIN. (6) tenant-routing.ts documents that slugs are lowercased at the DB layer.
- Deploy: post-deploy smoke tests hardened. WebAuthn / passkey smoke check now passes a real tenant slug (rather than hitting the platform root), matching how passkeys are actually challenged in production. Passkey smoke checks are also allowed during maintenance-mode windows so blue-green health checks don't fail because the platform is in maintenance. Local development health checks allowed through the post-deploy gate.
- Goals: modal polish and history label fixes. Check-in modal updated with clearer cadence copy. History panel label keys corrected (were displaying raw translation keys). Floating modal positioning fixed on small screens.
- Frontend: remaining TypeScript type errors and stale query patterns closed. Type-only pass over React pages — no behaviour changes.

### Changed

- Activity digest default changed from weekly to monthly. Feedback from early members found weekly digests too frequent for communities with moderate activity. Monthly is now the opt-in default for new users; existing preferences are unchanged. Critical instant-category events (DMs, connection requests, application status) are unaffected.
- Admin sidebar: navigation refined. Email Deliverability moved into the Settings group. Registration Security moved adjacent to Registration Policy. Prerender Engine entry restricted to platform super-admins only (was visible to tenant super-admins, who couldn't operate it). Ordering of secondary items tidied.
- SEO: detail page metadata enriched. Listing, event, job, blog post, and marketplace listing detail pages now emit og:updated_time, article:modified_time, and dateModified in JSON-LD. Listing and event pages also emit geo.placename when coordinates are present.
- SEO: account and settings pages marked noindex. /settings/*, /wallet/*, /notifications, /profile/edit, and similar authenticated-only pages now emit <meta name="robots" content="noindex">. Prevents personal account pages from appearing in search results.
- SEO: public SEO route coverage improved. Organisation profiles, ideation challenges, and caring-community hubs added to the prerender route plan and sitemap.
- SEO: crawl metadata coverage improved. <link rel="canonical"> and og:url now use the tenant's canonical domain (custom domain or slug-prefixed) rather than always app.project-nexus.ie. Duplicate-content risk reduced for tenants on custom domains.
- i18n: complete frontend translation fallback audit. All 52 React locale namespaces verified across all 11 languages (en, ga, de, fr, it, pt, es, nl, pl, ja, ar). ~400 keys that existed only in en/ were filled with English fallbacks in all other language files. node scripts/check-i18n-drift.mjs now reports zero drift. This was the source of several recurring CI failures.
- Admin: icon-only buttons given accessible labels. Toolbar icon buttons in the admin panel that had no visible text now carry aria-label attributes. Screen-reader and keyboard users can identify all admin actions.
- Newsletter heatmap contrast improved. Day/time engagement heatmap in the newsletter admin now uses a higher-contrast colour ramp for the top quartile, making peak send-time cells distinguishable in both light and dark mode.

---

## 1.5.0 - 2026-05-13

**Project NEXUS is now Generally Available.** After running as a release candidate since 2026-03-27, the v1.5 line — covering the full Laravel 12 migration, the React SPA frontend, federation, multi-tenant scoping hardening, the SEO overhaul, the email system rewrite, and the PWA update architecture — is promoted to GA. The platform as a whole is live and supported; newer modules may still ship with their own per-module maturity label.

### Changed

- Release marker promoted from RC → GA. RELEASE_STATUS.stageKey is now 'ga' with label "Generally Available (v1.5)". The amber "Release Candidate" footer strip is replaced with a calm GA strip linking to the new Features page and the public Changelog (this file, on GitHub).
- Footer Changelog link now points to CHANGELOG.md in the source repository — the canonical, public-facing version history.
- /development-status page replaced with /features — a public marketing-grade features inventory with honest per-module maturity chips (GA / Beta / Preview). The old /development-status URL 301s to /features so existing bookmarks survive. Federation is explicitly labelled Beta — Live with external partners, protocols still hardening to reflect reality: real partnerships exchange data daily while the wire protocols are still being hardened against edge cases.
- PWA update flow rewritten (2026-05-10). Replaced precache-shell + click-to-update workflow with NetworkFirst HTML + API stale-client gate. The HTML shell is no longer precached by the service worker; navigations are served NetworkFirst with a 3s timeout. Every API response carries X-Build: <sha>; the frontend interceptor force-redirects to /api/sw-reset if a build mismatch persists past a 10-minute grace window. Sentry events are now tagged with build_commit and build_time. Deploys propagate to users on their next navigation, with no UI prompt. See react-frontend/CLAUDE.md#pwa-update-architecture and the feedback_pwa_android_update.md memory file for the full architecture.

### Removed

- react-frontend/public/sw-rescue.js — service worker rescue shim that force-navigated clients via client.navigate(). Made redundant by NetworkFirst.
- /clear-site-data nginx route. Older SWs intercepted it and served the precached SPA shell, making it useless for actually-stuck users. /api/sw-reset does the same job and bypasses every SW we've ever shipped via the universal /^\/api\// denylist.
- "Update to the latest version" link in the mobile drawer (and the nav.update_app translation key in all 11 languages, the triggerSoftAppUpdate helper). With NetworkFirst + the API gate, no user will ever need a manual force-update button.

### Added

- Public SECURITY.md vulnerability disclosure policy.
- Public CODE_OF_CONDUCT.md community participation expectations.
- Dependabot coverage for Composer, npm, Docker, and GitHub Actions.
- Dependency Review workflow for pull request dependency changes.
- Tag-driven GitHub Release workflow and release process documentation.
- Request ID middleware that returns X-Request-Id and shares request, tenant, and user context with application logs.
- Comprehensive documentation suite API Endpoints V2 reference (80+ endpoints documented) React Component Library documentation (40+ components) Developer Guide for extending the platform User guides for Smart Matching and Reviews System

### Changed

- README now documents the public repository topology, visible quality gates, security process, and release process.
- README now clarifies that native mobile packaging is separate from the default public Docker workflow.

---

## 1.5.0-rc.1 - 2026-03-27

This release candidate covers nearly all development from 2026-01-18 to present. It represents the full maturation of the V1.5 line: a complete React SPA frontend, a full Laravel 12 migration, WebAuthn passkey support, expanded i18n, federation, social features, and comprehensive security hardening.

### Added

#### Laravel 12 Migration (Completed 2026-03-21)

- Laravel 12.54 is now the sole HTTP handler — all 1,218 routes wired to Laravel controllers
- All 223 services converted to native Eloquent implementations (zero stubs remain)
- 5 Event Listeners fully implemented: NewUserRegistered, ExchangeCompleted, ListingCreated, MessageSent, VolunteerHoursLogged
- Full 386-table baseline migration (artisan migrate works from scratch)
- Laravel scheduler replaces custom cron runner for all 25 scheduled tasks
- Nexus\ namespace fully eliminated — 100% App\ namespace throughout
- Dead legacy code deleted: 192 src/Services and src/Models files, 73 legacy framework files, all legacy PHP frontend controllers and views (civicone, modern, starter themes)
- Maintenance mode system: two-layer (file + database) with scripts/maintenance.sh and automatic deploy integration

#### React Frontend (Primary UI)

- Full React 18 + TypeScript + HeroUI + Tailwind CSS 4 SPA replacing all PHP-rendered user-facing views
- Capacitor-based native mobile app (iOS/Android) from the same React codebase
- React Native Expo mobile app with separate test suite (mobile/)
- 108 admin panel pages with 100% parity to legacy PHP admin
- Super Admin panel for cross-tenant management
- Universal Compose Hub with feature-gated tabs (listing, event, group, poll, post)
- PostDetailPage with direct post links and auto-open comments
- Explore / For You page with 7-source recommendation algorithm
- PWA service worker with auto-reload on stale chunks and update banner
- Google Maps integration (replacing Mapbox) with marker clustering and near-me filters
- Sales site at project-nexus.ie (separate container)
- Component refactors: ConversationPage, GroupDetailPage, SettingsPage split into sub-components

#### Authentication & Security

- WebAuthn / passkeys authentication (react-frontend/src/lib/webauthn.ts, BiometricSettings.tsx)
- TOTP two-factor authentication with trusted device support
- Registration policy engine: email verification gate, admin approval gate, invite codes, waitlist mode
- Identity verification module with per-tenant provider credential management (AES-256-GCM)
- Mandatory profile photo + bio enforcement on onboarding
- 7-layer regression prevention system (pre-commit → pre-push → CI → PR → Zod → local → deploy)
- Redis-based rate limiting on all API endpoints
- CSRF protection on all write operations and forms
- Sentry error tracking integrated in PHP and React
- Dependabot CVE alerts resolved; rollup, dompurify, serialize-javascript, tar, basic-ftp patched

#### Internationalisation (i18n)

- 7 languages: English, Irish (Gaeilge), German, French, Italian, Portuguese, Spanish
- All languages enabled for every tenant; tenant default language overrides browser detection
- 33 i18n namespace files per language (~4,571 keys each) covering all modules
- Language switcher on unauthenticated navbar and auth pages
- Translation drift detection added to CI and pre-push hook
- PHP admin i18n groundwork (English-only for now)

#### Federation

- Federation API V1 live: Neighborhoods, Credit Agreements, External Partners
- Partner detail page at /federation/partners/:id
- Federation connections route and FederationConnection type
- All federation features enabled by default for new tenants
- Federation gating uses dedicated tables (federation_system_control, federation_tenant_whitelist, federation_tenant_features)

#### Social Features

- Post reactions (emoji) on feed items and comments
- User presence indicators (online/away/offline) with heartbeat
- Link previews for shared URLs
- Media carousel with lightbox and thumbnail navigation
- @mention system with batch resolution and banned-user guards
- Stories feature with 30-story limit, audience controls, and IDOR prevention
- Video player with accessibility (focus restore, aria-live counter)
- Explore page with category chips, infinite scroll For You feed, and trending content
- Group feed tab and listing social features via shared social module
- Profile aggregated activity feed

#### Jobs & Volunteering Modules

- Enterprise-grade Jobs module: job templates, hiring teams, inline interview/offer response, salary display, bias audit, candidate moderation, talent search
- Volunteering module expansion: 7 new services, 5 React tabs, QR check-in, shift management, recurring shifts, expense tracking, certificates
- Organisation registration and opportunity posting UI
- Volunteer notification dispatch on application events

#### Polls, Ideation & Other Modules

- Polls module: create, vote, and results pages
- Ideation Challenges module: create campaigns, submit ideas, favourites, tags, cover images, draft saving, "turn ideas into teams" conversion
- 96 additional features across 18 modules implemented in the 2026-03-01 build sprint

#### Algorithms & Search

- Meilisearch integration with SQL fallback for listings search; index synced on create/update/delete
- EdgeRank feed algorithm upgraded to 15-signal pipeline with full CTR tracking
- Collaborative Filtering (CollaborativeFilteringService) for personalised recommendations
- OpenAI embedding-based matching (EmbeddingService)
- FeedRankingService with geo-decay, context-aware mode, and configurable signals
- GroupRecommendationEngine with cold-start handling
- Rubix ML, Wilson Score, and Bayesian average for member and listing ranking
- Cross-Module Matching Service with debug panel in admin
- User–User CF boost, dismissed listings suppression, skill proficiency in matching
- Batch geocode script (scripts/batch_geocode_users.php) for backfilling user coordinates
- OpenAPI 3.0 specification for V2 API added to repo

#### Onboarding

- Admin-configurable onboarding module (5 phases): backend config, admin UI, dynamic frontend steps, safeguarding step, listing creation modes (draft/review/active)
- Broker dashboard integration with safeguarding presets
- Atomic /complete transaction wrapping full onboarding flow

#### CRM & Admin

- CRM module: member notes, coordinator tasks, onboarding funnel, CRM webhook dispatches for volunteering events
- Newsletter admin: full parity with legacy PHP admin, stats improvements, activity page, SendGrid provider, per-tenant email config
- Tenant CRUD with full parity to legacy PHP admin including super admin role
- Registration policy admin UI with explanations for all modes
- 6 new admin management pages, algorithm settings page, Match Debug Panel
- Tenant super admin role; tenant lifecycle hardening

#### Email & Notifications

- SendGrid email provider with per-tenant configuration and SPF/DMARC deliverability fixes
- Email notifications for events, groups, endorsements, wallet credits received, reviews received
- All notification links made fully tenant-aware
- Fix for 404 dead links across all email notification types
- Nightly DB backup cron

#### Infrastructure & DevOps

- Git-based production deployment replacing file upload
- scripts/safe-deploy.sh with full/quick/rollback/status modes; automated migrations on deploy
- Docker production images protected from dev-image contamination
- Cloudflare cache purge automated in deploy scripts
- scripts/maintenance.sh for atomic two-layer maintenance mode toggle
- Migrations tracked in git; all legacy SQL migrations committed to migrations/
- Ahrefs Web Analytics on sales site and React app
- PHP memory_limit raised to 4G for PHPUnit; 8G for production containers
- .gitattributes enforcing LF line endings on shell scripts

#### Testing & Quality

- PHPStan level 3 added (warning-only; 123 pre-existing errors baseline)
- ESLint 9 flat config with 929-warning baseline
- 4,504+ PHPUnit tests (0 errors, 0 failures at point of Laravel migration merge)
- 118 Eloquent model factories added; 64 service test suites; 88 coverage-gap test files
- Vitest test suite for React with 71 WebAuthn tests, 66 ComposeHub tests, 367 social tests
- React Native Expo mobile test suite with auth, hooks, and screen tests
- E2E tests migrated fully to React frontend (Playwright)
- Lighthouse CI added for performance regression prevention
- Vitest Axe accessibility testing integrated in CI
- API contract test stage added to CI pipeline
- Translation drift detection in CI and pre-push hook

### Changed

- Primary frontend is now React SPA only — all PHP-rendered user pages removed
- PHP admin legacy views remain only at /admin-legacy/ and /super-admin/
- Routes split from monolithic routes.php (2,487 lines) into 14 domain-specific partials
- Tenant routing: /:tenantSlug URL prefix with 42 reserved paths and tenantPath() helper
- Login is fully tenant-URL-aware; super admin can access any tenant
- Maps provider migrated from Mapbox to Google Places / Google Maps API
- Feed algorithm: default mode is Recent, EdgeRank as alternative; unified feed_activity table
- Compose Hub: Post tab removed; Listing set as default tab
- Navbar redesigned with mega menu, utility bar, command palette, and intelligent collapsing
- More dropdown reorganised with Partner Communities collapsible and Activity dividers
- avatar column renamed avatar_url across the entire codebase (4 affected files)
- Irish-specific phone and location validation removed globally; international E.164 throughout
- CORS wildcards replaced with per-origin validation
- routes.php and all controllers now under app/ namespace exclusively

### Fixed

- Cross-tenant IDOR in Group::findById() — missing tenant_id scope (security audit 2026-03-09)
- AdminContentApiController menu_items DELETE/UPDATE lacked embedded tenant check (security audit 2026-03-09)
- WalletFeatures fatal error and Exchanges config regression
- Pusher auth 401 on login page; Pusher unsubscribe against closing WebSocket; Pusher 405 in production
- Feed load-more returning duplicate items from cursor pagination
- Balance alert emails spamming all users instead of the target user
- register() function not granting welcome credits on no-approval tenants
- Blog infinite re-render loop (cursor in useCallback deps)
- Avatar uploads: DB update silently failing, double /api/ URL prefix, file permission bug in production
- FeedRankingService::getConfig() visibility (private → public)
- Legal document GET routes moved outside auth:sanctum — were silently returning generic defaults
- Service worker auto-reloading during message composition
- PWA icons corrected; stale chunk auto-reload on deploy for both Chrome and Firefox error patterns
- AbortController race conditions resolved across 83 pages
- estimated_hours column PDOException on listings creation
- created_by column reference on jobs page (should be user_id)
- image_url → image column on feed_posts table
- Sanctum cross-tenant auth bypass
- GDPR column names (type, location) fixed across multiple endpoints
- Cookie consent Bearer-token-aware auth; returns 200 when no record found
- Onboarding redirect loop resolved using onboarding_completed flag as sole source of truth
- CMS page cascade delete for menu items
- Custom domain tenant resolution: path no longer mistaken for slug
- Presence heartbeat 429 rate limiting
- Broken avatar URLs from stale domain references after legacy frontend removal
- Duplicate comment reactions route removed
- longitude field: standardised to lon (not lng) across nearby endpoint calls

### Security

- Critical: Cross-tenant IDOR in Group::findById() — fixed 2026-03-09 (see audit-history.md)
- Critical: AdminContentApiController DELETE/UPDATE lacking tenant check — fixed 2026-03-09
- Critical: God mode privilege escalation vulnerabilities fixed
- Critical: Open redirect vulnerabilities removed from login scripts
- Critical: SQL injection protections hardened across 50+ files
- Critical: Hardcoded production DB credentials removed from tracked files
- Critical: Pusher fallback key removed from NotificationsContext
- High: XSS vulnerabilities in view files fixed; DOMPurify and serialize-javascript patched
- High: CORS wildcards replaced with origin validation
- High: Rate limiting added to auth endpoints; login relaxed to 10 attempts/5 min
- High: Registration policy gates enforced on all entry points (not just registration)
- High: Super admin cross-tenant access control hardened
- High: Tenant isolation gaps in events, groups, messages, exchanges hardened
- High: 2FA enforced for all admin users
- High: AES-256-GCM encryption for per-tenant identity provider credentials
- Medium: nosemgrep annotations added for Semgrep false positives
- 18 tenant isolation regression tests added; admin security regression gate script added
- SPDX/AGPL-3.0-or-later headers on 100% of source files (1,230/1,230 files verified)

### Removed

- All legacy PHP frontend themes: civicone, modern (user-facing), starter — fully deleted
- 229 dead PHP frontend routes and 42 legacy frontend controllers
- 192 src/Services and src/Models files replaced by native Laravel equivalents
- Legacy Database:: class replaced everywhere by Laravel DB facade
- Nexus\ namespace entirely eliminated from the codebase
- 73 dead legacy framework files and all legacy ob_start delegation patterns

---

## Project history

Project NEXUS development began in mid-December 2025. The 1.5 line was developed throughout early 2026 and entered release-candidate status on 2026-03-27 before being promoted to General Availability on 2026-05-13. There are no earlier public releases — anything tagged before 1.5.0-rc.1 was internal development against the legacy PHP codebase and is not separately versioned here.

For the people behind the project, see [CONTRIBUTORS.md](CONTRIBUTORS.md) — the canonical attribution file.

---

## Support

- Issues: https://github.com/jasperfordesq-ai/nexus-v1/issues
- Documentation: /docs directory
- Email: jasper@hour-timebank.ie

---
