diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml
index 91baba1..2bf661a 100644
--- a/.github/workflows/api.yml
+++ b/.github/workflows/api.yml
@@ -23,7 +23,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
- version: 9
+ version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -75,7 +75,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
- version: 9
+ version: 10.33.0
- name: Setup Node.js
uses: actions/setup-node@v4
diff --git a/README.md b/README.md
index a360c27..edad57c 100644
--- a/README.md
+++ b/README.md
@@ -1,184 +1,28 @@
-
+
+
+
+ Event management for the University of Edinburgh's Computing Society and its Special Interest Groups (SIGs). +
-## Architecture - -This is a **pnpm monorepo** with three packages: - -| Package | Description | Tech Stack | -| ------------- | ---------------------- | ------------------------------------------------------- | -| `apps/api` | REST API server | Fastify, Drizzle ORM, PostgreSQL | -| `apps/web` | Frontend application | React 19, TanStack Router, TanStack Query, Tailwind CSS | -| `apps/shared` | Shared types & schemas | Zod, TypeScript | +--- ## Features -- **Event Management** - Create, edit, publish, and delete events -- **Event Registration** - Members can register for events with custom form fields -- **Registration Management** - Committee members can accept, reject, or waitlist registrations -- **Analytics** - View registration statistics and charts -- **Role-based Access** - Member and Committee roles with different permissions -- **Authentication** - Clerk authentication with webhook sync - -## Getting Started - -### 1. Install Dependencies - -```bash -pnpm install -``` - -### 2. Environment Setup - -**API** (`apps/api/.env`): - -```env -# Database -DATABASE_URL="postgresql://user:password@your-host.neon.tech/neondb?sslmode=require" - -# Clerk Authentication -CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key -CLERK_SECRET_KEY=sk_test_your_secret_key - -# Clerk Webhooks (for user sync) -CLERK_WEBHOOK_SECRET=whsec_your_webhook_secret -``` - -**Web** (`apps/web/.env.local`): - -```env -VITE_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key -VITE_API_URL=http://localhost:8080 -``` - -### 3. Database Setup - -Generate and run migrations: - -```bash -cd apps/api -pnpm db:migrate -``` - -### 4. Build Shared Package - -```bash -pnpm --filter @events.comp-soc.com/shared build -``` - -### 5. Start Development Servers - -```bash -# Run both API and Web -pnpm dev - -# Or run individually -pnpm dev:api # API on http://localhost:8080 -pnpm dev:web # Web on http://localhost:3000 -``` - -## Clerk Webhook Setup - -To sync users from Clerk to your database: - -1. Go to [Clerk Dashboard](https://dashboard.clerk.com) → Webhooks -2. Create a new endpoint with URL: `https://your-api-domain.com/webhooks/clerk` -3. Subscribe to events: `user.created`, `user.updated`, `user.deleted` -4. Copy the **Signing Secret** to `CLERK_WEBHOOK_SECRET` in your API `.env` - -For local development, use [ngrok](https://ngrok.com) to expose your local server: - -```bash -ngrok http 8080 -# Use the ngrok URL + /webhooks/clerk as your endpoint -``` - -## API Endpoints - -| Method | Endpoint | Description | -| -------- | ------------------------------------------- | -------------------------- | -| `GET` | `/health` | Health check | -| `GET` | `/v1/events` | List events | -| `POST` | `/v1/events` | Create event | -| `GET` | `/v1/events/:id` | Get event | -| `PUT` | `/v1/events/:id` | Update event | -| `DELETE` | `/v1/events/:id` | Delete event | -| `GET` | `/v1/events/:eventId/registrations` | List registrations | -| `POST` | `/v1/events/:eventId/registrations` | Create registration | -| `PATCH` | `/v1/events/:eventId/registrations/:userId` | Update registration status | -| `GET` | `/v1/users/:id` | Get user | -| `GET` | `/v1/users/registrations` | Get user's registrations | -| `POST` | `/webhooks/clerk` | Clerk webhook endpoint | - -## Scripts - -### Root - -```bash -pnpm dev # Run all apps in development -pnpm build # Build all packages -pnpm lint # Lint all apps -pnpm format # Format code with Prettier -pnpm typecheck # Type check all packages -``` - -### API (`apps/api`) - -```bash -pnpm dev # Start dev server with hot reload -pnpm build # Build for production -pnpm start # Start production server -pnpm db:generate # Generate migrations -pnpm db:migrate # Run migrations -pnpm db:studio # Open Drizzle Studio -pnpm test # Run tests -``` - -### Web (`apps/web`) - -```bash -pnpm dev # Start dev server on port 3000 -pnpm build # Build for production -pnpm preview # Preview production build -pnpm test # Run tests -``` - -## Docker - -Build and run the API with Docker: - -```bash -docker-compose up --build -``` - -The API will be available at `http://localhost:8080`. - -## Deployment - -### API - -The API is containerized and pushed to GitHub Container Registry on merge to `main`: - -``` -ghcr.io/compsoc-edinburgh/events-api:latest -``` - -### Web - -The web app can be deployed to Vercel. The `vercel.json` is already configured. - -## Testing - -API tests run against a PostgreSQL container: +- **Discover events** from CompSoc and every Special Interest Group in one place. +- **Analytics reports** per event for committee members — registrations over time, breakdowns by status and answer. +- **Approval flows** for committee and Sig executives to accept, reject or waitlist participants. +- **SDK** (TBD) for external CompSoc applications to consume the same API. -```bash -cd apps/api -pnpm test:local # Spins up test DB, runs tests, tears down -``` +## Apps -## License +This is a **pnpm monorepo**. Each app has its own README with setup, scripts and deeper details. -MIT +| Package | Description | Tech Stack | +| ---------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| [`apps/web`](./apps/web/README.md) | Frontend — browse events, register, manage Sigs. | React 19, TanStack Start, TanStack Router, TanStack Query, Tailwind CSS v4, Clerk, Radix UI | +| [`apps/api`](./apps/api/README.md) | REST API — events, registrations, auth, webhooks. | Fastify, Drizzle ORM, PostgreSQL, Zod, Clerk | +| [`apps/shared`](./apps/shared/README.md) | Shared Zod schemas, types and constants used by both apps. | Zod, TypeScript | diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 4bfa669..4f6627a 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,7 +1,8 @@ # Build stage -FROM node:20-alpine AS builder +FROM node:22-alpine AS builder -RUN corepack enable && corepack prepare pnpm@latest --activate +ARG PNPM_VERSION=10.33.0 +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate WORKDIR /app @@ -26,9 +27,10 @@ WORKDIR /app/apps/api RUN pnpm build # Production stage -FROM node:20-alpine AS production +FROM node:22-alpine AS production -RUN corepack enable && corepack prepare pnpm@latest --activate +ARG PNPM_VERSION=10.33.0 +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate WORKDIR /app diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..cd2497e --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,81 @@ +# `@events.comp-soc.com/api` + +REST API powering the CompSoc Events Platform. Handles events, registrations, user sync with Clerk, and committee/Sig role-based access control. + +## Stack + +- **Fastify** as the HTTP server +- **Drizzle ORM** + **PostgreSQL** (Neon-compatible) +- **Zod** + `drizzle-zod` for validation and schema inference +- **Clerk** (`@clerk/fastify`) for auth, **Svix** for webhook signature verification +- **Vitest** for testing, with a containerised Postgres for integration tests +- **nanoid** for ID generation + +## Environment + +Create `apps/api/.env`: + +```env +DATABASE_URL="postgresql://user:password@host/db?sslmode=require" + +CLERK_PUBLISHABLE_KEY=pk_test_... +CLERK_SECRET_KEY=sk_test_... +CLERK_WEBHOOK_SECRET=whsec_... +``` + +## Scripts + +```bash +pnpm dev # Dev server with hot reload (nodemon + tsx) on :8080 +pnpm build # Compile to dist/ +pnpm start # Run compiled server +pnpm type-check # tsc --noEmit + +pnpm db:generate # Generate Drizzle migrations from schema +pnpm db:migrate # Apply pending migrations +pnpm db:studio # Open Drizzle Studio + +pnpm test # Vitest run (expects test DB up) +pnpm test:local # Spins up the test DB, runs, tears down +pnpm test:coverage # Vitest with v8 coverage +``` + +## Module layout + +Routes are organised by domain. Each module has `route.ts`, `service.ts`, `store.ts`, `schema.ts`: + +``` +src/ +├── app.ts Server bootstrap +├── server.ts Fastify instance + plugin registration +├── db/ Drizzle schema + connection +├── lib/ auth guards, error handler, logger +├── modules/ +│ ├── events/ /v1/events +│ ├── registration/ /v1/events/:id/registrations +│ ├── users/ /v1/users +│ ├── webhooks/clerk.ts Clerk → DB sync via Svix +│ ├── core/ shared API schemas +│ └── health.ts /health +└── plugins/db.ts Drizzle plugin +``` + +## Clerk webhooks + +To sync users into the DB, point a Clerk webhook at `POST /webhooks/clerk`: + +1. Clerk Dashboard → Webhooks → new endpoint `https://your-api/webhooks/clerk` +2. Subscribe to `user.created`, `user.updated`, `user.deleted` +3. Copy the signing secret into `CLERK_WEBHOOK_SECRET` + +## Docker + +```bash +docker-compose up --build +``` + +The API is published to GitHub Container Registry on merges to `main`: + +``` +ghcr.io/compsoc-edinburgh/events-api:latest +``` diff --git a/apps/api/drizzle/0002_organiser_enum.sql b/apps/api/drizzle/0002_organiser_enum.sql new file mode 100644 index 0000000..20cabef --- /dev/null +++ b/apps/api/drizzle/0002_organiser_enum.sql @@ -0,0 +1,2 @@ +CREATE TYPE "public"."organiserSig" AS ENUM('compsoc', 'projectShare', 'bitSig', 'evp', 'cloudSig', 'tardis', 'CCSig', 'typeSig', 'sigInt', 'gameDevSig', 'edinburghAI', 'neuroTechSig', 'quantSig');--> statement-breakpoint +ALTER TABLE "events" ALTER COLUMN "organiser" SET DATA TYPE "public"."organiserSig" USING "organiser"::"public"."organiserSig"; diff --git a/apps/api/drizzle/meta/0002_snapshot.json b/apps/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..e69f849 --- /dev/null +++ b/apps/api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,343 @@ +{ + "id": "a4b8e3d0-2c1f-4f0a-9b3d-7a2e1d8c4f02", + "prevId": "52a74620-b04c-4d5e-a0ff-4156cfc1055a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organiser": { + "name": "organiser", + "type": "organiserSig", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "eventState", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "priority": { + "name": "priority", + "type": "eventPriority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "about_markdown": { + "name": "about_markdown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_url": { + "name": "location_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "form": { + "name": "form", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "state_idx": { + "name": "state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.registrations": { + "name": "registrations", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "registrationStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "form_data": { + "name": "form_data", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_user_event": { + "name": "unique_user_event", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "registrations_user_id_users_id_fk": { + "name": "registrations_user_id_users_id_fk", + "tableFrom": "registrations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "registrations_event_id_events_id_fk": { + "name": "registrations_event_id_events_id_fk", + "tableFrom": "registrations", + "tableTo": "events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "registrations_user_id_event_id_pk": { + "name": "registrations_user_id_event_id_pk", + "columns": ["user_id", "event_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "roles", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "sigs": { + "name": "sigs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.eventPriority": { + "name": "eventPriority", + "schema": "public", + "values": ["default", "pinned"] + }, + "public.eventState": { + "name": "eventState", + "schema": "public", + "values": ["draft", "published"] + }, + "public.organiserSig": { + "name": "organiserSig", + "schema": "public", + "values": [ + "compsoc", + "projectShare", + "bitSig", + "evp", + "cloudSig", + "tardis", + "CCSig", + "typeSig", + "sigInt", + "gameDevSig", + "edinburghAI", + "neuroTechSig", + "quantSig" + ] + }, + "public.registrationStatus": { + "name": "registrationStatus", + "schema": "public", + "values": ["pending", "accepted", "waitlist", "rejected"] + }, + "public.roles": { + "name": "roles", + "schema": "public", + "values": ["member", "sig_executive", "committee"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 6418317..2026469 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1769946474409, "tag": "0001_military_juggernaut", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1769946474500, + "tag": "0002_organiser_enum", + "breakpoints": true } ] } diff --git a/apps/api/package.json b/apps/api/package.json index 4cb7cbd..c6e0ed0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,12 +18,12 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { - "@clerk/fastify": "^2.6.12", + "@clerk/fastify": "^3.1.31", "@events.comp-soc.com/shared": "workspace:*", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.8.3", - "fastify": "^5.6.2", + "fastify": "5.8.5", "fastify-plugin": "^5.1.0", "fastify-tsconfig": "^3.0.0", "nanoid": "^5.1.6", diff --git a/apps/api/src/db/db.ts b/apps/api/src/db/db.ts index 3dc1a6a..ea422a3 100644 --- a/apps/api/src/db/db.ts +++ b/apps/api/src/db/db.ts @@ -6,7 +6,6 @@ export type SqlContext = NodePgDatabase