Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925252a1f2 | ||
|
|
60726b71ce | ||
|
|
e58787ae7a | ||
|
|
8f533e1f61 | ||
|
|
3c5246424b | ||
|
|
bbdceb38b6 | ||
|
|
c0965bfcf5 | ||
|
|
eb2159fa33 | ||
|
|
0551226de5 | ||
|
|
1ae9c45eed | ||
|
|
fd2bb07047 | ||
|
|
2d4bc5c38d |
@@ -7,6 +7,54 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.41.1] - 2026-10-27
|
||||
|
||||
### Changed
|
||||
|
||||
- **No licence now means read-only**, the same as a suspended licence: a site that isn't connected
|
||||
to the Hub can't create events, registrations or payments until a licence key is connected
|
||||
(admins see a red banner). `LICENCE_CHECK_DISABLED=true` switches this off outside production,
|
||||
for the end-to-end tests.
|
||||
- **Read-only mode also blocks editing events**, their options and their variants (suspended,
|
||||
revoked and unlicensed sites).
|
||||
- The site **checks in with the Hub 30 seconds after starting**, and 30 seconds after a licence
|
||||
key is connected, instead of waiting up to 15 minutes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A site the Hub no longer knows (`INSTALL_NOT_FOUND`, e.g. the licence was released) kept showing
|
||||
"Active" on its old licence. It now drops the connection and licence, and shows "No licence".
|
||||
|
||||
## [1.41.0] - 2026-10-26
|
||||
|
||||
### Added
|
||||
|
||||
- **Hope Events Hub connection** (`docs/hub-protocol.md`). Each install connects to the Hub
|
||||
(`events-hub.crosscode.co.za`) with a licence key:
|
||||
- **Licence step in `/setup`** (on the last screen), and **Site settings → Licence** for existing
|
||||
installs: connect, see the licence (licensed to, plan, end date, grace period), the last check-in,
|
||||
and "Check now".
|
||||
- A **heartbeat every 15 minutes** (`hub-heartbeat` job; `HUB_HEARTBEAT_ENABLED=false` to switch off):
|
||||
version, health (database, background jobs, last backup, free disk, server errors, pending
|
||||
migrations) and totals (users, events, registrations, tickets, check-ins, payment providers, income
|
||||
as a range only). Never anyone's personal information.
|
||||
- **Licences are checked on the site** with the Hub's public key, so a Hub outage never switches a
|
||||
site off and a licence can't be faked by editing the database:
|
||||
- **Grace period** (licence past its end date, default 14 days): everything works; **only site
|
||||
admins** see "Licence needs renewing — X days left".
|
||||
- **Suspended or revoked**: read-only — no new events, registrations, tickets, payments, waitlist
|
||||
places, coupons or bundles. Tickets still scan; all data can still be viewed and exported;
|
||||
refunds, cancellations and privacy requests still work.
|
||||
- Not connected yet: works as before; admins see "Connect to the Hub".
|
||||
- **Update available**: admins see when a newer version is out, with the command to run.
|
||||
- **`/health`** — status, version, database, background jobs and last backup, for monitoring.
|
||||
- **Install script** (`scripts/install.sh`): a fresh Ubuntu server to a running site in one command —
|
||||
Node 20, PostgreSQL, PM2, nginx, HTTPS, configuration, migrations, build; then `/setup`.
|
||||
|
||||
### Changed
|
||||
|
||||
- The generic settings endpoints never read or write the Hub connection (install id, secret, licence).
|
||||
|
||||
## [1.40.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
+12
@@ -642,6 +642,7 @@ can't start, the same jobs run on in-process timers.
|
||||
| `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`) |
|
||||
| `hub-heartbeat` — check in with the Hope Events Hub and pick up licence changes (only once connected) | Every 15 minutes (`HUB_HEARTBEAT_ENABLED=false`) |
|
||||
| `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) |
|
||||
| `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) |
|
||||
| `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
|
||||
@@ -650,6 +651,17 @@ can't start, the same jobs run on in-process timers.
|
||||
|
||||
## Operations
|
||||
|
||||
- **Install script** (`scripts/install.sh`) — a fresh Ubuntu server to a running site: Node 20,
|
||||
PostgreSQL, PM2, nginx, HTTPS, configuration, migrations and build; then `/setup`.
|
||||
- **Hope Events Hub** — every install connects with a licence key (`/setup`, or Site settings →
|
||||
Licence) and checks in every 15 minutes with its version, health and totals (no personal
|
||||
information; income as a range). Licences are signed tokens checked on the site, so a Hub outage
|
||||
never switches it off: past the end date there's a grace period (banner for site admins only),
|
||||
then — or when suspended, revoked or not licensed — the site is read-only (events can't be
|
||||
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.
|
||||
See `docs/hub-protocol.md`.
|
||||
- **`/health`** — status, version, database, background jobs and last backup.
|
||||
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
|
||||
one above 500 MB of memory.
|
||||
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
|
||||
|
||||
@@ -98,6 +98,24 @@ Documentation: see Frontend README for environment and scripts.
|
||||
|
||||
## Deployment
|
||||
|
||||
### A new install
|
||||
|
||||
On a fresh Ubuntu 22.04/24.04 server, with the domain's DNS already pointing at it:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://c-git.crosscode.co.za/joshua/hope-events/raw/branch/main/scripts/install.sh
|
||||
sudo bash install.sh --domain events.yourchurch.org --email you@yourchurch.org [--version v1.41.0]
|
||||
```
|
||||
|
||||
It installs Node 20, PostgreSQL (with its own database and a random password), PM2, nginx (the
|
||||
API, uploads, `/health` and `/docs` to the backend, everything else to the site) and an HTTPS
|
||||
certificate, writes `backend/.env` and `frontend/.env.production`, migrates, builds and starts the
|
||||
app. Then open `https://<domain>/setup`: create the admin account and enter the **licence key**
|
||||
to connect the site to the Hope Events Hub. Several installs can share a server with different
|
||||
`--name`, `--dir`, `--web-port` and `--api-port`. Run `--help` for all options.
|
||||
|
||||
### Updating
|
||||
|
||||
To update a running install, on the server in the repo folder:
|
||||
|
||||
- `bash scripts/deploy.sh` — the latest `main`
|
||||
|
||||
+10
@@ -84,6 +84,16 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
|
||||
## Hope Events Hub (licence)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `HUB_URL` (env) | `https://events-hub.crosscode.co.za` | Where the Hub runs. |
|
||||
| `HUB_PUBLIC_KEY` (env) | The production key built into `backend/src/hub/publicKey.js` | Only for a local or test Hub. Licence tokens are checked with this key. |
|
||||
| `HUB_HEARTBEAT_ENABLED` (env) | `true` | The 15-minute `hub-heartbeat` job (plus one check-in 30 s after starting and after connecting). Off means the licence isn't refreshed — after 30 days the site goes into grace, then read-only. |
|
||||
| `LICENCE_CHECK_DISABLED` (env) | `false` | `true` turns read-only mode off — **ignored when `NODE_ENV=production`**. For the end-to-end tests and local development without a Hub. |
|
||||
| `hub_install_id`, `hub_install_secret` (encrypted), `hub_licence`, `hub_last_heartbeat_at`, `hub_last_error`, `hub_latest_version` | — | Written only by `src/hub/client.js` when connecting and on each heartbeat. Never readable or writable through the generic settings endpoints. Deleting them disconnects the site (it becomes "no licence", which is read-only). The heartbeat deletes the install and licence keys itself when the Hub answers `INSTALL_NOT_FOUND`. |
|
||||
|
||||
## WhatsApp
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|
||||
@@ -76,6 +76,13 @@ PRIVACY_JOBS_ENABLED=true
|
||||
# Privacy (POPIA): nightly 03:30 retention job (Admin → Privacy → Retention). Note: inactive
|
||||
# accounts are anonymised after 5 years by default (with a warning 30 days before).
|
||||
RETENTION_JOBS_ENABLED=true
|
||||
|
||||
# ─── Hope Events Hub (licence and health) ─────────────────────────────────────
|
||||
# Where the Hub runs. The site checks in every 15 minutes once connected (Site settings → Licence).
|
||||
# HUB_URL=https://events-hub.crosscode.co.za
|
||||
# For a local or test Hub only: its public key (the release has the production key built in).
|
||||
# HUB_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----"
|
||||
HUB_HEARTBEAT_ENABLED=true
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
@@ -44,6 +44,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Forms](#forms)
|
||||
- [Provider Transactions](#provider-transactions)
|
||||
- [WhatsApp Admin](#whatsapp-admin)
|
||||
- [Hub and licence](#hub-and-licence)
|
||||
- [Webhooks](#webhooks)
|
||||
- [Settings](#settings)
|
||||
- [Setup](#setup)
|
||||
@@ -836,6 +837,19 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
|
||||
|
||||
---
|
||||
|
||||
## Hub and licence
|
||||
|
||||
The connection to the Hope Events Hub (`src/hub/`, protocol in `docs/hub-protocol.md`).
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/health` | public | `{ status, version, db, jobs, lastBackupAt }` — 503 when the database is down |
|
||||
| GET | `/api/hub/status` | admin | `{ state: active\|grace\|suspended\|revoked\|unlicensed, readOnly, daysLeft, endsAt, graceEndsAt, licence, connected, keyConfigured, hubUrl, lastHeartbeatAt, lastError, version, latestVersion, updateAvailable }` |
|
||||
| POST | `/api/hub/connect` | admin (incl. the `/setup` token) | `{ licenceKey }` — registers with the Hub; stores the install id, encrypted secret and licence token. Hub errors pass through with their `code` (`LICENCE_NOT_FOUND`, `LICENCE_IN_USE`, `LICENCE_REVOKED`, `HUB_UNREACHABLE`, …). Audited as `hub_connected` |
|
||||
| POST | `/api/hub/heartbeat` | admin | Check in now; returns the status |
|
||||
|
||||
**Read-only mode** (licence suspended, revoked, or past its grace period): `403 { code: "LICENCE_READ_ONLY" }` on `POST /api/events`, event options and variants, `POST /api/registrations` (and `/manual`), `PUT /api/registrations/:id/options`, `POST /api/payments`, `/payments/checkout` (`/send`), `/payments/yoco-checkout` (`/send`), `/payments/pay/:id`, `POST /api/waitlist`, `POST /api/coupons`, `POST /api/bundles/event/:id`. Everything else — scanning, exports, refunds, cancellations, privacy requests, settings — keeps working, and payment webhooks for payments already started are still processed.
|
||||
|
||||
## Webhooks
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'hub_connected';
|
||||
|
||||
@@ -602,6 +602,7 @@ enum AdminAuditAction {
|
||||
retention_run
|
||||
breach_recorded
|
||||
breach_updated
|
||||
hub_connected
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { licenceStatus } = require('../hub/licence');
|
||||
const hub = require('../hub/client');
|
||||
const { APP_VERSION } = require('../hub/report');
|
||||
|
||||
function compareVersions(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] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** What the admin pages show: licence state, Hub connection, version and update. */
|
||||
async function statusPayload({ fresh = false } = {}) {
|
||||
const s = await licenceStatus({ fresh });
|
||||
return {
|
||||
state: s.state,
|
||||
readOnly: s.readOnly,
|
||||
daysLeft: s.daysLeft,
|
||||
endsAt: s.endsAt,
|
||||
graceEndsAt: s.graceEndsAt,
|
||||
licence: s.claims ? {
|
||||
customer: s.claims.customer,
|
||||
plan: s.claims.plan,
|
||||
status: s.claims.status,
|
||||
licenceExpiresAt: s.claims.licenceExpiresAt,
|
||||
graceDays: s.claims.graceDays,
|
||||
issuedAt: s.claims.issuedAt,
|
||||
validUntil: s.claims.validUntil,
|
||||
limits: s.claims.limits || {},
|
||||
} : null,
|
||||
connected: s.connected,
|
||||
keyConfigured: s.keyConfigured,
|
||||
hubUrl: hub.hubUrl(),
|
||||
lastHeartbeatAt: s.lastHeartbeatAt,
|
||||
lastError: s.lastError,
|
||||
version: APP_VERSION,
|
||||
latestVersion: s.latestVersion,
|
||||
updateAvailable: !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0),
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Licence and Hub status (for the admin banner and Site settings → Licence)
|
||||
// @route GET /api/hub/status
|
||||
// @access Private/Admin
|
||||
const getStatus = async (req, res) => {
|
||||
try {
|
||||
res.json(await statusPayload());
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Connect this install to the Hub with a licence key (also the /setup licence step)
|
||||
// @route POST /api/hub/connect body: { licenceKey }
|
||||
// @access Private/Admin
|
||||
const connect = async (req, res) => {
|
||||
try {
|
||||
const licenceKey = String(req.body?.licenceKey || '').trim();
|
||||
if (!licenceKey) { res.status(400); throw new Error('Enter your licence key.'); }
|
||||
const me = await prisma.user.findUnique({ where: { id: req.user.id }, select: { name: true, email: true } });
|
||||
const claims = await hub.connect(licenceKey, me);
|
||||
hub.heartbeatSoon();
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_connected', targetType: 'hub', metadata: { customer: claims.customer, hubUrl: hub.hubUrl() }, ip: getClientIp(req) });
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
const status = e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
|
||||
res.status(status >= 500 && e.code ? 502 : status).json({ message: e.message || safeErrorMessage(e), code: e.code });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send a heartbeat now (checks the connection and picks up licence changes)
|
||||
// @route POST /api/hub/heartbeat
|
||||
// @access Private/Admin
|
||||
const heartbeatNow = async (req, res) => {
|
||||
try {
|
||||
await hub.heartbeat();
|
||||
res.json(await statusPayload({ fresh: true }));
|
||||
} catch (e) {
|
||||
res.status(502).json({ message: e.message || safeErrorMessage(e), code: e.code, status: await statusPayload({ fresh: true }).catch(() => null) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getStatus, connect, heartbeatNow, statusPayload, compareVersions };
|
||||
@@ -15,6 +15,11 @@ const PAYMENT_PRIVATE_KEYS = new Set([
|
||||
'payfast_merchant_id', 'payfast_merchant_key', 'payfast_passphrase', 'payfast_mode',
|
||||
]);
|
||||
|
||||
// The Hub connection (install id and secret, licence token) is only written by src/hub —
|
||||
// an admin editing it here could borrow another install's licence.
|
||||
const HUB_PRIVATE_KEYS = new Set(require('../hub/client').HUB_KEYS);
|
||||
const PRIVATE_KEYS = new Set([...PAYMENT_PRIVATE_KEYS, ...HUB_PRIVATE_KEYS]);
|
||||
|
||||
const PUBLIC_KEYS = [
|
||||
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
|
||||
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
|
||||
@@ -53,7 +58,7 @@ const getAllSettings = async (req, res) => {
|
||||
const rows = await prisma.appSetting.findMany();
|
||||
const settings = {};
|
||||
for (const r of rows) {
|
||||
if (PAYMENT_PRIVATE_KEYS.has(r.key)) continue;
|
||||
if (PRIVATE_KEYS.has(r.key)) continue;
|
||||
if (ENCRYPTED_KEYS.has(r.key)) {
|
||||
// Return a sentinel so the UI knows the value is set, without exposing it.
|
||||
// smtp_user (email address) we can safely return as-is after decryption so
|
||||
@@ -89,7 +94,7 @@ const updateSettings = async (req, res) => {
|
||||
}
|
||||
|
||||
const ops = Object.entries(updates)
|
||||
.filter(([k, v]) => v !== undefined && v !== null && !PAYMENT_PRIVATE_KEYS.has(k))
|
||||
.filter(([k, v]) => v !== undefined && v !== null && !PRIVATE_KEYS.has(k))
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
|
||||
@@ -128,7 +133,7 @@ const updateSettings = async (req, res) => {
|
||||
actorRole: req.user.role,
|
||||
action: 'settings_updated',
|
||||
targetType: 'AppSetting',
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PAYMENT_PRIVATE_KEYS.has(k)) },
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null && !PRIVATE_KEYS.has(k)) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
@@ -205,7 +210,7 @@ const runSetup = async (req, res) => {
|
||||
|
||||
const toSave = { ...settings, setup_complete: 'true' };
|
||||
const ops = Object.entries(toSave)
|
||||
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PAYMENT_PRIVATE_KEYS.has(k))
|
||||
.filter(([k, v]) => v !== undefined && v !== null && String(v).trim() !== '' && !PRIVATE_KEYS.has(k))
|
||||
.map(([key, value]) => {
|
||||
let storedValue = String(value);
|
||||
if (ENCRYPTED_KEYS.has(key)) storedValue = encrypt(storedValue);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Talking to the Hope Events Hub (docs/hub-protocol.md): register with a licence key, then a
|
||||
* signed heartbeat every 15 minutes. Install → Hub only. Stored in AppSetting:
|
||||
* hub_install_id, hub_install_secret (encrypted), hub_licence (signed token),
|
||||
* hub_last_heartbeat_at, hub_last_error, hub_latest_version.
|
||||
* These keys are kept out of the generic settings endpoints (settingsController).
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { encrypt, decrypt } = require('../utils/encryption');
|
||||
const { verifyToken, clearLicenceCache } = require('./licence');
|
||||
const report = require('./report');
|
||||
|
||||
const PROTOCOL_VERSION = 1;
|
||||
const TIMEOUT_MS = 15000;
|
||||
const HUB_KEYS = ['hub_install_id', 'hub_install_secret', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version'];
|
||||
|
||||
function hubUrl() {
|
||||
return String(process.env.HUB_URL || 'https://events-hub.crosscode.co.za').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
class HubError extends Error {
|
||||
constructor(message, code, status) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.statusCode = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(values) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === null) await prisma.appSetting.deleteMany({ where: { key } });
|
||||
else await prisma.appSetting.upsert({ where: { key }, update: { value: String(value) }, create: { key, value: String(value) } });
|
||||
}
|
||||
clearLicenceCache();
|
||||
}
|
||||
|
||||
async function read(keys) {
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: keys } } });
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
}
|
||||
|
||||
async function post(path, body, headers = {}) {
|
||||
const raw = JSON.stringify(body);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${hubUrl()}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(typeof headers === 'function' ? headers(raw) : headers) },
|
||||
body: raw,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new HubError(`The Hub (${hubUrl()}) could not be reached: ${e?.message || e}`, 'HUB_UNREACHABLE', 502);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new HubError(data.message || `The Hub answered ${res.status}`, data.code || 'HUB_ERROR', res.status);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Register this install with a licence key. Returns the licence claims. */
|
||||
async function connect(licenceKey, adminContact) {
|
||||
if (!require('./publicKey').hubPublicKey()) {
|
||||
throw new HubError('This version of Hope Events has no Hub key built in, so it can\'t check licences yet.', 'NO_PUBLIC_KEY', 500);
|
||||
}
|
||||
const install = await report.installInfo();
|
||||
if (!/^https?:\/\//.test(install.url)) {
|
||||
throw new HubError('Set the site\'s web address first (FRONTEND_URL on the server, or Site settings → Organisation).', 'NO_SITE_URL', 400);
|
||||
}
|
||||
const data = await post('/api/installs/register', {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
licenceKey: String(licenceKey || '').trim(),
|
||||
install: { ...install, adminContact: adminContact ? { name: adminContact.name || null, email: adminContact.email || null } : null },
|
||||
});
|
||||
const claims = verifyToken(data.licence);
|
||||
if (!claims || claims.installId !== data.installId) {
|
||||
throw new HubError('The Hub answered with a licence this install can\'t verify. Is HUB_URL pointing at the right Hub?', 'BAD_LICENCE', 502);
|
||||
}
|
||||
await save({
|
||||
hub_install_id: data.installId,
|
||||
hub_install_secret: encrypt(data.installSecret),
|
||||
hub_licence: data.licence,
|
||||
hub_last_heartbeat_at: new Date().toISOString(),
|
||||
hub_last_error: null,
|
||||
hub_latest_version: data.latestVersion || null,
|
||||
});
|
||||
return claims;
|
||||
}
|
||||
|
||||
/** Send a heartbeat (the `hub-heartbeat` job). Skips quietly when not connected. */
|
||||
async function heartbeat() {
|
||||
const s = await read(['hub_install_id', 'hub_install_secret']);
|
||||
if (!s.hub_install_id || !s.hub_install_secret) return { skipped: 'not connected' };
|
||||
const secret = decrypt(s.hub_install_secret);
|
||||
const body = { protocolVersion: PROTOCOL_VERSION, version: report.APP_VERSION, health: await report.health(), stats: await report.stats() };
|
||||
try {
|
||||
const data = await post('/api/installs/heartbeat', body, raw => {
|
||||
const timestamp = new Date().toISOString();
|
||||
return {
|
||||
'X-Hub-Install': s.hub_install_id,
|
||||
'X-Hub-Timestamp': timestamp,
|
||||
'X-Hub-Signature': crypto.createHmac('sha256', secret).update(`${timestamp}.${raw}`).digest('hex'),
|
||||
};
|
||||
});
|
||||
const claims = verifyToken(data.licence);
|
||||
await save({
|
||||
// Keep the old token if the new one doesn't check out (never accept an unverified one).
|
||||
...(claims && claims.installId === s.hub_install_id ? { hub_licence: data.licence } : {}),
|
||||
hub_last_heartbeat_at: new Date().toISOString(),
|
||||
hub_last_error: null,
|
||||
hub_latest_version: data.latestVersion || null,
|
||||
});
|
||||
return { ok: true, status: claims?.status || null };
|
||||
} catch (e) {
|
||||
await save({ hub_last_error: `${new Date().toISOString()} ${e.code || ''} ${e.message}`.slice(0, 500) });
|
||||
// The Hub doesn't know this install any more (licence released): drop the connection and the
|
||||
// licence, so the site is unlicensed (read-only) and shows "Connect" until a key is entered.
|
||||
if (e.code === 'INSTALL_NOT_FOUND') {
|
||||
await save({ hub_install_id: null, hub_install_secret: null, hub_licence: null });
|
||||
return { ok: false, reconnect: true };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A heartbeat soon (on startup, and after connecting), so a licence change on the Hub shows
|
||||
* without waiting for the next 15-minute run. Never throws; skipped when the job is switched off.
|
||||
*/
|
||||
let soonTimer = null;
|
||||
function heartbeatSoon(delayMs = 30 * 1000) {
|
||||
if (String(process.env.JOBS_ENABLED || 'true').toLowerCase() === 'false') return;
|
||||
if (String(process.env.HUB_HEARTBEAT_ENABLED || 'true').toLowerCase() === 'false') return;
|
||||
clearTimeout(soonTimer);
|
||||
soonTimer = setTimeout(() => {
|
||||
heartbeat().catch(e => console.warn('[hub] Heartbeat failed:', e?.message || e));
|
||||
}, delayMs);
|
||||
soonTimer.unref?.();
|
||||
}
|
||||
|
||||
module.exports = { PROTOCOL_VERSION, HUB_KEYS, hubUrl, HubError, connect, heartbeat, heartbeatSoon };
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Licence checks (docs/hub-protocol.md §4). The Hub signs a licence token with its private
|
||||
* key; this install checks it with the public key and works out its state, offline:
|
||||
*
|
||||
* active — everything works
|
||||
* grace — past the end date for up to graceDays: works, site admins see a banner
|
||||
* suspended — suspended by the Hub, or grace ran out: read-only (no new events,
|
||||
* registrations or payments); tickets still scan, data can be exported
|
||||
* revoked — like suspended, with a permanent banner
|
||||
* unlicensed — not connected to the Hub (never, or the Hub released this install): read-only,
|
||||
* like suspended, until a licence key is connected
|
||||
*
|
||||
* LICENCE_CHECK_DISABLED=true turns read-only off outside production (the end-to-end tests).
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { hubPublicKey } = require('./publicKey');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const READ_ONLY_STATES = new Set(['suspended', 'revoked', 'unlicensed']);
|
||||
const CACHE_MS = 30 * 1000;
|
||||
|
||||
let publicKeyObject = null;
|
||||
let publicKeyPem = null;
|
||||
function publicKey() {
|
||||
const pem = hubPublicKey();
|
||||
if (!pem) return null;
|
||||
if (pem !== publicKeyPem) {
|
||||
try {
|
||||
publicKeyObject = crypto.createPublicKey(pem);
|
||||
} catch {
|
||||
throw new Error("The Hub public key can't be read — check HUB_PUBLIC_KEY in backend/.env (copy it from the Hub's Settings page).");
|
||||
}
|
||||
publicKeyPem = pem;
|
||||
}
|
||||
return publicKeyObject;
|
||||
}
|
||||
|
||||
/** The token's claims if the Hub signed it, else null. */
|
||||
function verifyToken(token, key = publicKey()) {
|
||||
if (!key) return null;
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length !== 3 || parts[0] !== 'hel1') return null;
|
||||
try {
|
||||
const ok = crypto.verify(null, Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], 'base64url'));
|
||||
return ok ? JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The state for verified claims (or null = never connected). Pure, for tests.
|
||||
* Returns { state, readOnly, endsAt, graceEndsAt, daysLeft }.
|
||||
*/
|
||||
function stateFor(claims, installId, now = new Date()) {
|
||||
if (!claims || (installId && claims.installId !== installId)) {
|
||||
return { state: 'unlicensed', readOnly: true, endsAt: null, graceEndsAt: null, daysLeft: null };
|
||||
}
|
||||
const ends = [claims.licenceExpiresAt, claims.validUntil].filter(Boolean).map(d => new Date(d));
|
||||
const endsAt = ends.length ? new Date(Math.min(...ends)) : null;
|
||||
const graceEndsAt = endsAt ? new Date(endsAt.getTime() + (claims.graceDays || 0) * DAY) : null;
|
||||
let state;
|
||||
if (claims.status === 'revoked') state = 'revoked';
|
||||
else if (claims.status === 'suspended') state = 'suspended';
|
||||
else if (!endsAt || now < endsAt) state = 'active';
|
||||
else if (now < graceEndsAt) state = 'grace';
|
||||
else state = 'suspended';
|
||||
const daysLeft = state === 'grace' ? Math.max(0, Math.ceil((graceEndsAt - now) / DAY)) : null;
|
||||
return { state, readOnly: READ_ONLY_STATES.has(state), endsAt, graceEndsAt, daysLeft, expired: state === 'suspended' && claims.status === 'active' };
|
||||
}
|
||||
|
||||
function checkDisabled() {
|
||||
return process.env.NODE_ENV !== 'production' && String(process.env.LICENCE_CHECK_DISABLED || '').toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
let cache = null;
|
||||
|
||||
/** This install's licence: claims, state and Hub connection details. Cached for 30 s. */
|
||||
async function licenceStatus({ fresh = false } = {}) {
|
||||
if (!fresh && cache && Date.now() - cache.at < CACHE_MS) return cache.value;
|
||||
const rows = await prisma.appSetting.findMany({ where: { key: { in: ['hub_install_id', 'hub_licence', 'hub_last_heartbeat_at', 'hub_last_error', 'hub_latest_version'] } } });
|
||||
const s = Object.fromEntries(rows.map(r => [r.key, r.value]));
|
||||
const claims = verifyToken(s.hub_licence);
|
||||
const value = {
|
||||
keyConfigured: !!publicKey(),
|
||||
connected: !!s.hub_install_id,
|
||||
installId: s.hub_install_id || null,
|
||||
claims: claims && claims.installId === s.hub_install_id ? claims : null,
|
||||
...stateFor(claims, s.hub_install_id || null),
|
||||
...(checkDisabled() ? { readOnly: false } : {}),
|
||||
lastHeartbeatAt: s.hub_last_heartbeat_at || null,
|
||||
lastError: s.hub_last_error || null,
|
||||
latestVersion: s.hub_latest_version || null,
|
||||
};
|
||||
cache = { at: Date.now(), value };
|
||||
return value;
|
||||
}
|
||||
|
||||
function clearLicenceCache() {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
module.exports = { verifyToken, stateFor, licenceStatus, clearLicenceCache, READ_ONLY_STATES };
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The Hope Events Hub's public licence key (Ed25519). Installs check every licence token with
|
||||
* it, so a licence can't be faked by editing the database. The matching private key exists
|
||||
* only on the Hub server (events-hub repo, `npm run generate-keys`).
|
||||
*
|
||||
* Set PRODUCTION_PUBLIC_KEY to the key the production Hub prints, before the release that ships
|
||||
* the Hub connection. HUB_PUBLIC_KEY (env) overrides it — for a local Hub while developing.
|
||||
*/
|
||||
|
||||
const PRODUCTION_PUBLIC_KEY = null;
|
||||
|
||||
/**
|
||||
* A public key as proper PEM, however it was pasted: with real line breaks, with "\n", all on
|
||||
* one line ("-----BEGIN PUBLIC KEY-----MCow…-----END PUBLIC KEY-----"), or just the base64.
|
||||
*/
|
||||
function normalisePem(input) {
|
||||
const text = String(input || '').replace(/\\n/g, '\n').trim();
|
||||
if (!text) return null;
|
||||
const body = text
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/, '')
|
||||
.replace(/-----END PUBLIC KEY-----/, '')
|
||||
.replace(/\s+/g, '');
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) return text; // not base64 — let the caller's parse fail clearly
|
||||
const lines = body.match(/.{1,64}/g).join('\n');
|
||||
return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----\n`;
|
||||
}
|
||||
|
||||
function hubPublicKey() {
|
||||
return normalisePem(process.env.HUB_PUBLIC_KEY) || PRODUCTION_PUBLIC_KEY;
|
||||
}
|
||||
|
||||
module.exports = { hubPublicKey, normalisePem, PRODUCTION_PUBLIC_KEY };
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Read-only mode while the licence is suspended, revoked or missing (not connected to the Hub):
|
||||
* no new or edited events, and no *new* activity — registrations, tickets added, payments started
|
||||
* or recorded, waitlist places, coupons or bundles.
|
||||
* Everything else keeps working on purpose: scanning tickets for events people already paid for,
|
||||
* viewing and exporting data, refunds and cancellations, privacy requests, logging in, and the
|
||||
* settings needed to renew the licence. Payment webhooks for payments already started also still
|
||||
* arrive (they're mounted before this middleware).
|
||||
*/
|
||||
|
||||
const { licenceStatus } = require('./licence');
|
||||
|
||||
const BLOCKED = [
|
||||
['POST', /^\/api\/events\/?$/],
|
||||
['PUT', /^\/api\/events\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/events\/[^/]+\/options\/?$/],
|
||||
['PUT', /^\/api\/events\/options\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/events\/options\/[^/]+\/variants\/?$/],
|
||||
['PUT', /^\/api\/events\/variants\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/registrations\/?$/],
|
||||
['POST', /^\/api\/registrations\/manual\/?$/],
|
||||
['PUT', /^\/api\/registrations\/[^/]+\/options\/?$/],
|
||||
['POST', /^\/api\/payments\/?$/],
|
||||
['POST', /^\/api\/payments\/(checkout|yoco-checkout)(\/send)?\/?$/],
|
||||
['POST', /^\/api\/payments\/pay\/[^/]+\/?$/],
|
||||
['POST', /^\/api\/waitlist\/?$/],
|
||||
['POST', /^\/api\/coupons\/?$/],
|
||||
['POST', /^\/api\/bundles\/event\/[^/]+\/?$/],
|
||||
];
|
||||
|
||||
function isBlocked(method, path) {
|
||||
return BLOCKED.some(([m, re]) => m === method && re.test(path));
|
||||
}
|
||||
|
||||
async function licenceReadOnly(req, res, next) {
|
||||
if (!isBlocked(req.method, req.path)) return next();
|
||||
try {
|
||||
const status = await licenceStatus();
|
||||
if (!status.readOnly) return next();
|
||||
return res.status(403).json({
|
||||
code: 'LICENCE_READ_ONLY',
|
||||
message: status.state === 'revoked'
|
||||
? "This site's licence has been revoked, so nothing new can be added and events can't be changed. Tickets still scan and all data can still be viewed and exported."
|
||||
: status.state === 'unlicensed'
|
||||
? "This site has no licence, so nothing new can be added and events can't be changed. A site admin can connect a licence key in Site settings → Licence."
|
||||
: "This site's licence is suspended, so nothing new can be added and events can't be changed for now. Tickets still scan and all data can still be viewed and exported. Please contact the site's administrator.",
|
||||
});
|
||||
} catch {
|
||||
// Never let a licence check failure take the site down.
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { licenceReadOnly, isBlocked, BLOCKED };
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* What this install tells the Hub (docs/hub-protocol.md §3): health, and aggregate counts —
|
||||
* no personal information. Income goes as a range, never an amount.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const prisma = require('../config/db');
|
||||
const errorCounter = require('../utils/errorCounter');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const APP_VERSION = require('../../package.json').version;
|
||||
const STARTED_AT = Date.now();
|
||||
|
||||
const INCOME_BANDS = [
|
||||
[1000, 'R0–R1 000'],
|
||||
[5000, 'R1 001–R5 000'],
|
||||
[20000, 'R5 001–R20 000'],
|
||||
[50000, 'R20 001–R50 000'],
|
||||
[100000, 'R50 001–R100 000'],
|
||||
];
|
||||
|
||||
/** The range an amount falls in: "R5 001–R20 000", or "over R100 000". */
|
||||
function incomeBand(amount) {
|
||||
const rand = Math.max(0, Math.round(Number(amount) || 0));
|
||||
for (const [max, label] of INCOME_BANDS) if (rand <= max) return label;
|
||||
return 'over R100 000';
|
||||
}
|
||||
|
||||
async function postgresVersion() {
|
||||
try {
|
||||
const rows = await prisma.$queryRawUnsafe('SHOW server_version');
|
||||
return String(rows[0]?.server_version || '').split(' ')[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Migrations in the repo that the database hasn't applied. */
|
||||
async function pendingMigrations() {
|
||||
try {
|
||||
const dir = path.join(__dirname, '..', '..', 'prisma', 'migrations');
|
||||
const folders = fs.readdirSync(dir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name);
|
||||
const rows = await prisma.$queryRawUnsafe('SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL');
|
||||
const applied = new Set(rows.map(r => r.migration_name));
|
||||
return folders.filter(f => !applied.has(f)).length;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function disk() {
|
||||
try {
|
||||
const s = await fs.promises.statfs(path.join(__dirname, '..', '..'));
|
||||
return { freeBytes: s.bavail * s.bsize, totalBytes: s.blocks * s.bsize };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function jobsHealth() {
|
||||
try {
|
||||
const { jobStatus } = require('../jobs');
|
||||
const status = await jobStatus(prisma);
|
||||
const since = Date.now() - DAY;
|
||||
const failed = status.jobs.flatMap(j => j.recent).filter(r => r.state === 'failed' && new Date(r.completedOn || r.startedOn || 0).getTime() > since).length;
|
||||
return { mode: status.mode, failedLast24h: failed };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function lastBackupAt() {
|
||||
try {
|
||||
const backups = await require('../utils/backupUtils').listBackups();
|
||||
return backups[0]?.createdAt ? new Date(backups[0].createdAt).toISOString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function health() {
|
||||
let db = true;
|
||||
try { await prisma.$queryRawUnsafe('SELECT 1'); } catch { db = false; }
|
||||
const [jobs, backupAt, diskInfo, pending] = await Promise.all([jobsHealth(), lastBackupAt(), disk(), pendingMigrations()]);
|
||||
return {
|
||||
uptimeSeconds: Math.round((Date.now() - STARTED_AT) / 1000),
|
||||
db,
|
||||
jobs,
|
||||
lastBackupAt: backupAt,
|
||||
disk: diskInfo,
|
||||
errorsLast24h: errorCounter.countLast24h(),
|
||||
pendingMigrations: pending,
|
||||
};
|
||||
}
|
||||
|
||||
async function stats(now = new Date()) {
|
||||
const since = new Date(now.getTime() - 30 * DAY);
|
||||
const realUser = { isActive: true, NOT: [{ email: { endsWith: '@deleted.invalid' } }, { email: { endsWith: '@guest.local' } }] };
|
||||
const [users, activeEvents, upcomingEvents, totalEvents, regs30, regsTotal, tickets30, ticketsTotal, checkIns30, income30, incomeAll, providers] = await Promise.all([
|
||||
prisma.user.count({ where: realUser }),
|
||||
prisma.event.count({ where: { isActive: true, startDate: { lte: now }, endDate: { gte: now } } }),
|
||||
prisma.event.count({ where: { isActive: true, startDate: { gt: now } } }),
|
||||
prisma.event.count(),
|
||||
prisma.registration.count({ where: { createdAt: { gte: since } } }),
|
||||
prisma.registration.count(),
|
||||
prisma.ticket.count({ where: { createdAt: { gte: since } } }),
|
||||
prisma.ticket.count(),
|
||||
prisma.ticketUsage.count({ where: { scannedAt: { gte: since } } }),
|
||||
prisma.payment.aggregate({ where: { createdAt: { gte: since } }, _sum: { amount: true } }),
|
||||
prisma.payment.aggregate({ _sum: { amount: true } }),
|
||||
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
|
||||
]);
|
||||
return {
|
||||
users,
|
||||
events: { active: activeEvents, upcoming: upcomingEvents, total: totalEvents },
|
||||
registrations: { last30d: regs30, total: regsTotal },
|
||||
tickets: { last30d: tickets30, total: ticketsTotal },
|
||||
checkIns: { last30d: checkIns30 },
|
||||
// Messages aren't logged one by one, so there's nothing to count yet.
|
||||
messages: null,
|
||||
paymentProviders: providers,
|
||||
campuses: false,
|
||||
income: { last30d: incomeBand(income30._sum.amount), allTime: incomeBand(incomeAll._sum.amount) },
|
||||
};
|
||||
}
|
||||
|
||||
/** What the install says about itself when registering. */
|
||||
async function installInfo() {
|
||||
const { getSetting } = require('../utils/settingsCache');
|
||||
const [name, url, waConfigured, providers] = await Promise.all([
|
||||
getSetting('org_name', ''),
|
||||
getSetting('app_base_url', process.env.FRONTEND_URL || ''),
|
||||
require('../utils/whatsapp').isConfigured().catch(() => false),
|
||||
require('../payments').availableProviders().then(list => list.map(p => p.id)).catch(() => []),
|
||||
]);
|
||||
return {
|
||||
name: name || 'Hope Events',
|
||||
url: String(url || process.env.FRONTEND_URL || '').split(',')[0].trim().replace(/\/$/, ''),
|
||||
version: APP_VERSION,
|
||||
timezone: process.env.APP_TIMEZONE || 'Africa/Johannesburg',
|
||||
server: { node: process.versions.node, postgres: await postgresVersion(), os: `${os.platform()} ${os.release()}` },
|
||||
features: { whatsapp: !!waConfigured, paymentProviders: providers, campuses: false },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { APP_VERSION, incomeBand, health, stats, installInfo };
|
||||
@@ -156,7 +156,32 @@ app.use('/api/webhooks', webhookRoutes);
|
||||
// Apply JSON body parser for all non-webhook routes
|
||||
app.use(express.json());
|
||||
|
||||
// Count server errors (any 5xx response) for the Hub heartbeat.
|
||||
app.use((req, res, next) => {
|
||||
res.on('finish', () => { if (res.statusCode >= 500) require('./utils/errorCounter').recordError(); });
|
||||
next();
|
||||
});
|
||||
|
||||
// Licence suspended, revoked or missing: no new or edited events, registrations or payments (src/hub/readOnly.js).
|
||||
app.use(require('./hub/readOnly').licenceReadOnly);
|
||||
|
||||
// Health for monitoring (and the Hub): no secrets, no personal information.
|
||||
app.get('/health', async (req, res) => {
|
||||
const report = require('./hub/report');
|
||||
let db = true;
|
||||
try { await prisma.$queryRaw`SELECT 1`; } catch { db = false; }
|
||||
const health = db ? await report.health().catch(() => null) : null;
|
||||
res.status(db ? 200 : 503).json({
|
||||
status: db ? 'ok' : 'error',
|
||||
version: report.APP_VERSION,
|
||||
db,
|
||||
jobs: health?.jobs || null,
|
||||
lastBackupAt: health?.lastBackupAt || null,
|
||||
});
|
||||
});
|
||||
|
||||
// Use routes
|
||||
app.use('/api/hub', require('./routes/hubRoutes'));
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/registrations', registrationRoutes);
|
||||
@@ -886,6 +911,18 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Success', body:[{ eventId:'ev-uuid-...', title:'Camp 2025', totalTickets:50, scanned:38, remaining:12 }]}]},
|
||||
]},
|
||||
|
||||
{ title: 'Hub and licence', base: '/api/hub', endpoints: [
|
||||
{ method:'GET', path:'/health', auth:'public', desc:'Status, version, database, background jobs and last backup (503 if the database is down)',
|
||||
responses:[{ status:200, desc:'Healthy', body:{ status:'ok', version:'1.41.0', db:true, jobs:{ mode:'queue', failedLast24h:0 }, lastBackupAt:'2026-10-26T00:00:12.000Z' }}]},
|
||||
{ method:'GET', path:'/api/hub/status', auth:'admin', desc:'Licence state (active, grace, suspended, revoked, unlicensed), Hub connection, version and update',
|
||||
responses:[{ status:200, desc:'Success', body:{ state:'grace', readOnly:false, daysLeft:9, connected:true, version:'1.41.0', latestVersion:'1.42.0', updateAvailable:true }}]},
|
||||
{ method:'POST', path:'/api/hub/connect', auth:'admin', desc:'Connect to the Hope Events Hub with a licence key (also the /setup licence step)',
|
||||
request:{ body:{ licenceKey:'CEH-7K2M-9QXA-P4RT-WD8C' } },
|
||||
responses:[{ status:200, desc:'Connected (status as above)' }, { status:404, desc:'Unknown key', body:{ code:'LICENCE_NOT_FOUND', message:'That licence key was not found.' }}]},
|
||||
{ method:'POST', path:'/api/hub/heartbeat', auth:'admin', desc:'Check in with the Hub now (picks up licence changes)',
|
||||
responses:[{ status:200, desc:'Status' }, { status:502, desc:'Hub unreachable' }]},
|
||||
]},
|
||||
|
||||
{ title: 'Privacy (POPIA)', base: '/api/privacy', endpoints: [
|
||||
{ method:'GET', path:'/api/privacy/me', auth:'user', desc:'My news opt-in, its history, and my privacy requests',
|
||||
responses:[{ status:200, desc:'Success', body:{ news:{ email:true, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z', canWhatsApp:false, notificationPreference:'email', history:[{ channel:'email', granted:true, source:'signup', createdAt:'2026-10-23T08:00:00.000Z' }], requests:[] }}]},
|
||||
@@ -1533,6 +1570,9 @@ app.listen(PORT, () => {
|
||||
// backup, temp cleanup) run on the pg-boss job queue — see src/jobs/index.js.
|
||||
require('./jobs').startJobs().catch(e => console.warn('[jobs] Not started:', e?.message || e));
|
||||
|
||||
// Check in with the Hub 30 s after starting, so the licence is fresh without waiting 15 minutes.
|
||||
require('./hub/client').heartbeatSoon();
|
||||
|
||||
// Scheduled emails worker (polling)
|
||||
try {
|
||||
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
|
||||
@@ -56,6 +56,10 @@ const JOBS = [
|
||||
name: 'privacy', label: 'Privacy requests: remind admins of requests due soon', cron: '0 8 * * *', env: 'PRIVACY_JOBS_ENABLED',
|
||||
run: () => require('../utils/privacy').remindDueRequests(new Date()),
|
||||
},
|
||||
{
|
||||
name: 'hub-heartbeat', label: 'Hope Events Hub: heartbeat and licence refresh', cron: '*/15 * * * *', env: 'HUB_HEARTBEAT_ENABLED',
|
||||
run: () => require('../hub/client').heartbeat(),
|
||||
},
|
||||
{
|
||||
name: 'retention', label: 'Retention: delete or anonymise personal information past its retention period', cron: '30 3 * * *', env: 'RETENTION_JOBS_ENABLED',
|
||||
run: () => require('../utils/retention').runRetention(new Date()),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getStatus, connect, heartbeatNow } = require('../controllers/hubController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
// Licence and the Hope Events Hub (docs/hub-protocol.md). Admins only; the setup wizard's
|
||||
// admin token counts, so the licence step can run during /setup.
|
||||
router.get('/status', protect, admin, getStatus);
|
||||
router.post('/connect', protect, admin, connect);
|
||||
router.post('/heartbeat', protect, admin, heartbeatNow);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,17 @@
|
||||
// Server errors (5xx responses) in the last 24 hours, for the Hub heartbeat. In memory per
|
||||
// process: a restart starts again from zero, which is fine for spotting a spike.
|
||||
const WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_KEPT = 10000;
|
||||
let times = [];
|
||||
|
||||
function recordError(now = Date.now()) {
|
||||
times.push(now);
|
||||
if (times.length > MAX_KEPT) times = times.slice(-MAX_KEPT);
|
||||
}
|
||||
|
||||
function countLast24h(now = Date.now()) {
|
||||
times = times.filter(t => now - t < WINDOW_MS);
|
||||
return times.length;
|
||||
}
|
||||
|
||||
module.exports = { recordError, countLast24h };
|
||||
@@ -0,0 +1,133 @@
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { verifyToken, stateFor } = require('../src/hub/licence');
|
||||
const { incomeBand } = require('../src/hub/report');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
const { compareVersions } = require('../src/controllers/hubController');
|
||||
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
|
||||
function sign(payload, key = privateKey) {
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
return `hel1.${body}.${crypto.sign(null, Buffer.from(`hel1.${body}`), key).toString('base64url')}`;
|
||||
}
|
||||
|
||||
const NOW = new Date('2026-10-26T10:00:00Z');
|
||||
const base = {
|
||||
v: 1, installId: 'inst_1', status: 'active', graceDays: 14,
|
||||
licenceExpiresAt: '2027-10-31T21:59:59.000Z',
|
||||
issuedAt: '2026-10-26T09:45:00.000Z', validUntil: '2026-11-25T09:45:00.000Z',
|
||||
};
|
||||
|
||||
describe('licence tokens', () => {
|
||||
test('a token the Hub signed verifies; edited or foreign ones do not', () => {
|
||||
expect(verifyToken(sign(base), publicKey)).toMatchObject({ installId: 'inst_1', status: 'active' });
|
||||
const [p, , s] = sign(base).split('.');
|
||||
const edited = `${p}.${Buffer.from(JSON.stringify({ ...base, status: 'active', licenceExpiresAt: '2099-01-01' })).toString('base64url')}.${s}`;
|
||||
expect(verifyToken(edited, publicKey)).toBeNull();
|
||||
const other = crypto.generateKeyPairSync('ed25519');
|
||||
expect(verifyToken(sign(base, other.privateKey), publicKey)).toBeNull();
|
||||
expect(verifyToken('', publicKey)).toBeNull();
|
||||
expect(verifyToken(sign(base), null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('licence states', () => {
|
||||
test('never connected → unlicensed, read-only like suspended', () => {
|
||||
expect(stateFor(null, null, NOW)).toMatchObject({ state: 'unlicensed', readOnly: true });
|
||||
});
|
||||
|
||||
test('a token for another install counts as none', () => {
|
||||
expect(stateFor(base, 'inst_other', NOW).state).toBe('unlicensed');
|
||||
});
|
||||
|
||||
test('active until the earlier of the licence end and validUntil', () => {
|
||||
expect(stateFor(base, 'inst_1', NOW)).toMatchObject({ state: 'active', readOnly: false });
|
||||
expect(stateFor(base, 'inst_1', NOW).endsAt.toISOString()).toBe('2026-11-25T09:45:00.000Z');
|
||||
});
|
||||
|
||||
test('grace after the end date for graceDays, with days left — then read-only', () => {
|
||||
const expired = { ...base, licenceExpiresAt: '2026-10-20T00:00:00.000Z' };
|
||||
expect(stateFor(expired, 'inst_1', NOW)).toMatchObject({ state: 'grace', readOnly: false, daysLeft: 8 });
|
||||
expect(stateFor(expired, 'inst_1', new Date('2026-11-05T00:00:00Z'))).toMatchObject({ state: 'suspended', readOnly: true, expired: true });
|
||||
});
|
||||
|
||||
test('Hub unreachable: still active for 30 days, then grace, then read-only', () => {
|
||||
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-11-20T00:00:00Z')).state).toBe('active');
|
||||
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-12-01T00:00:00Z')).state).toBe('grace');
|
||||
expect(stateFor({ ...base, licenceExpiresAt: null }, 'inst_1', new Date('2026-12-15T00:00:00Z')).state).toBe('suspended');
|
||||
});
|
||||
|
||||
test('suspended and revoked are read-only straight away', () => {
|
||||
expect(stateFor({ ...base, status: 'suspended' }, 'inst_1', NOW)).toMatchObject({ state: 'suspended', readOnly: true });
|
||||
expect(stateFor({ ...base, status: 'revoked' }, 'inst_1', NOW)).toMatchObject({ state: 'revoked', readOnly: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('heartbeat report', () => {
|
||||
test('income is sent as a band, never an amount', () => {
|
||||
expect(incomeBand(0)).toBe('R0–R1 000');
|
||||
expect(incomeBand(1000)).toBe('R0–R1 000');
|
||||
expect(incomeBand(1000.4)).toBe('R0–R1 000');
|
||||
expect(incomeBand(1001)).toBe('R1 001–R5 000');
|
||||
expect(incomeBand(19999)).toBe('R5 001–R20 000');
|
||||
expect(incomeBand(100000)).toBe('R50 001–R100 000');
|
||||
expect(incomeBand(250000)).toBe('over R100 000');
|
||||
expect(incomeBand(null)).toBe('R0–R1 000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('read-only mode', () => {
|
||||
test.each([
|
||||
['POST', '/api/events'],
|
||||
['PUT', '/api/events/ev_1'],
|
||||
['PUT', '/api/events/options/opt_1'],
|
||||
['PUT', '/api/events/variants/var_1'],
|
||||
['POST', '/api/registrations'],
|
||||
['POST', '/api/registrations/manual'],
|
||||
['PUT', '/api/registrations/abc/options'],
|
||||
['POST', '/api/payments'],
|
||||
['POST', '/api/payments/checkout'],
|
||||
['POST', '/api/payments/pay/chk_1'],
|
||||
['POST', '/api/waitlist'],
|
||||
])('%s %s is blocked', (m, p) => expect(isBlocked(m, p)).toBe(true));
|
||||
|
||||
test.each([
|
||||
['POST', '/api/tickets/scan/QR123'],
|
||||
['GET', '/api/events'],
|
||||
['PUT', '/api/events/ev_1/notify-recipients'],
|
||||
['PUT', '/api/registrations/abc'],
|
||||
['POST', '/api/payments/refund'],
|
||||
['POST', '/api/privacy/me/requests'],
|
||||
['POST', '/api/hub/connect'],
|
||||
['POST', '/api/users/login'],
|
||||
['POST', '/api/reports/pdf'],
|
||||
])('%s %s keeps working', (m, p) => expect(isBlocked(m, p)).toBe(false));
|
||||
});
|
||||
|
||||
describe('pasting the Hub public key', () => {
|
||||
const { normalisePem } = require('../src/hub/publicKey');
|
||||
const pem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||
const base64 = pem.replace(/-----(BEGIN|END) PUBLIC KEY-----|\s/g, '');
|
||||
|
||||
test.each([
|
||||
['proper PEM', pem],
|
||||
['with \\n', pem.replace(/\n/g, '\\n')],
|
||||
['all on one line', `-----BEGIN PUBLIC KEY-----${base64}-----END PUBLIC KEY-----`],
|
||||
['just the base64', base64],
|
||||
])('%s works', (label, input) => {
|
||||
expect(() => crypto.createPublicKey(normalisePem(input))).not.toThrow();
|
||||
expect(verifyToken(sign(base), crypto.createPublicKey(normalisePem(input)))).not.toBeNull();
|
||||
});
|
||||
|
||||
test('empty means none', () => {
|
||||
expect(normalisePem('')).toBeNull();
|
||||
expect(normalisePem(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('version comparison for "update available"', () => {
|
||||
expect(compareVersions('1.41.0', '1.41.1')).toBeLessThan(0);
|
||||
expect(compareVersions('1.41.0', '1.41.0')).toBe(0);
|
||||
expect(compareVersions('2.0.0', '1.50.3')).toBeGreaterThan(0);
|
||||
});
|
||||
+28
-3
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-23, after **1.40.0**. The plan itself is in
|
||||
Last updated: 2026-10-27, after **1.41.1** (and Hub 1.0.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).
|
||||
|
||||
@@ -8,6 +8,8 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.41.1 | Licence fixes — no licence (never connected, or the Hub answers `INSTALL_NOT_FOUND`, which now drops the old licence) is read-only like suspended; read-only also blocks editing events/options/variants; heartbeat 30 s after start and after connecting; `LICENCE_CHECK_DISABLED` for e2e |
|
||||
| 1.41.0 | Hope Events Hub connection — licence step in `/setup` and Site settings → Licence, 15-minute heartbeat (health, totals, income bands), licence states checked offline (grace banner for site admins only, read-only when suspended/revoked), `/health`, "update available", `scripts/install.sh`. The Hub itself is `joshua/events-hub` 1.0.0 |
|
||||
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
|
||||
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
|
||||
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
|
||||
@@ -25,8 +27,13 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
## Next up
|
||||
|
||||
1. **1.41.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
`joshua/hope-events-hub`).
|
||||
1. **Go live with the Hub:** generate the production signing key on the Hub server
|
||||
(`npm run generate-keys` in `events-hub/backend`), put the **public** key in
|
||||
`backend/src/hub/publicKey.js` and release it as **1.41.1** — until then installs can't
|
||||
connect (they work as "not connected"). Deploy the Hub to `events-hub.crosscode.co.za`, create
|
||||
the admin (`npm run create-admin`), generate Hope Family Church's licence and connect it (install #1).
|
||||
Try `scripts/install.sh` on a test server.
|
||||
2. **1.42.0 — Multi-campus** (§12).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -150,6 +157,24 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
(`prepare-db.js` → `retention`); `privacy-notice.spec.ts` publishes a version that needs accepting, then accepts it for all
|
||||
seeded users in `afterAll` so later spec files don't meet the prompt.
|
||||
|
||||
## Where the Hub client lives (1.41.0)
|
||||
|
||||
- Protocol: `docs/hub-protocol.md` — the same file is in the Hub repo (`joshua/events-hub`,
|
||||
developed at `../events-hub`); change both together.
|
||||
- `backend/src/hub/`: `publicKey.js` (the production Hub key — **must be set before releasing**;
|
||||
`HUB_PUBLIC_KEY` env overrides it for a local Hub), `licence.js` (verify tokens, work out
|
||||
active / grace / suspended / revoked / unlicensed, cached 30 s), `client.js` (connect, heartbeat;
|
||||
AppSetting `hub_*` keys, which the generic settings endpoints never touch), `report.js` (health,
|
||||
totals, income bands), `readOnly.js` (the routes blocked in read-only mode — add any new
|
||||
"create" or event-edit route there). Read-only = suspended, revoked or unlicensed.
|
||||
- Job `hub-heartbeat` (every 15 min), routes `/api/hub/*`, `/health`.
|
||||
- Frontend: `components/admin/LicenceTab.tsx` (Site settings → Licence; its `ConnectForm` is also on
|
||||
the last `/setup` screen), `components/shared/LicenceBanner.tsx` (site admins only).
|
||||
- Local testing: run the Hub (`../events-hub`, ports 5200/3200), create a licence there, and start
|
||||
Hope Events with `HUB_URL=http://localhost:5200` and `HUB_PUBLIC_KEY` = the key on Hub → Settings.
|
||||
- `scripts/install.sh` — fresh Ubuntu server to a running site (written and syntax-checked; not yet
|
||||
run on a real server).
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
|
||||
+151
-11
@@ -8,7 +8,8 @@ previous round (1.12.0 – 1.23.0). Where to pick up: [HANDOVER.md](./HANDOVER.m
|
||||
|
||||
**Theme for 2.0:** turn a feature-rich church app into a product that can be
|
||||
sold — trustworthy (POPIA), flexible (multi-day events, several payment
|
||||
providers), and maintainable (tests, CI, typed backend, versioned API).
|
||||
providers), maintainable (tests, CI, typed backend, versioned API), and with
|
||||
**AI helpers** as the headline feature (§15).
|
||||
|
||||
**Status (2026-10-23):** all features in this document are approved. Shipped
|
||||
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
|
||||
@@ -21,14 +22,15 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
|
||||
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
|
||||
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
|
||||
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page).
|
||||
**Next: 1.41.0 install/update tooling + the Hope Events Hub (§13).**
|
||||
1.41.0 the Hub connection (licence, heartbeat, read-only mode, `/health`, install script) with Hub 1.0.0 (`joshua/events-hub`).
|
||||
**Next: 1.42.0 Multi-campus.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
| Repo | What |
|
||||
|------|------|
|
||||
| `joshua/hope-events` (this repo) | The church app. Everything in this document except the Hub app itself — including the Hub *client* (licence step, "Connect to Hub", heartbeat, licence checks) |
|
||||
| `joshua/hope-events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
|
||||
| `joshua/events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
|
||||
|
||||
---
|
||||
|
||||
@@ -48,8 +50,10 @@ notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (
|
||||
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
|
||||
13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub)
|
||||
14. [Foundations (platform work)](#14-foundations-platform-work)
|
||||
15. [Suggested build order](#suggested-build-order)
|
||||
16. [Decisions](#decisions-2026-10-22)
|
||||
15. [AI helpers (2.0 headline)](#15-ai-helpers-20-headline)
|
||||
16. [AI: later stages](#16-ai-later-stages)
|
||||
17. [Suggested build order](#suggested-build-order)
|
||||
18. [Decisions](#decisions-2026-10-22)
|
||||
|
||||
---
|
||||
|
||||
@@ -590,7 +594,7 @@ whether they're healthy, basic usage stats, and their **licence** status.
|
||||
|
||||
### Separate app, separate repo, separate server
|
||||
|
||||
- **Its own git repository:** `joshua/hope-events-hub` on the same Gitea
|
||||
- **Its own git repository:** `joshua/events-hub` (https://c-git.crosscode.co.za/joshua/events-hub.git) on the same Gitea
|
||||
server (c-git.crosscode.co.za), with its own semver tags, CHANGELOG and
|
||||
deploys. It is never shipped to churches, so none of its code (customers,
|
||||
licence signing key, other churches' data) ends up on a customer's server.
|
||||
@@ -665,7 +669,7 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
| State | What the install does |
|
||||
|-------|----------------------|
|
||||
| **Active** | Everything works |
|
||||
| **Grace** (expired or Hub unreachable) | Everything works; admins see a banner "Licence needs renewing — X days left". Default 14 days |
|
||||
| **Grace** (expired or Hub unreachable) | Everything works; **only site admins** see a banner "Licence needs renewing — X days left" (on their dashboard pages — never supervisors, staff, users or the public site). Default 14 days |
|
||||
| **Suspended** | **Read-only:** no new events, no new registrations or payments. Existing tickets **still scan**, and admins can still view and **export all data** |
|
||||
| **Revoked** | Same as suspended, with a permanent banner |
|
||||
|
||||
@@ -679,7 +683,8 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
events people already paid for, and get its own data out (POPIA access
|
||||
rights). Suspension blocks *new* activity only.
|
||||
- **One plan for now.** Plan limits (e.g. number of campuses, WhatsApp) can
|
||||
be carried in the token later.
|
||||
be carried in the token later. The first per-licence limit comes with 2.0: the
|
||||
**monthly AI cap in rand** (§15), kept and enforced on the Hub, since the Hub meters AI.
|
||||
|
||||
### Things to be aware of
|
||||
|
||||
@@ -703,6 +708,8 @@ model Install { id, customerId, licenceId, name, url, secretHash, version,
|
||||
model Heartbeat { id, installId, receivedAt, version, health Json, stats Json }
|
||||
// rolled up to daily after 30 days
|
||||
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes
|
||||
// 2.0, AI (§15): Licence gets aiMonthlyCapCents; usage per call, no prompt/response text
|
||||
model AiUsage { id, installId, helper, model, inputTokens, outputTokens, costCents, createdAt }
|
||||
```
|
||||
|
||||
---
|
||||
@@ -724,6 +731,134 @@ keep adding to.
|
||||
|
||||
---
|
||||
|
||||
## 15. AI helpers (2.0 headline)
|
||||
|
||||
Small, one-button AI helpers in the places people already work. **No chat** in 2.0:
|
||||
each helper is one request with a predictable cost, and a person always reviews
|
||||
the result before anything is saved or sent.
|
||||
|
||||
**Who:** Supervisors and admins (the people who create events and read reports).
|
||||
|
||||
### The helpers
|
||||
|
||||
| Helper | Where | What it does |
|
||||
|--------|-------|--------------|
|
||||
| **Draft an event** | Event wizard, first step: "Describe your event" | *"Youth camp 14–16 March at Camp Wartburg, R450, 60 places, kids need consent"* → fills in title, description, dates/sessions, location, ticket options and prices, capacity, consent settings. The supervisor checks every field and saves as usual |
|
||||
| **Summarise this report** | Every report (Reports page) | A few plain-language insights under the report: trends, outliers, comparisons with the previous period |
|
||||
| **Draft a message** | Email / WhatsApp attendees, broadcasts | Turns a short note (*"remind them to bring a sleeping bag, bus leaves 7:30"*) into a friendly message in the right tone and length (shorter for WhatsApp), with placeholders like `{{name}}` |
|
||||
| **Summarise feedback** | Post-event feedback results | Themes, what people liked, what to improve, notable comments |
|
||||
| **Improve this text** | Event description, banner, message boxes | Rewrite for clarity, fix spelling, shorten |
|
||||
|
||||
Every helper shows **"AI suggestion — check before saving"**, can be undone, and
|
||||
never saves, sends or publishes by itself.
|
||||
|
||||
### Provider and model
|
||||
|
||||
- **One provider: Claude (Anthropic), model Claude Haiku 5.5** — the cheapest
|
||||
current model ($0.10 per million input tokens, $0.50 per million output, for
|
||||
prompts under 100K tokens; prices as of 2026-10). Good enough for drafting and
|
||||
summarising; a helper can move to a bigger model later if quality needs it.
|
||||
- **Rough cost:** a helper call is ~2,000–10,000 tokens in and ~500 out, so well under
|
||||
US$0.002 per click — about R1.50–R3 per thousand clicks. Fixed instructions are
|
||||
prompt-cached, which makes repeat calls cheaper still.
|
||||
- Structured output (a JSON schema) for "Draft an event", so the reply maps straight
|
||||
onto the event form and is validated before it's shown.
|
||||
|
||||
### Key, caps and billing — through the Hub
|
||||
|
||||
Joshua provides the API key; churches don't need their own. The key must never sit on
|
||||
an install (one leaked install would leak it for everyone), so:
|
||||
|
||||
- **Installs call AI through the Hub:** `POST /api/ai/complete` on the Hub, signed with
|
||||
the install secret like the heartbeat. The Hub adds the key, calls Claude, and returns
|
||||
the result. Still **install → Hub only** — the Hub never reaches into an install.
|
||||
- **The Hub meters every call** in rand: tokens used × the model's price × an exchange rate
|
||||
Joshua sets in the Hub (with an optional margin). Usage is kept per install per month.
|
||||
- **Monthly rand cap per licence**, set by Joshua in the Hub to match what the church pays
|
||||
(e.g. R0 = AI off, R50, R200). The licence's cap is the first per-plan limit. At 80% the
|
||||
install's admins get a warning email; at 100% the helpers are switched off until the next
|
||||
month (or until Joshua raises the cap). Optional per-user daily limit against runaway use.
|
||||
- **Admins see their usage** in Site settings → AI: used this month (R and number of uses),
|
||||
the cap, a daily chart, use per helper and per person, and an on/off switch per helper.
|
||||
- If the Hub or Claude is unavailable, the buttons say so; nothing else in the app is affected.
|
||||
|
||||
### POPIA
|
||||
|
||||
- Anthropic is added to the **operator register** (and Joshua's Hub, which relays the
|
||||
requests) and to the privacy notice: processing outside South Africa (POPIA s72),
|
||||
for drafting and summarising only.
|
||||
- **Send as little personal information as possible:** reports and feedback are sent as
|
||||
counts, totals and anonymised comments — no names, contact details, medical or
|
||||
children's information. Drafting an event or a message needs none.
|
||||
- The Hub stores **no prompt or response text** — only the install, helper, tokens, cost
|
||||
and time. Check Anthropic's commercial terms on retention and training before launch.
|
||||
- Uses that include personal information (e.g. feedback comments) go in the access log.
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Install: `backend/src/ai/` — one client for the Hub's AI endpoint, one prompt + schema
|
||||
per helper, usage shown from the Hub. Feature flag per helper, all off when the licence
|
||||
has no AI or the Hub isn't connected.
|
||||
- Hub: the AI proxy, metering, caps, warnings, an exchange-rate setting, and a per-install
|
||||
usage view for Joshua (alongside the existing install detail page).
|
||||
- A small **eval set** (example event descriptions, reports, notes, with what a good answer
|
||||
looks like) to check quality when changing the prompt or model.
|
||||
- Help content, audit log entries (AI settings changed), CHANGELOG, FEATURES.md, SETTINGS.md.
|
||||
|
||||
---
|
||||
|
||||
## 16. AI: later stages
|
||||
|
||||
Ideas for after 2.0, in rough order. **Not tied to versions** — each is decided when it's
|
||||
picked up, and all of them reuse the Hub proxy, metering and caps from §15.
|
||||
|
||||
### Ask AI (read-only assistant)
|
||||
|
||||
A chat panel for supervisors and admins: *"How many paid for Camp compared with last
|
||||
year?"*, *"Which events this month are under half full?"*, *"Who hasn't paid yet for
|
||||
Men's Breakfast?"*.
|
||||
|
||||
- The AI can only call **tools the app defines** — the existing stats and report queries,
|
||||
"find an event", "registrations by week" — never its own SQL. It runs with the
|
||||
logged-in person's permissions, so it sees nothing they couldn't.
|
||||
- Answers show the numbers they're based on and link to the matching report.
|
||||
- Costs more per question than a button (several tool calls), so it counts against the same
|
||||
cap; may need a bigger model for harder questions.
|
||||
|
||||
### Help and navigation assistant
|
||||
|
||||
*"How do I add a coupon?"*, *"Where do I see who checked in?"* → a short answer from the
|
||||
in-app help content (`frontend/src/content/help/`) with a link straight to the right page.
|
||||
No personal information involved, so it's cheap and simple — could also be offered to
|
||||
ordinary users.
|
||||
|
||||
### Actions with confirmation
|
||||
|
||||
The Ask assistant can also *do* things — create an event draft, schedule a reminder, offer
|
||||
waitlist places, record a cash payment — always as **"here's what I'll do → Confirm"**, with
|
||||
each confirmed action in the audit log.
|
||||
|
||||
### More helpers
|
||||
|
||||
Smaller one-button additions as they come up: suggest an event's price tiers from past
|
||||
events, write a post-event thank-you, draft certificate wording, translate a message into
|
||||
Afrikaans (ties in with §10), flag unusual payments for the cashup.
|
||||
|
||||
### Self-hosted AI (long term)
|
||||
|
||||
Run an open-weight model on Joshua's own GPU server instead of a paid API, once usage and
|
||||
income justify the hardware:
|
||||
|
||||
- Because every install already talks to the **Hub's** AI endpoint, moving to self-hosting
|
||||
is a change on the Hub only — the Hub gets a provider adapter (Claude now, self-hosted
|
||||
later, or both: simple helpers self-hosted, harder questions on Claude).
|
||||
- **POPIA bonus:** the data can stay in South Africa — no cross-border transfer.
|
||||
- **When it makes sense:** compare the monthly API bill with the cost of a GPU server
|
||||
(hardware or cloud rental, power, upkeep), and check the self-hosted model against the
|
||||
eval set from §15 before switching any helper over.
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Version | Features | Why |
|
||||
@@ -734,13 +869,13 @@ keep adding to.
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.41.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 6 | 1.41.0 ✅ + Hub v1 ✅ | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.42.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.43.0 – 1.44.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.45.0 – 1.47.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.48.0 – 1.50.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.51.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
|
||||
@@ -761,7 +896,7 @@ feature.
|
||||
| 6 | Selling model | **One install per church** — data separation, any server. No multi-tenant. **Multi-campus** inside an install is in scope |
|
||||
| — | Pro-rata rounding | Nearest R10 |
|
||||
| — | Multi-campus | In scope (§12), inside one install |
|
||||
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
|
||||
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
|
||||
| 7 | Payment provider order | Yoco · PayFast. Joshua can supply documentation links |
|
||||
| — | SnapScan, Zapper | Dropped (2026-10-23): Yoco and PayFast are enough. Later versions moved up by two |
|
||||
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
|
||||
@@ -771,8 +906,13 @@ feature.
|
||||
| — | Hub domain | `events-hub.crosscode.co.za` (2026-10-23) |
|
||||
| — | Licence keys | Generated by the Hub when Joshua clicks "Generate" on a customer (2026-10-23) |
|
||||
| — | Grace period / suspension | 14-day grace; suspended = read-only (tickets still scan, data export still works) (2026-10-23) |
|
||||
| — | Grace banner, Hub repo, Hub development | The grace-period banner shows **only to site admins**. The Hub repo is `joshua/events-hub` (https://c-git.crosscode.co.za/joshua/events-hub.git); it is developed locally first, then deployed by Joshua (2026-10-24) |
|
||||
| — | Income in the heartbeat | Included, as **ranges** only (R0–R1 000, R1 001–R5 000, …), never exact amounts (2026-10-23) |
|
||||
| — | Licence plans | One plan for now (2026-10-23) |
|
||||
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
|
||||
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
|
||||
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
|
||||
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
|
||||
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
|
||||
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
|
||||
| — | POPIA: deleting an account | A request: the account is deactivated at once, personal info is anonymised when an admin approves. Waits while the person has an open registration for an upcoming event (2026-10-23) |
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Hub protocol (version 1)
|
||||
|
||||
The contract between a **Hope Events install** (the church's site) and the **Hope Events
|
||||
Hub** (`events-hub.crosscode.co.za`). A copy of this file lives in both repositories
|
||||
(`joshua/hope-events` → `docs/hub-protocol.md`, `joshua/events-hub` → `docs/hub-protocol.md`);
|
||||
change both together.
|
||||
|
||||
Principles:
|
||||
|
||||
- **Install → Hub only.** The install always starts the conversation; the Hub never calls
|
||||
into an install. Works behind firewalls, and the Hub has no access to church data.
|
||||
- **No personal information** is ever sent — counts, versions and health only. Income is
|
||||
sent as a range, never an amount.
|
||||
- **A Hub outage never switches a church off.** Licences are signed tokens the install
|
||||
verifies offline with the Hub's public key.
|
||||
- Every request carries `protocolVersion`. The Hub accepts the current and the previous
|
||||
version, so installs waiting to update keep working.
|
||||
|
||||
All bodies are JSON (`Content-Type: application/json`). Times are ISO 8601 UTC strings.
|
||||
Errors are `{ "message": "...", "code": "..." }` with a 4xx/5xx status.
|
||||
|
||||
---
|
||||
|
||||
## 1. Public key
|
||||
|
||||
```
|
||||
GET /api/public-key
|
||||
→ 200 { "protocolVersions": [1], "keyId": "k1", "algorithm": "Ed25519", "publicKey": "-----BEGIN PUBLIC KEY-----\n…" }
|
||||
```
|
||||
|
||||
Installs ship with the Hub's public key built in (and may refresh it from here). The private
|
||||
key exists only on the Hub server.
|
||||
|
||||
## 2. Register an install
|
||||
|
||||
Sent once, at the end of `/setup` (licence step) or from Site settings → "Connect to Hub".
|
||||
|
||||
```
|
||||
POST /api/installs/register
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"licenceKey": "CEH-7K2M-9QXA-P4RT-WD8C",
|
||||
"install": {
|
||||
"name": "Hope Family Church",
|
||||
"url": "https://events.hopefamily.co.za",
|
||||
"adminContact": { "name": "Jane Admin", "email": "jane@hopefamily.co.za" },
|
||||
"version": "1.41.0",
|
||||
"timezone": "Africa/Johannesburg",
|
||||
"server": { "node": "20.18.0", "postgres": "16.4", "os": "linux 6.8" },
|
||||
"features": { "whatsapp": true, "paymentProviders": ["yoco", "payfast"], "campuses": false }
|
||||
}
|
||||
}
|
||||
→ 201 {
|
||||
"installId": "inst_…",
|
||||
"installSecret": "…", // shown once; the install stores it encrypted
|
||||
"licence": "<licence token>",
|
||||
"heartbeatIntervalMinutes": 15,
|
||||
"latestVersion": "1.41.0"
|
||||
}
|
||||
```
|
||||
|
||||
- `404 LICENCE_NOT_FOUND` — unknown key. `403 LICENCE_REVOKED` — revoked licences can't register.
|
||||
- One licence = one install. Registering again **from the same URL** re-registers (new
|
||||
secret, same `installId`). From a different URL → `409 LICENCE_IN_USE` until Joshua releases
|
||||
the licence in the Hub (e.g. after a move to a new domain).
|
||||
- The `adminContact` is the one piece of personal information: a work contact for the
|
||||
church's admin, so Joshua can reach them. It is not used for anything else.
|
||||
- Rate-limited per IP.
|
||||
|
||||
## 3. Heartbeat
|
||||
|
||||
Every `heartbeatIntervalMinutes` (15) from the install's job queue.
|
||||
|
||||
```
|
||||
POST /api/installs/heartbeat
|
||||
X-Hub-Install: inst_…
|
||||
X-Hub-Timestamp: 2026-10-24T08:15:00.000Z
|
||||
X-Hub-Signature: <hex HMAC-SHA256(installSecret, timestamp + "." + rawBody)>
|
||||
{
|
||||
"protocolVersion": 1,
|
||||
"version": "1.41.0",
|
||||
"health": {
|
||||
"uptimeSeconds": 86400,
|
||||
"db": true,
|
||||
"jobs": { "mode": "queue", "failedLast24h": 0 },
|
||||
"lastBackupAt": "2026-10-24T00:00:12.000Z", // null if none
|
||||
"disk": { "freeBytes": 21474836480, "totalBytes": 53687091200 }, // null if unknown
|
||||
"errorsLast24h": 0, // null if not tracked
|
||||
"pendingMigrations": 0
|
||||
},
|
||||
"stats": {
|
||||
"users": 412,
|
||||
"events": { "active": 3, "upcoming": 5, "total": 61 },
|
||||
"registrations": { "last30d": 88, "total": 2310 },
|
||||
"tickets": { "last30d": 140, "total": 3901 },
|
||||
"checkIns": { "last30d": 96 },
|
||||
"messages": { "email30d": 610, "whatsapp30d": 233 },
|
||||
"paymentProviders": ["yoco", "payfast"],
|
||||
"campuses": false,
|
||||
"income": { "last30d": "R5 001–R20 000", "allTime": "over R100 000" }
|
||||
}
|
||||
}
|
||||
→ 200 { "licence": "<fresh licence token>", "latestVersion": "1.42.0", "serverTime": "…" }
|
||||
```
|
||||
|
||||
- The signature covers the exact bytes sent. Requests more than **5 minutes** off the Hub's
|
||||
clock are refused (`401 STALE_REQUEST`); a bad signature is `401 BAD_SIGNATURE`; an unknown or
|
||||
released install is `404 INSTALL_NOT_FOUND` (the install should show "Reconnect to Hub").
|
||||
- **Income bands** (worked out on the install; the amount never leaves it): `R0–R1 000`,
|
||||
`R1 001–R5 000`, `R5 001–R20 000`, `R20 001–R50 000`, `R50 001–R100 000`, `over R100 000`.
|
||||
- The Hub keeps every heartbeat for 30 days, then one per install per day.
|
||||
|
||||
## 4. Licence token
|
||||
|
||||
`hel1.<payload>.<signature>` — `payload` is base64url JSON, `signature` is the base64url
|
||||
Ed25519 signature of the ASCII string `hel1.<payload>`.
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"licenceId": "lic_…",
|
||||
"installId": "inst_…",
|
||||
"customer": "Hope Family Church",
|
||||
"plan": "standard",
|
||||
"status": "active", // active | suspended | revoked
|
||||
"licenceExpiresAt": "2027-10-31T21:59:59.000Z", // null = no end date
|
||||
"graceDays": 14,
|
||||
"issuedAt": "2026-10-24T08:15:00.000Z",
|
||||
"validUntil": "2026-11-23T08:15:00.000Z", // issuedAt + 30 days
|
||||
"limits": { "aiMonthlyCapCents": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
### What the install does with it
|
||||
|
||||
The install keeps the newest valid token (signature checks out, `installId` matches) and works
|
||||
out its state:
|
||||
|
||||
| State | When | What happens |
|
||||
|-------|------|--------------|
|
||||
| **active** | `status` is active and now is before `min(licenceExpiresAt, validUntil)` | Everything works |
|
||||
| **grace** | after that date, for up to `graceDays` | Everything works; **only site admins** see a banner "Licence needs renewing — X days left" |
|
||||
| **suspended** | `status` is suspended, or grace has run out | Read-only: events can't be created or edited; no new registrations or payments. Tickets still scan; admins can view and export all data; privacy requests still work |
|
||||
| **revoked** | `status` is revoked | Same as suspended, with a permanent banner |
|
||||
| **unlicensed** | never connected, or the Hub answered `INSTALL_NOT_FOUND` (the install drops its token) | Same as suspended (events can't be created or edited either); admins see "Connect to Hub" |
|
||||
|
||||
`validUntil` is refreshed by every heartbeat, so a Hub outage only matters after 30 days (then
|
||||
grace starts). Blocking the Hub can't keep a suspended licence active for more than 30 days.
|
||||
|
||||
## 5. Versioning
|
||||
|
||||
- `protocolVersion` goes up only for changes an older install can't handle. Adding optional
|
||||
fields to a heartbeat or a token doesn't need a new version — both sides ignore fields they
|
||||
don't know.
|
||||
- The Hub accepts protocol `N` and `N-1`; anything else is `400 UNSUPPORTED_PROTOCOL`.
|
||||
|
||||
## 6. Later (not in v1)
|
||||
|
||||
- **AI proxy** (Hope Events 2.0): `POST /api/ai/complete`, signed like the heartbeat; the Hub holds
|
||||
the Claude API key, meters each call in rand and enforces `limits.aiMonthlyCapCents`.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -51,6 +51,7 @@ const ACTIONS = [
|
||||
"retention_run",
|
||||
"breach_recorded",
|
||||
"breach_updated",
|
||||
"hub_connected",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
@@ -94,6 +95,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
retention_run: "Retention job deleted/anonymised data",
|
||||
breach_recorded: "Security compromise recorded",
|
||||
breach_updated: "Security compromise updated",
|
||||
hub_connected: "Connected to the Hope Events Hub",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
|
||||
@@ -7,18 +7,19 @@ 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, type LucideIcon } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, KeyRound, type LucideIcon } 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 { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs";
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs" | "licence";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -30,6 +31,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "payments", label: "Payments", icon: CreditCard },
|
||||
{ id: "backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ id: "jobs", label: "Background jobs", icon: Timer },
|
||||
{ id: "licence", label: "Licence", icon: KeyRound },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
@@ -713,6 +715,7 @@ function SiteSettingsPageInner() {
|
||||
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
|
||||
{activeTab === "licence" && <LicenceTab active={activeTab === "licence"} />}
|
||||
</div>
|
||||
|
||||
{activeTab === "branding" && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
|
||||
import { PrivacyPrompts } from "@/components/shared/PrivacyPrompts";
|
||||
import { LicenceBanner } from "@/components/shared/LicenceBanner";
|
||||
|
||||
// The sidebar shows only on these exact routes — each role's dashboard root,
|
||||
// plus Profile & Security and Site Settings (matching the redesign
|
||||
@@ -87,6 +88,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<Navbar />
|
||||
<LicenceBanner />
|
||||
<PrivacyPrompts />
|
||||
<MobileSidebar />
|
||||
<div className="flex-1 flex min-h-0">
|
||||
@@ -103,6 +105,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<LicenceBanner />
|
||||
<PrivacyPrompts />
|
||||
<main className="flex-1 p-6 bg-gray-50">{children}</main>
|
||||
<Footer />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { ConnectForm } from "@/components/admin/LicenceTab";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
@@ -96,6 +97,9 @@ export default function SetupPage() {
|
||||
const [smtpUser, setSmtpUser] = useState("");
|
||||
const [smtpPass, setSmtpPass] = useState("");
|
||||
|
||||
// Done screen — licence (optional)
|
||||
const [licenceConnected, setLicenceConnected] = useState<string | null>(null);
|
||||
|
||||
// Check setup isn't already done
|
||||
useEffect(() => {
|
||||
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
|
||||
@@ -590,6 +594,18 @@ export default function SetupPage() {
|
||||
Email delivery is not configured yet. Set it up under Admin → Site Settings after logging in.
|
||||
</p>
|
||||
)}
|
||||
{/* Licence: connect to the Hope Events Hub now, or later in Site settings → Licence. */}
|
||||
<div className="text-left border rounded-xl p-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Licence</h3>
|
||||
{licenceConnected ? (
|
||||
<p className="text-sm text-green-700">✓ Connected — licensed to {licenceConnected}.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-gray-500">Enter the licence key you received to connect this site to the Hope Events Hub. Until a licence is connected, events can't be created or edited. You can also do this later under Admin → Site Settings → Licence.</p>
|
||||
<ConnectForm token={setupToken} compact onConnected={s => setLicenceConnected(s.licence?.customer || "your organisation")} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="mt-4 px-6 py-2.5 bg-brand-600 hover:bg-brand-700 text-white rounded-lg text-sm font-medium"
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { KeyRound, RefreshCw, ShieldCheck, ShieldAlert, ArrowUpCircle } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export type HubStatus = {
|
||||
state: "active" | "grace" | "suspended" | "revoked" | "unlicensed";
|
||||
readOnly: boolean;
|
||||
daysLeft: number | null;
|
||||
endsAt: string | null;
|
||||
graceEndsAt: string | null;
|
||||
licence: { customer: string; plan: string; status: string; licenceExpiresAt: string | null; graceDays: number; issuedAt: string; validUntil: string } | null;
|
||||
connected: boolean;
|
||||
keyConfigured: boolean;
|
||||
hubUrl: string;
|
||||
lastHeartbeatAt: string | null;
|
||||
lastError: string | null;
|
||||
version: string;
|
||||
latestVersion: string | null;
|
||||
updateAvailable: boolean;
|
||||
};
|
||||
|
||||
const STATE: Record<HubStatus["state"], { label: string; tone: string; text: string }> = {
|
||||
active: { label: "Active", tone: "bg-green-50 text-green-800 border-green-200", text: "Everything works." },
|
||||
grace: { label: "Needs renewing", tone: "bg-amber-50 text-amber-900 border-amber-200", text: "Everything still works for now — renew before the grace period ends." },
|
||||
suspended: { label: "Suspended", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: events can't be created or edited, and no new registrations or payments. Tickets still scan and all data can be exported." },
|
||||
revoked: { label: "Revoked", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only: events can't be created or edited, and no new registrations or payments. Tickets still scan and all data can be exported." },
|
||||
unlicensed: { label: "No licence", tone: "bg-red-50 text-red-800 border-red-200", text: "Read-only until you connect a licence key: events can't be created or edited, and no new registrations or payments." },
|
||||
};
|
||||
|
||||
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
|
||||
|
||||
/** Enter a licence key and connect. Used in Site settings → Licence and the /setup wizard. */
|
||||
export function ConnectForm({ token, onConnected, compact = false }: { token: string | null; onConnected: (s: HubStatus) => void; compact?: boolean }) {
|
||||
const [key, setKey] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onConnected(await apiFetch<HubStatus>("/api/hub/connect", { method: "POST", authToken: token, body: { licenceKey: key } }));
|
||||
setKey("");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Could not connect.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700" htmlFor="licenceKey">Licence key</label>
|
||||
<div className={`flex gap-2 ${compact ? "flex-col sm:flex-row" : ""}`}>
|
||||
<input id="licenceKey" className={`${inputCls} font-mono uppercase`} placeholder="CEH-XXXX-XXXX-XXXX-XXXX" value={key} onChange={e => setKey(e.target.value)} required autoComplete="off" />
|
||||
<button disabled={busy || !key.trim()} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50 whitespace-nowrap">
|
||||
{busy ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-700">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Site settings → Licence: licence state, the Hub connection, and this site's version. */
|
||||
export function LicenceTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [status, setStatus] = useState<HubStatus | 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 { setStatus(await apiFetch<HubStatus>("/api/hub/status", { authToken: token })); } catch (e) { setMsg({ type: "err", text: errorMessage(e) || "Could not load the licence." }); }
|
||||
}, [token]);
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
const checkNow = async () => {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
setStatus(await apiFetch<HubStatus>("/api/hub/heartbeat", { method: "POST", authToken: token }));
|
||||
setMsg({ type: "ok", text: "Checked with the Hub." });
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "The Hub could not be reached." });
|
||||
load();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!status) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
const st = STATE[status.state];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Licence</h2>
|
||||
<p className="text-sm text-gray-500">This site checks in with the Hope Events Hub 30 seconds after starting and every 15 minutes after that: version, health and totals only — never anyone's personal information.</p>
|
||||
</div>
|
||||
|
||||
<div className={`border rounded-xl p-4 ${st.tone}`}>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
{status.state === "active" ? <ShieldCheck className="w-5 h-5" /> : <ShieldAlert className="w-5 h-5" />}
|
||||
{st.label}
|
||||
{status.state === "grace" && status.daysLeft != null && <span className="font-normal">— {status.daysLeft} day{status.daysLeft === 1 ? "" : "s"} left</span>}
|
||||
</div>
|
||||
<p className="text-sm mt-1">{st.text}</p>
|
||||
</div>
|
||||
|
||||
{!status.keyConfigured && (
|
||||
<p className="text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
This version has no Hub key built in yet, so it can't check licences. Update Hope Events, or set HUB_PUBLIC_KEY on the server for testing.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status.licence && (
|
||||
<dl className="grid sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<div><dt className="text-gray-500">Licensed to</dt><dd className="font-medium">{status.licence.customer}</dd></div>
|
||||
<div><dt className="text-gray-500">Plan</dt><dd className="capitalize">{status.licence.plan}</dd></div>
|
||||
<div><dt className="text-gray-500">Licence ends</dt><dd>{status.licence.licenceExpiresAt ? fmt(status.licence.licenceExpiresAt) : "No end date"}</dd></div>
|
||||
<div><dt className="text-gray-500">Grace period</dt><dd>{status.licence.graceDays} days</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div className="border rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 font-medium text-gray-900"><KeyRound className="w-4 h-4" /> Hub connection</div>
|
||||
<dl className="grid sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
<div><dt className="text-gray-500">Hub</dt><dd className="break-all">{status.hubUrl}</dd></div>
|
||||
<div><dt className="text-gray-500">Last check-in</dt><dd>{fmt(status.lastHeartbeatAt)}</dd></div>
|
||||
</dl>
|
||||
{status.lastError && <p className="text-xs text-red-700 break-words">Last problem: {status.lastError}</p>}
|
||||
{status.connected && (
|
||||
<button onClick={checkNow} 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">
|
||||
<RefreshCw className={`w-4 h-4 ${busy ? "animate-spin" : ""}`} /> Check now
|
||||
</button>
|
||||
)}
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-sm text-gray-600 mb-2">{status.connected ? "New licence key, or moved to a new address? Connect again:" : "Enter the licence key you received:"}</p>
|
||||
<ConnectForm token={token} onConnected={s => { setStatus(s); setMsg({ type: "ok", text: "Connected to the Hub." }); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-4 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium text-gray-900 mb-1"><ArrowUpCircle className="w-4 h-4" /> Version</div>
|
||||
<p>This site runs Hope Events <strong>{status.version}</strong>.{status.latestVersion && !status.updateAvailable && " It's up to date."}</p>
|
||||
{status.updateAvailable && (
|
||||
<p className="mt-1 text-brand-700">
|
||||
Version {status.latestVersion} is available. On the server: <code className="bg-gray-100 px-1 rounded">bash scripts/deploy.sh v{status.latestVersion}</code>
|
||||
</p>
|
||||
)}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ShieldAlert, KeyRound, ArrowUpCircle } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import type { HubStatus } from "@/components/admin/LicenceTab";
|
||||
|
||||
/**
|
||||
* Licence and update notices for **site admins only** (never supervisors, staff, users or the
|
||||
* public site): grace period, suspended/revoked, not connected, update available.
|
||||
*/
|
||||
export function LicenceBanner() {
|
||||
const { user, token } = useAuth();
|
||||
const [status, setStatus] = useState<HubStatus | null>(null);
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || !token) return;
|
||||
apiFetch<HubStatus>("/api/hub/status", { authToken: token }).then(setStatus).catch(() => {});
|
||||
}, [isAdmin, token]);
|
||||
|
||||
if (!isAdmin || !status) return null;
|
||||
const link = <Link href="/dashboard/admin/settings?tab=licence" className="font-medium underline">Licence settings</Link>;
|
||||
|
||||
if (status.state === "grace") {
|
||||
return (
|
||||
<div role="status" className="bg-amber-50 border-b border-amber-200 text-amber-900 text-sm px-4 py-2 flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 shrink-0" />
|
||||
<span>Licence needs renewing — {status.daysLeft} day{status.daysLeft === 1 ? "" : "s"} left. {link}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status.state === "suspended" || status.state === "revoked") {
|
||||
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>
|
||||
The licence is {status.state === "revoked" ? "revoked" : "suspended"}: the site is read-only — events can't be created or edited, and no new registrations or payments. Tickets still scan and all data can be exported. {link}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status.state === "unlicensed" && status.readOnly) {
|
||||
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">
|
||||
<KeyRound className="w-4 h-4 shrink-0" />
|
||||
<span>This site has no licence, so it's read-only — events can't be created or edited. Connect your licence key in {link}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (status.updateAvailable) {
|
||||
return (
|
||||
<div role="status" className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm px-4 py-2 flex items-center gap-2">
|
||||
<ArrowUpCircle className="w-4 h-4 shrink-0" />
|
||||
<span>Hope Events {status.latestVersion} is available (this site runs {status.version}). {link}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -139,5 +139,26 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "licence",
|
||||
label: "Licence",
|
||||
icon: KeyRound,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>The site's licence and its connection to the Hope Events Hub. The site checks in every 15 minutes with its version, health and totals — never anyone's personal information.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={KeyRound} title="Connecting" tone="brand">
|
||||
Enter the licence key you received (CEH-XXXX-XXXX-XXXX-XXXX). If the site moves to a new address, ask your provider to release the licence, then connect again.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Timer} title="Grace period" tone="amber">
|
||||
After the licence's end date everything keeps working for the grace period (usually 14 days); only site admins see a reminder.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Scale} title="Read-only" tone="gray">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.1",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.40.0",
|
||||
"version": "1.41.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
@@ -46,6 +46,8 @@ export default defineConfig({
|
||||
FRONTEND_URL: `http://localhost:${WEB_PORT}`,
|
||||
APP_BASE_URL: `http://localhost:${WEB_PORT}`,
|
||||
JOBS_ENABLED: "false",
|
||||
// The test site has no licence; without this it would be read-only.
|
||||
LICENCE_CHECK_DISABLED: "true",
|
||||
SCHEDULED_EMAILS_ENABLED: "false",
|
||||
// The kiosk test signs a supervisor in with a password (no 2FA code by email).
|
||||
TWO_FACTOR_REQUIRED_ROLES: "false",
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs Hope Events on a fresh Ubuntu server (22.04 or 24.04): Node 20, PostgreSQL, PM2,
|
||||
# nginx and an HTTPS certificate, then the app itself. Finish in the browser at /setup.
|
||||
#
|
||||
# Usage (as root, or with sudo):
|
||||
# sudo bash install.sh --domain events.yourchurch.org --email you@yourchurch.org
|
||||
#
|
||||
# Options:
|
||||
# --domain the site's address (required); DNS must already point at this server
|
||||
# --email for the HTTPS certificate (Let's Encrypt) notices (required unless --no-ssl)
|
||||
# --repo git URL (default: https://c-git.crosscode.co.za/joshua/hope-events.git)
|
||||
# --version tag to install, e.g. v1.41.0 (default: the latest main)
|
||||
# --dir where to install (default: /opt/hope-events)
|
||||
# --user Linux user that owns and runs the app (default: hopeevents, created if needed)
|
||||
# --name PM2 / nginx name, unique per install on a server (default: hope-events)
|
||||
# --web-port frontend port (default: 3000) --api-port backend port (default: 5000)
|
||||
# --no-ssl skip the certificate (e.g. behind another proxy that does HTTPS)
|
||||
#
|
||||
# Re-running is safe: existing database credentials, .env files and PM2 config are kept.
|
||||
# Updates after that: `bash scripts/deploy.sh vX.Y.Z` from the install folder.
|
||||
set -Eeuo pipefail
|
||||
|
||||
DOMAIN=""; EMAIL=""; REPO="https://c-git.crosscode.co.za/joshua/hope-events.git"; VERSION="main"
|
||||
DIR="/opt/hope-events"; APP_USER="hopeevents"; NAME="hope-events"; WEB_PORT=3000; API_PORT=5000; SSL=1
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--domain) DOMAIN="$2"; shift 2 ;;
|
||||
--email) EMAIL="$2"; shift 2 ;;
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--version) VERSION="$2"; shift 2 ;;
|
||||
--dir) DIR="$2"; shift 2 ;;
|
||||
--user) APP_USER="$2"; shift 2 ;;
|
||||
--name) NAME="$2"; shift 2 ;;
|
||||
--web-port) WEB_PORT="$2"; shift 2 ;;
|
||||
--api-port) API_PORT="$2"; shift 2 ;;
|
||||
--no-ssl) SSL=0; shift ;;
|
||||
-h|--help) sed -n '2,22p' "$0"; exit 0 ;;
|
||||
*) echo "Unknown option: $1 (see --help)"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
fail() { echo "ERROR: $*" >&2; exit 1; }
|
||||
step() { echo; echo "==> $*"; }
|
||||
[ "$(id -u)" = 0 ] || fail "Run as root (sudo bash install.sh …)."
|
||||
[ -n "$DOMAIN" ] || fail "--domain is required."
|
||||
[ "$SSL" = 0 ] || [ -n "$EMAIL" ] || fail "--email is required for the HTTPS certificate (or use --no-ssl)."
|
||||
[[ "$NAME" =~ ^[a-z0-9-]+$ ]] || fail "--name may only use a-z, 0-9 and -."
|
||||
DB_NAME="$(echo "$NAME" | tr '-' '_')"
|
||||
SCHEME="https"; [ "$SSL" = 1 ] || SCHEME="http"
|
||||
SITE_URL="$SCHEME://$DOMAIN"
|
||||
as_app() { sudo -u "$APP_USER" -H bash -lc "$*"; }
|
||||
random() { openssl rand -base64 48 | tr -d '/+=\n' | head -c "${1:-48}"; }
|
||||
|
||||
step "System packages"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -q
|
||||
apt-get install -y -q curl git nginx postgresql postgresql-client openssl build-essential ca-certificates gnupg
|
||||
[ "$SSL" = 0 ] || apt-get install -y -q certbot python3-certbot-nginx
|
||||
|
||||
step "Node.js 20"
|
||||
if ! command -v node >/dev/null || [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 20 ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y -q nodejs
|
||||
fi
|
||||
node -v
|
||||
command -v pm2 >/dev/null || npm install -g pm2
|
||||
|
||||
step "App user ($APP_USER)"
|
||||
id "$APP_USER" >/dev/null 2>&1 || useradd --create-home --shell /bin/bash "$APP_USER"
|
||||
|
||||
step "Database ($DB_NAME)"
|
||||
CRED_FILE="/root/.${NAME}-db"
|
||||
if [ -f "$CRED_FILE" ]; then
|
||||
DB_PASS="$(cat "$CRED_FILE")"
|
||||
else
|
||||
DB_PASS="$(random 32)"
|
||||
echo "$DB_PASS" > "$CRED_FILE"; chmod 600 "$CRED_FILE"
|
||||
fi
|
||||
sudo -u postgres psql -v ON_ERROR_STOP=1 -q <<SQL
|
||||
DO \$\$ BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$DB_NAME') THEN
|
||||
CREATE ROLE "$DB_NAME" LOGIN PASSWORD '$DB_PASS';
|
||||
ELSE
|
||||
ALTER ROLE "$DB_NAME" WITH LOGIN PASSWORD '$DB_PASS';
|
||||
END IF;
|
||||
END \$\$;
|
||||
SQL
|
||||
sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" | grep -q 1 \
|
||||
|| sudo -u postgres createdb -O "$DB_NAME" "$DB_NAME"
|
||||
DATABASE_URL="postgresql://$DB_NAME:$DB_PASS@localhost:5432/$DB_NAME"
|
||||
|
||||
step "Code ($VERSION → $DIR)"
|
||||
if [ ! -d "$DIR/.git" ]; then
|
||||
mkdir -p "$DIR"; chown "$APP_USER:$APP_USER" "$DIR"
|
||||
as_app "git clone --quiet '$REPO' '$DIR'"
|
||||
fi
|
||||
as_app "cd '$DIR' && git fetch --tags --quiet origin && if [ '$VERSION' = main ]; then git checkout -q --detach origin/main; else git checkout -q '$VERSION'; fi"
|
||||
|
||||
step "Configuration"
|
||||
if [ ! -f "$DIR/backend/.env" ]; then
|
||||
cat > "$DIR/backend/.env" <<ENV
|
||||
NODE_ENV=production
|
||||
PORT=$API_PORT
|
||||
DATABASE_URL=$DATABASE_URL
|
||||
JWT_SECRET=$(random 64)
|
||||
FRONTEND_URL=$SITE_URL
|
||||
APP_BASE_URL=$SITE_URL
|
||||
BACKEND_URL=$SITE_URL
|
||||
TRUST_PROXY=1
|
||||
APP_TIMEZONE=Africa/Johannesburg
|
||||
ENV
|
||||
echo " wrote backend/.env (keep JWT_SECRET: it also encrypts stored secrets)"
|
||||
else
|
||||
echo " backend/.env exists — kept"
|
||||
fi
|
||||
if [ ! -f "$DIR/frontend/.env.production" ]; then
|
||||
echo "NEXT_PUBLIC_API_URL=$SITE_URL" > "$DIR/frontend/.env.production"
|
||||
fi
|
||||
if [ ! -f "$DIR/ecosystem.config.js" ]; then
|
||||
sed -e "s/'hope-events-backend'/'$NAME-backend'/" -e "s/'hope-events-frontend'/'$NAME-frontend'/" -e "s/-p 3000/-p $WEB_PORT/" \
|
||||
"$DIR/ecosystem.config.example.js" > "$DIR/ecosystem.config.js"
|
||||
fi
|
||||
chown "$APP_USER:$APP_USER" "$DIR/backend/.env" "$DIR/frontend/.env.production" "$DIR/ecosystem.config.js"
|
||||
chmod 600 "$DIR/backend/.env"
|
||||
|
||||
step "Install, migrate, build"
|
||||
as_app "cd '$DIR/backend' && npm ci --no-audit --no-fund && npx prisma migrate deploy && npx prisma generate"
|
||||
as_app "cd '$DIR/frontend' && npm ci --no-audit --no-fund && npm run build"
|
||||
|
||||
step "Start with PM2"
|
||||
as_app "cd '$DIR' && pm2 startOrReload ecosystem.config.js && pm2 save"
|
||||
env PATH="$PATH:/usr/bin" pm2 startup systemd -u "$APP_USER" --hp "$(eval echo "~$APP_USER")" >/dev/null
|
||||
|
||||
step "nginx ($DOMAIN)"
|
||||
cat > "/etc/nginx/sites-available/$NAME" <<NGINX
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN;
|
||||
client_max_body_size 25m;
|
||||
|
||||
# The API, uploaded files, health and API docs go to the backend; everything else to the site.
|
||||
location ~ ^/(api|uploads|health|docs)(/|\$) {
|
||||
proxy_pass http://127.0.0.1:$API_PORT;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:$WEB_PORT;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
}
|
||||
}
|
||||
NGINX
|
||||
ln -sf "/etc/nginx/sites-available/$NAME" "/etc/nginx/sites-enabled/$NAME"
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
if [ "$SSL" = 1 ]; then
|
||||
step "HTTPS certificate"
|
||||
certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos -m "$EMAIL" --redirect
|
||||
fi
|
||||
|
||||
step "Checking"
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS "http://127.0.0.1:$API_PORT/health" >/dev/null 2>&1; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
curl -fsS "http://127.0.0.1:$API_PORT/health" && echo || echo " The backend isn't answering yet — check: sudo -u $APP_USER pm2 logs"
|
||||
|
||||
echo
|
||||
echo "Done. Open $SITE_URL/setup to create the admin account, then enter the licence key."
|
||||
echo "Database password: $CRED_FILE. Updates: cd $DIR && sudo -u $APP_USER bash scripts/deploy.sh vX.Y.Z"
|
||||
Reference in New Issue
Block a user