Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Guidelines for AI coding agents working on the official Micro Club website.
- `src/components/registrations/`: the multi-step registration form (`/registrations`, route in `src/app/(registrations)/registrations/`). `state/` holds the store (form state outside React, saved as a draft in `sessionStorage`), the context, the submit hook and the unsaved-changes guard; `steps/` and `fields/` the step bodies and the field components; the stepper, actions, review dialog and `registration-flow.tsx` sit at the root. Fields subscribe to their own part of the state with `useRegistrationField`, so typing in one field does not render the others.
- `src/components/admin/`: the admin area (sign-in and password forms, users table, row actions; `registrations/` for the HR registrations list and page, with the review buttons, the recommendation dialog and the timeline, and the recommendations list). Forms that must be rate limited (sign in, forgot and reset password) call `authClient` (`src/lib/auth/client.ts`) over HTTP; the rest go through Server Actions.
- `src/lib/auth/`: Better Auth. `auth.ts` (config, `server-only`), `permissions.ts` (roles and permissions, shared with the browser), `session.ts` (`getSession`, `requireStaff`), `emails.ts`, `password.ts`, `schema.ts` (Zod), `client.ts`.
- `src/lib/registrations/`: registration code shared or server-side: `schema.ts` (Zod, shared with the browser: the Server Action stays the source of truth), `model.ts` (Mongoose, `server-only`), `upload-student-id.ts` (browser upload to Cloudinary), `status.ts` (registration and review states), `list-params.ts` (state of the admin list in the URL, shared), `review.ts` (admin list and page queries, `server-only`), `timeline.ts` (the append-only timeline of reviews and recommendations, and the rules computing a registration's status from it, shared), `timeline-store.ts` (its writes, `server-only`), `review-schema.ts` (Zod, shared), `recommendations.ts` (the sections and departments to recommend to), `recommendation-params.ts` (state of the recommendations list in the URL, shared), `recommendation-list.ts` (its queries, `server-only`). The contact form's code lives in `src/lib/contact/` (`schema.ts`, shared with the browser, and `email.ts`). Infrastructure used by more than one feature stays flat in `src/lib/` (`mongodb.ts`, `cloudinary.ts`, `rate-limit.ts`, `email.ts`).
- `src/lib/registrations/`: registration code shared or server-side: `schema.ts` (Zod, shared with the browser: the Server Action stays the source of truth), `model.ts` (Mongoose, `server-only`), `upload-student-id.ts` (browser upload to Cloudinary), `status.ts` (registration and review states), `list-params.ts` (state of the admin list in the URL, shared), `list-navigation.ts` (the previous and next arrows of a registration in the list it was opened from, shared), `review.ts` (admin list and page queries, `server-only`), `timeline.ts` (the append-only timeline of reviews and recommendations, and the rules computing a registration's status from it, shared), `timeline-store.ts` (its writes, `server-only`), `review-schema.ts` (Zod, shared), `recommendations.ts` (the sections and departments to recommend to), `recommendation-params.ts` (state of the recommendations list in the URL, shared), `recommendation-list.ts` (its queries, `server-only`). The contact form's code lives in `src/lib/contact/` (`schema.ts`, shared with the browser, and `email.ts`). Infrastructure used by more than one feature stays flat in `src/lib/` (`mongodb.ts`, `cloudinary.ts`, `rate-limit.ts`, `email.ts`).
- `src/actions/`: Server Actions (`"use server"`), in `admin/` (staff only, guarded by `requireStaff()`) or `public/` (site visitors, rate limited), kept thin: validate input, rate limit, then call a `server-only` module in `src/lib/` (e.g. `src/lib/microbot-api.ts`, `src/lib/cloudinary.ts`), the only place that reads its secrets from `process.env`. Never expose a secret with `NEXT_PUBLIC_`; document every variable in `.env.example`.
- `src/config/`: site metadata, section anchors, menu links, admin routes (`admin.ts`).
- `src/const/`: static content constants, one file per section named like the section file (e.g. `src/const/statistics.ts` exports `STATISTICS` and its types).
Expand All @@ -53,6 +53,7 @@ Guidelines for AI coding agents working on the official Micro Club website.
- `pnpm db:up` starts MongoDB from the root `docker-compose.yml` as a single-node replica set, like production: Better Auth writes in transactions (`pnpm db:down` stops it); `MONGODB_URI=mongodb://localhost:27017/microclub?replicaSet=rs0` in `.env.local`. Mongoose and Better Auth share one client (`getMongoClient()` in `src/lib/mongodb.ts`).
- Rate limits are counted in MongoDB, shared by every server instance: the Server Actions call `isRateLimited()` from `src/lib/rate-limit.ts` (`rate_limits` collection, keyed by `clientIp()`), Better Auth uses `rateLimit.storage: "database"` (`rateLimit` collection, on in production only). Both read the client IP from `x-real-ip`, then `x-forwarded-for`.
- `pnpm db:seed-registrations` fills the local database with fake registrations (emails `@seed.test`, replaced on each run; refuses a non-local `MONGODB_URI`). Their student IDs do not exist, so their page shows "File unavailable".
- `pnpm db:seed-staff` creates local test staff accounts, password `microclub-dev`, no forced password change: `president@seed.test`, `hr1@seed.test` and `hr2@seed.test` (recreated on each run; refuses a non-local `MONGODB_URI`).
- Emails go through `sendEmail()` in `src/lib/email.ts` (Resend, `RESEND_API_KEY`) with the shared branded layout (`renderEmail`). Without a key, development prints them to the server console.
- Student IDs are uploaded by the browser straight to Cloudinary with a signature from `src/actions/public/sign-student-id-upload.ts` (`authenticated` assets, no public URL), then `src/actions/public/submit-registration.ts` checks Cloudinary's signature of the upload response (no Admin API call: the free plan allows 500 per hour) and saves the registration. Rate limits there are per email, with a loose per-IP backstop (campus networks and CGNAT share one IP). Needs the `CLOUDINARY_*` variables.
- Registrations open and close with `REGISTRATIONS_WINDOW` in `src/config/registrations.ts` (`opensAt` / `closesAt`, ISO dates or `null`), read through `registrationsPhase()` in `src/lib/registrations/window.ts`. Outside the window `/registrations` is a 404 and the actions refuse submissions; the menu bar shows "Opening soon" or "Register now" with a countdown (`RegistrationsButton` in `RegistrationsCorner`), or nothing when closed.
Expand All @@ -63,7 +64,7 @@ Guidelines for AI coding agents working on the official Micro Club website.
- Guard every admin page and Server Action with `requireStaff({ permissions })` from `src/lib/auth/session.ts`. `src/proxy.ts` only checks that a session cookie exists.
- HR accounts are created from `/admin/users` with a generated password, emailed, and `mustChangePassword`, which locks the admin area until the user picks their own. Presidents are created and deactivated from the terminal only (`pnpm auth:create-president`, `pnpm auth:deactivate-president`): no role has `user:set-role`, and a `hooks.before` in `auth.ts` keeps a signed-in president from acting on non-HR accounts.
- Deactivating uses the admin plugin's ban: it blocks sign-in and ends every session.
- The registrations list is server-rendered from the URL (`?q=&status=&sort=&order=&page=`, see `list-params.ts`): search, filter, sort and page are links or GET forms, no client state. Links to a registration carry the list query, so its back link returns to the same view.
- The registrations list is server-rendered from the URL (`?q=&status=&sort=&order=&page=`, see `list-params.ts`): search, filter, sort and page are links or GET forms, no client state. Links to a registration carry the list query, so its back link returns to the same view and its previous and next arrows (and the ← → keys) walk through that list. Both lists show the oldest first by default. The "2nd Review" tab is not a status: it filters the pending registrations with exactly one reviewer (`ONE_REVIEWER_FILTER` in `timeline.ts`, the database version of `currentReviews`: change both together).
- Student IDs are served only by `/admin/registrations/[id]/student-id`, which checks the permission and streams the file from Cloudinary (`downloadStudentId`): never send a Cloudinary URL to the browser.

## Conventions
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ The registrations and the admin area need MongoDB: copy `.env.example` to `.env.
Staff sign in at `/admin` (Better Auth, email and password, no sign-up). Two roles, one per account:

- **President**: manages the HR accounts on `/admin/users` (create, deactivate, reactivate, reset password, delete). Each new account gets a generated password by email, changed at first sign-in.
- **HR**: reviews the registrations on `/admin/registrations`: a server-side list (search, status filter, sort, pages) and a page per registration with the student ID. Two HR members at most review an application, its status is computed from their reviews, and any HR member can recommend it to a section or department; both appear in its timeline. `/admin/recommendations` lists the recommendations, filtered by section, department and HR member.
- **HR**: reviews the registrations on `/admin/registrations`: a server-side list (search, status filter, sort, pages, oldest first by default; "2nd Review" lists the pending applications one HR member reviewed) and a page per registration with the student ID, with previous and next arrows (or the ← → keys) through the list it was opened from. Two HR members at most review an application, its status is computed from their reviews, and any HR member can recommend it to a section or department; both appear in its timeline. `/admin/recommendations` lists the recommendations, filtered by section, department and HR member.

The president's account is created from the terminal. The yearly handover: create the new president, then deactivate the old one.

Expand All @@ -44,6 +44,8 @@ pnpm auth:deactivate-president --email old.president@example.com

The scripts read `.env.local`: point `MONGODB_URI` (and `RESEND_API_KEY`, `AUTH_FROM_EMAIL`) at production to act there. Without a Resend key, the generated password is printed in the terminal.

For local testing, `pnpm db:seed-staff` creates `president@seed.test`, `hr1@seed.test` and `hr2@seed.test`, all with the password `microclub-dev` (local database only).

## Scripts

| Command | Description |
Expand All @@ -57,6 +59,7 @@ The scripts read `.env.local`: point `MONGODB_URI` (and `RESEND_API_KEY`, `AUTH_
| `pnpm format` | Format the codebase with Prettier |
| `pnpm db:up` / `pnpm db:down` | Start / stop the local MongoDB replica set (Docker) |
| `pnpm db:seed-registrations` | Fill the local database with fake registrations |
| `pnpm db:seed-staff` | Create local test staff accounts (see Admin area) |
| `pnpm auth:create-president` | Create a president account (see Admin area) |
| `pnpm auth:deactivate-president` | Deactivate a president account |

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"db:up": "docker compose up -d mongodb",
"db:down": "docker compose down",
"db:seed-registrations": "tsx --conditions=react-server --env-file-if-exists=.env.local scripts/seed-registrations.ts",
"db:seed-staff": "tsx --conditions=react-server --env-file-if-exists=.env.local scripts/seed-staff.ts",
"auth:create-president": "tsx --conditions=react-server --env-file-if-exists=.env.local scripts/create-president.ts",
"auth:deactivate-president": "tsx --conditions=react-server --env-file-if-exists=.env.local scripts/deactivate-president.ts"
},
Expand Down
10 changes: 10 additions & 0 deletions scripts/local-database.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** Shared by the seed scripts: they only ever write fake data to a local database. */
export function assertLocalDatabase() {
const uri = process.env.MONGODB_URI ?? ""
const host = /^mongodb(?:\+srv)?:\/\/(?:[^@/]*@)?([^:/?,]+)/.exec(uri)?.[1]
if (host !== "localhost" && host !== "127.0.0.1") {
throw new Error(
`Refusing to seed ${host ?? "an unknown database"}: only a local MONGODB_URI is allowed.`,
)
}
}
11 changes: 1 addition & 10 deletions scripts/seed-registrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type TimelineEntry,
} from "@/lib/registrations/timeline"

