From 48660732fe08ae879e33954135441e6b01e91225 Mon Sep 17 00:00:00 2001 From: joshua Date: Thu, 22 Oct 2026 15:49:46 +0200 Subject: [PATCH] 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 --- .gitea/workflows/ci.yml | 129 +++++++++ .gitignore | 7 +- CHANGELOG.md | 16 ++ FEATURES.md | 37 ++- README.md | 18 ++ backend/API_DOCUMENTATION.md | 23 +- backend/package-lock.json | 113 ++++---- backend/package.json | 2 + .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + backend/src/controllers/jobsController.js | 34 +++ backend/src/index.js | 236 +--------------- backend/src/jobs/index.js | 267 ++++++++++++++++++ backend/src/routes/adminRoutes.js | 3 + backend/tests/jobs.test.js | 54 ++++ e2e/fixtures.ts | 48 ++++ e2e/public.spec.ts | 28 ++ e2e/registration.spec.ts | 53 ++++ e2e/sessions.spec.ts | 51 ++++ e2e/supervisor.spec.ts | 62 ++++ e2e/support/db-url.js | 16 ++ e2e/support/prepare-db.js | 137 +++++++++ frontend/next.config.ts | 3 + .../src/app/dashboard/admin/settings/page.tsx | 7 +- .../dashboard/supervisor/at-the-door/page.tsx | 9 +- frontend/src/components/admin/JobsTab.tsx | 143 ++++++++++ frontend/src/content/help/site-settings.tsx | 23 +- frontend/src/lib/sessions.ts | 9 + frontend/src/types/index.ts | 2 +- frontend/tsconfig.json | 2 +- package-lock.json | 46 +++ package.json | 5 +- playwright.config.ts | 70 +++++ scripts/deploy.sh | 40 +++ 34 files changed, 1402 insertions(+), 294 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 backend/prisma/migrations/20261022150000_add_job_run_audit_action/migration.sql create mode 100644 backend/src/controllers/jobsController.js create mode 100644 backend/src/jobs/index.js create mode 100644 backend/tests/jobs.test.js create mode 100644 e2e/fixtures.ts create mode 100644 e2e/public.spec.ts create mode 100644 e2e/registration.spec.ts create mode 100644 e2e/sessions.spec.ts create mode 100644 e2e/supervisor.spec.ts create mode 100644 e2e/support/db-url.js create mode 100644 e2e/support/prepare-db.js create mode 100644 frontend/src/components/admin/JobsTab.tsx create mode 100644 playwright.config.ts create mode 100644 scripts/deploy.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..7bebf68 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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'" diff --git a/.gitignore b/.gitignore index e905716..984eb01 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,9 @@ hope-events-deployment-setup.txt # runtime data stores (mutated by the running app, not source) backend/data/scheduled-emails.json -backend/data/banner.json \ No newline at end of file +backend/data/banner.json +# end-to-end tests +e2e/.data/ +e2e/report/ +e2e/test-results/ +frontend/.next-e2e/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a03b91c..35e594e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **Background jobs page** (Admin → Site Settings → Background jobs): every scheduled job with its schedule, next run and last 10 runs (start, finish, result or error), and a "Run now" button (recorded in the audit log). +- **End-to-end tests** with Playwright (`npm run test:e2e`): they start their own backend and frontend against a separate, freshly seeded database and cover the public pages, free and paid registration through payment, ticket, check-in and cashup, multi-day check-in with the attendance register and certificates, creating a multi-session event in the wizard, the self-service kiosk, and the jobs page. +- **CI workflow** (`.gitea/workflows/ci.yml`): backend tests, frontend lint, typecheck and build, and the end-to-end tests on every push and pull request. It runs once a Gitea Actions runner is registered. +- **Deploy script** (`scripts/deploy.sh `): 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_`, or turn all jobs off with `JOBS_ENABLED=false`. The existing `*_ENABLED` flags still work; the old `*_JOB_INTERVAL_MS` settings are replaced by the schedules. If the queue can't start, the jobs run on timers as before. +- The server lets a running job finish before it stops or restarts. + +### Fixed + +- At the door, the Check-In tab counted every past session's check-in on a multi-day ticket, so from the second session the ticket showed as fully used. It now counts only today's session. + ## [1.33.0] - 2026-10-22 ### Added diff --git a/FEATURES.md b/FEATURES.md index dca1f53..47006f6 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -529,17 +529,25 @@ falling back to a general guide. ## Background jobs +Timed jobs run on a job queue in Postgres (pg-boss, `backend/src/jobs/index.js`): each +runs once per schedule even with several server processes, every run is recorded, +failed runs are retried twice, and a slow run never overlaps the next. Admin → Site +Settings → Background jobs shows each job's schedule, next run and last 10 runs, with +"Run now". Times are in `APP_TIMEZONE` (default Africa/Johannesburg). Change a schedule +with `JOB_SCHEDULE_` (cron); `JOBS_ENABLED=false` turns them all off. If the queue +can't start, the same jobs run on in-process timers. + | Job | Schedule | |-----|----------| -| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h | -| Daily event summary email | 07:00 daily | -| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) | -| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) | -| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) | -| Session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false` to disable) | -| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) | -| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) | -| Temp file cleanup (files older than 7 days in `/temp`) | Daily | +| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h (not on the queue) | +| `daily-summary` — daily event summary email | 07:00 daily (`DAILY_SUMMARY_ENABLED=false` to disable) | +| `waitlist` — expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false`) | +| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) | +| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) | +| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) | +| `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) | +| `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) | +| `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily | --- @@ -551,7 +559,16 @@ falling back to a general guide. set. - Unhandled promise rejections are logged and don't crash the server. - Backend status page (`/`) and built-in API docs page (`/docs`). -- Jest tests for early-bird tranche pricing and donation balances. +- **Tests** — Jest unit tests in `backend/tests` (`npm test`), and Playwright + end-to-end tests in `e2e/` (`npm run test:e2e`) that start their own backend and + frontend against a separate, freshly seeded database: public pages, free and paid + registration through to check-in and cashup, multi-day check-in, the attendance + register and certificates, the event wizard, the kiosk, and the admin jobs page. +- **CI** — `.gitea/workflows/ci.yml` runs the backend tests, frontend lint/typecheck/ + build and the end-to-end tests on every push (needs a Gitea Actions runner). A `v*` + tag also deploys over SSH with `scripts/deploy.sh` once the `DEPLOY_*` secrets are set: + it backs up the database, checks out the tag, migrates, builds and reloads PM2, and + goes back to the previous version if a step fails before the reload. - Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md). --- diff --git a/README.md b/README.md index aedc595..8aab3ab 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,26 @@ Documentation: see Frontend README for environment and scripts. - npm run dev - Next.js dev server runs at http://localhost:3000 by default; if it conflicts with backend, it will pick another port (e.g., 3001). Ensure NEXT_PUBLIC_API_URL points to the backend port. +## Tests + +- Backend unit tests: `cd backend && npm test` +- End-to-end tests (Playwright), from the repo root: + - once: `npm install` and `npx playwright install chromium` + - `npm run test:e2e` + - They start their own backend (port 5100) and frontend (port 3100) against a separate + database — `backend/.env`'s `DATABASE_URL` with `_e2e` added to the name, or + `E2E_DATABASE_URL` — which is dropped, recreated and seeded on every run. Your own dev + servers and database aren't touched. The database user needs permission to create + databases. +- CI: `.gitea/workflows/ci.yml` runs all of the above on every push once a Gitea Actions + runner is registered. Pushing a `v*` tag also deploys with `scripts/deploy.sh` when the + `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY` (and optional `DEPLOY_PATH`) secrets are set. + ## Deployment +To update a running install to a release: `bash scripts/deploy.sh v1.34.0` on the server +(backs up the database, migrates, builds, reloads PM2). + ### Backend Deployment 1. Provision a PostgreSQL database and set DATABASE_URL. diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index fd119f6..fc65d6f 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -29,6 +29,7 @@ Tokens are issued by `POST /api/users/login`. - [Post-event feedback](#post-event-feedback) - [Registration funnel](#registration-funnel) - [Impersonation](#impersonation) +- [Background jobs](#background-jobs) - [Payments](#payments) - [Tickets](#tickets) - [Broadcasts (Email)](#broadcasts-email) @@ -286,6 +287,22 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`: --- +## Background jobs + +Timed jobs run on a pg-boss queue in Postgres (schema `pgboss`, created on first start) — +see `backend/src/jobs/index.js`. + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/admin/jobs` | admin | `{ mode: "queue" \| "timers" \| "off", timezone, jobs: [{ name, label, cron, enabled, envFlag, nextRun, recent: [{ state, startedOn, completedOn, output }] }] }` | +| POST | `/api/admin/jobs/:name/run` | admin | Run a job now: `{ queued: true, id }` (or `{ queued: false, output }` in timer mode). Audit-logged as `job_run_manual` | + +Env: `JOBS_ENABLED=false` turns every job off; each job's own flag (`WAITLIST_JOB_ENABLED`, …) +turns it off; `JOB_SCHEDULE_` (e.g. `JOB_SCHEDULE_DAILY_SUMMARY="0 6 * * *"`) changes its +cron schedule; times are in `APP_TIMEZONE` (default Africa/Johannesburg). + +--- + ## Impersonation | Method | Path | Auth | Description | @@ -331,7 +348,7 @@ Data handling: Notes: - New field types `rating` (answers `"1"`–`"5"`) and `multiple_choice` (`options`: array of strings). They can be used on attendee forms too. - Only HMACs of link tokens are stored, and a reminder issues a new link. -- A job every 15 minutes (`FEEDBACK_JOB_ENABLED`, `FEEDBACK_JOB_INTERVAL_MS`) sends due forms, and sends one reminder 3 days later to people who haven't answered. +- A job every 15 minutes (`feedback` job; `FEEDBACK_JOB_ENABLED`, `JOB_SCHEDULE_FEEDBACK`) sends due forms, and sends one reminder 3 days later to people who haven't answered. ## Sponsor a spot @@ -464,7 +481,7 @@ are for the session running when downloaded; synced scans count for the session | GET | `/api/attendance/certificates/:id/pdf` | user+ | Download one (its person, the booker, or a supervisor) | | GET | `/api/attendance/certificates/verify/:code` | public | `{ valid, revoked?, name, event, completedOn, issuedAt }` | -A job every 5 minutes (`SESSION_JOBS_ENABLED`, `SESSION_JOBS_INTERVAL_MS`) sends due session +A job every 5 minutes (`sessions` job; `SESSION_JOBS_ENABLED`, `JOB_SCHEDULE_SESSIONS`) sends due session reminders (each once; when several are due at once only the closest is sent) and issues certificates for events with certificates on that ended in the last 14 days. @@ -488,7 +505,7 @@ Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitl How offers work: - Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks. - Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit. -- A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers. +- A job every 10 minutes (`waitlist` job; `WAITLIST_JOB_ENABLED`, `JOB_SCHEDULE_WAITLIST`) expires unclaimed offers and re-offers. - Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer. - Manual registration and adding places to an existing booking are now checked against stock too. diff --git a/backend/package-lock.json b/backend/package-lock.json index 5426b84..f86ba19 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,7 @@ "axios": "^1.11.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", + "cron-parser": "^4.9.0", "dotenv": "^16.3.1", "exceljs": "^4.4.0", "express": "^4.18.2", @@ -25,6 +26,7 @@ "node-fetch": "^2.7.0", "nodemailer": "^10.0.16", "pdfkit": "^0.17.1", + "pg-boss": "^10.4.2", "qrcode": "^1.5.4", "raw-body": "^3.0.0", "uuid": "^11.1.1" @@ -1615,9 +1617,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1639,9 +1638,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1663,9 +1659,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1687,9 +1680,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1711,9 +1701,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1735,9 +1722,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2707,9 +2691,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2724,9 +2705,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2741,9 +2719,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2758,9 +2733,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2775,9 +2747,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2792,9 +2761,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2809,9 +2775,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2826,9 +2789,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2843,9 +2803,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2860,9 +2817,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4143,6 +4097,19 @@ "node": ">= 10" } }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "deprecated": "v4 is no longer maintained, upgrade to v5", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7134,6 +7101,15 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7761,6 +7737,20 @@ } } }, + "node_modules/pg-boss": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-10.4.2.tgz", + "integrity": "sha512-AttEWOtSzn53av8OnCMWEanwRBvjkZCE1y5nLrZnwvkkMnlZ5XpWDpZ7sKI/BYjvi2OVieMX37arD2ACgJ750w==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.9.0", + "pg": "^8.16.3", + "serialize-error": "^8.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pg-cloudflare": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", @@ -8526,6 +8516,33 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/serialize-error": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", diff --git a/backend/package.json b/backend/package.json index bc3edbf..ed28c39 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,7 @@ "axios": "^1.11.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", + "cron-parser": "^4.9.0", "dotenv": "^16.3.1", "exceljs": "^4.4.0", "express": "^4.18.2", @@ -33,6 +34,7 @@ "node-fetch": "^2.7.0", "nodemailer": "^10.0.16", "pdfkit": "^0.17.1", + "pg-boss": "^10.4.2", "qrcode": "^1.5.4", "raw-body": "^3.0.0", "uuid": "^11.1.1" diff --git a/backend/prisma/migrations/20261022150000_add_job_run_audit_action/migration.sql b/backend/prisma/migrations/20261022150000_add_job_run_audit_action/migration.sql new file mode 100644 index 0000000..92ed454 --- /dev/null +++ b/backend/prisma/migrations/20261022150000_add_job_run_audit_action/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "AdminAuditAction" ADD VALUE 'job_run_manual'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ba8ded6..e227104 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -554,6 +554,7 @@ enum AdminAuditAction { attendance_unmarked certificate_issued certificate_revoked + job_run_manual } // Append-only audit trail for admin/supervisor-initiated actions with money or diff --git a/backend/src/controllers/jobsController.js b/backend/src/controllers/jobsController.js new file mode 100644 index 0000000..886b694 --- /dev/null +++ b/backend/src/controllers/jobsController.js @@ -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 }; diff --git a/backend/src/index.js b/backend/src/index.js index 97663cb..877db90 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -661,6 +661,8 @@ app.get('/docs', async (req, res) => { { title: 'Impersonation', base: '/api/admin', endpoints: [ { method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' }, + { method:'GET', path:'/api/admin/jobs', auth:'admin', desc:'Background jobs: queue mode, timezone, and each job schedule, next run and last 10 runs' }, + { method:'POST', path:'/api/admin/jobs/:name/run', auth:'admin', desc:'Run a background job now (queued for a worker); audit-logged' }, { method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]}, { method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' }, { method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' }, @@ -1445,229 +1447,9 @@ app.listen(PORT, () => { console.warn('[attachments sync] Not scheduled:', e?.message || e); } - // Schedule daily summaries at 07:00 local time (configurable) - try { - const enabled = String(process.env.DAILY_SUMMARY_ENABLED || 'true').toLowerCase() !== 'false'; - if (enabled) { - const { sendDailyEventSummaries } = require('./utils/notifications'); - function msUntilNext(hour, minute) { - const now = new Date(); - const next = new Date(now); - next.setHours(hour, minute, 0, 0); - if (next <= now) { - next.setDate(next.getDate() + 1); - } - return next.getTime() - now.getTime(); - } - async function scheduleNext() { - const delay = msUntilNext(7, 0); - setTimeout(async () => { - try { - console.log('[daily summaries] Running daily event summaries...'); - await sendDailyEventSummaries(new Date()); - console.log('[daily summaries] Completed sending daily event summaries.'); - } catch (e) { - console.error('[daily summaries] Failed:', e?.message || e); - } finally { - // Schedule next run - scheduleNext(); - } - }, delay); - } - scheduleNext(); - console.log('[daily summaries] Scheduler initialized (07:00 local time). Set DAILY_SUMMARY_ENABLED=false to disable.'); - } else { - console.log('[daily summaries] Scheduler disabled by env DAILY_SUMMARY_ENABLED=false'); - } - } catch (e) { - console.warn('[daily summaries] Not scheduled:', e?.message || e); - } - - // Waitlist job — expire unclaimed offers and offer any free places to the next people. - try { - const enabled = String(process.env.WAITLIST_JOB_ENABLED || 'true').toLowerCase() !== 'false'; - if (enabled) { - const { runWaitlistJob } = require('./utils/waitlist'); - const everyMs = parseInt(process.env.WAITLIST_JOB_INTERVAL_MS || String(10 * 60 * 1000), 10); - setInterval(async () => { - try { - const r = await runWaitlistJob(); - if (r.expired || r.offers) console.log(`[waitlist] ${r.expired} event(s) with expired offers, ${r.offers} new offer(s).`); - } catch (e) { - console.warn('[waitlist] Job failed:', e?.message || e); - } - }, everyMs); - console.log(`[waitlist] Job every ${Math.round(everyMs / 60000)} min.`); - } else { - console.log('[waitlist] Job disabled by env WAITLIST_JOB_ENABLED=false'); - } - } catch (e) { - console.warn('[waitlist] Not scheduled:', e?.message || e); - } - - // Funnel analytics — once a day, roll raw rows older than 90 days into daily totals. - try { - const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false'; - if (enabled) { - const { rollupOldEvents } = require('./utils/analytics'); - const run = async () => { - try { - const n = await rollupOldEvents(); - if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`); - } catch (e) { - console.warn('[analytics] Rollup failed:', e?.message || e); - } - }; - setTimeout(run, 5 * 60 * 1000); - setInterval(run, 24 * 60 * 60 * 1000); - console.log('[analytics] Rollup daily.'); - } else { - console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false'); - } - } catch (e) { - console.warn('[analytics] Not scheduled:', e?.message || e); - } - - // Feedback job — send post-event feedback forms at their send time, and the one - // reminder 3 days later. - try { - const enabled = String(process.env.FEEDBACK_JOB_ENABLED || 'true').toLowerCase() !== 'false'; - if (enabled) { - const { runFeedbackJob } = require('./utils/feedback'); - const everyMs = parseInt(process.env.FEEDBACK_JOB_INTERVAL_MS || String(15 * 60 * 1000), 10); - setInterval(async () => { - try { - const r = await runFeedbackJob(); - if (r.sent || r.reminded) console.log(`[feedback] ${r.sent} invite(s), ${r.reminded} reminder(s) sent.`); - } catch (e) { - console.warn('[feedback] Job failed:', e?.message || e); - } - }, everyMs); - console.log(`[feedback] Job every ${Math.round(everyMs / 60000)} min.`); - } else { - console.log('[feedback] Job disabled by env FEEDBACK_JOB_ENABLED=false'); - } - } catch (e) { - console.warn('[feedback] Not scheduled:', e?.message || e); - } - - // Multi-day events — reminders before each session (when the event has them on), and - // completion certificates once an event with certificates on has ended. - try { - const enabled = String(process.env.SESSION_JOBS_ENABLED || 'true').toLowerCase() !== 'false'; - if (enabled) { - const { runSessionReminderJob } = require('./utils/sessionReminders'); - const { runCertificateJob } = require('./utils/certificates'); - const everyMs = parseInt(process.env.SESSION_JOBS_INTERVAL_MS || String(5 * 60 * 1000), 10); - setInterval(async () => { - try { - const r = await runSessionReminderJob(); - if (r.sent) console.log(`[sessions] ${r.sent} reminder(s) sent for ${r.sessions} session(s).`); - } catch (e) { - console.warn('[sessions] Reminder job failed:', e?.message || e); - } - try { - const c = await runCertificateJob(); - if (c.issued) console.log(`[certificates] ${c.issued} issued, ${c.sent} sent.`); - } catch (e) { - console.warn('[certificates] Job failed:', e?.message || e); - } - }, everyMs); - console.log(`[sessions] Reminder and certificate job every ${Math.round(everyMs / 60000)} min.`); - } else { - console.log('[sessions] Jobs disabled by env SESSION_JOBS_ENABLED=false'); - } - } catch (e) { - console.warn('[sessions] Not scheduled:', e?.message || e); - } - - // Daily parental-consent job — reminders for unanswered requests, and purging - // medical/emergency details a set number of days after each event. - try { - const enabled = String(process.env.CONSENT_JOBS_ENABLED || 'true').toLowerCase() !== 'false'; - if (enabled) { - const { sendConsentReminders, purgeConsentMedicalData } = require('./utils/consent'); - const runConsentJobs = async () => { - try { - const reminded = await sendConsentReminders(new Date()); - const purged = await purgeConsentMedicalData(new Date()); - if (reminded || purged) console.log(`[consent] Sent ${reminded} reminder(s), purged medical details on ${purged} request(s).`); - } catch (e) { - console.warn('[consent] Daily job failed:', e?.message || e); - } - }; - const scheduleConsent = () => { - const now = new Date(); - const next = new Date(now); - next.setHours(9, 0, 0, 0); - if (next <= now) next.setDate(next.getDate() + 1); - setTimeout(async () => { await runConsentJobs(); scheduleConsent(); }, next.getTime() - now.getTime()); - }; - scheduleConsent(); - console.log('[consent] Scheduler initialized (09:00 local time).'); - } else { - console.log('[consent] Scheduler disabled by env CONSENT_JOBS_ENABLED=false'); - } - } catch (e) { - console.warn('[consent] Not scheduled:', e?.message || e); - } - - // Daily temp-file cleanup — removes files older than 7 days from /temp - try { - const { cleanupTempFiles } = require('./utils/cleanupTemp'); - function scheduleTempCleanup() { - const now = new Date(); - const next = new Date(now); - next.setHours(3, 0, 0, 0); // Run at 03:00 local time (low-traffic window) - if (next <= now) next.setDate(next.getDate() + 1); - setTimeout(() => { - try { - const result = cleanupTempFiles(); - if (result.deleted > 0 || result.errors > 0) { - console.log(`[temp cleanup] Deleted ${result.deleted} file(s), ${result.errors} error(s).`); - } - } catch (e) { - console.warn('[temp cleanup] Failed:', e?.message || e); - } finally { - scheduleTempCleanup(); - } - }, next.getTime() - now.getTime()); - } - scheduleTempCleanup(); - console.log('[temp cleanup] Scheduler initialized (03:00 local time, files older than 7 days).'); - } catch (e) { - console.warn('[temp cleanup] Not scheduled:', e?.message || e); - } - - // Nightly database backup at 02:00 local time (before the 03:00 temp cleanup) - try { - const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false'; - if (enabled) { - const { runBackup } = require('./utils/backupUtils'); - function scheduleNightlyBackup() { - const now = new Date(); - const next = new Date(now); - next.setHours(2, 0, 0, 0); - if (next <= now) next.setDate(next.getDate() + 1); - setTimeout(async () => { - try { - const result = await runBackup(); - console.log(`[backup] Completed: ${result.filename}`); - } catch (e) { - console.error('[backup] Failed:', e?.message || e); - } finally { - scheduleNightlyBackup(); - } - }, next.getTime() - now.getTime()); - } - scheduleNightlyBackup(); - console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.'); - } else { - console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false'); - } - } catch (e) { - console.warn('[backup] Not scheduled:', e?.message || e); - } + // Timed background jobs (daily summary, waitlist, feedback, sessions, consent, analytics, + // backup, temp cleanup) run on the pg-boss job queue — see src/jobs/index.js. + require('./jobs').startJobs().catch(e => console.warn('[jobs] Not started:', e?.message || e)); // Scheduled emails worker (polling) try { @@ -1729,6 +1511,14 @@ app.listen(PORT, () => { // Log unhandled promise rejections without killing the server, since a single // missed .catch() on fire-and-forget notification code (email/WhatsApp sends) // would otherwise take the whole app down. +// On restart/stop (PM2 sends SIGINT), let a running background job finish first. +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, async () => { + try { await require('./jobs').stopJobs(); } catch { /* exiting anyway */ } + process.exit(0); + }); +} + process.on('unhandledRejection', (err) => { console.error('UNHANDLED REJECTION!', err?.name, err?.message); console.error(err?.stack || err); diff --git a/backend/src/jobs/index.js b/backend/src/jobs/index.js new file mode 100644 index 0000000..1f53e4b --- /dev/null +++ b/backend/src/jobs/index.js @@ -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_ (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_ 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 }; diff --git a/backend/src/routes/adminRoutes.js b/backend/src/routes/adminRoutes.js index 904f28f..2f28b35 100644 --- a/backend/src/routes/adminRoutes.js +++ b/backend/src/routes/adminRoutes.js @@ -2,9 +2,12 @@ const express = require('express'); const router = express.Router(); const { listAuditLog } = require('../controllers/adminAuditController'); const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController'); +const { listJobs, runJobNow } = require('../controllers/jobsController'); const { protect, admin } = require('../middleware/authMiddleware'); router.get('/audit-log', protect, admin, listAuditLog); +router.get('/jobs', protect, admin, listJobs); +router.post('/jobs/:name/run', protect, admin, runJobNow); router.get('/impersonation-sessions', protect, admin, listSessions); // Called with the impersonation token itself (which belongs to the viewed user). router.post('/impersonate/end', protect, endImpersonation); diff --git a/backend/tests/jobs.test.js b/backend/tests/jobs.test.js new file mode 100644 index 0000000..f3f7d54 --- /dev/null +++ b/backend/tests/jobs.test.js @@ -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_ 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); + }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..cce117b --- /dev/null +++ b/e2e/fixtures.ts @@ -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; + 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(page: Page, role: Role, method: string, url: string, body?: unknown): Promise { + 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 }; diff --git a/e2e/public.spec.ts b/e2e/public.spec.ts new file mode 100644 index 0000000..2df206e --- /dev/null +++ b/e2e/public.spec.ts @@ -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(); + }); +}); diff --git a/e2e/registration.spec.ts b/e2e/registration.spec.ts new file mode 100644 index 0000000..153a6d6 --- /dev/null +++ b/e2e/registration.spec.ts @@ -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(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(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(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(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(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); + }); +}); diff --git a/e2e/sessions.spec.ts b/e2e/sessions.spec.ts new file mode 100644 index 0000000..27dd536 --- /dev/null +++ b/e2e/sessions.spec.ts @@ -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(); + }); +}); diff --git a/e2e/supervisor.spec.ts b/e2e/supervisor.spec.ts new file mode 100644 index 0000000..3557eba --- /dev/null +++ b/e2e/supervisor.spec.ts @@ -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(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(); + }); +}); diff --git a/e2e/support/db-url.js b/e2e/support/db-url.js new file mode 100644 index 0000000..72ec173 --- /dev/null +++ b/e2e/support/db-url.js @@ -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 }; diff --git a/e2e/support/prepare-db.js b/e2e/support/prepare-db.js new file mode 100644 index 0000000..bb9e37e --- /dev/null +++ b/e2e/support/prepare-db.js @@ -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); +}); + diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 01bb739..2aa3944 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -2,6 +2,9 @@ import type { NextConfig } from "next"; import { withSentryConfig } from "@sentry/nextjs"; const nextConfig: NextConfig = { + // The end-to-end tests run their own dev server; a separate build folder keeps it + // from clashing with one already running. + distDir: process.env.NEXT_DIST_DIR || ".next", images: { domains: ['images.unsplash.com'], }, diff --git a/frontend/src/app/dashboard/admin/settings/page.tsx b/frontend/src/app/dashboard/admin/settings/page.tsx index b5dec2f..0a44aa6 100644 --- a/frontend/src/app/dashboard/admin/settings/page.tsx +++ b/frontend/src/app/dashboard/admin/settings/page.tsx @@ -6,17 +6,18 @@ import { useSearchParams } from "next/navigation"; import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api"; import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useDismissingState } from "@/hooks/useDismissingState"; -import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, type LucideIcon } from "lucide-react"; +import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, Timer, type LucideIcon } from "lucide-react"; import { ColorPickerField } from "@/components/admin/ColorPickerField"; import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel"; import { BackupsTab } from "@/components/admin/BackupsTab"; +import { JobsTab } from "@/components/admin/JobsTab"; import { YocoTab } from "@/components/admin/YocoTab"; import { extractDominantColors } from "@/lib/extractColors"; import { mapsSearchUrl } from "@/lib/maps"; import { errorMessage } from "@/lib/utils"; import { BackLink } from "@/components/shared/BackLink"; -type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups"; +type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups" | "jobs"; const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [ { id: "organisation", label: "Organisation", icon: Building2 }, @@ -27,6 +28,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [ { id: "whatsapp", label: "WhatsApp", icon: MessageCircle }, { id: "yoco", label: "Yoco", icon: CreditCard }, { id: "backups", label: "Backups", icon: DatabaseBackup }, + { id: "jobs", label: "Background jobs", icon: Timer }, ]; const inputCls = @@ -705,6 +707,7 @@ function SiteSettingsPageInner() { {/* ── Backups ───────────────────────────────────────────────────────── */} {activeTab === "backups" && } + {activeTab === "jobs" && } {activeTab === "branding" && ( diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx index 493ecd5..fbdb68f 100644 --- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -15,6 +15,7 @@ import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type Lu import { errorMessage } from "@/lib/utils"; import { optionUnitNow, variantUnitNow } from "@/lib/pricing"; import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPrint"; +import { todaysSession } from "@/lib/sessions"; import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types"; type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" }; @@ -1045,10 +1046,14 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: { ); } + // Multi-day events: check-in is per session, so only today's session's check-ins count. + const session = todaysSession(ticketsForEvent[0]?.event); const myTickets = ticketsForEvent .filter(t => t.registrationOption?.registration?.id === registration.id) .map(t => { - const totalRedeemed = (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0); + const totalRedeemed = (t.usages || []) + .filter(u => !session || u.sessionId === session.id) + .reduce((s, u) => s + (u.quantityRedeemed || 1), 0); const remaining = (t.quantity || 1) - totalRedeemed; return { ...t, totalRedeemed, remaining }; }); @@ -1080,7 +1085,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: { return (
-
Check-In
+
Check-In{session ? {session.label || "Today's session"} : null}
{/* ✅ User */}
diff --git a/frontend/src/components/admin/JobsTab.tsx b/frontend/src/components/admin/JobsTab.tsx new file mode 100644 index 0000000..a5f5923 --- /dev/null +++ b/frontend/src/components/admin/JobsTab.tsx @@ -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 = { + 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(null); + const [loading, setLoading] = useState(true); + const [running, setRunning] = useState(null); + const [open, setOpen] = useState(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("/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 ( +
+
+
+

Background jobs

+

+ 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. +

+
+ +
+ + {message &&
{message.text}
} + {data?.mode === "timers" && ( +
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]".
+ )} + {data?.mode === "off" && ( +
All background jobs are switched off (JOBS_ENABLED=false).
+ )} + +
    + {(data?.jobs || []).map(job => { + const last = job.recent[0]; + return ( +
  • +
    +
    +
    {job.label}
    +
    + {job.enabled ? `${describeCron(job.cron)} · next ${when(job.nextRun)}` : `Off${job.envFlag ? ` (${job.envFlag}=false)` : ""}`} +
    +
    + {last && ( + + )} + +
    + {open === job.name && job.recent.length > 0 && ( + + + + {job.recent.map((r, i) => ( + + + + + + + ))} + +
    StartedFinishedStateResult
    {when(r.startedOn)}{when(r.completedOn)}{r.state}{outputText(r)}
    + )} +
  • + ); + })} + {!loading && data && data.jobs.length === 0 &&
  • No jobs.
  • } +
+

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.

+
+ ); +} diff --git a/frontend/src/content/help/site-settings.tsx b/frontend/src/content/help/site-settings.tsx index a43e644..312d847 100644 --- a/frontend/src/content/help/site-settings.tsx +++ b/frontend/src/content/help/site-settings.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard } from "lucide-react"; +import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play } from "lucide-react"; import { GuideItem } from "@/components/shared/GuideItem"; import type { HelpContent } from "./types"; -/** Help content for Site Settings (/dashboard/admin/settings), matching its six tabs. */ +/** Help content for Site Settings (/dashboard/admin/settings). */ export const siteSettingsHelpContent: HelpContent = { title: "Site Settings help", subtitle: "Configure your organisation, branding, email, WhatsApp, and legal pages.", @@ -96,5 +96,24 @@ export const siteSettingsHelpContent: HelpContent = {
), }, + { + key: "jobs", + label: "Background jobs", + icon: Timer, + content: ( +
+

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.

+
+ + 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. + + + Runs a job straight away, e.g. to send a feedback form without waiting. It's recorded in the audit log. + +
+

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.

+
+ ), + }, ], }; diff --git a/frontend/src/lib/sessions.ts b/frontend/src/lib/sessions.ts index caf3eb1..39bb1d0 100644 --- a/frontend/src/lib/sessions.ts +++ b/frontend/src/lib/sessions.ts @@ -163,3 +163,12 @@ export function currentSessionLabel(event: Pick new Date(x.startsAt).toDateString() === today); return s ? sessionLabel(s, event.sessions) : null; } + +/** The session of a multi-day event open for check-in today (browser's local day), or null. */ +export function todaysSession(event: Pick | 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; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index cafca7e..9ceb2e3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -528,7 +528,7 @@ export type Ticket = { transferRequests?: { id: string; toName: string; toEmail?: string | null; toPhone?: string | null; createdAt?: string }[]; event?: Event; registrationOption?: RegistrationOption; - usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[]; + usages?: { id: string; scannedAt: string; quantityRedeemed?: number; sessionId?: string | null }[]; }; export type Payment = { diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index c133409..2e83474 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -22,6 +22,6 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-e2e/types/**/*.ts"], "exclude": ["node_modules"] } diff --git a/package-lock.json b/package-lock.json index fda1ce2..2c959a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,26 @@ "version": "1.33.0", "license": "ISC", "devDependencies": { + "@playwright/test": "^1.64.0", "concurrently": "^9.2.1" } }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -175,6 +192,35 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 541ebac..1977a2c 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,16 @@ "start:frontend": "cd frontend && npm run start", "start": "concurrently \"npm run start:backend\" \"npm run start:frontend\"", "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", - "build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate" + "build": "cd frontend && npm run build && cd .. && cd backend && npm run prisma:generate", + "test:e2e": "playwright test", + "test": "npm --prefix backend test" }, "keywords": [], "author": "", "license": "ISC", "description": "", "devDependencies": { + "@playwright/test": "^1.64.0", "concurrently": "^9.2.1" }, "overrides": { diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..079763c --- /dev/null +++ b/playwright.config.ts @@ -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", + }, + }, + ], +}); diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..113ebeb --- /dev/null +++ b/scripts/deploy.sh @@ -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 }" +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"