Compare commits

...
4 Commits
Author SHA1 Message Date
joshua 98173c1380 Merge branch 'chore/roadmap-no-afrikaans' 2026-10-28 15:02:22 +02:00
joshuaandClaude Opus 5.5 6297613d0d Roadmap: languages (Afrikaans) removed from scope
The accessibility pass becomes 1.50.0 and the TypeScript backend /
OpenAPI / api v2 1.51.0. Section 10 is kept for reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 15:02:22 +02:00
joshua 5efb4cd6dd Merge branch 'feat/saved-filters' 2026-10-28 14:57:34 +02:00
joshuaandClaude Opus 5.5 e40803cbbc Saved filters on list pages (1.49.2)
Manage registrations, Users, Events and Reports: save the current
filters under a name, apply one with a click, star one as the default
for the page (not applied over a deep link). Per person, stored on the
server, removed when an account is anonymised. /api/saved-filters.
Migration 20261028180000_saved_filters. Completes roadmap section 9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:57:34 +02:00
27 changed files with 494 additions and 29 deletions
+13
View File
@@ -7,6 +7,19 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.49.2] - 2026-10-28
### Added
- **Saved filters** on Manage registrations, Users, Events and Reports: set the filters, then
**Saved filters → Save** under a name; click one to apply it; **★** makes it the default, applied
when the page opens (but not over a link that already says what to show, such as the
dashboard's "Waiting for you"). Reports remember the report, events, dates, campus and the
past/inactive/closed options. Per person, stored on the server (up to 30 per page), removed when
an account is anonymised.
- `/api/saved-filters` (list, save, rename/default, delete — your own only). Migration
`20261028180000_saved_filters`.
## [1.49.1] - 2026-10-28
### Added
+3
View File
@@ -481,6 +481,9 @@ logout controls. Disconnected sessions are recovered automatically.
(records the balance by method), move to another ticket (same tickets and QR
codes, repriced), put in a room or group, export to Excel, cancel — each
through the same rules as one at a time, with a result per registration.
- **Saved filters** — on Manage registrations, Users, Events and Reports: name the
current filters, apply one with a click, star one as the default for the page
(not applied over a deep link). Per person, on the server.
- **Cashup** — see below.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
+6
View File
@@ -12,6 +12,12 @@ Write for church admins, not developers: no environment variables, endpoints or
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
-->
## 1.49.2
- **Saved filters.** On Manage registrations, Users, Events and Reports, save the filters you use
under a name and get back to them with one click. Star one and it's ready every time you open the
page. They're yours and follow you to any device.
## 1.49.1
- **Do many at once.** On Manage registrations, tick the people you want (or select everyone
+12
View File
@@ -933,6 +933,18 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
role linked to the room); checked-in ones are counted in `leaders`.
## Saved filters
Per person, on list pages `registrations`, `users`, `events`, `reports` (staff+; your own only — someone
else's id answers 404).
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/saved-filters?page=` | Mine for the page, default first |
| POST | `/api/saved-filters` | `{ page, name, params, isDefault? }` — params: strings, numbers, booleans, lists of strings (≤ 4 KB); same name replaces; ≤ 30 per page |
| PUT | `/api/saved-filters/:id` | `{ name?, isDefault? }` (one default per page) |
| DELETE | `/api/saved-filters/:id` | Delete |
## Bulk actions on registrations
`POST /api/registrations/bulk` (supervisor+): `{ action, registrationIds: [...] (≤ 500), ...params }` →
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.49.1",
"version": "1.49.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.49.1",
"version": "1.49.2",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.49.1",
"version": "1.49.2",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "SavedFilter" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"page" TEXT NOT NULL,
"name" TEXT NOT NULL,
"params" JSONB NOT NULL,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SavedFilter_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SavedFilter_userId_page_idx" ON "SavedFilter"("userId", "page");
-- CreateIndex
CREATE UNIQUE INDEX "SavedFilter_userId_page_name_key" ON "SavedFilter"("userId", "page", "name");
-- AddForeignKey
ALTER TABLE "SavedFilter" ADD CONSTRAINT "SavedFilter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+18
View File
@@ -164,6 +164,7 @@ model User {
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
seriesFollows SeriesFollow[]
seasonPassPurchases SeasonPassPurchase[]
savedFilters SavedFilter[]
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
@@ -2297,3 +2298,20 @@ model WebhookDelivery {
@@index([userId])
@@index([createdAt])
}
/// A person's saved filters on a list page (1.49.2): `page` is the list (registrations, users,
/// events, reports), `params` the filter values. At most one default per person and page.
model SavedFilter {
id String @id @default(uuid())
userId String
page String
name String
params Json
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, page, name])
@@index([userId, page])
}
+9
View File
@@ -189,6 +189,7 @@ app.use('/api/series', require('./routes/seriesRoutes'));
app.use('/api/season-passes', require('./routes/seasonPassRoutes'));
app.use('/api/outbound-webhooks', require('./routes/outboundWebhookRoutes'));
app.use('/api/dashboard', require('./routes/dashboardRoutes'));
app.use('/api/saved-filters', require('./routes/savedFilterRoutes'));
app.use('/api/users', userRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/registrations', registrationRoutes);
@@ -1015,6 +1016,14 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/series/:id/compare', auth:'supervisor+', desc:'Year on year: registrations, tickets, income (as on the dashboard), checked in (average per session on multi-session events) and attendance rate per event in the series, oldest first, with the % change from the one before. Campus-scoped staff: their campuses\x27 events only' },
]},
{ title: 'Saved filters', base: '/api/saved-filters', endpoints: [
{ method:'GET', path:'/api/saved-filters?page=', auth:'staff+', desc:'My saved filters for a list page (registrations, users, events, reports), default first' },
{ method:'POST', path:'/api/saved-filters', auth:'staff+', desc:'Save (or replace by name) a filter: { page, name, params, isDefault? }. Up to 30 per page; making one the default clears the old default',
request:{ body:{ page:'registrations', name:'Camp — unpaid', params:{ event:'ev-uuid', status:'pending' }, isDefault:false } } },
{ method:'PUT', path:'/api/saved-filters/:id', auth:'staff+', desc:'Rename it, or make it (or stop it being) my default — my own only' },
{ method:'DELETE', path:'/api/saved-filters/:id', auth:'staff+', desc:'Delete one of my saved filters' },
]},
{ title: 'Dashboard and command palette', base: '/api/dashboard', endpoints: [
{ method:'GET', path:'/api/dashboard/today', auth:'staff+', desc:'Events on today (or with a session today): places, people checked in today, children in now, volunteers rostered/checked in. Supervisors and admins also get money in (today, last 7 days) and "waiting" items: unpaid registrations for the next two weeks, parental consent, waitlist, cover requests, roster gaps, and for admins cashups due, provider payments to reconcile, privacy requests, failed webhooks. Campus-scoped' },
{ method:'GET', path:'/api/dashboard/search?q=', auth:'staff+', desc:'Command palette search: events; supervisors also payments by amount ("R450"); admins also people and registrations by reference. { results: [{ type, id, title, subtitle, href }] }' },
+24
View File
@@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
const { protect, staff } = require('../middleware/authMiddleware');
const { campusIndependent } = require('../utils/campusScope');
const filters = require('../utils/savedFilters');
const OWN = campusIndependent('a person\'s own saved filters (the lists they filter are campus-checked themselves)');
const fail = (res, e) => res.status(e.statusCode || 400).json({ message: e.message });
// Saved filters on list pages (1.49.2) — always the signed-in person's own.
router.get('/', protect, staff, OWN, async (req, res) => {
try { res.json(await filters.list(req.user.id, req.query.page)); } catch (e) { fail(res, e); }
});
router.post('/', protect, staff, OWN, async (req, res) => {
try { res.status(201).json(await filters.save(req.user.id, req.body || {})); } catch (e) { fail(res, e); }
});
router.put('/:id', protect, staff, OWN, async (req, res) => {
try { res.json(await filters.update(req.user.id, req.params.id, req.body || {})); } catch (e) { fail(res, e); }
});
router.delete('/:id', protect, staff, OWN, async (req, res) => {
try { await filters.remove(req.user.id, req.params.id); res.json({ ok: true }); } catch (e) { fail(res, e); }
});
module.exports = router;
+2
View File
@@ -467,6 +467,8 @@ async function anonymiseUser(userId, db = prisma) {
await tx.seriesFollow.deleteMany({ where: { userId } });
// Webhook deliveries about them (kept 30 days for the delivery log).
await tx.webhookDelivery.deleteMany({ where: { userId } });
// Their saved list filters (can hold search text, such as names).
await tx.savedFilter.deleteMany({ where: { userId } });
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Saved filters (1.49.2): a person names the filters they use on a list page and applies them
* with one click; one per page can be their default (applied when the page opens). Stored per
* person on the server, so they follow them to any device. Only their own are ever read or changed.
*/
const prisma = require('../config/db');
// The list pages that have saved filters, and who can open them.
const PAGES = { registrations: 'admin', users: 'admin', events: 'supervisor', reports: 'supervisor' };
const MAX_PER_PAGE = 30;
const MAX_PARAMS_BYTES = 4000;
function filterError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
function cleanPage(page) {
const p = String(page || '');
if (!PAGES[p]) throw filterError('Unknown list page');
return p;
}
function cleanName(name) {
const n = String(name || '').trim().replace(/\s+/g, ' ');
if (!n) throw filterError('Give the filter a name');
return n.slice(0, 60);
}
/** The filter values: a plain object of strings, numbers, booleans and lists of strings. Pure. */
function cleanParams(params) {
if (!params || typeof params !== 'object' || Array.isArray(params)) throw filterError('Nothing to save');
const out = {};
for (const [k, v] of Object.entries(params)) {
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(k)) continue;
if (typeof v === 'string') out[k] = v.slice(0, 500);
else if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
else if (typeof v === 'boolean') out[k] = v;
else if (Array.isArray(v)) out[k] = v.filter(x => typeof x === 'string').slice(0, 200).map(x => x.slice(0, 100));
}
if (JSON.stringify(out).length > MAX_PARAMS_BYTES) throw filterError('Too much to save in one filter');
return out;
}
const view = f => ({ id: f.id, page: f.page, name: f.name, params: f.params, isDefault: f.isDefault, updatedAt: f.updatedAt });
async function list(userId, page) {
const rows = await prisma.savedFilter.findMany({ where: { userId, page: cleanPage(page) }, orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] });
return rows.map(view);
}
/** Saves (or replaces, by name) a filter. Making it the default clears the old default. */
async function save(userId, { page, name, params, isDefault = false }) {
const p = cleanPage(page);
const n = cleanName(name);
const data = cleanParams(params);
const existing = await prisma.savedFilter.findUnique({ where: { userId_page_name: { userId, page: p, name: n } } });
if (!existing && (await prisma.savedFilter.count({ where: { userId, page: p } })) >= MAX_PER_PAGE) {
throw filterError(`You can keep up to ${MAX_PER_PAGE} filters on a page — delete one first`);
}
return prisma.$transaction(async tx => {
if (isDefault) await tx.savedFilter.updateMany({ where: { userId, page: p, isDefault: true }, data: { isDefault: false } });
const row = existing
? await tx.savedFilter.update({ where: { id: existing.id }, data: { params: data, ...(isDefault ? { isDefault: true } : {}) } })
: await tx.savedFilter.create({ data: { userId, page: p, name: n, params: data, isDefault: !!isDefault } });
return view(row);
});
}
async function own(userId, id) {
const row = await prisma.savedFilter.findUnique({ where: { id: String(id) } });
if (!row || row.userId !== userId) throw filterError('Saved filter not found', 404);
return row;
}
/** Rename, or make (or stop being) the default. */
async function update(userId, id, { name, isDefault }) {
const row = await own(userId, id);
return prisma.$transaction(async tx => {
if (isDefault === true) await tx.savedFilter.updateMany({ where: { userId, page: row.page, isDefault: true, NOT: { id: row.id } }, data: { isDefault: false } });
try {
return view(await tx.savedFilter.update({
where: { id: row.id },
data: { ...(name !== undefined ? { name: cleanName(name) } : {}), ...(isDefault !== undefined ? { isDefault: isDefault === true } : {}) },
}));
} catch (e) {
if (e?.code === 'P2002') throw filterError('You already have a filter with that name on this page');
throw e;
}
});
}
async function remove(userId, id) {
const row = await own(userId, id);
await prisma.savedFilter.delete({ where: { id: row.id } });
}
module.exports = { PAGES, MAX_PER_PAGE, cleanParams, cleanName, cleanPage, list, save, update, remove };
+41
View File
@@ -0,0 +1,41 @@
const mockDb = {
savedFilter: { findMany: jest.fn(), findUnique: jest.fn(), count: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn(), delete: jest.fn() },
$transaction: jest.fn(fn => fn(mockDb)),
};
jest.mock('../src/config/db', () => mockDb);
const filters = require('../src/utils/savedFilters');
beforeEach(() => jest.clearAllMocks());
test('only simple filter values are kept', () => {
expect(filters.cleanParams({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b', 3], n: 2, nested: { x: 1 }, 'bad key': 'x', fn: null })).toEqual({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b'], n: 2 });
expect(() => filters.cleanParams(null)).toThrow('Nothing to save');
expect(() => filters.cleanParams({ q: 'x'.repeat(500), a: 'x'.repeat(500), b: 'x'.repeat(500), c: 'x'.repeat(500), d: 'x'.repeat(500), e: 'x'.repeat(500), f: 'x'.repeat(500), g: 'x'.repeat(500), h: 'x'.repeat(500) })).toThrow('Too much');
});
test('pages and names are checked', async () => {
await expect(filters.list('u1', 'secrets')).rejects.toThrow('Unknown list page');
expect(() => filters.cleanName(' ')).toThrow('name');
expect(filters.cleanName(' Camp — unpaid ')).toBe('Camp — unpaid');
});
test('saving a default clears the old default; saving the same name replaces it', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f1' });
mockDb.savedFilter.update.mockImplementation(async ({ data }) => ({ id: 'f1', page: 'registrations', name: 'Unpaid', ...data }));
await filters.save('u1', { page: 'registrations', name: 'Unpaid', params: { status: 'pending' }, isDefault: true });
expect(mockDb.savedFilter.updateMany).toHaveBeenCalledWith({ where: { userId: 'u1', page: 'registrations', isDefault: true }, data: { isDefault: false } });
expect(mockDb.savedFilter.create).not.toHaveBeenCalled();
});
test('at most 30 per page', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue(null);
mockDb.savedFilter.count.mockResolvedValue(30);
await expect(filters.save('u1', { page: 'events', name: 'One more', params: { q: 'x' } })).rejects.toThrow('up to 30');
});
test('someone else\'s saved filter is "not found"', async () => {
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f9', userId: 'someone-else', page: 'users' });
await expect(filters.remove('u1', 'f9')).rejects.toMatchObject({ statusCode: 404 });
await expect(filters.update('u1', 'f9', { name: 'Mine now' })).rejects.toMatchObject({ statusCode: 404 });
expect(mockDb.savedFilter.delete).not.toHaveBeenCalled();
});
+14 -2
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.49.1** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-28, after **1.49.2** (and Hub 1.5.0). The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs.
| Version | What |
|---------|------|
| 1.49.2 | **Saved filters** (§9 part 3) on registrations, users, events and reports (`/api/saved-filters`, `components/shared/SavedFilters.tsx`). Migration `20261028180000_saved_filters` |
| 1.49.1 | **Bulk actions** on Manage registrations (§9 part 2): message, mark paid, move ticket, allocate, export, cancel (`POST /api/registrations/bulk`, `utils/bulkRegistrations.js`). Migration `20261028170000_registrations_bulk` |
| 1.49.0 | **Dashboard and command palette** (§9 part 1): Today and Waiting for you on the staff/supervisor/admin dashboards (`GET /api/dashboard/today`), Ctrl+K palette (`GET /api/dashboard/search`, pages in `lib/commandPages.ts`), deep links on events/registrations/users |
| 1.48.2 | Four demo seeds (`npm run demo:small|events|ministry|multicampus`, `demo:list`); fixes `set-demo`, broken since 1.43.0 by TRUNCATE CASCADE through `User.homeCampusId` |
@@ -57,7 +58,9 @@ encrypted settings unreadable or break existing installs.
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
a test server (both done 2026-10-28).
1. **1.49.2 — saved filters** per user on list pages (§9), then 1.50.0 Afrikaans and 1.51.0 accessibility.
1. **1.50.0 — Accessibility pass (WCAG 2.2 AA)** (§11), then **1.51.0 — TypeScript backend, OpenAPI
and `/api/v2`** (§14), then **2.0.0 — AI helpers** (§15). Languages (Afrikaans, §10) are removed
from scope (2026-10-28).
## Working on this repo
@@ -405,6 +408,15 @@ a test server (both done 2026-10-28).
goes through `/api/reports/excel` (access-logged). Tests: `tests/bulkRegistrations.test.js`, e2e
`bulk.spec.ts`.
## Saved filters (1.49.2)
- `backend/src/utils/savedFilters.js` (`PAGES` is the list of pages with saved filters; values are
cleaned to simple types) + `routes/savedFilterRoutes.js`. Removed in `anonymiseUser`.
- `components/shared/SavedFilters.tsx` takes the page's current filter values and an `onApply`;
`skipDefault` when the URL already says what to show. **Adding it to another list page:** add the
page to `PAGES` (and the component's `page` type), then pass the values in and apply them out.
- e2e `saved-filters.spec.ts` (deletes what it saves).
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
+11 -7
View File
@@ -36,7 +36,9 @@ ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
shipped; 1.49.1 **bulk actions** shipped. **Next: 1.49.2 saved filters.**
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. **Next: 1.50.0
Accessibility pass (WCAG 2.2 AA).**
**Repositories:**
@@ -58,7 +60,7 @@ shipped; 1.49.1 **bulk actions** shipped. **Next: 1.49.2 saved filters.**
7. ~~[Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)~~ — removed from scope
8. [Outbound webhooks](#8-outbound-webhooks)
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
10. ~~[Languages (Afrikaans first)](#10-languages-afrikaans-first)~~ — removed from scope
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
13. [Central management platform (Cross Events Hub)](#13-central-management-platform-cross-events-hub)
@@ -518,7 +520,7 @@ register; deliveries kept 30 days.)
## 9. Admin dashboard, command palette, bulk actions
Three releases (2026-10-28): **1.49.0** dashboard (Today, Waiting for you) and the Ctrl+K command
palette ✅; **1.49.1** bulk actions ✅; **1.49.2** saved filters.
palette ✅; **1.49.1** bulk actions ✅; **1.49.2** saved filters ✅.
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
far, money in (today/week), pending items (unreconciled payments, consent
@@ -533,6 +535,8 @@ palette ✅; **1.49.1** bulk actions ✅; **1.49.2** saved filters.
## 10. Languages (Afrikaans first)
> **Removed from scope (2026-10-28).** Kept here for reference only; not planned for 2.0.
- i18n framework on the frontend (e.g. `next-intl`); English + Afrikaans.
- User language preference; emails, WhatsApp messages and PDFs sent in the
user's language, with per-language templates.
@@ -939,8 +943,7 @@ each confirmed action in the audit log.
### More helpers
Smaller one-button additions as they come up: suggest an event's price tiers from past
events, write a post-event thank-you, draft certificate wording, translate a message into
Afrikaans (ties in with §10), flag unusual payments for the cashup.
events, write a post-event thank-you, draft certificate wording, flag unusual payments for the cashup.
### Self-hosted AI (long term)
@@ -971,8 +974,8 @@ income justify the hardware:
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
| 10 | 1.49.0 ✅ – 1.49.2, 1.50.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass (Afrikaans removed from scope) | Polish before the big release |
| 11 | 1.51.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`),
@@ -1023,6 +1026,7 @@ feature.
| — | Multi-campus split | Two releases: **1.43.0 campuses**, **1.44.0 campus-scoped staff**; everything after moves up by one. Scoped staff can view and scan "All campuses" events, only admins and unscoped supervisors edit them. "Everyone at campus X" = home campus X, plus (tick box) registrants of campus X's events in the last 12 months. Campus landing pages are in 1.43.0 (2026-10-27) |
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
| — | Languages (Afrikaans) | **Removed from scope** (2026-10-28); the accessibility pass becomes 1.50.0 and the TypeScript backend / `/api/v2` 1.51.0 |
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses. Following a series is **News & announcements**; copies keep the source's certificate and reminder settings. Season pass: one checkout registers for every event on it, the supervisor picks the events, the price is split in proportion to normal prices (2026-10-28) |
+33
View File
@@ -0,0 +1,33 @@
import { test, expect, loginAs } from "./fixtures";
// Saved filters on list pages (1.49.2): save the current filters, make them the default (applied
// when the page opens), and delete them again.
test("an admin saves a registrations filter as the default, and deletes it", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/registrations");
const status = page.locator("select").filter({ has: page.locator("option", { hasText: "All statuses" }) });
await status.selectOption("cancelled");
const saved = page.getByTestId("saved-filters");
await saved.getByRole("button", { name: /Saved filters/ }).click();
await saved.getByLabel("Save the current filters as").fill("Cancelled ones");
await saved.getByLabel("Use when this page opens").check();
await saved.getByRole("button", { name: "Save", exact: true }).click();
await expect(saved.getByRole("button", { name: "Cancelled ones" }).first()).toBeVisible();
// The default is applied when the page opens…
await page.reload();
await expect(status).toHaveValue("cancelled");
await expect(saved.getByRole("button", { name: /Cancelled ones/ }).first()).toBeVisible();
// …but not over a link that says what to show.
await page.goto("/dashboard/admin/registrations?status=paid");
await expect(status).toHaveValue("paid");
await saved.getByRole("button", { name: /Saved filters/ }).click();
page.once("dialog", d => d.accept());
await saved.getByRole("button", { name: "Delete Cancelled ones" }).click();
await expect(saved.getByText("No saved filters yet")).toBeVisible();
await page.goto("/dashboard/admin/registrations");
await expect(status).toHaveValue("");
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.49.1",
"version": "1.49.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.49.1",
"version": "1.49.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.49.1",
"version": "1.49.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -10,6 +10,7 @@ import { errorMessage } from "@/lib/utils";
import type { Event, FormResponse, Registration } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { RegistrationBulkBar } from "@/components/admin/RegistrationBulkBar";
import { SavedFilters } from "@/components/shared/SavedFilters";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
@@ -292,6 +293,15 @@ export default function AdminRegistrationsPage() {
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={loadRegistrations} disabled={loadingRegs}>
{loadingRegs ? "Loading…" : "Refresh"}
</button>
<SavedFilters
page="registrations"
value={{ q: query, event: eventFilter, status: statusFilter, past: includePastEvents, inactive: includeInactiveEvents }}
onApply={p => {
setQuery(String(p.q || "")); setEventFilter(String(p.event || "")); setStatusFilter(String(p.status || ""));
setIncludePastEvents(p.past === true); setIncludeInactiveEvents(p.inactive === true);
}}
skipDefault={typeof window !== "undefined" && /[?&](q|status|event)=/.test(window.location.search)}
/>
</div>
<div className="mt-2 text-xs text-gray-500">{filtered.length} of {registrations.length} registrations</div>
</div>
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
import type { Paginated } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
import { useCampuses } from "@/hooks/useCampuses";
import { SavedFilters } from "@/components/shared/SavedFilters";
interface UserItem {
id: string;
@@ -359,6 +360,17 @@ export default function AdminUsersPage() {
<option value="false">Inactive only</option>
</select>
</div>
<SavedFilters
page="users" className="self-end"
value={{ q: query, role: roleFilter, active: activeFilter }}
onApply={p => {
const q = String(p.q || "");
setQuery(q); setDebouncedQuery(q);
setRoleFilter(String(p.role || "") as Role | "");
setActiveFilter(String(p.active || "") as "" | "true" | "false");
}}
skipDefault={typeof window !== "undefined" && /[?&]q=/.test(window.location.search)}
/>
<div>
<label className="block text-xs text-gray-600 mb-1">Per page</label>
<select className="border rounded px-2 py-1.5 text-sm" value={pageSize} onChange={e => handlePageSizeChange(Number(e.target.value))}>
@@ -10,6 +10,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useMyCampuses } from "@/hooks/useCampuses";
import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
import { SavedFilters } from "@/components/shared/SavedFilters";
import { Calendar, Copy } from "lucide-react";
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
import { errorMessage } from "@/lib/utils";
@@ -2159,6 +2160,14 @@ export default function ManageEventsPage() {
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={showClosed} onChange={e => setShowClosed(e.target.checked)} /> Show closed
</label>
<SavedFilters
page="events"
value={{ q: search, campus, past: includePast, inactive: showInactive, closed: showClosed }}
onApply={p => {
setSearch(String(p.q || "")); setCampus(String(p.campus || ""));
setIncludePast(p.past === true); setShowInactive(p.inactive === true); setShowClosed(p.closed === true);
}}
/>
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
</div>
+24 -9
View File
@@ -15,6 +15,7 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import { CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
import { SavedFilters } from "@/components/shared/SavedFilters";
import type { ConsentRow, Event, EventCashup, EventFinancials, EventSeries, MedicalRow, Payment, Registration, SeriesComparison, Ticket, UserSummary } from "@/types";
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
@@ -1512,15 +1513,29 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<CampusFilter
value={campus}
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
withChurchWide
everyLabel="All campuses (church-wide total)"
className="mb-3"
id="reports-campus"
mine
/>
<div className="flex flex-wrap items-start gap-3 mb-3">
<CampusFilter
value={campus}
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
withChurchWide
everyLabel="All campuses (church-wide total)"
id="reports-campus"
mine
/>
<SavedFilters
page="reports"
value={{ report, events: selectedEventIds, from: dateFrom, to: dateTo, campus, past: showPastEvents, inactive: showInactiveEvents, closed: showClosedEvents }}
onApply={p => {
if (typeof p.report === "string" && REPORTS.some(r => r.key === p.report)) setReport(p.report as ReportKey);
setCampus(String(p.campus || ""));
setShowPastEvents(p.past === true); setShowInactiveEvents(p.inactive === true); setShowClosedEvents(p.closed !== false);
setSelectedEventIds(Array.isArray(p.events) ? p.events : []);
setDateFrom(String(p.from || "")); setDateTo(String(p.to || ""));
setReady(false);
}}
skipDefault={!!searchParams.get("report")}
/>
</div>
<ReportsShell
events={filteredEvents}
@@ -0,0 +1,116 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Star, Trash2, Check } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
export type FilterValues = Record<string, string | number | boolean | string[]>;
type Saved = { id: string; name: string; params: FilterValues; isDefault: boolean };
/**
* Saved filters on a list page (1.49.2): apply one with a click, save the current filters under a
* name, star one as the default (applied when the page opens, unless the link already says what
* to show — `skipDefault`). Stored per person on the server.
*/
export function SavedFilters({ page, value, onApply, skipDefault = false, className = "" }: {
page: "registrations" | "users" | "events" | "reports";
value: FilterValues;
onApply: (params: FilterValues) => void;
skipDefault?: boolean;
className?: string;
}) {
const { token } = useAuth();
const [list, setList] = useState<Saved[]>([]);
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [name, setName] = useState("");
const [asDefault, setAsDefault] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applied, setApplied] = useState<string | null>(null);
const defaultDone = useRef(false);
const boxRef = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const rows = await apiFetch<Saved[]>(`/api/saved-filters?page=${page}`, { authToken: token });
setList(rows);
// The default, once, when the page opens.
if (!defaultDone.current) {
defaultDone.current = true;
const def = rows.find(r => r.isDefault);
if (def && !skipDefault) { onApply(def.params); setApplied(def.id); }
}
} catch { /* the page works without them */ }
// onApply/skipDefault are read once, for the default.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, page]);
useEffect(() => { load(); }, [load]);
// Close when clicking elsewhere.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const act = async (fn: () => Promise<unknown>) => {
setError(null);
try { await fn(); await load(); } catch (e) { setError(errorMessage(e) || "Couldn't save that"); }
};
const save = () => act(async () => {
setSaving(true);
try {
const row = await apiFetch<Saved>("/api/saved-filters", { method: "POST", authToken: token || undefined, body: { page, name, params: value, isDefault: asDefault } });
setApplied(row.id); setName(""); setAsDefault(false);
} finally { setSaving(false); }
});
const toggleDefault = (f: Saved) => act(() => apiFetch(`/api/saved-filters/${f.id}`, { method: "PUT", authToken: token || undefined, body: { isDefault: !f.isDefault } }));
const remove = (f: Saved) => act(async () => {
if (!confirm(`Delete the saved filter "${f.name}"?`)) return;
await apiFetch(`/api/saved-filters/${f.id}`, { method: "DELETE", authToken: token || undefined });
});
const current = list.find(f => f.id === applied);
return (
<div className={`relative inline-block ${className}`} ref={boxRef} data-testid="saved-filters">
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50">
<Bookmark className="w-4 h-4" /> {current ? current.name : "Saved filters"}{list.length > 0 && !current ? ` (${list.length})` : ""}
</button>
{open && (
<div className="absolute z-30 mt-1 w-72 right-0 sm:left-0 sm:right-auto rounded-xl border bg-white shadow-lg p-2 space-y-2" role="dialog" aria-label="Saved filters">
{list.length === 0 ? (
<p className="text-xs text-gray-500 px-1">No saved filters yet. Set the filters the way you like them and save them here.</p>
) : (
<ul className="max-h-60 overflow-y-auto">
{list.map(f => (
<li key={f.id} className="flex items-center gap-1 rounded hover:bg-gray-50">
<button type="button" className="flex-1 text-left text-sm px-2 py-1.5 truncate" onClick={() => { onApply(f.params); setApplied(f.id); setOpen(false); }}>
{applied === f.id && <Check className="inline w-3.5 h-3.5 mr-1 text-brand-600" />}{f.name}
</button>
<button type="button" onClick={() => toggleDefault(f)} title={f.isDefault ? "Default — click to stop" : "Make this the default when the page opens"} aria-label={f.isDefault ? `${f.name} is the default` : `Make ${f.name} the default`} className="p-1">
<Star className={`w-4 h-4 ${f.isDefault ? "fill-amber-400 text-amber-500" : "text-gray-300 hover:text-gray-500"}`} />
</button>
<button type="button" onClick={() => remove(f)} aria-label={`Delete ${f.name}`} className="p-1 text-gray-300 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</li>
))}
</ul>
)}
<div className="border-t pt-2 space-y-1.5">
<label className="block text-xs text-gray-600" htmlFor={`save-filter-${page}`}>Save the current filters as</label>
<div className="flex gap-1.5">
<input id={`save-filter-${page}`} className="flex-1 min-w-0 border rounded px-2 py-1 text-sm" placeholder="e.g. Camp — unpaid" value={name} onChange={e => setName(e.target.value)} onKeyDown={e => { if (e.key === "Enter" && name.trim()) save(); }} />
<button type="button" disabled={saving || !name.trim()} onClick={save} className="px-2.5 py-1 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Save</button>
</div>
<label className="flex items-center gap-1.5 text-xs text-gray-600"><input type="checkbox" checked={asDefault} onChange={e => setAsDefault(e.target.checked)} /> Use when this page opens</label>
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
</div>
)}
</div>
);
}
@@ -1,5 +1,5 @@
import React from "react";
import { ClipboardList, Filter, Ban, CheckSquare } from "lucide-react";
import { ClipboardList, Filter, Ban, CheckSquare, Bookmark } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -26,6 +26,9 @@ export const dashboardAdminRegistrationsHelpContent: HelpContent = {
<div className="space-y-4">
<p>Click a row to expand it and see ticket options (with early-bird flags where applied), the payment history, and any submitted form responses.</p>
<div className="space-y-4">
<GuideItem icon={Bookmark} title="Saved filters" tone="amber">
Set the filters the way you use them, then Saved filters → name them and Save. Click one to apply it; the star makes it the one used when the page opens. They&apos;re yours (other people don&apos;t see them) and follow you to any device. The Users, Events and Reports pages have them too.
</GuideItem>
<GuideItem icon={CheckSquare} title="Doing many at once" tone="violet">
Tick registrations (or &quot;Select all shown&quot; after filtering) and choose an action: send a message (email or WhatsApp, as an event update), mark paid (records what each still owes, in cash, EFT, card or other — tickets go out by email), move to another ticket (their tickets and QR codes stay the same; the price follows the new ticket), put their named people in a room or group, export to Excel, or cancel. Each one follows the same rules as doing it one at a time, and you see what happened to each. Moving tickets and groups need registrations from one event.
</GuideItem>
+1 -1
View File
@@ -29,7 +29,7 @@ export const reportsHelpContent: HelpContent = {
Preview your report, adjust filters, and choose how you want the data to appear.
</GuideItem>
<GuideItem icon={Download} title="Export or email" tone="amber">
Export your report to Excel, PDF, or send it by email or WhatsApp. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026), or use Series: year on year to compare a whole series&apos; registrations, income and attendance. The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page&apos;s Share and Save QR buttons tag their links as &quot;share&quot; and &quot;qr&quot;. WhatsApp doesn&apos;t say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as &quot;direct&quot;. It counts only visitors who allowed cookies.
Export your report to Excel, PDF, or send it by email or WhatsApp. Saved filters (next to the campus filter) keep a report with its events, dates and options under a name — star one to have it ready when the page opens. Tip: select several events in the Feedback summary to compare their ratings (e.g. Camp 2025 vs Camp 2026), or use Series: year on year to compare a whole series&apos; registrations, income and attendance. The Registration funnel shows where people drop off between viewing an event and paying, by source and device. The source comes from short links and utm_source links, else the site the visitor came from (Facebook, Instagram, Google…). The event page&apos;s Share and Save QR buttons tag their links as &quot;share&quot; and &quot;qr&quot;. WhatsApp doesn&apos;t say where a click came from, so add ?utm_source=whatsapp to links you post there, or they show as &quot;direct&quot;. It counts only visitors who allowed cookies.
</GuideItem>
</div>
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.49.1",
"version": "1.49.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.49.1",
"version": "1.49.2",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.49.1",
"version": "1.49.2",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",