import { assertLocalDatabase } from "./local-database"
import { run } from "./run"

const SEED_EMAIL = /@seed\.test$/
Expand Down Expand Up @@ -144,16 +145,6 @@ function random(seed: number) {
}
}

function assertLocalDatabase() {
const uri = process.env.MONGODB_URI ?? ""
const host = /^mongodb(?:\+srv)?:\/\/(?:[^@/]*@)?([^:/?,]+)/.exec(uri)?.[1]
if (host !== "localhost" && host !== "127.0.0.1") {
throw new Error(
`Refusing to seed ${host ?? "an unknown database"}: only a local MONGODB_URI is allowed.`,
)
}
}

run(async () => {
assertLocalDatabase()
const { values } = parseArgs({ options: { count: { type: "string", default: "40" } } })
Expand Down
40 changes: 40 additions & 0 deletions scripts/seed-staff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Creates test staff accounts in the LOCAL database, to sign in to the admin area: one president
* and two HR members (two HR reviews settle an application). Refuses any database that is not on
* localhost. Running it again recreates them (their emails end with `@seed.test`, their sessions
* end); real accounts are never touched.
*
* pnpm db:seed-staff
*
* Every account has the password below and no forced password change.
*/
import { auth } from "@/lib/auth/auth"

import { assertLocalDatabase } from "./local-database"
import { run } from "./run"

const PASSWORD = "microclub-dev"

const STAFF = [
{ name: "Test President", email: "president@seed.test", role: "president" },
{ name: "Test HR One", email: "hr1@seed.test", role: "hr" },
{ name: "Test HR Two", email: "hr2@seed.test", role: "hr" },
] as const

run(async () => {
assertLocalDatabase()
const context = await auth.$context

for (const { name, email, role } of STAFF) {
const existing = await context.internalAdapter.findUserByEmail(email)
if (existing) await context.internalAdapter.deleteUser(existing.user.id)
// A server call without a session: the only way to set the `president` role.
await auth.api.createUser({ body: { name, email, password: PASSWORD, role } })
}

console.info(
`Seeded ${STAFF.length} staff accounts, password "${PASSWORD}":\n${STAFF.map(
({ email, role }) => ` - ${email} (${role})`,
).join("\n")}`,
)
})
Loading
Loading