diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c948c0..2f11272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.51.0] - 2026-10-29 + +### Added + +- **API v2** (`/api/v2`), the groundwork for 2.0: every endpoint is declared once with zod + schemas (`src/v2/route.ts`). The declaration validates the request, strips the response to what + the schema lists, and generates the **OpenAPI 3.1** document (`GET /api/v2/openapi.json`, admin) + shown at **`/docs/v2`** (Swagger UI, served locally). One error shape + `{ error: { code, message, fields? } }` with stable codes, and one list shape + `{ data, meta: { page, limit, total, pages } }`. First endpoints (read-only): `GET /events`, + `GET /events/:id`, `GET /me`, `GET /me/registrations`. v1 (`/api/…`) is unchanged. +- **TypeScript in the backend:** `tsconfig.json` (`allowJs`; `.ts` strict), `npm run build` → + `dist/`, `npm run typecheck`, `npm run dev` through tsx, Jest runs `.ts` via ts-jest. Typed + doors into JS modules in `src/v2/legacy.ts`. +- Tests: `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts`. + +### Changed + +- The server moved from `backend/src/index.js` to `backend/src/app.js`. `src/index.js` is now a + launcher that runs the matching build in `dist/` (or compiles on the fly with tsx when there is + none), so existing PM2 configs keep working unchanged. +- `scripts/deploy.sh` and `scripts/install.sh` build the backend (`npm ci --include=dev`, then + `npm run build`); a rollback to a release before 1.51.0 skips the build. The first update to + 1.51.0 by an older deploy script still starts (it compiles at start-up); the next update builds. +- e2e starts the backend with tsx. + ## [1.50.2] - 2026-10-29 ### Added diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index cd347e3..228e0cf 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -305,7 +305,8 @@ cd /opt/hope-events sudo -u hopeevents -H bash scripts/deploy.sh v1.41.2 # or no tag for the latest main ``` -It backs up the database, checks out the release, installs, migrates, builds and reloads PM2, +It backs up the database, checks out the release, installs, migrates, builds (the backend too, +since 1.51.0) and reloads PM2, and goes back to the previous version if anything fails first. `backend/.env`, `frontend/.env.production`, `ecosystem.config.js` and the nginx/frpc configs are not in git, so updates keep your changes from this guide. diff --git a/FEATURES.md b/FEATURES.md index 967fa56..2a77f02 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -889,7 +889,10 @@ can't start, the same jobs run on in-process timers. - **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are set. - 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`); **API v2** (`/api/v2`, 1.51.0) + with one error shape, paged lists and an OpenAPI 3.1 reference at `/docs/v2` (admins). +- **TypeScript backend** (1.51.0): `.ts` files are checked strictly, `.js` compiles as it is; + deploys build `dist/`, which `src/index.js` runs. - **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 diff --git a/WHATS-NEW.md b/WHATS-NEW.md index 3cf206e..2e973ed 100644 --- a/WHATS-NEW.md +++ b/WHATS-NEW.md @@ -12,6 +12,12 @@ Write for church admins, not developers: no environment variables, endpoints or developer log is CHANGELOG.md. A tagged version without a section here shows "No details given". --> +## 1.51.0 + +- **Groundwork for Cross Events 2.0.** Behind the scenes, the server is moving to a newer, safer + way of talking to the app, ready for the AI helpers in 2.0. Nothing changes in how you use + Cross Events. + ## 1.50.2 - **Finding an event quickly.** Wherever staff and supervisors choose an event (the scanner, at diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index 5616a32..1093c3b 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -2,6 +2,12 @@ Full interactive docs (with request/response examples) are available at `GET /docs?token=` on the running backend. +> **API v2 (1.51.0 on):** `/api/v2` is described by OpenAPI 3.1. The reference is at `/docs/v2` +> (admin token), the document at `GET /api/v2/openapi.json` (admin). It is generated from the route +> declarations, so it is not repeated here. v2 responses are `{ data }` (lists add +> `meta: { page, limit, total, pages }`, `?page=&limit=` up to 100) and errors +> `{ error: { code, message, fields? } }`. This file documents v1 (`/api/…`), which stays until 2.0. + All authenticated requests require: ``` Authorization: Bearer diff --git a/backend/README.md b/backend/README.md index 4cf19d4..4b1baf2 100644 --- a/backend/README.md +++ b/backend/README.md @@ -152,14 +152,22 @@ npm run prisma:generate ## Running the Server ```bash -# Development (auto-reload) +# Development (auto-reload; runs the TypeScript in src/ through tsx) npm run dev -# Production +# Production: build once per update, then start (src/index.js runs the build in dist/) +npm run build npm start + +# Type-check only +npm run typecheck ``` -Visit `/` for the status page. Visit `/docs?token=` for full API documentation (admin only). +Since 1.51.0 the backend is partly TypeScript (`allowJs`: `.js` compiles as it is, `.ts` is checked +strictly). `src/index.js` is a small launcher: with plain Node it runs `dist/` when `dist/build.json` +matches this version, otherwise it compiles on the fly with tsx. The server itself is `src/app.js`. + +Visit `/` for the status page. Visit `/docs?token=` for the v1 API documentation and `/docs/v2` for the v2 (OpenAPI) reference — both admin only. --- @@ -532,6 +540,7 @@ Setup: Admin → Site Settings → **Yoco**. Paste the secret key (checked with ### PM2 (recommended) ```bash npm install -g pm2 +npm run build pm2 start src/index.js --name hope-events-api pm2 save && pm2 startup ``` diff --git a/backend/jest.config.js b/backend/jest.config.js index dc0a5df..568035d 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,4 +1,8 @@ module.exports = { testEnvironment: 'node', - testMatch: ['**/tests/**/*.test.js'], + testMatch: ['**/tests/**/*.test.js', '**/tests/**/*.test.ts'], + // TypeScript (src/**/*.ts and tests) runs through ts-jest; plain JS is left as it is. + transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json' }] }, + moduleFileExtensions: ['ts', 'js', 'json'], + modulePathIgnorePatterns: ['/dist/'], }; diff --git a/backend/package-lock.json b/backend/package-lock.json index 0ca566b..08fa20f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "event-management-backend", - "version": "1.50.2", + "version": "1.51.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "event-management-backend", - "version": "1.50.2", + "version": "1.51.0", "hasInstallScript": true, "dependencies": { "@prisma/adapter-pg": "^7.10.1", @@ -29,11 +29,23 @@ "pg-boss": "^10.4.2", "qrcode": "^1.5.4", "raw-body": "^3.0.0", - "uuid": "^11.1.1" + "swagger-ui-dist": "^5.34.0", + "tsx": "^4.23.15", + "typescript": "^5.9.3", + "uuid": "^11.1.1", + "zod": "^4.4.3" }, "devDependencies": { + "@types/express": "^4.17.25", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^20.19.43", + "@types/supertest": "^7.2.1", + "@types/swagger-ui-dist": "^3.30.6", "jest": "^30.4.2", - "prisma": "^7.10.1" + "prisma": "^7.10.1", + "supertest": "^7.3.1", + "ts-jest": "^29.4.15" } }, "node_modules/@apm-js-collab/code-transformer": { @@ -732,6 +744,422 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@fast-csv/format": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", @@ -746,6 +1174,12 @@ "lodash.isnil": "^4.0.0" } }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@fast-csv/parse": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", @@ -761,6 +1195,12 @@ "lodash.uniq": "^4.5.0" } }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1377,6 +1817,19 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -1490,6 +1943,16 @@ "node": ">=14" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@parcel/watcher": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", @@ -2178,6 +2641,13 @@ } } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sentry/conventions": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.16.0.tgz", @@ -2391,6 +2861,34 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", @@ -2486,6 +2984,32 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -2493,6 +3017,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2520,6 +3051,28 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.26", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz", @@ -2527,12 +3080,36 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "14.18.63", - "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", - "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, "license": "MIT" }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/pg": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", @@ -2544,6 +3121,20 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -2554,6 +3145,39 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2561,6 +3185,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.1.tgz", + "integrity": "sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/swagger-ui-dist": { + "version": "3.30.6", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-dist/-/swagger-ui-dist-3.30.6.tgz", + "integrity": "sha512-FVxN7wjLYRtJsZBscOcOcf8oR++m38vbUFjT33Mr9HBuasX9bRDrJsp7iwixcOtKSHEEa2B7o2+4wEiXqC+Ebw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3256,6 +3911,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -3610,6 +4272,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -3982,6 +4657,16 @@ "node": ">= 0.8" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compress-commons": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", @@ -4053,6 +4738,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4417,6 +5109,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dfa": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", @@ -4662,6 +5365,47 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4968,6 +5712,13 @@ "fast-decode-uri-component": "^1.0.1" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", @@ -5124,6 +5875,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5154,6 +5923,20 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/fstream": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", @@ -5338,6 +6121,28 @@ "devOptional": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.8", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -7050,6 +7855,13 @@ "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", "license": "MIT" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -7135,6 +7947,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7405,6 +8224,13 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -8466,9 +9292,9 @@ "license": "Apache-2.0" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8871,6 +9697,99 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.4.1.tgz", + "integrity": "sha512-PVMkMrhKrSTtFhUU8jiWuGh/gyRMKiyCTCmm0+qVzRNxlfntmRKJkDhlEXN62x3HIb33sQ4kmL8lcSdccWAchQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.3.1.tgz", + "integrity": "sha512-vNaNCFoCR/edDbErQZp5KLNT8R4dSPHX7rlDLoE8hWLAm/878j6ShMbe8HK/E6RwwuhlSXVelXAIg9AxR5WWPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.34.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.34.0.tgz", + "integrity": "sha512-H6tAIjr3XqE31PbkZl0OFya1ziRLDfTwh3J6OHdumPtPCLfjTnyLeagl/55kFNZcTOz9a9QI9WP0FXldwiGJag==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/synckit": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", @@ -9087,12 +10006,106 @@ "node": "*" } }, + "node_modules/ts-jest": { + "version": "29.4.15", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.15.tgz", + "integrity": "sha512-s2qmEUYFRqbjz1wv0EmccNPVOb45HOFpFuhzQJLKShYVisuTbCFPIIAIV2EuK5rIxb4BejLDbH7dnBZfYjybUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.10", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <9", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-jest/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -9129,6 +10142,39 @@ "node": ">= 0.6" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/unicode-properties": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", @@ -9380,6 +10426,13 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "license": "ISC" }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -9591,6 +10644,15 @@ "engines": { "node": ">= 10" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index ed1ec27..98f5cbe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,11 +1,11 @@ { "name": "event-management-backend", - "version": "1.50.2", + "version": "1.51.0", "description": "Event Management System Backend", "main": "src/index.js", "scripts": { "start": "node src/index.js", - "dev": "node --watch src/index.js", + "dev": "tsx watch src/index.js", "test": "jest", "postinstall": "prisma generate", "prisma:generate": "prisma generate", @@ -14,7 +14,9 @@ "prisma:reset": "prisma migrate reset --force", "migrate:env-to-db": "node scripts/migrate-env-to-db.js", "migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run", - "set-demo": "node scripts/set-demo.js" + "set-demo": "node scripts/set-demo.js", + "build": "tsc -p tsconfig.json && node -e \"require('fs').writeFileSync('dist/build.json', JSON.stringify({ version: require('./package.json').version, builtAt: new Date().toISOString() }))\"", + "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { "@prisma/adapter-pg": "^7.10.1", @@ -37,11 +39,23 @@ "pg-boss": "^10.4.2", "qrcode": "^1.5.4", "raw-body": "^3.0.0", - "uuid": "^11.1.1" + "swagger-ui-dist": "^5.34.0", + "tsx": "^4.23.15", + "typescript": "^5.9.3", + "uuid": "^11.1.1", + "zod": "^4.4.3" }, "devDependencies": { + "@types/express": "^4.17.25", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^20.19.43", + "@types/supertest": "^7.2.1", + "@types/swagger-ui-dist": "^3.30.6", "jest": "^30.4.2", - "prisma": "^7.10.1" + "prisma": "^7.10.1", + "supertest": "^7.3.1", + "ts-jest": "^29.4.15" }, "overrides": { "uuid": "^11.1.1", @@ -54,6 +68,8 @@ "prisma@7.10.1": true, "@prisma/engines@7.10.1": true, "unrs-resolver@1.12.2": true, - "@parcel/watcher": false + "@parcel/watcher": false, + "@scarf/scarf": false, + "esbuild": false } } diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 0000000..e3695e9 --- /dev/null +++ b/backend/src/app.js @@ -0,0 +1,1812 @@ +const path = require('path'); +const dotenv = require('dotenv'); + +// Load environment variables +dotenv.config(); + +// Error monitoring — a no-op if SENTRY_DSN isn't set, so this is safe in every +// environment (dev, a fresh deploy that hasn't configured Sentry yet, etc.). +// Must run before express/@prisma/client are required below — Sentry's +// auto-instrumentation patches those modules via a require hook, which only +// works if Sentry.init() runs before they're first required into the cache. +if (process.env.SENTRY_DSN) { + const Sentry = require('@sentry/node'); + Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.NODE_ENV || 'development', + // Small single-VM deployment, not high-traffic — start conservative and raise + // this once real usage is visible in Sentry, rather than sampling every request. + tracesSampleRate: 0.1, + }); +} + +const express = require('express'); +const { version: API_VERSION } = require('../package.json'); +const cors = require('cors'); +const rateLimit = require('express-rate-limit'); +const prisma = require('./config/db'); +const { notFound, errorHandler } = require('./middleware/errorMiddleware'); +const getRawBody = require('raw-body'); + +// Initialize Express app +const app = express(); +const PORT = process.env.PORT || 3000; + +// Trust proxy — required when running behind a reverse proxy (e.g. nginx on a +// separate server) so req.ip / X-Forwarded-For are read correctly by +// express-rate-limit and friends. Accepts a hop count ("1"), "true"/"false", +// or a comma-separated list of trusted proxy IPs/CIDRs. +if (process.env.TRUST_PROXY) { + const raw = process.env.TRUST_PROXY.trim(); + let trustProxyValue; + if (raw === 'true') trustProxyValue = true; + else if (raw === 'false') trustProxyValue = false; + else if (/^\d+$/.test(raw)) trustProxyValue = parseInt(raw, 10); + else if (raw.includes(',')) trustProxyValue = raw.split(',').map((s) => s.trim()); + else trustProxyValue = raw; + app.set('trust proxy', trustProxyValue); + console.log(`[startup] trust proxy set to: ${JSON.stringify(trustProxyValue)}`); +} + +// CORS — allow only the configured frontend origin +const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000') + .split(',') + .map((o) => o.trim()); + +const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; +const isTesting = process.env.NODE_ENV === 'testing'; +const isProd = process.env.NODE_ENV === 'production'; +const modeLabel = isProd ? 'production' : isTesting ? 'testing' : 'development'; +console.log(`Running in ${modeLabel} mode.`); + +// In testing or production, require all critical env vars to be set at startup +if (isTesting || isProd) { + const required = ['DATABASE_URL', 'JWT_SECRET']; + const missing = required.filter(k => !process.env[k]); + if (missing.length > 0) { + console.error(`[startup] Missing required environment variables for ${modeLabel} mode: ${missing.join(', ')}`); + process.exit(1); + } +} + +app.use(cors({ + origin: (origin, callback) => { + // Allow requests with no origin (e.g. mobile apps, curl, Postman) + if (!origin) return callback(null, true); + // Always allow configured origins + if (allowedOrigins.includes(origin)) return callback(null, true); + // In dev or testing, also allow any LAN origin on the same port (e.g. phone on 192.168.x.x:3000) + if (isDev || isTesting) { + try { + const u = new URL(origin); + const isLan = /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(u.hostname); + if (isLan) return callback(null, true); + } catch {} + } + callback(new Error(`CORS: origin '${origin}' not allowed`)); + }, + credentials: true, +})); + +// Global rate limiter — 5000 requests per 5 minutes per IP +// Webhooks are excluded because they come from Yoco's servers and are already HMAC-verified +const globalLimiter = rateLimit({ + windowMs: 5 * 60 * 1000, + max: 5000, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many requests, please try again later.' }, + skip: (req) => req.path.startsWith('/api/webhooks'), +}); +app.use(globalLimiter); + +// Impersonation is strictly read-only: refuse every write made with an impersonation +// token before it can reach any route (see utils/impersonation.js). +app.use(require('./utils/impersonation').readOnlyGuard); + +// Raw body parsing middleware for webhooks +app.use((req, res, next) => { + if (req.path.startsWith('/api/webhooks')) { + getRawBody(req, { + length: req.headers['content-length'], + encoding: 'utf-8' + }, (err, rawBody) => { + if (err) return next(err); + req.rawBody = rawBody; + next(); + }); + } else { + next(); + } +}); + + +// Import routes +const userRoutes = require('./routes/userRoutes'); +const eventRoutes = require('./routes/eventRoutes'); +const registrationRoutes = require('./routes/registrationRoutes'); +const paymentRoutes = require('./routes/paymentRoutes'); +const ticketRoutes = require('./routes/ticketRoutes'); +const webhookRoutes = require('./routes/webhookRoutes'); +const uploadRoutes = require('./routes/uploadRoutes'); +const reportRoutes = require('./routes/reportRoutes'); +const formRoutes = require('./routes/formRoutes'); +const providerTransactionRoutes = require('./routes/providerTransactionRoutes'); +const automationRoutes = require('./routes/automationRoutes'); +const broadcastRoutes = require('./routes/broadcastRoutes'); +const whatsappBroadcastRoutes = require('./routes/whatsappBroadcastRoutes'); +const scheduledEmailRoutes = require('./routes/scheduledEmailRoutes'); +const sectionRoutes = require('./routes/sectionRoutes'); +const bannerRoutes = require('./routes/bannerRoutes'); +const whatsappRoutes = require('./routes/whatsappRoutes'); +const settingsRoutes = require('./routes/settingsRoutes'); +const setupRoutes = require('./routes/setupRoutes'); +const costRoutes = require('./routes/costRoutes'); +const cashupRoutes = require('./routes/cashupRoutes'); +const statsRoutes = require('./routes/statsRoutes'); +const adminRoutes = require('./routes/adminRoutes'); +const backupRoutes = require('./routes/backupRoutes'); +const yocoRoutes = require('./routes/yocoRoutes'); +const payfastRoutes = require('./routes/payfastRoutes'); +const privacyRoutes = require('./routes/privacyRoutes'); + +// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream +app.use('/api/webhooks', webhookRoutes); + +// Apply JSON body parser for all non-webhook routes +app.use(express.json()); + +// Count server errors (any 5xx response) for the Hub heartbeat. +app.use((req, res, next) => { + res.on('finish', () => { if (res.statusCode >= 500) require('./utils/errorCounter').recordError(); }); + next(); +}); + +// Licence suspended, revoked or missing: no new or edited events, registrations or payments (src/hub/readOnly.js). +app.use(require('./hub/readOnly').licenceReadOnly); + +// Health for monitoring (and the Hub): no secrets, no personal information. +app.get('/health', async (req, res) => { + const report = require('./hub/report'); + let db = true; + try { await prisma.$queryRaw`SELECT 1`; } catch { db = false; } + const health = db ? await report.health().catch(() => null) : null; + res.status(db ? 200 : 503).json({ + status: db ? 'ok' : 'error', + version: report.APP_VERSION, + db, + jobs: health?.jobs || null, + lastBackupAt: health?.lastBackupAt || null, + }); +}); + +// Use routes +// /api/v2 (1.51.0, TypeScript): consistent errors and paging, described by OpenAPI at /docs/v2. +app.use('/api/v2', require('./v2').v2Router()); +app.use('/docs/v2', require('./v2').v2DocsRouter()); +app.use('/api/hub', require('./routes/hubRoutes')); +app.use('/api/campuses', require('./routes/campusRoutes')); +app.use('/api/kids', require('./routes/kidsRoutes')); +app.use('/api/volunteers', require('./routes/volunteerRoutes')); +app.use('/api/series', require('./routes/seriesRoutes')); +app.use('/api/season-passes', require('./routes/seasonPassRoutes')); +app.use('/api/outbound-webhooks', require('./routes/outboundWebhookRoutes')); +app.use('/api/dashboard', require('./routes/dashboardRoutes')); +app.use('/api/saved-filters', require('./routes/savedFilterRoutes')); +app.use('/api/users', userRoutes); +app.use('/api/events', eventRoutes); +app.use('/api/registrations', registrationRoutes); +app.use('/api/payments', paymentRoutes); +app.use('/api/tickets', ticketRoutes); +app.use('/api/uploads', uploadRoutes); +app.use('/api/reports', reportRoutes); +app.use('/api/forms', formRoutes); +app.use('/api/provider-transactions', providerTransactionRoutes); +app.use('/api/yoco-transactions', providerTransactionRoutes); // older name +app.use('/api/automations', automationRoutes); +app.use('/api/broadcasts', broadcastRoutes); +app.use('/api/whatsapp-broadcasts', whatsappBroadcastRoutes); +app.use('/api/scheduled-emails', scheduledEmailRoutes); +app.use('/api/sections', sectionRoutes); +app.use('/api/banner', bannerRoutes); +app.use('/api/whatsapp', whatsappRoutes); +app.use('/api/privacy', privacyRoutes); +app.use('/api/settings', settingsRoutes); +app.use('/api/setup', setupRoutes); +app.use('/api/stats', statsRoutes); +app.use('/api', costRoutes); +app.use('/api/cashups', cashupRoutes); +app.use('/api/admin', adminRoutes); +app.use('/api/coupons', require('./routes/couponRoutes')); +app.use('/api/bundles', require('./routes/bundleRoutes')); +app.use('/api/consent', require('./routes/consentRoutes')); +app.use('/api/waitlist', require('./routes/waitlistRoutes')); +app.use('/api/scan-locks', require('./routes/scanLockRoutes')); +app.use('/api/allocations', require('./routes/allocationRoutes')); +app.use('/api/sponsorships', require('./routes/sponsorshipRoutes')); +app.use('/api/feedback', require('./routes/feedbackRoutes')); +app.use('/api/attendance', require('./routes/attendanceRoutes')); +app.use('/api/analytics', require('./routes/analyticsRoutes')); +app.use('/api/backups', backupRoutes); +app.use('/api/yoco', yocoRoutes); +app.use('/api/payfast', payfastRoutes); + +// Pre-warm the settings cache so synchronous helpers have DB values from startup +const { getSettingSync, warmCache } = require('./utils/settingsCache'); +warmCache().catch(() => {}); +// Uploaded branding assets can include SVGs, which may embed + +`; + + res.send(html); +}); + +// Error middleware +app.use(notFound); + +// Sentry captures the error here, then passes it through unchanged — errorHandler +// below remains the sole source of what's actually sent back to the client. +if (process.env.SENTRY_DSN) { + const Sentry = require('@sentry/node'); + Sentry.setupExpressErrorHandler(app); +} + +app.use(errorHandler); + +// Start server +app.listen(PORT, () => { + console.log(`Server is running at http://localhost:${PORT}`); + // Attempt a one-time background sync of attachment manifests into DB + try { + const { syncManifestsToDb } = require('./utils/attachmentsSync'); + const prismaShared = require('./config/db'); + setTimeout(async () => { + try { + const summary = await syncManifestsToDb(prismaShared, { dryRun: false }); + if ((summary.imported || 0) > 0) { + console.log(`[attachments sync] Imported ${summary.imported} attachments from manifests (skipped ${summary.skipped}).`); + } else { + console.log('[attachments sync] No attachments imported (either none found or already in DB).'); + } + } catch (e) { + console.warn('[attachments sync] Failed:', e?.message || e); + } + }, 1000); + } catch (e) { + console.warn('[attachments sync] 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)); + + // Check in with the Hub 30 s after starting, so the licence is fresh without waiting 15 minutes. + require('./hub/client').heartbeatSoon(); + + // Scheduled emails worker (polling) + try { + const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false'; + if (enabled) { + const { getDueJobs, updateJob, purgeSentJobs } = require('./utils/scheduledEmails'); + const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController'); + const { sendBroadcast } = require('./controllers/broadcastController'); + const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController'); + const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10); + setInterval(async () => { + try { + try { purgeSentJobs(24 * 60 * 60 * 1000); } catch (e) { console.warn('[scheduled emails] purge failed:', e?.message || e); } + const due = getDueJobs(new Date()); + if (!due || due.length === 0) return; + for (const job of due) { + try { + updateJob(job.id, { status: 'sending', attempts: (job.attempts || 0) + 1, lastError: null }); + let responseBody = null; let code = 200; + const mockRes = { + status: (c) => { code = c; return mockRes; }, + json: (b) => { responseBody = b; return mockRes; } + }; + if (job.broadcast && job.channel === 'whatsapp') { + const mockReq = { body: job.payload }; + await sendWhatsAppBroadcast(mockReq, mockRes); + } else if (job.broadcast) { + const mockReq = { body: job.payload }; + await sendBroadcast(mockReq, mockRes); + } else if (job.channel === 'whatsapp') { + const mockReq = { params: { id: job.eventId }, body: job.payload }; + await whatsappEventAttendees(mockReq, mockRes); + } else { + const mockReq = { params: { id: job.eventId }, body: job.payload }; + await emailEventAttendees(mockReq, mockRes); + } + if (code >= 200 && code < 300) { + updateJob(job.id, { status: 'sent', sentAt: new Date().toISOString(), lastResult: responseBody || null }); + } else { + updateJob(job.id, { status: 'error', lastError: (responseBody && responseBody.message) ? responseBody.message : `HTTP ${code}` }); + } + } catch (e) { + updateJob(job.id, { status: 'error', lastError: String(e?.message || e) }); + } + } + } catch (e) { + console.warn('[scheduled emails] poll failed:', e?.message || e); + } + }, isNaN(intervalMs) ? 30000 : intervalMs); + console.log('[scheduled emails] Worker initialized. Set SCHEDULED_EMAILS_ENABLED=false to disable.'); + } else { + console.log('[scheduled emails] Worker disabled by env SCHEDULED_EMAILS_ENABLED=false'); + } + } catch (e) { + console.warn('[scheduled emails] Worker not started:', e?.message || e); + } +}); + +// 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); + if (process.env.SENTRY_DSN) { + try { require('@sentry/node').captureException(err); } catch {} + } +}); + +module.exports = { app, prisma }; \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index 01203a6..1ca4ee2 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,1808 +1,34 @@ +// Starts the backend (the server itself is app.js). +// +// Since 1.51.0 part of the backend is TypeScript. Production runs the compiled build in dist/ +// (`npm run build`, done by scripts/deploy.sh), but every install's PM2 config points here, at +// src/index.js — so with plain Node this hands over to dist/ when its build matches this +// version, and otherwise compiles the TypeScript on the fly with tsx (e.g. right after an +// update made by an older deploy script, which didn't build). Under tsx or Jest, which load +// .ts files themselves, and when this file is the one in dist/, it just loads app.js. +const fs = require('fs'); const path = require('path'); -const dotenv = require('dotenv'); -// Load environment variables -dotenv.config(); - -// Error monitoring — a no-op if SENTRY_DSN isn't set, so this is safe in every -// environment (dev, a fresh deploy that hasn't configured Sentry yet, etc.). -// Must run before express/@prisma/client are required below — Sentry's -// auto-instrumentation patches those modules via a require hook, which only -// works if Sentry.init() runs before they're first required into the cache. -if (process.env.SENTRY_DSN) { - const Sentry = require('@sentry/node'); - Sentry.init({ - dsn: process.env.SENTRY_DSN, - environment: process.env.NODE_ENV || 'development', - // Small single-VM deployment, not high-traffic — start conservative and raise - // this once real usage is visible in Sentry, rather than sampling every request. - tracesSampleRate: 0.1, - }); -} - -const express = require('express'); -const { version: API_VERSION } = require('../package.json'); -const cors = require('cors'); -const rateLimit = require('express-rate-limit'); -const prisma = require('./config/db'); -const { notFound, errorHandler } = require('./middleware/errorMiddleware'); -const getRawBody = require('raw-body'); - -// Initialize Express app -const app = express(); -const PORT = process.env.PORT || 3000; - -// Trust proxy — required when running behind a reverse proxy (e.g. nginx on a -// separate server) so req.ip / X-Forwarded-For are read correctly by -// express-rate-limit and friends. Accepts a hop count ("1"), "true"/"false", -// or a comma-separated list of trusted proxy IPs/CIDRs. -if (process.env.TRUST_PROXY) { - const raw = process.env.TRUST_PROXY.trim(); - let trustProxyValue; - if (raw === 'true') trustProxyValue = true; - else if (raw === 'false') trustProxyValue = false; - else if (/^\d+$/.test(raw)) trustProxyValue = parseInt(raw, 10); - else if (raw.includes(',')) trustProxyValue = raw.split(',').map((s) => s.trim()); - else trustProxyValue = raw; - app.set('trust proxy', trustProxyValue); - console.log(`[startup] trust proxy set to: ${JSON.stringify(trustProxyValue)}`); -} - -// CORS — allow only the configured frontend origin -const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000') - .split(',') - .map((o) => o.trim()); - -const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV; -const isTesting = process.env.NODE_ENV === 'testing'; -const isProd = process.env.NODE_ENV === 'production'; -const modeLabel = isProd ? 'production' : isTesting ? 'testing' : 'development'; -console.log(`Running in ${modeLabel} mode.`); - -// In testing or production, require all critical env vars to be set at startup -if (isTesting || isProd) { - const required = ['DATABASE_URL', 'JWT_SECRET']; - const missing = required.filter(k => !process.env[k]); - if (missing.length > 0) { - console.error(`[startup] Missing required environment variables for ${modeLabel} mode: ${missing.join(', ')}`); - process.exit(1); +function builtApp() { + const dist = path.join(__dirname, '..', 'dist'); + try { + const built = JSON.parse(fs.readFileSync(path.join(dist, 'build.json'), 'utf8')); + if (built.version === require('../package.json').version) return path.join(dist, 'app.js'); + } catch { + // no build yet } + return null; } -app.use(cors({ - origin: (origin, callback) => { - // Allow requests with no origin (e.g. mobile apps, curl, Postman) - if (!origin) return callback(null, true); - // Always allow configured origins - if (allowedOrigins.includes(origin)) return callback(null, true); - // In dev or testing, also allow any LAN origin on the same port (e.g. phone on 192.168.x.x:3000) - if (isDev || isTesting) { - try { - const u = new URL(origin); - const isLan = /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(u.hostname); - if (isLan) return callback(null, true); - } catch {} - } - callback(new Error(`CORS: origin '${origin}' not allowed`)); - }, - credentials: true, -})); - -// Global rate limiter — 5000 requests per 5 minutes per IP -// Webhooks are excluded because they come from Yoco's servers and are already HMAC-verified -const globalLimiter = rateLimit({ - windowMs: 5 * 60 * 1000, - max: 5000, - standardHeaders: true, - legacyHeaders: false, - message: { message: 'Too many requests, please try again later.' }, - skip: (req) => req.path.startsWith('/api/webhooks'), -}); -app.use(globalLimiter); - -// Impersonation is strictly read-only: refuse every write made with an impersonation -// token before it can reach any route (see utils/impersonation.js). -app.use(require('./utils/impersonation').readOnlyGuard); - -// Raw body parsing middleware for webhooks -app.use((req, res, next) => { - if (req.path.startsWith('/api/webhooks')) { - getRawBody(req, { - length: req.headers['content-length'], - encoding: 'utf-8' - }, (err, rawBody) => { - if (err) return next(err); - req.rawBody = rawBody; - next(); - }); +if (path.basename(__dirname) === 'src' && !require.extensions['.ts']) { + const built = builtApp(); + if (built) { + module.exports = require(built); } else { - next(); + console.warn('[startup] No build for this version in dist/ — compiling TypeScript as it loads (run `npm run build`).'); + require('tsx/cjs'); + module.exports = require('./app'); } -}); - - -// Import routes -const userRoutes = require('./routes/userRoutes'); -const eventRoutes = require('./routes/eventRoutes'); -const registrationRoutes = require('./routes/registrationRoutes'); -const paymentRoutes = require('./routes/paymentRoutes'); -const ticketRoutes = require('./routes/ticketRoutes'); -const webhookRoutes = require('./routes/webhookRoutes'); -const uploadRoutes = require('./routes/uploadRoutes'); -const reportRoutes = require('./routes/reportRoutes'); -const formRoutes = require('./routes/formRoutes'); -const providerTransactionRoutes = require('./routes/providerTransactionRoutes'); -const automationRoutes = require('./routes/automationRoutes'); -const broadcastRoutes = require('./routes/broadcastRoutes'); -const whatsappBroadcastRoutes = require('./routes/whatsappBroadcastRoutes'); -const scheduledEmailRoutes = require('./routes/scheduledEmailRoutes'); -const sectionRoutes = require('./routes/sectionRoutes'); -const bannerRoutes = require('./routes/bannerRoutes'); -const whatsappRoutes = require('./routes/whatsappRoutes'); -const settingsRoutes = require('./routes/settingsRoutes'); -const setupRoutes = require('./routes/setupRoutes'); -const costRoutes = require('./routes/costRoutes'); -const cashupRoutes = require('./routes/cashupRoutes'); -const statsRoutes = require('./routes/statsRoutes'); -const adminRoutes = require('./routes/adminRoutes'); -const backupRoutes = require('./routes/backupRoutes'); -const yocoRoutes = require('./routes/yocoRoutes'); -const payfastRoutes = require('./routes/payfastRoutes'); -const privacyRoutes = require('./routes/privacyRoutes'); - -// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream -app.use('/api/webhooks', webhookRoutes); - -// Apply JSON body parser for all non-webhook routes -app.use(express.json()); - -// Count server errors (any 5xx response) for the Hub heartbeat. -app.use((req, res, next) => { - res.on('finish', () => { if (res.statusCode >= 500) require('./utils/errorCounter').recordError(); }); - next(); -}); - -// Licence suspended, revoked or missing: no new or edited events, registrations or payments (src/hub/readOnly.js). -app.use(require('./hub/readOnly').licenceReadOnly); - -// Health for monitoring (and the Hub): no secrets, no personal information. -app.get('/health', async (req, res) => { - const report = require('./hub/report'); - let db = true; - try { await prisma.$queryRaw`SELECT 1`; } catch { db = false; } - const health = db ? await report.health().catch(() => null) : null; - res.status(db ? 200 : 503).json({ - status: db ? 'ok' : 'error', - version: report.APP_VERSION, - db, - jobs: health?.jobs || null, - lastBackupAt: health?.lastBackupAt || null, - }); -}); - -// Use routes -app.use('/api/hub', require('./routes/hubRoutes')); -app.use('/api/campuses', require('./routes/campusRoutes')); -app.use('/api/kids', require('./routes/kidsRoutes')); -app.use('/api/volunteers', require('./routes/volunteerRoutes')); -app.use('/api/series', require('./routes/seriesRoutes')); -app.use('/api/season-passes', require('./routes/seasonPassRoutes')); -app.use('/api/outbound-webhooks', require('./routes/outboundWebhookRoutes')); -app.use('/api/dashboard', require('./routes/dashboardRoutes')); -app.use('/api/saved-filters', require('./routes/savedFilterRoutes')); -app.use('/api/users', userRoutes); -app.use('/api/events', eventRoutes); -app.use('/api/registrations', registrationRoutes); -app.use('/api/payments', paymentRoutes); -app.use('/api/tickets', ticketRoutes); -app.use('/api/uploads', uploadRoutes); -app.use('/api/reports', reportRoutes); -app.use('/api/forms', formRoutes); -app.use('/api/provider-transactions', providerTransactionRoutes); -app.use('/api/yoco-transactions', providerTransactionRoutes); // older name -app.use('/api/automations', automationRoutes); -app.use('/api/broadcasts', broadcastRoutes); -app.use('/api/whatsapp-broadcasts', whatsappBroadcastRoutes); -app.use('/api/scheduled-emails', scheduledEmailRoutes); -app.use('/api/sections', sectionRoutes); -app.use('/api/banner', bannerRoutes); -app.use('/api/whatsapp', whatsappRoutes); -app.use('/api/privacy', privacyRoutes); -app.use('/api/settings', settingsRoutes); -app.use('/api/setup', setupRoutes); -app.use('/api/stats', statsRoutes); -app.use('/api', costRoutes); -app.use('/api/cashups', cashupRoutes); -app.use('/api/admin', adminRoutes); -app.use('/api/coupons', require('./routes/couponRoutes')); -app.use('/api/bundles', require('./routes/bundleRoutes')); -app.use('/api/consent', require('./routes/consentRoutes')); -app.use('/api/waitlist', require('./routes/waitlistRoutes')); -app.use('/api/scan-locks', require('./routes/scanLockRoutes')); -app.use('/api/allocations', require('./routes/allocationRoutes')); -app.use('/api/sponsorships', require('./routes/sponsorshipRoutes')); -app.use('/api/feedback', require('./routes/feedbackRoutes')); -app.use('/api/attendance', require('./routes/attendanceRoutes')); -app.use('/api/analytics', require('./routes/analyticsRoutes')); -app.use('/api/backups', backupRoutes); -app.use('/api/yoco', yocoRoutes); -app.use('/api/payfast', payfastRoutes); - -// Pre-warm the settings cache so synchronous helpers have DB values from startup -const { getSettingSync, warmCache } = require('./utils/settingsCache'); -warmCache().catch(() => {}); -// Uploaded branding assets can include SVGs, which may embed - -`; - - res.send(html); -}); - -// Error middleware -app.use(notFound); - -// Sentry captures the error here, then passes it through unchanged — errorHandler -// below remains the sole source of what's actually sent back to the client. -if (process.env.SENTRY_DSN) { - const Sentry = require('@sentry/node'); - Sentry.setupExpressErrorHandler(app); -} - -app.use(errorHandler); - -// Start server -app.listen(PORT, () => { - console.log(`Server is running at http://localhost:${PORT}`); - // Attempt a one-time background sync of attachment manifests into DB - try { - const { syncManifestsToDb } = require('./utils/attachmentsSync'); - const prismaShared = require('./config/db'); - setTimeout(async () => { - try { - const summary = await syncManifestsToDb(prismaShared, { dryRun: false }); - if ((summary.imported || 0) > 0) { - console.log(`[attachments sync] Imported ${summary.imported} attachments from manifests (skipped ${summary.skipped}).`); - } else { - console.log('[attachments sync] No attachments imported (either none found or already in DB).'); - } - } catch (e) { - console.warn('[attachments sync] Failed:', e?.message || e); - } - }, 1000); - } catch (e) { - console.warn('[attachments sync] 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)); - - // Check in with the Hub 30 s after starting, so the licence is fresh without waiting 15 minutes. - require('./hub/client').heartbeatSoon(); - - // Scheduled emails worker (polling) - try { - const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false'; - if (enabled) { - const { getDueJobs, updateJob, purgeSentJobs } = require('./utils/scheduledEmails'); - const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController'); - const { sendBroadcast } = require('./controllers/broadcastController'); - const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController'); - const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10); - setInterval(async () => { - try { - try { purgeSentJobs(24 * 60 * 60 * 1000); } catch (e) { console.warn('[scheduled emails] purge failed:', e?.message || e); } - const due = getDueJobs(new Date()); - if (!due || due.length === 0) return; - for (const job of due) { - try { - updateJob(job.id, { status: 'sending', attempts: (job.attempts || 0) + 1, lastError: null }); - let responseBody = null; let code = 200; - const mockRes = { - status: (c) => { code = c; return mockRes; }, - json: (b) => { responseBody = b; return mockRes; } - }; - if (job.broadcast && job.channel === 'whatsapp') { - const mockReq = { body: job.payload }; - await sendWhatsAppBroadcast(mockReq, mockRes); - } else if (job.broadcast) { - const mockReq = { body: job.payload }; - await sendBroadcast(mockReq, mockRes); - } else if (job.channel === 'whatsapp') { - const mockReq = { params: { id: job.eventId }, body: job.payload }; - await whatsappEventAttendees(mockReq, mockRes); - } else { - const mockReq = { params: { id: job.eventId }, body: job.payload }; - await emailEventAttendees(mockReq, mockRes); - } - if (code >= 200 && code < 300) { - updateJob(job.id, { status: 'sent', sentAt: new Date().toISOString(), lastResult: responseBody || null }); - } else { - updateJob(job.id, { status: 'error', lastError: (responseBody && responseBody.message) ? responseBody.message : `HTTP ${code}` }); - } - } catch (e) { - updateJob(job.id, { status: 'error', lastError: String(e?.message || e) }); - } - } - } catch (e) { - console.warn('[scheduled emails] poll failed:', e?.message || e); - } - }, isNaN(intervalMs) ? 30000 : intervalMs); - console.log('[scheduled emails] Worker initialized. Set SCHEDULED_EMAILS_ENABLED=false to disable.'); - } else { - console.log('[scheduled emails] Worker disabled by env SCHEDULED_EMAILS_ENABLED=false'); - } - } catch (e) { - console.warn('[scheduled emails] Worker not started:', e?.message || e); - } -}); - -// 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); - if (process.env.SENTRY_DSN) { - try { require('@sentry/node').captureException(err); } catch {} - } -}); - -module.exports = { app, prisma }; \ No newline at end of file diff --git a/backend/src/v2/errors.ts b/backend/src/v2/errors.ts new file mode 100644 index 0000000..78409bd --- /dev/null +++ b/backend/src/v2/errors.ts @@ -0,0 +1,82 @@ +import type { ErrorRequestHandler, RequestHandler } from 'express'; +import { ZodError } from 'zod'; + +/** + * /api/v2 errors all look the same: + * { "error": { "code": "VALIDATION_FAILED", "message": "…", "fields": { "limit": "…" } } } + * `code` is stable (clients switch on it), `message` is for people, `fields` only on validation. + */ +export type ErrorCode = + | 'VALIDATION_FAILED' + | 'UNAUTHENTICATED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'CONFLICT' + | 'RATE_LIMITED' + | 'READ_ONLY' + | 'INTERNAL'; + +export const ERROR_STATUS: Record = { + VALIDATION_FAILED: 400, + UNAUTHENTICATED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + RATE_LIMITED: 429, + READ_ONLY: 423, + INTERNAL: 500, +}; + +export class ApiError extends Error { + readonly code: ErrorCode; + readonly status: number; + readonly fields?: Record; + + constructor(code: ErrorCode, message: string, fields?: Record) { + super(message); + this.code = code; + this.status = ERROR_STATUS[code]; + this.fields = fields; + } +} + +export const notFound = (what = 'That'): ApiError => new ApiError('NOT_FOUND', `${what} wasn't found.`); + +/** "limit" or "items.0.quantity" → the first problem with it. */ +export function zodFields(err: ZodError): Record { + const fields: Record = {}; + for (const issue of err.issues) { + const key = issue.path.length ? issue.path.join('.') : '_'; + if (!fields[key]) fields[key] = issue.message; + } + return fields; +} + +const STATUS_CODE: Record = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED' }; +const PRISMA_CODE: Record = { P2002: 'CONFLICT', P2025: 'NOT_FOUND', P2003: 'CONFLICT' }; + +type LegacyError = Error & { statusCode?: number; status?: number; code?: string }; + +/** Turns anything thrown (ApiError, zod, Prisma, the v1 auth middleware's errors) into the v2 shape. */ +export function toApiError(err: unknown, responseStatus: number): ApiError { + if (err instanceof ApiError) return err; + if (err instanceof ZodError) return new ApiError('VALIDATION_FAILED', 'Some of what was sent is not valid.', zodFields(err)); + const e = (err || {}) as LegacyError; + if (typeof e.code === 'string' && PRISMA_CODE[e.code]) { + return new ApiError(PRISMA_CODE[e.code], e.code === 'P2002' ? 'A record with that value already exists.' : e.code === 'P2025' ? 'Record not found.' : 'A related record prevents this.'); + } + // The shared v1 middleware (protect, staff…) sets res.status() and passes an Error; + // utils throw errors with statusCode. Those messages are written for people already. + const status = e.statusCode || e.status || (responseStatus >= 400 ? responseStatus : 500); + const code = STATUS_CODE[status]; + if (code) return new ApiError(code, e.message || 'Request failed.'); + return new ApiError('INTERNAL', 'Something went wrong on the server.'); +} + +export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => { + const apiErr = toApiError(err, res.statusCode); + if (apiErr.code === 'INTERNAL') console.error('[api/v2]', req.method, req.originalUrl, err); + res.status(apiErr.status).json({ error: { code: apiErr.code, message: apiErr.message, ...(apiErr.fields ? { fields: apiErr.fields } : {}) } }); +}; + +export const notFoundHandler: RequestHandler = (_req, _res, next) => next(new ApiError('NOT_FOUND', 'There is no such API endpoint.')); diff --git a/backend/src/v2/index.ts b/backend/src/v2/index.ts new file mode 100644 index 0000000..3bc6fba --- /dev/null +++ b/backend/src/v2/index.ts @@ -0,0 +1,92 @@ +import express, { Router } from 'express'; +import path from 'path'; +import { mount, routes } from './route'; +import { buildOpenApi } from './openapi'; +import { errorHandler, notFoundHandler } from './errors'; +import auth from '../middleware/authMiddleware'; +// Resources: importing one registers its routes. +import './resources/events'; +import './resources/me'; + +const pkg = require('../../package.json') as { version: string }; + +/** /api/v2 — see route.ts for how endpoints are declared, openapi.ts for the docs. */ +export function v2Router(): Router { + const router = express.Router(); + + // The API description is for the people building on it (admins), like /docs. + router.get('/openapi.json', auth.protect, auth.admin, (_req, res) => { + res.json(buildOpenApi({ version: pkg.version })); + }); + + mount(router, routes); + router.use(notFoundHandler); + router.use(errorHandler); + return router; +} + +/** + * /docs/v2: Swagger UI (served from swagger-ui-dist, nothing from a CDN). The page itself is + * public; it asks for an admin token and uses it to load openapi.json and to try requests. + */ +export function v2DocsRouter(): Router { + const router = express.Router(); + const assets = path.dirname(require.resolve('swagger-ui-dist/package.json')); + router.use('/assets', express.static(assets, { index: false, maxAge: '1d' })); + router.get('/', (_req, res) => { + res.type('html').send(DOCS_HTML); + }); + return router; +} + +const DOCS_HTML = ` + + + + + Cross Events API v2 + + + + +
+

