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'"
+6 -1
View File
@@ -39,4 +39,9 @@ hope-events-deployment-setup.txt
# runtime data stores (mutated by the running app, not source) # runtime data stores (mutated by the running app, not source)
backend/data/scheduled-emails.json backend/data/scheduled-emails.json
backend/data/banner.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] ## [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 ## [1.33.0] - 2026-10-22
### Added ### Added
+27 -10
View File
@@ -529,17 +529,25 @@ falling back to a general guide.
## Background jobs ## 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 | | Job | Schedule |
|-----|----------| |-----|----------|
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h | | Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h (not on the queue) |
| Daily event summary email | 07:00 daily | | `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` to disable) | | `waitlist` — expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false`) |
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) | | `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) | | `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
| Session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false` to disable) | | `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) | | `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) |
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) | | `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) |
| Temp file cleanup (files older than 7 days in `/temp`) | Daily | | `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
--- ---
@@ -551,7 +559,16 @@ falling back to a general guide.
set. set.
- Unhandled promise rejections are logged and don't crash the server. - Unhandled promise rejections are logged and don't crash the server.
- Backend status page (`/`) and built-in API docs page (`/docs`). - 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). - 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 - 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. - 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 ## 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 ### Backend Deployment
1. Provision a PostgreSQL database and set DATABASE_URL. 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) - [Post-event feedback](#post-event-feedback)
- [Registration funnel](#registration-funnel) - [Registration funnel](#registration-funnel)
- [Impersonation](#impersonation) - [Impersonation](#impersonation)
- [Background jobs](#background-jobs)
- [Payments](#payments) - [Payments](#payments)
- [Tickets](#tickets) - [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email) - [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 ## Impersonation
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
@@ -331,7 +348,7 @@ Data handling:
Notes: Notes:
- New field types `rating` (answers `"1"`–`"5"`) and `multiple_choice` (`options`: array of strings). They can be used on attendee forms too. - 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. - 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 ## 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/: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 }` | | 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 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. 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: How offers work:
- Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks. - 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. - 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. - 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. - 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", "axios": "^1.11.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron-parser": "^4.9.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"express": "^4.18.2", "express": "^4.18.2",
@@ -25,6 +26,7 @@
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"nodemailer": "^10.0.16", "nodemailer": "^10.0.16",
"pdfkit": "^0.17.1", "pdfkit": "^0.17.1",
"pg-boss": "^10.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"raw-body": "^3.0.0", "raw-body": "^3.0.0",
"uuid": "^11.1.1" "uuid": "^11.1.1"
@@ -1615,9 +1617,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1639,9 +1638,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1663,9 +1659,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1687,9 +1680,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1711,9 +1701,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1735,9 +1722,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2707,9 +2691,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2724,9 +2705,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2741,9 +2719,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2758,9 +2733,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2775,9 +2747,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2792,9 +2761,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2809,9 +2775,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2826,9 +2789,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2843,9 +2803,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2860,9 +2817,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4143,6 +4097,19 @@
"node": ">= 10" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7134,6 +7101,15 @@
"url": "https://github.com/sponsors/wellwelwel" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "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": { "node_modules/pg-cloudflare": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
@@ -8526,6 +8516,33 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/serve-static": {
"version": "1.16.2", "version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
+2
View File
@@ -23,6 +23,7 @@
"axios": "^1.11.0", "axios": "^1.11.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"cron-parser": "^4.9.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"express": "^4.18.2", "express": "^4.18.2",
@@ -33,6 +34,7 @@
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"nodemailer": "^10.0.16", "nodemailer": "^10.0.16",
"pdfkit": "^0.17.1", "pdfkit": "^0.17.1",
"pg-boss": "^10.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"raw-body": "^3.0.0", "raw-body": "^3.0.0",
"uuid": "^11.1.1" "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 attendance_unmarked
certificate_issued certificate_issued
certificate_revoked certificate_revoked
job_run_manual
} }
// Append-only audit trail for admin/supervisor-initiated actions with money or // 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: [ { title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' }, { method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, 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/: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/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' }, { 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); console.warn('[attachments sync] Not scheduled:', e?.message || e);
} }
// Schedule daily summaries at 07:00 local time (configurable) // Timed background jobs (daily summary, waitlist, feedback, sessions, consent, analytics,
try { // backup, temp cleanup) run on the pg-boss job queue — see src/jobs/index.js.
const enabled = String(process.env.DAILY_SUMMARY_ENABLED || 'true').toLowerCase() !== 'false'; require('./jobs').startJobs().catch(e => console.warn('[jobs] Not started:', e?.message || e));
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);
}
// Scheduled emails worker (polling) // Scheduled emails worker (polling)
try { try {
@@ -1729,6 +1511,14 @@ app.listen(PORT, () => {
// Log unhandled promise rejections without killing the server, since a single // Log unhandled promise rejections without killing the server, since a single
// missed .catch() on fire-and-forget notification code (email/WhatsApp sends) // missed .catch() on fire-and-forget notification code (email/WhatsApp sends)
// would otherwise take the whole app down. // 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) => { process.on('unhandledRejection', (err) => {
console.error('UNHANDLED REJECTION!', err?.name, err?.message); console.error('UNHANDLED REJECTION!', err?.name, err?.message);
console.error(err?.stack || err); 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 router = express.Router();
const { listAuditLog } = require('../controllers/adminAuditController'); const { listAuditLog } = require('../controllers/adminAuditController');
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController'); const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
const { listJobs, runJobNow } = require('../controllers/jobsController');
const { protect, admin } = require('../middleware/authMiddleware'); const { protect, admin } = require('../middleware/authMiddleware');
router.get('/audit-log', protect, admin, listAuditLog); 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); router.get('/impersonation-sessions', protect, admin, listSessions);
// Called with the impersonation token itself (which belongs to the viewed user). // Called with the impersonation token itself (which belongs to the viewed user).
router.post('/impersonate/end', protect, endImpersonation); 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"; import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = { 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: { images: {
domains: ['images.unsplash.com'], domains: ['images.unsplash.com'],
}, },
@@ -6,17 +6,18 @@ import { useSearchParams } from "next/navigation";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api"; import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState"; 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 { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel"; import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab"; import { BackupsTab } from "@/components/admin/BackupsTab";
import { JobsTab } from "@/components/admin/JobsTab";
import { YocoTab } from "@/components/admin/YocoTab"; import { YocoTab } from "@/components/admin/YocoTab";
import { extractDominantColors } from "@/lib/extractColors"; import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps"; import { mapsSearchUrl } from "@/lib/maps";
import { errorMessage } from "@/lib/utils"; import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink"; 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 }[] = [ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 }, { 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: "whatsapp", label: "WhatsApp", icon: MessageCircle },
{ id: "yoco", label: "Yoco", icon: CreditCard }, { id: "yoco", label: "Yoco", icon: CreditCard },
{ id: "backups", label: "Backups", icon: DatabaseBackup }, { id: "backups", label: "Backups", icon: DatabaseBackup },
{ id: "jobs", label: "Background jobs", icon: Timer },
]; ];
const inputCls = const inputCls =
@@ -705,6 +707,7 @@ function SiteSettingsPageInner() {
{/* ── Backups ───────────────────────────────────────────────────────── */} {/* ── Backups ───────────────────────────────────────────────────────── */}
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />} {activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
{activeTab === "jobs" && <JobsTab active={activeTab === "jobs"} />}
</div> </div>
{activeTab === "branding" && ( {activeTab === "branding" && (
@@ -15,6 +15,7 @@ import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type Lu
import { errorMessage } from "@/lib/utils"; import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing"; import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPrint"; 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"; 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" }; 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 const myTickets = ticketsForEvent
.filter(t => t.registrationOption?.registration?.id === registration.id) .filter(t => t.registrationOption?.registration?.id === registration.id)
.map(t => { .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; const remaining = (t.quantity || 1) - totalRedeemed;
return { ...t, totalRedeemed, remaining }; return { ...t, totalRedeemed, remaining };
}); });
@@ -1080,7 +1085,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
return ( return (
<div className="border rounded-xl p-6 bg-white shadow-sm"> <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 */} {/* ✅ User */}
<div className="mb-4"> <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 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 { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types"; 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 = { export const siteSettingsHelpContent: HelpContent = {
title: "Site Settings help", title: "Site Settings help",
subtitle: "Configure your organisation, branding, email, WhatsApp, and legal pages.", subtitle: "Configure your organisation, branding, email, WhatsApp, and legal pages.",
@@ -96,5 +96,24 @@ export const siteSettingsHelpContent: HelpContent = {
</div> </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); const s = activeSessions(event.sessions).find(x => new Date(x.startsAt).toDateString() === today);
return s ? sessionLabel(s, event.sessions) : null; 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 }[]; transferRequests?: { id: string; toName: string; toEmail?: string | null; toPhone?: string | null; createdAt?: string }[];
event?: Event; event?: Event;
registrationOption?: RegistrationOption; registrationOption?: RegistrationOption;
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[]; usages?: { id: string; scannedAt: string; quantityRedeemed?: number; sessionId?: string | null }[];
}; };
export type Payment = { export type Payment = {
+1 -1
View File
@@ -22,6 +22,6 @@
"@/*": ["./src/*"] "@/*": ["./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"] "exclude": ["node_modules"]
} }
+46
View File
@@ -9,9 +9,26 @@
"version": "1.33.0", "version": "1.33.0",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.64.0",
"concurrently": "^9.2.1" "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": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -175,6 +192,35 @@
"node": ">=8" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "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:frontend": "cd frontend && npm run start",
"start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"", "start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev: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": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.64.0",
"concurrently": "^9.2.1" "concurrently": "^9.2.1"
}, },
"overrides": { "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"