Run background jobs on a pg-boss queue; add e2e tests, CI and deploy script

Timed jobs move from in-process timers to a job registry run by pg-boss:
once per schedule across processes, retries, run history, cron schedules
in the church timezone, and an admin Background jobs page with Run now.
Playwright end-to-end tests run against their own seeded database. A
Gitea Actions workflow runs backend tests, frontend checks and the e2e
tests, and deploys v* tags with scripts/deploy.sh once secrets are set.
Also fixes the at-the-door check-in count for multi-day tickets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 15:49:46 +02:00
co-authored by Claude Opus 5.5
parent 8cb2755493
commit 48660732fe
34 changed files with 1402 additions and 294 deletions
+129
View File
@@ -0,0 +1,129 @@
# Checks every push and pull request: backend tests, frontend lint/typecheck/build, and
# the Playwright end-to-end tests. Pushing a version tag (v1.2.3) also deploys, once the
# DEPLOY_* secrets are set (Repository → Settings → Actions → Secrets).
#
# Needs a Gitea Actions runner with the "ubuntu-latest" label (Docker), e.g. act_runner:
# https://docs.gitea.com/usage/actions/act-runner
name: CI
on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:
env:
NODE_VERSION: "20"
# Throwaway values for the test database and tokens — never production secrets.
JWT_SECRET: ci-only-jwt-secret-not-for-production
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci
E2E_DATABASE_URL: postgresql://postgres:postgres@postgres:5432/events_ci_e2e
jobs:
backend:
name: Backend tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: events_ci
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: backend/package-lock.json
- run: npm ci
working-directory: backend
- name: Migrations apply cleanly
run: npx prisma migrate deploy
working-directory: backend
- run: npm test
working-directory: backend
frontend:
name: Frontend lint, typecheck, build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- run: npx eslint src
working-directory: frontend
- run: npx tsc --noEmit -p .
working-directory: frontend
- run: npm run build
working-directory: frontend
env:
NEXT_PUBLIC_API_URL: http://localhost:5000
NEXT_TELEMETRY_DISABLED: "1"
e2e:
name: End-to-end tests
runs-on: ubuntu-latest
needs: [backend, frontend]
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: events_ci
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: npm ci
- run: npm ci
working-directory: backend
- run: npm ci
working-directory: frontend
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
CI: "true"
- name: Keep the report when tests fail
if: failure()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: |
e2e/report
e2e/test-results
retention-days: 7
deploy:
name: Deploy tagged release
runs-on: ubuntu-latest
needs: [backend, frontend, e2e]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Deploy over SSH (skipped until DEPLOY_HOST is set)
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
TAG: ${{ github.ref_name }}
run: |
if [ -z "$DEPLOY_HOST" ]; then
echo "DEPLOY_HOST isn't set — skipping the deploy."
exit 0
fi
mkdir -p ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new "$DEPLOY_USER@$DEPLOY_HOST" \
"cd '${DEPLOY_PATH:-~/hope-events}' && bash scripts/deploy.sh '$TAG'"
+5
View File
@@ -40,3 +40,8 @@ hope-events-deployment-setup.txt
# runtime data stores (mutated by the running app, not source)
backend/data/scheduled-emails.json
backend/data/banner.json
# end-to-end tests
e2e/.data/
e2e/report/
e2e/test-results/
frontend/.next-e2e/
+16
View File
@@ -7,6 +7,22 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- **Background jobs page** (Admin → Site Settings → Background jobs): every scheduled job with its schedule, next run and last 10 runs (start, finish, result or error), and a "Run now" button (recorded in the audit log).
- **End-to-end tests** with Playwright (`npm run test:e2e`): they start their own backend and frontend against a separate, freshly seeded database and cover the public pages, free and paid registration through payment, ticket, check-in and cashup, multi-day check-in with the attendance register and certificates, creating a multi-session event in the wizard, the self-service kiosk, and the jobs page.
- **CI workflow** (`.gitea/workflows/ci.yml`): backend tests, frontend lint, typecheck and build, and the end-to-end tests on every push and pull request. It runs once a Gitea Actions runner is registered.
- **Deploy script** (`scripts/deploy.sh <tag>`): backs up the database, checks out the tag, installs, migrates, builds and reloads PM2, going back to the previous version if a step fails before the reload. CI runs it over SSH for `v*` tags once the `DEPLOY_*` secrets are set.
### Changed
- Timed background jobs (daily summary, waitlist, feedback, session reminders and certificates, consent, analytics rollup, backup, temp cleanup) now run on a job queue in Postgres (pg-boss) instead of in-process timers. Each runs once per schedule even with more than one server process, failed runs are retried, and a slow run never overlaps the next. Schedules are cron, in `APP_TIMEZONE` (Africa/Johannesburg by default) rather than the server's clock; change one with `JOB_SCHEDULE_<NAME>`, or turn all jobs off with `JOBS_ENABLED=false`. The existing `*_ENABLED` flags still work; the old `*_JOB_INTERVAL_MS` settings are replaced by the schedules. If the queue can't start, the jobs run on timers as before.
- The server lets a running job finish before it stops or restarts.
### Fixed
- At the door, the Check-In tab counted every past session's check-in on a multi-day ticket, so from the second session the ticket showed as fully used. It now counts only today's session.
## [1.33.0] - 2026-10-22
### Added
+27 -10
View File
@@ -529,17 +529,25 @@ falling back to a general guide.
## Background jobs
Timed jobs run on a job queue in Postgres (pg-boss, `backend/src/jobs/index.js`): each
runs once per schedule even with several server processes, every run is recorded,
failed runs are retried twice, and a slow run never overlaps the next. Admin → Site
Settings → Background jobs shows each job's schedule, next run and last 10 runs, with
"Run now". Times are in `APP_TIMEZONE` (default Africa/Johannesburg). Change a schedule
with `JOB_SCHEDULE_<NAME>` (cron); `JOBS_ENABLED=false` turns them all off. If the queue
can't start, the same jobs run on in-process timers.
| Job | Schedule |
|-----|----------|
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
| Daily event summary email | 07:00 daily |
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) |
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) |
| Session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false` to disable) |
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h (not on the queue) |
| `daily-summary` — daily event summary email | 07:00 daily (`DAILY_SUMMARY_ENABLED=false` to disable) |
| `waitlist` — expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false`) |
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_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 |
---
@@ -551,7 +559,16 @@ falling back to a general guide.
set.
- Unhandled promise rejections are logged and don't crash the server.
- Backend status page (`/`) and built-in API docs page (`/docs`).
- Jest tests for early-bird tranche pricing and donation balances.
- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright
end-to-end tests in `e2e/` (`npm run test:e2e`) that start their own backend and
frontend against a separate, freshly seeded database: public pages, free and paid
registration through to check-in and cashup, multi-day check-in, the attendance
register and certificates, the event wizard, the kiosk, and the admin jobs page.
- **CI** — `.gitea/workflows/ci.yml` runs the backend tests, frontend lint/typecheck/
build and the end-to-end tests on every push (needs a Gitea Actions runner). A `v*`
tag also deploys over SSH with `scripts/deploy.sh` once the `DEPLOY_*` secrets are set:
it backs up the database, checks out the tag, migrates, builds and reloads PM2, and
goes back to the previous version if a step fails before the reload.
- Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md).
---
+18
View File
@@ -81,8 +81,26 @@ Documentation: see Frontend README for environment and scripts.
- npm run dev
- Next.js dev server runs at http://localhost:3000 by default; if it conflicts with backend, it will pick another port (e.g., 3001). Ensure NEXT_PUBLIC_API_URL points to the backend port.
## Tests
- Backend unit tests: `cd backend && npm test`
- End-to-end tests (Playwright), from the repo root:
- once: `npm install` and `npx playwright install chromium`
- `npm run test:e2e`
- They start their own backend (port 5100) and frontend (port 3100) against a separate
database — `backend/.env`'s `DATABASE_URL` with `_e2e` added to the name, or
`E2E_DATABASE_URL` — which is dropped, recreated and seeded on every run. Your own dev
servers and database aren't touched. The database user needs permission to create
databases.
- CI: `.gitea/workflows/ci.yml` runs all of the above on every push once a Gitea Actions
runner is registered. Pushing a `v*` tag also deploys with `scripts/deploy.sh` when the
`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY` (and optional `DEPLOY_PATH`) secrets are set.
## Deployment
To update a running install to a release: `bash scripts/deploy.sh v1.34.0` on the server
(backs up the database, migrates, builds, reloads PM2).
### Backend Deployment
1. Provision a PostgreSQL database and set DATABASE_URL.
+20 -3
View File
@@ -29,6 +29,7 @@ Tokens are issued by `POST /api/users/login`.
- [Post-event feedback](#post-event-feedback)
- [Registration funnel](#registration-funnel)
- [Impersonation](#impersonation)
- [Background jobs](#background-jobs)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -286,6 +287,22 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Background jobs
Timed jobs run on a pg-boss queue in Postgres (schema `pgboss`, created on first start) —
see `backend/src/jobs/index.js`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/admin/jobs` | admin | `{ mode: "queue" \| "timers" \| "off", timezone, jobs: [{ name, label, cron, enabled, envFlag, nextRun, recent: [{ state, startedOn, completedOn, output }] }] }` |
| POST | `/api/admin/jobs/:name/run` | admin | Run a job now: `{ queued: true, id }` (or `{ queued: false, output }` in timer mode). Audit-logged as `job_run_manual` |
Env: `JOBS_ENABLED=false` turns every job off; each job's own flag (`WAITLIST_JOB_ENABLED`, …)
turns it off; `JOB_SCHEDULE_<NAME>` (e.g. `JOB_SCHEDULE_DAILY_SUMMARY="0 6 * * *"`) changes its
cron schedule; times are in `APP_TIMEZONE` (default Africa/Johannesburg).
---
## Impersonation
| Method | Path | Auth | Description |
@@ -331,7 +348,7 @@ Data handling:
Notes:
- New field types `rating` (answers `"1"`–`"5"`) and `multiple_choice` (`options`: array of strings). They can be used on attendee forms too.
- Only HMACs of link tokens are stored, and a reminder issues a new link.
- A job every 15 minutes (`FEEDBACK_JOB_ENABLED`, `FEEDBACK_JOB_INTERVAL_MS`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
- A job every 15 minutes (`feedback` job; `FEEDBACK_JOB_ENABLED`, `JOB_SCHEDULE_FEEDBACK`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
## Sponsor a spot
@@ -464,7 +481,7 @@ are for the session running when downloaded; synced scans count for the session
| GET | `/api/attendance/certificates/:id/pdf` | user+ | Download one (its person, the booker, or a supervisor) |
| GET | `/api/attendance/certificates/verify/:code` | public | `{ valid, revoked?, name, event, completedOn, issuedAt }` |
A job every 5 minutes (`SESSION_JOBS_ENABLED`, `SESSION_JOBS_INTERVAL_MS`) sends due session
A job every 5 minutes (`sessions` job; `SESSION_JOBS_ENABLED`, `JOB_SCHEDULE_SESSIONS`) sends due session
reminders (each once; when several are due at once only the closest is sent) and issues
certificates for events with certificates on that ended in the last 14 days.
@@ -488,7 +505,7 @@ Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitl
How offers work:
- Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks.
- Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit.
- A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers.
- A job every 10 minutes (`waitlist` job; `WAITLIST_JOB_ENABLED`, `JOB_SCHEDULE_WAITLIST`) expires unclaimed offers and re-offers.
- Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer.
- Manual registration and adding places to an existing booking are now checked against stock too.
+65 -48
View File
@@ -15,6 +15,7 @@
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
@@ -25,6 +26,7 @@
"node-fetch": "^2.7.0",
"nodemailer": "^10.0.16",
"pdfkit": "^0.17.1",
"pg-boss": "^10.4.2",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"uuid": "^11.1.1"
@@ -1615,9 +1617,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1639,9 +1638,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1663,9 +1659,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1687,9 +1680,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1711,9 +1701,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1735,9 +1722,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2707,9 +2691,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2724,9 +2705,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2741,9 +2719,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2758,9 +2733,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2775,9 +2747,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2792,9 +2761,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2809,9 +2775,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2826,9 +2789,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2843,9 +2803,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2860,9 +2817,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4143,6 +4097,19 @@
"node": ">= 10"
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"deprecated": "v4 is no longer maintained, upgrade to v5",
"license": "MIT",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7134,6 +7101,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -7761,6 +7737,20 @@
}
}
},
"node_modules/pg-boss": {
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-10.4.2.tgz",
"integrity": "sha512-AttEWOtSzn53av8OnCMWEanwRBvjkZCE1y5nLrZnwvkkMnlZ5XpWDpZ7sKI/BYjvi2OVieMX37arD2ACgJ750w==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.9.0",
"pg": "^8.16.3",
"serialize-error": "^8.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
@@ -8526,6 +8516,33 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serialize-error": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
"license": "MIT",
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/serialize-error/node_modules/type-fest": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+2
View File
@@ -23,6 +23,7 @@
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"cron-parser": "^4.9.0",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
@@ -33,6 +34,7 @@
"node-fetch": "^2.7.0",
"nodemailer": "^10.0.16",
"pdfkit": "^0.17.1",
"pg-boss": "^10.4.2",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"uuid": "^11.1.1"
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'job_run_manual';
+1
View File
@@ -554,6 +554,7 @@ enum AdminAuditAction {
attendance_unmarked
certificate_issued
certificate_revoked
job_run_manual
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
+34
View File
@@ -0,0 +1,34 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const jobs = require('../jobs');
// @desc Background jobs: schedule, next run and the last 10 runs of each
// @route GET /api/admin/jobs
// @access Private/Admin
const listJobs = async (req, res) => {
try {
res.json(await jobs.jobStatus(prisma));
} catch (error) {
res.status(500).json({ message: safeErrorMessage(error) });
}
};
// @desc Run a background job now
// @route POST /api/admin/jobs/:name/run
// @access Private/Admin
const runJobNow = async (req, res) => {
try {
const result = await jobs.runNow(req.params.name);
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'job_run_manual',
targetType: 'Job', targetId: req.params.name, metadata: { queued: result.queued }, ip: getClientIp(req),
});
res.json(result);
} catch (error) {
res.status(error.statusCode || 500).json({ message: safeErrorMessage(error) });
}
};
module.exports = { listJobs, runJobNow };
+13 -223
View File
@@ -661,6 +661,8 @@ app.get('/docs', async (req, res) => {
{ title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
{ method:'GET', path:'/api/admin/jobs', auth:'admin', desc:'Background jobs: queue mode, timezone, and each job schedule, next run and last 10 runs' },
{ method:'POST', path:'/api/admin/jobs/:name/run', auth:'admin', desc:'Run a background job now (queued for a worker); audit-logged' },
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
@@ -1445,229 +1447,9 @@ app.listen(PORT, () => {
console.warn('[attachments sync] Not scheduled:', e?.message || e);
}
// Schedule daily summaries at 07:00 local time (configurable)
try {
const enabled = String(process.env.DAILY_SUMMARY_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { sendDailyEventSummaries } = require('./utils/notifications');
function msUntilNext(hour, minute) {
const now = new Date();
const next = new Date(now);
next.setHours(hour, minute, 0, 0);
if (next <= now) {
next.setDate(next.getDate() + 1);
}
return next.getTime() - now.getTime();
}
async function scheduleNext() {
const delay = msUntilNext(7, 0);
setTimeout(async () => {
try {
console.log('[daily summaries] Running daily event summaries...');
await sendDailyEventSummaries(new Date());
console.log('[daily summaries] Completed sending daily event summaries.');
} catch (e) {
console.error('[daily summaries] Failed:', e?.message || e);
} finally {
// Schedule next run
scheduleNext();
}
}, delay);
}
scheduleNext();
console.log('[daily summaries] Scheduler initialized (07:00 local time). Set DAILY_SUMMARY_ENABLED=false to disable.');
} else {
console.log('[daily summaries] Scheduler disabled by env DAILY_SUMMARY_ENABLED=false');
}
} catch (e) {
console.warn('[daily summaries] Not scheduled:', e?.message || e);
}
// Waitlist job — expire unclaimed offers and offer any free places to the next people.
try {
const enabled = String(process.env.WAITLIST_JOB_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runWaitlistJob } = require('./utils/waitlist');
const everyMs = parseInt(process.env.WAITLIST_JOB_INTERVAL_MS || String(10 * 60 * 1000), 10);
setInterval(async () => {
try {
const r = await runWaitlistJob();
if (r.expired || r.offers) console.log(`[waitlist] ${r.expired} event(s) with expired offers, ${r.offers} new offer(s).`);
} catch (e) {
console.warn('[waitlist] Job failed:', e?.message || e);
}
}, everyMs);
console.log(`[waitlist] Job every ${Math.round(everyMs / 60000)} min.`);
} else {
console.log('[waitlist] Job disabled by env WAITLIST_JOB_ENABLED=false');
}
} catch (e) {
console.warn('[waitlist] Not scheduled:', e?.message || e);
}
// Funnel analytics — once a day, roll raw rows older than 90 days into daily totals.
try {
const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { rollupOldEvents } = require('./utils/analytics');
const run = async () => {
try {
const n = await rollupOldEvents();
if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`);
} catch (e) {
console.warn('[analytics] Rollup failed:', e?.message || e);
}
};
setTimeout(run, 5 * 60 * 1000);
setInterval(run, 24 * 60 * 60 * 1000);
console.log('[analytics] Rollup daily.');
} else {
console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false');
}
} catch (e) {
console.warn('[analytics] Not scheduled:', e?.message || e);
}
// Feedback job — send post-event feedback forms at their send time, and the one
// reminder 3 days later.
try {
const enabled = String(process.env.FEEDBACK_JOB_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runFeedbackJob } = require('./utils/feedback');
const everyMs = parseInt(process.env.FEEDBACK_JOB_INTERVAL_MS || String(15 * 60 * 1000), 10);
setInterval(async () => {
try {
const r = await runFeedbackJob();
if (r.sent || r.reminded) console.log(`[feedback] ${r.sent} invite(s), ${r.reminded} reminder(s) sent.`);
} catch (e) {
console.warn('[feedback] Job failed:', e?.message || e);
}
}, everyMs);
console.log(`[feedback] Job every ${Math.round(everyMs / 60000)} min.`);
} else {
console.log('[feedback] Job disabled by env FEEDBACK_JOB_ENABLED=false');
}
} catch (e) {
console.warn('[feedback] Not scheduled:', e?.message || e);
}
// Multi-day events — reminders before each session (when the event has them on), and
// completion certificates once an event with certificates on has ended.
try {
const enabled = String(process.env.SESSION_JOBS_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runSessionReminderJob } = require('./utils/sessionReminders');
const { runCertificateJob } = require('./utils/certificates');
const everyMs = parseInt(process.env.SESSION_JOBS_INTERVAL_MS || String(5 * 60 * 1000), 10);
setInterval(async () => {
try {
const r = await runSessionReminderJob();
if (r.sent) console.log(`[sessions] ${r.sent} reminder(s) sent for ${r.sessions} session(s).`);
} catch (e) {
console.warn('[sessions] Reminder job failed:', e?.message || e);
}
try {
const c = await runCertificateJob();
if (c.issued) console.log(`[certificates] ${c.issued} issued, ${c.sent} sent.`);
} catch (e) {
console.warn('[certificates] Job failed:', e?.message || e);
}
}, everyMs);
console.log(`[sessions] Reminder and certificate job every ${Math.round(everyMs / 60000)} min.`);
} else {
console.log('[sessions] Jobs disabled by env SESSION_JOBS_ENABLED=false');
}
} catch (e) {
console.warn('[sessions] Not scheduled:', e?.message || e);
}
// Daily parental-consent job — reminders for unanswered requests, and purging
// medical/emergency details a set number of days after each event.
try {
const enabled = String(process.env.CONSENT_JOBS_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { sendConsentReminders, purgeConsentMedicalData } = require('./utils/consent');
const runConsentJobs = async () => {
try {
const reminded = await sendConsentReminders(new Date());
const purged = await purgeConsentMedicalData(new Date());
if (reminded || purged) console.log(`[consent] Sent ${reminded} reminder(s), purged medical details on ${purged} request(s).`);
} catch (e) {
console.warn('[consent] Daily job failed:', e?.message || e);
}
};
const scheduleConsent = () => {
const now = new Date();
const next = new Date(now);
next.setHours(9, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => { await runConsentJobs(); scheduleConsent(); }, next.getTime() - now.getTime());
};
scheduleConsent();
console.log('[consent] Scheduler initialized (09:00 local time).');
} else {
console.log('[consent] Scheduler disabled by env CONSENT_JOBS_ENABLED=false');
}
} catch (e) {
console.warn('[consent] Not scheduled:', e?.message || e);
}
// Daily temp-file cleanup — removes files older than 7 days from /temp
try {
const { cleanupTempFiles } = require('./utils/cleanupTemp');
function scheduleTempCleanup() {
const now = new Date();
const next = new Date(now);
next.setHours(3, 0, 0, 0); // Run at 03:00 local time (low-traffic window)
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(() => {
try {
const result = cleanupTempFiles();
if (result.deleted > 0 || result.errors > 0) {
console.log(`[temp cleanup] Deleted ${result.deleted} file(s), ${result.errors} error(s).`);
}
} catch (e) {
console.warn('[temp cleanup] Failed:', e?.message || e);
} finally {
scheduleTempCleanup();
}
}, next.getTime() - now.getTime());
}
scheduleTempCleanup();
console.log('[temp cleanup] Scheduler initialized (03:00 local time, files older than 7 days).');
} catch (e) {
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
}
// Nightly database backup at 02:00 local time (before the 03:00 temp cleanup)
try {
const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runBackup } = require('./utils/backupUtils');
function scheduleNightlyBackup() {
const now = new Date();
const next = new Date(now);
next.setHours(2, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => {
try {
const result = await runBackup();
console.log(`[backup] Completed: ${result.filename}`);
} catch (e) {
console.error('[backup] Failed:', e?.message || e);
} finally {
scheduleNightlyBackup();
}
}, next.getTime() - now.getTime());
}
scheduleNightlyBackup();
console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.');
} else {
console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false');
}
} catch (e) {
console.warn('[backup] Not scheduled:', e?.message || e);
}
// Timed background jobs (daily summary, waitlist, feedback, sessions, consent, analytics,
// 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));
// Scheduled emails worker (polling)
try {
@@ -1729,6 +1511,14 @@ app.listen(PORT, () => {
// Log unhandled promise rejections without killing the server, since a single
// missed .catch() on fire-and-forget notification code (email/WhatsApp sends)
// would otherwise take the whole app down.
// On restart/stop (PM2 sends SIGINT), let a running background job finish first.
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => {
try { await require('./jobs').stopJobs(); } catch { /* exiting anyway */ }
process.exit(0);
});
}
process.on('unhandledRejection', (err) => {
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
console.error(err?.stack || err);
+267
View File
@@ -0,0 +1,267 @@
/**
* Background jobs — one registry, run by pg-boss (a job queue stored in Postgres).
*
* Why a queue instead of setInterval:
* - Schedules live in the database, so a job runs once per schedule even when more than
* one backend process is running (PM2 cluster, two servers on one database).
* - Every run is recorded (state, start/end, result or error), shown on the admin
* Background jobs page, which can also run a job straight away.
* - A run that fails is retried, and a slow run never overlaps the next one.
*
* Each job can be switched off with its env flag (e.g. WAITLIST_JOB_ENABLED=false), and
* its schedule changed with JOB_SCHEDULE_<NAME> (cron, e.g. JOB_SCHEDULE_WAITLIST="*\/5 * * * *").
* JOBS_ENABLED=false turns all of them off (used by the end-to-end tests).
* If the queue can't start (e.g. the database user can't create the pgboss schema), the
* same jobs run on in-process timers instead, as before.
*/
const cronParser = require('cron-parser');
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
const SCHEMA = 'pgboss';
const JOBS = [
{
name: 'daily-summary', label: 'Daily event summary email', cron: '0 7 * * *', env: 'DAILY_SUMMARY_ENABLED',
run: async () => { await require('../utils/notifications').sendDailyEventSummaries(new Date()); return { sent: true }; },
},
{
name: 'waitlist', label: 'Waitlist: expire offers, offer freed places', cron: '*/10 * * * *', env: 'WAITLIST_JOB_ENABLED',
run: () => require('../utils/waitlist').runWaitlistJob(),
},
{
name: 'feedback', label: 'Post-event feedback forms and reminders', cron: '*/15 * * * *', env: 'FEEDBACK_JOB_ENABLED',
run: () => require('../utils/feedback').runFeedbackJob(),
},
{
name: 'sessions', label: 'Session reminders and certificates', cron: '*/5 * * * *', env: 'SESSION_JOBS_ENABLED',
run: async () => {
const reminders = await require('../utils/sessionReminders').runSessionReminderJob();
const certificates = await require('../utils/certificates').runCertificateJob();
return { reminders, certificates };
},
},
{
name: 'consent', label: 'Parental consent reminders and medical-detail deletion', cron: '0 9 * * *', env: 'CONSENT_JOBS_ENABLED',
run: async () => {
const { sendConsentReminders, purgeConsentMedicalData } = require('../utils/consent');
return { reminded: await sendConsentReminders(new Date()), purged: await purgeConsentMedicalData(new Date()) };
},
},
{
name: 'analytics-rollup', label: 'Funnel analytics: roll up visits older than 90 days', cron: '15 4 * * *', env: 'ANALYTICS_ROLLUP_ENABLED',
run: async () => ({ rolledUp: await require('../utils/analytics').rollupOldEvents() }),
},
{
name: 'backup', label: 'Database backup', cron: '0 2 * * *', env: 'BACKUP_ENABLED',
run: async () => ({ file: (await require('../utils/backupUtils').runBackup()).filename }),
},
{
name: 'temp-cleanup', label: 'Delete temp files older than 7 days', cron: '0 3 * * *', env: null,
run: async () => require('../utils/cleanupTemp').cleanupTempFiles(),
},
];
const flag = (name, def = true) => {
const v = process.env[name];
return v === undefined || v === '' ? def : String(v).toLowerCase() !== 'false';
};
/** Whether a job is switched on (JOBS_ENABLED and its own flag). */
function isEnabled(job) {
return flag('JOBS_ENABLED') && (!job.env || flag(job.env));
}
/** A job's cron schedule, with any JOB_SCHEDULE_<NAME> override (checked). */
function cronFor(job) {
const override = process.env[`JOB_SCHEDULE_${job.name.toUpperCase().replace(/-/g, '_')}`];
if (override) {
try {
cronParser.parseExpression(override, { tz: TIMEZONE });
return override;
} catch {
console.warn(`[jobs] Ignoring invalid schedule for ${job.name}: "${override}"`);
}
}
return job.cron;
}
/** When a cron schedule next fires after `from`. */
function nextRun(cron, from = new Date()) {
return cronParser.parseExpression(cron, { tz: TIMEZONE, currentDate: from }).next().toDate();
}
function summarize(result) {
try {
const s = JSON.stringify(result ?? null);
return s.length > 300 ? `${s.slice(0, 300)}…` : s;
} catch {
return '';
}
}
/** True when a job's result says nothing happened (all zeros/false/empty). */
function isNoop(value) {
if (value === null || value === undefined || value === 0 || value === false || value === '') return true;
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.values(value).every(isNoop);
return false;
}
async function runJob(job) {
const started = Date.now();
const result = await job.run();
// Quiet when nothing happened, like the old timers.
if (!isNoop(result)) console.log(`[jobs] ${job.name} done in ${Date.now() - started} ms: ${summarize(result)}`);
return result ?? null;
}
// ─── Runner ───────────────────────────────────────────────────────────────────
let boss = null;
let mode = 'off'; // 'queue' | 'timers' | 'off'
const timers = new Map();
// Timer mode keeps its own short history so the admin page still has something to show.
const timerHistory = new Map();
function startTimers(jobs) {
mode = 'timers';
for (const job of jobs) {
const cron = cronFor(job);
const scheduleNext = () => {
const delay = Math.max(1000, nextRun(cron).getTime() - Date.now());
timers.set(job.name, setTimeout(async () => {
const entry = { state: 'active', startedOn: new Date(), completedOn: null, output: null };
try {
entry.output = await runJob(job);
entry.state = 'completed';
} catch (e) {
entry.state = 'failed';
entry.output = { message: e?.message || String(e) };
console.warn(`[jobs] ${job.name} failed:`, e?.message || e);
} finally {
entry.completedOn = new Date();
timerHistory.set(job.name, [entry, ...(timerHistory.get(job.name) || [])].slice(0, 10));
scheduleNext();
}
}, Math.min(delay, 2 ** 31 - 1)));
};
scheduleNext();
}
console.log(`[jobs] Running ${jobs.length} job(s) on in-process timers.`);
}
/**
* Starts the background jobs. Call once after the server is listening.
* @returns {Promise<'queue'|'timers'|'off'>}
*/
async function startJobs() {
const enabled = JOBS.filter(isEnabled);
if (!flag('JOBS_ENABLED')) {
console.log('[jobs] All background jobs disabled by env JOBS_ENABLED=false');
mode = 'off';
return mode;
}
try {
const PgBoss = require('pg-boss');
boss = new PgBoss({ connectionString: process.env.DATABASE_URL, schema: SCHEMA, application_name: 'hope-events-jobs' });
boss.on('error', (e) => console.warn('[jobs] Queue error:', e?.message || e));
await boss.start();
const existing = new Set((await boss.getQueues()).map(q => q.name));
for (const job of JOBS) {
if (!existing.has(job.name)) {
// stately: at most one waiting and one running, so a slow run never piles up.
await boss.createQueue(job.name, { policy: 'stately', retryLimit: 2, retryDelay: 60, expireInSeconds: 60 * 60 });
}
if (isEnabled(job)) {
await boss.schedule(job.name, cronFor(job), null, { tz: TIMEZONE });
await boss.work(job.name, async () => {
try {
return await runJob(job);
} catch (e) {
console.warn(`[jobs] ${job.name} failed:`, e?.message || e);
throw e; // recorded as failed, and retried
}
});
} else {
await boss.unschedule(job.name).catch(() => {});
}
}
mode = 'queue';
const off = JOBS.filter(j => !isEnabled(j)).map(j => j.name);
console.log(`[jobs] Job queue started: ${enabled.map(j => j.name).join(', ')}${off.length ? ` (off: ${off.join(', ')})` : ''}.`);
} catch (e) {
console.warn('[jobs] Job queue unavailable, falling back to timers:', e?.message || e);
if (boss) { try { await boss.stop({ graceful: false }); } catch { /* ignore */ } }
boss = null;
startTimers(enabled);
}
return mode;
}
async function stopJobs() {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
if (boss) {
await boss.stop({ graceful: true, timeout: 10000 }).catch(() => {});
boss = null;
}
mode = 'off';
}
/** Runs a job now (admin "Run now"). Queue mode: queued for a worker; timer mode: inline. */
async function runNow(name) {
const job = JOBS.find(j => j.name === name);
if (!job) {
const err = new Error('Unknown job');
err.statusCode = 404;
throw err;
}
if (mode === 'queue' && isEnabled(job)) {
const id = await boss.send(job.name, null, { singletonKey: `${job.name}-manual` });
return { queued: true, id };
}
const output = await runJob(job);
const entry = { state: 'completed', startedOn: new Date(), completedOn: new Date(), output };
timerHistory.set(job.name, [entry, ...(timerHistory.get(job.name) || [])].slice(0, 10));
return { queued: false, output };
}
/** Each job with its schedule, next run and recent runs, for the admin page. */
async function jobStatus(prisma) {
const recent = new Map();
if (mode === 'queue') {
const rows = await prisma.$queryRawUnsafe(`
SELECT name, state, created_on AS "createdOn", started_on AS "startedOn", completed_on AS "completedOn", output
FROM (
SELECT name, state, created_on, started_on, completed_on, output,
row_number() OVER (PARTITION BY name ORDER BY created_on DESC) AS rn
FROM (
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.job
UNION ALL
SELECT name, state::text, created_on, started_on, completed_on, output FROM ${SCHEMA}.archive
) all_jobs
WHERE name = ANY($1::text[])
) ranked
WHERE rn <= 10
ORDER BY "createdOn" DESC`, JOBS.map(j => j.name));
for (const r of rows) recent.set(r.name, [...(recent.get(r.name) || []), r]);
} else {
for (const [name, list] of timerHistory) recent.set(name, list);
}
return {
mode,
timezone: TIMEZONE,
jobs: JOBS.map(job => {
const cron = cronFor(job);
const enabled = isEnabled(job);
return {
name: job.name, label: job.label, cron, enabled, envFlag: job.env,
nextRun: enabled && mode !== 'off' ? nextRun(cron) : null,
recent: recent.get(job.name) || [],
};
}),
};
}
module.exports = { JOBS, TIMEZONE, isEnabled, cronFor, nextRun, isNoop, startJobs, stopJobs, runNow, jobStatus };
+3
View File
@@ -2,9 +2,12 @@ const express = require('express');
const router = express.Router();
const { listAuditLog } = require('../controllers/adminAuditController');
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
const { listJobs, runJobNow } = require('../controllers/jobsController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/audit-log', protect, admin, listAuditLog);
router.get('/jobs', protect, admin, listJobs);
router.post('/jobs/:name/run', protect, admin, runJobNow);
router.get('/impersonation-sessions', protect, admin, listSessions);
// Called with the impersonation token itself (which belongs to the viewed user).
router.post('/impersonate/end', protect, endImpersonation);
+54
View File
@@ -0,0 +1,54 @@
const { JOBS, isEnabled, cronFor, nextRun, isNoop } = require('../src/jobs');
const job = (name) => JOBS.find(j => j.name === name);
describe('job registry', () => {
const saved = { ...process.env };
afterEach(() => { process.env = { ...saved }; });
test('every job has a valid cron schedule and a unique name', () => {
const names = new Set();
for (const j of JOBS) {
expect(names.has(j.name)).toBe(false);
names.add(j.name);
expect(() => nextRun(j.cron)).not.toThrow();
}
});
test('a job is on unless its own flag or JOBS_ENABLED is false', () => {
delete process.env.WAITLIST_JOB_ENABLED;
delete process.env.JOBS_ENABLED;
expect(isEnabled(job('waitlist'))).toBe(true);
process.env.WAITLIST_JOB_ENABLED = 'false';
expect(isEnabled(job('waitlist'))).toBe(false);
process.env.WAITLIST_JOB_ENABLED = 'true';
process.env.JOBS_ENABLED = 'false';
expect(isEnabled(job('waitlist'))).toBe(false);
expect(isEnabled(job('temp-cleanup'))).toBe(false);
});
test('JOB_SCHEDULE_<NAME> overrides the schedule; an invalid one is ignored', () => {
process.env.JOB_SCHEDULE_ANALYTICS_ROLLUP = '30 5 * * *';
expect(cronFor(job('analytics-rollup'))).toBe('30 5 * * *');
process.env.JOB_SCHEDULE_ANALYTICS_ROLLUP = 'every tuesday';
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
expect(cronFor(job('analytics-rollup'))).toBe(job('analytics-rollup').cron);
warn.mockRestore();
});
test('daily jobs fire at local (Johannesburg) time', () => {
// 07:00 SAST = 05:00 UTC
const next = nextRun('0 7 * * *', new Date('2026-11-04T06:00:00Z'));
expect(next.toISOString()).toBe('2026-11-05T05:00:00.000Z');
expect(nextRun('0 7 * * *', new Date('2026-11-04T04:00:00Z')).toISOString()).toBe('2026-11-04T05:00:00.000Z');
});
test('isNoop: quiet when nothing happened', () => {
expect(isNoop({ expired: 0, offers: 0 })).toBe(true);
expect(isNoop({ reminders: { sessions: 0, sent: 0 }, certificates: { issued: 0, sent: 0 } })).toBe(true);
expect(isNoop({ deleted: 0, errors: 0 })).toBe(true);
expect(isNoop(null)).toBe(true);
expect(isNoop({ sent: 3 })).toBe(false);
expect(isNoop({ file: 'backup.sql.gz' })).toBe(false);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { test as base, expect, type Page } from "@playwright/test";
import fs from "fs";
import path from "path";
export type Role = "admin" | "supervisor" | "staff" | "user";
export type Seed = {
users: Record<Role, { id: string; name: string; email: string; token: string }>;
events: { free: string; paid: string; course: string };
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
};
export const API = "http://localhost:5100";
// Written by e2e/support/prepare-db.js when the e2e backend starts, so read lazily.
let cached: Seed | null = null;
export function seed(): Seed {
if (!cached) cached = JSON.parse(fs.readFileSync(path.join(__dirname, ".data", "seed.json"), "utf8")) as Seed;
return cached;
}
/** Signs the page in as a seeded user (the token goes where the app keeps it). */
export async function loginAs(page: Page, role: Role) {
await page.addInitScript((token) => {
localStorage.setItem("hope_events_token", token);
}, seed().users[role].token);
}
export const test = base.extend({
page: async ({ page }, use) => {
// Skip the cookie notice so it never covers what the tests click.
await page.addInitScript(() => localStorage.setItem("hope_events_cookie_consent", "declined"));
await use(page);
},
});
/** Calls the e2e API as a seeded user. */
export async function api<T = unknown>(page: Page, role: Role, method: string, url: string, body?: unknown): Promise<T> {
const res = await page.request.fetch(`${API}${url}`, {
method,
headers: { Authorization: `Bearer ${seed().users[role].token}`, "Content-Type": "application/json" },
data: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
if (!res.ok()) throw new Error(`${method} ${url} → ${res.status()}: ${text}`);
return (text ? JSON.parse(text) : null) as T;
}
export { expect };
+28
View File
@@ -0,0 +1,28 @@
import { test, expect, seed } from "./fixtures";
test.describe("public pages", () => {
test("events list shows events, with the multi-day summary", async ({ page }) => {
await page.goto("/events");
await expect(page.getByRole("heading", { name: "All Events" })).toBeVisible();
await expect(page.getByText("E2E Free Evening")).toBeVisible();
const course = page.locator("div.border.rounded-xl", { hasText: "E2E Alpha Course" });
await expect(course.getByText(/5 sessions · /)).toBeVisible();
});
test("a multi-day event page shows the schedule and the late-joiner price", async ({ page }) => {
await page.goto(`/events/${seed().events.course}`);
await expect(page.getByRole("heading", { name: "E2E Alpha Course" })).toBeVisible();
const schedule = page.locator("div.rounded-xl", { has: page.getByRole("heading", { name: "Schedule" }) });
await expect(schedule.getByText("Week 1")).toBeVisible();
await expect(schedule.getByText("Week 5")).toBeVisible();
await expect(schedule.getByText("Next")).toBeVisible();
// Week 2 is in progress, so 3 of 5 are still to come: R300 × 3/5 = R180.
await expect(page.getByText(/3 of 5 sessions are still to come/).filter({ visible: true }).first()).toBeVisible();
await expect(page.getByText("R180.00").filter({ visible: true }).first()).toBeVisible();
});
test("an unknown certificate code is reported as not found", async ({ page }) => {
await page.goto("/certificates/verify/ABCDE-12345");
await expect(page.getByRole("heading", { name: "Certificate not found" })).toBeVisible();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type Registration = { id: string; status: string; eventId: string };
type Ticket = { id: string; qrCode: string; eventId: string };
type CashByUser = { rows: { userId: string | null; cash: { total: number; count: number } }[] };
test.describe("registration → payment → ticket → check-in → cashup", () => {
test("a free event: register and get a ticket straight away", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.free}`);
await expect(page.getByText("E2E Free Evening").first()).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
const tickets = await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets");
expect(tickets.some((t) => t.eventId === seed().events.free)).toBe(true);
});
test("a paid event: pending until paid, then a ticket that checks in once", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.paid}`);
await page.getByRole("button", { name: "+" }).first().click();
await expect(page.getByText("Total: R150.00")).toBeVisible();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
const reg = regs.find((r) => r.eventId === seed().events.paid);
expect(reg?.status).not.toBe("paid");
expect((await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets")).some((t) => t.eventId === seed().events.paid)).toBe(false);
// A supervisor records the cash payment at the desk.
await api(page, "supervisor", "POST", "/api/payments", { amount: 150, method: "cash", registrationId: reg!.id, eventId: seed().events.paid, userId: seed().users.user.id });
const ticket = (await api<Ticket[]>(page, "user", "GET", "/api/tickets/mytickets")).find((t) => t.eventId === seed().events.paid);
expect(ticket).toBeTruthy();
// My Events shows it.
await page.goto("/dashboard/user");
await expect(page.getByText("E2E Paid Conference").first()).toBeVisible();
// Staff check it in; a second scan is refused.
await api(page, "staff", "POST", `/api/tickets/scan/${ticket!.qrCode}`, { qty: 1 });
await expect(api(page, "staff", "POST", `/api/tickets/scan/${ticket!.qrCode}`, { qty: 1 })).rejects.toThrow(/403.*fully used/);
// Cashup: the R150 cash is on the supervisor who took it.
const cash = await api<CashByUser>(page, "supervisor", "GET", `/api/cashups/event/${seed().events.paid}/cash-by-user`);
const mine = cash.rows.find((r) => r.userId === seed().users.supervisor.id);
expect(mine?.cash.total).toBe(150);
expect(mine?.cash.count).toBe(1);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
test.describe("multi-day events", () => {
test("check-in is once per session, and the register and certificates follow", async ({ page }) => {
const { course, events } = seed();
// Scanning today: Week 2.
const scan = await api<{ session: { label: string } }>(page, "staff", "POST", `/api/tickets/scan/${course.qrCode}`, { qty: 1 });
expect(scan.session.label).toBe("Week 2");
await expect(api(page, "staff", "POST", `/api/tickets/scan/${course.qrCode}`, { qty: 1 })).rejects.toThrow(/Already checked in for Week 2/);
// The register shows weeks 1 and 2 ticked.
await loginAs(page, "supervisor");
await page.goto(`/dashboard/supervisor/attendance?eventId=${events.course}`);
const row = page.locator("tr", { hasText: "Thandi Test" });
await expect(row.getByText("2/5")).toBeVisible();
// Tick week 3 by hand (e.g. let in without a scan), then untick it.
const week3 = row.locator("td").nth(3).getByRole("button");
await week3.click();
await expect(row.getByText("3/5")).toBeVisible();
await week3.click();
await expect(row.getByText("2/5")).toBeVisible();
// Two sessions meets the rule (at least 2), so a certificate can be issued now.
await page.getByRole("button", { name: "Issue now" }).click();
await expect(page.getByText("1 certificate issued.")).toBeVisible();
const code = (await row.locator("span.font-mono").innerText()).trim();
expect(code).toMatch(/^[A-Z0-9]{5}-[A-Z0-9]{5}$/);
// Anyone can check it.
await page.goto(`/certificates/verify/${code}`);
await expect(page.getByRole("heading", { name: "Valid certificate" })).toBeVisible();
await expect(page.getByText("Thandi Test")).toBeVisible();
});
test("the attendee sees their certificate on My Events", async ({ page }) => {
await loginAs(page, "user");
await page.goto("/dashboard/user");
await expect(page.getByRole("heading", { name: "My Certificates" })).toBeVisible();
await expect(page.getByText("E2E Alpha Course").first()).toBeVisible();
});
test("the scanner offers today's session for a multi-day event", async ({ page }) => {
await loginAs(page, "staff");
await page.goto("/dashboard/staff/ticket-scanning");
await page.locator("select").first().selectOption({ label: "E2E Alpha Course" });
await expect(page.locator("select", { has: page.locator("option", { hasText: "Today's session" }) })).toBeVisible();
await expect(page.getByText("Today's session (Week 2)")).toBeAttached();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type EventRow = { id: string; title: string; scheduleType?: string; sessions?: { cancelled?: boolean }[] };
test.describe("supervisor tools", () => {
test("create a weekly multi-session event in the wizard", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/events");
await page.getByRole("button", { name: "+ Add Event" }).click();
await expect(page.getByRole("heading", { name: "New Event" })).toBeVisible();
await page.getByPlaceholder("Event title").fill("E2E Wizard Course");
await page.getByRole("button", { name: "Multiple sessions" }).click();
// The repeat pattern defaults to weekly, 18:30–20:30, 5 sessions from today.
await page.getByRole("button", { name: "Create sessions" }).click();
await expect(page.getByText(/^5 sessions — edit any date/)).toBeVisible();
await page.locator('input[type="number"][placeholder="0"]').first().fill("250");
// Through the remaining steps to Create Event.
const create = page.getByRole("button", { name: "Create Event" });
for (let i = 0; i < 15 && !(await create.isVisible()); i++) {
await page.getByRole("button", { name: "Next →" }).click();
}
await create.click();
await expect(page.getByRole("heading", { name: "New Event" })).toBeHidden();
const row = page.locator("li", { hasText: "E2E Wizard Course" });
await expect(row.getByText(/5 sessions · /)).toBeVisible();
const all = await api<EventRow[]>(page, "supervisor", "GET", "/api/events/all");
const created = all.find((e) => e.title === "E2E Wizard Course");
expect(created?.scheduleType).toBe("sessions");
expect(created?.sessions?.filter((s) => !s.cancelled)).toHaveLength(5);
});
test("self-service kiosk: a visitor registers for a free event", async ({ page }) => {
await page.goto("/self-service");
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
await page.locator("select").selectOption(value!);
await page.getByRole("button", { name: "Start Kiosk" }).click();
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
await page.getByPlaceholder("john@example.com").fill("walkin@e2e.test");
await page.getByPlaceholder("+27 82 000 0000").fill("0820000099");
await page.getByPlaceholder("John Smith").fill("Walk In Visitor");
await page.getByRole("button", { name: "+" }).first().click();
await page.getByRole("button", { name: "Register", exact: true }).click();
await expect(page.getByRole("heading", { name: "You're Registered!" })).toBeVisible();
});
test("admin: background jobs page", async ({ page }) => {
await loginAs(page, "admin");
await page.goto("/dashboard/admin/settings?tab=jobs");
await expect(page.getByRole("heading", { name: "Background jobs" })).toBeVisible();
// The e2e backend runs with JOBS_ENABLED=false.
await expect(page.getByText(/All background jobs are switched off/)).toBeVisible();
await expect(page.getByText("Session reminders and certificates")).toBeVisible();
});
});
+16
View File
@@ -0,0 +1,16 @@
// The e2e database: E2E_DATABASE_URL, or backend/.env's DATABASE_URL with "_e2e" added
// to the database name — always separate from the database you develop against.
const path = require('path');
function e2eDatabaseUrl() {
if (process.env.E2E_DATABASE_URL) return process.env.E2E_DATABASE_URL;
const backend = path.join(__dirname, '..', '..', 'backend');
require(require.resolve('dotenv', { paths: [backend] })).config({ path: path.join(backend, '.env'), quiet: true });
if (!process.env.DATABASE_URL) throw new Error('Set E2E_DATABASE_URL (or DATABASE_URL in backend/.env) for the e2e tests.');
const u = new URL(process.env.DATABASE_URL);
const name = u.pathname.replace(/^\//, '');
u.pathname = `/${name.endsWith('_e2e') ? name : `${name}_e2e`}`;
return u.toString();
}
module.exports = { e2eDatabaseUrl };
+137
View File
@@ -0,0 +1,137 @@
/**
* Prepares the end-to-end test database, then exits. Run before the e2e backend starts
* (see playwright.config.ts): drops and recreates the database, applies the migrations,
* and seeds settings, users and events. Writes e2e/.data/seed.json with the ids and
* login tokens the tests use.
*
* Database: E2E_DATABASE_URL, or backend/.env's DATABASE_URL with "_e2e" added to the
* database name. It is always a separate database — never the one you develop against.
*/
const path = require('path');
const fs = require('fs');
const { execSync } = require('child_process');
const root = path.join(__dirname, '..', '..');
const backend = path.join(root, 'backend');
const req = (m) => require(require.resolve(m, { paths: [backend] }));
req('dotenv').config({ path: path.join(backend, '.env') });
const { e2eDatabaseUrl } = require('./db-url');
async function recreateDatabase(url) {
const { Client } = req('pg');
const target = new URL(url);
const dbName = decodeURIComponent(target.pathname.replace(/^\//, ''));
if (!/_e2e$|^e2e|test/i.test(dbName)) throw new Error(`Refusing to reset "${dbName}" — the e2e database name must contain "e2e" or "test".`);
const admin = new URL(url);
admin.pathname = '/postgres';
admin.search = '';
const client = new Client({ connectionString: admin.toString() });
await client.connect();
await client.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
await client.query(`CREATE DATABASE "${dbName}"`);
await client.end();
}
async function seed(url) {
process.env.DATABASE_URL = url;
const prisma = require(path.join(backend, 'src/config/db'));
const { generateToken, hashPassword } = require(path.join(backend, 'src/config/auth'));
const { generateTicketsForRegistration } = require(path.join(backend, 'src/utils/ticketUtils'));
await prisma.appSetting.createMany({
data: [
{ key: 'setup_complete', value: 'true' },
{ key: 'org_name', value: 'E2E Church' },
{ key: 'org_address', value: '1 Test Street, Pretoria' },
],
});
const password = await hashPassword('E2e-password-123');
const mk = (role, name, email, phoneNumber) => prisma.user.create({
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date() },
});
const users = {
admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'),
supervisor: await mk('supervisor', 'Sam Supervisor', 'supervisor@e2e.test', '0820000002'),
staff: await mk('staff', 'Stan Staff', 'staff@e2e.test', '0820000003'),
user: await mk('user', 'Thandi Test', 'user@e2e.test', '0820000004'),
};
const D = 86400000;
const now = Date.now();
const at = (days, hourUtc = 16, minute = 30) => {
const d = new Date(now + days * D);
d.setUTCHours(hourUtc, minute, 0, 0);
return d;
};
const free = await prisma.event.create({
data: {
title: 'E2E Free Evening', description: 'A free evening for the tests.', startDate: at(1), endDate: at(1, 19),
price: 0, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Entry', price: 0, isMainTicket: true }] },
},
});
const paid = await prisma.event.create({
data: {
title: 'E2E Paid Conference', description: 'A paid one-day conference.', startDate: at(3, 7), endDate: at(3, 15),
price: 150, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
eventOptions: { create: [{ name: 'Conference ticket', price: 150, isMainTicket: true }] },
},
});
// Multi-day: week 1 was a week ago, week 2 is in progress now, then three more weeks.
const todayStart = new Date(now - 30 * 60 * 1000);
const sessions = [-7, 0, 7, 14, 21].map((days, i) => {
const startsAt = new Date(todayStart.getTime() + days * D);
return { label: `Week ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 90 * 60 * 1000), order: i };
});
const course = await prisma.event.create({
data: {
title: 'E2E Alpha Course', description: 'Five weekly evenings.', startDate: sessions[0].startsAt, endDate: sessions[4].endsAt,
price: 300, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
scheduleType: 'sessions', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
certificatesEnabled: true,
certificateConfig: { template: 'classic', title: 'Certificate of Completion', body: '{name} completed {event}.', rule: { type: 'count', value: 2 } },
sessions: { create: sessions },
eventOptions: { create: [{ name: 'Course ticket', price: 300, isMainTicket: true }] },
},
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
});
await prisma.event.update({ where: { id: course.id }, data: { lateJoinUntilSessionId: course.sessions[2].id } });
// Thandi is booked on the course and was checked in for week 1.
const reg = await prisma.registration.create({
data: {
userId: users.user.id, eventId: course.id, status: 'paid',
registrationOptions: { create: [{ eventOptionId: course.eventOptions[0].id, quantity: 1, priceSnapshot: 300, tranches: { create: [{ quantity: 1, priceSnapshot: 300 }] } }] },
},
});
await prisma.payment.create({ data: { amount: 300, method: 'cash', userId: users.user.id, registrationId: reg.id, eventId: course.id, recordedById: users.supervisor.id } });
await generateTicketsForRegistration(reg.id);
const ticket = await prisma.ticket.findFirst({ where: { eventId: course.id } });
await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: users.staff.id, sessionId: course.sessions[0].id, scannedAt: course.sessions[0].startsAt } });
const out = {
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
events: { free: free.id, paid: paid.id, course: course.id },
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
};
fs.mkdirSync(path.join(root, 'e2e', '.data'), { recursive: true });
fs.writeFileSync(path.join(root, 'e2e', '.data', 'seed.json'), JSON.stringify(out, null, 2));
await prisma.$disconnect?.();
}
(async () => {
const url = e2eDatabaseUrl();
await recreateDatabase(url);
execSync('npx prisma migrate deploy', { cwd: backend, env: { ...process.env, DATABASE_URL: url }, stdio: 'ignore' });
await seed(url);
console.log('[e2e] Database ready:', new URL(url).pathname.slice(1));
process.exit(0);
})().catch((e) => {
console.error('[e2e] Preparing the database failed:', e);
process.exit(1);
});
+3
View File
@@ -2,6 +2,9 @@ import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = {
// The end-to-end tests run their own dev server; a separate build folder keeps it
// from clashing with one already running.
distDir: process.env.NEXT_DIST_DIR || ".next",
images: {
domains: ['images.unsplash.com'],
},
@@ -6,17 +6,18 @@ import { useSearchParams } from "next/navigation";
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, type LucideIcon } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, 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 { YocoTab } from "@/components/admin/YocoTab";
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" | "yoco" | "backups";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups" | "jobs";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
@@ -27,6 +28,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
{ id: "yoco", label: "Yoco", icon: CreditCard },
{ id: "backups", label: "Backups", icon: DatabaseBackup },
{ id: "jobs", label: "Background jobs", icon: Timer },
];
const inputCls =
@@ -705,6 +707,7 @@ function SiteSettingsPageInner() {
{/* ── Backups ───────────────────────────────────────────────────────── */}
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
</div>
{activeTab === "branding" && (
@@ -15,6 +15,7 @@ import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type Lu
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPrint";
import { todaysSession } from "@/lib/sessions";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -1045,10 +1046,14 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
);
}
// Multi-day events: check-in is per session, so only today's session's check-ins count.
const session = todaysSession(ticketsForEvent[0]?.event);
const myTickets = ticketsForEvent
.filter(t => t.registrationOption?.registration?.id === registration.id)
.map(t => {
const totalRedeemed = (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const totalRedeemed = (t.usages || [])
.filter(u => !session || u.sessionId === session.id)
.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (t.quantity || 1) - totalRedeemed;
return { ...t, totalRedeemed, remaining };
});
@@ -1080,7 +1085,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
return (
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-xl font-semibold mb-4">Check-In</div>
<div className="text-xl font-semibold mb-4">Check-In{session ? <span className="ml-2 text-sm font-normal text-gray-500">{session.label || "Today's session"}</span> : null}</div>
{/* ✅ User */}
<div className="mb-4">
+143
View File
@@ -0,0 +1,143 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Timer, Play, RefreshCw } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
type JobRun = { state: string; createdOn?: string; startedOn: string | null; completedOn: string | null; output: unknown };
type JobInfo = { name: string; label: string; cron: string; enabled: boolean; envFlag: string | null; nextRun: string | null; recent: JobRun[] };
type JobsResponse = { mode: "queue" | "timers" | "off"; timezone: string; jobs: JobInfo[] };
const STATE: Record<string, string> = {
completed: "bg-green-50 text-green-800",
failed: "bg-red-50 text-red-700",
active: "bg-blue-50 text-blue-700",
created: "bg-gray-100 text-gray-600",
retry: "bg-amber-50 text-amber-800",
cancelled: "bg-gray-100 text-gray-500",
};
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
function describeCron(cron: string): string {
const m = /^\*\/(\d+) \* \* \* \*$/.exec(cron);
if (m) return `Every ${m[1]} minutes`;
const d = /^(\d+) (\d+) \* \* \*$/.exec(cron);
if (d) return `Daily at ${d[2].padStart(2, "0")}:${d[1].padStart(2, "0")}`;
return cron;
}
function outputText(run: JobRun): string {
if (run.output === null || run.output === undefined) return "";
if (typeof run.output === "object" && run.output && "message" in run.output && run.state === "failed") return String((run.output as { message: unknown }).message);
try { return JSON.stringify(run.output); } catch { return ""; }
}
// Settings → Background jobs: what runs when, the last runs of each job, and "Run now".
export function JobsTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [data, setData] = useState<JobsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState<string | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
setData(await apiFetch<JobsResponse>("/api/admin/jobs", { authToken: token }));
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to load background jobs" });
} finally {
setLoading(false);
}
}, [token, setMessage]);
useEffect(() => { if (active) load(); }, [active, load]);
const runNow = async (job: JobInfo) => {
if (!token) return;
setRunning(job.name);
try {
const r = await apiFetch<{ queued: boolean }>(`/api/admin/jobs/${job.name}/run`, { method: "POST", authToken: token });
setMessage({ type: "ok", text: r.queued ? `"${job.label}" queued — it starts within a few seconds.` : `"${job.label}" ran.` });
setTimeout(load, r.queued ? 4000 : 0);
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Couldn't run the job" });
} finally {
setRunning(null);
}
};
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2"><Timer className="w-5 h-5 text-brand-600" /> Background jobs</h2>
<p className="text-sm text-gray-500 mt-0.5">
Tasks the server runs on a schedule ({data?.timezone || "local"} time). Each runs once per schedule, even with more than one server process, and failed runs are retried.
</p>
</div>
<button type="button" onClick={load} disabled={loading} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50 disabled:opacity-50 shrink-0">
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} /> Refresh
</button>
</div>
{message && <div className={`p-3 border rounded text-sm ${message.type === "ok" ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`}>{message.text}</div>}
{data?.mode === "timers" && (
<div className="p-3 border rounded bg-amber-50 text-amber-900 text-sm">The job queue couldn&apos;t start, so jobs are running on simple timers in this server process (no retries, history only since the last restart). Check the server log for &quot;[jobs]&quot;.</div>
)}
{data?.mode === "off" && (
<div className="p-3 border rounded bg-gray-50 text-gray-700 text-sm">All background jobs are switched off (JOBS_ENABLED=false).</div>
)}
<ul className="divide-y border rounded-lg">
{(data?.jobs || []).map(job => {
const last = job.recent[0];
return (
<li key={job.name} className="p-3">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 mr-auto">
<div className="font-medium text-sm text-gray-900">{job.label}</div>
<div className="text-xs text-gray-500">
{job.enabled ? `${describeCron(job.cron)} · next ${when(job.nextRun)}` : `Off${job.envFlag ? ` (${job.envFlag}=false)` : ""}`}
</div>
</div>
{last && (
<button type="button" onClick={() => setOpen(open === job.name ? null : job.name)} className={`text-xs px-2 py-0.5 rounded ${STATE[last.state] || "bg-gray-100 text-gray-600"}`} title="Show recent runs">
{last.state} · {when(last.completedOn || last.startedOn || last.createdOn || null)}
</button>
)}
<button type="button" disabled={running !== null || data?.mode === "off"} onClick={() => runNow(job)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50">
<Play className="w-3.5 h-3.5" /> {running === job.name ? "Starting…" : "Run now"}
</button>
</div>
{open === job.name && job.recent.length > 0 && (
<table className="mt-2 w-full text-xs">
<thead className="text-gray-500"><tr><th className="text-left font-medium py-1">Started</th><th className="text-left font-medium">Finished</th><th className="text-left font-medium">State</th><th className="text-left font-medium">Result</th></tr></thead>
<tbody>
{job.recent.map((r, i) => (
<tr key={i} className="border-t">
<td className="py-1 pr-2 whitespace-nowrap">{when(r.startedOn)}</td>
<td className="pr-2 whitespace-nowrap">{when(r.completedOn)}</td>
<td className="pr-2"><span className={`px-1.5 py-0.5 rounded ${STATE[r.state] || ""}`}>{r.state}</span></td>
<td className="font-mono text-[11px] text-gray-600 break-all">{outputText(r)}</td>
</tr>
))}
</tbody>
</table>
)}
</li>
);
})}
{!loading && data && data.jobs.length === 0 && <li className="p-3 text-sm text-gray-500">No jobs.</li>}
</ul>
<p className="text-xs text-gray-400">Change a schedule with the env var JOB_SCHEDULE_&lt;NAME&gt; (cron, e.g. JOB_SCHEDULE_WAITLIST=&quot;*/5 * * * *&quot;), or switch one off with its own flag.</p>
</div>
);
}
+21 -2
View File
@@ -1,9 +1,9 @@
import React from "react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for Site Settings (/dashboard/admin/settings), matching its six tabs. */
/** Help content for Site Settings (/dashboard/admin/settings). */
export const siteSettingsHelpContent: HelpContent = {
title: "Site Settings help",
subtitle: "Configure your organisation, branding, email, WhatsApp, and legal pages.",
@@ -96,5 +96,24 @@ export const siteSettingsHelpContent: HelpContent = {
</div>
),
},
{
key: "jobs",
label: "Background jobs",
icon: Timer,
content: (
<div className="space-y-4">
<p>Tasks the server runs on a schedule: the daily summary email, waitlist offers, feedback forms, session reminders and certificates, consent reminders, analytics, the nightly backup and temp-file cleanup.</p>
<div className="space-y-4">
<GuideItem icon={Timer} title="Schedule and history" tone="brand">
Each job shows when it runs and when it runs next. Click a job&apos;s last result to see its last 10 runs — when they started and finished, and what they did (or the error). A failed run is retried twice.
</GuideItem>
<GuideItem icon={Play} title="Run now" tone="green">
Runs a job straight away, e.g. to send a feedback form without waiting. It&apos;s recorded in the audit log.
</GuideItem>
</div>
<p className="text-xs text-gray-500">Jobs run once per schedule even when more than one server process is running. Schedules and on/off switches are set by the server&apos;s environment variables.</p>
</div>
),
},
],
};
+9
View File
@@ -163,3 +163,12 @@ export function currentSessionLabel(event: Pick<Event, "scheduleType" | "session
const s = activeSessions(event.sessions).find(x => new Date(x.startsAt).toDateString() === today);
return s ? sessionLabel(s, event.sessions) : null;
}
/** The session of a multi-day event open for check-in today (browser's local day), or null. */
export function todaysSession(event: Pick<Event, "scheduleType" | "sessions"> | null | undefined): EventSession | null {
if (!event || event.scheduleType !== "sessions") return null;
const today = new Date().toDateString();
const now = Date.now();
const list = activeSessions(event.sessions).filter(x => new Date(x.startsAt).toDateString() === today);
return list.find(s => now <= new Date(s.endsAt).getTime()) || list[list.length - 1] || null;
}
+1 -1
View File
@@ -528,7 +528,7 @@ export type Ticket = {
transferRequests?: { id: string; toName: string; toEmail?: string | null; toPhone?: string | null; createdAt?: string }[];
event?: Event;
registrationOption?: RegistrationOption;
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
usages?: { id: string; scannedAt: string; quantityRedeemed?: number; sessionId?: string | null }[];
};
export type Payment = {
+1 -1
View File
@@ -22,6 +22,6 @@
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"],
"exclude": ["node_modules"]
}
+46
View File
@@ -9,9 +9,26 @@
"version": "1.33.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
"concurrently": "^9.2.1"
}
},
"node_modules/@playwright/test": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -175,6 +192,35 @@
"node": ">=8"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+4 -1
View File
@@ -9,13 +9,16 @@
"start:frontend": "cd frontend && npm run start",
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate"
"build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate",
"test:e2e": "playwright test",
"test": "npm --prefix backend test"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@playwright/test": "^1.64.0",
"concurrently": "^9.2.1"
},
"overrides": {
+70
View File
@@ -0,0 +1,70 @@
import { defineConfig, devices } from "@playwright/test";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { e2eDatabaseUrl } = require("./e2e/support/db-url");
// End-to-end tests: their own backend (port 5100) and frontend (port 3100) against a
// separate database that is recreated and seeded on every run (e2e/support/prepare-db.js).
// Run with `npm run test:e2e`. In CI (CI=true) the frontend is built and started instead
// of run in dev mode.
const API_PORT = 5100;
const WEB_PORT = 3100;
const ci = !!process.env.CI;
const databaseUrl = e2eDatabaseUrl();
export default defineConfig({
testDir: "./e2e",
testMatch: "**/*.spec.ts",
// One shared database, so tests run one after another.
workers: 1,
fullyParallel: false,
retries: ci ? 1 : 0,
timeout: 120_000,
expect: { timeout: 20_000 },
reporter: ci ? [["list"], ["html", { open: "never", outputFolder: "e2e/report" }]] : "list",
outputDir: "e2e/test-results",
use: {
baseURL: `http://localhost:${WEB_PORT}`,
trace: "retain-on-failure",
screenshot: "only-on-failure",
// Dev-mode pages compile on first visit.
navigationTimeout: 90_000,
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: [
{
command: "node ../e2e/support/prepare-db.js && node src/index.js",
cwd: "backend",
url: `http://localhost:${API_PORT}/api/settings/needs-setup`,
timeout: 180_000,
reuseExistingServer: false,
stdout: "ignore",
stderr: "pipe",
env: {
PORT: String(API_PORT),
DATABASE_URL: databaseUrl,
E2E_DATABASE_URL: databaseUrl,
FRONTEND_URL: `http://localhost:${WEB_PORT}`,
APP_BASE_URL: `http://localhost:${WEB_PORT}`,
JOBS_ENABLED: "false",
SCHEDULED_EMAILS_ENABLED: "false",
// The kiosk test signs a supervisor in with a password (no 2FA code by email).
TWO_FACTOR_REQUIRED_ROLES: "false",
},
},
{
command: ci ? `npm run build && npx next start -p ${WEB_PORT}` : `npx next dev -p ${WEB_PORT}`,
cwd: "frontend",
url: `http://localhost:${WEB_PORT}`,
timeout: 600_000,
reuseExistingServer: false,
stdout: "ignore",
stderr: "pipe",
env: {
NEXT_PUBLIC_API_URL: `http://localhost:${API_PORT}`,
API_URL: `http://localhost:${API_PORT}`,
NEXT_DIST_DIR: ci ? ".next" : ".next-e2e",
NEXT_TELEMETRY_DISABLED: "1",
},
},
],
});
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Updates this install to a tagged release: back up the database, check out the tag,
# install, migrate, build, and reload PM2. If anything fails before the reload, the
# previous version keeps running and the checkout goes back to it.
#
# Usage (on the server, from the repo folder): bash scripts/deploy.sh v1.34.0
# CI runs this over SSH when a v* tag is pushed (see .gitea/workflows/ci.yml).
set -euo pipefail
TAG="${1:?Usage: scripts/deploy.sh <tag>}"
cd "$(dirname "$0")/.."
PREVIOUS="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
echo "==> Deploying $TAG (currently $PREVIOUS)"
echo "==> Backing up the database"
(cd backend && node -e "require('dotenv').config(); require('./src/utils/backupUtils').runBackup().then(r => console.log(' ', r.filename)).catch(e => { console.error(' Backup failed:', e.message); process.exit(1); })")
rollback() {
echo "!!> Deploy failed — going back to $PREVIOUS (the running version was not touched)"
git checkout -q "$PREVIOUS"
(cd backend && npm ci --silent) || true
(cd frontend && npm ci --silent && npm run build --silent) || true
}
trap rollback ERR
git fetch --tags --quiet origin
git checkout -q "$TAG"
echo "==> Backend: install and migrate"
(cd backend && npm ci && npm run prisma:deploy)
echo "==> Frontend: install and build"
(cd frontend && npm ci && npm run build)
trap - ERR
echo "==> Reloading PM2"
pm2 reload ecosystem.config.js --update-env
echo "==> Deployed $TAG"