Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dacd49652b | ||
|
|
4c066e67d9 | ||
|
|
0ffbb13a1f |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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,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 },
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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." }}]},
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user