Cross Events API v2

+

The API reference is for admins. Paste your admin token (after signing in, it's under hope_events_token in the browser's local storage).

+ + + + +
+
+ + + +`; diff --git a/backend/src/v2/legacy.ts b/backend/src/v2/legacy.ts new file mode 100644 index 0000000..74cfe65 --- /dev/null +++ b/backend/src/v2/legacy.ts @@ -0,0 +1,32 @@ +/** + * Typed doors into the JavaScript side of the backend. TypeScript infers loose types from the + * .js files (the Prisma client is built by a function, helpers have defaulted options), so v2 + * code imports them from here instead. When a module is converted to TypeScript, its line here + * goes and callers import it directly. + */ +import type { PrismaClient } from '@prisma/client'; + +// The client in config/db.js only adds query hooks (webhooks), so its models are PrismaClient's. +export const prisma = require('../config/db') as PrismaClient; + +type CampusFilter = '' | 'all' | string; +const campuses = require('../utils/campuses') as { + campusesEnabled(): Promise; + resolveCampusFilter(value: string | undefined): Promise; + campusWhere(filter: CampusFilter, opts: { enabled: boolean; withAllCampuses?: boolean }): object; +}; +export const { campusesEnabled, resolveCampusFilter, campusWhere } = campuses; + +export const { publicEventWhere } = require('../utils/series') as { + publicEventWhere(now?: Date): { isActive: true; isHidden: false; goLiveAt: { lte: Date } }; +}; + +type OptionLike = { id: string; price: number; earlyBirdTiers?: unknown[] }; +export const { publicOptions } = require('../utils/volunteers') as { + publicOptions(event: { eventOptions?: O[]; kidsOptionId?: string | null; volunteerOptionId?: string | null }): O[]; +}; + +export const { getEffectiveUnitPrice, computeRegistrationTotalDue } = require('../utils/pricing') as { + getEffectiveUnitPrice(option: OptionLike, referenceTime: Date | null, atTime: Date): number; + computeRegistrationTotalDue(registration: object, atTime: Date): number; +}; diff --git a/backend/src/v2/openapi.ts b/backend/src/v2/openapi.ts new file mode 100644 index 0000000..8a16f6a --- /dev/null +++ b/backend/src/v2/openapi.ts @@ -0,0 +1,106 @@ +import { z } from 'zod'; +import { routes as allRoutes, type AnyRoute, type Access } from './route'; +import { ERROR_STATUS } from './errors'; + +/** + * The OpenAPI 3.1 document for /api/v2, built from the route declarations (route.ts), so it is + * always what the code does. Served at /api/v2/openapi.json, shown at /docs/v2. + */ + +type JsonSchema = Record; + +const toSchema = (schema: z.ZodType, io: 'input' | 'output'): JsonSchema => { + const { $schema: _drop, ...rest } = z.toJSONSchema(schema, { io, unrepresentable: 'any' }) as JsonSchema; + return rest; +}; + +const ACCESS_TEXT: Record = { + public: 'Anyone.', + optional: 'Anyone; signed-in staff may see more.', + user: 'Any signed-in person.', + staff: 'Staff, supervisors and admins.', + supervisor: 'Supervisors and admins.', + admin: 'Admins only.', +}; + +const errorSchema = { + type: 'object', + required: ['error'], + properties: { + error: { + type: 'object', + required: ['code', 'message'], + properties: { + code: { type: 'string', enum: Object.keys(ERROR_STATUS) }, + message: { type: 'string' }, + fields: { type: 'object', additionalProperties: { type: 'string' }, description: 'Per-field problems (VALIDATION_FAILED only)' }, + }, + }, + }, +}; + +const errorResponse = (description: string) => ({ description, content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } } }); + +function parameters(def: AnyRoute) { + const out: JsonSchema[] = []; + const add = (schema: z.ZodType | undefined, where: 'path' | 'query') => { + if (!schema) return; + const json = toSchema(schema, 'input') as { properties?: Record; required?: string[] }; + for (const [name, prop] of Object.entries(json.properties || {})) { + const { description, ...propSchema } = prop as JsonSchema & { description?: string }; + out.push({ name, in: where, required: where === 'path' || (json.required || []).includes(name), ...(description ? { description } : {}), schema: propSchema }); + } + }; + add(def.params, 'path'); + add(def.query, 'query'); + return out; +} + +export function buildOpenApi(opts: { version: string; serverUrl?: string; list?: AnyRoute[] }) { + const list = opts.list ?? allRoutes; + const paths: Record> = {}; + for (const def of list) { + const path = def.path.replace(/:(\w+)/g, '{$1}'); + const secured = def.access !== 'public' && def.access !== 'optional'; + paths[path] ||= {}; + paths[path][def.method] = { + tags: [def.tag], + summary: def.summary, + description: [def.description, `**Who:** ${ACCESS_TEXT[def.access]}`].filter(Boolean).join('\n\n'), + operationId: `${def.method}${path.replace(/[{}]/g, '').split('/').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}`, + ...(secured ? { security: [{ bearerAuth: [] }] } : def.access === 'optional' ? { security: [{}, { bearerAuth: [] }] } : {}), + parameters: parameters(def), + ...(def.body ? { requestBody: { required: true, content: { 'application/json': { schema: toSchema(def.body, 'input') } } } } : {}), + responses: { + [String(def.status ?? 200)]: { description: 'OK', content: { 'application/json': { schema: toSchema(def.response, 'output') } } }, + ...(def.params || def.query || def.body ? { 400: errorResponse('Validation failed (VALIDATION_FAILED)') } : {}), + ...(secured ? { 401: errorResponse('Not signed in (UNAUTHENTICATED)') } : {}), + ...(secured && def.access !== 'user' ? { 403: errorResponse('Not allowed for this role (FORBIDDEN)') } : {}), + ...(def.params ? { 404: errorResponse('Not found (NOT_FOUND)') } : {}), + }, + }; + } + return { + openapi: '3.1.0', + info: { + title: 'Cross Events API', + version: opts.version, + description: [ + 'Version 2 of the Cross Events API. Every response is JSON:', + '- a single item is `{ "data": { … } }`;', + '- a list is `{ "data": [ … ], "meta": { "page", "limit", "total", "pages" } }`, paged with `?page=` and `?limit=` (at most 100);', + '- an error is `{ "error": { "code", "message", "fields"? } }` with a stable `code`.', + '', + 'Dates are ISO 8601 in UTC; amounts are in rand. Sign in through the app and send the token as `Authorization: Bearer `.', + 'Version 1 (`/api/…`) keeps working while the app moves over; it will be removed in 2.0.', + ].join('\n'), + }, + servers: [{ url: opts.serverUrl ?? '/api/v2' }], + tags: Array.from(new Set(list.map(r => r.tag))).map(name => ({ name })), + paths, + components: { + securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } }, + schemas: { Error: errorSchema }, + }, + }; +} diff --git a/backend/src/v2/paging.ts b/backend/src/v2/paging.ts new file mode 100644 index 0000000..dfcd5ec --- /dev/null +++ b/backend/src/v2/paging.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +/** `?page=2&limit=25` on every v2 list (limit 1–100, default 25). */ +export const pageQuery = z.object({ + page: z.coerce.number().int().min(1).default(1).describe('Page number, from 1'), + limit: z.coerce.number().int().min(1).max(100).default(25).describe('Items per page (at most 100)'), +}); +export type PageQuery = z.infer; + +export const pageMeta = z.object({ + page: z.number().int(), + limit: z.number().int(), + total: z.number().int().describe('Items across all pages'), + pages: z.number().int().describe('Number of pages (at least 1)'), +}); + +/** The response shape of a v2 list: `{ data: [...], meta: { page, limit, total, pages } }`. */ +export const paged = (item: T) => z.object({ data: z.array(item), meta: pageMeta }); + +/** The response shape of a single v2 resource: `{ data: {...} }`. */ +export const single = (item: T) => z.object({ data: item }); + +/** Prisma's skip/take for a page. */ +export const skipTake = ({ page, limit }: PageQuery) => ({ skip: (page - 1) * limit, take: limit }); + +export function pageOf(data: T[], total: number, { page, limit }: PageQuery) { + return { data, meta: { page, limit, total, pages: Math.max(1, Math.ceil(total / limit)) } }; +} diff --git a/backend/src/v2/resources/events.ts b/backend/src/v2/resources/events.ts new file mode 100644 index 0000000..4b05c2b --- /dev/null +++ b/backend/src/v2/resources/events.ts @@ -0,0 +1,130 @@ +import { z } from 'zod'; +import { defineRoute } from '../route'; +import { notFound } from '../errors'; +import { pageQuery, paged, single, skipTake, pageOf } from '../paging'; +import { absoluteUrl, campusRef, id, idParams, iso, isoDate, seriesRef } from '../schemas'; +import { prisma, campusesEnabled, campusWhere, resolveCampusFilter, publicEventWhere, publicOptions, getEffectiveUnitPrice } from '../legacy'; + +const eventSummary = z.object({ + id, + title: z.string(), + startDate: isoDate, + endDate: isoDate, + location: z.string().nullable(), + price: z.number().describe('The headline price in rand (0 = free)'), + pictureUrl: z.string().nullable(), + requiresRegistration: z.boolean(), + scheduleType: z.enum(['single', 'sessions']), + campus: campusRef.nullable().describe('null = for all campuses (or campuses are off)'), + series: seriesRef.nullable(), +}); + +const ticketOption = z.object({ + id, + name: z.string(), + price: z.number().describe('The normal price in rand'), + currentPrice: z.number().describe('What it costs now (an early-bird price while one runs)'), + isMainTicket: z.boolean(), + sessionMode: z.enum(['all', 'pick']).describe('"pick" = the buyer chooses sessions'), +}); + +const session = z.object({ + id, + label: z.string().nullable(), + startsAt: isoDate, + endsAt: isoDate, + location: z.string().nullable(), + cancelled: z.boolean(), +}); + +const eventDetail = eventSummary.extend({ + description: z.string().nullable(), + registrationDeadline: isoDate.nullable(), + ticketOptions: z.array(ticketOption), + sessions: z.array(session).describe('Multi-day events only; empty otherwise'), +}); + +const SUMMARY_SELECT = { + id: true, title: true, startDate: true, endDate: true, location: true, price: true, picture: true, + requiresRegistration: true, scheduleType: true, + campus: { select: { id: true, name: true, slug: true } }, + series: { select: { id: true, name: true, slug: true } }, +} as const; + +type SummaryRow = { + id: string; title: string; startDate: Date; endDate: Date; location: string | null; price: number; picture: string | null; + requiresRegistration: boolean; scheduleType: 'single' | 'sessions'; + campus: { id: string; name: string; slug: string } | null; series: { id: string; name: string; slug: string } | null; +}; + +const toSummary = (req: Parameters[0], e: SummaryRow): z.input => ({ + id: e.id, title: e.title, startDate: iso(e.startDate), endDate: iso(e.endDate), location: e.location ?? null, + price: e.price, pictureUrl: absoluteUrl(req, e.picture), requiresRegistration: e.requiresRegistration, + scheduleType: e.scheduleType, campus: e.campus ?? null, series: e.series ?? null, +}); + +export const listEvents = defineRoute({ + method: 'get', + path: '/events', + tag: 'Events', + summary: 'Upcoming public events', + description: 'Events anyone can see (active, not hidden, live) that have not ended, soonest first.', + access: 'public', + query: pageQuery.extend({ + q: z.string().trim().max(100).optional().describe('Search the title'), + campus: z.string().max(64).optional().describe("A campus id or slug (its events plus church-wide ones), or 'all' for church-wide events only"), + series: z.string().max(120).optional().describe('A series slug'), + }), + response: paged(eventSummary), + async handler({ query, req }) { + const now = new Date(); + const enabled = await campusesEnabled(); + const and: object[] = [publicEventWhere(now), { endDate: { gte: now } }]; + if (query.campus) and.push(campusWhere(await resolveCampusFilter(query.campus), { enabled, withAllCampuses: true })); + if (query.series) and.push({ series: { slug: query.series } }); + if (query.q) and.push({ title: { contains: query.q, mode: 'insensitive' } }); + const where = { AND: and }; + const [rows, total] = await Promise.all([ + prisma.event.findMany({ where, select: SUMMARY_SELECT, orderBy: [{ startDate: 'asc' }, { id: 'asc' }], ...skipTake(query) }), + prisma.event.count({ where }), + ]); + return pageOf((rows as SummaryRow[]).map(e => toSummary(req, e)), total, query); + }, +}); + +export const getEvent = defineRoute({ + method: 'get', + path: '/events/:id', + tag: 'Events', + summary: 'One public event', + description: 'With its ticket options (internal kids check-in and volunteer tickets left out) and sessions. Hidden events can be fetched by id (they are unlisted, not private).', + access: 'public', + params: idParams, + response: single(eventDetail), + async handler({ params, req }) { + const now = new Date(); + const { isHidden: _hiddenOk, ...visible } = publicEventWhere(now); + const e = await prisma.event.findFirst({ + where: { id: params.id, ...visible }, + select: { + ...SUMMARY_SELECT, description: true, registrationDeadline: true, kidsOptionId: true, volunteerOptionId: true, + eventOptions: { include: { earlyBirdTiers: true }, orderBy: [{ isMainTicket: 'desc' }, { name: 'asc' }] }, + sessions: { orderBy: { startsAt: 'asc' } }, + }, + }); + if (!e) throw notFound('That event'); + const options = publicOptions(e); + return { + data: { + ...toSummary(req, e as unknown as SummaryRow), + description: e.description ?? null, + registrationDeadline: iso(e.registrationDeadline), + ticketOptions: options.map(o => ({ + id: o.id, name: o.name, price: o.price, currentPrice: getEffectiveUnitPrice(o, null, now), + isMainTicket: o.isMainTicket, sessionMode: o.sessionMode, + })), + sessions: e.sessions.map(s => ({ id: s.id, label: s.label, startsAt: iso(s.startsAt), endsAt: iso(s.endsAt), location: s.location, cancelled: !!s.cancelledAt })), + }, + }; + }, +}); diff --git a/backend/src/v2/resources/me.ts b/backend/src/v2/resources/me.ts new file mode 100644 index 0000000..5d62983 --- /dev/null +++ b/backend/src/v2/resources/me.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import { defineRoute } from '../route'; +import { ApiError } from '../errors'; +import { pageQuery, paged, single, skipTake, pageOf } from '../paging'; +import { eventRef, id, iso, isoDate } from '../schemas'; +import { prisma, computeRegistrationTotalDue } from '../legacy'; + +const me = z.object({ + id, + name: z.string(), + email: z.string(), + phone: z.string().nullable(), + role: z.enum(['admin', 'supervisor', 'staff', 'user']), + twoFactorEnabled: z.boolean(), + createdAt: isoDate, +}); + +export const getMe = defineRoute({ + method: 'get', + path: '/me', + tag: 'Me', + summary: 'The signed-in person', + access: 'user', + response: single(me), + async handler({ user }) { + const u = await prisma.user.findUnique({ + where: { id: user!.id }, + select: { id: true, name: true, email: true, phoneNumber: true, role: true, twoFactorEnabled: true, createdAt: true }, + }); + if (!u) throw new ApiError('UNAUTHENTICATED', 'User not found'); + return { data: { id: u.id, name: u.name, email: u.email, phone: u.phoneNumber ?? null, role: u.role, twoFactorEnabled: !!u.twoFactorEnabled, createdAt: iso(u.createdAt) } }; + }, +}); + +const STATUSES = ['pending', 'confirmed', 'partial_paid', 'paid', 'cancelled'] as const; + +const myRegistration = z.object({ + id, + status: z.enum(STATUSES), + createdAt: isoDate, + event: eventRef, + items: z.array(z.object({ + ticket: z.string(), + variant: z.string().nullable(), + quantity: z.number().int(), + unitPrice: z.number(), + })), + totalDue: z.number().describe('In rand, after discounts'), + amountPaid: z.number().describe('In rand (refunds subtracted)'), + balance: z.number().describe('totalDue − amountPaid; 0 or less = nothing owing'), +}); + +const r2 = (n: number) => Math.round(n * 100) / 100; + +export const listMyRegistrations = defineRoute({ + method: 'get', + path: '/me/registrations', + tag: 'Me', + summary: 'My registrations', + description: 'Newest first. By default only ones for events that have not ended, and not cancelled; `past=true` lists everything.', + access: 'user', + query: pageQuery.extend({ + past: z.enum(['true', 'false']).default('false').transform(v => v === 'true').describe('Include past and cancelled registrations'), + }), + response: paged(myRegistration), + async handler({ query, user }) { + const now = new Date(); + const where = query.past + ? { userId: user!.id } + : { userId: user!.id, status: { not: 'cancelled' as const }, event: { endDate: { gte: now } } }; + const [rows, total] = await Promise.all([ + prisma.registration.findMany({ + where, + include: { + event: { select: { id: true, title: true, startDate: true, endDate: true } }, + registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { name: true, price: true } }, tranches: true } }, + payments: { select: { amount: true, createdAt: true } }, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + ...skipTake(query), + }), + prisma.registration.count({ where }), + ]); + return pageOf(rows.map(r => { + const totalDue = r2(computeRegistrationTotalDue(r, now)); + const amountPaid = r2(r.payments.reduce((s, p) => s + p.amount, 0)); + return { + id: r.id, + status: r.status, + createdAt: iso(r.createdAt), + event: { id: r.event.id, title: r.event.title, startDate: iso(r.event.startDate), endDate: iso(r.event.endDate) }, + items: r.registrationOptions.map(o => ({ ticket: o.eventOption?.name ?? '', variant: o.variant?.name ?? null, quantity: o.quantity, unitPrice: o.priceSnapshot ?? o.eventOption?.price ?? 0 })), + totalDue, + amountPaid, + balance: r2(totalDue - amountPaid), + }; + }), total, query); + }, +}); diff --git a/backend/src/v2/route.ts b/backend/src/v2/route.ts new file mode 100644 index 0000000..83522dc --- /dev/null +++ b/backend/src/v2/route.ts @@ -0,0 +1,105 @@ +import type { Request, RequestHandler, Response, Router } from 'express'; +import { z } from 'zod'; +import { ApiError } from './errors'; +import auth from '../middleware/authMiddleware'; + +/** + * Every /api/v2 endpoint is declared with `defineRoute`: method, path, who may call it, and zod + * schemas for the params, query, body and response. The same declaration + * - validates the request (400 VALIDATION_FAILED with per-field messages), + * - checks the response against its schema and strips anything it doesn't list (so a field + * can't leak by being added to a Prisma include), and + * - becomes the OpenAPI document (openapi.ts) — the docs can't drift from the code. + */ + +export type Access = 'public' | 'optional' | 'user' | 'staff' | 'supervisor' | 'admin'; + +/** The signed-in person, as the auth middleware puts it on the request. */ +export type ApiUser = { id: string; name: string; email: string; role: 'admin' | 'supervisor' | 'staff' | 'user' }; + +export type Ctx = { + params: P; + query: Q; + body: B; + user: ApiUser | null; + req: Request; + res: Response; +}; + +type Schema = z.ZodType; +type Infer = S extends z.ZodType ? z.infer : undefined; + +export type RouteDef

= { + method: 'get' | 'post' | 'put' | 'patch' | 'delete'; + /** Express-style, relative to /api/v2: "/events/:id". */ + path: string; + summary: string; + description?: string; + tag: string; + access: Access; + /** + * Staff and supervisor routes must say how they treat campus-scoped staff (see + * utils/campusScope.js and tests/campusScopeRoutes.test.js) — e.g. "scopeEvent(params.id)". + */ + campusScope?: string; + params?: P; + query?: Q; + body?: B; + response: R; + /** HTTP status on success (default 200). */ + status?: number; + handler: (ctx: Ctx, Infer, Infer>) => Promise> | z.input; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyRoute = RouteDef; + +export const routes: AnyRoute[] = []; + +const ACCESS: Record = { + public: [], + optional: [auth.optionalAuth], + user: [auth.protect], + staff: [auth.protect, auth.staff], + supervisor: [auth.protect, auth.supervisor], + admin: [auth.protect, auth.admin], +}; + +export function defineRoute

(def: RouteDef): RouteDef { + if ((def.access === 'staff' || def.access === 'supervisor') && !def.campusScope) { + throw new Error(`${def.method.toUpperCase()} /api/v2${def.path}: staff and supervisor routes must declare campusScope`); + } + routes.push(def as AnyRoute); + return def; +} + +const parse = (schema: S, value: unknown): Infer => + (schema ? schema.parse(value) : undefined) as Infer; + +/** Adds every defined route to the router. */ +export function mount(router: Router, list: AnyRoute[] = routes): void { + for (const def of list) { + const run: RequestHandler = async (req, res, next) => { + try { + const ctx = { + params: parse(def.params, req.params), + query: parse(def.query, req.query), + body: parse(def.body, req.body ?? {}), + user: (req as Request & { user?: ApiUser }).user ?? null, + req, + res, + }; + const result = await def.handler(ctx); + const checked = def.response.safeParse(result); + if (!checked.success) { + console.error(`[api/v2] ${def.method.toUpperCase()} ${def.path}: response doesn't match its schema`, checked.error.issues); + throw new ApiError('INTERNAL', 'Something went wrong on the server.'); + } + res.status(def.status ?? 200).json(checked.data); + } catch (err) { + next(err); + } + }; + router[def.method](def.path, ...ACCESS[def.access], run); + } +} diff --git a/backend/src/v2/schemas.ts b/backend/src/v2/schemas.ts new file mode 100644 index 0000000..3c3a1ae --- /dev/null +++ b/backend/src/v2/schemas.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import type { Request } from 'express'; + +/** Shared pieces of v2 schemas. Dates go out as ISO 8601 strings (UTC). */ + +export const isoDate = z.iso.datetime(); +export const id = z.string().min(1).max(64); +export const idParams = z.object({ id: id.describe('The id') }); + +/** A Date (or date string) as ISO 8601, for building responses. */ +export function iso(d: Date | string): string; +export function iso(d: Date | string | null | undefined): string | null; +export function iso(d: Date | string | null | undefined): string | null { + return d == null ? null : new Date(d).toISOString(); +} + +/** A stored picture path ("/uploads/…") as an absolute URL on the host the request came to. */ +export function absoluteUrl(req: Request, url: string | null | undefined): string | null { + const raw = String(url || '').trim(); + if (!raw) return null; + if (/^https?:\/\//.test(raw)) return raw; + return `${req.protocol}://${req.get('host')}${raw.startsWith('/') ? '' : '/'}${raw}`; +} + +export const campusRef = z.object({ id, name: z.string(), slug: z.string() }); +export const seriesRef = z.object({ id, name: z.string(), slug: z.string() }); +export const eventRef = z.object({ id, title: z.string(), startDate: isoDate, endDate: isoDate }); diff --git a/backend/tests/v2.test.ts b/backend/tests/v2.test.ts new file mode 100644 index 0000000..9fe7694 --- /dev/null +++ b/backend/tests/v2.test.ts @@ -0,0 +1,152 @@ +import express from 'express'; +import request from 'supertest'; + +// /api/v2 (1.51.0): the route declarations validate requests, shape responses and errors the same +// way everywhere, and produce the OpenAPI document. + +const mockDb = { + event: { findMany: jest.fn(), count: jest.fn(), findFirst: jest.fn() }, + user: { findUnique: jest.fn() }, + registration: { findMany: jest.fn(), count: jest.fn() }, +}; +jest.mock('../src/config/db', () => mockDb); +jest.mock('../src/utils/campuses', () => ({ + campusesEnabled: jest.fn().mockResolvedValue(false), + resolveCampusFilter: jest.fn(async (v: string) => v), + campusWhere: jest.fn(() => ({})), +})); + +// Signed in as whoever the test says (the real middleware checks JWTs against the database). +let signedIn: { id: string; role: string } | null = null; +jest.mock('../src/middleware/authMiddleware', () => { + const deny = (status: number, message: string) => (_req: unknown, res: { status(n: number): void }, next: (e: Error) => void) => { res.status(status); next(new Error(message)); }; + return { + protect: (req: { user?: unknown }, res: { status(n: number): void }, next: (e?: Error) => void) => (signedIn ? ((req.user = signedIn), next()) : deny(401, 'Not authorized, no token')(req, res, next as (e: Error) => void)), + optionalAuth: (req: { user?: unknown }, _res: unknown, next: () => void) => { if (signedIn) req.user = signedIn; next(); }, + admin: (req: { user?: { role: string } }, res: { status(n: number): void }, next: (e?: Error) => void) => (req.user?.role === 'admin' ? next() : deny(403, 'Not authorized as an admin')(req, res, next as (e: Error) => void)), + staff: jest.fn(), supervisor: jest.fn(), + }; +}); + +import { v2Router } from '../src/v2'; +import { routes } from '../src/v2/route'; +import { buildOpenApi } from '../src/v2/openapi'; + +const app = express(); +app.use(express.json()); +app.use('/api/v2', v2Router()); + +const event = (over: Record = {}) => ({ + id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z'), + location: 'Camp Joy', price: 600, picture: '/uploads/events/camp.jpg', requiresRegistration: true, scheduleType: 'single', + campus: null, series: { id: 's1', name: 'Camps', slug: 'camps' }, ...over, +}); + +beforeEach(() => { signedIn = null; jest.clearAllMocks(); }); + +describe('GET /api/v2/events', () => { + test('a page of public events, with meta and ISO dates', async () => { + mockDb.event.findMany.mockResolvedValue([event()]); + mockDb.event.count.mockResolvedValue(31); + const res = await request(app).get('/api/v2/events?page=2&limit=10&q=camp'); + expect(res.status).toBe(200); + expect(res.body.meta).toEqual({ page: 2, limit: 10, total: 31, pages: 4 }); + expect(res.body.data[0]).toMatchObject({ id: 'e1', startDate: '2026-12-04T16:00:00.000Z', series: { slug: 'camps' } }); + expect(res.body.data[0].pictureUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/uploads\/events\/camp\.jpg$/); + const args = mockDb.event.findMany.mock.calls[0][0]; + expect(args).toMatchObject({ skip: 10, take: 10 }); + expect(args.where.AND).toEqual(expect.arrayContaining([{ isActive: true, isHidden: false, goLiveAt: { lte: expect.any(Date) } }, { title: { contains: 'camp', mode: 'insensitive' } }])); + }); + + test('fields the schema does not list are never sent', async () => { + mockDb.event.findMany.mockResolvedValue([event({ cashupStatus: 'closed', createdById: 'u9' })]); + mockDb.event.count.mockResolvedValue(1); + const res = await request(app).get('/api/v2/events'); + expect(Object.keys(res.body.data[0]).sort()).toEqual(['campus', 'endDate', 'id', 'location', 'pictureUrl', 'price', 'requiresRegistration', 'scheduleType', 'series', 'startDate', 'title']); + }); + + test('a bad query is a VALIDATION_FAILED error naming the field', async () => { + const res = await request(app).get('/api/v2/events?limit=500&page=0'); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + expect(Object.keys(res.body.error.fields).sort()).toEqual(['limit', 'page']); + expect(mockDb.event.findMany).not.toHaveBeenCalled(); + }); +}); + +describe('GET /api/v2/events/:id', () => { + test('leaves out kids and volunteer tickets and shows the early-bird price now', async () => { + mockDb.event.findFirst.mockResolvedValue({ + ...event(), description: 'A weekend away', registrationDeadline: null, kidsOptionId: 'kids', volunteerOptionId: null, + eventOptions: [ + { id: 'o1', name: 'Camper', price: 600, isMainTicket: true, sessionMode: 'all', earlyBirdTiers: [{ id: 't', price: 500, deadline: new Date(Date.now() + 864e5), stockLimit: 0 }] }, + { id: 'kids', name: 'Kids check-in', price: 0, isMainTicket: false, sessionMode: 'all', earlyBirdTiers: [] }, + ], + sessions: [], + }); + const res = await request(app).get('/api/v2/events/e1'); + expect(res.status).toBe(200); + expect(res.body.data.ticketOptions).toEqual([{ id: 'o1', name: 'Camper', price: 600, currentPrice: 500, isMainTicket: true, sessionMode: 'all' }]); + }); + + test('an unknown or private event is NOT_FOUND', async () => { + mockDb.event.findFirst.mockResolvedValue(null); + const res = await request(app).get('/api/v2/events/nope'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: { code: 'NOT_FOUND', message: "That event wasn't found." } }); + }); +}); + +describe('me', () => { + test('signed out is UNAUTHENTICATED in the v2 shape', async () => { + const res = await request(app).get('/api/v2/me'); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe('UNAUTHENTICATED'); + }); + + test('my registrations with totals and what is owing', async () => { + signedIn = { id: 'u1', role: 'user' }; + mockDb.registration.findMany.mockResolvedValue([{ + id: 'r1', status: 'partial_paid', createdAt: new Date('2026-10-01T10:00:00Z'), discountTotal: 0, + event: { id: 'e1', title: 'Youth Camp', startDate: new Date('2026-12-04T16:00:00Z'), endDate: new Date('2026-12-06T12:00:00Z') }, + registrationOptions: [{ quantity: 2, priceSnapshot: 600, eventOption: { name: 'Camper', price: 600, earlyBirdTiers: [] }, variant: null, tranches: [] }], + payments: [{ amount: 500, createdAt: new Date() }], + }]); + mockDb.registration.count.mockResolvedValue(1); + const res = await request(app).get('/api/v2/me/registrations'); + expect(res.status).toBe(200); + expect(res.body.data[0]).toMatchObject({ id: 'r1', totalDue: 1200, amountPaid: 500, balance: 700, items: [{ ticket: 'Camper', variant: null, quantity: 2, unitPrice: 600 }] }); + expect(mockDb.registration.findMany.mock.calls[0][0].where).toMatchObject({ userId: 'u1', status: { not: 'cancelled' } }); + }); +}); + +describe('the API description', () => { + test('openapi.json is for admins', async () => { + signedIn = { id: 'u1', role: 'user' }; + expect((await request(app).get('/api/v2/openapi.json')).body.error.code).toBe('FORBIDDEN'); + signedIn = { id: 'a1', role: 'admin' }; + const res = await request(app).get('/api/v2/openapi.json'); + expect(res.status).toBe(200); + expect(res.body.openapi).toBe('3.1.0'); + }); + + test('every route is described, with its parameters, auth and response', () => { + const doc = buildOpenApi({ version: 'test' }); + for (const r of routes) { + const op = (doc.paths as Record>>)[r.path.replace(/:(\w+)/g, '{$1}')][r.method]; + expect(op).toBeDefined(); + expect(op.responses).toHaveProperty(String(r.status ?? 200)); + } + const list = doc.paths['/events'].get as { parameters: { name: string; in: string }[]; security?: unknown }; + expect(list.parameters.map(p => p.name)).toEqual(['page', 'limit', 'q', 'campus', 'series']); + expect(list.security).toBeUndefined(); + expect((doc.paths['/me'].get as { security: unknown }).security).toEqual([{ bearerAuth: [] }]); + expect((doc.paths['/events/{id}'].get as { parameters: { name: string; in: string; required: boolean }[] }).parameters[0]).toMatchObject({ name: 'id', in: 'path', required: true }); + }); + + test('an unknown v2 path is a v2 NOT_FOUND', async () => { + const res = await request(app).get('/api/v2/nothing-here'); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('NOT_FOUND'); + }); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..fb96420 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,25 @@ +{ + // The backend is moving to TypeScript a file at a time (1.51.0): .js files are compiled as they + // are (not type-checked), .ts files are checked strictly. `npm run build` writes dist/, which is + // what production runs (dist/index.js); dev and the e2e tests run src/ through tsx. + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2023"], + "rootDir": "src", + "outDir": "dist", + "allowJs": true, + "checkJs": false, + "strict": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "types": ["node", "jest"] + }, + "include": ["src"] +} diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 64df75d..306b9ce 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,6 +1,6 @@ # Handover — where things stand and how to carry on -Last updated: 2026-10-29, after **1.50.2** (and Hub 1.5.0). The plan itself is in +Last updated: 2026-10-29, after **1.51.0** (and Hub 1.5.0). The plan itself is in [ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md). @@ -14,6 +14,7 @@ encrypted settings unreadable or break existing installs. | Version | What | |---------|------| +| 1.51.0 | **TypeScript backend + `/api/v2` foundations** (§14): tsconfig (`allowJs`), build to `dist/`, launcher `src/index.js` → `src/app.js`; v2 route declarations with zod → validation, response stripping and OpenAPI 3.1 at `/docs/v2`; first endpoints events, event, me, my registrations. Tests `tests/v2.test.ts`, e2e `e2e/api-v2.spec.ts` | | 1.50.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` | | 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` | | 1.50.0 | **Accessibility pass** (§11, WCAG 2.2 AA) on public pages, auth, registration, payment, My Events and the kiosk: skip link, focus ring, labelled fields, named quantity buttons, announced errors, `ModalOverlay` popups (focus trap, Escape), contrast and underlined in-text links, reduce-motion; Branding warns about a too-light primary colour. axe + keyboard e2e in `e2e/accessibility.spec.ts` | @@ -61,7 +62,9 @@ encrypted settings unreadable or break existing installs. Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on a test server (both done 2026-10-28). -1. **1.51.0 — TypeScript backend, OpenAPI and `/api/v2`** (§14), then **2.0.0 — AI helpers** (§15). +1. **1.51.1 — v2 write endpoints** (register, pay, check-in, users; split `registrationController` + and `eventController` into TS services as they move; typed frontend calls from the schemas), then + **1.51.2 — the rest of v2, Express 5, v1 deprecation headers**, then **2.0.0 — AI helpers** (§15). Languages (Afrikaans, §10) are removed from scope (2026-10-28). 2. AI rules decided 2026-10-29 (see §15 in the roadmap): the AI buttons show only when the licence's monthly cap is above R0, and are disabled (not hidden) once it's reached. The Hub @@ -82,7 +85,7 @@ a test server (both done 2026-10-28). full log; WHATS-NEW.md is what churches see before updating (the Hub reads it from main every 15 minutes, for tagged versions only and sets the latest version from the tags — nothing to do in the Hub). - **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md, - backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and + backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/app.js` (v2 documents itself), and in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`). - **Prisma migrations:** `npx prisma migrate dev --create-only --name x` against a local DB. Prisma 7 keeps adding an unrelated `_EventNotifyRecipients` primary-key/index @@ -450,6 +453,27 @@ a test server (both done 2026-10-28). `fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search picker would replace them; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array). +## TypeScript and API v2 (1.51.0) + +- **Starting the backend:** PM2 runs `src/index.js`, a launcher. With plain Node it runs + `dist/app.js` when `dist/build.json` has this version; otherwise tsx compiles on the fly (with a + warning). tsx and typescript are runtime dependencies for that fallback. `npm run dev` and e2e + use tsx, so they never pick up a stale `dist/`. The server is `src/app.js`. +- **Paths:** `dist/` mirrors `src/`, so `path.join(__dirname, '..', '..', 'public')` works from + both. Non-code files in `src/` aren't copied (the favicon is read from `src/`). +- **New code in TypeScript.** Convert a JS module when a release touches it; until then import it + through `src/v2/legacy.ts` with a typed signature. TypeScript infers loose types from JS (the + Prisma client especially), so v2 code takes `prisma` from there, typed as `PrismaClient`. +- **A v2 endpoint** is `defineRoute({ method, path, tag, summary, access, params?, query?, body?, + response, handler })` in `src/v2/resources/.ts`, imported in `src/v2/index.ts`. Lists use + `pageQuery`, `paged()` and `pageOf()`; single items `single()`. Dates go out as ISO strings + (`iso()`), so response schemas use `isoDate`. Throw `ApiError(code, message)` or `notFound()`. + The response is parsed by its schema: unknown fields are dropped, and a mismatch is a 500 plus a + log line (tests catch it). Staff/supervisor routes must declare `campusScope` (start-up fails + otherwise). +- Not yet v2-shaped: the global middleware in front of every route (rate limit, impersonation + read-only guard, licence read-only) still answers `{ message }`. Do that with 1.51.2. + ## Event picker and webhook guide (1.50.2) - **Choosing an event:** use `EventPicker` (`components/shared/EventPicker.tsx`), not a `