Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dea712b72 | ||
|
|
f1714f9286 | ||
|
|
c0d01ce993 | ||
|
|
0414854dec | ||
|
|
dd537a1a16 |
@@ -7,6 +7,70 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.48.1] - 2026-10-28
|
||||
|
||||
### Changed
|
||||
|
||||
- **Built-in privacy notice brought up to date** with volunteer rosters (what is kept, reminders,
|
||||
who sees the roster and cover requests), following an event series, season passes, webhooks
|
||||
(connected systems, what they receive, 30-day copies), how people agree to the notice, and the
|
||||
`cross_events_dismissed_notices` browser-storage item. Sites that haven't published their own
|
||||
version show the new text straight away.
|
||||
- **Sites that published their own version** are now told: Admin → Privacy → Privacy notice lists
|
||||
what changed in the built-in text since they last reviewed it, marks each section with new text
|
||||
(**show it** / **Use the new text**), offers **Use all the new text**, and **Add** for built-in
|
||||
sections the notice doesn't have. **Done — hide this** records the review
|
||||
(`privacy_notice_builtin_reviewed`). Their own text is never changed without them; publish (with
|
||||
"Significant change" if people should accept again) to make it live.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Agreeing to the notice when an account was made for you** — activating an account (made by
|
||||
staff, at the door or by whoever registered you) now asks you to agree to the Terms of Use and
|
||||
Privacy Policy, as sign-up does (consent log source "Account activation").
|
||||
- **Never accepted?** Anyone who has never accepted any version (accounts made for them, or from
|
||||
before acceptances were recorded) is asked once after login ("Our privacy notice"), even while
|
||||
the built-in notice is in use. Before, they were only asked after a version marked as a
|
||||
significant change was published.
|
||||
- The "Change my cookie choice" button on the privacy page also shows above a cookie section an
|
||||
admin wrote themselves (any section whose id mentions cookies), not only the built-in one.
|
||||
|
||||
## [1.48.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Outbound webhooks** — Site settings → **Webhooks**: send what happens here to other systems
|
||||
(ChurchSuite, Planning Center, Google Sheets through Zapier or Make…). Add an https address and
|
||||
choose what to send: `registration.created`, `registration.paid`, `registration.cancelled`,
|
||||
`payment.received`, `payment.refunded`, `ticket.checked_in`, `user.created`, `event.published`.
|
||||
- Each message is JSON `{ id, type, createdAt, data }` signed with the webhook's own secret
|
||||
(`X-CrossEvents-Signature: t=…,v1=…`, HMAC-SHA256 of `<t>.<body>`; shown once, "New secret" to
|
||||
rotate). **Send test**, a **delivery log** (30 days, with the full message and what came back) and
|
||||
**Resend**. Failures are retried after 1 min, 5 min, 30 min, 2 h and 12 h, then marked failed.
|
||||
- Events are noticed in one place (a Prisma client extension in `config/db.js`), so every way of
|
||||
registering, paying, cancelling, refunding or checking in is covered; each is checked again a
|
||||
moment later, so a change that was rolled back sends nothing.
|
||||
- Safety and privacy: https addresses on public networks only (checked again at each send;
|
||||
redirects aren't followed); each webhook is added to the POPIA operator register (marked inactive
|
||||
when deleted); kids check-in and volunteer tickets are never sent; deliveries are deleted after 30
|
||||
days and with an anonymised account.
|
||||
- New `webhooks` job (every minute, `WEBHOOKS_JOBS_ENABLED`); `WEBHOOKS_ENABLED=false` stops
|
||||
noticing events. Audit action `webhook_endpoint_changed` (migration `20261028160000_webhooks`).
|
||||
|
||||
## [1.47.4] - 2026-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Update requested, then a newer version came out** — the Licence page said the newer version
|
||||
was requested ("Version 1.47.3 is available. Update requested …") when the request was for an
|
||||
older one, and there was no way to ask for the newer version; the update banner stayed hidden.
|
||||
Now it shows the request ("You asked for version 1.47.0 on …") and, below it, that a newer
|
||||
version has come out with **Ask for 1.47.3 instead** (Withdraw request stays). The banner shows
|
||||
again and says to change the request. Changing it is audited as `hub_update_requested` with
|
||||
`changedFrom`.
|
||||
- With Cross Events Hub 1.5.0 the Hub moves the open request on to the new version and emails the
|
||||
provider once; with an older Hub the site withdraws the request and makes it again.
|
||||
|
||||
## [1.47.3] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
+26
-3
@@ -729,8 +729,12 @@ and published, numbered versions (earlier ones stay readable with `?version=N`).
|
||||
Placeholders (`{org}`, `{ioName}`, `{ioEmail}`, `{website}`, `{orgEmail}`,
|
||||
`{medicalRetentionDays}`, `{operators}`) are filled in from Site settings and the
|
||||
operator register. A version marked as a significant change is accepted once by everyone
|
||||
after their next login; sign-up records the version accepted. The contact page shows the
|
||||
Information Officer.
|
||||
after their next login. People agree to the notice at sign-up and when they activate an account
|
||||
made for them; anyone who never has is asked once after login. When the built-in text changes
|
||||
in an update, a site with its own published version sees what changed in the editor, with each
|
||||
changed section marked (**Use the new text**, **Use all the new text**, **Add** missing sections,
|
||||
**Done — hide this**). The "Change my cookie choice" button shows above the cookie section. The
|
||||
contact page shows the Information Officer.
|
||||
|
||||
**Registers and logs** (same admin page):
|
||||
|
||||
@@ -772,6 +776,23 @@ falling back to a general guide.
|
||||
|
||||
---
|
||||
|
||||
## Outbound webhooks
|
||||
|
||||
Site settings → **Webhooks** (admins): send what happens to other systems as it happens.
|
||||
|
||||
- **Endpoints**: name, https address, and the event types to send — registration created / paid /
|
||||
cancelled, payment received / refunded, ticket checked in, account created, event published.
|
||||
Switch off, edit, delete, **Send test**, **New secret**.
|
||||
- **Messages**: JSON `{ id, type, createdAt, data }` (registration with items and totals, payment,
|
||||
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.
|
||||
- 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
|
||||
is added to the operator register; deliveries are deleted after 30 days and with an account.
|
||||
|
||||
## Background jobs
|
||||
|
||||
Timed jobs run on a job queue in Postgres (pg-boss, `backend/src/jobs/index.js`): each
|
||||
@@ -792,6 +813,7 @@ can't start, the same jobs run on in-process timers.
|
||||
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
|
||||
| `volunteers` — volunteer reminders the day before a shift | Every 15 minutes (`VOLUNTEER_JOBS_ENABLED=false`) |
|
||||
| `series` — email a series' followers when a new event in it goes live | Every 15 minutes (`SERIES_JOBS_ENABLED=false`) |
|
||||
| `webhooks` — send webhook deliveries that are due, retry failures, delete deliveries after 30 days | Every minute (`WEBHOOKS_JOBS_ENABLED=false`) |
|
||||
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
|
||||
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_JOBS_ENABLED=false`) |
|
||||
| `retention` — delete/anonymise personal information past its retention period (Admin → Privacy → Retention) | 03:30 daily (`RETENTION_JOBS_ENABLED=false`) |
|
||||
@@ -814,7 +836,8 @@ can't start, the same jobs run on in-process timers.
|
||||
created or edited; no new registrations or payments; tickets still scan, data can be exported).
|
||||
The site checks in 30 seconds after starting and after a licence key is connected. Admins see when an update is available, with
|
||||
**what's new** in each version, and press **Request update**, which tells the provider through the Hub (the site never updates
|
||||
itself). A request can be **withdrawn**. **Not now** hides the update banner until the next version; urgent updates (security or
|
||||
itself). A request can be **withdrawn**; when a newer version comes out after asking, the page shows
|
||||
the version asked for and **Ask for … instead** moves the request on to the newer one. **Not now** hides the update banner until the next version; urgent updates (security or
|
||||
serious bug fixes) can't be put off and show a red banner.
|
||||
- **Notices from the provider** — maintenance and other messages the provider posts in the Hub show as a banner on the dashboard
|
||||
for site admins and supervisors, in their time window; each can be closed (per browser) and stays listed on Site settings →
|
||||
|
||||
@@ -117,6 +117,9 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, children at kids check-in (`kids_profiles`) 6, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers and kids profiles 1; maximum 240. Saving marks them reviewed (readiness check). |
|
||||
| `retention_last_run` | — | When the `retention` job last ran and what it did (readiness check: within 2 days). |
|
||||
| `VOLUNTEER_JOBS_ENABLED` (env) | `true` | The `volunteers` job (every 15 minutes) that sends volunteer reminders the day before a shift. |
|
||||
| `WEBHOOKS_JOBS_ENABLED` (env) | `true` | The `webhooks` job (every minute): sends deliveries that are due (retries), picks up sends cut off by a restart, deletes deliveries older than 30 days. New events are sent straight away without it. |
|
||||
| `WEBHOOKS_ENABLED` (env) | `true` | `false` stops noticing events for outbound webhooks altogether (nothing new is queued). |
|
||||
| `WEBHOOKS_ALLOW_PRIVATE` (env) | `false` | `true` lets webhook endpoints use http and private or local addresses — for local testing only (the e2e tests set it). |
|
||||
| `SERIES_JOBS_ENABLED` (env) | `true` | The `series` job (every 15 minutes) that emails a series' followers once when a new event in it goes live (News & announcements: only people opted in to news). |
|
||||
| `RETENTION_JOBS_ENABLED` (env) | `true` | The nightly 03:30 `retention` job. Off means nothing expires automatically. |
|
||||
| `wawp_webhook_last_seen` | — | Set when WAWP's webhook arrives with the right secret; the readiness page uses it to check WhatsApp STOP is set up. |
|
||||
|
||||
@@ -12,6 +12,30 @@ 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.48.1
|
||||
|
||||
- **Privacy notice brought up to date** with volunteer rosters, event series, season passes and
|
||||
webhooks. If you haven't published your own version, your site shows the new text straight away.
|
||||
- **Published your own version?** Admin → Privacy → Privacy notice now shows what changed in the
|
||||
built-in text, section by section, so you can take the new wording with one click and publish.
|
||||
- **Everyone agrees to the notice once.** People whose account was made for them agree when they
|
||||
activate it, and anyone who never has is asked once after they next log in.
|
||||
|
||||
## 1.48.0
|
||||
|
||||
- **Webhooks.** Connect Cross Events to other systems — ChurchSuite, Planning Center, a Google
|
||||
Sheet through Zapier or Make. Choose what to send (new registrations, payments, check-ins, new
|
||||
accounts, published events) in Site settings → Webhooks, and it goes out as it happens.
|
||||
- Every message is signed so the other system can trust it, failures are tried again
|
||||
automatically, and a delivery log shows what was sent.
|
||||
- Each webhook is added to your operator register for POPIA. Children's details are never sent.
|
||||
|
||||
## 1.47.4
|
||||
|
||||
- **Asked for an update, and a newer one came out?** The Licence page now shows which version you
|
||||
asked for, tells you when a newer one is out, and lets you ask for that one instead with one
|
||||
button.
|
||||
|
||||
## 1.47.3
|
||||
|
||||
- **Season passes.** Sell a ticket for several events in a series at one price — say all of next
|
||||
|
||||
@@ -78,6 +78,10 @@ PAYMENT_STATUS_JOB_ENABLED=true
|
||||
# Volunteer rosters: reminders the day before a shift (every 15 minutes)
|
||||
VOLUNTEER_JOBS_ENABLED=true
|
||||
SERIES_JOBS_ENABLED=true
|
||||
WEBHOOKS_JOBS_ENABLED=true
|
||||
WEBHOOKS_ENABLED=true
|
||||
# Local testing only: lets webhook endpoints be http:// or on a private network
|
||||
WEBHOOKS_ALLOW_PRIVATE=false
|
||||
# Privacy (POPIA): daily 08:00 reminder to admins of data requests due within a week
|
||||
PRIVACY_JOBS_ENABLED=true
|
||||
# Privacy (POPIA): nightly 03:30 retention job (Admin → Privacy → Retention). Note: inactive
|
||||
|
||||
@@ -949,6 +949,33 @@ Events take `seriesId` (null = none) on create and update; `GET /api/events?seri
|
||||
series; `GET /api/events/:id` includes `series` and `nextInSeries`. The `series` job emails
|
||||
followers once per event (`Event.seriesAnnouncedAt`) when it is public and hasn't started.
|
||||
|
||||
## Outbound webhooks
|
||||
|
||||
Admin only. Endpoints receive `POST` JSON `{ id: "evt_…", type, createdAt, data }` with headers
|
||||
`X-CrossEvents-Event`, `X-CrossEvents-Delivery` and `X-CrossEvents-Signature: t=<unix seconds>,v1=<hex>`
|
||||
where v1 = HMAC-SHA256(secret, `<t>.<raw body>`). Answer 2xx within 10 s; anything else is retried
|
||||
(1 min, 5 min, 30 min, 2 h, 12 h). The same `id` can arrive more than once — de-duplicate on it.
|
||||
|
||||
| Type | `data` |
|
||||
|------|--------|
|
||||
| `registration.created` / `.paid` / `.cancelled` | `registration { id, status, totalDue, amountPaid, items[], seasonPassPurchaseId }`, `event`, `person` |
|
||||
| `payment.received` / `payment.refunded` | `payment { id, amount, method, provider, isDonation, registrationId }`, `event`, `person` |
|
||||
| `ticket.checked_in` | `checkIn { id, scannedAt, people, manual, offline, session }`, `ticket`, `event`, `person` |
|
||||
| `user.created` | `person { id, name, email, phone, role, createdAt }` |
|
||||
| `event.published` | `event { id, title, description, startDate, endDate, location, price, goLiveAt, url, campus, series }` |
|
||||
| `webhook.test` | `{ message }` (Send test) |
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types |
|
||||
| POST | `/api/outbound-webhooks` | `{ name, url, events }` → endpoint plus `secret` (only here) |
|
||||
| PUT | `/api/outbound-webhooks/:id` | `{ name?, url?, events?, isActive? }` |
|
||||
| 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 |
|
||||
| POST | `/api/outbound-webhooks/deliveries/:id/resend` | Send again now |
|
||||
|
||||
## Season passes
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.47.3",
|
||||
"version": "1.48.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.47.3",
|
||||
"version": "1.48.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.47.3",
|
||||
"version": "1.48.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'webhook_endpoint_changed';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookEndpoint" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"secret" TEXT NOT NULL,
|
||||
"events" TEXT[],
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"operatorId" TEXT,
|
||||
"lastSuccessAt" TIMESTAMP(3),
|
||||
"lastFailureAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WebhookEndpoint_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebhookDelivery" (
|
||||
"id" TEXT NOT NULL,
|
||||
"endpointId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastStatusCode" INTEGER,
|
||||
"lastError" TEXT,
|
||||
"deliveredAt" TIMESTAMP(3),
|
||||
"userId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WebhookDelivery_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_status_nextAttemptAt_idx" ON "WebhookDelivery"("status", "nextAttemptAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx" ON "WebhookDelivery"("endpointId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_userId_idx" ON "WebhookDelivery"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_endpointId_fkey" FOREIGN KEY ("endpointId") REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -817,6 +817,7 @@ enum AdminAuditAction {
|
||||
series_updated
|
||||
series_deleted
|
||||
season_pass_changed
|
||||
webhook_endpoint_changed
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -2249,3 +2250,49 @@ model VolunteerSwap {
|
||||
@@index([roleId, status])
|
||||
@@index([slotId])
|
||||
}
|
||||
|
||||
/// Outbound webhooks (1.48.0): a URL that is sent what happens here (registrations, payments,
|
||||
/// check-ins, new accounts, published events), signed with its own secret.
|
||||
model WebhookEndpoint {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
url String
|
||||
// HMAC secret, encrypted (utils/encryption.js); shown to the admin once, when made or rotated.
|
||||
secret String
|
||||
// Event types it gets, e.g. ["registration.paid", "ticket.checked_in"].
|
||||
events String[]
|
||||
isActive Boolean @default(true)
|
||||
// The operator register entry made for it (POPIA), if any.
|
||||
operatorId String?
|
||||
lastSuccessAt DateTime?
|
||||
lastFailureAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deliveries WebhookDelivery[]
|
||||
}
|
||||
|
||||
/// One message to one endpoint, with its retries. Kept 30 days.
|
||||
model WebhookDelivery {
|
||||
id String @id @default(uuid())
|
||||
endpointId String
|
||||
// The event's own id (the same for every endpoint it went to) and type.
|
||||
eventId String
|
||||
type String
|
||||
payload Json
|
||||
// pending | sending | retrying | succeeded | failed
|
||||
status String @default("pending")
|
||||
attempts Int @default(0)
|
||||
nextAttemptAt DateTime @default(now())
|
||||
lastStatusCode Int?
|
||||
lastError String?
|
||||
deliveredAt DateTime?
|
||||
// Whose personal information is in it, so it can be removed with their account.
|
||||
userId String?
|
||||
createdAt DateTime @default(now())
|
||||
endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([status, nextAttemptAt])
|
||||
@@index([endpointId, createdAt])
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
|
||||
// Prisma 7 talks to Postgres through the node-postgres driver adapter instead of
|
||||
// the old Rust engine. pg has no connect timeout by default; 5s matches Prisma 5.
|
||||
function createPrismaClient() {
|
||||
function createBaseClient() {
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
connectionTimeoutMillis: 5000,
|
||||
@@ -11,6 +11,86 @@ function createPrismaClient() {
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound webhooks (utils/webhooks.js) notice what happens here, whichever code path does it:
|
||||
* registrations made, paid or cancelled, payments and refunds, check-ins, new accounts, events
|
||||
* becoming public. Noticing only queues; the record is read again before anything is sent, so a
|
||||
* transaction that rolls back sends nothing. A failure here never affects the write itself.
|
||||
*/
|
||||
function withWebhooks(base) {
|
||||
const notice = (type, id) => {
|
||||
try { require('../utils/webhooks').notice(type, id); } catch (e) { console.warn('[webhooks] notice:', e?.message || e); }
|
||||
};
|
||||
const isPublic = e => !!e && e.isActive !== false && !e.isHidden;
|
||||
// The status before an update (read outside the caller's transaction: the committed value).
|
||||
const before = (model, where, select) => (where?.id ? base[model].findUnique({ where: { id: where.id }, select }).catch(() => null) : Promise.resolve(null));
|
||||
|
||||
return base.$extends({
|
||||
query: {
|
||||
registration: {
|
||||
async create({ args, query }) {
|
||||
const r = await query(args);
|
||||
if (r?.id) {
|
||||
notice('registration.created', r.id);
|
||||
if (r.status === 'paid') notice('registration.paid', r.id);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
async update({ args, query }) {
|
||||
const status = args?.data?.status;
|
||||
if (status !== 'paid' && status !== 'cancelled') return query(args);
|
||||
const prev = await before('registration', args.where, { status: true });
|
||||
const r = await query(args);
|
||||
if (r?.id && prev && prev.status !== status) notice(status === 'paid' ? 'registration.paid' : 'registration.cancelled', r.id);
|
||||
return r;
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
async create({ args, query }) {
|
||||
const p = await query(args);
|
||||
// Applying an earlier donation to an order isn't new money (see cashupUtils.isDonationLeg).
|
||||
const leg = p && !p.isDonation && p.originalPaymentId && p.amount > 0;
|
||||
if (p?.id && !leg && p.amount !== 0) notice(p.amount < 0 ? 'payment.refunded' : 'payment.received', p.id);
|
||||
return p;
|
||||
},
|
||||
},
|
||||
ticketUsage: {
|
||||
async create({ args, query }) {
|
||||
const u = await query(args);
|
||||
if (u?.id) notice('ticket.checked_in', u.id);
|
||||
return u;
|
||||
},
|
||||
},
|
||||
user: {
|
||||
async create({ args, query }) {
|
||||
const u = await query(args);
|
||||
if (u?.id) notice('user.created', u.id);
|
||||
return u;
|
||||
},
|
||||
},
|
||||
event: {
|
||||
async create({ args, query }) {
|
||||
const e = await query(args);
|
||||
if (e?.id && isPublic(e)) notice('event.published', e.id);
|
||||
return e;
|
||||
},
|
||||
async update({ args, query }) {
|
||||
const touches = args?.data && ('isActive' in args.data || 'isHidden' in args.data);
|
||||
if (!touches) return query(args);
|
||||
const prev = await before('event', args.where, { isActive: true, isHidden: true });
|
||||
const e = await query(args);
|
||||
if (e?.id && prev && !isPublic(prev) && isPublic(e)) notice('event.published', e.id);
|
||||
return e;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createPrismaClient() {
|
||||
return withWebhooks(createBaseClient());
|
||||
}
|
||||
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
module.exports = prisma;
|
||||
|
||||
@@ -13,10 +13,13 @@ function compareVersions(a, b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** An update request still waiting: until this site runs the version that was asked for. */
|
||||
function pendingUpdateRequest(version, requestedAt, requestedVersion) {
|
||||
/**
|
||||
* An update request still waiting: until this site runs the version that was asked for.
|
||||
* `outdated`: a newer version than the one asked for is out, so the request can be changed to it.
|
||||
*/
|
||||
function pendingUpdateRequest(version, requestedAt, requestedVersion, latestVersion = null) {
|
||||
if (!requestedAt || !requestedVersion || compareVersions(version, requestedVersion) >= 0) return null;
|
||||
return { requestedAt, version: requestedVersion };
|
||||
return { requestedAt, version: requestedVersion, outdated: !!latestVersion && compareVersions(latestVersion, requestedVersion) > 0 };
|
||||
}
|
||||
|
||||
const str = (v, max) => String(v ?? '').slice(0, max);
|
||||
@@ -87,7 +90,7 @@ async function statusPayload({ fresh = false } = {}) {
|
||||
urgentUpdate,
|
||||
updateSnoozed: updateAvailable && updateSnoozed({ latestVersion: s.latestVersion, snoozedVersion: s.updateSnoozedVersion, urgent: urgentUpdate }),
|
||||
releases,
|
||||
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion),
|
||||
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion, s.latestVersion),
|
||||
notices: activeNotices(s.notices),
|
||||
};
|
||||
}
|
||||
@@ -139,8 +142,11 @@ const heartbeatNow = async (req, res) => {
|
||||
const requestUpdate = async (req, res) => {
|
||||
try {
|
||||
const result = await hub.requestUpdate();
|
||||
if (!result.alreadyRequested) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_requested', targetType: 'hub', metadata: { from: APP_VERSION, to: result.version }, ip: getClientIp(req) });
|
||||
if (!result.alreadyRequested || result.changed) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_requested', targetType: 'hub',
|
||||
metadata: { from: APP_VERSION, to: result.version, ...(result.changed ? { changedFrom: result.previousVersion || null } : {}) }, ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
const prisma = require('../config/db');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const webhooks = require('../utils/webhooks');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || 400).json({ message: error.message });
|
||||
|
||||
const audit = (req, endpoint, action, metadata = {}) => logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'webhook_endpoint_changed', targetType: 'WebhookEndpoint', targetId: endpoint.id,
|
||||
metadata: { action, name: endpoint.name, host: hostOf(endpoint.url), ...metadata }, ip: getClientIp(req),
|
||||
});
|
||||
|
||||
function hostOf(url) {
|
||||
try { return new URL(url).host; } catch { return null; }
|
||||
}
|
||||
|
||||
/** What goes to an endpoint, in words, for its operator register entry. */
|
||||
function dataShared(events) {
|
||||
const parts = [];
|
||||
if (events.some(e => e.startsWith('registration.') || e === 'ticket.checked_in' || e.startsWith('payment.') || e === 'user.created')) {
|
||||
parts.push('names, email addresses and phone numbers of people who register or have an account');
|
||||
}
|
||||
if (events.some(e => e.startsWith('registration.'))) parts.push('what they registered for and its status');
|
||||
if (events.some(e => e.startsWith('payment.'))) parts.push('payment and refund amounts');
|
||||
if (events.includes('ticket.checked_in')) parts.push('check-in times');
|
||||
if (events.includes('event.published')) parts.push('published event details');
|
||||
return parts.length ? `${parts.join('; ')}.` : 'Nothing yet (no event types chosen).';
|
||||
}
|
||||
|
||||
function cleanEvents(list) {
|
||||
const events = [...new Set((Array.isArray(list) ? list : []).map(String))].filter(t => webhooks.TYPES.has(t));
|
||||
if (!events.length) throw webhooks.webhookError('Choose at least one thing to send');
|
||||
return events;
|
||||
}
|
||||
|
||||
function cleanName(name) {
|
||||
const n = String(name || '').trim();
|
||||
if (!n) throw webhooks.webhookError('Give the webhook a name, e.g. the service it goes to');
|
||||
return n.slice(0, 100);
|
||||
}
|
||||
|
||||
const view = e => ({
|
||||
id: e.id, name: e.name, url: e.url, events: e.events, isActive: e.isActive, operatorId: e.operatorId,
|
||||
lastSuccessAt: e.lastSuccessAt, lastFailureAt: e.lastFailureAt, createdAt: e.createdAt,
|
||||
});
|
||||
|
||||
// @desc Endpoints, the event types, and the last week's delivery counts per endpoint
|
||||
// @route GET /api/outbound-webhooks
|
||||
// @access Private/Admin
|
||||
const list = async (req, res) => {
|
||||
try {
|
||||
const endpoints = await prisma.webhookEndpoint.findMany({ orderBy: { createdAt: 'asc' } });
|
||||
const since = new Date(Date.now() - 7 * 864e5);
|
||||
const counts = await prisma.webhookDelivery.groupBy({ by: ['endpointId', 'status'], where: { createdAt: { gte: since } }, _count: { _all: true } });
|
||||
res.json({
|
||||
eventTypes: webhooks.EVENT_TYPES,
|
||||
keepDays: webhooks.KEEP_DAYS,
|
||||
endpoints: endpoints.map(e => ({
|
||||
...view(e),
|
||||
lastWeek: Object.fromEntries(counts.filter(c => c.endpointId === e.id).map(c => [c.status, c._count._all])),
|
||||
})),
|
||||
});
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Add an endpoint: the secret is in this answer only. Also added to the operator register.
|
||||
// @route POST /api/outbound-webhooks { name, url, events }
|
||||
// @access Private/Admin
|
||||
const create = async (req, res) => {
|
||||
try {
|
||||
const name = cleanName(req.body?.name);
|
||||
const url = webhooks.cleanUrl(req.body?.url);
|
||||
const events = cleanEvents(req.body?.events);
|
||||
const secret = webhooks.newSecret();
|
||||
const u = new URL(url);
|
||||
// POPIA: whoever receives this is an operator of the church's personal information.
|
||||
const operator = await prisma.operator.create({
|
||||
data: { name: `${name} (webhook)`, purpose: 'Receives notifications from this site through a webhook', dataShared: dataShared(events), website: u.origin },
|
||||
}).catch(() => null);
|
||||
const endpoint = await prisma.webhookEndpoint.create({ data: { name, url, events, secret: webhooks.encrypt(secret), operatorId: operator?.id || null } });
|
||||
webhooks.forgetEndpoints();
|
||||
audit(req, endpoint, 'created', { events });
|
||||
res.status(201).json({ ...view(endpoint), secret });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @route PUT /api/outbound-webhooks/:id { name?, url?, events?, isActive? }
|
||||
// @access Private/Admin
|
||||
const update = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
|
||||
if (!current) return res.status(404).json({ message: 'Webhook not found' });
|
||||
const data = {};
|
||||
if (req.body?.name !== undefined) data.name = cleanName(req.body.name);
|
||||
if (req.body?.url !== undefined) data.url = webhooks.cleanUrl(req.body.url);
|
||||
if (req.body?.events !== undefined) data.events = cleanEvents(req.body.events);
|
||||
if (req.body?.isActive !== undefined) data.isActive = req.body.isActive !== false;
|
||||
const endpoint = await prisma.webhookEndpoint.update({ where: { id: current.id }, data });
|
||||
if (current.operatorId && (data.events || data.url || data.name || data.isActive !== undefined)) {
|
||||
await prisma.operator.update({
|
||||
where: { id: current.operatorId },
|
||||
data: { name: `${endpoint.name} (webhook)`, dataShared: dataShared(endpoint.events), website: new URL(endpoint.url).origin, active: endpoint.isActive },
|
||||
}).catch(() => {});
|
||||
}
|
||||
webhooks.forgetEndpoints();
|
||||
audit(req, endpoint, 'updated', { changedKeys: Object.keys(data) });
|
||||
res.json(view(endpoint));
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Delete an endpoint and its delivery log; its operator entry is marked inactive
|
||||
// @route DELETE /api/outbound-webhooks/:id
|
||||
// @access Private/Admin
|
||||
const remove = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
|
||||
if (!current) return res.status(404).json({ message: 'Webhook not found' });
|
||||
await prisma.webhookEndpoint.delete({ where: { id: current.id } });
|
||||
if (current.operatorId) await prisma.operator.update({ where: { id: current.operatorId }, data: { active: false } }).catch(() => {});
|
||||
webhooks.forgetEndpoints();
|
||||
audit(req, current, 'deleted');
|
||||
res.json({ ok: true });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc A new secret (the old one stops working at once); in this answer only
|
||||
// @route POST /api/outbound-webhooks/:id/rotate-secret
|
||||
// @access Private/Admin
|
||||
const rotateSecret = async (req, res) => {
|
||||
try {
|
||||
const current = await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id } });
|
||||
if (!current) return res.status(404).json({ message: 'Webhook not found' });
|
||||
const secret = webhooks.newSecret();
|
||||
await prisma.webhookEndpoint.update({ where: { id: current.id }, data: { secret: webhooks.encrypt(secret) } });
|
||||
audit(req, current, 'secret_rotated');
|
||||
res.json({ secret });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Send a "webhook.test" now and say what came back
|
||||
// @route POST /api/outbound-webhooks/:id/test
|
||||
// @access Private/Admin
|
||||
const test = async (req, res) => {
|
||||
try {
|
||||
if (!(await prisma.webhookEndpoint.findUnique({ where: { id: req.params.id }, select: { id: true } }))) return res.status(404).json({ message: 'Webhook not found' });
|
||||
const d = await webhooks.sendTest(req.params.id);
|
||||
res.json({ status: d?.status, statusCode: d?.lastStatusCode ?? null, error: d?.lastError || null });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc An endpoint's deliveries, newest first (?status=failed to filter)
|
||||
// @route GET /api/outbound-webhooks/:id/deliveries
|
||||
// @access Private/Admin
|
||||
const deliveries = async (req, res) => {
|
||||
try {
|
||||
const where = { endpointId: req.params.id, ...(req.query.status ? { status: String(req.query.status) } : {}) };
|
||||
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 },
|
||||
});
|
||||
res.json(rows);
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
// @desc Send a delivery again now
|
||||
// @route POST /api/outbound-webhooks/deliveries/:id/resend
|
||||
// @access Private/Admin
|
||||
const resend = async (req, res) => {
|
||||
try {
|
||||
const d = await webhooks.resend(req.params.id);
|
||||
res.json({ status: d?.status, statusCode: d?.lastStatusCode ?? null, error: d?.lastError || null });
|
||||
} catch (error) { fail(res, error); }
|
||||
};
|
||||
|
||||
module.exports = { list, create, update, remove, rotateSecret, test, deliveries, resend, dataShared };
|
||||
@@ -65,12 +65,30 @@ const getNoticeDraft = async (req, res) => {
|
||||
placeholders: notice.PLACEHOLDERS,
|
||||
versions: await versionList(),
|
||||
builtIn: notice.defaultSections(),
|
||||
// Changes to the built-in text since this site last reviewed them (only once it publishes its own).
|
||||
builtInUpdates: await notice.builtInUpdates(),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The built-in text changes have been gone through (the editor stops listing them)
|
||||
// @route POST /api/privacy/notice/builtin-reviewed
|
||||
// @access Private/Admin
|
||||
const markBuiltInReviewed = async (req, res) => {
|
||||
try {
|
||||
await notice.markBuiltInReviewed();
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'settings_updated', targetType: 'privacy_notice',
|
||||
metadata: { builtInReviewed: notice.BUILT_IN_REVISION }, ip: getClientIp(req),
|
||||
});
|
||||
res.json({ builtInUpdates: [] });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save the draft (body.sections)
|
||||
// @route PUT /api/privacy/notice/draft
|
||||
// @access Private/Admin
|
||||
@@ -425,6 +443,7 @@ const updateBreach = async (req, res) => {
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
markBuiltInReviewed,
|
||||
getReadiness,
|
||||
getRetention,
|
||||
saveRetention,
|
||||
|
||||
@@ -1114,6 +1114,13 @@ const activateAccount = async (req, res) => {
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true }
|
||||
});
|
||||
|
||||
// The activation form asks them to agree to the terms and privacy notice, as sign-up does.
|
||||
// Without it (an older page) they're asked once after login instead.
|
||||
if (req.body.acceptTerms === true) {
|
||||
await require('../utils/privacyNotice').acceptNotice(user.id, { source: 'activation', ip: getClientIp(req) })
|
||||
.catch(e => console.warn('[activate] notice acceptance not saved:', e?.message || e));
|
||||
}
|
||||
|
||||
// Send welcome email (skip guest/placeholder accounts)
|
||||
if (updated.email && !updated.email.endsWith('@guest.local')) {
|
||||
sendWelcomeEmail(updated).catch(() => {});
|
||||
|
||||
@@ -145,17 +145,36 @@ async function heartbeat() {
|
||||
}
|
||||
}
|
||||
|
||||
/** a < b as x.y.z versions. */
|
||||
function olderThan(a, b) {
|
||||
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0);
|
||||
const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
|
||||
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i];
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Hub for this site to be updated (§3a). Joshua is told; the site never updates itself.
|
||||
* Returns { requestedAt, version, alreadyRequested }.
|
||||
* Asking again after a newer version came out moves the request on to it (`changed`). A Hub before
|
||||
* 1.5.0 can't do that (it doesn't send `changed`), so the old request is withdrawn and made again.
|
||||
* Returns { requestedAt, version, alreadyRequested, changed, previousVersion }.
|
||||
*/
|
||||
async function requestUpdate() {
|
||||
let data;
|
||||
try {
|
||||
data = await signedPost('/api/installs/update-request', { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION });
|
||||
} catch (e) {
|
||||
if (e.code === 'NOT_FOUND') throw new HubError('The Hub doesn\'t take update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
|
||||
throw e;
|
||||
const ask = async () => {
|
||||
try {
|
||||
return await signedPost('/api/installs/update-request', { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION });
|
||||
} catch (e) {
|
||||
if (e.code === 'NOT_FOUND') throw new HubError('The Hub doesn\'t take update requests yet. Please contact your provider.', 'NOT_SUPPORTED', 502);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
let data = await ask();
|
||||
if (data.alreadyRequested && data.changed === undefined) {
|
||||
const asked = (await read(['hub_update_requested_version'])).hub_update_requested_version || null;
|
||||
if (!asked || olderThan(asked, data.version)) {
|
||||
await cancelUpdateRequest();
|
||||
data = { ...(await ask()), changed: true, previousVersion: asked };
|
||||
}
|
||||
}
|
||||
await save({ hub_update_requested_at: data.requestedAt, hub_update_requested_version: data.version || null });
|
||||
return data;
|
||||
|
||||
@@ -187,6 +187,7 @@ app.use('/api/kids', require('./routes/kidsRoutes'));
|
||||
app.use('/api/volunteers', require('./routes/volunteerRoutes'));
|
||||
app.use('/api/series', require('./routes/seriesRoutes'));
|
||||
app.use('/api/season-passes', require('./routes/seasonPassRoutes'));
|
||||
app.use('/api/outbound-webhooks', require('./routes/outboundWebhookRoutes'));
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/registrations', registrationRoutes);
|
||||
@@ -1011,6 +1012,18 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/series/:id/compare', auth:'supervisor+', desc:'Year on year: registrations, tickets, income (as on the dashboard), checked in (average per session on multi-session events) and attendance rate per event in the series, oldest first, with the % change from the one before. Campus-scoped staff: their campuses\x27 events only' },
|
||||
]},
|
||||
|
||||
{ title: 'Outbound webhooks', base: '/api/outbound-webhooks', endpoints: [
|
||||
{ method:'GET', path:'/api/outbound-webhooks', auth:'admin', desc:'Endpoints (with last-week delivery counts per status) and the event types: registration.created, registration.paid, registration.cancelled, payment.received, payment.refunded, ticket.checked_in, user.created, event.published' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks', auth:'admin', desc:'Add an endpoint (https, public address). The answer holds the signing secret — shown only now. Also added to the POPIA operator register. Audited as webhook_endpoint_changed',
|
||||
request:{ body:{ name:'ChurchSuite sync', url:'https://example.com/hooks/cross-events', events:['registration.paid','ticket.checked_in'] } } },
|
||||
{ method:'PUT', path:'/api/outbound-webhooks/:id', auth:'admin', desc:'Change name, url, events or isActive' },
|
||||
{ method:'DELETE', path:'/api/outbound-webhooks/:id', auth:'admin', desc:'Delete an endpoint and its deliveries (its operator entry is marked inactive)' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks/:id/rotate-secret', auth:'admin', desc:'A new signing secret (the old one stops at once)' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks/:id/test', auth:'admin', desc:'Send a webhook.test now; answers { status, statusCode, error }' },
|
||||
{ method:'GET', path:'/api/outbound-webhooks/:id/deliveries?status=&limit=', auth:'admin', desc:'Delivery log, newest first, with payloads (kept 30 days)' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks/deliveries/:id/resend', auth:'admin', desc:'Send a delivery again now' },
|
||||
]},
|
||||
|
||||
{ title: 'Season passes', base: '/api/season-passes', endpoints: [
|
||||
{ method:'GET', path:'/api/season-passes?seriesId=', auth:'supervisor+', desc:'A series\x27 passes: events, normal prices, each event\x27s share, sold and unpaid counts' },
|
||||
{ method:'POST', path:'/api/season-passes', auth:'supervisor+', desc:'Create a pass. Events must be in the series; not youth (consent) events, staff-only or per-session tickets. Campus-scoped supervisors: only their campuses\x27 events. Audited as season_pass_changed',
|
||||
|
||||
@@ -49,6 +49,10 @@ const JOBS = [
|
||||
name: 'volunteers', label: 'Volunteer reminders the day before a shift', cron: '*/15 * * * *', env: 'VOLUNTEER_JOBS_ENABLED',
|
||||
run: () => require('../utils/volunteers').runReminderJob(),
|
||||
},
|
||||
{
|
||||
name: 'webhooks', label: 'Outbound webhooks: send what is due, retry failures, delete deliveries after 30 days', cron: '* * * * *', env: 'WEBHOOKS_JOBS_ENABLED',
|
||||
run: () => require('../utils/webhooks').runJob(),
|
||||
},
|
||||
{
|
||||
name: 'series', label: 'Event series: tell followers when a new event goes live', cron: '*/15 * * * *', env: 'SERIES_JOBS_ENABLED',
|
||||
run: () => require('../utils/series').runAnnounceJob(),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { list, create, update, remove, rotateSecret, test, deliveries, resend } = require('../controllers/outboundWebhookController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Outbound webhooks (1.48.0): admins only. Payment provider webhooks *into* the site are /api/webhooks/<provider>.
|
||||
router.use(protect, admin);
|
||||
router.get('/', list);
|
||||
router.post('/', create);
|
||||
router.post('/deliveries/:id/resend', resend);
|
||||
router.put('/:id', update);
|
||||
router.delete('/:id', remove);
|
||||
router.post('/:id/rotate-secret', rotateSecret);
|
||||
router.post('/:id/test', test);
|
||||
router.get('/:id/deliveries', deliveries);
|
||||
|
||||
module.exports = router;
|
||||
@@ -21,6 +21,7 @@ const {
|
||||
saveNoticeDraft,
|
||||
discardNoticeDraft,
|
||||
publishNotice,
|
||||
markBuiltInReviewed,
|
||||
listOperators,
|
||||
createOperator,
|
||||
updateOperator,
|
||||
@@ -64,6 +65,7 @@ router.get('/notice/draft', protect, admin, getNoticeDraft);
|
||||
router.put('/notice/draft', protect, admin, saveNoticeDraft);
|
||||
router.delete('/notice/draft', protect, admin, discardNoticeDraft);
|
||||
router.post('/notice/publish', protect, admin, publishNotice);
|
||||
router.post('/notice/builtin-reviewed', protect, admin, markBuiltInReviewed);
|
||||
|
||||
// Admin: operator register, consent log, access log
|
||||
router.get('/operators', protect, admin, listOperators);
|
||||
|
||||
@@ -465,6 +465,8 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
|
||||
// Event series they followed.
|
||||
await tx.seriesFollow.deleteMany({ where: { userId } });
|
||||
// Webhook deliveries about them (kept 30 days for the delivery log).
|
||||
await tx.webhookDelivery.deleteMany({ where: { userId } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
|
||||
});
|
||||
|
||||
@@ -24,9 +24,9 @@ const DEFAULT_SECTIONS = [
|
||||
title: 'Plain English Summary',
|
||||
body: [
|
||||
"We respect your privacy — here's what you need to know:",
|
||||
'• We only collect info needed to run events: registrations, payments, tickets, check-in and, for children\'s ministry, kids check-in.',
|
||||
'• We only collect info needed to run events: registrations, payments, tickets, check-in, volunteer rosters and, for children\'s ministry, kids check-in.',
|
||||
"• Your information is kept in this site's own database, run by the hosting provider listed in section 9. It isn't shared with other organisations that use the same software.",
|
||||
"• We don't sell your data, and only share it with the services listed in section 9, which process payments and deliver messages for us.",
|
||||
"• We don't sell your data, and only share it with the services listed in section 9 — they process payments and deliver messages for us, and any other system we connect (such as church-management software) gets only the updates we chose.",
|
||||
'• If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.',
|
||||
'• For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.',
|
||||
"• For kids check-in, parents add their children's details once. Allergies, medical and custody notes are encrypted, shown only to kids-ministry staff, and deleted when a child hasn't been checked in for a while (section 7). Children are collected with a pickup code.",
|
||||
@@ -53,6 +53,8 @@ const DEFAULT_SECTIONS = [
|
||||
'• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.',
|
||||
'• Event information: events you register for, ticket selections (including the sessions you chose on multi-session events), discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned, per session), room or group allocations, and completion certificates issued to you.',
|
||||
'• Your home campus, if the organisation has more than one campus and you choose one.',
|
||||
'• Serving (if you volunteer): the roles and shifts you sign up for or are given, requests to pass a shift on and who took it, when you checked in, and the hours you served.',
|
||||
'• Event series you follow, and season passes you buy (a season pass registers you for each event on it, so each shows in your registrations).',
|
||||
"• Kids check-in (if you use it): your family's name, your children's names, dates of birth, gender, allergies, medical notes and custody notes, the adults you allow to collect them (name, phone number, relationship), and each check-in — when, which room, the pickup code, who checked them in and out, and who collected them (see section 8).",
|
||||
'• Details about other people you register (see section 3), parental consent details for youth events, and children at kids check-in (see section 8).',
|
||||
'• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.',
|
||||
@@ -90,6 +92,8 @@ const DEFAULT_SECTIONS = [
|
||||
'• Send optional reminders before sessions, and issue completion certificates (each has a code anyone can use to check it is genuine: the check shows the name on the certificate and the event).',
|
||||
'• Check children in and out at kids check-in, send you their pickup code, and ask you to come to the room if a leader needs you.',
|
||||
'• Show you the events of your home campus first, if the organisation has more than one campus.',
|
||||
'• Run volunteer rosters: remind you the day before you serve, pass your shift on to someone who accepts it when you can\'t make it, and check you in when you arrive.',
|
||||
'• Tell you when a new event in a series you follow comes out. This counts as news, so it\'s only sent if you get news and announcements.',
|
||||
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
|
||||
'• Send you an optional feedback form after an event, with one reminder.',
|
||||
'• Manage user accounts and event attendance records, and give support when you ask for help.',
|
||||
@@ -106,7 +110,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• Payment receipts.',
|
||||
'• Your event tickets (as a PDF document).',
|
||||
'• Security alerts such as new login notifications, password changes, and account closure confirmations, and sign-in codes if you chose WhatsApp for two-factor authentication.',
|
||||
'• Session reminders, if the event sends them.',
|
||||
'• Session reminders, if the event sends them, and volunteer reminders the day before you serve.',
|
||||
'• Kids check-in: your children\'s pickup code, and a request to come to the room if a leader needs you. These never include medical or custody details. If you ask the desk to, the pickup code is also sent to one of your authorised adults.',
|
||||
'• News and announcements, only if you asked for them on WhatsApp. Reply STOP to any of these to stop them.',
|
||||
'WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages.',
|
||||
@@ -133,6 +137,7 @@ const DEFAULT_SECTIONS = [
|
||||
'We keep personal information only as long as we need it:',
|
||||
'{retention}',
|
||||
"• Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
|
||||
'• Copies of the updates sent to connected systems (webhooks, section 9) are kept for 30 days so problems can be checked, then deleted — and straight away when your account is deleted.',
|
||||
"• Family lists downloaded to a kids check-in desk for offline use stay on that device until the desk is synced and the list is refreshed or removed. Only kids-ministry staff can download them, and each download is recorded.",
|
||||
'You can ask for your account to be deleted at any time from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
|
||||
].join('\n'),
|
||||
@@ -161,10 +166,12 @@ const DEFAULT_SECTIONS = [
|
||||
'We do not sell or rent your personal information. Outside our organisation, it is shared only with these service providers ("operators"), each only for its purpose:',
|
||||
'{operators}',
|
||||
'• Anyone we are required to share it with under South African law.',
|
||||
'If we connect another system — for example our church-management software — through a webhook, it is listed above with what it receives. It gets only the updates we chose (such as new registrations, payments and check-ins), and never children\'s details from kids check-in.',
|
||||
'Within our organisation, people see only what they need:',
|
||||
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
|
||||
'• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details from parental consent forms are shown to supervisors only.',
|
||||
"• Kids-ministry staff see the children checked in, with their allergies, medical and custody notes, the parents' names and phone numbers, and the adults allowed to collect them.",
|
||||
'• Staff running a volunteer roster see who is serving in each role, their contact details, check-ins and hours. If you ask for someone to take over your shift, your first name, the role and any note you add are shown to the event\x27s other volunteers and others who could take it over.',
|
||||
'• If the organisation has several campuses, a staff member may be limited to their own campuses\' events.',
|
||||
'• Supervisors and administrators manage registrations, payments and communication for events. Every export of personal information (reports, attendance registers, lists) is recorded.',
|
||||
'• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.',
|
||||
@@ -205,6 +212,7 @@ const DEFAULT_SECTIONS = [
|
||||
'• hope_events_scan_device, hope_events_offline_lock and the "hope-events-offline-scan" database — on a staff member\'s scanner in offline mode: the device\'s id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.',
|
||||
'• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.',
|
||||
"• hope_events_kids_event and hope_events_kids_room — on a kids check-in device: the event and room last chosen.",
|
||||
'• cross_events_dismissed_notices — notices from the software provider (such as planned maintenance) that you have closed.',
|
||||
"• hope_events_kids_device and hope_events_kids_offline_… — on a kids check-in desk in offline mode: the device's id, the list of families and children (with allergies, medical and custody notes), pickup codes kept for the device, and check-ins waiting to sync.",
|
||||
'Optional — only if you click "Allow" on the cookie notice:',
|
||||
'• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose "No thanks", nothing is counted and no visit id is created.',
|
||||
@@ -230,6 +238,7 @@ const DEFAULT_SECTIONS = [
|
||||
title: '13. Changes to This Notice',
|
||||
body: [
|
||||
'We may update this notice from time to time. Each update is published as a new version with its date, and earlier versions stay available on this page.',
|
||||
'You are asked to agree to this notice when you create an account, or when you activate an account that was made for you (for example when someone registered you, or at the door). If you have never agreed to it — such as an account from before we asked — we ask once, the next time you log in.',
|
||||
'If a change is significant, we ask you to accept the new version the next time you log in.',
|
||||
].join('\n'),
|
||||
},
|
||||
@@ -253,6 +262,49 @@ const DEFAULT_SECTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Changes to the built-in text, newest last. A site that published its own version keeps its own
|
||||
* text, so the editor lists the changes made since the revision it last reviewed
|
||||
* (`privacy_notice_builtin_reviewed`) and offers the new text section by section.
|
||||
*/
|
||||
const BUILT_IN_UPDATES = [
|
||||
{
|
||||
revision: '1.48.1',
|
||||
summary: [
|
||||
'Volunteer rosters: what is kept about people who serve, reminders, and who sees the roster.',
|
||||
'Following an event series, and season passes.',
|
||||
'Webhooks: other systems the organisation connects, what they receive, and that copies are kept 30 days.',
|
||||
'How people agree to the notice: at sign-up, at account activation, or once after login if they never have.',
|
||||
'A browser-storage item for closed provider notices.',
|
||||
],
|
||||
sections: ['summary', 'information', 'use', 'whatsapp', 'retention', 'sharing', 'cookies', 'changes'],
|
||||
},
|
||||
];
|
||||
const BUILT_IN_REVISION = BUILT_IN_UPDATES[BUILT_IN_UPDATES.length - 1].revision;
|
||||
const REVIEWED_KEY = 'privacy_notice_builtin_reviewed';
|
||||
|
||||
function revisionLess(a, b) {
|
||||
const pa = String(a || '0').split('.').map(Number);
|
||||
const pb = String(b || '0').split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in text changes this site hasn't reviewed yet. None while nothing is published (the site
|
||||
* shows the built-in text, so it is always current).
|
||||
*/
|
||||
async function builtInUpdates(db = prisma) {
|
||||
if (!(await latestNotice(db))) return [];
|
||||
const row = await db.appSetting.findUnique({ where: { key: REVIEWED_KEY } });
|
||||
return BUILT_IN_UPDATES.filter(u => !row?.value || revisionLess(row.value, u.revision));
|
||||
}
|
||||
|
||||
/** The admin has gone through the built-in changes (taken what they want, or not). */
|
||||
async function markBuiltInReviewed(db = prisma) {
|
||||
await db.appSetting.upsert({ where: { key: REVIEWED_KEY }, update: { value: BUILT_IN_REVISION }, create: { key: REVIEWED_KEY, value: BUILT_IN_REVISION } });
|
||||
}
|
||||
|
||||
function defaultSections() {
|
||||
return DEFAULT_SECTIONS.map(s => ({ ...s }));
|
||||
}
|
||||
@@ -340,11 +392,17 @@ async function requiredVersion(db = prisma) {
|
||||
return row ? row.version : null;
|
||||
}
|
||||
|
||||
/** Whether someone must (re-)accept the notice before carrying on. */
|
||||
/**
|
||||
* Whether someone must accept the notice before carrying on, and why: 'first' — they never
|
||||
* accepted any version (an account made for them by staff, at the door or by someone who
|
||||
* registered them, or from before acceptances were recorded); 'updated' — a version marked as a
|
||||
* significant change came out after the one they accepted. false when nothing is needed.
|
||||
*/
|
||||
async function noticePromptPending(user, db = prisma) {
|
||||
if (user.noticeVersionAccepted == null) return 'first';
|
||||
const required = await requiredVersion(db);
|
||||
if (required == null) return false;
|
||||
return (user.noticeVersionAccepted ?? -1) < required;
|
||||
return user.noticeVersionAccepted < required ? 'updated' : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -447,6 +505,11 @@ async function publish({ sections, changeSummary, requiresAcceptance, publishedB
|
||||
|
||||
module.exports = {
|
||||
PLACEHOLDERS,
|
||||
BUILT_IN_UPDATES,
|
||||
BUILT_IN_REVISION,
|
||||
builtInUpdates,
|
||||
markBuiltInReviewed,
|
||||
revisionLess,
|
||||
defaultSections,
|
||||
cleanSections,
|
||||
noticeContext,
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Outbound webhooks (1.48.0): admins add endpoints (Site settings → Webhooks) and choose event
|
||||
* types; what happens here is POSTed to them as JSON, signed with the endpoint's secret.
|
||||
*
|
||||
* - Events are noticed in one place, a Prisma client extension (config/db.js), so every path that
|
||||
* creates a registration, marks it paid, records a payment, checks a ticket in… is covered.
|
||||
* - `notice` only queues (in memory); about a second later `flush` reads the record again — a
|
||||
* transaction that rolled back, or a status that changed back, sends nothing — builds the
|
||||
* payload and stores one WebhookDelivery per endpoint that wants the type, then sends them.
|
||||
* - Sending: `X-CrossEvents-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">`
|
||||
* (like Stripe), 10 s timeout, redirects not followed, 2xx = delivered. Failures are retried
|
||||
* after 1 min, 5 min, 30 min, 2 h and 12 h, then marked failed (Resend on the delivery log).
|
||||
* - Only https URLs on public addresses (no SSRF into the server's own network), unless
|
||||
* WEBHOOKS_ALLOW_PRIVATE=true (local testing).
|
||||
* - Never sent: kids check-in and volunteer tickets (children's data stays here).
|
||||
* - Deliveries hold personal information, so they're kept 30 days and removed with an account.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const dns = require('dns').promises;
|
||||
const net = require('net');
|
||||
const prisma = require('../config/db');
|
||||
const { encrypt, decrypt } = require('./encryption');
|
||||
|
||||
const EVENT_TYPES = [
|
||||
{ type: 'registration.created', label: 'Registration made', description: 'Someone registers (online, at the door, by staff or with a season pass).' },
|
||||
{ type: 'registration.paid', label: 'Registration paid', description: 'A registration is paid in full — or made free.' },
|
||||
{ type: 'registration.cancelled', label: 'Registration cancelled', description: 'A registration is cancelled.' },
|
||||
{ type: 'payment.received', label: 'Payment received', description: 'Any payment or donation is recorded (online or by staff).' },
|
||||
{ type: 'payment.refunded', label: 'Payment refunded', description: 'A refund is recorded.' },
|
||||
{ type: 'ticket.checked_in', label: 'Ticket checked in', description: 'A ticket is scanned or ticked present.' },
|
||||
{ type: 'user.created', label: 'Account created', description: 'A new account is made.' },
|
||||
{ type: 'event.published', label: 'Event published', description: 'An event becomes public (made active and not hidden).' },
|
||||
];
|
||||
const TYPES = new Set(EVENT_TYPES.map(t => t.type));
|
||||
|
||||
const RETRY_AFTER_MS = [60e3, 5 * 60e3, 30 * 60e3, 2 * 3600e3, 12 * 3600e3];
|
||||
const MAX_ATTEMPTS = RETRY_AFTER_MS.length + 1;
|
||||
const KEEP_DAYS = 30;
|
||||
const TIMEOUT_MS = 10000;
|
||||
|
||||
function webhookError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
const allowPrivate = () => String(process.env.WEBHOOKS_ALLOW_PRIVATE || '').toLowerCase() === 'true';
|
||||
|
||||
// ─── Endpoint addresses ──────────────────────────────────────────────────────
|
||||
|
||||
/** Whether an IP address is private, loopback, link-local or otherwise not on the internet. */
|
||||
function isPrivateAddress(ip) {
|
||||
if (net.isIPv4(ip)) {
|
||||
const [a, b] = ip.split('.').map(Number);
|
||||
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
|
||||
}
|
||||
if (net.isIPv6(ip)) {
|
||||
const v = ip.toLowerCase();
|
||||
if (v === '::1' || v === '::') return true;
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v);
|
||||
if (mapped) return isPrivateAddress(mapped[1]);
|
||||
return /^f[cd]/.test(v) || /^fe[89ab]/.test(v);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A cleaned endpoint URL, or an error: https (http only for local testing), no user:password. */
|
||||
function cleanUrl(raw) {
|
||||
let u;
|
||||
try { u = new URL(String(raw || '').trim()); } catch { throw webhookError('Enter the full address, starting with https://'); }
|
||||
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && allowPrivate())) throw webhookError('The address must start with https://');
|
||||
if (u.username || u.password) throw webhookError('Leave the user name and password out of the address');
|
||||
if (!allowPrivate() && (u.hostname === 'localhost' || (net.isIP(u.hostname) && isPrivateAddress(u.hostname)))) {
|
||||
throw webhookError('That address is on a private network — webhooks only go to public addresses');
|
||||
}
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
/** Refuses a host that resolves to a private address (checked at every send: DNS can change). */
|
||||
async function assertPublicHost(hostname) {
|
||||
if (allowPrivate()) return;
|
||||
const addresses = net.isIP(hostname) ? [{ address: hostname }] : await dns.lookup(hostname, { all: true });
|
||||
if (!addresses.length || addresses.some(a => isPrivateAddress(a.address))) {
|
||||
throw webhookError(`${hostname} points to a private network address`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Signing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const newSecret = () => `whsec_${crypto.randomBytes(24).toString('base64url')}`;
|
||||
|
||||
/** The signature header for a body: t=<seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">. */
|
||||
function signatureHeader(secret, body, at = Date.now()) {
|
||||
const t = Math.floor(at / 1000);
|
||||
const v1 = crypto.createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
|
||||
return `t=${t},v1=${v1}`;
|
||||
}
|
||||
|
||||
// ─── Endpoints (cached for `notice`) ─────────────────────────────────────────
|
||||
|
||||
let endpointCache = null;
|
||||
let endpointCacheAt = 0;
|
||||
async function activeEndpoints() {
|
||||
if (!endpointCache || Date.now() - endpointCacheAt > 60e3) {
|
||||
endpointCache = await prisma.webhookEndpoint.findMany({ where: { isActive: true }, select: { id: true, events: true } });
|
||||
endpointCacheAt = Date.now();
|
||||
}
|
||||
return endpointCache;
|
||||
}
|
||||
function forgetEndpoints() { endpointCache = null; }
|
||||
|
||||
// ─── Noticing events ─────────────────────────────────────────────────────────
|
||||
|
||||
const queue = [];
|
||||
let flushTimer = null;
|
||||
const FLUSH_DELAY_MS = 1500;
|
||||
|
||||
/**
|
||||
* Something happened (from the Prisma extension). Queued and checked again shortly; never throws.
|
||||
* `ref` is the record's id.
|
||||
*/
|
||||
function notice(type, ref) {
|
||||
if (!TYPES.has(type) || !ref) return;
|
||||
if (String(process.env.WEBHOOKS_ENABLED || 'true').toLowerCase() === 'false') return;
|
||||
queue.push({ type, ref, at: new Date() });
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(() => { flushTimer = null; flush().catch(e => console.warn('[webhooks] flush:', e?.message || e)); }, FLUSH_DELAY_MS);
|
||||
flushTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds and stores the deliveries for what was noticed, then sends them. */
|
||||
async function flush() {
|
||||
const items = queue.splice(0, queue.length);
|
||||
if (!items.length) return [];
|
||||
const endpoints = await activeEndpoints();
|
||||
const made = [];
|
||||
for (const item of items) {
|
||||
const targets = endpoints.filter(e => e.events.includes(item.type));
|
||||
if (!targets.length) continue;
|
||||
let built;
|
||||
try { built = await buildPayload(item.type, item.ref); } catch (e) { console.warn(`[webhooks] ${item.type} ${item.ref}:`, e?.message || e); continue; }
|
||||
if (!built) continue;
|
||||
const eventId = `evt_${crypto.randomUUID()}`;
|
||||
const payload = { id: eventId, type: item.type, createdAt: item.at.toISOString(), data: built.data };
|
||||
for (const ep of targets) {
|
||||
made.push(await prisma.webhookDelivery.create({ data: { endpointId: ep.id, eventId, type: item.type, payload, userId: built.userId || null } }));
|
||||
}
|
||||
}
|
||||
for (const d of made) await send(d.id).catch(e => console.warn('[webhooks] send:', e?.message || e));
|
||||
return made;
|
||||
}
|
||||
|
||||
// ─── Payloads ────────────────────────────────────────────────────────────────
|
||||
|
||||
const frontendUrl = () => String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const person = u => (u ? { id: u.id, name: u.name, email: u.email, phone: u.phoneNumber || null } : null);
|
||||
const eventRef = e => (e ? { id: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate } : null);
|
||||
const reservedIds = e => new Set([e?.kidsOptionId, e?.volunteerOptionId].filter(Boolean));
|
||||
const EVENT_SELECT = { id: true, title: true, startDate: true, endDate: true, kidsOptionId: true, volunteerOptionId: true };
|
||||
const USER_SELECT = { id: true, name: true, email: true, phoneNumber: true };
|
||||
|
||||
async function registrationData(id, status) {
|
||||
const r = await prisma.registration.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
event: { select: EVENT_SELECT }, user: { select: USER_SELECT }, payments: true,
|
||||
registrationOptions: { include: { eventOption: { select: { id: true, name: true } }, variant: { select: { name: true } }, tranches: true } },
|
||||
},
|
||||
});
|
||||
if (!r || (status && r.status !== status)) return null;
|
||||
// Kids check-in and volunteer places are internal (and can be about children): never sent.
|
||||
const reserved = reservedIds(r.event);
|
||||
if (r.registrationOptions.length && r.registrationOptions.every(o => reserved.has(o.eventOptionId))) return null;
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const totalDue = Math.round(computeRegistrationTotalDue(r, new Date()) * 100) / 100;
|
||||
const paid = Math.round(r.payments.reduce((s, p) => s + p.amount, 0) * 100) / 100;
|
||||
return {
|
||||
userId: r.userId,
|
||||
data: {
|
||||
registration: {
|
||||
id: r.id, status: r.status, createdAt: r.createdAt, totalDue, amountPaid: paid,
|
||||
seasonPassPurchaseId: r.seasonPassPurchaseId || null,
|
||||
items: r.registrationOptions.filter(o => !reserved.has(o.eventOptionId)).map(o => ({ ticket: o.eventOption?.name, variant: o.variant?.name || null, quantity: o.quantity, unitPrice: o.priceSnapshot })),
|
||||
},
|
||||
event: eventRef(r.event),
|
||||
person: person(r.user),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPayload(type, ref) {
|
||||
switch (type) {
|
||||
case 'registration.created': return registrationData(ref);
|
||||
case 'registration.paid': return registrationData(ref, 'paid');
|
||||
case 'registration.cancelled': return registrationData(ref, 'cancelled');
|
||||
case 'payment.received':
|
||||
case 'payment.refunded': {
|
||||
const p = await prisma.payment.findUnique({ where: { id: ref }, include: { user: { select: USER_SELECT }, event: { select: EVENT_SELECT } } });
|
||||
if (!p) return null;
|
||||
return {
|
||||
userId: p.userId,
|
||||
data: {
|
||||
payment: { id: p.id, amount: p.amount, method: p.method, provider: p.provider || null, isDonation: p.isDonation, registrationId: p.registrationId, createdAt: p.createdAt },
|
||||
event: eventRef(p.event),
|
||||
person: person(p.user),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'ticket.checked_in': {
|
||||
const u = await prisma.ticketUsage.findUnique({
|
||||
where: { id: ref },
|
||||
include: {
|
||||
session: { select: { id: true, label: true, startsAt: true } },
|
||||
ticket: { include: { event: { select: EVENT_SELECT }, user: { select: USER_SELECT }, holder: { select: USER_SELECT }, attendee: { select: { name: true } }, registrationOption: { select: { eventOptionId: true } } } },
|
||||
},
|
||||
});
|
||||
if (!u) return null;
|
||||
if (reservedIds(u.ticket.event).has(u.ticket.registrationOption?.eventOptionId)) return null;
|
||||
// A transferred ticket belongs to its holder now.
|
||||
const who = u.ticket.holder || u.ticket.user;
|
||||
return {
|
||||
userId: who?.id || u.ticket.userId,
|
||||
data: {
|
||||
checkIn: { id: u.id, scannedAt: u.scannedAt, people: u.quantityRedeemed, manual: u.manual, offline: u.offline, session: u.session || null },
|
||||
ticket: { id: u.ticket.id, registrationOptionId: u.ticket.registrationOptionId, holderName: u.ticket.attendee?.name || who?.name || null },
|
||||
event: eventRef(u.ticket.event),
|
||||
person: person(who),
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'user.created': {
|
||||
const user = await prisma.user.findUnique({ where: { id: ref }, select: { ...USER_SELECT, role: true, createdAt: true } });
|
||||
if (!user) return null;
|
||||
return { userId: user.id, data: { person: { ...person(user), role: user.role, createdAt: user.createdAt } } };
|
||||
}
|
||||
case 'event.published': {
|
||||
const e = await prisma.event.findUnique({
|
||||
where: { id: ref },
|
||||
select: { ...EVENT_SELECT, description: true, location: true, price: true, isActive: true, isHidden: true, goLiveAt: true, campus: { select: { name: true, slug: true } }, series: { select: { name: true, slug: true } } },
|
||||
});
|
||||
if (!e || !e.isActive || e.isHidden) return null;
|
||||
return {
|
||||
userId: null,
|
||||
data: {
|
||||
event: {
|
||||
id: e.id, title: e.title, description: e.description, startDate: e.startDate, endDate: e.endDate, location: e.location,
|
||||
price: e.price, goLiveAt: e.goLiveAt, url: `${frontendUrl()}/events/${e.id}`, campus: e.campus || null, series: e.series || null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sending ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The wait before the next try after `attempts` failed tries, or null when it's given up. Pure. */
|
||||
function retryDelay(attempts) {
|
||||
return attempts < MAX_ATTEMPTS ? RETRY_AFTER_MS[attempts - 1] : null;
|
||||
}
|
||||
|
||||
/** POSTs one delivery (claimed first, so two workers never send it twice). */
|
||||
async function send(deliveryId) {
|
||||
const claimed = await prisma.webhookDelivery.updateMany({ where: { id: deliveryId, status: { in: ['pending', 'retrying'] } }, data: { status: 'sending', nextAttemptAt: new Date() } });
|
||||
if (!claimed.count) return null;
|
||||
const d = await prisma.webhookDelivery.findUnique({ where: { id: deliveryId }, include: { endpoint: true } });
|
||||
const attempts = d.attempts + 1;
|
||||
const body = JSON.stringify(d.payload);
|
||||
let statusCode = null;
|
||||
let error = null;
|
||||
try {
|
||||
if (!d.endpoint.isActive) throw webhookError('The endpoint is switched off');
|
||||
const url = new URL(cleanUrl(d.endpoint.url));
|
||||
await assertPublicHost(url.hostname);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'CrossEvents-Webhooks/1',
|
||||
'X-CrossEvents-Event': d.type,
|
||||
'X-CrossEvents-Delivery': d.id,
|
||||
'X-CrossEvents-Signature': signatureHeader(decrypt(d.endpoint.secret), body),
|
||||
},
|
||||
body,
|
||||
});
|
||||
statusCode = res.status;
|
||||
if (res.status < 200 || res.status >= 300) error = `Answered ${res.status}${res.status >= 300 && res.status < 400 ? ' (redirects are not followed)' : ''}`;
|
||||
} catch (e) {
|
||||
error = e?.name === 'TimeoutError' ? `No answer within ${TIMEOUT_MS / 1000} seconds` : (e?.message || String(e));
|
||||
}
|
||||
const now = new Date();
|
||||
const ok = !error;
|
||||
const wait = ok ? null : retryDelay(attempts);
|
||||
const updated = await prisma.webhookDelivery.update({
|
||||
where: { id: d.id },
|
||||
data: {
|
||||
attempts, lastStatusCode: statusCode, lastError: error ? String(error).slice(0, 500) : null,
|
||||
status: ok ? 'succeeded' : wait ? 'retrying' : 'failed',
|
||||
deliveredAt: ok ? now : null,
|
||||
nextAttemptAt: wait ? new Date(now.getTime() + wait) : now,
|
||||
},
|
||||
});
|
||||
await prisma.webhookEndpoint.update({ where: { id: d.endpointId }, data: ok ? { lastSuccessAt: now } : { lastFailureAt: now } }).catch(() => {});
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** The `webhooks` job: sends what's due, picks up sends cut off by a restart, prunes old deliveries. */
|
||||
async function runJob(now = new Date()) {
|
||||
// A send is claimed with nextAttemptAt = now; one still "sending" 5 minutes later was cut off.
|
||||
await prisma.webhookDelivery.updateMany({ where: { status: 'sending', nextAttemptAt: { lt: new Date(now.getTime() - 5 * 60e3) } }, data: { status: 'retrying' } });
|
||||
const due = await prisma.webhookDelivery.findMany({ where: { status: { in: ['pending', 'retrying'] }, nextAttemptAt: { lte: now } }, orderBy: { nextAttemptAt: 'asc' }, take: 100, select: { id: true } });
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
for (const { id } of due) {
|
||||
const r = await send(id).catch(() => null);
|
||||
if (r?.status === 'succeeded') sent++;
|
||||
else if (r) failed++;
|
||||
}
|
||||
const pruned = await prisma.webhookDelivery.deleteMany({ where: { createdAt: { lt: new Date(now.getTime() - KEEP_DAYS * 864e5) } } });
|
||||
return { due: due.length, sent, failed, pruned: pruned.count };
|
||||
}
|
||||
|
||||
/** Sends a delivery again now (a fresh try, whatever happened before). */
|
||||
async function resend(deliveryId) {
|
||||
const d = await prisma.webhookDelivery.findUnique({ where: { id: deliveryId }, select: { id: true } });
|
||||
if (!d) throw webhookError('Delivery not found', 404);
|
||||
await prisma.webhookDelivery.update({ where: { id: d.id }, data: { status: 'pending', attempts: 0, nextAttemptAt: new Date() } });
|
||||
return send(d.id);
|
||||
}
|
||||
|
||||
/** A "webhook.test" delivery to one endpoint, sent at once. */
|
||||
async function sendTest(endpointId) {
|
||||
const eventId = `evt_${crypto.randomUUID()}`;
|
||||
const payload = { id: eventId, type: 'webhook.test', createdAt: new Date().toISOString(), data: { message: 'A test from Cross Events. If you can read this, the webhook works.' } };
|
||||
const d = await prisma.webhookDelivery.create({ data: { endpointId, eventId, type: 'webhook.test', payload } });
|
||||
return send(d.id);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EVENT_TYPES, TYPES, MAX_ATTEMPTS, KEEP_DAYS,
|
||||
isPrivateAddress, cleanUrl, assertPublicHost, newSecret, signatureHeader, encrypt,
|
||||
notice, flush, buildPayload, retryDelay, send, runJob, resend, sendTest, forgetEndpoints, webhookError,
|
||||
};
|
||||
@@ -148,12 +148,18 @@ test('version comparison for "update available"', () => {
|
||||
|
||||
test('an update request shows until the site runs the version asked for', () => {
|
||||
const at = '2026-10-27T09:00:00.000Z';
|
||||
expect(pendingUpdateRequest('1.41.2', at, '1.42.0')).toEqual({ requestedAt: at, version: '1.42.0' });
|
||||
expect(pendingUpdateRequest('1.41.2', at, '1.42.0')).toEqual({ requestedAt: at, version: '1.42.0', outdated: false });
|
||||
expect(pendingUpdateRequest('1.42.0', at, '1.42.0')).toBeNull();
|
||||
expect(pendingUpdateRequest('1.43.0', at, '1.42.0')).toBeNull();
|
||||
expect(pendingUpdateRequest('1.41.2', null, null)).toBeNull();
|
||||
});
|
||||
|
||||
test('a request for a version that has since been overtaken can be changed', () => {
|
||||
const at = '2026-10-27T09:00:00.000Z';
|
||||
expect(pendingUpdateRequest('1.46.2', at, '1.47.0', '1.47.3')).toEqual({ requestedAt: at, version: '1.47.0', outdated: true });
|
||||
expect(pendingUpdateRequest('1.46.2', at, '1.47.3', '1.47.3')).toEqual({ requestedAt: at, version: '1.47.3', outdated: false });
|
||||
});
|
||||
|
||||
describe("what's new", () => {
|
||||
const releases = [
|
||||
{ version: '1.46.0', notes: 'Older', urgent: false, releasedAt: '2026-10-20T10:00:00.000Z' },
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Asking for an update again after a newer version came out: the request moves on to it — on the
|
||||
// Hub itself (1.5.0, `changed`), or by withdrawing and asking again with an older Hub.
|
||||
const mockSettings = new Map();
|
||||
jest.mock('../src/config/db', () => ({
|
||||
appSetting: {
|
||||
findMany: jest.fn(async ({ where }) => [...mockSettings].filter(([key]) => where.key.in.includes(key)).map(([key, value]) => ({ key, value }))),
|
||||
upsert: jest.fn(async ({ where, create }) => { mockSettings.set(where.key, create.value); }),
|
||||
deleteMany: jest.fn(async ({ where }) => { mockSettings.delete(where.key); }),
|
||||
},
|
||||
}));
|
||||
jest.mock('../src/hub/report', () => ({ APP_VERSION: '1.46.2' }));
|
||||
|
||||
const { encrypt } = require('../src/utils/encryption');
|
||||
const hub = require('../src/hub/client');
|
||||
|
||||
const reply = (status, body) => ({ ok: status < 400, status, json: async () => body });
|
||||
|
||||
describe('changing an update request', () => {
|
||||
let calls;
|
||||
beforeEach(() => {
|
||||
mockSettings.clear();
|
||||
mockSettings.set('hub_install_id', 'install-1');
|
||||
mockSettings.set('hub_install_secret', encrypt('secret'));
|
||||
mockSettings.set('hub_update_requested_at', '2026-10-20T09:00:00.000Z');
|
||||
mockSettings.set('hub_update_requested_version', '1.47.0');
|
||||
calls = [];
|
||||
});
|
||||
afterEach(() => { delete global.fetch; });
|
||||
|
||||
test('Hub 1.5.0 moves the open request on to the latest version', async () => {
|
||||
global.fetch = jest.fn(async url => {
|
||||
calls.push(new URL(url).pathname);
|
||||
return reply(200, { requestedAt: '2026-10-28T09:00:00.000Z', version: '1.47.3', alreadyRequested: true, changed: true, previousVersion: '1.47.0' });
|
||||
});
|
||||
const out = await hub.requestUpdate();
|
||||
expect(calls).toEqual(['/api/installs/update-request']);
|
||||
expect(out).toMatchObject({ version: '1.47.3', changed: true, previousVersion: '1.47.0' });
|
||||
expect(mockSettings.get('hub_update_requested_version')).toBe('1.47.3');
|
||||
expect(mockSettings.get('hub_update_requested_at')).toBe('2026-10-28T09:00:00.000Z');
|
||||
});
|
||||
|
||||
test('an older Hub (no `changed`): the old request is withdrawn and made again', async () => {
|
||||
let open = true;
|
||||
global.fetch = jest.fn(async url => {
|
||||
const path = new URL(url).pathname;
|
||||
calls.push(path);
|
||||
if (path.endsWith('/cancel')) { open = false; return reply(200, { cancelled: true }); }
|
||||
return open
|
||||
? reply(200, { requestedAt: '2026-10-20T09:00:00.000Z', version: '1.47.3', alreadyRequested: true })
|
||||
: reply(201, { requestedAt: '2026-10-28T09:00:00.000Z', version: '1.47.3', alreadyRequested: false });
|
||||
});
|
||||
const out = await hub.requestUpdate();
|
||||
expect(calls).toEqual(['/api/installs/update-request', '/api/installs/update-request/cancel', '/api/installs/update-request']);
|
||||
expect(out).toMatchObject({ version: '1.47.3', changed: true, previousVersion: '1.47.0' });
|
||||
expect(mockSettings.get('hub_update_requested_version')).toBe('1.47.3');
|
||||
});
|
||||
|
||||
test('asking again for the version already asked for changes nothing', async () => {
|
||||
mockSettings.set('hub_update_requested_version', '1.47.3');
|
||||
global.fetch = jest.fn(async url => {
|
||||
calls.push(new URL(url).pathname);
|
||||
return reply(200, { requestedAt: '2026-10-20T09:00:00.000Z', version: '1.47.3', alreadyRequested: true });
|
||||
});
|
||||
await hub.requestUpdate();
|
||||
expect(calls).toEqual(['/api/installs/update-request']);
|
||||
});
|
||||
});
|
||||
@@ -60,18 +60,23 @@ describe('cleanSections', () => {
|
||||
});
|
||||
|
||||
describe('noticePromptPending', () => {
|
||||
test('nobody is asked while no version needs re-acceptance', async () => {
|
||||
test('someone who never accepted any version is asked once (first)', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(false);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe('first');
|
||||
});
|
||||
|
||||
test('nobody else is asked while no version needs re-acceptance', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
test('asked when they accepted an older version than the newest one that needs it', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 3 });
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe(true);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(true);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe('updated');
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 0 })).toBe('updated');
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 3 })).toBe(false);
|
||||
expect(await notice.noticePromptPending({ noticeVersionAccepted: 5 })).toBe(false);
|
||||
expect(prisma.privacyNotice.findFirst.mock.calls[0][0].where).toEqual({ requiresAcceptance: true });
|
||||
expect(prisma.privacyNotice.findFirst.mock.calls.at(-1)[0].where).toEqual({ requiresAcceptance: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,3 +112,32 @@ describe('reportRowCount', () => {
|
||||
expect(reportRowCount(null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changes to the built-in text', () => {
|
||||
test('a site that never published shows the built-in text, so there is nothing to review', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue(null);
|
||||
expect(await notice.builtInUpdates()).toEqual([]);
|
||||
});
|
||||
|
||||
test('a site with its own version is shown the changes it has not reviewed', async () => {
|
||||
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 2 });
|
||||
prisma.appSetting.findUnique.mockResolvedValue(null);
|
||||
expect((await notice.builtInUpdates()).map(u => u.revision)).toContain('1.48.1');
|
||||
prisma.appSetting.findUnique.mockResolvedValue({ value: notice.BUILT_IN_REVISION });
|
||||
expect(await notice.builtInUpdates()).toEqual([]);
|
||||
});
|
||||
|
||||
test('every changed section exists in the built-in text', () => {
|
||||
const ids = notice.defaultSections().map(s => s.id);
|
||||
for (const u of notice.BUILT_IN_UPDATES) for (const id of u.sections) expect(ids).toContain(id);
|
||||
expect(notice.revisionLess('1.47.9', '1.48.1')).toBe(true);
|
||||
expect(notice.revisionLess('1.48.10', '1.48.9')).toBe(false);
|
||||
});
|
||||
|
||||
test('the built-in text covers the features since 1.46', () => {
|
||||
const all = notice.defaultSections().map(s => s.body).join('\n');
|
||||
for (const words of ['volunteer', 'series you follow', 'season pass', 'webhook', 'cross_events_dismissed_notices', 'activate an account']) {
|
||||
expect([words, all.toLowerCase().includes(words.toLowerCase())]).toEqual([words, true]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
const mockDb = {
|
||||
webhookEndpoint: { findMany: jest.fn(), update: jest.fn().mockResolvedValue({}) },
|
||||
webhookDelivery: { updateMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), create: jest.fn(), findMany: jest.fn(), deleteMany: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
registration: { findUnique: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
|
||||
const crypto = require('crypto');
|
||||
const webhooks = require('../src/utils/webhooks');
|
||||
const { encrypt } = require('../src/utils/encryption');
|
||||
|
||||
describe('outbound webhooks', () => {
|
||||
afterEach(() => { delete global.fetch; delete process.env.WEBHOOKS_ALLOW_PRIVATE; jest.clearAllMocks(); });
|
||||
|
||||
describe('where they may go', () => {
|
||||
test('private, loopback and link-local addresses are refused', () => {
|
||||
for (const ip of ['10.1.2.3', '127.0.0.1', '192.168.0.10', '172.16.5.5', '172.31.255.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '::1', 'fd00::1', 'fe80::1', '::ffff:10.0.0.1']) {
|
||||
expect([ip, webhooks.isPrivateAddress(ip)]).toEqual([ip, true]);
|
||||
}
|
||||
for (const ip of ['8.8.8.8', '172.32.0.1', '93.184.216.34', '2606:4700::1111']) {
|
||||
expect([ip, webhooks.isPrivateAddress(ip)]).toEqual([ip, false]);
|
||||
}
|
||||
});
|
||||
|
||||
test('https only, no passwords in the address, no private hosts', () => {
|
||||
expect(webhooks.cleanUrl(' https://hooks.example.com/x?y=1 ')).toBe('https://hooks.example.com/x?y=1');
|
||||
expect(() => webhooks.cleanUrl('http://hooks.example.com/x')).toThrow('https://');
|
||||
expect(() => webhooks.cleanUrl('https://user:pw@hooks.example.com/')).toThrow('password');
|
||||
expect(() => webhooks.cleanUrl('https://localhost/x')).toThrow('private');
|
||||
expect(() => webhooks.cleanUrl('https://10.0.0.5/x')).toThrow('private');
|
||||
expect(() => webhooks.cleanUrl('not a url')).toThrow('https://');
|
||||
});
|
||||
|
||||
test('local testing can allow http and private addresses', () => {
|
||||
process.env.WEBHOOKS_ALLOW_PRIVATE = 'true';
|
||||
expect(webhooks.cleanUrl('http://localhost:5199/hook')).toBe('http://localhost:5199/hook');
|
||||
});
|
||||
});
|
||||
|
||||
test('the signature can be checked by the receiver', () => {
|
||||
const body = JSON.stringify({ id: 'evt_1', type: 'registration.paid' });
|
||||
const header = webhooks.signatureHeader('whsec_abc', body, 1_800_000_000_000);
|
||||
const [, t, v1] = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
|
||||
expect(t).toBe('1800000000');
|
||||
expect(v1).toBe(crypto.createHmac('sha256', 'whsec_abc').update(`${t}.${body}`).digest('hex'));
|
||||
});
|
||||
|
||||
test('retries after 1 min, 5 min, 30 min, 2 h and 12 h, then gives up', () => {
|
||||
expect([1, 2, 3, 4, 5, 6].map(webhooks.retryDelay)).toEqual([60e3, 300e3, 1800e3, 7200e3, 43200e3, null]);
|
||||
});
|
||||
|
||||
describe('sending', () => {
|
||||
const delivery = (extra = {}) => ({
|
||||
id: 'd1', endpointId: 'ep1', type: 'registration.paid', attempts: 0, payload: { id: 'evt_1', type: 'registration.paid', data: {} },
|
||||
endpoint: { id: 'ep1', url: 'https://93.184.216.34/hook', isActive: true, secret: encrypt('whsec_s') }, ...extra,
|
||||
});
|
||||
beforeEach(() => {
|
||||
mockDb.webhookDelivery.updateMany.mockResolvedValue({ count: 1 });
|
||||
mockDb.webhookDelivery.update.mockImplementation(async ({ data }) => ({ id: 'd1', ...data }));
|
||||
});
|
||||
|
||||
test('a 2xx answer is delivered, signed, without following redirects', async () => {
|
||||
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery());
|
||||
global.fetch = jest.fn().mockResolvedValue({ status: 204 });
|
||||
const out = await webhooks.send('d1');
|
||||
expect(out.status).toBe('succeeded');
|
||||
const [, init] = global.fetch.mock.calls[0];
|
||||
expect(init.redirect).toBe('manual');
|
||||
expect(init.headers['X-CrossEvents-Event']).toBe('registration.paid');
|
||||
expect(init.headers['X-CrossEvents-Signature']).toMatch(/^t=\d+,v1=[0-9a-f]{64}$/);
|
||||
expect(mockDb.webhookEndpoint.update.mock.calls[0][0].data).toHaveProperty('lastSuccessAt');
|
||||
});
|
||||
|
||||
test('a failure is retried later, and given up after the last try', async () => {
|
||||
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery());
|
||||
global.fetch = jest.fn().mockResolvedValue({ status: 500 });
|
||||
expect((await webhooks.send('d1'))).toMatchObject({ status: 'retrying', attempts: 1, lastStatusCode: 500, lastError: 'Answered 500' });
|
||||
|
||||
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery({ attempts: 5 }));
|
||||
global.fetch = jest.fn().mockRejectedValue(new Error('connect ECONNREFUSED'));
|
||||
expect((await webhooks.send('d1'))).toMatchObject({ status: 'failed', attempts: 6, lastError: 'connect ECONNREFUSED' });
|
||||
});
|
||||
|
||||
test('a redirect is not delivered', async () => {
|
||||
mockDb.webhookDelivery.findUnique.mockResolvedValue(delivery());
|
||||
global.fetch = jest.fn().mockResolvedValue({ status: 302 });
|
||||
expect((await webhooks.send('d1')).lastError).toMatch(/redirects are not followed/);
|
||||
});
|
||||
|
||||
test('a delivery someone else is already sending is left alone', async () => {
|
||||
mockDb.webhookDelivery.updateMany.mockResolvedValue({ count: 0 });
|
||||
global.fetch = jest.fn();
|
||||
expect(await webhooks.send('d1')).toBeNull();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('what happened is checked again, then one delivery per endpoint that wants it', async () => {
|
||||
webhooks.forgetEndpoints();
|
||||
mockDb.webhookEndpoint.findMany.mockResolvedValue([{ id: 'a', events: ['user.created'] }, { id: 'b', events: ['registration.paid'] }, { id: 'c', events: ['user.created', 'event.published'] }]);
|
||||
mockDb.user.findUnique.mockResolvedValue({ id: 'u1', name: 'Thandi', email: 't@test.local', phoneNumber: null, role: 'user', createdAt: new Date() });
|
||||
// A registration that was paid but changed back before the check: nothing is sent.
|
||||
mockDb.registration.findUnique.mockResolvedValue({ id: 'r1', status: 'pending' });
|
||||
mockDb.webhookDelivery.create.mockImplementation(async ({ data }) => ({ id: `d-${data.endpointId}`, ...data }));
|
||||
mockDb.webhookDelivery.updateMany.mockResolvedValue({ count: 0 });
|
||||
webhooks.notice('user.created', 'u1');
|
||||
webhooks.notice('registration.paid', 'r1');
|
||||
webhooks.notice('not.a.type', 'x');
|
||||
const made = await webhooks.flush();
|
||||
expect(made.map(d => [d.endpointId, d.type, d.userId])).toEqual([['a', 'user.created', 'u1'], ['c', 'user.created', 'u1']]);
|
||||
expect(made[0].eventId).toBe(made[1].eventId);
|
||||
expect(made[0].payload.data.person).toMatchObject({ id: 'u1', email: 't@test.local' });
|
||||
});
|
||||
});
|
||||
+33
-3
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-28, after **1.47.3** (and Hub 1.4.0). The plan itself is in
|
||||
Last updated: 2026-10-28, after **1.48.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,9 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.48.1 | Built-in privacy notice updated (rosters, series, season passes, webhooks, acceptance); editor shows built-in changes to sites with their own version; activation asks for agreement; never-accepted users asked once after login |
|
||||
| 1.48.0 | **Outbound webhooks** (§8): Site settings → Webhooks, 8 event types, signed deliveries with retries and a delivery log, operator register entry per endpoint. Migration `20261028160000_webhooks` |
|
||||
| 1.47.4 | Fix: an update request for a version that has since been overtaken shows as such, with **Ask for … instead** (and the banner again). Hub 1.5.0 moves the open request on (`changed`); with an older Hub `requestUpdate` withdraws and asks again |
|
||||
| 1.47.3 | **Season passes** (§6 part 4): passes on the Event series page, buy on the series page (a registration per event, one checkout), price split by normal prices, one Payment per event from the webhook. Migration `20261028150000_season_pass` |
|
||||
| 1.47.2 | **Series: year on year** report (§6 part 3): `GET /api/series/:id/compare` (`compareSeries` in `utils/series.js`), report `seriesComparison` in `ReportsV2`, "Compare" on the Event series page |
|
||||
| 1.47.1 | **Event series** (§6 part 2): Event series page, Series box on events, public `/series/<slug>`, "Next in this series", follow (News & announcements email from the `series` job when a new event goes live). Migration `20261028140000_event_series` |
|
||||
@@ -51,7 +54,8 @@ 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.48.0 — outbound webhooks** (§8).
|
||||
1. **1.49.0 — Admin dashboard, command palette (Ctrl+K), bulk actions** (§9), then 1.50.0 Afrikaans
|
||||
and 1.51.0 accessibility.
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -165,7 +169,15 @@ a test server (both done 2026-10-28).
|
||||
(renders whatever `/api/privacy/notice` returns), admin `app/dashboard/admin/privacy` +
|
||||
`components/admin/privacy/*`, prompt `components/shared/NoticePrompt.tsx` (via
|
||||
`PrivacyPrompts.tsx`: notice first, then the news question). **Changing the built-in text
|
||||
changes the live notice only for installs that haven't published a version yet.**
|
||||
changes the live notice only for installs that haven't published a version yet** — so every
|
||||
change to `DEFAULT_SECTIONS` must also add an entry to `BUILT_IN_UPDATES` (the release, a summary,
|
||||
the section ids changed). Sites with their own version then see it in the editor until they press
|
||||
"Done — hide this" (AppSetting `privacy_notice_builtin_reviewed`). A test checks the ids exist.
|
||||
- Accepting the notice: sign-up (`source: 'signup'`), account activation (`'activation'`, the form's
|
||||
terms box) and the after-login prompt (`'prompt'`). `noticePromptPending` is `'first'` for anyone
|
||||
who never accepted (`noticeVersionAccepted` null) and `'updated'` when a significant version came
|
||||
out later. The e2e seed sets `noticeVersionAccepted: 0` so the prompt doesn't cover every test.
|
||||
- The cookie button on the privacy page shows above any section whose id contains "cookie".
|
||||
- Access log: `utils/accessLog.js` (`logDataAccess(req, { action, what, eventId, records })`).
|
||||
**Any new bulk export or list of personal data should call it** and get a label in
|
||||
`ACCESS_ACTIONS`. Operators: `Operator` table, seeded in the 1.39.0 migration.
|
||||
@@ -331,6 +343,24 @@ a test server (both done 2026-10-28).
|
||||
(staff cancel and refund each event's registration), one combined payment email (each event's
|
||||
payment sends its own).
|
||||
|
||||
## Where outbound webhooks live (1.48.0)
|
||||
|
||||
- **Noticing:** `backend/src/config/db.js` wraps the Prisma client with `$extends` query hooks
|
||||
(registration create/update to paid or cancelled, payment create, ticketUsage create, user create,
|
||||
event create/update to public). **New code gets webhooks for free** as long as it writes through
|
||||
`prisma` (`updateMany` isn't hooked). Hooks only call `notice`; never put slow work there.
|
||||
- `backend/src/utils/webhooks.js` — event types, the in-memory queue (`notice` → `flush` after 1.5 s,
|
||||
which re-reads the record and skips it if it changed back), payload builders (kids/volunteer tickets
|
||||
left out), `send` (claim, SSRF check, sign, 10 s timeout, retry schedule), `runJob` (`webhooks` job:
|
||||
due retries, stuck sends, 30-day prune), `resend`, `sendTest`. Queued events are lost if the
|
||||
process stops in that 1.5 s (no outbox table yet).
|
||||
- `controllers/outboundWebhookController.js` + `routes/outboundWebhookRoutes.js`
|
||||
(`/api/outbound-webhooks`, admin). Operator register entry per endpoint (`operatorId`).
|
||||
- **Adding an event type:** add it to `EVENT_TYPES`, a case in `buildPayload`, a hook in `db.js` if
|
||||
needed, and a line in `dataShared` (operator register text).
|
||||
- Frontend: `components/admin/WebhooksTab.tsx`. Tests: `tests/webhooks.test.js`, e2e
|
||||
`webhooks.spec.ts` (runs a receiver on localhost:5199; e2e backend has `WEBHOOKS_ALLOW_PRIVATE`).
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
|
||||
+6
-3
@@ -35,7 +35,8 @@ up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticke
|
||||
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
|
||||
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
|
||||
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
|
||||
report** and 1.47.3 **season passes** shipped — §6 is done. **Next: 1.48.0 Outbound webhooks.**
|
||||
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. **Next: 1.49.0 Admin dashboard, command
|
||||
palette and bulk actions.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -500,7 +501,9 @@ year-on-year reports, **1.47.3** season pass.
|
||||
|
||||
## 8. Outbound webhooks
|
||||
|
||||
Let other systems react to what happens in Cross Events.
|
||||
Let other systems react to what happens in Cross Events. (Shipped in 1.48.0: Site settings →
|
||||
Webhooks; events noticed through a Prisma client extension; each endpoint added to the operator
|
||||
register; deliveries kept 30 days.)
|
||||
|
||||
- Admin adds endpoints and picks events: `registration.created`,
|
||||
`registration.paid`, `registration.cancelled`, `payment.refunded`,
|
||||
@@ -964,7 +967,7 @@ income justify the hardware:
|
||||
| 6 | 1.41.0 ✅ – 1.42.0 ✅ + Hub 1.0 ✅ – 1.1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo); rename to Cross Events (1.41.3); Request update (1.42.0 / Hub 1.1.0) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 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 |
|
||||
| 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.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 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 |
|
||||
|
||||
@@ -144,12 +144,19 @@ Signed exactly like the heartbeat.
|
||||
POST /api/installs/update-request
|
||||
X-Hub-Install / X-Hub-Timestamp / X-Hub-Signature (as in §3)
|
||||
{ "protocolVersion": 1, "version": "1.41.2" }
|
||||
→ 201 { "requestedAt": "2026-10-27T09:00:00.000Z", "version": "1.42.0", "alreadyRequested": false }
|
||||
→ 200 { …, "alreadyRequested": true } // a request is already open: not emailed again
|
||||
→ 201 { "requestedAt": "2026-10-27T09:00:00.000Z", "version": "1.42.0", "alreadyRequested": false, "changed": false }
|
||||
→ 200 { …, "alreadyRequested": true, "changed": false } // a request for the latest is already open: not emailed again
|
||||
→ 200 { …, "alreadyRequested": true, "changed": true, "previousVersion": "1.42.0" }
|
||||
// the open request was for an older version: it now asks for the latest
|
||||
```
|
||||
|
||||
- The Hub opens an `update_requested` alert on the install (shown under Alerts) and emails the
|
||||
alert address once. It is resolved when a heartbeat reports a newer version than before.
|
||||
- **Changing a request (Hub 1.5.0):** when a request is open for a version that has since been
|
||||
overtaken, asking again moves it on to the latest version — the alert says "latest is 1.47.3
|
||||
(changed from 1.47.0)" and the alert address is emailed once about the change. `version` is
|
||||
always the version now asked for. A Hub before 1.5.0 doesn't send `changed`; installs then
|
||||
withdraw the request and ask again (two emails, same result).
|
||||
- `409 UP_TO_DATE` — the install already runs the Hub's latest version (or none is set).
|
||||
- Errors as in §3 (`INSTALL_NOT_FOUND`, `STALE_REQUEST`, `BAD_SIGNATURE`). Rate-limited (10 an
|
||||
hour per IP). A Hub older than this section answers `404 NOT_FOUND`.
|
||||
|
||||
@@ -11,6 +11,7 @@ export type Seed = {
|
||||
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
|
||||
unsubscribeToken: string;
|
||||
newsPromptUser: { id: string; token: string };
|
||||
noticeFirstUser: { id: string; token: string };
|
||||
retention: { toWarn: string; toAnonymise: string };
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ test.describe("privacy notice, operators and access log (POPIA)", () => {
|
||||
// A version that needs accepting is published below; accept it for every seeded account
|
||||
// afterwards so later test files don't meet the prompt.
|
||||
test.afterAll(async ({ request }) => {
|
||||
const tokens = [...(["admin", "supervisor", "staff", "user"] as Role[]).map(r => seed().users[r].token), seed().newsPromptUser.token];
|
||||
const tokens = [...(["admin", "supervisor", "staff", "user"] as Role[]).map(r => seed().users[r].token), seed().newsPromptUser.token, seed().noticeFirstUser.token];
|
||||
for (const token of tokens) {
|
||||
await request.post(`${API}/api/privacy/me/notice/accept`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
}
|
||||
@@ -55,6 +55,52 @@ test.describe("privacy notice, operators and access log (POPIA)", () => {
|
||||
await api(page, "admin", "DELETE", `/api/privacy/operators/${op.id}`);
|
||||
});
|
||||
|
||||
test("after publishing its own version, the editor points out new built-in text", async ({ page }) => {
|
||||
// The published version came from the built-in text; put an older wording back in one changed
|
||||
// section so it shows as having new text.
|
||||
const draft = await api<Draft>(page, "admin", "GET", "/api/privacy/notice/draft");
|
||||
const sections = draft.sections.map(s => (s.id === "changes" ? { ...s, body: "We may update this notice from time to time." } : s));
|
||||
await api(page, "admin", "PUT", "/api/privacy/notice/draft", { sections });
|
||||
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/privacy?tab=notice");
|
||||
const banner = page.getByTestId("builtin-updates");
|
||||
await expect(banner).toContainText("The built-in privacy notice has been updated");
|
||||
await expect(banner).toContainText("How people agree to the notice");
|
||||
const marked = page.getByTestId("section-has-new-text");
|
||||
await expect(marked).toHaveCount(1);
|
||||
await marked.getByText("New built-in text for this section — show it").click();
|
||||
await marked.getByRole("button", { name: "Use the new text" }).click();
|
||||
await expect(page.getByTestId("section-has-new-text")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Save draft" }).click();
|
||||
await expect(page.getByText("Draft saved.")).toBeVisible();
|
||||
|
||||
await banner.getByRole("button", { name: "Done — hide this" }).click();
|
||||
await expect(page.getByTestId("builtin-updates")).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("1. Introduction text")).toBeVisible();
|
||||
await expect(page.getByTestId("builtin-updates")).toHaveCount(0);
|
||||
await api(page, "admin", "DELETE", "/api/privacy/notice/draft");
|
||||
});
|
||||
|
||||
test("someone who never accepted the notice is asked once, after login", async ({ page }) => {
|
||||
await page.addInitScript(token => localStorage.setItem("hope_events_token", token), seed().noticeFirstUser.token);
|
||||
await page.goto("/dashboard/user");
|
||||
const dialog = page.getByRole("dialog", { name: "Our privacy notice" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText("It explains what we keep about you");
|
||||
await dialog.getByRole("button", { name: "I accept" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.reload();
|
||||
await expect(page.getByText("My Events").first()).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "Our privacy notice" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the cookie button stays on a published notice", async ({ page }) => {
|
||||
await page.goto("/legal/privacy");
|
||||
await expect(page.getByRole("button", { name: "Change my cookie choice" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a significant change is accepted once, after login", async ({ page }) => {
|
||||
const draft = await api<Draft>(page, "admin", "GET", "/api/privacy/notice/draft");
|
||||
// Re-acceptance needs a summary of what changed.
|
||||
|
||||
@@ -48,10 +48,10 @@ async function seed(url) {
|
||||
});
|
||||
|
||||
const password = await hashPassword('E2e-password-123');
|
||||
// Opted in to news and already asked (newsAskedAt), so the one-time news question
|
||||
// doesn't cover the dashboards in every test.
|
||||
// Opted in to news and already asked (newsAskedAt), and accepted the built-in privacy notice
|
||||
// (version 0), so neither one-time question covers the dashboards in every test.
|
||||
const mk = (role, name, email, phoneNumber) => prisma.user.create({
|
||||
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date(), newsEmail: true, newsWhatsApp: true, newsAskedAt: new Date() },
|
||||
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date(), newsEmail: true, newsWhatsApp: true, newsAskedAt: new Date(), noticeVersionAccepted: 0 },
|
||||
});
|
||||
const users = {
|
||||
admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'),
|
||||
@@ -195,7 +195,12 @@ async function seed(url) {
|
||||
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
|
||||
// An account that has never answered the news question (asked once after login).
|
||||
newsPromptUser: await (async () => {
|
||||
const u = await prisma.user.create({ data: { name: 'Nomsa New', email: 'prompt@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true } });
|
||||
const u = await prisma.user.create({ data: { name: 'Nomsa New', email: 'prompt@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true, noticeVersionAccepted: 0 } });
|
||||
return { id: u.id, token: generateToken(u.id, u.role, 0) };
|
||||
})(),
|
||||
// An account made for someone (never accepted the privacy notice): asked once after login.
|
||||
noticeFirstUser: await (async () => {
|
||||
const u = await prisma.user.create({ data: { name: 'Pieter Placed', email: 'placed@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true, newsAskedAt: new Date() } });
|
||||
return { id: u.id, token: generateToken(u.id, u.role, 0) };
|
||||
})(),
|
||||
// Retention: two accounts untouched for six years — one not yet warned, one warned 40 days
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import http from "http";
|
||||
import crypto from "crypto";
|
||||
import { test, expect, loginAs, API } from "./fixtures";
|
||||
|
||||
// Outbound webhooks (1.48.0): an admin adds an endpoint (a receiver this test runs on localhost),
|
||||
// sends a test, and a new account is delivered — signed with the endpoint's secret.
|
||||
type Received = { headers: http.IncomingHttpHeaders; body: string };
|
||||
const RECEIVER_PORT = 5199;
|
||||
|
||||
test.describe.serial("outbound webhooks", () => {
|
||||
const received: Received[] = [];
|
||||
let server: http.Server;
|
||||
let secret = "";
|
||||
|
||||
test.beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", c => { body += c; });
|
||||
req.on("end", () => { received.push({ headers: req.headers, body }); res.writeHead(200).end("ok"); });
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(RECEIVER_PORT, resolve));
|
||||
});
|
||||
test.afterAll(async () => { await new Promise(resolve => server.close(resolve)); });
|
||||
|
||||
const signed = (r: Received) => {
|
||||
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(String(r.headers["x-crossevents-signature"]));
|
||||
return !!m && crypto.createHmac("sha256", secret).update(`${m[1]}.${r.body}`).digest("hex") === m[2];
|
||||
};
|
||||
|
||||
test("an admin adds a webhook and sends a test", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/settings?tab=webhooks");
|
||||
await page.getByRole("button", { name: "+ Add webhook" }).click();
|
||||
await page.getByLabel("Name").fill("E2E receiver");
|
||||
await page.getByLabel("Address (https)").fill(`http://localhost:${RECEIVER_PORT}/hook`);
|
||||
await page.getByText("Account created", { exact: true }).click();
|
||||
await page.getByText("Registration made", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Add webhook" }).click();
|
||||
|
||||
await expect(page.getByTestId("webhook-secret")).toBeVisible();
|
||||
secret = (await page.getByTestId("webhook-secret").locator("code").first().innerText()).trim();
|
||||
expect(secret).toMatch(/^whsec_/);
|
||||
await page.getByRole("button", { name: "I've saved it" }).click();
|
||||
|
||||
const endpoint = page.getByTestId("webhook-endpoint").filter({ hasText: "E2E receiver" });
|
||||
await endpoint.getByRole("button", { name: "Send test" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("Test delivered (answered 200)");
|
||||
const testHook = received.find(r => r.headers["x-crossevents-event"] === "webhook.test");
|
||||
expect(testHook && signed(testHook)).toBe(true);
|
||||
});
|
||||
|
||||
test("a new account is sent, signed, and shows in the delivery log", async ({ page, request }) => {
|
||||
const email = `webhook-${Date.now()}@test.local`;
|
||||
const res = await request.post(`${API}/api/users`, { data: { name: "Webhook Person", email, password: "E2e-password-123", phoneNumber: "0820000123" } });
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
await expect.poll(() => received.filter(r => r.headers["x-crossevents-event"] === "user.created").length, { timeout: 15000 }).toBeGreaterThan(0);
|
||||
const hook = received.find(r => r.headers["x-crossevents-event"] === "user.created")!;
|
||||
expect(signed(hook)).toBe(true);
|
||||
const body = JSON.parse(hook.body);
|
||||
expect(body).toMatchObject({ type: "user.created", data: { person: { name: "Webhook Person", email } } });
|
||||
expect(body.id).toMatch(/^evt_/);
|
||||
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/settings?tab=webhooks");
|
||||
const endpoint = page.getByTestId("webhook-endpoint").filter({ hasText: "E2E receiver" });
|
||||
await endpoint.getByRole("button", { name: "Delivery log" }).click();
|
||||
await expect(endpoint.getByTestId("webhook-delivery").filter({ hasText: "user.created" })).toContainText("succeeded");
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.47.3",
|
||||
"version": "1.48.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.47.3",
|
||||
"version": "1.48.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.47.3",
|
||||
"version": "1.48.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import React, { Suspense, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -16,8 +17,11 @@ function ActivateAccountContent() {
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// An account made for them (by staff, at the door, or by whoever registered them): they agree
|
||||
// to the terms and privacy notice here, as at sign-up.
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(() => password.length >= 8 && password === confirm && !!token, [password, confirm, token]);
|
||||
const canSubmit = useMemo(() => password.length >= 8 && password === confirm && !!token && accepted, [password, confirm, token, accepted]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -25,7 +29,7 @@ function ActivateAccountContent() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password } });
|
||||
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password, acceptTerms: accepted } });
|
||||
// Auto-login: save the session token (under the key AuthContext reads) and load the
|
||||
// dashboard fresh so the new session is picked up.
|
||||
if (data?.token) saveToken(data.token);
|
||||
@@ -64,6 +68,14 @@ function ActivateAccountContent() {
|
||||
{password && confirm && password !== confirm && (
|
||||
<p className="text-xs text-red-600">Passwords do not match.</p>
|
||||
)}
|
||||
<div className="flex items-start gap-2">
|
||||
<input id="terms" type="checkbox" checked={accepted} onChange={e => setAccepted(e.target.checked)} className="mt-1 h-4 w-4 border-gray-300 rounded accent-brand-600" required />
|
||||
<label htmlFor="terms" className="text-sm text-gray-700 leading-snug">
|
||||
I confirm that I've read and agree to the{" "}
|
||||
<Link href="/legal/terms" target="_blank" className="text-brand-600 hover:underline">Terms of Use</Link> and{" "}
|
||||
<Link href="/legal/privacy" target="_blank" className="text-brand-600 hover:underline">Privacy Policy</Link>.
|
||||
</label>
|
||||
</div>
|
||||
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60 w-full py-2.5 font-medium">
|
||||
{loading ? "Activating…" : "Activate my account"}
|
||||
</button>
|
||||
|
||||
@@ -7,20 +7,21 @@ import Link from "next/link";
|
||||
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, MapPinned, type LucideIcon, Webhook } from "lucide-react";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
import { JobsTab } from "@/components/admin/JobsTab";
|
||||
import { PaymentsTab } from "@/components/admin/PaymentsTab";
|
||||
import { LicenceTab } from "@/components/admin/LicenceTab";
|
||||
import { WebhooksTab } from "@/components/admin/WebhooksTab";
|
||||
import { CampusesTab } from "@/components/admin/CampusesTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs" | "licence";
|
||||
type TabId = "organisation" | "campuses" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "webhooks" | "backups" | "jobs" | "licence";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -31,6 +32,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "legal", label: "Legal", icon: Scale },
|
||||
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
|
||||
{ id: "payments", label: "Payments", icon: CreditCard },
|
||||
{ id: "webhooks", label: "Webhooks", icon: Webhook },
|
||||
{ id: "backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ id: "jobs", label: "Background jobs", icon: Timer },
|
||||
{ id: "licence", label: "Licence", icon: KeyRound },
|
||||
@@ -716,6 +718,7 @@ function SiteSettingsPageInner() {
|
||||
|
||||
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
{activeTab === "webhooks" && <WebhooksTab active={activeTab === "webhooks"} />}
|
||||
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
|
||||
{activeTab === "licence" && <LicenceTab active={activeTab === "licence"} />}
|
||||
{activeTab === "campuses" && <CampusesTab active={activeTab === "campuses"} />}
|
||||
|
||||
@@ -24,6 +24,7 @@ type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
signup: "when you signed up",
|
||||
activation: "when you activated your account",
|
||||
privacy_centre: "in your privacy settings",
|
||||
prompt: "when we asked after logging in",
|
||||
unsubscribe_link: "with an unsubscribe link",
|
||||
|
||||
@@ -23,6 +23,7 @@ const STATE: Record<string, string> = {
|
||||
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
|
||||
|
||||
function describeCron(cron: string): string {
|
||||
if (cron === "* * * * *") return "Every minute";
|
||||
const m = /^\*\/(\d+) \* \* \* \*$/.exec(cron);
|
||||
if (m) return `Every ${m[1]} minutes`;
|
||||
const d = /^(\d+) (\d+) \* \* \*$/.exec(cron);
|
||||
|
||||
@@ -24,7 +24,8 @@ export type HubStatus = {
|
||||
urgentUpdate: boolean;
|
||||
updateSnoozed: boolean;
|
||||
releases: HubRelease[];
|
||||
updateRequest: { requestedAt: string; version: string } | null;
|
||||
// `outdated`: a newer version than the one asked for is out (the request can be changed to it).
|
||||
updateRequest: { requestedAt: string; version: string; outdated?: boolean } | null;
|
||||
notices: HubNotice[];
|
||||
};
|
||||
|
||||
@@ -138,6 +139,8 @@ export function LicenceTab({ active }: { active: boolean }) {
|
||||
}
|
||||
};
|
||||
const requestUpdate = () => updateAction("/api/hub/update-request", undefined, "Update requested. Your provider has been told and will update the site.");
|
||||
// Same request again: the Hub moves the open request on to the latest version.
|
||||
const changeRequest = () => updateAction("/api/hub/update-request", undefined, `Request changed to version ${status?.latestVersion}. Your provider has been told.`);
|
||||
const withdrawRequest = () => {
|
||||
if (!confirm("Withdraw the update request? Your provider is told not to update the site for now.")) return;
|
||||
updateAction("/api/hub/update-request/cancel", undefined, "Update request withdrawn. Your provider has been told.");
|
||||
@@ -208,12 +211,26 @@ export function LicenceTab({ active }: { active: boolean }) {
|
||||
</p>
|
||||
)}
|
||||
{status.updateRequest ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-gray-600">Version {status.latestVersion} is available. Update requested on {fmt(status.updateRequest.requestedAt)} — your provider will install it.</p>
|
||||
{status.connected && (
|
||||
<button onClick={withdrawRequest} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
|
||||
<Undo2 className="w-4 h-4" /> Withdraw request
|
||||
</button>
|
||||
<div className="space-y-2" data-testid="update-request">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-gray-600">
|
||||
You asked for version <strong>{status.updateRequest.version}</strong> on {fmt(status.updateRequest.requestedAt)} — your provider will install it.
|
||||
</p>
|
||||
{status.connected && (
|
||||
<button onClick={withdrawRequest} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50">
|
||||
<Undo2 className="w-4 h-4" /> Withdraw request
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{status.updateRequest.outdated && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-brand-200 bg-brand-50 p-3">
|
||||
<p className="text-brand-800">Version <strong>{status.latestVersion}</strong> has come out since. Ask for it instead, and your provider installs that one.</p>
|
||||
{status.connected && (
|
||||
<button onClick={changeRequest} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
<ArrowUpCircle className="w-4 h-4" /> {busy ? "Working…" : `Ask for ${status.latestVersion} instead`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Webhook, Send, RefreshCw, KeyRound, Trash2, Pencil, Copy, ChevronDown, ChevronRight, RotateCcw } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type EventType = { type: string; label: string; description: string };
|
||||
type Endpoint = {
|
||||
id: string; name: string; url: string; events: string[]; isActive: boolean;
|
||||
lastSuccessAt: string | null; lastFailureAt: string | null; createdAt: string;
|
||||
lastWeek?: Record<string, number>;
|
||||
};
|
||||
type Delivery = {
|
||||
id: string; eventId: string; type: string; status: string; attempts: number; nextAttemptAt: string;
|
||||
lastStatusCode: number | null; lastError: string | null; deliveredAt: string | null; createdAt: string; payload: unknown;
|
||||
};
|
||||
type ListResponse = { eventTypes: EventType[]; keepDays: number; endpoints: Endpoint[] };
|
||||
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" }) : "—");
|
||||
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",
|
||||
};
|
||||
|
||||
/** Shown once after adding an endpoint or making a new secret. */
|
||||
function SecretBox({ secret, onClose }: { secret: string; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm space-y-2" data-testid="webhook-secret">
|
||||
<p className="font-medium text-amber-900">Signing secret — copy it now, it won't be shown again</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 break-all rounded bg-white border px-2 py-1 text-xs">{secret}</code>
|
||||
<button type="button" className="inline-flex items-center gap-1 px-2 py-1 rounded border bg-white text-xs hover:bg-gray-50"
|
||||
onClick={() => { navigator.clipboard?.writeText(secret).then(() => setCopied(true)).catch(() => {}); }}>
|
||||
<Copy className="w-3.5 h-3.5" /> {copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-amber-900">
|
||||
Each request has an <code>X-CrossEvents-Signature: t=…,v1=…</code> header: v1 is the HMAC-SHA256 (hex) of <code><t>.<body></code> with this secret. Check it, and that t is recent, before trusting a request.
|
||||
</p>
|
||||
<button type="button" className="text-xs underline text-amber-900" onClick={onClose}>I've saved it</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 load = useCallback(async () => {
|
||||
setRows(await apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries`, { authToken: token }).catch(() => []));
|
||||
}, [endpointId, token]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
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>;
|
||||
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>
|
||||
<ul className="divide-y border rounded-lg bg-white">
|
||||
{rows.map(d => (
|
||||
<li key={d.id} className="text-xs" data-testid="webhook-delivery">
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
|
||||
<button type="button" onClick={() => setOpen(open === d.id ? null : d.id)} className="text-gray-500" aria-label="Show what was sent">
|
||||
{open === d.id ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<span className="text-gray-500 w-28">{when(d.createdAt)}</span>
|
||||
<code className="font-medium">{d.type}</code>
|
||||
<span className={`px-1.5 py-0.5 rounded ${STATUS[d.status] || "bg-gray-100"}`}>{d.status}</span>
|
||||
<span className="text-gray-500">
|
||||
{d.attempts} tr{d.attempts === 1 ? "y" : "ies"}{d.lastStatusCode ? ` · ${d.lastStatusCode}` : ""}{d.status === "retrying" ? ` · next ${when(d.nextAttemptAt)}` : ""}
|
||||
</span>
|
||||
{d.lastError && <span className="text-red-700 truncate max-w-[16rem]" title={d.lastError}>{d.lastError}</span>}
|
||||
{d.status !== "sending" && (
|
||||
<button type="button" disabled={busy === d.id} onClick={() => resend(d.id)} className="ml-auto inline-flex items-center gap-1 px-2 py-0.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50">
|
||||
<RotateCcw className="w-3 h-3" /> Resend
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open === d.id && <pre className="mx-3 mb-2 max-h-64 overflow-auto rounded bg-gray-50 p-2 text-[11px]">{JSON.stringify(d.payload, null, 2)}</pre>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings → Webhooks: send what happens here (registrations, payments, check-ins…) to other systems.
|
||||
export function WebhooksTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [data, setData] = useState<ListResponse | null>(null);
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [secret, setSecret] = useState<{ endpointId: string; value: string } | null>(null);
|
||||
const [openLog, setOpenLog] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try { setData(await apiFetch<ListResponse>("/api/outbound-webhooks", { authToken: token })); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Couldn't load webhooks" }); }
|
||||
}, [token]);
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
const run = async (fn: () => Promise<void>) => {
|
||||
setBusy(true); setMsg(null);
|
||||
try { await fn(); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Something went wrong" }); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const save = () => run(async () => {
|
||||
if (!draft) return;
|
||||
const body = { name: draft.name, url: draft.url, events: draft.events };
|
||||
if (draft.id) {
|
||||
await apiFetch(`/api/outbound-webhooks/${draft.id}`, { method: "PUT", authToken: token || undefined, body });
|
||||
setMsg({ type: "ok", text: "Webhook saved." });
|
||||
} else {
|
||||
const made = await apiFetch<Endpoint & { secret: string }>("/api/outbound-webhooks", { method: "POST", authToken: token || undefined, body });
|
||||
setSecret({ endpointId: made.id, value: made.secret });
|
||||
setMsg({ type: "ok", text: `"${made.name}" added, and listed in the operator register (Admin → Privacy).` });
|
||||
}
|
||||
setDraft(null);
|
||||
await load();
|
||||
});
|
||||
|
||||
const toggle = (e: Endpoint) => run(async () => {
|
||||
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "PUT", authToken: token || undefined, body: { isActive: !e.isActive } });
|
||||
await load();
|
||||
});
|
||||
const test = (e: Endpoint) => run(async () => {
|
||||
const r = await apiFetch<{ status: string; statusCode: number | null; error: string | null }>(`/api/outbound-webhooks/${e.id}/test`, { method: "POST", authToken: token || undefined });
|
||||
setMsg(r.status === "succeeded"
|
||||
? { type: "ok", text: `Test delivered (answered ${r.statusCode}).` }
|
||||
: { type: "err", text: `Test not delivered: ${r.error || "no answer"}. It will be retried; see the delivery log.` });
|
||||
if (openLog === e.id) setOpenLog(null);
|
||||
await load();
|
||||
});
|
||||
const rotate = (e: Endpoint) => run(async () => {
|
||||
if (!confirm(`Make a new secret for "${e.name}"? The old one stops working at once, so update the receiving side straight away.`)) return;
|
||||
const r = await apiFetch<{ secret: string }>(`/api/outbound-webhooks/${e.id}/rotate-secret`, { method: "POST", authToken: token || undefined });
|
||||
setSecret({ endpointId: e.id, value: r.secret });
|
||||
});
|
||||
const remove = (e: Endpoint) => run(async () => {
|
||||
if (!confirm(`Delete "${e.name}" and its delivery log?`)) return;
|
||||
await apiFetch(`/api/outbound-webhooks/${e.id}`, { method: "DELETE", authToken: token || undefined });
|
||||
setMsg({ type: "ok", text: "Webhook deleted. Its operator register entry is marked inactive." });
|
||||
await load();
|
||||
});
|
||||
|
||||
const toggleEvent = (type: string) => setDraft(d => d && ({ ...d, events: d.events.includes(type) ? d.events.filter(t => t !== type) : [...d.events, type] }));
|
||||
|
||||
if (!data) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
const label = (type: string) => data.eventTypes.find(t => t.type === type)?.label || type;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"><Webhook className="w-5 h-5" /> Webhooks</h2>
|
||||
<p className="text-sm text-gray-500 max-w-2xl">
|
||||
Send what happens here — registrations, payments, check-ins, new accounts — to another system as it happens (e.g. ChurchSuite or Planning Center through Zapier or Make).
|
||||
</p>
|
||||
</div>
|
||||
{!draft && <button type="button" onClick={() => { setMsg(null); setDraft({ name: "", url: "", events: [] }); }} className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 font-medium">+ Add webhook</button>}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-600 bg-gray-50 border rounded-lg p-3">
|
||||
Webhooks send personal information (names, email addresses, phone numbers) to the service you choose, so each one is added to your operator register. Make sure you have an agreement with that service. What was sent is kept {data.keepDays} days for the delivery log. Children's details from kids check-in are never sent.
|
||||
</p>
|
||||
|
||||
{msg && <div className={`p-3 border rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`} role="status">{msg.text}</div>}
|
||||
{secret && <SecretBox secret={secret.value} onClose={() => setSecret(null)} />}
|
||||
|
||||
{draft && (
|
||||
<div className="border rounded-xl p-4 space-y-3 bg-white" data-testid="webhook-form">
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="webhook-name">Name</label>
|
||||
<input id="webhook-name" className="w-full border rounded px-3 py-2 text-sm" value={draft.name} onChange={e => setDraft({ ...draft, name: e.target.value })} placeholder="e.g. ChurchSuite sync" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="webhook-url">Address (https)</label>
|
||||
<input id="webhook-url" className="w-full border rounded px-3 py-2 text-sm" value={draft.url} onChange={e => setDraft({ ...draft, url: e.target.value })} placeholder="https://…" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-600 mb-1">Send when</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{data.eventTypes.map(t => (
|
||||
<label key={t.type} className="flex items-start gap-2 text-sm border rounded-lg p-2 cursor-pointer hover:bg-gray-50">
|
||||
<input type="checkbox" className="mt-1" checked={draft.events.includes(t.type)} onChange={() => toggleEvent(t.type)} />
|
||||
<span><span className="font-medium">{t.label}</span> <code className="text-[10px] text-gray-500">{t.type}</code><span className="block text-xs text-gray-500">{t.description}</span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" disabled={busy || !draft.name.trim() || !draft.url.trim() || !draft.events.length} onClick={save} className="px-4 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
|
||||
{busy ? "Saving…" : draft.id ? "Save webhook" : "Add webhook"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setDraft(null)} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.endpoints.length === 0 && !draft ? (
|
||||
<p className="text-sm text-gray-500 border rounded-xl p-4">No webhooks yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{data.endpoints.map(e => (
|
||||
<li key={e.id} className="border rounded-xl p-4 bg-white space-y-2" data-testid="webhook-endpoint">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900 flex items-center gap-2">
|
||||
{e.name}
|
||||
{!e.isActive && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">Off</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 break-all">{e.url}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">{e.events.map(t => <span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700">{label(t)}</span>)}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Last delivered {when(e.lastSuccessAt)}{e.lastFailureAt && <> · last failure {when(e.lastFailureAt)}</>}
|
||||
{e.lastWeek && Object.keys(e.lastWeek).length > 0 && <> · this week: {Object.entries(e.lastWeek).map(([s, n]) => `${n} ${s}`).join(", ")}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" disabled={busy} onClick={() => test(e)} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><Send className="w-3.5 h-3.5" /> Send test</button>
|
||||
<button type="button" onClick={() => { setMsg(null); setDraft({ id: e.id, name: e.name, url: e.url, events: e.events }); }} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50"><Pencil className="w-3.5 h-3.5" /> Edit</button>
|
||||
<button type="button" disabled={busy} onClick={() => toggle(e)} className="text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50">{e.isActive ? "Switch off" : "Switch on"}</button>
|
||||
<button type="button" disabled={busy} onClick={() => rotate(e)} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50 disabled:opacity-50"><KeyRound className="w-3.5 h-3.5" /> New secret</button>
|
||||
<button type="button" disabled={busy} onClick={() => remove(e)} className="inline-flex items-center gap-1 text-xs px-2.5 py-1.5 rounded bg-red-50 text-red-700 border border-red-200 disabled:opacity-50"><Trash2 className="w-3.5 h-3.5" /> Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => setOpenLog(openLog === e.id ? null : e.id)} className="inline-flex items-center gap-1 text-xs text-brand-700 hover:underline">
|
||||
{openLog === e.id ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />} Delivery log
|
||||
</button>
|
||||
{openLog === e.id && token && <Deliveries endpointId={e.id} token={token} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ type ParentalRow = { id: string; at: string | null; child: string; event: string
|
||||
type LogResponse<T> = { rows: T[]; total: number; pageSize: number };
|
||||
|
||||
const SOURCE: Record<string, string> = {
|
||||
signup: "Sign-up", privacy_centre: "Privacy page", prompt: "Asked after login", unsubscribe_link: "Unsubscribe link",
|
||||
signup: "Sign-up", activation: "Account activation", privacy_centre: "Privacy page", prompt: "Asked after login", unsubscribe_link: "Unsubscribe link",
|
||||
whatsapp_stop: "Replied STOP", existing_account: "Existing account (1.38.0)", admin: "Administrator",
|
||||
};
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowUp, ArrowDown, Trash2, Plus, RotateCcw, Eye, PencilLine } from "lucide-react";
|
||||
import { ArrowUp, ArrowDown, Trash2, Plus, RotateCcw, Eye, PencilLine, Sparkles } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { PrivacyNoticeSection, type NoticeSection } from "@/components/shared/PrivacyNoticeSection";
|
||||
|
||||
type Version = { version: number; publishedAt: string; changeSummary: string | null; requiresAcceptance: boolean; publishedBy: string | null; acceptances: number };
|
||||
type Draft = { sections: NoticeSection[]; rendered: NoticeSection[]; isDraft: boolean; savedAt: string | null; versions: Version[]; builtIn: NoticeSection[] };
|
||||
// Changes to the built-in text since this site last reviewed them (only once it has published its own).
|
||||
type BuiltInUpdate = { revision: string; summary: string[]; sections: string[] };
|
||||
type Draft = { sections: NoticeSection[]; rendered: NoticeSection[]; isDraft: boolean; savedAt: string | null; versions: Version[]; builtIn: NoticeSection[]; builtInUpdates?: BuiltInUpdate[] };
|
||||
type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
|
||||
const PLACEHOLDER_HELP: [string, string][] = [
|
||||
@@ -106,10 +108,45 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
// The built-in text changed since this site last looked: hide the list once the admin has been through it.
|
||||
const markReviewed = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch("/api/privacy/notice/builtin-reviewed", { method: "POST", authToken: token });
|
||||
setDraft(d => (d ? { ...d, builtInUpdates: [] } : d));
|
||||
setMsg({ type: "ok", text: "Done. Remember to save the draft and publish if you took any new text." });
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not save." });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!draft) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
|
||||
const builtInById = new Map(draft.builtIn.map(s => [s.id, s]));
|
||||
const current = draft.versions[0];
|
||||
const updates = draft.builtInUpdates || [];
|
||||
const updatedIds = new Set(updates.flatMap(u => u.sections));
|
||||
const differs = (s: NoticeSection) => { const b = builtInById.get(s.id); return !!b && (b.body !== s.body || b.title !== s.title); };
|
||||
// Built-in sections this notice doesn't have (removed, or new in the built-in text).
|
||||
const missing = draft.builtIn.filter(b => !sections.some(s => s.id === b.id));
|
||||
const pendingIds = sections.filter(s => updatedIds.has(s.id) && differs(s)).map(s => s.id);
|
||||
/** Puts a built-in section back where it sits in the built-in text. */
|
||||
const addBuiltIn = (b: NoticeSection) => {
|
||||
const order = draft.builtIn.map(x => x.id);
|
||||
const after = order.slice(0, order.indexOf(b.id)).reverse().find(id => sections.some(s => s.id === id));
|
||||
const at = after ? sections.findIndex(s => s.id === after) + 1 : 0;
|
||||
change([...sections.slice(0, at), { ...b }, ...sections.slice(at)]);
|
||||
};
|
||||
const useAllNew = () => {
|
||||
if (!confirm("Replace every changed section with the new built-in text? Your own wording in those sections is replaced (other sections stay as they are). Nothing is public until you publish.")) return;
|
||||
const next = sections.map(s => {
|
||||
const b = updatedIds.has(s.id) ? builtInById.get(s.id) : undefined;
|
||||
return b ? { ...b } : s;
|
||||
});
|
||||
change(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -148,6 +185,24 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
{dirty && <span className="self-center text-xs text-amber-700">Unsaved changes</span>}
|
||||
</div>
|
||||
|
||||
{updates.length > 0 && (
|
||||
<div className="border border-amber-200 bg-amber-50 rounded-xl p-4 text-sm text-amber-900 space-y-2" data-testid="builtin-updates">
|
||||
<div className="flex items-center gap-2 font-medium"><Sparkles className="w-4 h-4" /> The built-in privacy notice has been updated</div>
|
||||
<p>Your published notice keeps your own text, so the new wording from Cross Events {updates.map(u => u.revision).join(", ")} isn't in it yet:</p>
|
||||
<ul className="list-disc pl-5">{updates.flatMap(u => u.summary).map((line, i) => <li key={i}>{line}</li>)}</ul>
|
||||
<p>
|
||||
{pendingIds.length > 0
|
||||
? <>Sections with new text are marked below. Use the new text where you want it (it replaces your wording in that section), then save the draft and publish.</>
|
||||
: <>Your draft already has the new text in every changed section. Save the draft and publish to make it live.</>}
|
||||
{" "}Tick "Significant change" when publishing if people should accept it again.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pendingIds.length > 0 && <button onClick={useAllNew} disabled={busy} className="px-3 py-1.5 rounded-lg bg-amber-600 text-white text-sm font-medium hover:bg-amber-700 disabled:opacity-50">Use all the new text</button>}
|
||||
<button onClick={markReviewed} disabled={busy} className="px-3 py-1.5 rounded-lg border border-amber-300 bg-white text-sm hover:bg-amber-100 disabled:opacity-50">Done — hide this</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg && <div className={`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>}
|
||||
|
||||
{publishing && (
|
||||
@@ -182,8 +237,9 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
<div className="space-y-3">
|
||||
{sections.map((s, i) => {
|
||||
const original = builtInById.get(s.id);
|
||||
const hasNew = updatedIds.has(s.id) && differs(s);
|
||||
return (
|
||||
<div key={`${s.id}-${i}`} className="border rounded-xl p-3 bg-white">
|
||||
<div key={`${s.id}-${i}`} className={`border rounded-xl p-3 bg-white ${hasNew ? "border-amber-300" : ""}`} data-testid={hasNew ? "section-has-new-text" : undefined}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input className={`${inputCls} font-medium`} value={s.title} onChange={e => update(i, { title: e.target.value })} aria-label="Section title" />
|
||||
<button onClick={() => move(i, -1)} disabled={i === 0} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move up"><ArrowUp className="w-4 h-4" /></button>
|
||||
@@ -194,9 +250,27 @@ export function NoticeEditor({ token }: { token: string }) {
|
||||
<button onClick={() => confirm(`Remove "${s.title}"?`) && change(sections.filter((_, j) => j !== i))} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label="Remove section"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<textarea className={`${inputCls} font-mono text-xs`} rows={Math.min(18, Math.max(3, s.body.split("\n").length + 1))} value={s.body} onChange={e => update(i, { body: e.target.value })} aria-label={`${s.title} text`} />
|
||||
{hasNew && original && (
|
||||
<details className="mt-2 text-xs">
|
||||
<summary className="cursor-pointer text-amber-800 font-medium">New built-in text for this section — show it</summary>
|
||||
<pre className="mt-2 whitespace-pre-wrap rounded-lg bg-amber-50 border border-amber-200 p-2 font-mono text-[11px] text-gray-700">{original.body}</pre>
|
||||
<button onClick={() => update(i, { title: original.title, body: original.body })} className="mt-2 px-3 py-1.5 rounded-lg bg-amber-600 text-white hover:bg-amber-700">Use the new text</button>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{missing.length > 0 && (
|
||||
<div className="border border-dashed rounded-xl p-3 text-sm text-gray-600 space-y-1">
|
||||
<div className="font-medium text-gray-800">Built-in sections not in your notice</div>
|
||||
{missing.map(b => (
|
||||
<div key={b.id} className="flex items-center justify-between gap-2">
|
||||
<span>{b.title}{updatedIds.has(b.id) && <span className="ml-1 text-xs text-amber-700">(new text)</span>}</span>
|
||||
<button onClick={() => addBuiltIn(b)} className="inline-flex items-center gap-1 px-2 py-1 rounded border bg-white text-xs hover:bg-gray-50"><Plus className="w-3.5 h-3.5" /> Add</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => change([...sections, { id: `section-${sections.length + 1}`, title: "New section", body: "" }])} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm hover:bg-gray-50">
|
||||
<Plus className="w-4 h-4" /> Add section
|
||||
</button>
|
||||
|
||||
@@ -52,17 +52,21 @@ export function LicenceBanner() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Once an update has been requested there's nothing more for the admin to do. "Not now" hides it
|
||||
// until the next version, except for urgent (security or serious bug) updates.
|
||||
if (status.urgentUpdate && !status.updateRequest) {
|
||||
// Once the latest version has been requested there's nothing more for the admin to do. A request
|
||||
// for a version that has since been overtaken shows again, to change it. "Not now" hides it until
|
||||
// the next version, except for urgent (security or serious bug) updates.
|
||||
const request = status.updateRequest;
|
||||
const awaiting = !!request && !request.outdated;
|
||||
const ask = request?.outdated ? <>You asked for {request.version}; change your request in {link}</> : <>Please request the update in {link}</>;
|
||||
if (status.urgentUpdate && !awaiting) {
|
||||
return (
|
||||
<div role="alert" className="bg-red-50 border-b border-red-200 text-red-800 text-sm px-4 py-2 flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 shrink-0" />
|
||||
<span>Cross Events {status.latestVersion} fixes a security problem or a serious bug (this site runs {status.version}). Please request the update in {link}</span>
|
||||
<span>Cross Events {status.latestVersion} fixes a security problem or a serious bug (this site runs {status.version}). {ask}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status.updateAvailable && !status.updateRequest && !status.updateSnoozed) {
|
||||
if (status.updateAvailable && !awaiting && !status.updateSnoozed) {
|
||||
const notNow = async () => {
|
||||
setBusy(true);
|
||||
try { setStatus(await apiFetch<HubStatus>("/api/hub/update-snooze", { method: "POST", authToken: token, body: { snooze: true } })); } catch { /* the Licence page shows why */ }
|
||||
@@ -72,7 +76,7 @@ export function LicenceBanner() {
|
||||
<div role="status" className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm px-4 py-2 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<ArrowUpCircle className="w-4 h-4 shrink-0" />
|
||||
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). See what's new and request it in {link}</span>
|
||||
<span>Cross Events {status.latestVersion} is available (this site runs {status.version}). {request?.outdated ? <>You asked for {request.version}; see what's new and change your request in {link}</> : <>See what's new and request it in {link}</>}</span>
|
||||
</span>
|
||||
<button onClick={notNow} disabled={busy} className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-brand-700 hover:bg-brand-100 disabled:opacity-50" title="Hide this until the next version comes out">
|
||||
<BellOff className="w-3.5 h-3.5" /> Not now
|
||||
|
||||
@@ -9,11 +9,12 @@ import { errorMessage } from "@/lib/utils";
|
||||
import type { PublicNotice } from "@/components/shared/PrivacyNoticeSection";
|
||||
|
||||
/**
|
||||
* POPIA: a privacy notice version marked as a significant change has been published, and
|
||||
* this person accepted an older one. Asked once after login; it can't be waved away, but the
|
||||
* notice and the Privacy page (to delete the account instead) stay reachable.
|
||||
* POPIA: asked once after login when this person never accepted the privacy notice (an account
|
||||
* made for them, or from before acceptances were recorded) — reason "first" — or a version marked
|
||||
* as a significant change came out after the one they accepted ("updated"). It can't be waved
|
||||
* away, but the notice and the Privacy page (to delete the account instead) stay reachable.
|
||||
*/
|
||||
export function NoticePrompt({ onDone }: { onDone: () => void }) {
|
||||
export function NoticePrompt({ reason = "updated", onDone }: { reason?: "first" | "updated"; onDone: () => void }) {
|
||||
const { token } = useAuth();
|
||||
const [notice, setNotice] = useState<PublicNotice | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -40,7 +41,7 @@ export function NoticePrompt({ onDone }: { onDone: () => void }) {
|
||||
};
|
||||
|
||||
// The newest version that needed accepting, for "what changed".
|
||||
const changes = notice.versions.find(v => v.requiresAcceptance)?.changeSummary || notice.changeSummary;
|
||||
const changes = reason === "updated" ? notice.versions.find(v => v.requiresAcceptance)?.changeSummary || notice.changeSummary : null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-labelledby="notice-prompt-title">
|
||||
@@ -49,7 +50,7 @@ export function NoticePrompt({ onDone }: { onDone: () => void }) {
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-brand-50 text-brand-600 shrink-0">
|
||||
<ScrollText className="w-5 h-5" />
|
||||
</div>
|
||||
<h2 id="notice-prompt-title" className="text-lg font-semibold text-gray-900">We've updated our privacy notice</h2>
|
||||
<h2 id="notice-prompt-title" className="text-lg font-semibold text-gray-900">{reason === "first" ? "Our privacy notice" : <>We've updated our privacy notice</>}</h2>
|
||||
</div>
|
||||
{changes && (
|
||||
<div className="mb-3 p-3 rounded-lg bg-gray-50 border text-sm text-gray-700 whitespace-pre-line">
|
||||
@@ -57,6 +58,7 @@ export function NoticePrompt({ onDone }: { onDone: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 mb-5">
|
||||
{reason === "first" && <>It explains what we keep about you and why, and your rights. </>}
|
||||
Please read the <Link href="/legal/privacy" target="_blank" className="text-brand-600 hover:underline">privacy notice</Link> and
|
||||
accept it to carry on using your account. If you don't agree, you can{" "}
|
||||
<Link href="/dashboard/user/privacy" className="text-brand-600 hover:underline">delete your account</Link> instead.
|
||||
|
||||
@@ -42,7 +42,8 @@ export function PrivacyNoticeSection({ section, first = false }: { section: Noti
|
||||
) : (
|
||||
<h3 className="text-xl font-semibold text-brand-600 mb-3">{section.title}</h3>
|
||||
)}
|
||||
{section.id === "cookies" && (
|
||||
{/* Above the cookie section: the built-in "cookies" one, or one an admin wrote about cookies. */}
|
||||
{/cookie/i.test(section.id) && (
|
||||
<CookieSettingsButton className="mb-3 px-3 py-1.5 text-sm rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50">
|
||||
Change my cookie choice
|
||||
</CookieSettingsButton>
|
||||
|
||||
@@ -18,6 +18,6 @@ export function PrivacyPrompts() {
|
||||
const finishNotice = useCallback(() => setNoticeDone(true), []);
|
||||
|
||||
if (pathname === "/dashboard/user/privacy") return null;
|
||||
if (user?.noticePromptPending && !noticeDone) return <NoticePrompt onDone={finishNotice} />;
|
||||
if (user?.noticePromptPending && !noticeDone) return <NoticePrompt reason={user.noticePromptPending} onDone={finishNotice} />;
|
||||
return <NewsPrompt />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye, Gauge, Timer, AlertTriangle } from "lucide-react";
|
||||
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye, Gauge, Timer, AlertTriangle, Sparkles, Users } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -79,6 +79,12 @@ export const adminPrivacyHelpContent: HelpContent = {
|
||||
<GuideItem icon={ScrollText} title="Editing" tone="brand">
|
||||
The notice starts from the built-in text. Change any section, add or remove sections, and save a draft — nothing is public until you publish. Placeholders like {"{org}"}, {"{ioName}"} and {"{operators}"} are filled in from Site settings and the operator register, so they stay up to date.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Sparkles} title="When the built-in text is updated" tone="violet">
|
||||
Until you publish your own version, the site shows the built-in text, so updates appear by themselves. Once you have published, your text stays as it is — after an update a yellow box lists what changed and marks the sections with new text. Show it, use it section by section (or all at once), save and publish. "Done — hide this" when you've been through it.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="How people agree to it" tone="blue">
|
||||
At sign-up, and when they activate an account that was made for them (by staff, at the door or by whoever registered them). Anyone who has never agreed is asked once after they log in. Every agreement is in the consent log.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Send} title="Publishing" tone="green">
|
||||
Each publish is a numbered version with its date, and earlier versions stay readable on the privacy page. Tick "Significant change" and say what changed when people should accept it again — they're asked once, after they next log in. Leave it unticked for small fixes.
|
||||
</GuideItem>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users, BellOff, Megaphone } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound, ArrowUpCircle, MapPinned, Archive, Users, BellOff, Megaphone, Webhook, Send } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -147,6 +147,27 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "webhooks",
|
||||
label: "Webhooks",
|
||||
icon: Webhook,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>Send what happens here to another system as it happens — for example to ChurchSuite, Planning Center or a Google Sheet through Zapier or Make.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Webhook} title="Add a webhook" tone="brand">
|
||||
"+ Add webhook": a name, the https address the other system gave you, and what to send — registrations made, paid or cancelled, payments and refunds, check-ins, new accounts, published events. Copy the signing secret it shows (once) into the other system so it can check requests really come from you.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Send} title="Test and delivery log" tone="green">
|
||||
"Send test" sends a test message straight away. The delivery log shows everything sent in the last 30 days, what came back, and the full message. Anything that fails is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours; "Resend" tries again now.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Scale} title="Privacy" tone="amber">
|
||||
Webhooks send names, email addresses and phone numbers, so each webhook is added to your operator register (Admin → Privacy) — have an agreement with the service. Children's details from kids check-in are never sent. Switch a webhook off or delete it at any time.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "jobs",
|
||||
label: "Background jobs",
|
||||
@@ -184,7 +205,7 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
When a licence is suspended, revoked or past its grace period, nothing new can be added — no events, registrations or payments. Tickets for events people already paid for still scan, and all data can still be viewed and exported.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ArrowUpCircle} title="Updates" tone="brand">
|
||||
When a new version is out, the Version box shows what's new in it, so you can decide whether you want it. Press Request update and your provider updates the site for you — there's nothing to run yourself. Changed your mind? Withdraw request tells your provider not to.
|
||||
When a new version is out, the Version box shows what's new in it, so you can decide whether you want it. Press Request update and your provider updates the site for you — there's nothing to run yourself. Changed your mind? Withdraw request tells your provider not to. If a newer version comes out before your provider has updated the site, the page shows which version you asked for and offers "Ask for … instead".
|
||||
</GuideItem>
|
||||
<GuideItem icon={BellOff} title="Not now" tone="gray">
|
||||
Not now (on the Licence page or the update banner) hides the reminder until the next version comes out. Updates that fix a security problem or a serious bug can't be put off.
|
||||
|
||||
@@ -12,7 +12,8 @@ export type AuthUser = {
|
||||
// POPIA: hasn't answered the "news and announcements" opt-in question yet (asked once).
|
||||
newsPromptPending?: boolean;
|
||||
// POPIA: a privacy notice version they must accept has been published since they last did.
|
||||
noticePromptPending?: boolean;
|
||||
// Why the privacy notice must be accepted after login: never accepted, or a newer version needs it.
|
||||
noticePromptPending?: false | "first" | "updated";
|
||||
// Multi-campus: their home campus (the events page starts on it).
|
||||
homeCampusId?: string | null;
|
||||
// Campus-scoped staff: the campuses they're limited to (null = every campus).
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.47.3",
|
||||
"version": "1.48.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.47.3",
|
||||
"version": "1.48.1",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.47.3",
|
||||
"version": "1.48.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
@@ -51,6 +51,8 @@ export default defineConfig({
|
||||
SCHEDULED_EMAILS_ENABLED: "false",
|
||||
// The kiosk test signs a supervisor in with a password (no 2FA code by email).
|
||||
TWO_FACTOR_REQUIRED_ROLES: "false",
|
||||
// webhooks.spec.ts runs its own receiver on localhost.
|
||||
WEBHOOKS_ALLOW_PRIVATE: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user