Audience: Full-stack developers. This document covers the complete system architecture, database design, REST API contract, frontend implementation, security posture, and operational details.
- System Overview
- Repository Layout
- Infrastructure & Deployment
- Database
- Backend — API Server
- REST API Reference
- Frontend — React SPA
- Authentication & Authorisation
- Security Controls
- Data / Privacy Rules
- Development Workflow
MHC is a full-stack web application that manages and displays the historical records of the Monterey Hockey Club. It tracks:
- Member profiles — both playing members ("BRN" prefix) and non-playing volunteers ("MHC" prefix)
- A self-referencing recruitment tree (who recruited whom)
- Grand final appearances and rosters
- Committee roles held per year
- Trophy / award winners
- A static club history timeline (hardcoded in the frontend)
- Year-by-year overview aggregating all of the above
Public access: All GET endpoints are unauthenticated — the club's historical record is publicly readable.
Admin access: All mutating operations (POST, PUT, DELETE) require a valid JWT issued by the /api/auth/login endpoint.
MHC/
├── docker-compose.yml # Orchestrates db / server / client containers
├── client/ # React SPA (Vite)
│ ├── Dockerfile # Multi-stage: Node build → nginx:alpine serve
│ ├── nginx.conf # SPA fallback + /api/ reverse-proxy to server:5000
│ ├── vite.config.js # Dev-mode proxy mirrors nginx
│ └── src/
│ ├── App.jsx # Router tree
│ ├── components/Layout.jsx # Shell: header, nav, footer, dark-mode toggle
│ ├── contexts/AuthContext.jsx # React context wrapping token + user state
│ ├── services/
│ │ ├── api.js # All fetch wrappers (one export per API call)
│ │ └── tokenStore.js # localStorage token store with pub/sub
│ └── pages/ # One file per route (see §7)
└── server/ # Express API
├── Dockerfile # node:20-slim + openssl; runs prisma generate
├── src/
│ ├── index.js # App bootstrap: helmet, CORS, rate-limits, routes
│ ├── prismaClient.js # Singleton PrismaClient export
│ ├── schemas.js # All Zod request schemas
│ ├── middleware/
│ │ ├── auth.js # requireAuth — JWT Bearer verification
│ │ └── validate.js # Zod validation middleware factory
│ ├── utils/httpError.js # HttpError class + asyncHandler wrapper
│ ├── routes/ # Express routers (one per resource)
│ └── controllers/ # Business logic (one per resource)
└── prisma/
├── schema.prisma # Data model + datasource
├── seed.js # Initial data seed entry point
└── migrations/ # Prisma migration history (3 applied migrations)
Three services, one network (Docker default bridge):
| Service | Image / Build | Internal Port | Exposed |
|---|---|---|---|
db |
postgres:16 |
5432 | Not exposed to host |
server |
./server (Node 20) |
5000 | Not exposed to host |
client |
./client (nginx:alpine) |
80 | :80 → host |
All inter-service communication is internal. The only host-facing port is 80 (client). The API is never directly reachable from the internet — all traffic goes through the nginx reverse proxy in the client container.
Persistent storage: A named Docker volume mhc_postgres_data backs the PostgreSQL data directory.
Supplied at runtime via .env (not committed). Required variables:
| Variable | Consumer | Purpose |
|---|---|---|
POSTGRES_USER |
db, server | DB username |
POSTGRES_PASSWORD |
db, server | DB password |
POSTGRES_DB |
db, server | Database name |
DATABASE_URL |
server | Full Prisma connection string (postgresql://user:pass@db:5432/dbname) |
JWT_SECRET |
server | HS256 signing secret — must be a strong random string |
FRONTEND_ORIGIN |
server | Comma-separated allowed CORS origins (e.g. https://mhc.example.com) |
ADMIN_USERNAME |
server | Single admin account username |
ADMIN_PASSWORD_HASH |
server | bcrypt hash of admin password (use server/scripts/hashPassword.js to generate) |
NODE_ENV |
server | Set to production in the compose file |
TOKEN_TTL (optional) |
server | JWT expiry duration (default: 8h) |
client/nginx.conf:
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://server:5000; # Forward all /api/* to Express
}
location / {
try_files $uri $uri/ /index.html; # SPA fallback
}
}- All API calls from the browser go to the same origin (port 80), avoiding cross-origin issues in production.
VITE_API_URLis not needed in production;api.jsdefaults to/api(same-origin).
FROM node:20-alpine AS build # Stage 1: Vite build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build # Outputs to /app/dist
FROM nginx:alpine # Stage 2: Serve static files
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80FROM node:20-slim
RUN apt-get update -y && apt-get install -y openssl # Required by Prisma
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npx prisma generate # Generate PrismaClient from schema
RUN npm prune --omit=dev # Strip devDependencies from final image
EXPOSE 5000
CMD ["node", "src/index.js"]PostgreSQL 16, managed by Prisma ORM (v5.22).
Four application models plus one junction table:
The central entity. Primary key is a human-assigned string following a strict convention.
| Column | Type | Notes |
|---|---|---|
id |
String PK |
BRN### (players) or MHC### (volunteers). Validated by regex ^(BRN|MHC)\d{3}$ |
firstName |
String |
|
lastName |
String |
|
approximateDob |
DateTime |
Always stored as YYYY-01-01T00:00:00.000Z — see §10 |
yearJoined |
Int |
|
gamesPlayedTotal |
Int? |
Defaults to 0; nullable for volunteers |
gamesPlayed1stGrade |
Int? |
|
gamesPlayed2ndGrade |
Int? |
|
gamesPlayed3rdGrade |
Int? |
|
gamesPlayedSummerComp |
Int? |
|
goalsScored |
Int? |
|
isRetired |
Boolean |
Default false |
retirementYear |
Int? |
|
recruitedById |
String? FK |
Self-referencing FK → Member.id |
Self-referencing recruitment relation:
Member ─── recruitedBy ──→ Member (0 or 1)
Member ←── recruits ────── Member (0 to many)
| Column | Type | Notes |
|---|---|---|
id |
String PK |
e.g. GF_2023_1st — alphanumeric + underscores/dashes |
year |
Int |
|
grade |
String |
e.g. 1st Grade |
opponent |
String |
|
score |
String |
e.g. 3-1 |
result |
String |
Win, Loss, or Draw |
| Column | Type | Notes |
|---|---|---|
memberId |
String FK |
→ Member.id |
grandFinalId |
String FK |
→ GrandFinal.id |
isCaptain |
Boolean |
Default false |
| (composite PK) | [memberId, grandFinalId] |
Enforces uniqueness |
| Column | Type | Notes |
|---|---|---|
id |
Int PK |
Auto-increment |
memberId |
String FK |
→ Member.id |
position |
String |
e.g. President, Life Member |
year |
Int |
| Column | Type | Notes |
|---|---|---|
id |
Int PK |
Auto-increment |
memberId |
String FK |
→ Member.id |
awardName |
String |
e.g. Brian Wilson Memorial Trophy |
year |
Int |
Three applied migrations (timestamps are UTC):
| Migration | Change |
|---|---|
20260430035927_init |
Initial schema — all five tables |
20260430053933_add_retirement_fields |
Added isRetired, retirementYear to Member |
20260430104221_add_iscaptain_to_roster |
Added isCaptain to GrandFinalRoster |
Run migrations with: npx prisma migrate deploy (production) or npx prisma migrate dev (development).
Member ────────────────── GrandFinalRoster ─── GrandFinal
│ (0..* each side)
│
├──── CommitteeRole (1 Member → many roles)
├──── Trophy (1 Member → many trophies)
└──── Member (self-ref: recruiter → recruits)
| Package | Version | Role |
|---|---|---|
express |
^4.19 | HTTP framework |
@prisma/client |
^5.22 | Database ORM |
jsonwebtoken |
^9.0 | JWT sign / verify |
bcryptjs |
^3.0 | Password hash comparison |
helmet |
^8.1 | Security headers |
cors |
^2.8 | CORS enforcement |
express-rate-limit |
^8.4 | Rate limiting |
zod |
^4.4 | Request schema validation |
dotenv |
^16.4 | Env loading |
Middleware stack applied in order:
app.set('trust proxy', 1)— tells Express to trust one upstream proxy hop (nginx), required soexpress-rate-limitreads real client IPs fromX-Forwarded-For.helmet()— sets HSTS,X-Content-Type-Options,X-Frame-Options,Referrer-Policy, etc.crossOriginResourcePolicyis set tocross-originto allow the SPA to consume the API.- CORS — origins parsed from
FRONTEND_ORIGINenv var (comma-separated). Non-browser clients (noOriginheader) are allowed through. Credentials mode enabled. express.json({ limit: '50kb' })— body parser with hard payload cap.- Rate limiters — applied to all
/apiroutes (see §9). - Route handlers — each resource mounted under
/api/<resource>. - 404 handler — catches any unmatched
/api/*path. - Centralised error handler — 4-argument Express error middleware; maps known error types to HTTP status codes and suppresses stack traces in production.
HttpError (src/utils/httpError.js):
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
this.expose = true; // safe to send to client
}
}asyncHandler wraps every async controller so that thrown errors (and rejected promises) flow to the central error middleware rather than crashing the process.
Central error handler classification:
| Condition | HTTP Status | Client Response |
|---|---|---|
err.message.startsWith('CORS:') |
403 | { error: 'Origin not allowed' } |
err.type === 'entity.too.large' |
413 | { error: 'Payload too large' } |
err.type === 'entity.parse.failed' |
400 | { error: 'Malformed JSON' } |
err.expose === true (HttpError) |
err.status |
{ error: err.message } |
| Anything else (prod) | 500 | { error: 'Internal server error' } |
| Anything else (dev) | 500 | Full error details |
Prisma error codes are caught in controllers and mapped to HttpErrors:
| Prisma Code | Meaning | Mapped HTTP |
|---|---|---|
P2002 |
Unique constraint violated | 409 Conflict |
P2003 |
Foreign key constraint failed | 400 Bad Request |
P2025 |
Record not found | 404 Not Found |
A middleware factory: validate(schema, part = 'body').
- Calls
schema.parse(req[part])(Zod strict mode strips unknown fields). - On success, replaces
req[part]with the parsed/coerced value — this is the mass-assignment defence. - On
ZodError, returns400with an array of{ path, message }issues.
All schemas live in src/schemas.js and use .strict() — any unknown key is rejected.
Base path: /api
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/health |
None | Returns { status: 'OK', message: '...' } |
| Method | Path | Auth | Rate limit | Body | Response |
|---|---|---|---|---|---|
POST |
/auth/login |
None | 10 req / 15 min / IP | { username, password } |
{ token, username, role } |
GET |
/auth/me |
Bearer JWT | General | — | { username, role } |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/members |
None | Paginated, filterable, sortable member list |
GET |
/members/:id |
None | Full member detail with all relations |
GET |
/members/:id/recruitment-tree |
None | Recursive recruitment tree (depth-first) |
POST |
/members |
Bearer JWT | Create member |
PUT |
/members/:id |
Bearer JWT | Update member (partial supported) |
DELETE |
/members/:id |
Bearer JWT | Delete member |
GET /members query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
search |
string | — | Case-insensitive match on firstName or lastName |
type |
player | volunteer |
— | Filters by ID prefix (BRN / MHC) |
status |
active | retired |
— | Filters by isRetired flag |
yearJoined |
integer | — | Exact year match |
sortBy |
id | name | yearJoined | games | goals | age |
id |
Sort column |
sortDir |
asc | desc |
asc |
Sort direction |
page |
integer ≥ 1 | 1 | Page number |
pageSize |
integer 1–500 | 50 | Records per page |
GET /members response envelope:
{
"data": [ /* Member[] — scalar fields only, no relations */ ],
"total": 250,
"page": 1,
"pageSize": 50
}GET /members/:id response — full member with relations:
{
"id": "BRN001",
"firstName": "...",
"approximateDob": "1960-01-01T00:00:00.000Z",
"yearJoined": 1978,
"gamesPlayedTotal": 312,
"...",
"recruitedBy": { "id": "MHC000", "firstName": "Jack", "lastName": "Fox" },
"recruits": [ { "id": "BRN005", ... } ],
"committeeRoles": [ { "id": 1, "position": "President", "year": 1985 } ],
"trophies": [ { "id": 3, "awardName": "Brian Wilson Memorial Trophy", "year": 1990 } ],
"grandFinals": [ { "memberId": "BRN001", "grandFinalId": "GF_1988_1C", "isCaptain": false, "grandFinal": { ... } } ]
}Recruitment tree — recursive structure:
{
"id": "BRN001",
"firstName": "...",
"yearJoined": 1978,
"recruits": [
{
"id": "BRN010",
"recruits": [ ... ]
}
]
}| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/grand-finals |
None | All grand finals with rosters (ordered year desc, grade asc) |
GET |
/grand-finals/:id |
None | Single grand final with roster |
POST |
/grand-finals |
Bearer JWT | Create grand final |
PUT |
/grand-finals/:id |
Bearer JWT | Update grand final |
DELETE |
/grand-finals/:id |
Bearer JWT | Delete grand final |
GET |
/grand-finals/:grandFinalId/roster |
None | Roster for a specific grand final |
POST |
/grand-finals/:grandFinalId/roster |
Bearer JWT | Add member to roster |
DELETE |
/grand-finals/:grandFinalId/roster/:memberId |
Bearer JWT | Remove member from roster |
Grand final ID format: alphanumeric with underscores/dashes only (e.g. GF_2023_1st).
result field is an enum: Win, Loss, or Draw.
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/trophies |
None | All trophies with member detail (year desc, name asc) |
GET |
/trophies/:id |
None | Single trophy |
POST |
/trophies |
Bearer JWT | Create trophy |
PUT |
/trophies/:id |
Bearer JWT | Update trophy |
DELETE |
/trophies/:id |
Bearer JWT | Delete trophy |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/committee-roles |
None | All committee roles with member detail (year desc, position asc) |
GET |
/committee-roles/:id |
None | Single role |
POST |
/committee-roles |
Bearer JWT | Create role |
PUT |
/committee-roles/:id |
Bearer JWT | Update role |
DELETE |
/committee-roles/:id |
Bearer JWT | Delete role |
| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/years |
None | Sorted list of all years that have any data (excludes placeholder 1970) |
GET |
/years/:year |
None | Aggregated overview for a year: members joined, committee, grand finals, trophies, life members inducted |
| Package | Version | Role |
|---|---|---|
react |
^19.2 | UI framework |
react-dom |
^19.2 | DOM rendering |
react-router-dom |
^7.14 | Client-side routing |
vite |
^8.0 | Build tool + dev server |
No UI component library — all styles are custom CSS with CSS custom properties (variables) for theming.
| Path | Component | Description |
|---|---|---|
/ |
→ /members redirect |
Default route |
/login |
LoginPage |
Admin login form |
/members |
MembersPage |
Paginated, filterable member directory |
/members/new |
MemberFormPage |
Create member form (auth required) |
/members/:id |
MemberDetailPage |
Full member profile |
/members/:id/edit |
MemberFormPage |
Edit member (auth required) |
/members/:id/recruitment-tree |
RecruitmentTreePage |
Interactive tree visualisation |
/grand-finals |
GrandFinalsPage |
All grand finals list |
/grand-finals/new |
GrandFinalDetailPage |
Create grand final (auth required) |
/grand-finals/:id |
GrandFinalDetailPage |
Grand final detail + roster management |
/trophies |
TrophiesPage |
Full trophy/award history |
/committee |
CommitteePage |
Committee roles across all years |
/years |
YearOverviewPage |
Year selector + aggregate view |
/years/:year |
YearOverviewPage |
Specific year overview |
/history |
HistoryPage |
Static club history timeline |
No Redux or Zustand — state is managed via:
- URL search params — all filter/sort/pagination state for the members list lives in the URL (
?search=…&type=player&page=2). This makes state persistent, shareable, and gives browser history navigation for free. - React Context —
AuthContextprovidesuser,isAuthenticated,login(),logout(), andloadingto all components viauseAuth()hook. - Local
useState— loading, error, and page-level data state per component.
On mount:
- Reads the stored JWT from
localStorageviatokenStore.getToken(). - If a token exists, calls
GET /api/auth/meto validate it server-side and hydrate theuserobject. - Subscribes to
tokenStorechanges (e.g. 401 auto-logout fromapi.jsclears the token → context re-validates).
On login:
- Calls
POST /api/auth/login. - Stores the received JWT via
tokenStore.setToken(). - Sets
userstate directly from the response.
A minimal pub/sub store backed by localStorage:
const KEY = 'mhc_token';
const listeners = new Set();
export const getToken = () => localStorage.getItem(KEY);
export const setToken = (token) => { /* set or remove */ listeners.forEach(cb => cb(token)); };
export const subscribe = (cb) => { listeners.add(cb); return () => listeners.delete(cb); };When api.js detects a 401 response, it calls setToken(null) — this notifies AuthContext, which re-validates (finds no token) and sets user to null, causing the UI to reflect logged-out state.
A thin fetch wrapper:
BASE_URL = import.meta.env.VITE_API_URL || '/api'— defaults to same-origin/api, which nginx proxies to the server container. Override via Vite env var for non-containerised development.- Automatically attaches
Authorization: Bearer <token>header when a token is in the store. - On any non-
2xxresponse, parses the JSON error body and throwsnew Error(err.error). - On
204 No Content, returnsnullinstead of trying to parse an empty body.
Implemented in Layout.jsx:
- State seeded from
localStorage.getItem('theme') === 'dark'. document.documentElement.setAttribute('data-theme', ...)toggles the root attribute.- All CSS is written against
[data-theme="dark"]and[data-theme="light"]custom-property overrides. - Preference persisted to
localStorageon every toggle.
MembersPage — the most complex page:
- All filter/sort/pagination state is in URL search params via
useSearchParams. - Uses a separate
searchDraftstate for the text input to avoid fetching on every keystroke; fetch is triggered only on form submit. updateParams()helper merges param updates while resetting the page to 1 when filters change.- Sortable column headers (
SortHeadercomponent) toggle direction when clicking an active column.
RecruitmentTreePage — renders the recursive tree returned by the API:
- A recursive
TreeNodecomponent handles collapse/expand at each node. - Depth is tracked via a CSS custom property
--depthfor indentation.
HistoryPage — a static timeline of club milestones hardcoded as a JS array. Not driven by the API.
YearOverviewPage — fetches /years on mount for the year selector, then fetches /years/:year when a year is selected. Groups committee members by position using a predefined display-order array.
Single-admin model — one username/password pair stored as environment variables. There is no user table or registration flow.
The admin password is never stored in plaintext. A bcrypt hash is stored in ADMIN_PASSWORD_HASH and generated offline using server/scripts/hashPassword.js. At login, bcrypt.compare() is always called regardless of whether the username matched — this prevents timing-based username enumeration.
- Algorithm: HS256 (implicit in
jsonwebtokendefault) - Payload:
{ sub: adminUsername, role: 'admin', iat, exp } - Expiry: 8 hours (configurable via
TOKEN_TTLenv var) - Secret: validated at startup — if
JWT_SECRETis not set, the server refuses to start:if (!JWT_SECRET) throw new Error('JWT_SECRET is not set. Refusing to start.');
Reads Authorization: Bearer <token> header. Returns 401 if:
- Header is missing or scheme is not
Bearer jwt.verify()throws (invalid signature, expired, malformed)
On success, sets req.user to the decoded payload and calls next().
| Operation | Protected? |
|---|---|
All GET routes |
Public |
POST /auth/login |
Public (but rate-limited at 10/15min/IP) |
All POST, PUT, DELETE routes |
requireAuth required |
Three buckets implemented with express-rate-limit:
| Bucket | Applied to | Limit |
|---|---|---|
| General | All /api routes |
300 req / 15 min / IP |
| Write | All POST/PUT/PATCH/DELETE on /api |
30 req / 15 min / IP |
| Login | POST /api/auth/login specifically |
10 req / 15 min / IP |
All buckets use standard RateLimit-* headers (standardHeaders: true), and legacy X-RateLimit-* headers are disabled.
helmet sets all of the following response headers:
| Header | Value / Effect |
|---|---|
Strict-Transport-Security |
HSTS with includeSubDomains |
X-Content-Type-Options |
nosniff — prevents MIME sniffing |
X-Frame-Options |
SAMEORIGIN — prevents clickjacking |
X-XSS-Protection |
Disabled (modern browsers use CSP instead) |
Referrer-Policy |
no-referrer |
Cross-Origin-Resource-Policy |
cross-origin (explicitly set — required for the API to be consumed by the SPA on the same origin through nginx) |
- Allowlist-driven: only origins in
FRONTEND_ORIGINare permitted. - Non-browser clients (no
Originheader) pass through (necessary for server-to-server and health checks). credentials: trueallows cookies to be sent (future-proofing; currently only Bearer tokens are used).- CORS denial returns
403 { error: 'Origin not allowed' }.
Every mutating endpoint (and the member list GET) passes through a Zod schema:
.strict()on all object schemas — unknown keys are rejected with a 400 error, preventing mass-assignment attacks.- Member ID format enforced by regex
^(BRN|MHC)\d{3}$. - Grand Final ID restricted to alphanumeric + underscores/dashes.
- All string fields have explicit max-length caps.
- All integer fields have min/max bounds (years: 1900–2100, counts: 0–100,000).
- Request body is replaced with the parsed (coerced + stripped) Zod output before reaching the controller.
express.json({ limit: '50kb' }) — requests with a body larger than 50 KB are rejected with 413.
In NODE_ENV=production, unhandled errors return a generic { error: 'Internal server error' } — no stack traces, Prisma error codes, or schema details are sent to the client. Full error details are logged server-side only.
Dates of birth are normalised server-side to YYYY-01-01 before database persistence — individual's birth day and month are never stored. See §10 for full detail.
The approximateDob column always stores 01/01/YYYY (January 1st of birth year). The sanitiseDob() function in membersController.js enforces this on both POST and PUT:
const sanitiseDob = (raw) => {
const d = new Date(raw);
if (isNaN(d.getTime())) throw new HttpError(400, 'Invalid approximateDob');
return new Date(`${d.getFullYear()}-01-01T00:00:00.000Z`);
};The frontend displays only the birth year and a derived approximate age (currentYear - birthYear). No day or month is stored or displayed.
The schema contains no email addresses, phone numbers, physical addresses, or government identifiers. The audit confirmed this.
| Prefix | Meaning |
|---|---|
BRN### |
Playing member ("Baggy Red Number") |
MHC### |
Non-playing volunteer / administrator |
MHC000 is a reserved ID for the club founder used as a sentinel "original member" node in the recruitment tree. The getYearOverview controller explicitly excludes MHC000 from "members joined in year" lists.
- Docker & Docker Compose
- Node.js 20+ (for running scripts locally)
# Copy and fill in the env file
cp .env.example .env # (create if not present — see §3 for required vars)
# Start all services
docker compose up --build
# App available at http://localhost:80Requires a local PostgreSQL instance. Set DATABASE_URL in server/.env.
# Terminal 1 — API server
cd server
npm install
npx prisma migrate dev # Apply migrations
npx prisma db seed # (optional) seed data
npm run dev # nodemon on port 5000
# Terminal 2 — React dev server
cd client
npm install
npm run dev # Vite on port 5173, proxies /api → localhost:5000server/prisma/seed.js is the entry point. The server/prisma/ directory contains numerous one-off data-load scripts (CSV importers, fixup scripts) used during initial data migration. These are not part of the normal seed flow — they were used manually during the data-migration phase.
cd server
node scripts/hashPassword.js
# Follow the prompt; copy the resulting hash into ADMIN_PASSWORD_HASH env var# From server/
npx prisma migrate dev # Create & apply a new migration (dev only)
npx prisma migrate deploy # Apply pending migrations (production)
npx prisma generate # Regenerate PrismaClient after schema change
npx prisma studio # Open Prisma Studio GUI (dev only)
npx prisma db seed # Run seed.js| Script | Purpose |
|---|---|
hashPassword.js |
Generate bcrypt hash for ADMIN_PASSWORD_HASH env var |
checkMhcIds.js |
Validate member ID consistency |
retireVolunteers.js |
Bulk-set retirement flags |
seedGrandFinals.js / seedGrandFinals2.js |
Historical grand final data load |
seedStirrerTrophies.js |
Load stirrer-of-year trophy data |
setActiveRoster.js |
Manage current-year roster |
setCaptains.js |
Mark captains in grand final rosters |