Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ca896130b | ||
|
|
d2d8cb1934 |
@@ -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
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Full interactive docs (with request/response examples) are available at `GET /docs?token=<admin-jwt>` 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 <jwt>
|
||||
|
||||
+12
-3
@@ -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=<admin-jwt>` 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=<admin-jwt>` 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
|
||||
```
|
||||
|
||||
@@ -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: ['<rootDir>/dist/'],
|
||||
};
|
||||
|
||||
Generated
+1073
-11
File diff suppressed because it is too large
Load Diff
+22
-6
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1812
File diff suppressed because it is too large
Load Diff
+26
-1800
File diff suppressed because it is too large
Load Diff
@@ -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<ErrorCode, number> = {
|
||||
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<string, string>;
|
||||
|
||||
constructor(code: ErrorCode, message: string, fields?: Record<string, string>) {
|
||||
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<string, string> {
|
||||
const fields: Record<string, string> = {};
|
||||
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<number, ErrorCode> = { 400: 'VALIDATION_FAILED', 401: 'UNAUTHENTICATED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 409: 'CONFLICT', 423: 'READ_ONLY', 429: 'RATE_LIMITED' };
|
||||
const PRISMA_CODE: Record<string, ErrorCode> = { 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.'));
|
||||
@@ -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 = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cross Events API v2</title>
|
||||
<link rel="stylesheet" href="/docs/v2/assets/swagger-ui.css">
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, sans-serif; }
|
||||
#login { max-width: 40rem; margin: 3rem auto; padding: 0 1rem; }
|
||||
#login input { width: 100%; padding: .5rem; font: inherit; border: 1px solid #9ca3af; border-radius: 6px; box-sizing: border-box; }
|
||||
#login button { margin-top: .75rem; padding: .5rem 1rem; font: inherit; background: #1d4ed8; color: #fff; border: 0; border-radius: 6px; cursor: pointer; }
|
||||
#login .err { color: #b91c1c; margin-top: .5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="login" hidden>
|
||||
<h1>Cross Events API v2</h1>
|
||||
<p>The API reference is for admins. Paste your admin token (after signing in, it's under <code>hope_events_token</code> in the browser's local storage).</p>
|
||||
<label for="token">Admin token</label>
|
||||
<input id="token" autocomplete="off">
|
||||
<button id="go" type="button">Show the API</button>
|
||||
<p class="err" id="err" role="alert"></p>
|
||||
</main>
|
||||
<div id="ui"></div>
|
||||
<script src="/docs/v2/assets/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var params = new URLSearchParams(location.search);
|
||||
var token = params.get('token') || sessionStorage.getItem('v2docs_token') || localStorage.getItem('hope_events_token') || '';
|
||||
function show(t) {
|
||||
fetch('/api/v2/openapi.json', { headers: { Authorization: 'Bearer ' + t } }).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (b) { throw new Error((b.error && b.error.message) || r.statusText); });
|
||||
return r.json();
|
||||
}).then(function (spec) {
|
||||
sessionStorage.setItem('v2docs_token', t);
|
||||
if (params.has('token')) history.replaceState(null, '', location.pathname);
|
||||
document.getElementById('login').hidden = true;
|
||||
var ui = SwaggerUIBundle({ spec: spec, dom_id: '#ui', deepLinking: true, persistAuthorization: false });
|
||||
ui.preauthorizeApiKey('bearerAuth', t);
|
||||
}).catch(function (e) {
|
||||
document.getElementById('login').hidden = false;
|
||||
document.getElementById('err').textContent = t ? 'That token did not work: ' + e.message : '';
|
||||
});
|
||||
}
|
||||
document.getElementById('go').addEventListener('click', function () { show(document.getElementById('token').value.trim()); });
|
||||
show(token);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -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<boolean>;
|
||||
resolveCampusFilter(value: string | undefined): Promise<CampusFilter>;
|
||||
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<O extends { id: string }>(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;
|
||||
};
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<Access, string> = {
|
||||
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<string, JsonSchema>; 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<string, Record<string, unknown>> = {};
|
||||
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 <token>`.',
|
||||
'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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<typeof pageQuery>;
|
||||
|
||||
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 = <T extends z.ZodType>(item: T) => z.object({ data: z.array(item), meta: pageMeta });
|
||||
|
||||
/** The response shape of a single v2 resource: `{ data: {...} }`. */
|
||||
export const single = <T extends z.ZodType>(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<T>(data: T[], total: number, { page, limit }: PageQuery) {
|
||||
return { data, meta: { page, limit, total, pages: Math.max(1, Math.ceil(total / limit)) } };
|
||||
}
|
||||
@@ -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<typeof absoluteUrl>[0], e: SummaryRow): z.input<typeof eventSummary> => ({
|
||||
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 })),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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<P, Q, B> = {
|
||||
params: P;
|
||||
query: Q;
|
||||
body: B;
|
||||
user: ApiUser | null;
|
||||
req: Request;
|
||||
res: Response;
|
||||
};
|
||||
|
||||
type Schema = z.ZodType;
|
||||
type Infer<S> = S extends z.ZodType ? z.infer<S> : undefined;
|
||||
|
||||
export type RouteDef<P extends Schema | undefined, Q extends Schema | undefined, B extends Schema | undefined, R extends Schema> = {
|
||||
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<P>, Infer<Q>, Infer<B>>) => Promise<z.input<R>> | z.input<R>;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AnyRoute = RouteDef<any, any, any, any>;
|
||||
|
||||
export const routes: AnyRoute[] = [];
|
||||
|
||||
const ACCESS: Record<Access, RequestHandler[]> = {
|
||||
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<P extends Schema | undefined, Q extends Schema | undefined, B extends Schema | undefined, R extends Schema>(def: RouteDef<P, Q, B, R>): RouteDef<P, Q, B, R> {
|
||||
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 = <S extends Schema | undefined>(schema: S, value: unknown): Infer<S> =>
|
||||
(schema ? schema.parse(value) : undefined) as Infer<S>;
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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<string, Record<string, Record<string, unknown>>>)[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');
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
+27
-3
@@ -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/<area>.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 `<select>`.
|
||||
|
||||
+3
-3
@@ -38,8 +38,8 @@ Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an
|
||||
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
|
||||
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
|
||||
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
|
||||
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. **Next: 1.51.0 TypeScript
|
||||
backend / `/api/v2`.**
|
||||
pass** shipped. 1.50.1 **pagination and search on long lists** and 1.50.2 **event picker and webhook guide** shipped. 1.51.0 **TypeScript backend and `/api/v2`
|
||||
foundations** shipped. **Next: 1.51.1 v2 write endpoints, then 1.51.2.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -1007,7 +1007,7 @@ income justify the hardware:
|
||||
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (Afrikaans removed from scope) | Polish before the big release |
|
||||
| 11 | 1.51.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 11 | 1.51.0 ✅, 1.51.1, 1.51.2 | TypeScript backend + OpenAPI + `/api/v2` alongside v1: 1.51.0 foundations (build, launcher, route declarations, errors, paging, OpenAPI at `/docs/v2`, first read endpoints); 1.51.1 write endpoints and services, typed frontend calls; 1.51.2 the rest, Express 5, v1 deprecation headers | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, expect, seed, API } from "./fixtures";
|
||||
|
||||
// /api/v2 (1.51.0) against the real server and database: paged public events, the v2 error
|
||||
// shape, the signed-in person, and the OpenAPI reference at /docs/v2.
|
||||
test.describe("API v2", () => {
|
||||
test("public events come a page at a time; errors have a code", async ({ request }) => {
|
||||
const res = await request.get(`${API}/api/v2/events?limit=2`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.meta).toMatchObject({ page: 1, limit: 2 });
|
||||
expect(body.meta.total).toBeGreaterThan(2);
|
||||
|
||||
const search = await (await request.get(`${API}/api/v2/events?q=free%20evening`)).json();
|
||||
expect(search.data.map((e: { title: string }) => e.title)).toEqual(["E2E Free Evening"]);
|
||||
|
||||
const one = await (await request.get(`${API}/api/v2/events/${seed().events.paid}`)).json();
|
||||
expect(one.data.ticketOptions.length).toBeGreaterThan(0);
|
||||
|
||||
const bad = await request.get(`${API}/api/v2/events?limit=1000`);
|
||||
expect(bad.status()).toBe(400);
|
||||
expect((await bad.json()).error).toMatchObject({ code: "VALIDATION_FAILED", fields: { limit: expect.any(String) } });
|
||||
|
||||
const missing = await request.get(`${API}/api/v2/events/00000000-0000-4000-8000-000000000000`);
|
||||
expect((await missing.json()).error.code).toBe("NOT_FOUND");
|
||||
});
|
||||
|
||||
test("me and my registrations need a token", async ({ request }) => {
|
||||
expect((await request.get(`${API}/api/v2/me`)).status()).toBe(401);
|
||||
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
const me = await (await request.get(`${API}/api/v2/me`, { headers })).json();
|
||||
expect(me.data).toMatchObject({ id: seed().users.user.id, role: "user" });
|
||||
const regs = await (await request.get(`${API}/api/v2/me/registrations?past=true`, { headers })).json();
|
||||
expect(regs.meta).toMatchObject({ page: 1, limit: 25 });
|
||||
expect(regs.data.length).toBe(Math.min(25, regs.meta.total));
|
||||
});
|
||||
|
||||
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
|
||||
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
|
||||
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
|
||||
|
||||
await page.goto(`${API}/docs/v2?token=${seed().users.admin.token}`);
|
||||
await expect(page.getByRole("heading", { name: /Cross Events API/ })).toBeVisible();
|
||||
await expect(page.locator(".opblock-summary-path", { hasText: "/events/{id}" })).toBeVisible();
|
||||
// The token isn't left in the address bar.
|
||||
expect(page.url()).not.toContain("token=");
|
||||
});
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.50.2",
|
||||
"version": "1.51.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.50.2",
|
||||
"version": "1.51.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.50.2",
|
||||
"version": "1.51.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.50.2",
|
||||
"version": "1.51.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.50.2",
|
||||
"version": "1.51.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.14.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.50.2",
|
||||
"version": "1.51.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
@@ -32,7 +32,8 @@ export default defineConfig({
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: [
|
||||
{
|
||||
command: "node ../e2e/support/prepare-db.js && node src/index.js",
|
||||
// tsx runs the TypeScript straight from src/ (never a stale dist/ build).
|
||||
command: "node ../e2e/support/prepare-db.js && npx tsx src/index.js",
|
||||
cwd: "backend",
|
||||
url: `http://localhost:${API_PORT}/api/settings/needs-setup`,
|
||||
timeout: 180_000,
|
||||
|
||||
+5
-3
@@ -63,7 +63,8 @@ rollback() {
|
||||
clear_pm2_config
|
||||
git checkout -q "$PREVIOUS"
|
||||
restore_pm2_config
|
||||
(cd backend && npm ci --silent) || true
|
||||
# Releases from 1.51.0 on have a TypeScript build; older ones run src/ as it is.
|
||||
(cd backend && npm ci --include=dev --silent && { ! grep -q '"build":' package.json || npm run build --silent; }) || true
|
||||
(cd frontend && npm ci --silent && npm run build --silent) || true
|
||||
}
|
||||
trap rollback ERR
|
||||
@@ -76,8 +77,9 @@ if [ ! -f "$PM2_CONFIG" ]; then
|
||||
echo " Created $PM2_CONFIG from the example — check its app names and port."
|
||||
fi
|
||||
|
||||
echo "==> Backend: install and migrate"
|
||||
(cd backend && npm ci && npm run prisma:deploy)
|
||||
echo "==> Backend: install, migrate and build"
|
||||
# --include=dev: the TypeScript build needs the type packages even where NODE_ENV=production.
|
||||
(cd backend && npm ci --include=dev && npm run prisma:deploy && npm run build)
|
||||
|
||||
echo "==> Frontend: install and build"
|
||||
(cd frontend && npm ci && npm run build)
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ chown "$APP_USER:$APP_USER" "$DIR/backend/.env" "$DIR/frontend/.env.production"
|
||||
chmod 600 "$DIR/backend/.env"
|
||||
|
||||
step "Install, migrate, build"
|
||||
as_app "cd '$DIR/backend' && npm ci --no-audit --no-fund && npx prisma migrate deploy && npx prisma generate"
|
||||
as_app "cd '$DIR/backend' && npm ci --include=dev --no-audit --no-fund && npx prisma migrate deploy && npx prisma generate && npm run build"
|
||||
as_app "cd '$DIR/frontend' && npm ci --no-audit --no-fund && npm run build"
|
||||
|
||||
step "Start with PM2"
|
||||
|
||||
Reference in New Issue
Block a user