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:
@@ -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'"
|
||||||
@@ -40,3 +40,8 @@ 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/
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Generated
+65
-48
@@ -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",
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'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 "[jobs]".</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_<NAME> (cron, e.g. JOB_SCHEDULE_WAITLIST="*/5 * * * *"), or switch one off with its own flag.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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'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's environment variables.</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+46
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user