Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 4c066e67d9 Pagination and search on long lists (1.50.1)
Audit log: search (person, target, IP), role filter, every action in the
filter, inclusive To date. Data requests: search and paging (was cut at
200). Webhook deliveries: status filter and Load older. Supervisor Payments
loads only what it needs (unpaid registrations, recent/today/donation
payments, a person's own for refunds) instead of everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 06:53:54 +02:00
26 changed files with 460 additions and 147 deletions
+28
View File
@@ -7,6 +7,34 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.50.1] - 2026-10-29
### Added
- **Audit log:** search by person (name or email), target (type or `#id`) or IP, a Role filter,
**Clear filters**, and "21–40 of 312" under the list. The Action filter now lists every action
that can be logged (it was missing 35 — campuses, kids check-in, volunteers, series, season
passes, webhooks, bulk actions, Hub updates, certificates and more), sorted by name.
- **Data requests:** search by name or email, and closed requests a page (50) at a time — the list
used to stop silently at 200.
- **Webhook delivery log:** filter by status (failed, retrying, succeeded) and **Load older** —
it used to show only the latest 50.
- API filters: `/api/registrations?unpaid=1&userId=`, `/api/payments?userId=&from=&donations=1`,
`/api/privacy/requests?search=&page=&limit=`, `/api/outbound-webhooks/:id/deliveries?before=`,
`/api/admin/audit-log?search=&role=`. Shared `components/shared/Pager.tsx`.
### Fixed
- Audit log: the **To** date left out that day's entries (it meant midnight at the start of the
day). Both dates are now whole days in the site's time zone.
### Changed
- **Payments** (supervisor) no longer downloads every payment and registration ever made when it
opens: it loads the latest payments, today's (for the totals), donations with what they were
applied to, and only registrations still owing. Refunds load the chosen person's own payments and
registrations when they're picked. Faster as the years add up; the page works the same.
## [1.50.0] - 2026-10-28
### Added
+4 -3
View File
@@ -489,7 +489,8 @@ logout controls. Disconnected sessions are recovered automatically.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
cancellations, event create/update/delete, settings changes, and coupon codes
(created, edited, turned off, applied or removed by staff).
(created, edited, turned off, applied or removed by staff), and every later feature's admin
actions. Search by person, target or IP; filter by action, role and dates; 50 a page.
- **Site settings** — see below.
---
@@ -774,7 +775,7 @@ contact page shows the Information Officer.
and the people affected were told, and drafts of both notifications.
- **Readiness** — a scored checklist of what's set up, each item linking to its fix.
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
**For admins** — **Data requests** (`/dashboard/admin/data-requests`, searchable, closed ones paged): correction and
deletion requests with a 30-day due date, emailed to the Information Officer and
admins, reminded daily when due within a week. Completing a deletion anonymises the
person: name, contact details, the people they registered, form answers, consent
@@ -816,7 +817,7 @@ Site settings → **Webhooks** (admins): send what happens to other systems as i
check-in with session, person, event); headers `X-CrossEvents-Event`, `X-CrossEvents-Delivery` and
`X-CrossEvents-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">` with the endpoint's secret.
- **Delivery log** per endpoint (30 days): status, tries, response code or error, the full message,
**Resend**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
**Resend**; filter by status and **Load older**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
- Every path that registers, pays, cancels, refunds or checks in is covered (Prisma client
extension); rolled-back changes send nothing. Kids check-in and volunteer tickets are never sent.
- https and public addresses only (DNS checked at each send, redirects not followed). Each endpoint
+9
View File
@@ -12,6 +12,15 @@ 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.50.1
- **Finding things in long lists.** The audit log can be searched by person, what was changed or
IP address, filtered by role, and its action list now includes every kind of action. Data
requests can be searched and come a page at a time, and the webhook delivery log can show only
failed deliveries and load older ones.
- **Fix:** the audit log's "To" date now includes that day.
- **Faster Payments page** for churches with a few years of payments behind them.
## 1.50.0
- **Easier for everyone to use.** The public pages, registration, payment, My Events and the
+4 -4
View File
@@ -243,7 +243,7 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
| PUT | `/api/registrations/:id/forms/draft` | optional | Save form draft |
| POST | `/api/registrations/:id/forms/responses` | optional | Submit form responses |
| PUT | `/api/registrations/:id/forms/responses` | supervisor+ | Replace form responses |
| GET | `/api/registrations` | staff+ | All registrations |
| GET | `/api/registrations?page=&limit=&unpaid=1&userId=` | staff+ | All registrations (a plain array; paged `{ data, total, page, limit, pages }` when `page` or `limit` is given). `unpaid=1`: still owing (pending, confirmed, partially paid); `userId`: one person's |
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
@@ -570,7 +570,7 @@ The data subject's tools, unsubscribe links and the admin data-requests inbox (`
| GET | `/api/privacy/unsubscribe/:token` | public (signed token) | What the link is for: an account (masked email, news state) or an address |
| POST | `/api/privacy/unsubscribe/:token` | public (signed token) | `{ channels?: ["email","whatsapp"] }` — turn news off (default both) |
| POST | `/api/privacy/unsubscribe/:token/one-click` | public (signed token) | RFC 8058 one-click (from the `List-Unsubscribe` header) — email news off. GET redirects to `/unsubscribe` |
| GET | `/api/privacy/requests?status=open\|closed\|all` | admin | Data requests (not self-service downloads), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended) |
| GET | `/api/privacy/requests?status=open\|closed\|all&search=&page=&limit=` | admin | Data requests (not self-service downloads), 50 a page (`total`, `pages`), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended). `search` matches name or email |
| POST | `/api/privacy/requests/:id/complete` | admin | `{ response? }` (required for corrections; emailed). Deletion: anonymises the account — 409 while it has blockers. Audited as `data_request_completed` (+ `user_anonymised`) |
| POST | `/api/privacy/requests/:id/reject` | admin | `{ response, cancelled?: bool }` — reject (or mark withdrawn); a deletion reopens the account. Audited as `data_request_rejected` |
@@ -643,7 +643,7 @@ Options and variants also accept an optional `staffPrice`, charged automatically
| GET | `/api/payments/mypayments` | user+ | Own payments |
| GET | `/api/payments/:id` | user+ | Payment by ID |
| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration |
| GET | `/api/payments` | supervisor+ | All payments |
| GET | `/api/payments?page=&limit=&userId=&from=&donations=1` | supervisor+ | Payments, newest first, paged (`limit` up to 200). `userId`: one person's; `from`: created on/after (ISO); `donations=1`: donations and the legs applying them |
| GET | `/api/payments/event/:eventId` | staff+ | Payments for an event |
| PUT | `/api/payments/assign-donation` | supervisor+ | Assign a donation payment to a registration |
| POST | `/api/payments/refund` | supervisor+ | Create refund record |
@@ -1005,7 +1005,7 @@ where v1 = HMAC-SHA256(secret, `<t>.<raw body>`). Answer 2xx within 10 s; anythi
| DELETE | `/api/outbound-webhooks/:id` | Endpoint and its deliveries |
| POST | `/api/outbound-webhooks/:id/rotate-secret` | `{ secret }` |
| POST | `/api/outbound-webhooks/:id/test` | `{ status, statusCode, error }` |
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=` | Delivery log |
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=&before=` | Delivery log, newest first; `before` (a `createdAt`) loads older ones |
| POST | `/api/outbound-webhooks/deliveries/:id/resend` | Send again now |
## Season passes
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.50.0",
"version": "1.50.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.50.0",
"version": "1.50.1",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.50.0",
"version": "1.50.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -6,8 +6,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
// @access Admin
const listAuditLog = async (req, res) => {
try {
const { page, limit, actorId, action, from, to } = req.query;
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
const { page, limit, actorId, action, role, from, to, search } = req.query;
const result = await getAdminAuditLog({ page, limit, actorId, action, role, from, to, search });
res.json(result);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
@@ -154,6 +154,9 @@ const test = async (req, res) => {
const deliveries = async (req, res) => {
try {
const where = { endpointId: req.params.id, ...(req.query.status ? { status: String(req.query.status) } : {}) };
// "Load older": the next page is everything before the last one shown.
const before = req.query.before ? new Date(String(req.query.before)) : null;
if (before && !Number.isNaN(before.getTime())) where.createdAt = { lt: before };
const rows = await prisma.webhookDelivery.findMany({
where, orderBy: { createdAt: 'desc' }, take: Math.min(200, parseInt(req.query.limit, 10) || 50),
select: { id: true, eventId: true, type: true, status: true, attempts: true, nextAttemptAt: true, lastStatusCode: true, lastError: true, deliveredAt: true, createdAt: true, payload: true },
+9 -1
View File
@@ -354,7 +354,15 @@ const getPayments = async (req, res) => {
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
// its registration's), so unlinked donations aren't listed for them.
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
const scopeWhere = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
// Narrowing filters: ?userId= (one person's), ?from= (on or after, e.g. today's start),
// ?donations=1 (donations and the legs that apply them to registrations).
const filters = [];
if (req.query.userId) filters.push({ userId: String(req.query.userId) });
const from = req.query.from ? new Date(String(req.query.from)) : null;
if (from && !Number.isNaN(from.getTime())) filters.push({ createdAt: { gte: from } });
if (['1', 'true'].includes(String(req.query.donations || ''))) filters.push({ OR: [{ isDonation: true }, { originalPaymentId: { not: null } }] });
const where = filters.length ? { AND: [scopeWhere, ...filters] } : scopeWhere;
const [payments, total] = await prisma.$transaction([
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.payment.count({ where })
+11 -5
View File
@@ -227,12 +227,18 @@ const listRequests = async (req, res) => {
const where = { selfService: false };
if (status === 'open') where.status = 'open';
else if (status === 'closed') where.status = { not: 'open' };
const rows = await prisma.dataRequest.findMany({
const q = String(req.query.search || '').trim().slice(0, 100);
if (q) where.OR = [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }];
// Closed requests pile up over the years, so they come a page at a time (newest first).
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const [rows, total] = await Promise.all([prisma.dataRequest.findMany({
where,
orderBy: status === 'open' ? { dueAt: 'asc' } : { createdAt: 'desc' },
take: 200,
take: limit,
skip: (page - 1) * limit,
include: { handledBy: { select: { name: true } }, user: { select: { isActive: true, phoneNumber: true } } },
});
}), prisma.dataRequest.count({ where })]);
const requests = await Promise.all(rows.map(async r => ({
id: r.id, type: r.type, status: r.status, name: r.name, email: r.email, details: r.details, response: r.response,
dueAt: r.dueAt, closedAt: r.closedAt, createdAt: r.createdAt, userId: r.userId,
@@ -240,8 +246,8 @@ const listRequests = async (req, res) => {
overdue: r.status === 'open' && r.dueAt < new Date(),
blockers: r.type === 'deletion' && r.status === 'open' && r.userId ? await privacy.deletionBlockers(r.userId) : [],
})));
const openCount = status === 'open' ? requests.length : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
res.json({ requests, openCount });
const openCount = status === 'open' && !q ? total : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
res.json({ requests, openCount, total, page, limit, pages: Math.max(1, Math.ceil(total / limit)) });
} catch (error) {
fail(res, error);
}
@@ -569,7 +569,13 @@ const getRegistrations = async (req, res) => {
// array of every registration) working unchanged; callers that pass either param get back
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
// Campus-scoped staff: registrations for events they can see.
const where = await require('../utils/campusScope').listWhere(req, 'event');
const scopeWhere = await require('../utils/campusScope').listWhere(req, 'event');
// Narrowing filters, so callers needn't download every registration ever made:
// ?unpaid=1 — still owing (not paid or cancelled); ?userId= — one person's.
const filters = [];
if (['1', 'true'].includes(String(req.query.unpaid || ''))) filters.push({ status: { in: ['pending', 'confirmed', 'partial_paid'] } });
if (req.query.userId) filters.push({ userId: String(req.query.userId) });
const where = filters.length ? { AND: [scopeWhere, ...filters] } : scopeWhere;
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
const registrations = await prisma.registration.findMany({ where, include });
return res.json(attachComputedTotalsToList(registrations));
+1 -1
View File
@@ -704,7 +704,7 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, role, from, to, search)' },
{ method:'GET', path:'/api/admin/jobs', auth:'admin', desc:'Background jobs: queue mode, timezone, and each job schedule, next run and last 10 runs' },
{ method:'POST', path:'/api/admin/jobs/:name/run', auth:'admin', desc:'Run a background job now (queued for a worker); audit-logged' },
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
+33 -5
View File
@@ -21,16 +21,44 @@ async function logAdminAction({ actorId, actorRole, action, targetType, targetId
}
}
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
const DAY_MS = 24 * 60 * 60 * 1000;
// A date (YYYY-MM-DD) means midnight in the server's time zone (APP_TIMEZONE), not UTC.
const startOf = (v) => new Date(DATE_ONLY.test(v) ? `${v}T00:00:00` : v);
// The Prisma filter for the audit-log page. Dates (YYYY-MM-DD) are whole days, so `to`
// includes that day. `search` matches the person (name or email), the target type or id,
// or the IP.
function auditLogWhere({ actorId, action, role, from, to, search } = {}) {
const where = {};
if (actorId) where.actorId = actorId;
if (action) where.action = action;
if (role) where.actorRole = role;
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = new Date(from);
if (to) where.createdAt.lte = new Date(to);
if (from) where.createdAt.gte = startOf(from);
if (to) {
if (DATE_ONLY.test(to)) where.createdAt.lt = new Date(startOf(to).getTime() + DAY_MS);
else where.createdAt.lte = new Date(to);
}
}
const q = String(search || '').trim().slice(0, 100);
if (q) {
const contains = { contains: q, mode: 'insensitive' };
where.OR = [
{ actor: { name: contains } },
{ actor: { email: contains } },
{ targetType: contains },
{ targetId: { startsWith: q.replace(/^#/, '') } },
{ ip: { startsWith: q } },
];
}
return where;
}
// Paginated listing for the admin audit-log page.
async function getAdminAuditLog({ page = 1, limit = 50, ...filters } = {}) {
const where = auditLogWhere(filters);
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
@@ -49,4 +77,4 @@ async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, t
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
}
module.exports = { logAdminAction, getAdminAuditLog };
module.exports = { logAdminAction, getAdminAuditLog, auditLogWhere };
+40
View File
@@ -0,0 +1,40 @@
jest.mock('../src/config/db', () => ({}));
const { auditLogWhere } = require('../src/utils/adminAudit');
describe('auditLogWhere', () => {
test('no filters → everything', () => {
expect(auditLogWhere({})).toEqual({});
});
test('a "to" date includes that whole day', () => {
const where = auditLogWhere({ from: '2026-10-01', to: '2026-10-28' });
expect(where.createdAt.gte).toEqual(new Date('2026-10-01T00:00:00'));
expect(where.createdAt.lt).toEqual(new Date('2026-10-29T00:00:00'));
expect(where.createdAt.lte).toBeUndefined();
});
test('a full timestamp is used as given', () => {
const where = auditLogWhere({ to: '2026-10-28T10:00:00.000Z' });
expect(where.createdAt.lte).toEqual(new Date('2026-10-28T10:00:00.000Z'));
});
test('action and role filter exactly', () => {
expect(auditLogWhere({ action: 'refund_created', role: 'supervisor' })).toEqual({ action: 'refund_created', actorRole: 'supervisor' });
});
test('search looks at the person, the target and the IP', () => {
const where = auditLogWhere({ search: ' #ab12 ' });
expect(where.OR).toEqual([
{ actor: { name: { contains: '#ab12', mode: 'insensitive' } } },
{ actor: { email: { contains: '#ab12', mode: 'insensitive' } } },
{ targetType: { contains: '#ab12', mode: 'insensitive' } },
{ targetId: { startsWith: 'ab12' } },
{ ip: { startsWith: '#ab12' } },
]);
});
test('a blank search is ignored', () => {
expect(auditLogWhere({ search: ' ' }).OR).toBeUndefined();
});
});
+16 -8
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-28, after **1.50.0** (and Hub 1.5.0). The plan itself is in
Last updated: 2026-10-28, after **1.50.1** (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.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
| 1.50.0 | **Accessibility pass** (§11, WCAG 2.2 AA) on public pages, auth, registration, payment, My Events and the kiosk: skip link, focus ring, labelled fields, named quantity buttons, announced errors, `ModalOverlay` popups (focus trap, Escape), contrast and underlined in-text links, reduce-motion; Branding warns about a too-light primary colour. axe + keyboard e2e in `e2e/accessibility.spec.ts` |
| 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` |
@@ -59,11 +60,7 @@ 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.50.1 — pagination and search on long lists**: check the Audit log and the other pages whose
data grows large (registrations, users, payments, messages, access/consent logs, background
jobs, webhooks deliveries …) for server-side paging, search and filters, and add them where
missing (Joshua, 2026-10-28).
2. **1.51.0 — TypeScript backend, OpenAPI and `/api/v2`** (§14), then **2.0.0 — AI helpers** (§15).
1. **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
@@ -436,6 +433,19 @@ a test server (both done 2026-10-28).
weren't in §11's scope; many of their labels still aren't tied to fields and they use hand-made
popups. Do them when those pages are next worked on.
## Long lists (1.50.1)
- A list that grows for ever must page on the server (`page`/`limit`, `total`, `pages`) or load
older items with a `before` cursor, and offer a search. `components/shared/Pager.tsx` renders
"21–40 of 312 · Prev · Next". Already paged: users, registrations (Manage registrations), events,
payments, audit log, consent/access logs, data requests, My payments.
- **Audit-log actions:** a new `AdminAuditAction` needs a label in `ACTION_LABELS`
(`app/dashboard/admin/audit-log/page.tsx`) — the filter is built from that list.
- **Still loading everything (fine for now, watch as installs grow):** user pickers that call
`fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search
picker would replace them; event pickers using `/api/events/all?includePast=true`; the coupons
list; `GET /api/registrations` without `page` (other callers still use the plain array).
## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
@@ -446,8 +456,6 @@ a test server (both done 2026-10-28).
real WAWP webhook is `/api/whatsapp/webhook`. Remove the stub in a later release.
- There's no log of individual messages sent, so "Download my data" can't include "messages
sent to you" yet.
- The audit-log page's action filter list was already missing newer actions before 1.38.0
(only the three privacy ones were added).
- At the door, per-session tickets are booked on new registrations only; editing an
existing registration leaves its booked sessions as they are (the backend refuses
+3 -3
View File
@@ -38,8 +38,8 @@ Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an
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** 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. 1.50.0 **accessibility
pass** shipped. **Next: 1.50.1 pagination and search on long lists** (Audit log and other
large lists), then 1.51.0 TypeScript backend / `/api/v2`.
pass** shipped. 1.50.1 **pagination and search on long lists** shipped. **Next: 1.51.0 TypeScript
backend / `/api/v2`.**
**Repositories:**
@@ -978,7 +978,7 @@ 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.50.1 | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists (Afrikaans removed from scope) | Polish before the big release |
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists (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 |
+51
View File
@@ -0,0 +1,51 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
// 1.50.1: long lists load a page (or just what's needed) at a time, with search and filters.
test.describe("long lists", () => {
test("Payments: today, recent and a person's payments for a refund", async ({ page }) => {
// A donation recorded today for the seeded user (on an event no other test totals up).
await api(page, "supervisor", "POST", "/api/payments", {
amount: 77, method: "cash", isDonation: true, eventId: seed().events.conference, userId: seed().users.user.id,
});
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/payments");
await expect(page.getByText("R 77.00").first()).toBeVisible();
// Refund: picking the person loads their payments.
await page.locator("label", { hasText: "Refund" }).first().click();
await page.getByPlaceholder("Search by name, email or phone…").last().fill(seed().users.user.name);
await page.getByRole("button", { name: new RegExp(seed().users.user.name) }).first().click();
await expect(page.locator("option", { hasText: "R 77.00" })).toHaveCount(1);
});
test("Payments API: narrowing filters", async ({ page }) => {
const userId = seed().users.user.id;
const mine = await api<{ data: { userId: string }[] }>(page, "supervisor", "GET", `/api/payments?userId=${userId}&limit=200`);
expect(mine.data.length).toBeGreaterThan(0);
expect(mine.data.every((p) => p.userId === userId)).toBe(true);
const unpaid = await api<{ status: string }[]>(page, "supervisor", "GET", "/api/registrations?unpaid=1");
expect(unpaid.every((r) => ["pending", "confirmed", "partial_paid"].includes(r.status))).toBe(true);
});
test("Audit log: search and the full list of actions", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/audit-log");
const action = page.getByLabel("Action");
await expect(action.locator("option", { hasText: "Bulk action on registrations" })).toHaveCount(1);
await expect(action.locator("option", { hasText: "Webhook added, changed or deleted" })).toHaveCount(1);
await page.getByLabel("Search").fill("nobody-matches-this-xyz");
await expect(page.getByText("No matching audit entries.")).toBeVisible();
await expect(page.getByText(/0 actions match the filters/)).toBeVisible();
await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page.getByLabel("Search")).toHaveValue("");
});
test("Data requests: search", async ({ page }) => {
const res = await api<{ requests: unknown[]; total: number }>(page, "admin", "GET", "/api/privacy/requests?status=closed&search=nobody-matches-this-xyz");
expect(res.requests).toEqual([]);
expect(res.total).toBe(0);
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.50.0",
"version": "1.50.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.50.0",
"version": "1.50.1",
"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.50.0",
"version": "1.50.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -10,50 +10,6 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
const ACTIONS = [
"refund_created",
"donation_assigned",
"donation_unassigned",
"registration_created_manual",
"registration_cancelled",
"event_created",
"event_updated",
"event_deleted",
"settings_updated",
"coupon_created",
"coupon_updated",
"coupon_disabled",
"coupon_applied_manual",
"bundle_created",
"bundle_updated",
"bundle_deleted",
"consent_override",
"consent_rerequested",
"medical_list_viewed",
"scan_lock_acquired",
"scan_lock_released",
"scan_lock_force_released",
"offline_sync",
"ticket_transferred",
"impersonation_started",
"impersonation_ended",
"yoco_config_updated",
"yoco_webhook_registered",
"yoco_webhook_deleted",
"data_request_completed",
"data_request_rejected",
"user_anonymised",
"privacy_notice_published",
"operator_created",
"operator_updated",
"operator_deleted",
"retention_policies_updated",
"retention_run",
"breach_recorded",
"breach_updated",
"hub_connected",
] as const;
const ACTION_LABELS: Record<string, string> = {
refund_created: "Refund created",
donation_assigned: "Donation assigned",
@@ -122,8 +78,23 @@ const ACTION_LABELS: Record<string, string> = {
series_deleted: "Event series deleted",
season_pass_changed: "Season pass created, changed or deleted",
registrations_bulk: "Bulk action on registrations",
attendance_marked: "Attendance marked",
attendance_unmarked: "Attendance unmarked",
certificate_issued: "Certificate issued",
certificate_revoked: "Certificate revoked",
event_sessions_changed: "Event sessions changed",
job_run_manual: "Background job run by hand",
payfast_config_updated: "PayFast settings changed",
payment_providers_updated: "Payment providers changed",
webhook_endpoint_changed: "Webhook added, changed or deleted",
};
// Every action that can be logged (keep in step with AdminAuditAction in schema.prisma),
// sorted by label for the filter.
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).sort((x, y) => x[1].localeCompare(y[1]));
const ROLES = ["admin", "supervisor", "staff"] as const;
interface AuditLogEntry {
id: string;
actorId: string | null;
@@ -212,16 +183,32 @@ export default function AdminAuditLogPage() {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const [actionFilter, setActionFilter] = useState<string>("");
const [roleFilter, setRoleFilter] = useState<string>("");
const [searchInput, setSearchInput] = useState<string>("");
const [search, setSearch] = useState<string>("");
const [fromFilter, setFromFilter] = useState<string>("");
const [toFilter, setToFilter] = useState<string>("");
const buildQuery = useCallback((p: number) => {
const qs = new URLSearchParams({ page: String(p), limit: String(pageSize) });
if (actionFilter) qs.set("action", actionFilter);
if (roleFilter) qs.set("role", roleFilter);
if (search) qs.set("search", search);
if (fromFilter) qs.set("from", fromFilter);
if (toFilter) qs.set("to", toFilter);
return `/api/admin/audit-log?${qs.toString()}`;
}, [actionFilter, fromFilter, toFilter]);
}, [actionFilter, roleFilter, search, fromFilter, toFilter]);
// Search as you type, once typing pauses.
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput.trim()), 350);
return () => clearTimeout(t);
}, [searchInput]);
const filtered = !!(actionFilter || roleFilter || search || fromFilter || toFilter);
const clearFilters = () => {
setActionFilter(""); setRoleFilter(""); setSearchInput(""); setSearch(""); setFromFilter(""); setToFilter("");
};
const load = useCallback(async (p = 1) => {
if (!token) return;
@@ -251,7 +238,7 @@ export default function AdminAuditLogPage() {
<div>
<BackLink href="/dashboard/admin" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Admin Audit Log</h1>
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} recorded</p>
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} {filtered ? "match the filters" : "recorded"}</p>
</div>
</div>
@@ -262,25 +249,48 @@ export default function AdminAuditLogPage() {
)}
{isAdmin && token && <ImpersonationSessions token={token} />}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{error && <div role="alert" className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div className="flex-1 min-w-[14rem]">
<label htmlFor="audit-search" className="block text-xs text-gray-600 mb-1">Search</label>
<input
id="audit-search"
type="search"
className="w-full border rounded px-2 py-1.5 text-sm"
placeholder="Person, target (e.g. Event or #id) or IP"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Action</label>
<select className="border rounded px-2 py-1.5 text-sm" value={actionFilter} onChange={e => setActionFilter(e.target.value)}>
<label htmlFor="audit-action" className="block text-xs text-gray-600 mb-1">Action</label>
<select id="audit-action" className="border rounded px-2 py-1.5 text-sm max-w-[16rem]" value={actionFilter} onChange={e => setActionFilter(e.target.value)}>
<option value="">All actions</option>
{ACTIONS.map(a => <option key={a} value={a}>{ACTION_LABELS[a]}</option>)}
{ACTION_OPTIONS.map(([a, label]) => <option key={a} value={a}>{label}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={fromFilter} onChange={e => setFromFilter(e.target.value)} />
<label htmlFor="audit-role" className="block text-xs text-gray-600 mb-1">Role</label>
<select id="audit-role" className="border rounded px-2 py-1.5 text-sm" value={roleFilter} onChange={e => setRoleFilter(e.target.value)}>
<option value="">Any role</option>
{ROLES.map(r => <option key={r} value={r} className="capitalize">{r[0].toUpperCase() + r.slice(1)}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={toFilter} onChange={e => setToFilter(e.target.value)} />
<label htmlFor="audit-from" className="block text-xs text-gray-600 mb-1">From</label>
<input id="audit-from" type="date" className="border rounded px-2 py-1.5 text-sm" value={fromFilter} onChange={e => setFromFilter(e.target.value)} />
</div>
<div>
<label htmlFor="audit-to" className="block text-xs text-gray-600 mb-1">To</label>
<input id="audit-to" type="date" className="border rounded px-2 py-1.5 text-sm" value={toFilter} min={fromFilter || undefined} onChange={e => setToFilter(e.target.value)} />
</div>
{filtered && (
<button className="text-sm px-2 py-1.5 rounded text-brand-700 hover:bg-brand-50" onClick={clearFilters}>
Clear filters
</button>
)}
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => load(page)} disabled={fetching}>
{fetching ? "Refreshing…" : "Refresh"}
</button>
@@ -307,9 +317,9 @@ export default function AdminAuditLogPage() {
<div className="text-xs text-gray-500">{e.actor.email}</div>
</>
) : (
<span className="text-gray-400 italic">Deleted user</span>
<span className="text-gray-500 italic">Deleted user</span>
)}
<div className="text-xs text-gray-400 capitalize">{e.actorRole}</div>
<div className="text-xs text-gray-500 capitalize">{e.actorRole}</div>
</TableCell>
<TableCell>{ACTION_LABELS[e.action] || e.action}</TableCell>
<TableCell className="text-xs text-gray-600">{e.targetType}{e.targetId ? ` #${e.targetId.slice(0, 8)}` : ""}</TableCell>
@@ -323,7 +333,7 @@ export default function AdminAuditLogPage() {
)}
{fetching && (
<TableRow>
<TableCell colSpan={5} className="text-gray-400">Loading…</TableCell>
<TableCell colSpan={5} className="text-gray-500">Loading…</TableCell>
</TableRow>
)}
</TableBody>
@@ -331,7 +341,7 @@ export default function AdminAuditLogPage() {
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm">
<span className="text-gray-500">Page {page} of {totalPages}</span>
<span className="text-gray-500">{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} · page {page} of {totalPages}</span>
<div className="flex gap-1">
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { Pager } from "@/components/shared/Pager";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Inbox, AlertTriangle } from "lucide-react";
import { errorMessage } from "@/lib/utils";
@@ -135,18 +136,33 @@ export default function DataRequestsPage() {
const [view, setView] = useState<"open" | "closed">("open");
const [requests, setRequests] = useState<DataRequest[] | null>(null);
const [openCount, setOpenCount] = useState(0);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const pageSize = 50;
// Search once typing pauses; a new search or tab starts at page 1.
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput.trim()), 350);
return () => clearTimeout(t);
}, [searchInput]);
useEffect(() => { setPage(1); }, [view, search]);
const [msg, setMsg] = useDismissingState<Msg>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ requests: DataRequest[]; openCount: number }>(`/api/privacy/requests?status=${view}`, { authToken: token });
const qs = new URLSearchParams({ status: view, page: String(page), limit: String(pageSize) });
if (search) qs.set("search", search);
const res = await apiFetch<{ requests: DataRequest[]; openCount: number; total: number }>(`/api/privacy/requests?${qs.toString()}`, { authToken: token });
setRequests(res.requests);
setOpenCount(res.openCount);
setTotal(res.total ?? res.requests.length);
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load requests." });
}
}, [token, view, setMsg]);
}, [token, view, page, search, setMsg]);
useEffect(() => { setRequests(null); load(); }, [load]);
return (
@@ -163,20 +179,28 @@ export default function DataRequestsPage() {
</p>
</div>
<div className="flex gap-2 mb-4">
<div className="flex flex-wrap items-center gap-2 mb-4">
{(["open", "closed"] as const).map(v => (
<button key={v} onClick={() => setView(v)} className={`px-3 py-1.5 rounded-lg text-sm font-medium ${view === v ? "bg-brand-600 text-white" : "bg-white border text-gray-700 hover:bg-gray-50"}`}>
<button key={v} onClick={() => setView(v)} aria-pressed={view === v} className={`px-3 py-1.5 rounded-lg text-sm font-medium ${view === v ? "bg-brand-600 text-white" : "bg-white border text-gray-700 hover:bg-gray-50"}`}>
{v === "open" ? `Open${openCount ? ` (${openCount})` : ""}` : "Closed"}
</button>
))}
<input
type="search"
aria-label="Search by name or email"
placeholder="Search by name or email"
className="ml-auto border rounded-lg px-3 py-1.5 text-sm w-full sm:w-64"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
/>
</div>
{msg && <div className={`mb-4 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{requests === null ? (
<p className="text-sm text-gray-400">Loading…</p>
<p className="text-sm text-gray-500">Loading…</p>
) : requests.length === 0 ? (
<p className="text-sm text-gray-500">{view === "open" ? "No open requests." : "No closed requests yet."}</p>
<p className="text-sm text-gray-500">{search ? "No requests match your search." : view === "open" ? "No open requests." : "No closed requests yet."}</p>
) : view === "open" ? (
<ul className="space-y-3">
{requests.map(r => <OpenRequest key={r.id} request={r} token={token!} onDone={m => { setMsg(m); load(); }} />)}
@@ -189,13 +213,14 @@ export default function DataRequestsPage() {
<span className="font-medium text-gray-900">{TYPE_LABEL[r.type]}</span>
<span className="text-gray-700">— {r.name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_TONE[r.status]}`}>{r.status}</span>
<span className="ml-auto text-xs text-gray-400">{r.closedAt ? `Closed ${fmtDate(r.closedAt)}` : ""}{r.handledBy ? ` by ${r.handledBy}` : ""}</span>
<span className="ml-auto text-xs text-gray-500">{r.closedAt ? `Closed ${fmtDate(r.closedAt)}` : ""}{r.handledBy ? ` by ${r.handledBy}` : ""}</span>
</div>
{r.response && <p className="text-gray-600 mt-1 whitespace-pre-line">{r.response}</p>}
</li>
))}
</ul>
)}
<Pager page={page} pageSize={pageSize} total={total} onPage={setPage} />
</div>
);
}
@@ -53,6 +53,19 @@ type ProviderTx = {
note?: string | null;
};
// What each registration still owes. The backend attaches a tranche-aware totalDueComputed
// (exact even when a line spans multiple early-bird prices); otherwise estimate it here.
function computeOutstanding(list: Registration[]): Outstanding {
const now = new Date();
const map: Outstanding = {};
for (const r of list) {
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
return map;
}
function RegistrationOptions({ regs, regOutstanding }: {
regs: Registration[];
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
@@ -161,7 +174,11 @@ function PaymentsContent() {
if (!user) router.replace("/login");
}, [user, loading, router]);
const [payments, setPayments] = useState<Payment[]>([]);
// Not every payment ever made: the latest few for "Recent payments", today's for the totals,
// and donations with the legs that apply them (for assigning and the assigned list).
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
const [todayPayments, setTodayPayments] = useState<Payment[]>([]);
const [donationPayments, setDonationPayments] = useState<Payment[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
@@ -173,8 +190,15 @@ function PaymentsContent() {
if (!token) return;
try {
setLoadingList(true);
const list = await fetchAllPayments(token);
setPayments(list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
const start = new Date(); start.setHours(0, 0, 0, 0);
const [recent, today, donations] = await Promise.all([
apiFetch<{ data: Payment[] }>("/api/payments?page=1&limit=40", { authToken: token }),
fetchAllPayments(token, { from: start.toISOString() }),
fetchAllPayments(token, { donations: "1" }),
]);
setRecentPayments(recent?.data || []);
setTodayPayments(today);
setDonationPayments(donations);
} catch (e) {
setError(errorMessage(e) || "Failed to load payments");
} finally {
@@ -184,25 +208,16 @@ function PaymentsContent() {
useEffect(() => { loadPayments(); }, [loadPayments]);
// Load registrations for dropdowns.
// GET /api/registrations already embeds each registration's `payments`, so outstanding
// balances are computed from that in one pass — no per-registration follow-up requests.
// Registrations still owing (not paid or cancelled), for the dropdowns. GET /api/registrations
// embeds each registration's `payments`, so balances come from that in one pass. Refunds load
// the chosen person's own registrations (including paid ones) when needed.
const loadRegistrations = useCallback(async () => {
if (!token) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
const regs = await apiFetch<Registration[]>("/api/registrations?unpaid=1", { authToken: token });
const list = Array.isArray(regs) ? regs : [];
const now = new Date();
const map: Outstanding = {};
for (const r of list) {
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
setRegOutstanding(map);
setRegOutstanding(computeOutstanding(list));
// Sort by createdAt desc
const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
setRegistrations(sorted);
@@ -420,11 +435,11 @@ function PaymentsContent() {
const todayTotals = useMemo(() => {
const start = new Date(); start.setHours(0,0,0,0);
// Exclude donation-application legs — that money was already counted once, as the donation.
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p));
const today = todayPayments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p));
const revenue = today.reduce((s,p)=> s + (p.amount||0), 0);
const donations = today.filter(p => p.isDonation).length;
return { revenue, donations, count: today.length };
}, [payments]);
}, [todayPayments]);
const [mode, setMode] = useState<'payment'|'refund'|'donation'|'reconcile'|'link'>("payment");
@@ -741,10 +756,7 @@ function PaymentsContent() {
{mode === 'refund' && (
<>
<RefundSection
payments={payments}
allUsers={allUsers}
regsForUser={(uid:string)=> registrations.filter((r)=> String(r.user?.id||r.userId)===String(uid))}
regOutstanding={regOutstanding}
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Refund recorded'); }}
/>
{loadingUsers && <div className="text-xs text-gray-500">Loading users…</div>}
@@ -754,7 +766,7 @@ function PaymentsContent() {
{mode === 'donation' && (
<>
<DonationAssignSection
payments={payments}
payments={donationPayments}
allUsers={allUsers}
registrations={registrations}
regOutstanding={regOutstanding}
@@ -762,7 +774,7 @@ function PaymentsContent() {
/>
{loadingUsers && <div className="text-xs text-gray-500">Loading users…</div>}
<AssignedDonationsList
payments={payments}
payments={donationPayments}
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation unassigned'); }}
/>
</>
@@ -815,7 +827,7 @@ function PaymentsContent() {
{loadingList && <span className="text-xs text-gray-500">Loading…</span>}
</div>
<ul className="text-sm space-y-2 max-h-[520px] overflow-auto pr-2">
{payments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
{recentPayments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
const amt = p.amount || 0;
const isRefund = amt < 0;
return (
@@ -834,7 +846,7 @@ function PaymentsContent() {
</li>
);
})}
{payments.length === 0 && <li className="text-gray-500">No payments yet.</li>}
{recentPayments.length === 0 && <li className="text-gray-500">No payments yet.</li>}
</ul>
</div>
</div>
@@ -864,14 +876,13 @@ function PaymentsContent() {
}
type RefundSectionProps = {
payments: Payment[];
allUsers: User[];
regsForUser: (userId: string) => Registration[];
regOutstanding: Outstanding;
onDone: () => void | Promise<void>;
};
function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone }: RefundSectionProps) {
// The chosen person's own payments and registrations (paid ones too) are loaded when they're
// picked, rather than every payment and registration up front.
function RefundSection({ allUsers, onDone }: RefundSectionProps) {
const { token } = useAuth();
const [userId, setUserId] = useState<string>("");
const [target, setTarget] = useState<'payment'|'registration'>('payment');
@@ -883,10 +894,28 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
const paymentsForUser = useMemo(() => {
if (!userId) return [];
return payments.filter(p => String(p.userId) === String(userId) && (p.amount || 0) > 0);
}, [payments, userId]);
const [payments, setPayments] = useState<Payment[]>([]);
const [regs, setRegs] = useState<Registration[]>([]);
const [loadingUser, setLoadingUser] = useState(false);
useEffect(() => {
setPayments([]); setRegs([]);
if (!userId || !token) return;
let cancelled = false;
setLoadingUser(true);
Promise.all([
fetchAllPayments(token, { userId }),
apiFetch<Registration[]>(`/api/registrations?userId=${encodeURIComponent(userId)}`, { authToken: token }),
]).then(([p, r]) => {
if (cancelled) return;
setPayments(p);
setRegs((Array.isArray(r) ? r : []).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
}).catch((e) => { if (!cancelled) setErr(errorMessage(e) || "Couldn't load this person's payments"); })
.finally(() => { if (!cancelled) setLoadingUser(false); });
return () => { cancelled = true; };
}, [userId, token]);
const regOutstanding = useMemo(() => computeOutstanding(regs), [regs]);
const paymentsForUser = useMemo(() => payments.filter(p => (p.amount || 0) > 0), [payments]);
useEffect(() => {
// Reset dependent fields on changes
@@ -907,7 +936,6 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
}
}, [paymentId, target, payments]);
const regs = useMemo(() => regsForUser(userId), [regsForUser, userId]);
const submitRefund = async () => {
if (!token) return;
@@ -948,6 +976,7 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
<div className="grid gap-2">
<label className="text-xs text-gray-600">User</label>
<UserSearchField allUsers={allUsers} value={userId} onChange={uid => setUserId(uid)} />
{loadingUser && <div className="text-xs text-gray-500" role="status">Loading their payments…</div>}
<label className="text-xs text-gray-600 mt-2">Refund target</label>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-2"><input type="radio" name="refundTarget" checked={target==='payment'} onChange={()=>setTarget('payment')} /> Payment</label>
+38 -5
View File
@@ -20,6 +20,9 @@ type ListResponse = { eventTypes: EventType[]; keepDays: number; endpoints: Endp
type Draft = { id?: string; name: string; url: string; events: string[] };
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
// Deliveries shown at a time in the log ("Load older" fetches the next lot).
const PAGE = 50;
const STATUS: Record<string, string> = {
succeeded: "bg-green-50 text-green-800", failed: "bg-red-50 text-red-700", retrying: "bg-amber-50 text-amber-800",
pending: "bg-gray-100 text-gray-600", sending: "bg-blue-50 text-blue-700",
@@ -50,21 +53,46 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
const [rows, setRows] = useState<Delivery[] | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [status, setStatus] = useState("");
const [more, setMore] = useState(false);
const fetchPage = useCallback((before?: string) => {
const qs = new URLSearchParams({ limit: String(PAGE) });
if (status) qs.set("status", status);
if (before) qs.set("before", before);
return apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries?${qs.toString()}`, { authToken: token }).catch(() => [] as Delivery[]);
}, [endpointId, token, status]);
const load = useCallback(async () => {
setRows(await apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries`, { authToken: token }).catch(() => []));
}, [endpointId, token]);
const page = await fetchPage();
setRows(page);
setMore(page.length === PAGE);
}, [fetchPage]);
useEffect(() => { load(); }, [load]);
const loadOlder = async () => {
if (!rows?.length) return;
const page = await fetchPage(rows[rows.length - 1].createdAt);
setRows([...rows, ...page]);
setMore(page.length === PAGE);
};
const resend = async (id: string) => {
setBusy(id);
await apiFetch(`/api/outbound-webhooks/deliveries/${id}/resend`, { method: "POST", authToken: token }).catch(() => {});
setBusy(null);
load();
};
if (!rows) return <p className="text-xs text-gray-400">Loading…</p>;
if (!rows.length) return <p className="text-xs text-gray-500">Nothing sent yet.</p>;
if (!rows) return <p className="text-xs text-gray-500">Loading…</p>;
if (!rows.length && !status) return <p className="text-xs text-gray-500">Nothing sent yet.</p>;
return (
<div className="space-y-1" data-testid="webhook-deliveries">
<div className="flex justify-end"><button type="button" onClick={load} className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-gray-900"><RefreshCw className="w-3 h-3" /> Refresh</button></div>
<div className="flex items-center justify-between gap-2">
<select aria-label="Show deliveries" className="border rounded px-2 py-1 text-xs" value={status} onChange={e => setStatus(e.target.value)}>
<option value="">All deliveries</option>
<option value="failed">Failed</option>
<option value="retrying">Retrying</option>
<option value="succeeded">Succeeded</option>
</select>
<button type="button" onClick={load} className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-gray-900"><RefreshCw className="w-3 h-3" aria-hidden="true" /> Refresh</button>
</div>
{!rows.length && <p className="text-xs text-gray-500">None with this status.</p>}
<ul className="divide-y border rounded-lg bg-white">
{rows.map(d => (
<li key={d.id} className="text-xs" data-testid="webhook-delivery">
@@ -89,6 +117,11 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
</li>
))}
</ul>
{more && (
<div className="flex justify-center">
<button type="button" onClick={loadOlder} className="text-xs px-3 py-1 rounded border bg-white hover:bg-gray-50">Load older</button>
</div>
)}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
/**
* "21–40 of 312 · ← Prev · Next →" under a server-paged list. Hidden when everything fits on
* one page.
*/
export function Pager({ page, pageSize, total, onPage, busy }: {
page: number;
pageSize: number;
total: number;
onPage: (page: number) => void;
busy?: boolean;
}) {
const pages = Math.max(1, Math.ceil(total / pageSize));
if (pages <= 1) return null;
const btn = "px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40";
return (
<nav aria-label="Pages" className="flex items-center justify-between mt-4 text-sm">
<span className="text-gray-500">
{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} · page {page} of {pages}
</span>
<div className="flex gap-1">
<button className={btn} disabled={page <= 1 || busy} onClick={() => onPage(page - 1)}>← Prev</button>
<button className={btn} disabled={page >= pages || busy} onClick={() => onPage(page + 1)}>Next →</button>
</div>
</nav>
);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.50.0",
"version": "1.50.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.50.0",
"version": "1.50.1",
"license": "ISC",
"devDependencies": {
"@axe-core/playwright": "^4.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.50.0",
"version": "1.50.1",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",