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 @@ -Screenshot 2026-01-31 at 13 21 43 +

+ CompSoc mascot +

-# CompSoc Events Platform +

CompSoc Events Platform

-A full-stack event management platform for the University of Edinburgh's Computing Society (CompSoc). Members can browse and register for events organized by the society and its Special Interest Groups (SIGs). +

+ 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; const pool = new Pool({ connectionString: process.env.DATABASE_URL!, - max: 10, }); export const db = drizzle(pool, { schema }); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 7618394..1e239aa 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -19,28 +19,21 @@ import { Sigs, } from "@events.comp-soc.com/shared"; -export const usersRole = pgEnum("roles", [ - UserRole.Member, - UserRole.SigExecutive, - UserRole.Committee, -]); +const enumValues = (obj: Record): [T, ...T[]] => + Object.values(obj) as [T, ...T[]]; -export const eventState = pgEnum("eventState", [EventState.Draft, EventState.Published]); -export const eventPriority = pgEnum("eventPriority", [EventPriority.Default, EventPriority.Pinned]); - -export const registrationStatus = pgEnum("registrationStatus", [ - RegistrationStatus.Pending, - RegistrationStatus.Accepted, - RegistrationStatus.Waitlist, - RegistrationStatus.Rejected, -]); +export const usersRole = pgEnum("roles", enumValues(UserRole)); +export const eventState = pgEnum("eventState", enumValues(EventState)); +export const eventPriority = pgEnum("eventPriority", enumValues(EventPriority)); +export const registrationStatus = pgEnum("registrationStatus", enumValues(RegistrationStatus)); +export const organiserSig = pgEnum("organiserSig", enumValues(Sigs)); export const usersTable = pgTable("users", { id: text("id").primaryKey(), email: text("email").notNull().unique(), firstName: text("first_name").notNull(), lastName: text("last_name").notNull(), - role: usersRole("role").default("member").notNull(), + role: usersRole("role").default(UserRole.Member).notNull(), sigs: json("sigs").$type(), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at") @@ -53,9 +46,9 @@ export const eventsTable = pgTable( { id: text("id").primaryKey(), title: text("title").notNull(), - organiser: text("organiser").notNull(), - state: eventState("state").default("draft").notNull(), - priority: eventPriority("priority").default("default").notNull(), + organiser: organiserSig("organiser").notNull(), + state: eventState("state").default(EventState.Draft).notNull(), + priority: eventPriority("priority").default(EventPriority.Default).notNull(), capacity: integer("capacity"), date: timestamp("date").notNull(), aboutMarkdown: text("about_markdown"), @@ -79,7 +72,7 @@ export const registrationsTable = pgTable( eventId: text("event_id") .notNull() .references(() => eventsTable.id, { onDelete: "cascade" }), - status: registrationStatus("status").notNull().default("pending"), + status: registrationStatus("status").notNull().default(RegistrationStatus.Pending), answers: json("form_data").$type(), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at") diff --git a/apps/api/src/lib/auth-guard.ts b/apps/api/src/lib/auth-guard.ts index 56d2452..32e152a 100644 --- a/apps/api/src/lib/auth-guard.ts +++ b/apps/api/src/lib/auth-guard.ts @@ -1,6 +1,6 @@ import { getAuth } from "@clerk/fastify"; import { FastifyReply, FastifyRequest } from "fastify"; -import { Sigs, canManageSig, isEventManager } from "@events.comp-soc.com/shared"; +import { isEventManager } from "@events.comp-soc.com/shared"; const requireAuth = async (request: FastifyRequest, reply: FastifyReply) => { const { userId, sessionClaims } = getAuth(request); @@ -22,8 +22,4 @@ const requireEventManager = async (request: FastifyRequest, reply: FastifyReply) } }; -const userCanManageSig = (request: FastifyRequest, organiser: Sigs): boolean => { - return canManageSig(request.user.role, request.user.sigs, organiser); -}; - -export { requireAuth, requireEventManager, userCanManageSig }; +export { requireAuth, requireEventManager }; diff --git a/apps/api/src/lib/error-handler.ts b/apps/api/src/lib/error-handler.ts index 5197a65..7c4238c 100644 --- a/apps/api/src/lib/error-handler.ts +++ b/apps/api/src/lib/error-handler.ts @@ -3,7 +3,8 @@ import { AppError } from "./errors.js"; import { FastifyReply, FastifyRequest } from "fastify"; export const errorHandler = (error: unknown, request: FastifyRequest, reply: FastifyReply) => { - request.log.error(error); + const isExpected = error instanceof AppError && error.statusCode < 500; + request.log[isExpected ? "warn" : "error"](error); if (error instanceof ZodError) { return reply.status(400).send({ diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts index aed24e3..e2da354 100644 --- a/apps/api/src/lib/errors.ts +++ b/apps/api/src/lib/errors.ts @@ -14,6 +14,12 @@ export class ConflictError extends AppError { } } +export class BadRequestError extends AppError { + constructor(message = "Bad request") { + super(400, message); + } +} + export class NotFoundError extends AppError { constructor(message = "Resource not found") { super(404, message); diff --git a/apps/api/src/modules/core/schema.ts b/apps/api/src/modules/core/schema.ts deleted file mode 100644 index bd9e1ce..0000000 --- a/apps/api/src/modules/core/schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from "zod"; - -export const QueryFilterSchema = z.object({ - page: z.coerce.number().min(1).default(1), - limit: z.coerce.number().min(1).max(100).default(20), -}); diff --git a/apps/api/src/modules/events/route.test.ts b/apps/api/src/modules/events/route.test.ts index 702d350..54de847 100644 --- a/apps/api/src/modules/events/route.test.ts +++ b/apps/api/src/modules/events/route.test.ts @@ -1,15 +1,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { - activeMockAuthState, - setMockAuth, - setSigExecutiveAuth, - setMemberAuth, -} from "../../../tests/mock-auth.js"; +import { activeMockAuthState, setMockAuth, setSigExecutiveAuth } from "../../../tests/mock-auth.js"; import { FastifyInstance } from "fastify"; import { buildServer } from "../../server.js"; import { db } from "../../db/db.js"; import { sql, eq } from "drizzle-orm"; -import { eventsTable, registrationsTable, usersTable } from "../../db/schema.js"; +import { eventsTable, registrationsTable } from "../../db/schema.js"; import type { CreateEventRequest, UpdateEventRequest } from "@events.comp-soc.com/shared"; import { Sigs } from "@events.comp-soc.com/shared"; @@ -20,6 +15,9 @@ vi.mock("@clerk/fastify", () => { }; }); +const futureDate = () => new Date(Date.now() + 60 * 60 * 1000); +const pastDate = () => new Date(Date.now() - 60 * 60 * 1000); + describe("Event", () => { let app: FastifyInstance; @@ -34,15 +32,14 @@ describe("Event", () => { beforeEach(async () => { await db.execute(sql`TRUNCATE TABLE ${registrationsTable} CASCADE`); await db.execute(sql`TRUNCATE TABLE ${eventsTable} CASCADE`); - await db.execute(sql`TRUNCATE TABLE ${usersTable} CASCADE`); }); describe("GET /v1/events", () => { beforeEach(async () => { const baseEvent = { aboutMarkdown: "md", - organiser: "projectShare", - date: new Date(), + organiser: Sigs.ProjectShare, + date: futureDate(), }; await db.insert(eventsTable).values([ @@ -67,17 +64,6 @@ describe("Event", () => { expect(data).toHaveLength(3); }); - it("should return ONLY published events for regular members", async () => { - setMockAuth({ userId: "mem_1", sessionClaims: { metadata: { role: "member" } } }); - - const response = await app.inject({ - method: "GET", - url: "/v1/events", - }); - - expect(response.statusCode).toBe(200); - }); - it("should return ALL events (draft & published) for committee members", async () => { setMockAuth({ userId: "admin", sessionClaims: { metadata: { role: "committee" } } }); @@ -90,35 +76,65 @@ describe("Event", () => { expect(response.json()).toHaveLength(5); }); - it("should support pagination (limit/page)", async () => { + it("should allow committee to filter by state explicitly", async () => { setMockAuth({ userId: "admin", sessionClaims: { metadata: { role: "committee" } } }); - // Page 1, Limit 2 - const res1 = await app.inject({ + const response = await app.inject({ method: "GET", - url: "/v1/events?page=1&limit=2", + url: "/v1/events?state=draft", }); - expect(res1.json()).toHaveLength(2); - // Page 3, Limit 2 (Should have 1 item left: 5 total) - const res2 = await app.inject({ - method: "GET", - url: "/v1/events?page=3&limit=2", - }); - expect(res2.json()).toHaveLength(1); + const data = response.json(); + expect(data).toHaveLength(2); + expect(data[0].state).toBe("draft"); }); - it("should allow committee to filter by state explicitly", async () => { - setMockAuth({ userId: "admin", sessionClaims: { metadata: { role: "committee" } } }); + it("should filter published events by inclusive date range", async () => { + setMockAuth({ userId: null, sessionClaims: null }); + + await db.insert(eventsTable).values([ + { + id: "archive-start", + title: "Archive January", + state: "published", + aboutMarkdown: "md", + organiser: Sigs.Compsoc, + date: new Date("2026-01-01T12:00:00.000Z"), + }, + { + id: "archive-end", + title: "Archive May", + state: "published", + aboutMarkdown: "md", + organiser: Sigs.Compsoc, + date: new Date("2026-05-22T12:00:00.000Z"), + }, + { + id: "archive-today", + title: "Archive Today", + state: "published", + aboutMarkdown: "md", + organiser: Sigs.Compsoc, + date: new Date("2026-05-23T12:00:00.000Z"), + }, + { + id: "archive-draft", + title: "Archive Draft", + state: "draft", + aboutMarkdown: "md", + organiser: Sigs.Compsoc, + date: new Date("2026-03-01T12:00:00.000Z"), + }, + ]); const response = await app.inject({ method: "GET", - url: "/v1/events?state=draft", + url: "/v1/events?state=published&includePast=true&dateFrom=2026-01-01&dateTo=2026-05-22&search=Archive", }); - const data = response.json(); - expect(data).toHaveLength(2); - expect(data[0].state).toBe("draft"); + expect(response.statusCode).toBe(200); + const ids = response.json().map((event: { id: string }) => event.id); + expect(ids).toEqual(["archive-start", "archive-end"]); }); }); @@ -130,16 +146,16 @@ describe("Event", () => { title: "Secret", state: "draft", aboutMarkdown: "md", - organiser: "soc", - date: new Date(), + organiser: Sigs.Compsoc, + date: futureDate(), }, { id: "public-event", title: "Public", state: "published", aboutMarkdown: "md", - organiser: "soc", - date: new Date(), + organiser: Sigs.Compsoc, + date: futureDate(), }, ]); }); @@ -166,27 +182,16 @@ describe("Event", () => { expect(response.statusCode).toBe(200); expect(response.json().id).toBe("draft-event"); }); - - it("should return 404 if event does not exist", async () => { - setMockAuth({ userId: "admin", sessionClaims: { metadata: { role: "committee" } } }); - - const response = await app.inject({ - method: "GET", - url: "/v1/events/non-existent", - }); - - expect(response.statusCode).toBe(404); - }); }); describe("POST /v1/events", () => { const validPayload: CreateEventRequest = { title: "Hackathon 2025", - organiser: "projectShare", + organiser: Sigs.ProjectShare, state: "draft", priority: "default", capacity: 150, - date: new Date().toISOString(), + date: futureDate().toISOString(), aboutMarkdown: "# Details", location: "Comp Lab", locationURL: "https://maps.google.com", @@ -239,18 +244,6 @@ describe("Event", () => { expect(response.statusCode).toBe(400); }); - - it("should fail (400) if date is invalid", async () => { - setMockAuth({ userId: "admin", sessionClaims: { metadata: { role: "committee" } } }); - - const response = await app.inject({ - method: "POST", - url: "/v1/events", - payload: { ...validPayload, date: "invalid-date-string" }, - }); - - expect(response.statusCode).toBe(400); - }); }); describe("PUT /v1/events/:id", () => { @@ -262,8 +255,8 @@ describe("Event", () => { title: "Old Title", state: "draft", aboutMarkdown: "Old MD", - organiser: "soc", - date: new Date(), + organiser: Sigs.Compsoc, + date: futureDate(), capacity: 50, }); }); @@ -289,16 +282,25 @@ describe("Event", () => { expect(dbEvent.capacity).toBe(50); }); - it("should return 404 if updating non-existent event", async () => { + it("should reject editing historical events", async () => { setMockAuth({ userId: "admin", sessionClaims: { metadata: { role: "committee" } } }); + await db.insert(eventsTable).values({ + id: "past-update-test-id", + title: "Already Happened", + state: "published", + aboutMarkdown: "Past MD", + organiser: Sigs.Compsoc, + date: pastDate(), + }); + const response = await app.inject({ method: "PUT", - url: "/v1/events/ghost-event", - payload: { title: "Ghost" }, + url: "/v1/events/past-update-test-id", + payload: { title: "New Title" }, }); - expect(response.statusCode).toBe(403); + expect(response.statusCode).toBe(409); }); }); @@ -311,8 +313,8 @@ describe("Event", () => { title: "To Be Deleted", state: "draft", aboutMarkdown: "md", - organiser: "soc", - date: new Date(), + organiser: Sigs.Compsoc, + date: futureDate(), }); }); @@ -331,30 +333,24 @@ describe("Event", () => { expect(result).toHaveLength(0); }); - it("should CASCADE delete: deleting event must delete associated registrations", async () => { + it("should reject deleting historical events", async () => { setMockAuth({ userId: "admin", sessionClaims: { metadata: { role: "committee" } } }); - await db - .insert(usersTable) - .values({ id: "u2", firstName: "A", lastName: "B", email: "u2@gmail.com" }); - - await db.insert(registrationsTable).values({ - userId: "u2", - eventId: eventId, - status: "accepted", + + await db.insert(eventsTable).values({ + id: "past-delete-target", + title: "Past Event", + state: "published", + aboutMarkdown: "md", + organiser: Sigs.Compsoc, + date: pastDate(), }); const response = await app.inject({ method: "DELETE", - url: `/v1/events/${eventId}`, + url: "/v1/events/past-delete-target", }); - expect(response.statusCode).toBe(200); - - const regs = await db - .select() - .from(registrationsTable) - .where(eq(registrationsTable.eventId, eventId)); - expect(regs).toHaveLength(0); + expect(response.statusCode).toBe(409); }); }); @@ -362,7 +358,7 @@ describe("Event", () => { beforeEach(async () => { const baseEvent = { aboutMarkdown: "md", - date: new Date(), + date: futureDate(), }; await db.insert(eventsTable).values([ @@ -433,23 +429,38 @@ describe("Event", () => { expect(ids).not.toContain("compsoc-draft"); }); - it("should return draft events for multiple assigned SIGs", async () => { - setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI, Sigs.QuantSig]); + it("should return only assigned SIG drafts when sig_executive filters by draft state", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); const response = await app.inject({ method: "GET", - url: "/v1/events", + url: "/v1/events?state=draft", }); expect(response.statusCode).toBe(200); const data = response.json(); + const ids = data.map((e: { id: string }) => e.id); + + expect(ids).toEqual(["ai-draft"]); + }); - // Should see: all published (3) + AI draft (1) + Quant draft (1) = 5 - expect(data).toHaveLength(5); + it("should return all published events and no drafts when sig_executive filters by published state", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "GET", + url: "/v1/events?state=published", + }); + expect(response.statusCode).toBe(200); + const data = response.json(); const ids = data.map((e: { id: string }) => e.id); - expect(ids).toContain("ai-draft"); - expect(ids).toContain("quant-draft"); + + expect(ids).toContain("ai-pub"); + expect(ids).toContain("quant-pub"); + expect(ids).toContain("compsoc-pub"); + expect(ids).not.toContain("ai-draft"); + expect(ids).not.toContain("quant-draft"); expect(ids).not.toContain("compsoc-draft"); }); }); @@ -463,7 +474,7 @@ describe("Event", () => { state: "draft", aboutMarkdown: "md", organiser: Sigs.EdinburghAI, - date: new Date(), + date: futureDate(), }, { id: "quant-draft-event", @@ -471,7 +482,7 @@ describe("Event", () => { state: "draft", aboutMarkdown: "md", organiser: Sigs.QuantSig, - date: new Date(), + date: futureDate(), }, ]); }); @@ -507,7 +518,7 @@ describe("Event", () => { state: "draft", priority: "default", capacity: 50, - date: new Date().toISOString(), + date: futureDate().toISOString(), aboutMarkdown: "# AI Event", location: "AI Lab", locationURL: "https://maps.google.com", @@ -539,48 +550,6 @@ describe("Event", () => { expect(response.statusCode).toBe(403); }); - - it("should forbid sig_executive from creating CompSoc events", async () => { - setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI, Sigs.QuantSig]); - - const response = await app.inject({ - method: "POST", - url: "/v1/events", - payload: { ...validPayload, organiser: Sigs.Compsoc }, - }); - - expect(response.statusCode).toBe(403); - }); - - it("should allow sig_executive with multiple SIGs to create events for any of them", async () => { - setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI, Sigs.QuantSig]); - - const response1 = await app.inject({ - method: "POST", - url: "/v1/events", - payload: { ...validPayload, organiser: Sigs.EdinburghAI }, - }); - expect(response1.statusCode).toBe(201); - - const response2 = await app.inject({ - method: "POST", - url: "/v1/events", - payload: { ...validPayload, organiser: Sigs.QuantSig }, - }); - expect(response2.statusCode).toBe(201); - }); - - it("should forbid regular members from creating events", async () => { - setMemberAuth("member"); - - const response = await app.inject({ - method: "POST", - url: "/v1/events", - payload: validPayload, - }); - - expect(response.statusCode).toBe(401); - }); }); describe("SIG Executive - PUT /v1/events/:id", () => { @@ -592,7 +561,7 @@ describe("Event", () => { state: "draft", aboutMarkdown: "md", organiser: Sigs.EdinburghAI, - date: new Date(), + date: futureDate(), }, { id: "quant-event", @@ -600,7 +569,7 @@ describe("Event", () => { state: "draft", aboutMarkdown: "md", organiser: Sigs.QuantSig, - date: new Date(), + date: futureDate(), }, ]); }); @@ -665,7 +634,7 @@ describe("Event", () => { state: "draft", aboutMarkdown: "md", organiser: Sigs.EdinburghAI, - date: new Date(), + date: futureDate(), }, { id: "quant-delete", @@ -673,7 +642,7 @@ describe("Event", () => { state: "draft", aboutMarkdown: "md", organiser: Sigs.QuantSig, - date: new Date(), + date: futureDate(), }, ]); }); diff --git a/apps/api/src/modules/events/route.ts b/apps/api/src/modules/events/route.ts index ca5417f..eac0651 100644 --- a/apps/api/src/modules/events/route.ts +++ b/apps/api/src/modules/events/route.ts @@ -1,16 +1,11 @@ import { FastifyInstance } from "fastify"; import { getAuth } from "@clerk/fastify"; -import { - CreateEventSchema, - EventIdSchema, - EventsQueryFilterSchema, - UpdateEventSchema, -} from "./schema.js"; +import { CreateEventSchema, EventIdSchema, UpdateEventSchema } from "./schema.js"; import { eventService } from "./service.js"; import { EventContractSchema, + EventsQueryFilterSchema, UpdateEventContractSchema, - Sigs, canManageSig, } from "@events.comp-soc.com/shared"; import { nanoid } from "nanoid"; @@ -54,8 +49,8 @@ export const eventRoutes = async (server: FastifyInstance) => { const dto = EventContractSchema.parse(request.body); const { role, sigs } = request.user; - if (!canManageSig(role, sigs, dto.organiser as Sigs)) { - throw new ForbiddenError("You cannot create events for this SIG"); + if (!canManageSig(role, sigs, dto.organiser)) { + throw new ForbiddenError("You cannot create events for this Sig"); } const generatedId = nanoid(); @@ -88,12 +83,12 @@ export const eventRoutes = async (server: FastifyInstance) => { throw new ForbiddenError("Event not found"); } - if (!canManageSig(role, sigs, existingEvent.organiser as Sigs)) { - throw new ForbiddenError("You cannot edit events for this SIG"); + if (!canManageSig(role, sigs, existingEvent.organiser)) { + throw new ForbiddenError("You cannot edit events for this Sig"); } - if (dto.organiser && !canManageSig(role, sigs, dto.organiser as Sigs)) { - throw new ForbiddenError("You cannot transfer events to this SIG"); + if (dto.organiser && !canManageSig(role, sigs, dto.organiser)) { + throw new ForbiddenError("You cannot transfer events to this Sig"); } const data = UpdateEventSchema.parse({ @@ -123,8 +118,8 @@ export const eventRoutes = async (server: FastifyInstance) => { throw new ForbiddenError("Event not found"); } - if (!canManageSig(role, sigs, existingEvent.organiser as Sigs)) { - throw new ForbiddenError("You cannot delete events for this SIG"); + if (!canManageSig(role, sigs, existingEvent.organiser)) { + throw new ForbiddenError("You cannot delete events for this Sig"); } const deletedEvent = await eventService.deleteEvent({ diff --git a/apps/api/src/modules/events/schema.ts b/apps/api/src/modules/events/schema.ts index 598583d..275ff01 100644 --- a/apps/api/src/modules/events/schema.ts +++ b/apps/api/src/modules/events/schema.ts @@ -1,13 +1,12 @@ import { z } from "zod"; import { createInsertSchema } from "drizzle-zod"; +import { IdSchema } from "@events.comp-soc.com/shared"; import { eventsTable } from "../../db/schema.js"; -import { BaseUserSchema } from "../users/schema.js"; -import { QueryFilterSchema } from "../core/schema.js"; const BaseEventSchema = createInsertSchema(eventsTable); -export const EventIdSchema = BaseEventSchema.pick({ - id: true, +export const EventIdSchema = z.object({ + id: IdSchema, }); export const CreateEventSchema = BaseEventSchema.omit({ @@ -21,18 +20,9 @@ export const UpdateEventSchema = BaseEventSchema.omit({ }) .partial() .extend({ - id: BaseUserSchema.shape.id, + id: IdSchema, }); -export const EventsQueryFilterSchema = QueryFilterSchema.extend({ - state: BaseEventSchema.shape.state.optional(), - includePast: z - .enum(["true", "false"]) - .optional() - .transform((val) => val === "true"), -}); - export type EventId = z.infer; export type CreateEvent = z.infer; export type UpdateEvent = z.infer; -export type EventsQueryFilter = z.infer; diff --git a/apps/api/src/modules/events/service.ts b/apps/api/src/modules/events/service.ts index 5a4657a..996a829 100644 --- a/apps/api/src/modules/events/service.ts +++ b/apps/api/src/modules/events/service.ts @@ -1,8 +1,15 @@ import { eventStore } from "./store.js"; import { SqlContext } from "../../db/db.js"; -import { CreateEvent, EventId, EventsQueryFilter, UpdateEvent } from "./schema.js"; -import { NotFoundError } from "../../lib/errors.js"; -import { UserRole, EventState, Nullable, Sigs } from "@events.comp-soc.com/shared"; +import { CreateEvent, EventId, UpdateEvent } from "./schema.js"; +import { ConflictError, NotFoundError } from "../../lib/errors.js"; +import { + EventState, + EventsQueryFilter, + Nullable, + Sigs, + UserRole, +} from "@events.comp-soc.com/shared"; +import { isHistoricalEvent, mergeEventsByDate, scopeSigs } from "./utils.js"; export const eventService = { async getEvents({ @@ -19,28 +26,47 @@ export const eventService = { const isCommittee = role === UserRole.Committee; const isSigExecutive = role === UserRole.SigExecutive; - const authorisedFilters = { - ...filters, - state: isCommittee ? filters.state : EventState.Published, - }; + if (isCommittee) { + return eventStore.get({ db, filters }); + } + + if (isSigExecutive) { + const managedSigs = scopeSigs(sigs, filters.sigs); + + if (filters.state === EventState.Draft) { + if (managedSigs.length === 0) return []; + + return eventStore.get({ + db, + filters: { ...filters, state: EventState.Draft, sigs: managedSigs }, + }); + } + + const publishedEvents = await eventStore.get({ + db, + filters: { ...filters, state: EventState.Published }, + }); - let events = await eventStore.get({ db, filters: authorisedFilters }); + if (filters.state === EventState.Published || managedSigs.length === 0) { + return publishedEvents; + } - if (isSigExecutive && sigs && sigs.length > 0 && !filters.state) { const draftEvents = await eventStore.get({ db, - filters: { ...filters, state: EventState.Draft }, + filters: { ...filters, state: EventState.Draft, sigs: managedSigs }, }); - const sigDraftEvents = draftEvents.filter((e) => sigs.includes(e.organiser as Sigs)); - events = [...events, ...sigDraftEvents]; + return mergeEventsByDate(publishedEvents, draftEvents); + } - events = events - .filter((event, index, self) => index === self.findIndex((e) => e.id === event.id)) - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + if (filters.state === EventState.Draft) { + return []; } - return events; + return eventStore.get({ + db, + filters: { ...filters, state: EventState.Published }, + }); }, async getEventById({ @@ -64,12 +90,10 @@ export const eventService = { } if (event.state === EventState.Draft) { - if (isCommittee) { - return event; - } - if (isSigExecutive && sigs && sigs.includes(event.organiser as Sigs)) { + if (isCommittee || (isSigExecutive && sigs && sigs.includes(event.organiser))) { return event; } + throw new NotFoundError(`Event with ${id} not found`); } @@ -83,6 +107,15 @@ export const eventService = { async updateEvent({ db, data }: { db: SqlContext; data: UpdateEvent }) { const { id } = data; + const existing = await eventStore.findById({ db, data: { id } }); + if (!existing) { + throw new NotFoundError(`Event with ${id} not found`); + } + + if (isHistoricalEvent(existing.date)) { + throw new ConflictError("Historical events cannot be edited"); + } + const updated = await eventStore.update({ db, data }); if (!updated) { throw new NotFoundError(`Event with ${id} not found`); @@ -94,6 +127,15 @@ export const eventService = { async deleteEvent({ db, data }: { db: SqlContext; data: EventId }) { const { id } = data; + const existing = await eventStore.findById({ db, data }); + if (!existing) { + throw new NotFoundError(`Event with ${id} not found`); + } + + if (isHistoricalEvent(existing.date)) { + throw new ConflictError("Historical events cannot be deleted"); + } + const deleted = await eventStore.delete({ db, data }); if (!deleted) { throw new NotFoundError(`Event with ${id} not found`); diff --git a/apps/api/src/modules/events/store.ts b/apps/api/src/modules/events/store.ts index e0746f3..e6c3036 100644 --- a/apps/api/src/modules/events/store.ts +++ b/apps/api/src/modules/events/store.ts @@ -1,7 +1,8 @@ -import { eq, gte, and, SQL } from "drizzle-orm"; +import { eq, gte, lt, and, ilike, inArray, SQL } from "drizzle-orm"; import { SqlContext } from "../../db/db.js"; -import { CreateEvent, EventId, EventsQueryFilter, UpdateEvent } from "./schema.js"; +import { CreateEvent, EventId, UpdateEvent } from "./schema.js"; import { eventsTable, registrationsTable } from "../../db/schema.js"; +import type { EventsQueryFilter, Nullable } from "@events.comp-soc.com/shared"; export const eventStore = { async create({ db, data }: { db: SqlContext; data: CreateEvent }) { @@ -35,23 +36,34 @@ export const eventStore = { }, async get({ db, filters }: { db: SqlContext; filters: EventsQueryFilter }) { - const { page, limit, state, includePast } = filters; - const offset = (page - 1) * limit; + const { state, includePast, search, sigs, dateFrom, dateTo } = filters; const today = new Date(); today.setUTCHours(0, 0, 0, 0); + const toUtcDayStart = (value: string) => new Date(`${value}T00:00:00.000Z`); + const toExclusiveUtcDayEnd = (value: string) => { + const dayEnd = toUtcDayStart(value); + dayEnd.setUTCDate(dayEnd.getUTCDate() + 1); + return dayEnd; + }; + + const rangeStart: Nullable = dateFrom ? toUtcDayStart(dateFrom) : null; + const rangeEnd: Nullable = dateTo ? toExclusiveUtcDayEnd(dateTo) : null; + const conditions = [ state ? eq(eventsTable.state, state) : null, - !includePast ? gte(eventsTable.date, today) : null, + !includePast && !dateFrom && !dateTo ? gte(eventsTable.date, today) : null, + search ? ilike(eventsTable.title, `%${search}%`) : null, + sigs && sigs.length > 0 ? inArray(eventsTable.organiser, sigs) : null, + rangeStart ? gte(eventsTable.date, rangeStart) : null, + rangeEnd ? lt(eventsTable.date, rangeEnd) : null, ].filter((condition): condition is SQL => condition !== null); return db .select() .from(eventsTable) .where(conditions.length > 0 ? and(...conditions) : undefined) - .limit(limit) - .offset(offset) .orderBy(eventsTable.date); }, diff --git a/apps/api/src/modules/events/utils.ts b/apps/api/src/modules/events/utils.ts new file mode 100644 index 0000000..af43854 --- /dev/null +++ b/apps/api/src/modules/events/utils.ts @@ -0,0 +1,30 @@ +import type { Sigs } from "@events.comp-soc.com/shared"; + +export const isHistoricalEvent = (date: Date | string, now = new Date()): boolean => { + return new Date(date).getTime() < now.getTime(); +}; + +export const scopeSigs = ( + managedSigs: Sigs[] | undefined, + requestedSigs: Sigs[] | undefined +): Sigs[] => { + if (!managedSigs || managedSigs.length === 0) return []; + if (!requestedSigs || requestedSigs.length === 0) return managedSigs; + + const requested = new Set(requestedSigs); + return managedSigs.filter((sig) => requested.has(sig)); +}; + +export const mergeEventsByDate = ( + ...eventGroups: Event[][] +): Event[] => { + const eventsById = new Map(); + + eventGroups.flat().forEach((event) => { + eventsById.set(event.id, event); + }); + + return [...eventsById.values()].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() + ); +}; diff --git a/apps/api/src/modules/registration/projections.ts b/apps/api/src/modules/registration/projections.ts new file mode 100644 index 0000000..e6692e1 --- /dev/null +++ b/apps/api/src/modules/registration/projections.ts @@ -0,0 +1,16 @@ +import { eventsTable, registrationsTable, usersTable } from "../../db/schema.js"; + +export const RegistrationStoreSelection = { + userId: registrationsTable.userId, + firstName: usersTable.firstName, + lastName: usersTable.lastName, + email: usersTable.email, + eventId: registrationsTable.eventId, + status: registrationsTable.status, + answers: registrationsTable.answers, + createdAt: registrationsTable.createdAt, + updatedAt: registrationsTable.updatedAt, + eventTitle: eventsTable.title, + eventDate: eventsTable.date, + eventLocation: eventsTable.location, +}; diff --git a/apps/api/src/modules/registration/route.test.ts b/apps/api/src/modules/registration/route.test.ts index e8d5bfc..5283c0c 100644 --- a/apps/api/src/modules/registration/route.test.ts +++ b/apps/api/src/modules/registration/route.test.ts @@ -4,14 +4,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites import { db } from "../../db/db.js"; import { eventsTable, registrationsTable, usersTable } from "../../db/schema.js"; import { buildServer } from "../../server.js"; -import { - activeMockAuthState, - setMockAuth, - setSigExecutiveAuth, - setMemberAuth, -} from "../../../tests/mock-auth.js"; +import { activeMockAuthState, setMockAuth, setSigExecutiveAuth } from "../../../tests/mock-auth.js"; import { Sigs } from "@events.comp-soc.com/shared"; +const futureDate = () => new Date(Date.now() + 60 * 60 * 1000); +const pastDate = () => new Date(Date.now() - 60 * 60 * 1000); + vi.mock("@clerk/fastify", () => { return { getAuth: vi.fn(() => activeMockAuthState), @@ -52,7 +50,7 @@ describe("Registration", () => { state: "published", aboutMarkdown: "markdown", organiser: "projectShare", - date: new Date(), + date: futureDate(), capacity: null, }, { @@ -61,7 +59,7 @@ describe("Registration", () => { state: "draft", aboutMarkdown: "markdown", organiser: "projectShare", - date: new Date(), + date: futureDate(), capacity: null, }, { @@ -70,9 +68,42 @@ describe("Registration", () => { state: "published", aboutMarkdown: "markdown", organiser: "projectShare", - date: new Date(), + date: futureDate(), capacity: 2, }, + { + id: "past-event", + title: "Past Event", + state: "published", + aboutMarkdown: "markdown", + organiser: "projectShare", + date: pastDate(), + capacity: null, + }, + { + id: "form-event", + title: "Form Event", + state: "published", + aboutMarkdown: "markdown", + organiser: "projectShare", + date: futureDate(), + capacity: null, + form: [ + { + id: "diet", + type: "select", + label: "Diet", + required: true, + options: ["None", "Vegan"], + }, + { + id: "notes", + type: "textarea", + label: "Notes", + required: false, + }, + ], + }, ]); }); @@ -140,124 +171,97 @@ describe("Registration", () => { expect(response.statusCode).toBe(404); }); - }); - describe("PUT /v1/events/:eventId/registrations/:targetUserId", () => { - beforeEach(async () => { - await db.insert(usersTable).values([ - { id: "test-user", email: "test@example.com", firstName: "Test", lastName: "User" }, - { id: "other-user", email: "other@example.com", firstName: "Other", lastName: "User" }, - ]); - - await db.insert(eventsTable).values({ - id: "test-event", - title: "Test Event", - state: "published", - aboutMarkdown: "markdown", - organiser: "projectShare", - date: new Date(), - capacity: 2, - }); - - await db.insert(registrationsTable).values({ + it("should return 409 if user tries to register to a past published event", async () => { + setMockAuth({ userId: "test-user", - eventId: "test-event", - status: "pending", + sessionClaims: { metadata: { role: "member" } }, }); - }); - - it("should return 401 if user is not authenticated", async () => { - setMockAuth({ userId: null, sessionClaims: null }); const response = await app.inject({ - method: "PUT", - url: "/v1/events/test-event/registrations/test-user", - payload: { status: "accepted" }, + method: "POST", + url: "/v1/events/past-event/registrations", + payload: {}, }); - expect(response.statusCode).toBe(401); - expect(response.json()).toEqual({ message: "Unauthorised" }); + expect(response.statusCode).toBe(409); }); - it("should return 401 if non-committee tries to update registration", async () => { + it("should accept answers that match the event form", async () => { setMockAuth({ - userId: "other-user", + userId: "test-user", sessionClaims: { metadata: { role: "member" } }, }); const response = await app.inject({ - method: "PUT", - url: "/v1/events/test-event/registrations/test-user", - payload: { status: "accepted" }, + method: "POST", + url: "/v1/events/form-event/registrations", + payload: { + answers: { + diet: "Vegan", + notes: " See you there ", + }, + }, }); - expect(response.statusCode).toBe(401); + expect(response.statusCode).toBe(201); + expect(response.json().answers).toEqual({ + diet: "Vegan", + notes: "See you there", + }); }); - it("should allow committee to update registration status to accepted", async () => { + it("should return 400 if a required form answer is missing", async () => { setMockAuth({ - userId: "committee-user", - sessionClaims: { metadata: { role: "committee" } }, + userId: "test-user", + sessionClaims: { metadata: { role: "member" } }, }); const response = await app.inject({ - method: "PUT", - url: "/v1/events/test-event/registrations/test-user", - payload: { status: "accepted" }, + method: "POST", + url: "/v1/events/form-event/registrations", + payload: { answers: { notes: "No diet answer" } }, }); - expect(response.statusCode).toBe(200); - - const data = response.json(); - expect(data.status).toBe("accepted"); + expect(response.statusCode).toBe(400); }); - it("should return 409 if accepting registration would exceed capacity", async () => { - await db.insert(usersTable).values([ - { id: "user-1", email: "user1@example.com", firstName: "User", lastName: "One" }, - { id: "user-2", email: "user2@example.com", firstName: "User", lastName: "Two" }, - ]); - - await db.insert(registrationsTable).values([ - { userId: "user-1", eventId: "test-event", status: "accepted" }, - { userId: "user-2", eventId: "test-event", status: "accepted" }, - ]); - + it("should return 400 if a select answer is not one of the event options", async () => { setMockAuth({ - userId: "committee-user", - sessionClaims: { metadata: { role: "committee" } }, + userId: "test-user", + sessionClaims: { metadata: { role: "member" } }, }); const response = await app.inject({ - method: "PUT", - url: "/v1/events/test-event/registrations/test-user", - payload: { status: "accepted" }, + method: "POST", + url: "/v1/events/form-event/registrations", + payload: { answers: { diet: "Pizza" } }, }); - expect(response.statusCode).toBe(409); + expect(response.statusCode).toBe(400); }); - it("should return 404 if registration does not exist", async () => { + it("should return 400 if the payload includes an unknown form answer", async () => { setMockAuth({ - userId: "committee-user", - sessionClaims: { metadata: { role: "committee" } }, + userId: "test-user", + sessionClaims: { metadata: { role: "member" } }, }); const response = await app.inject({ - method: "PUT", - url: "/v1/events/test-event/registrations/non-existing-user", - payload: { status: "accepted" }, + method: "POST", + url: "/v1/events/form-event/registrations", + payload: { answers: { diet: "None", adminOnly: "yes" } }, }); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(400); }); }); - describe("GET /v1/events/:eventId/registrations", () => { + describe("PUT /v1/events/:eventId/registrations/:targetUserId", () => { beforeEach(async () => { await db.insert(usersTable).values([ - { id: "user-1", email: "u1@ex.com", firstName: "U", lastName: "1" }, - { id: "user-2", email: "u2@ex.com", firstName: "U", lastName: "2" }, + { id: "test-user", email: "test@example.com", firstName: "Test", lastName: "User" }, + { id: "other-user", email: "other@example.com", firstName: "Other", lastName: "User" }, ]); await db.insert(eventsTable).values({ @@ -267,73 +271,72 @@ describe("Registration", () => { aboutMarkdown: "markdown", organiser: "projectShare", date: new Date(), + capacity: 2, }); - await db.insert(registrationsTable).values([ - { userId: "user-1", eventId: "test-event", status: "accepted" }, - { userId: "user-2", eventId: "test-event", status: "pending" }, - ]); + await db.insert(registrationsTable).values({ + userId: "test-user", + eventId: "test-event", + status: "pending", + }); }); - it("should filter registrations by status query param", async () => { + it("should return 401 if non-committee tries to update registration", async () => { setMockAuth({ - userId: "committee-user", - sessionClaims: { metadata: { role: "committee" } }, + userId: "other-user", + sessionClaims: { metadata: { role: "member" } }, }); const response = await app.inject({ - method: "GET", - url: "/v1/events/test-event/registrations", - query: { status: "accepted" }, + method: "PUT", + url: "/v1/events/test-event/registrations/test-user", + payload: { status: "accepted" }, }); - expect(response.statusCode).toBe(200); - const data = response.json(); - expect(data).toHaveLength(1); - expect(data[0].userId).toBe("user-1"); - expect(data[0].status).toBe("accepted"); + expect(response.statusCode).toBe(401); }); - it("should filter registrations by userId query param", async () => { + it("should allow committee to update registration status to accepted", async () => { setMockAuth({ userId: "committee-user", sessionClaims: { metadata: { role: "committee" } }, }); const response = await app.inject({ - method: "GET", - url: "/v1/events/test-event/registrations", - query: { userId: "user-2" }, + method: "PUT", + url: "/v1/events/test-event/registrations/test-user", + payload: { status: "accepted" }, }); expect(response.statusCode).toBe(200); + const data = response.json(); - expect(data).toHaveLength(1); - expect(data[0].userId).toBe("user-2"); + expect(data.status).toBe("accepted"); }); - it("should return empty array if event has no registrations", async () => { + it("should return 409 if accepting registration would exceed capacity", async () => { + await db.insert(usersTable).values([ + { id: "user-1", email: "user1@example.com", firstName: "User", lastName: "One" }, + { id: "user-2", email: "user2@example.com", firstName: "User", lastName: "Two" }, + ]); + + await db.insert(registrationsTable).values([ + { userId: "user-1", eventId: "test-event", status: "accepted" }, + { userId: "user-2", eventId: "test-event", status: "accepted" }, + ]); + setMockAuth({ userId: "committee-user", sessionClaims: { metadata: { role: "committee" } }, }); - await db.insert(eventsTable).values({ - id: "empty-event", - title: "Empty Event", - state: "published", - aboutMarkdown: "md", - organiser: "projectShare", - date: new Date(), - }); - const response = await app.inject({ - method: "GET", - url: "/v1/events/empty-event/registrations", + method: "PUT", + url: "/v1/events/test-event/registrations/test-user", + payload: { status: "accepted" }, }); - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual([]); + expect(response.statusCode).toBe(409); }); }); @@ -360,17 +363,6 @@ describe("Registration", () => { }); }); - it("should return 401 if user is not authenticated", async () => { - setMockAuth({ userId: null, sessionClaims: null }); - - const response = await app.inject({ - method: "GET", - url: "/v1/events/test-event/registrations/me", - }); - - expect(response.statusCode).toBe(401); - }); - it("should allow a user to fetch their own registration", async () => { setMockAuth({ userId: "test-user", @@ -571,47 +563,15 @@ describe("Registration", () => { const mediumCount = sizeAnalytics.data.find((d: DataOption) => d.option === "Medium").count; const smallCount = sizeAnalytics.data.find((d: DataOption) => d.option === "Small").count; - const largeCount = sizeAnalytics.data.find((d: DataOption) => d.option === "Large").count; expect(mediumCount).toBe(2); expect(smallCount).toBe(1); - expect(largeCount).toBe(0); const dietAnalytics = data.countByAnswers["diet-field"]; expect(dietAnalytics.data.find((d: DataOption) => d.option === "Vegan").count).toBe(1); expect(dietAnalytics.data.find((d: DataOption) => d.option === "None").count).toBe(2); }); - it("should initialize zero-counts for events with no registrations", async () => { - await db.insert(eventsTable).values({ - id: "empty-event", - title: "Empty", - state: "published", - date: new Date(), - organiser: "p", - aboutMarkdown: "", - form: [{ id: "sel", type: "select", label: "Sel", options: ["A", "B"], required: false }], - }); - - setMockAuth({ - userId: "committee-user", - sessionClaims: { metadata: { role: "committee" } }, - }); - - const response = await app.inject({ - method: "GET", - url: "/v1/events/empty-event/registrations/analytics", - }); - - expect(response.statusCode).toBe(200); - const data = response.json(); - - expect(data.totalCount).toBe(0); - expect(data.countByStatus).toEqual({}); - // Ensure the chart data structure is still built from the schema - expect(data.countByAnswers["sel"].data[0].count).toBe(0); - }); - it("should return 401 for non-committee members", async () => { setMockAuth({ userId: "u1", // Regular user @@ -625,17 +585,6 @@ describe("Registration", () => { expect(response.statusCode).toBe(401); }); - - it("should return 401 for unauthenticated users", async () => { - setMockAuth({ userId: null, sessionClaims: null }); - - const response = await app.inject({ - method: "GET", - url: `/v1/events/${analyticsEventId}/registrations/analytics`, - }); - - expect(response.statusCode).toBe(401); - }); }); describe("DELETE /v1/events/:eventId/registrations/:targetUserId", () => { @@ -661,18 +610,6 @@ describe("Registration", () => { }); }); - it("should return 401 if user is not authenticated", async () => { - setMockAuth({ userId: null, sessionClaims: null }); - - const response = await app.inject({ - method: "DELETE", - url: "/v1/events/test-event/registrations/test-user", - }); - - expect(response.statusCode).toBe(401); - expect(response.json()).toEqual({ message: "Unauthorised" }); - }); - it("should allow committee to delete any registration", async () => { setMockAuth({ userId: "committee-user", @@ -721,20 +658,6 @@ describe("Registration", () => { expect(response.statusCode).toBe(403); }); - - it("should return 404 if registration does not exist", async () => { - setMockAuth({ - userId: "committee-user", - sessionClaims: { metadata: { role: "committee" } }, - }); - - const response = await app.inject({ - method: "DELETE", - url: "/v1/events/test-event/registrations/non-existing-user", - }); - - expect(response.statusCode).toBe(404); - }); }); // ===== SIG EXECUTIVE ROLE TESTS ===== @@ -793,17 +716,6 @@ describe("Registration", () => { expect(response.statusCode).toBe(403); }); - - it("should forbid regular members from viewing registrations", async () => { - setMemberAuth("member"); - - const response = await app.inject({ - method: "GET", - url: "/v1/events/ai-event/registrations", - }); - - expect(response.statusCode).toBe(401); - }); }); describe("SIG Executive - PUT /v1/events/:eventId/registrations/:userId", () => { @@ -980,17 +892,6 @@ describe("Registration", () => { expect(response.statusCode).toBe(403); }); - - it("should forbid regular members from viewing analytics", async () => { - setMemberAuth("member"); - - const response = await app.inject({ - method: "GET", - url: "/v1/events/ai-analytics-event/registrations/analytics", - }); - - expect(response.statusCode).toBe(401); - }); }); describe("SIG Executive - DELETE /v1/events/:eventId/registrations/:userId", () => { diff --git a/apps/api/src/modules/registration/route.ts b/apps/api/src/modules/registration/route.ts index a1014cb..845b43c 100644 --- a/apps/api/src/modules/registration/route.ts +++ b/apps/api/src/modules/registration/route.ts @@ -3,7 +3,6 @@ import { CreateRegistrationSchema, RegistrationEventIdSchema, RegistrationParamsSchema, - RegistrationsQueryFilterSchema, UpdateBatchStatusRegistrationSchema, UpdateRegistrationSchema, } from "./schema.js"; @@ -12,7 +11,6 @@ import { RegistrationContractSchema, RegistrationStatusBatchUpdateSchema, RegistrationUpdateContractSchema, - Sigs, canManageSig, } from "@events.comp-soc.com/shared"; import { requireAuth, requireEventManager } from "../../lib/auth-guard.js"; @@ -49,7 +47,7 @@ export const registrationRoutes = async (server: FastifyInstance) => { data: { id: eventId }, }); - if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + if (!event || !canManageSig(role, sigs, event.organiser)) { throw new ForbiddenError("You cannot manage registrations for this event"); } @@ -74,7 +72,7 @@ export const registrationRoutes = async (server: FastifyInstance) => { data: { id: eventId }, }); - if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + if (!event || !canManageSig(role, sigs, event.organiser)) { throw new ForbiddenError("You cannot manage registrations for this event"); } @@ -94,7 +92,6 @@ export const registrationRoutes = async (server: FastifyInstance) => { server.get("/", { preHandler: [requireEventManager] }, async (request, reply) => { const params = RegistrationEventIdSchema.parse(request.params); - const filters = RegistrationsQueryFilterSchema.parse(request.query); const { role, sigs } = request.user; const event = await eventService.getEventForAuth({ @@ -102,16 +99,13 @@ export const registrationRoutes = async (server: FastifyInstance) => { data: { id: params.eventId }, }); - if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + if (!event || !canManageSig(role, sigs, event.organiser)) { throw new ForbiddenError("You cannot view registrations for this event"); } const events = await registrationService.getRegistrations({ db: server.db, - filters: { - id: params.eventId, - ...filters, - }, + data: { id: params.eventId }, }); return reply.status(200).send(events); @@ -142,7 +136,7 @@ export const registrationRoutes = async (server: FastifyInstance) => { data: { id: params.eventId }, }); - if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + if (!event || !canManageSig(role, sigs, event.organiser)) { throw new ForbiddenError("You cannot manage registrations for this event"); } @@ -169,7 +163,7 @@ export const registrationRoutes = async (server: FastifyInstance) => { data: { id: eventId }, }); - if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + if (!event || !canManageSig(role, sigs, event.organiser)) { throw new ForbiddenError("You cannot view analytics for this event"); } diff --git a/apps/api/src/modules/registration/schema.ts b/apps/api/src/modules/registration/schema.ts index 6b38402..e45301b 100644 --- a/apps/api/src/modules/registration/schema.ts +++ b/apps/api/src/modules/registration/schema.ts @@ -1,60 +1,39 @@ import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; -import { eventsTable, registrationsTable, usersTable } from "../../db/schema.js"; -import { UserIdSchema } from "../users/schema.js"; +import { IdSchema } from "@events.comp-soc.com/shared"; +import { registrationsTable } from "../../db/schema.js"; export const BaseRegistrationSchema = createInsertSchema(registrationsTable); -export const RegistrationStoreSelection = { - userId: registrationsTable.userId, - firstName: usersTable.firstName, - lastName: usersTable.lastName, - email: usersTable.email, - eventId: registrationsTable.eventId, - status: registrationsTable.status, - answers: registrationsTable.answers, - createdAt: registrationsTable.createdAt, - updatedAt: registrationsTable.updatedAt, - eventTitle: eventsTable.title, - eventDate: eventsTable.date, - eventLocation: eventsTable.location, -}; - export const CreateRegistrationSchema = BaseRegistrationSchema.omit({ createdAt: true, updatedAt: true, }); export const UpdateRegistrationSchema = z.object({ - userId: BaseRegistrationSchema.shape.userId, - eventId: BaseRegistrationSchema.shape.eventId, + userId: IdSchema, + eventId: IdSchema, status: BaseRegistrationSchema.shape.status, }); export const UpdateBatchStatusRegistrationSchema = z.object({ - eventId: BaseRegistrationSchema.shape.eventId, - userIds: z.array(BaseRegistrationSchema.shape.userId), + eventId: IdSchema, + userIds: z.array(IdSchema), status: BaseRegistrationSchema.shape.status, }); -export const RegistrationsQueryFilterSchema = z.object({ - userId: UserIdSchema.shape.id.optional(), - status: BaseRegistrationSchema.shape.status.optional(), -}); - export const RegistrationParamsSchema = z.object({ - userId: BaseRegistrationSchema.shape.userId, - eventId: BaseRegistrationSchema.shape.eventId, + userId: IdSchema, + eventId: IdSchema, }); export const RegistrationEventIdSchema = z.object({ - eventId: BaseRegistrationSchema.shape.eventId, + eventId: IdSchema, }); export type CreateRegistration = z.infer; export type UpdateRegistration = z.infer; export type RegistrationParams = z.infer; -export type RegistrationsQueryFilter = z.infer; export type RegistrationEventId = z.infer; export type UpdateBatchRegistration = z.infer; diff --git a/apps/api/src/modules/registration/service.ts b/apps/api/src/modules/registration/service.ts index fff5f80..78bc2b1 100644 --- a/apps/api/src/modules/registration/service.ts +++ b/apps/api/src/modules/registration/service.ts @@ -3,15 +3,15 @@ import { CreateRegistration, RegistrationEventId, RegistrationParams, - RegistrationsQueryFilter, UpdateBatchRegistration, UpdateRegistration, } from "./schema.js"; import { eventStore } from "../events/store.js"; -import { UserRole, Sigs, canManageSig } from "@events.comp-soc.com/shared"; +import { EventState, Sigs, UserRole, canManageSig } from "@events.comp-soc.com/shared"; import { ConflictError, NotFoundError, UnauthorizedError } from "../../lib/errors.js"; import { registrationStore } from "./store.js"; import { EventId } from "../events/schema.js"; +import { validateRegistrationAnswers } from "./utils.js"; export const registrationService = { async createRegistration({ db, data }: { db: SqlContext; data: CreateRegistration }) { @@ -21,10 +21,19 @@ export const registrationService = { data: { id: data.eventId }, }); - if (event.state === "draft") { + if (!event || event.state === EventState.Draft) { throw new NotFoundError(`Event with ${data.eventId} not found`); } + if (event.date.getTime() < Date.now()) { + throw new ConflictError("Registration is closed for this event"); + } + + const answers = validateRegistrationAnswers({ + form: event.form, + answers: data.answers, + }); + const existing = await registrationStore.getByUserAndEvent({ db: tx, data: { userId: data.userId, eventId: data.eventId }, @@ -36,7 +45,7 @@ export const registrationService = { return await registrationStore.create({ db: tx, - data: { ...data, status: "pending" }, + data: { ...data, answers, status: "pending" }, }); }); }, @@ -48,14 +57,8 @@ export const registrationService = { }); }, - async getRegistrations({ - db, - filters, - }: { - db: SqlContext; - filters: RegistrationsQueryFilter & Pick; - }) { - return registrationStore.get({ db, filters }); + async getRegistrations({ db, data }: { db: SqlContext; data: Pick }) { + return registrationStore.get({ db, data }); }, async updateRegistration({ db, data }: { db: SqlContext; data: UpdateRegistration }) { @@ -187,7 +190,7 @@ export const registrationService = { data: { id: data.eventId }, }); - if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + if (!event || !canManageSig(role, sigs, event.organiser)) { throw new UnauthorizedError("You do not have permission to delete this registration"); } } diff --git a/apps/api/src/modules/registration/store.ts b/apps/api/src/modules/registration/store.ts index 03fe4a1..cfeb519 100644 --- a/apps/api/src/modules/registration/store.ts +++ b/apps/api/src/modules/registration/store.ts @@ -4,11 +4,10 @@ import { CreateRegistration, FormAnalyticsEntry, RegistrationParams, - RegistrationsQueryFilter, - RegistrationStoreSelection, UpdateBatchRegistration, UpdateRegistration, } from "./schema.js"; +import { RegistrationStoreSelection } from "./projections.js"; import { SqlContext } from "../../db/db.js"; import { eventsTable, registrationsTable, usersTable } from "../../db/schema.js"; import { EventId } from "../events/schema.js"; @@ -105,27 +104,13 @@ export const registrationStore = { return registration; }, - async get({ - db, - filters, - }: { - db: SqlContext; - filters: RegistrationsQueryFilter & Pick; - }) { - const { id, status, userId } = filters; - + async get({ db, data }: { db: SqlContext; data: Pick }) { return db .select(RegistrationStoreSelection) .from(registrationsTable) .innerJoin(eventsTable, eq(registrationsTable.eventId, eventsTable.id)) .innerJoin(usersTable, eq(registrationsTable.userId, usersTable.id)) - .where( - and( - eq(registrationsTable.eventId, id), - userId ? eq(registrationsTable.userId, userId) : undefined, - status ? eq(registrationsTable.status, status) : undefined - ) - ) + .where(eq(registrationsTable.eventId, data.id)) .orderBy(registrationsTable.createdAt); }, diff --git a/apps/api/src/modules/registration/utils.ts b/apps/api/src/modules/registration/utils.ts new file mode 100644 index 0000000..a60b122 --- /dev/null +++ b/apps/api/src/modules/registration/utils.ts @@ -0,0 +1,41 @@ +import type { CustomField, Nullable, RegistrationFormAnswer } from "@events.comp-soc.com/shared"; +import { BadRequestError } from "../../lib/errors.js"; + +export const validateRegistrationAnswers = ({ + form, + answers, +}: { + form: Nullable | undefined; + answers: Nullable | undefined; +}): RegistrationFormAnswer => { + const fields = form ?? []; + const providedAnswers = answers ?? {}; + const fieldIds = new Set(fields.map((field) => field.id)); + + for (const answerId of Object.keys(providedAnswers)) { + if (!fieldIds.has(answerId)) { + throw new BadRequestError(`Unknown registration answer: ${answerId}`); + } + } + + const normalisedAnswers: RegistrationFormAnswer = {}; + + for (const field of fields) { + const answer = providedAnswers[field.id]?.trim(); + + if (!answer) { + if (field.required) { + throw new BadRequestError(`${field.label} is required`); + } + continue; + } + + if (field.type === "select" && !field.options?.includes(answer)) { + throw new BadRequestError(`${field.label} has an invalid answer`); + } + + normalisedAnswers[field.id] = answer; + } + + return normalisedAnswers; +}; diff --git a/apps/api/src/modules/users/route.test.ts b/apps/api/src/modules/users/route.test.ts index 78b841a..25dec8b 100644 --- a/apps/api/src/modules/users/route.test.ts +++ b/apps/api/src/modules/users/route.test.ts @@ -5,6 +5,7 @@ import { buildServer } from "../../server.js"; import { db } from "../../db/db.js"; import { sql, eq } from "drizzle-orm"; import { eventsTable, registrationsTable, usersTable } from "../../db/schema.js"; +import { Sigs } from "@events.comp-soc.com/shared"; vi.mock("@clerk/fastify", () => { return { @@ -42,21 +43,6 @@ describe("User", () => { await db.insert(usersTable).values(targetUser); }); - it("should return 404 if user does not exist", async () => { - setMockAuth({ userId: "admin", sessionClaims: { metadata: { role: "committee" } } }); - - const response = await app.inject({ - method: "GET", - url: "/v1/users/non_existent_id", - }); - - expect(response.statusCode).toBe(404); - expect(response.json()).toEqual({ - message: "User with non_existent_id not found", - statusCode: 404, - }); - }); - it("should allow a user to see their own full profile (including email)", async () => { setMockAuth({ userId: targetUser.id, @@ -155,18 +141,6 @@ describe("User", () => { expect(response.statusCode).toBe(400); }); - it("should reject missing required fields (lastName)", async () => { - setMockAuth({ userId: "user_y", sessionClaims: { metadata: { role: "member" } } }); - - const response = await app.inject({ - method: "POST", - url: "/v1/users", - payload: { firstName: "John", email: "john@test.com" }, - }); - - expect(response.statusCode).toBe(400); - }); - it("should ignore attempts to set protected fields (like role) via payload", async () => { const authId = "hacker_1"; setMockAuth({ userId: authId, sessionClaims: { metadata: { role: "member" } } }); @@ -243,18 +217,6 @@ describe("User", () => { const [dbUser] = await db.select().from(usersTable).where(eq(usersTable.id, existingUser.id)); expect(dbUser.lastName).toBe("AdminEdited"); }); - - it("should return 404 when updating non-existent user", async () => { - setMockAuth({ userId: "admin_user", sessionClaims: { metadata: { role: "committee" } } }); - - const response = await app.inject({ - method: "PUT", - url: "/v1/users/ghost_user", - payload: { firstName: "Casper" }, - }); - - expect(response.statusCode).toBe(404); - }); }); describe("DELETE /v1/users/:id", () => { @@ -313,17 +275,6 @@ describe("User", () => { const users = await db.select().from(usersTable).where(eq(usersTable.id, userToDelete.id)); expect(users).toHaveLength(0); }); - - it("should return 404 if deleting non-existent user", async () => { - setMockAuth({ userId: "admin", sessionClaims: { metadata: { role: "committee" } } }); - - const response = await app.inject({ - method: "DELETE", - url: "/v1/users/already_gone", - }); - - expect(response.statusCode).toBe(404); - }); }); describe("GET /v1/users/registrations", () => { @@ -343,24 +294,12 @@ describe("User", () => { title: "Hackathon 2024", state: "published", aboutMarkdown: "md", - organiser: "soc", + organiser: Sigs.Compsoc, date: new Date(), }, ]); }); - it("should return 204 No Content if user has no registrations", async () => { - setMockAuth({ userId: activeUserId, sessionClaims: { metadata: { role: "member" } } }); - - const response = await app.inject({ - method: "GET", - url: "/v1/users/registrations", - }); - - expect(response.statusCode).toBe(204); - expect(response.body).toBe(""); - }); - it("should return list of registrations with joined event details", async () => { // Create a registration await db.insert(registrationsTable).values({ diff --git a/apps/api/src/modules/users/route.ts b/apps/api/src/modules/users/route.ts index 99de799..529a2c2 100644 --- a/apps/api/src/modules/users/route.ts +++ b/apps/api/src/modules/users/route.ts @@ -1,7 +1,11 @@ import { FastifyInstance } from "fastify"; -import { CreateUserSchema, UserIdSchema, UpdateUserSchema } from "./schema.js"; +import { CreateUserSchema, UpdateUserSchema, UserIdSchema } from "./schema.js"; import { userService } from "./service.js"; -import { UpdateUserContractSchema, UserContractSchema } from "@events.comp-soc.com/shared"; +import { + UpdateUserContractSchema, + UserContractSchema, + UserRegistrationsQueryFilterSchema, +} from "@events.comp-soc.com/shared"; import { requireAuth } from "../../lib/auth-guard.js"; export const userRoutes = async (server: FastifyInstance) => { @@ -23,12 +27,14 @@ export const userRoutes = async (server: FastifyInstance) => { server.get("/registrations", { preHandler: [requireAuth] }, async (request, reply) => { const { userId } = request.user; + const filters = UserRegistrationsQueryFilterSchema.parse(request.query); const registrations = await userService.getUserRegistrations({ db: server.db, data: { id: userId, }, + filters, }); if (registrations && registrations.length > 0) { diff --git a/apps/api/src/modules/users/schema.ts b/apps/api/src/modules/users/schema.ts index d0183eb..60d7e59 100644 --- a/apps/api/src/modules/users/schema.ts +++ b/apps/api/src/modules/users/schema.ts @@ -1,11 +1,12 @@ import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; +import { IdSchema } from "@events.comp-soc.com/shared"; import { usersTable } from "../../db/schema.js"; export const BaseUserSchema = createInsertSchema(usersTable); -export const UserIdSchema = BaseUserSchema.pick({ - id: true, +export const UserIdSchema = z.object({ + id: IdSchema, }); export const CreateUserSchema = BaseUserSchema.omit({ @@ -20,7 +21,7 @@ export const UpdateUserSchema = BaseUserSchema.omit({ }) .partial() .extend({ - id: BaseUserSchema.shape.id, + id: IdSchema, }); export type UserId = z.infer; diff --git a/apps/api/src/modules/users/service.ts b/apps/api/src/modules/users/service.ts index 431f1c1..d0d8c97 100644 --- a/apps/api/src/modules/users/service.ts +++ b/apps/api/src/modules/users/service.ts @@ -1,8 +1,8 @@ import { SqlContext } from "../../db/db.js"; -import { CreateUser, UserId, UpdateUser } from "./schema.js"; +import { CreateUser, UpdateUser, UserId } from "./schema.js"; import { NotFoundError, UnauthorizedError } from "../../lib/errors.js"; import { userStore } from "./store.js"; -import { Nullable, UserRole } from "@events.comp-soc.com/shared"; +import { Nullable, UserRegistrationsQueryFilter, UserRole } from "@events.comp-soc.com/shared"; export const userService = { async getUserById({ @@ -35,8 +35,16 @@ export const userService = { return userStore.create({ db, data }); }, - async getUserRegistrations({ db, data }: { db: SqlContext; data: UserId }) { - return userStore.getRegistrationsById({ db, data }); + async getUserRegistrations({ + db, + data, + filters, + }: { + db: SqlContext; + data: UserId; + filters?: UserRegistrationsQueryFilter; + }) { + return userStore.getRegistrationsById({ db, data, filters }); }, async updateUser({ diff --git a/apps/api/src/modules/users/store.ts b/apps/api/src/modules/users/store.ts index 577ae77..4304bc4 100644 --- a/apps/api/src/modules/users/store.ts +++ b/apps/api/src/modules/users/store.ts @@ -1,8 +1,9 @@ -import { and, eq, gt, sql } from "drizzle-orm"; +import { and, eq, gte, lt, SQL } from "drizzle-orm"; import { SqlContext } from "../../db/db.js"; import { CreateUser, UpdateUser, UserId } from "./schema.js"; import { eventsTable, registrationsTable, usersTable } from "../../db/schema.js"; -import { RegistrationStoreSelection } from "../registration/schema.js"; +import { RegistrationStoreSelection } from "../registration/projections.js"; +import type { UserRegistrationsQueryFilter } from "@events.comp-soc.com/shared"; export const userStore = { async create({ db, data }: { db: SqlContext; data: CreateUser }) { @@ -32,18 +33,30 @@ export const userStore = { return result[0]; }, - async getRegistrationsById({ db, data }: { db: SqlContext; data: UserId }) { + async getRegistrationsById({ + db, + data, + filters, + }: { + db: SqlContext; + data: UserId; + filters?: UserRegistrationsQueryFilter; + }) { const { id } = data; + const conditions = [ + eq(registrationsTable.userId, id), + filters?.from ? gte(eventsTable.date, new Date(filters.from)) : null, + filters?.until ? lt(eventsTable.date, new Date(filters.until)) : null, + ].filter((c): c is SQL => c !== null); + return db .select(RegistrationStoreSelection) .from(registrationsTable) .innerJoin(usersTable, eq(registrationsTable.userId, usersTable.id)) .innerJoin(eventsTable, eq(registrationsTable.eventId, eventsTable.id)) - .where( - and(eq(registrationsTable.userId, id), gt(eventsTable.date, sql`NOW() - INTERVAL '1 day'`)) - ) - .orderBy(registrationsTable.createdAt); + .where(and(...conditions)) + .orderBy(eventsTable.date); }, async delete({ db, data }: { db: SqlContext; data: UserId }) { diff --git a/apps/api/src/modules/webhooks/clerk.ts b/apps/api/src/modules/webhooks/clerk.ts index 68b79b7..dbd8ba4 100644 --- a/apps/api/src/modules/webhooks/clerk.ts +++ b/apps/api/src/modules/webhooks/clerk.ts @@ -34,6 +34,22 @@ interface ClerkWebhookEvent { } export const clerkWebhookRoutes = async (server: FastifyInstance) => { + // Custom parses for the body so webhook can correctly check body for the verification + server.addContentTypeParser( + "application/json", + { parseAs: "string" }, + (req: FastifyRequest, body: string, done: (err: Nullable, data: unknown) => void) => { + try { + const json = JSON.parse(body); + // Store raw body for webhook verification + req.rawBody = body; + done(null, json); + } catch (err) { + done(err as Error, undefined); + } + } + ); + server.post( "/clerk", { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index fcc5b5a..a760c5f 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,4 +1,4 @@ -import Fastify, { FastifyRequest } from "fastify"; +import Fastify from "fastify"; import dbPlugin from "./plugins/db.js"; import { clerkPlugin } from "@clerk/fastify"; import { loggerConfig } from "./lib/logger.js"; @@ -14,26 +14,13 @@ export function buildServer() { logger: loggerConfig, }); - server.addContentTypeParser( - "application/json", - { parseAs: "string" }, - (req: FastifyRequest, body: string, done: (err: Error | null, data: unknown) => void) => { - try { - const json = JSON.parse(body); - // Store raw body for webhook verification - req.rawBody = body; - done(null, json); - } catch (err) { - done(err as Error, undefined); - } - } - ); - server.register(dbPlugin); - server.register(clerkPlugin); + // Handles connection between clerk and backend (will be removed when migration from clerk starts) + server.register(clerkPlugin); server.register(clerkWebhookRoutes, { prefix: "/webhooks" }); + // Handles all business logic server.register(userRoutes, { prefix: "/v1/users" }); server.register(eventRoutes, { prefix: "/v1/events" }); server.register(registrationRoutes, { prefix: "/v1/events/:eventId/registrations" }); diff --git a/apps/shared/README.md b/apps/shared/README.md new file mode 100644 index 0000000..a6fe4e1 --- /dev/null +++ b/apps/shared/README.md @@ -0,0 +1,42 @@ +# `@events.comp-soc.com/shared` + +Shared types, Zod schemas and constants consumed by both `apps/web` and `apps/api`. The single source of truth for contracts that cross the network boundary. + +## Stack + +- **TypeScript** (compiled to `dist/`) +- **Zod** for runtime schemas + inferred static types + +## What's inside + +``` +src/ +├── core/ Sigs enum, shared primitives (Nullable, etc.) +├── events/ Event contract & response schemas, EventState, EventPriority +├── registrations/ Registration contract & response schemas, RegistrationStatus +├── users/ User schema, UserRole, role helpers (isEventManager, canManageSig) +└── index.ts Public exports +``` + +Every cross-cutting type the frontend and backend agree on lives here — when the API changes a shape, you change it once. + +## Scripts + +```bash +pnpm build # tsc to dist/ +``` + +## Consuming + +Both `apps/web` and `apps/api` import via the package name and the `workspace:*` protocol in `package.json`: + +```ts +import { + EventResponseSchema, + RegistrationStatus, + Sigs, + canManageSig, +} from "@events.comp-soc.com/shared"; +``` + +Run `pnpm --filter @events.comp-soc.com/shared build` after editing schemas to refresh `dist/` for the other apps. diff --git a/apps/shared/src/core/schemas.ts b/apps/shared/src/core/schemas.ts new file mode 100644 index 0000000..4b5c42b --- /dev/null +++ b/apps/shared/src/core/schemas.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +// Canonical shape of every entity id on the wire. +export const IdSchema = z.string().min(1, "ID is required"); + +export type Id = z.infer; diff --git a/apps/shared/src/events/filters.ts b/apps/shared/src/events/filters.ts new file mode 100644 index 0000000..e8acdff --- /dev/null +++ b/apps/shared/src/events/filters.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; +import { Sigs } from "../core/constants.js"; +import { EventState } from "./constants.js"; + +/** + * Query filter for `GET /v1/events`. + * + * The wire format is HTTP query-string flat values: + * - `sigs` is a comma-joined string (`sigs=foo,bar`) and parses to `string[]`. + * - `includePast` is the string `"true"` / `"false"` and parses to a boolean. + * - `dateFrom` and `dateTo` are inclusive `YYYY-MM-DD` bounds. For a single + * day, pass `dateFrom === dateTo`. + * + * Consumers (api route + web fetch helper) both rely on the transforms here. + */ +export const EventsQueryFilterSchema = z.object({ + state: z.enum(EventState).optional(), + includePast: z + .enum(["true", "false"]) + .optional() + .transform((val) => val === "true"), + search: z.string().trim().min(1).max(200).optional(), + sigs: z + .string() + .optional() + .transform((val) => + val + ? (val + .split(",") + .map((s) => s.trim()) + .filter(Boolean) as Sigs[]) + : undefined + ), + dateFrom: z.iso.date().optional(), + dateTo: z.iso.date().optional(), +}); + +export type EventsQueryFilter = z.infer; diff --git a/apps/shared/src/events/schemas.ts b/apps/shared/src/events/schemas.ts index 23a9f24..d288bf3 100644 --- a/apps/shared/src/events/schemas.ts +++ b/apps/shared/src/events/schemas.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { Sigs } from "../core/constants.js"; +import { IdSchema } from "../core/schemas.js"; import { EventPriority, EventState } from "./constants.js"; export const CustomFieldSchema = z @@ -35,7 +36,7 @@ export const EventContractSchema = z.object({ export const UpdateEventContractSchema = EventContractSchema.partial(); export const EventResponseSchema = EventContractSchema.extend({ - id: z.string().min(1, "ID is required"), + id: IdSchema, createdAt: z.iso.datetime(), updatedAt: z.iso.datetime(), }); diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index d52bde0..34ac3d0 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -1,13 +1,17 @@ export * from "./events/schemas.js"; export * from "./events/types.js"; export * from "./events/constants.js"; +export * from "./events/filters.js"; export * from "./core/constants.js"; +export * from "./core/schemas.js"; export * from "./core/types.js"; export * from "./users/types.js"; export * from "./users/constants.js"; export * from "./users/schemas.js"; +export * from "./users/permissions.js"; +export * from "./users/filters.js"; export * from "./registrations/types.js"; export * from "./registrations/schemas.js"; diff --git a/apps/shared/src/registrations/schemas.ts b/apps/shared/src/registrations/schemas.ts index 3c4038f..4acf0dc 100644 --- a/apps/shared/src/registrations/schemas.ts +++ b/apps/shared/src/registrations/schemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { IdSchema } from "../core/schemas.js"; import { RegistrationStatus } from "./constants.js"; export const RegistrationAnswerSchema = z.record(z.string(), z.string()); @@ -12,16 +13,16 @@ export const RegistrationUpdateContractSchema = z.object({ }); export const RegistrationStatusBatchUpdateSchema = z.object({ - userIds: z.array(z.string()), + userIds: z.array(IdSchema), status: z.enum(RegistrationStatus), }); export const RegistrationResponseSchema = RegistrationContractSchema.extend({ - userId: z.string(), + userId: IdSchema, firstName: z.string(), lastName: z.string(), email: z.email(), - eventId: z.string(), + eventId: IdSchema, eventTitle: z.string().optional(), eventDate: z.iso.datetime(), eventLocation: z.string().nullable(), diff --git a/apps/shared/src/users/filters.ts b/apps/shared/src/users/filters.ts new file mode 100644 index 0000000..021eee1 --- /dev/null +++ b/apps/shared/src/users/filters.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +/** + * Query filter for `GET /v1/users/registrations`. + * + * Both `from` and `until` are ISO datetime strings (UTC). Use them to bound + * the event date range — e.g. `from = startOfToday()` for upcoming-only, + * `until = startOfToday()` for archive. + */ +export const UserRegistrationsQueryFilterSchema = z.object({ + /** Inclusive lower bound — return events with date >= from. */ + from: z.iso.datetime().optional(), + /** Exclusive upper bound — return events with date < until. */ + until: z.iso.datetime().optional(), +}); + +export type UserRegistrationsQueryFilter = z.infer; diff --git a/apps/shared/src/users/permissions.ts b/apps/shared/src/users/permissions.ts new file mode 100644 index 0000000..c367fa3 --- /dev/null +++ b/apps/shared/src/users/permissions.ts @@ -0,0 +1,18 @@ +import { Sigs } from "../core/constants.js"; +import { UserRole } from "./constants.js"; + +// Can `role` (with optional `userSigs`) manage resources owned by `targetSig` +export function canManageSig( + role: UserRole, + userSigs: Sigs[] | undefined, + targetSig: Sigs +): boolean { + if (role === UserRole.Committee) return true; + return !!(role === UserRole.SigExecutive && userSigs?.includes(targetSig)); +} + +// Used by route pre-handlers to decide whether the caller +// is allowed to take any event-management action at all. +export function isEventManager(role: UserRole): boolean { + return role === UserRole.Committee || role === UserRole.SigExecutive; +} diff --git a/apps/shared/src/users/schemas.ts b/apps/shared/src/users/schemas.ts index 65365de..d43a5d6 100644 --- a/apps/shared/src/users/schemas.ts +++ b/apps/shared/src/users/schemas.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { Sigs } from "../core/constants.js"; -import { UserRole } from "./constants.js"; +import { IdSchema } from "../core/schemas.js"; export const UserContractSchema = z.object({ email: z.email("Invalid email address"), @@ -10,21 +9,8 @@ export const UserContractSchema = z.object({ export const UpdateUserContractSchema = UserContractSchema.partial(); export const UserResponseSchema = UserContractSchema.extend({ - id: z.string().min(1, "ID is required"), + id: IdSchema, email: z.email().nullable(), createdAt: z.iso.datetime(), updatedAt: z.iso.datetime(), }); - -export function canManageSig( - role: UserRole, - userSigs: Sigs[] | undefined, - targetSig: Sigs -): boolean { - if (role === UserRole.Committee) return true; - return !!(role === UserRole.SigExecutive && userSigs?.includes(targetSig)); -} - -export function isEventManager(role: UserRole): boolean { - return role === UserRole.Committee || role === UserRole.SigExecutive; -} diff --git a/apps/web/README.md b/apps/web/README.md index 0177ffb..abccc27 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,309 +1,49 @@ -Welcome to your new TanStack app! +# `@events.comp-soc.com/web` -# Getting Started +Frontend application for the CompSoc Events Platform. Members browse and register for events; committee members and Sig executives create, draft and manage them. -To run this application: +## Stack -```bash -pnpm install -pnpm start -``` - -# Building For Production - -To build this application for production: - -```bash -pnpm build -``` - -## Testing - -This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with: - -```bash -pnpm test -``` - -## Styling - -This project uses [Tailwind CSS](https://tailwindcss.com/) for styling. - -## Linting & Formatting - -This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available: - -```bash -pnpm lint -pnpm format -pnpm check -``` - -## Setting up Clerk - -- Set the `VITE_CLERK_PUBLISHABLE_KEY` in your `.env.local`. - -## Shadcn - -Add components using the latest version of [Shadcn](https://ui.shadcn.com/). - -```bash -pnpm dlx shadcn@latest add button -``` - -## Routing - -This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`. - -### Adding A Route - -To add a new route to your application just add another a new file in the `./src/routes` directory. - -TanStack will automatically generate the content of the route file for you. - -Now that you have two routes you can use a `Link` component to navigate between them. - -### Adding Links - -To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`. - -```tsx -import { Link } from '@tanstack/react-router' -``` - -Then anywhere in your JSX you can use it like so: - -```tsx -About -``` - -This will create a link that will navigate to the `/about` route. - -More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent). - -### Using A Layout - -In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `` component. - -Here is an example layout that includes a header: - -```tsx -import { Outlet, createRootRoute } from '@tanstack/react-router' -import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' - -import { Link } from '@tanstack/react-router' - -export const Route = createRootRoute({ - component: () => ( - <> -
- -
- - - - ), -}) -``` +- **React 19** + **TanStack Start** (full-stack React framework on Vite) +- **TanStack Router** for routing, **TanStack Query** for server state, **TanStack Form** for forms +- **Tailwind CSS v4** for styling, **Radix UI** primitives + `shadcn`-style components +- **Clerk** for authentication +- **Zod** for runtime validation (shared with the API via `@events.comp-soc.com/shared`) +- **react-day-picker**, **recharts**, **sonner**, **lucide-react**, **date-fns**, **redaxios** -The `` component is not required so you can remove it if you don't want it in your layout. +## Environment -More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts). +Create `apps/web/.env.local`: -## Data Fetching - -There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered. - -For example: - -```tsx -const peopleRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/people', - loader: async () => { - const response = await fetch('https://swapi.dev/api/people') - return response.json() as Promise<{ - results: { - name: string - }[] - }> - }, - component: () => { - const data = peopleRoute.useLoaderData() - return ( -
    - {data.results.map((person) => ( -
  • {person.name}
  • - ))} -
- ) - }, -}) -``` - -Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters). - -### React-Query - -React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze. - -First add your dependencies: - -```bash -pnpm add @tanstack/react-query @tanstack/react-query-devtools -``` - -Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`. - -```tsx -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' - -// ... - -const queryClient = new QueryClient() - -// ... - -if (!rootElement.innerHTML) { - const root = ReactDOM.createRoot(rootElement) - - root.render( - - - , - ) -} -``` - -You can also add TanStack Query Devtools to the root route (optional). - -```tsx -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' - -const rootRoute = createRootRoute({ - component: () => ( - <> - - - - - ), -}) -``` - -Now you can use `useQuery` to fetch your data. - -```tsx -import { useQuery } from '@tanstack/react-query' - -import './App.css' - -function App() { - const { data } = useQuery({ - queryKey: ['people'], - queryFn: () => - fetch('https://swapi.dev/api/people') - .then((res) => res.json()) - .then((data) => data.results as { name: string }[]), - initialData: [], - }) - - return ( -
-
    - {data.map((person) => ( -
  • {person.name}
  • - ))} -
-
- ) -} - -export default App +```env +VITE_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key +VITE_API_URL=http://localhost:8080 ``` -You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview). - -## State Management - -Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project. - -First you need to add TanStack Store as a dependency: +## Scripts ```bash -pnpm add @tanstack/store +pnpm dev # Start dev server on http://localhost:3000 +pnpm build # Build for production +pnpm preview # Preview production build +pnpm test # Run Vitest +pnpm lint # ESLint +pnpm format # Prettier +pnpm check # Format + lint --fix ``` -Now let's create a simple counter in the `src/App.tsx` file as a demonstration. - -```tsx -import { useStore } from '@tanstack/react-store' -import { Store } from '@tanstack/store' -import './App.css' +## Project layout -const countStore = new Store(0) - -function App() { - const count = useStore(countStore) - return ( -
- -
- ) -} - -export default App ``` - -One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates. - -Let's check this out by doubling the count using derived state. - -```tsx -import { useStore } from '@tanstack/react-store' -import { Store, Derived } from '@tanstack/store' -import './App.css' - -const countStore = new Store(0) - -const doubledStore = new Derived({ - fn: () => countStore.state * 2, - deps: [countStore], -}) -doubledStore.mount() - -function App() { - const count = useStore(countStore) - const doubledCount = useStore(doubledStore) - - return ( -
- -
Doubled - {doubledCount}
-
- ) -} - -export default App +src/ +├── components/ UI primitives, layout, and feature components +├── config/ Sigs, navigation, page metadata +├── integrations/ Clerk + TanStack Query providers +├── lib/ data fetching (server fns), hooks, auth, utils +├── routes/ File-based TanStack Router routes +└── styles.css Tailwind theme tokens (background / surface / navigation) ``` -We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating. - -Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook. - -You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest). - -# Demo files - -Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed. - -# Learn More +## Deployment -You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com). +Configured for Vercel via `vercel.json`. Build runs `vite build`. diff --git a/apps/web/package.json b/apps/web/package.json index 893c843..e6361a6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,9 +12,9 @@ "check": "prettier --write . && eslint --fix" }, "dependencies": { - "@clerk/clerk-react": "^5.49.0", - "@clerk/tanstack-react-start": "^0.27.8", - "@clerk/themes": "^2.4.46", + "@clerk/clerk-react": "^5.61.6", + "@clerk/tanstack-react-start": "^0.29.11", + "@clerk/themes": "^2.4.57", "@events.comp-soc.com/shared": "workspace:*", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -81,7 +81,7 @@ "prettier": "^3.5.3", "tw-animate-css": "^1.4.0", "typescript": "^5.7.2", - "vite": "^7.1.7", + "vite": "8.0.5", "vitest": "^3.0.5", "web-vitals": "^5.1.0" } diff --git a/apps/web/public/page-images/404.webp b/apps/web/public/page-images/404.webp new file mode 100644 index 0000000..120c378 Binary files /dev/null and b/apps/web/public/page-images/404.webp differ diff --git a/apps/web/public/page-images/mascot-discord.webp b/apps/web/public/page-images/mascot-discord.webp new file mode 100644 index 0000000..9dc05d3 Binary files /dev/null and b/apps/web/public/page-images/mascot-discord.webp differ diff --git a/apps/web/public/page-images/no-events.webp b/apps/web/public/page-images/no-events.webp new file mode 100644 index 0000000..2e7e712 Binary files /dev/null and b/apps/web/public/page-images/no-events.webp differ diff --git a/apps/web/public/page-images/wrench.webp b/apps/web/public/page-images/wrench.webp new file mode 100644 index 0000000..0c0ed8b Binary files /dev/null and b/apps/web/public/page-images/wrench.webp differ diff --git a/apps/web/src/components/charts/form-select-chart.tsx b/apps/web/src/components/charts/form-select-chart.tsx index c791081..23036a9 100644 --- a/apps/web/src/components/charts/form-select-chart.tsx +++ b/apps/web/src/components/charts/form-select-chart.tsx @@ -68,7 +68,7 @@ export function SelectAnalyticsBarChart({ {title} - + - + } /> diff --git a/apps/web/src/components/controlls/create-register-event-button.tsx b/apps/web/src/components/controlls/create-register-event-button.tsx index 3776be4..ad58fd4 100644 --- a/apps/web/src/components/controlls/create-register-event-button.tsx +++ b/apps/web/src/components/controlls/create-register-event-button.tsx @@ -14,12 +14,10 @@ function CreateRegisterEventButton({ form, title, eventId, - disabled, }: { form: Array title: string eventId: string - disabled: boolean }) { const [open, setOpen] = useState(false) const { isAuthenticated } = useCommitteeAuth() @@ -47,7 +45,10 @@ function CreateRegisterEventButton({ return ( <> - ( + CompSoc mascot e.preventDefault()} + onDragStart={(e) => e.preventDefault()} + className="w-64 h-64 md:w-56 md:h-56 object-contain shrink-0 mx-auto md:mx-0 mt-6 md:mt-0 select-none pointer-events-auto [-webkit-user-drag:none] [-webkit-touch-callout:none]" + /> +) + +const DISCORD_URL = 'https://discord.gg/fmp7p9Ca4y' + +function DiscordSection() { + return ( +
+
+
+
+
+

+ Looks like you reached the bottom of the events +

+

+ CompSoc and our Special Interest Groups have plenty more in the + pipeline. New hackathons, talks, workshops and socials are added + all the time. +

+

+ Join our Discord to keep up with what's happening, hear about + events before they go live, and meet the people building things + with you. +

+ + +
+ + Discord + +
+
+
+ + +
+
+
+
+ ) +} + +export default DiscordSection diff --git a/apps/web/src/components/draft-badge.tsx b/apps/web/src/components/draft-badge.tsx index d0148d1..aa3985d 100644 --- a/apps/web/src/components/draft-badge.tsx +++ b/apps/web/src/components/draft-badge.tsx @@ -1,8 +1,10 @@ +import { Badge } from '@/components/ui/badge.tsx' + function DraftBadge() { return ( - + Draft - + ) } diff --git a/apps/web/src/components/event-card.tsx b/apps/web/src/components/event-card.tsx index 64d2a80..ea5db57 100644 --- a/apps/web/src/components/event-card.tsx +++ b/apps/web/src/components/event-card.tsx @@ -1,43 +1,57 @@ import { Link } from '@tanstack/react-router' -import { ArrowUpRight, CalendarIcon, MapPin } from 'lucide-react' +import { MapPin, Users } from 'lucide-react' import type { Event } from '@events.comp-soc.com/shared' import { SigBadge } from '@/components/sigs-badge.tsx' import { formatEventDate } from '@/lib/utils.ts' interface EventCardProps { event: Event + pinned?: boolean } -function EventCard({ event }: EventCardProps) { +function EventCard({ event, pinned = false }: EventCardProps) { const { full: date } = formatEventDate(event.date) return ( -
-
-
-
- -
-

- {event.title} -

+
+
+
+
- -
-
-
- - {date} -
-
- - {event.location} +
{date}
+ +

+ {event.title} +

+ +
+
+ + {event.location} +
+ + {event.capacity != null && ( +
+ + {event.capacity} +
+ )} + +
+ +
diff --git a/apps/web/src/components/forms/modify-event-form.tsx b/apps/web/src/components/forms/modify-event-form.tsx index 7e6ab28..c37b38a 100644 --- a/apps/web/src/components/forms/modify-event-form.tsx +++ b/apps/web/src/components/forms/modify-event-form.tsx @@ -51,7 +51,6 @@ import { InputGroupAddon, InputGroupButton, InputGroupInput, - InputGroupText, } from '@/components/ui/input-group.tsx' import { Tooltip, @@ -290,7 +289,7 @@ function ModifyEventForm({ {!isCommittee && ( - You can only create events for your assigned SIGs + You can only create events for your assigned Sigs )} {isInvalid && ( @@ -527,11 +526,8 @@ function ModifyEventForm({ onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} aria-invalid={isInvalid} - placeholder="example.com" + placeholder="https://maps.app.goo.gl/fgNpzT2rWR5GHqSb7" /> - - https:// - diff --git a/apps/web/src/components/home/calendar-panel.tsx b/apps/web/src/components/home/calendar-panel.tsx new file mode 100644 index 0000000..a13d6fd --- /dev/null +++ b/apps/web/src/components/home/calendar-panel.tsx @@ -0,0 +1,82 @@ +import { useMemo } from 'react' +import { Calendar } from '@/components/ui/calendar.tsx' + +interface CalendarPanelProps { + selected: Date | undefined + onSelect: (date: Date | undefined) => void + mode?: 'upcoming' | 'archive' +} + +function CalendarPanel({ + selected, + onSelect, + mode = 'upcoming', +}: CalendarPanelProps) { + const bounds = useMemo(() => { + const today = new Date() + today.setHours(0, 0, 0, 0) + + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + + const startOfCurrentMonth = new Date(today) + startOfCurrentMonth.setDate(1) + + const startOfCurrentYear = new Date(today.getFullYear(), 0, 1) + + const archiveEndMonth = + yesterday.getTime() >= startOfCurrentYear.getTime() + ? yesterday + : startOfCurrentYear + + return { + today, + yesterday, + startOfCurrentMonth, + startOfCurrentYear, + archiveEndMonth, + } + }, []) + + const isArchive = mode === 'archive' + + return ( +
+ +
+ ) +} + +export default CalendarPanel diff --git a/apps/web/src/components/home/events-list.tsx b/apps/web/src/components/home/events-list.tsx new file mode 100644 index 0000000..b89649d --- /dev/null +++ b/apps/web/src/components/home/events-list.tsx @@ -0,0 +1,52 @@ +import { EventPriority } from '@events.comp-soc.com/shared' +import type { Event } from '@events.comp-soc.com/shared' +import EventCard from '@/components/event-card.tsx' +import EmptyState from '@/components/layout/empty-state.tsx' +import { Skeleton } from '@/components/ui/skeleton.tsx' + +interface EventsListProps { + events: ReadonlyArray + isLoading: boolean +} + +function EventsList({ events, isLoading }: EventsListProps) { + if (isLoading) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) + } + + if (events.length === 0) { + return ( + + ) + } + + const pinnedEvents = events.filter( + (event) => event.priority === EventPriority.Pinned, + ) + const defaultEvents = events.filter( + (event) => event.priority === EventPriority.Default, + ) + + return ( +
+ {pinnedEvents.map((event) => ( + + ))} + {defaultEvents.map((event) => ( + + ))} +
+ ) +} + +export default EventsList diff --git a/apps/web/src/components/home/my-events-list.tsx b/apps/web/src/components/home/my-events-list.tsx new file mode 100644 index 0000000..8c97be6 --- /dev/null +++ b/apps/web/src/components/home/my-events-list.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' +import { Link } from '@tanstack/react-router' +import type { Registration } from '@events.comp-soc.com/shared' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip.tsx' +import { Skeleton } from '@/components/ui/skeleton.tsx' +import { Button } from '@/components/ui/button.tsx' +import { + RegistrationStatusDot, + STATUS_LABEL, +} from '@/components/registration-status.tsx' +import { formatEventDate } from '@/lib/utils.ts' + +const PREVIEW_COUNT = 5 + +interface MyEventsListProps { + registrations: ReadonlyArray + isSignedIn: boolean + isPending: boolean +} + +function MyEventsList({ + registrations, + isSignedIn, + isPending, +}: MyEventsListProps) { + const [expanded, setExpanded] = useState(false) + + if (isSignedIn && isPending) { + return ( +
+

Your events

+
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+ ) + } + + if (registrations.length === 0) return null + + const visible = expanded + ? registrations + : registrations.slice(0, PREVIEW_COUNT) + + return ( +
+

Your events

+
    + {visible.map((reg) => { + const { full: when } = formatEventDate(reg.eventDate) + return ( +
  • + +
    + + + + + + {STATUS_LABEL[reg.status]} + + + + {reg.eventTitle ?? 'Untitled event'} + +
    + {when} + +
  • + ) + })} +
+ {registrations.length > PREVIEW_COUNT && ( + + )} +
+ ) +} + +export default MyEventsList diff --git a/apps/web/src/components/home/sigs-filter.tsx b/apps/web/src/components/home/sigs-filter.tsx new file mode 100644 index 0000000..31879a0 --- /dev/null +++ b/apps/web/src/components/home/sigs-filter.tsx @@ -0,0 +1,61 @@ +import { ALL_SIGS } from '@/config/sigs.ts' + +interface SigsFilterProps { + selectedSigs: Array + onToggle: (id: string) => void +} + +function SigsFilter({ selectedSigs, onToggle }: SigsFilterProps) { + return ( +
+
    + {ALL_SIGS.map((sig) => { + const isSelected = selectedSigs.includes(sig.id) + return ( +
  • + +
  • + ) + })} +
+