diff --git a/apps/api/drizzle/0001_military_juggernaut.sql b/apps/api/drizzle/0001_military_juggernaut.sql new file mode 100644 index 0000000..f3495ab --- /dev/null +++ b/apps/api/drizzle/0001_military_juggernaut.sql @@ -0,0 +1,4 @@ +ALTER TYPE "public"."roles" ADD VALUE 'sig_executive' BEFORE 'committee';--> statement-breakpoint +ALTER TABLE "registrations" ALTER COLUMN "status" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "registrations" ALTER COLUMN "created_at" SET NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "sigs" json; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..05ecc74 --- /dev/null +++ b/apps/api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,323 @@ +{ + "id": "52a74620-b04c-4d5e-a0ff-4156cfc1055a", + "prevId": "5f44ca68-1f74-4879-a8be-a8fa310f8631", + "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": "text", + "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.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 8d0c546..6418317 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1767533608741, "tag": "0000_watery_black_tarantula", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1769946474409, + "tag": "0001_military_juggernaut", + "breakpoints": true } ] } diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index c44a7dc..7618394 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -16,9 +16,14 @@ import { RegistrationFormAnswer, RegistrationStatus, UserRole, + Sigs, } from "@events.comp-soc.com/shared"; -export const usersRole = pgEnum("roles", [UserRole.Member, UserRole.Committee]); +export const usersRole = pgEnum("roles", [ + UserRole.Member, + UserRole.SigExecutive, + UserRole.Committee, +]); export const eventState = pgEnum("eventState", [EventState.Draft, EventState.Published]); export const eventPriority = pgEnum("eventPriority", [EventPriority.Default, EventPriority.Pinned]); @@ -36,6 +41,7 @@ export const usersTable = pgTable("users", { firstName: text("first_name").notNull(), lastName: text("last_name").notNull(), role: usersRole("role").default("member").notNull(), + sigs: json("sigs").$type(), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at") .defaultNow() diff --git a/apps/api/src/lib/auth-guard.ts b/apps/api/src/lib/auth-guard.ts index e74cb12..56d2452 100644 --- a/apps/api/src/lib/auth-guard.ts +++ b/apps/api/src/lib/auth-guard.ts @@ -1,23 +1,29 @@ import { getAuth } from "@clerk/fastify"; import { FastifyReply, FastifyRequest } from "fastify"; +import { Sigs, canManageSig, isEventManager } from "@events.comp-soc.com/shared"; const requireAuth = async (request: FastifyRequest, reply: FastifyReply) => { const { userId, sessionClaims } = getAuth(request); const role = sessionClaims?.metadata?.role; + const sigs = sessionClaims?.metadata?.sigs; if (!userId || !role) { return reply.status(401).send({ message: "Unauthorised" }); } - request.user = { userId, role }; + request.user = { userId, role, sigs }; }; -const requireCommittee = async (request: FastifyRequest, reply: FastifyReply) => { +const requireEventManager = async (request: FastifyRequest, reply: FastifyReply) => { await requireAuth(request, reply); - if (request.user.role !== "committee") { + if (!isEventManager(request.user.role)) { return reply.status(401).send({ message: "Unauthorised" }); } }; -export { requireAuth, requireCommittee }; +const userCanManageSig = (request: FastifyRequest, organiser: Sigs): boolean => { + return canManageSig(request.user.role, request.user.sigs, organiser); +}; + +export { requireAuth, requireEventManager, userCanManageSig }; diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts index 7ad946a..aed24e3 100644 --- a/apps/api/src/lib/errors.ts +++ b/apps/api/src/lib/errors.ts @@ -25,3 +25,9 @@ export class UnauthorizedError extends AppError { super(403, message); } } + +export class ForbiddenError extends AppError { + constructor(message = "You do not have permission to perform this action") { + super(403, message); + } +} diff --git a/apps/api/src/modules/events/route.test.ts b/apps/api/src/modules/events/route.test.ts index f8834d3..702d350 100644 --- a/apps/api/src/modules/events/route.test.ts +++ b/apps/api/src/modules/events/route.test.ts @@ -1,11 +1,17 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { activeMockAuthState, setMockAuth } from "../../../tests/mock-auth.js"; +import { + activeMockAuthState, + setMockAuth, + setSigExecutiveAuth, + setMemberAuth, +} 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 type { CreateEventRequest, UpdateEventRequest } from "@events.comp-soc.com/shared"; +import { Sigs } from "@events.comp-soc.com/shared"; vi.mock("@clerk/fastify", () => { return { @@ -267,7 +273,6 @@ describe("Event", () => { const updatePayload: UpdateEventRequest = { title: "New Title", - // Note: Not sending capacity or state, they should remain unchanged }; const response = await app.inject({ @@ -293,7 +298,7 @@ describe("Event", () => { payload: { title: "Ghost" }, }); - expect(response.statusCode).toBe(404); + expect(response.statusCode).toBe(403); }); }); @@ -352,4 +357,348 @@ describe("Event", () => { expect(regs).toHaveLength(0); }); }); + + describe("SIG Executive - GET /v1/events", () => { + beforeEach(async () => { + const baseEvent = { + aboutMarkdown: "md", + date: new Date(), + }; + + await db.insert(eventsTable).values([ + { + ...baseEvent, + id: "ai-pub", + title: "AI Published", + state: "published", + organiser: Sigs.EdinburghAI, + }, + { + ...baseEvent, + id: "ai-draft", + title: "AI Draft", + state: "draft", + organiser: Sigs.EdinburghAI, + }, + { + ...baseEvent, + id: "quant-pub", + title: "Quant Published", + state: "published", + organiser: Sigs.QuantSig, + }, + { + ...baseEvent, + id: "quant-draft", + title: "Quant Draft", + state: "draft", + organiser: Sigs.QuantSig, + }, + { + ...baseEvent, + id: "compsoc-pub", + title: "CompSoc Published", + state: "published", + organiser: Sigs.Compsoc, + }, + { + ...baseEvent, + id: "compsoc-draft", + title: "CompSoc Draft", + state: "draft", + organiser: Sigs.Compsoc, + }, + ]); + }); + + it("should return published events + draft events for assigned SIGs only", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "GET", + url: "/v1/events", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + + expect(data).toHaveLength(4); + + const ids = data.map((e: { id: string }) => e.id); + expect(ids).toContain("ai-pub"); + expect(ids).toContain("ai-draft"); + expect(ids).toContain("quant-pub"); + expect(ids).toContain("compsoc-pub"); + expect(ids).not.toContain("quant-draft"); + expect(ids).not.toContain("compsoc-draft"); + }); + + it("should return draft events for multiple assigned SIGs", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI, Sigs.QuantSig]); + + const response = await app.inject({ + method: "GET", + url: "/v1/events", + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + + // Should see: all published (3) + AI draft (1) + Quant draft (1) = 5 + expect(data).toHaveLength(5); + + const ids = data.map((e: { id: string }) => e.id); + expect(ids).toContain("ai-draft"); + expect(ids).toContain("quant-draft"); + expect(ids).not.toContain("compsoc-draft"); + }); + }); + + describe("SIG Executive - GET /v1/events/:id", () => { + beforeEach(async () => { + await db.insert(eventsTable).values([ + { + id: "ai-draft-event", + title: "AI Draft", + state: "draft", + aboutMarkdown: "md", + organiser: Sigs.EdinburghAI, + date: new Date(), + }, + { + id: "quant-draft-event", + title: "Quant Draft", + state: "draft", + aboutMarkdown: "md", + organiser: Sigs.QuantSig, + date: new Date(), + }, + ]); + }); + + it("should return 200 for draft event if sig_executive manages that SIG", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "GET", + url: "/v1/events/ai-draft-event", + }); + + expect(response.statusCode).toBe(200); + expect(response.json().id).toBe("ai-draft-event"); + }); + + it("should return 404 for draft event if sig_executive does NOT manage that SIG", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "GET", + url: "/v1/events/quant-draft-event", + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe("SIG Executive - POST /v1/events", () => { + const validPayload: CreateEventRequest = { + title: "AI Workshop", + organiser: Sigs.EdinburghAI, + state: "draft", + priority: "default", + capacity: 50, + date: new Date().toISOString(), + aboutMarkdown: "# AI Event", + location: "AI Lab", + locationURL: "https://maps.google.com", + form: [], + }; + + it("should allow sig_executive to create event for their assigned SIG", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "POST", + url: "/v1/events", + payload: validPayload, + }); + + expect(response.statusCode).toBe(201); + const body = response.json(); + expect(body.organiser).toBe(Sigs.EdinburghAI); + }); + + it("should forbid sig_executive from creating event for SIG they don't manage", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "POST", + url: "/v1/events", + payload: { ...validPayload, organiser: Sigs.QuantSig }, + }); + + 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", () => { + beforeEach(async () => { + await db.insert(eventsTable).values([ + { + id: "ai-event", + title: "AI Event", + state: "draft", + aboutMarkdown: "md", + organiser: Sigs.EdinburghAI, + date: new Date(), + }, + { + id: "quant-event", + title: "Quant Event", + state: "draft", + aboutMarkdown: "md", + organiser: Sigs.QuantSig, + date: new Date(), + }, + ]); + }); + + it("should allow sig_executive to update event for their SIG", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "PUT", + url: "/v1/events/ai-event", + payload: { title: "Updated AI Event" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().title).toBe("Updated AI Event"); + }); + + it("should forbid sig_executive from updating event for SIG they don't manage", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "PUT", + url: "/v1/events/quant-event", + payload: { title: "Trying to update Quant" }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should forbid sig_executive from transferring event to SIG they don't manage", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "PUT", + url: "/v1/events/ai-event", + payload: { organiser: Sigs.QuantSig }, + }); + + expect(response.statusCode).toBe(403); + }); + + it("should allow sig_executive to transfer event between SIGs they manage", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI, Sigs.QuantSig]); + + const response = await app.inject({ + method: "PUT", + url: "/v1/events/ai-event", + payload: { organiser: Sigs.QuantSig }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().organiser).toBe(Sigs.QuantSig); + }); + }); + + describe("SIG Executive - DELETE /v1/events/:id", () => { + beforeEach(async () => { + await db.insert(eventsTable).values([ + { + id: "ai-delete", + title: "AI Delete", + state: "draft", + aboutMarkdown: "md", + organiser: Sigs.EdinburghAI, + date: new Date(), + }, + { + id: "quant-delete", + title: "Quant Delete", + state: "draft", + aboutMarkdown: "md", + organiser: Sigs.QuantSig, + date: new Date(), + }, + ]); + }); + + it("should allow sig_executive to delete event for their SIG", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "DELETE", + url: "/v1/events/ai-delete", + }); + + expect(response.statusCode).toBe(200); + expect(response.json().id).toBe("ai-delete"); + }); + + it("should forbid sig_executive from deleting event for SIG they don't manage", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "DELETE", + url: "/v1/events/quant-delete", + }); + + expect(response.statusCode).toBe(403); + }); + }); }); diff --git a/apps/api/src/modules/events/route.ts b/apps/api/src/modules/events/route.ts index d466525..ca5417f 100644 --- a/apps/api/src/modules/events/route.ts +++ b/apps/api/src/modules/events/route.ts @@ -7,20 +7,28 @@ import { UpdateEventSchema, } from "./schema.js"; import { eventService } from "./service.js"; -import { EventContractSchema, UpdateEventContractSchema } from "@events.comp-soc.com/shared"; +import { + EventContractSchema, + UpdateEventContractSchema, + Sigs, + canManageSig, +} from "@events.comp-soc.com/shared"; import { nanoid } from "nanoid"; -import { requireCommittee } from "../../lib/auth-guard.js"; +import { requireEventManager } from "../../lib/auth-guard.js"; +import { ForbiddenError } from "../../lib/errors.js"; export const eventRoutes = async (server: FastifyInstance) => { server.get("/", async (request, reply) => { const { sessionClaims } = getAuth(request); const role = sessionClaims?.metadata?.role; + const sigs = sessionClaims?.metadata?.sigs; const filters = EventsQueryFilterSchema.parse(request.query); const events = await eventService.getEvents({ db: server.db, filters, role: role ?? null, + sigs, }); return reply.status(200).send(events); @@ -29,19 +37,27 @@ export const eventRoutes = async (server: FastifyInstance) => { server.get("/:id", async (request, reply) => { const { sessionClaims } = getAuth(request); const role = sessionClaims?.metadata?.role; + const sigs = sessionClaims?.metadata?.sigs; const data = EventIdSchema.parse(request.params); const events = await eventService.getEventById({ db: server.db, data, role: role ?? null, + sigs, }); return reply.status(200).send(events); }); - server.post("/", { preHandler: [requireCommittee] }, async (request, reply) => { + server.post("/", { preHandler: [requireEventManager] }, async (request, reply) => { 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"); + } + const generatedId = nanoid(); const data = CreateEventSchema.parse({ @@ -58,9 +74,27 @@ export const eventRoutes = async (server: FastifyInstance) => { return reply.status(201).send(newEvent); }); - server.put("/:id", { preHandler: [requireCommittee] }, async (request, reply) => { + server.put("/:id", { preHandler: [requireEventManager] }, async (request, reply) => { const { id } = EventIdSchema.parse(request.params); const dto = UpdateEventContractSchema.parse(request.body); + const { role, sigs } = request.user; + + const existingEvent = await eventService.getEventForAuth({ + db: server.db, + data: { id }, + }); + + if (!existingEvent) { + 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 (dto.organiser && !canManageSig(role, sigs, dto.organiser as Sigs)) { + throw new ForbiddenError("You cannot transfer events to this SIG"); + } const data = UpdateEventSchema.parse({ ...dto, @@ -76,8 +110,23 @@ export const eventRoutes = async (server: FastifyInstance) => { return reply.status(200).send(updatedEvent); }); - server.delete("/:id", { preHandler: [requireCommittee] }, async (request, reply) => { + server.delete("/:id", { preHandler: [requireEventManager] }, async (request, reply) => { const data = EventIdSchema.parse(request.params); + const { role, sigs } = request.user; + + const existingEvent = await eventService.getEventForAuth({ + db: server.db, + data, + }); + + if (!existingEvent) { + throw new ForbiddenError("Event not found"); + } + + if (!canManageSig(role, sigs, existingEvent.organiser as Sigs)) { + throw new ForbiddenError("You cannot delete events for this SIG"); + } + const deletedEvent = await eventService.deleteEvent({ db: server.db, data, diff --git a/apps/api/src/modules/events/service.ts b/apps/api/src/modules/events/service.ts index b47a618..5a4657a 100644 --- a/apps/api/src/modules/events/service.ts +++ b/apps/api/src/modules/events/service.ts @@ -2,42 +2,74 @@ 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 } from "@events.comp-soc.com/shared"; +import { UserRole, EventState, Nullable, Sigs } from "@events.comp-soc.com/shared"; export const eventService = { async getEvents({ db, filters, role, + sigs, }: { db: SqlContext; filters: EventsQueryFilter; role: Nullable; + sigs?: Sigs[]; }) { const isCommittee = role === UserRole.Committee; + const isSigExecutive = role === UserRole.SigExecutive; const authorisedFilters = { ...filters, state: isCommittee ? filters.state : EventState.Published, }; - return eventStore.get({ db, filters: authorisedFilters }); + let events = await eventStore.get({ db, filters: authorisedFilters }); + + if (isSigExecutive && sigs && sigs.length > 0 && !filters.state) { + const draftEvents = await eventStore.get({ + db, + filters: { ...filters, state: EventState.Draft }, + }); + + const sigDraftEvents = draftEvents.filter((e) => sigs.includes(e.organiser as Sigs)); + events = [...events, ...sigDraftEvents]; + + 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()); + } + + return events; }, async getEventById({ db, data, role, + sigs, }: { db: SqlContext; data: EventId; role: Nullable; + sigs?: Sigs[]; }) { const { id } = data; const event = await eventStore.findById({ db, data }); const isCommittee = role === UserRole.Committee; + const isSigExecutive = role === UserRole.SigExecutive; - if (!event || (!isCommittee && event.state === EventState.Draft)) { + if (!event) { + throw new NotFoundError(`Event with ${id} not found`); + } + + if (event.state === EventState.Draft) { + if (isCommittee) { + return event; + } + if (isSigExecutive && sigs && sigs.includes(event.organiser as Sigs)) { + return event; + } throw new NotFoundError(`Event with ${id} not found`); } @@ -69,4 +101,8 @@ export const eventService = { return deleted; }, + + async getEventForAuth({ db, data }: { db: SqlContext; data: EventId }) { + return eventStore.findById({ db, data }); + }, }; diff --git a/apps/api/src/modules/registration/route.test.ts b/apps/api/src/modules/registration/route.test.ts index 3201f30..e8d5bfc 100644 --- a/apps/api/src/modules/registration/route.test.ts +++ b/apps/api/src/modules/registration/route.test.ts @@ -4,7 +4,13 @@ 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 } from "../../../tests/mock-auth.js"; +import { + activeMockAuthState, + setMockAuth, + setSigExecutiveAuth, + setMemberAuth, +} from "../../../tests/mock-auth.js"; +import { Sigs } from "@events.comp-soc.com/shared"; vi.mock("@clerk/fastify", () => { return { @@ -730,4 +736,317 @@ describe("Registration", () => { expect(response.statusCode).toBe(404); }); }); + + // ===== SIG EXECUTIVE ROLE TESTS ===== + + describe("SIG Executive - GET /v1/events/:eventId/registrations", () => { + 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" }, + ]); + + await db.insert(eventsTable).values([ + { + id: "ai-event", + title: "AI Event", + state: "published", + aboutMarkdown: "markdown", + organiser: Sigs.EdinburghAI, + date: new Date(), + }, + { + id: "quant-event", + title: "Quant Event", + state: "published", + aboutMarkdown: "markdown", + organiser: Sigs.QuantSig, + date: new Date(), + }, + ]); + + await db.insert(registrationsTable).values([ + { userId: "user-1", eventId: "ai-event", status: "pending" }, + { userId: "user-2", eventId: "quant-event", status: "pending" }, + ]); + }); + + it("should allow sig_executive to view registrations for their SIG's event", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "GET", + url: "/v1/events/ai-event/registrations", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toHaveLength(1); + }); + + it("should forbid sig_executive from viewing registrations for other SIG's event", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "GET", + url: "/v1/events/quant-event/registrations", + }); + + 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", () => { + beforeEach(async () => { + await db + .insert(usersTable) + .values([ + { id: "test-user", email: "test@example.com", firstName: "Test", lastName: "User" }, + ]); + + await db.insert(eventsTable).values([ + { + id: "ai-event", + title: "AI Event", + state: "published", + aboutMarkdown: "markdown", + organiser: Sigs.EdinburghAI, + date: new Date(), + capacity: 10, + }, + { + id: "quant-event", + title: "Quant Event", + state: "published", + aboutMarkdown: "markdown", + organiser: Sigs.QuantSig, + date: new Date(), + capacity: 10, + }, + ]); + + await db.insert(registrationsTable).values([ + { userId: "test-user", eventId: "ai-event", status: "pending" }, + { userId: "test-user", eventId: "quant-event", status: "pending" }, + ]); + }); + + it("should allow sig_executive to update registration for their SIG's event", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "PUT", + url: "/v1/events/ai-event/registrations/test-user", + payload: { status: "accepted" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().status).toBe("accepted"); + }); + + it("should forbid sig_executive from updating registration for other SIG's event", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "PUT", + url: "/v1/events/quant-event/registrations/test-user", + payload: { status: "accepted" }, + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe("SIG Executive - POST /v1/events/:eventId/registrations/batch-accept", () => { + beforeEach(async () => { + await db.insert(eventsTable).values([ + { + id: "ai-batch-event", + title: "AI Batch Event", + state: "published", + capacity: 2, + aboutMarkdown: "md", + organiser: Sigs.EdinburghAI, + date: new Date(), + }, + { + id: "quant-batch-event", + title: "Quant Batch Event", + state: "published", + capacity: 2, + aboutMarkdown: "md", + organiser: Sigs.QuantSig, + date: new Date(), + }, + ]); + + await db.insert(usersTable).values([ + { id: "p1", email: "p1@ex.com", firstName: "P1", lastName: "U" }, + { id: "p2", email: "p2@ex.com", firstName: "P2", lastName: "U" }, + ]); + + await db.insert(registrationsTable).values([ + { userId: "p1", eventId: "ai-batch-event", status: "pending" }, + { userId: "p2", eventId: "ai-batch-event", status: "pending" }, + { userId: "p1", eventId: "quant-batch-event", status: "pending" }, + ]); + }); + + it("should allow sig_executive to batch accept for their SIG's event", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "POST", + url: "/v1/events/ai-batch-event/registrations/batch-accept", + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ acceptedCount: 2 }); + }); + + it("should forbid sig_executive from batch accepting for other SIG's event", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "POST", + url: "/v1/events/quant-batch-event/registrations/batch-accept", + }); + + expect(response.statusCode).toBe(403); + }); + }); + + describe("SIG Executive - GET /v1/events/:eventId/registrations/analytics", () => { + beforeEach(async () => { + await db.insert(eventsTable).values([ + { + id: "ai-analytics-event", + title: "AI Analytics Event", + state: "published", + date: new Date(), + organiser: Sigs.EdinburghAI, + aboutMarkdown: "md", + form: [], + }, + { + id: "quant-analytics-event", + title: "Quant Analytics Event", + state: "published", + date: new Date(), + organiser: Sigs.QuantSig, + aboutMarkdown: "md", + form: [], + }, + ]); + + await db + .insert(usersTable) + .values([{ id: "u1", email: "u1@test.com", firstName: "A", lastName: "A" }]); + + await db + .insert(registrationsTable) + .values([{ userId: "u1", eventId: "ai-analytics-event", status: "accepted" }]); + }); + + it("should allow sig_executive to view analytics for their SIG's event", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "GET", + url: "/v1/events/ai-analytics-event/registrations/analytics", + }); + + expect(response.statusCode).toBe(200); + expect(response.json().totalCount).toBe(1); + }); + + it("should forbid sig_executive from viewing analytics for other SIG's event", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "GET", + url: "/v1/events/quant-analytics-event/registrations/analytics", + }); + + 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", () => { + beforeEach(async () => { + await db + .insert(usersTable) + .values([ + { id: "test-user", email: "test@example.com", firstName: "Test", lastName: "User" }, + ]); + + await db.insert(eventsTable).values([ + { + id: "ai-event", + title: "AI Event", + state: "published", + aboutMarkdown: "markdown", + organiser: Sigs.EdinburghAI, + date: new Date(), + }, + { + id: "quant-event", + title: "Quant Event", + state: "published", + aboutMarkdown: "markdown", + organiser: Sigs.QuantSig, + date: new Date(), + }, + ]); + + await db.insert(registrationsTable).values([ + { userId: "test-user", eventId: "ai-event", status: "pending" }, + { userId: "test-user", eventId: "quant-event", status: "pending" }, + ]); + }); + + it("should allow sig_executive to delete registration for their SIG's event", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "DELETE", + url: "/v1/events/ai-event/registrations/test-user", + }); + + expect(response.statusCode).toBe(200); + expect(response.json().userId).toBe("test-user"); + }); + + it("should forbid sig_executive from deleting registration for other SIG's event", async () => { + setSigExecutiveAuth("sig-exec", [Sigs.EdinburghAI]); + + const response = await app.inject({ + method: "DELETE", + url: "/v1/events/quant-event/registrations/test-user", + }); + + expect(response.statusCode).toBe(403); + }); + }); }); diff --git a/apps/api/src/modules/registration/route.ts b/apps/api/src/modules/registration/route.ts index 3421468..a1014cb 100644 --- a/apps/api/src/modules/registration/route.ts +++ b/apps/api/src/modules/registration/route.ts @@ -12,8 +12,12 @@ import { RegistrationContractSchema, RegistrationStatusBatchUpdateSchema, RegistrationUpdateContractSchema, + Sigs, + canManageSig, } from "@events.comp-soc.com/shared"; -import { requireAuth, requireCommittee } from "../../lib/auth-guard.js"; +import { requireAuth, requireEventManager } from "../../lib/auth-guard.js"; +import { eventService } from "../events/service.js"; +import { ForbiddenError } from "../../lib/errors.js"; export const registrationRoutes = async (server: FastifyInstance) => { server.post("/", { preHandler: [requireAuth] }, async (request, reply) => { @@ -36,8 +40,18 @@ export const registrationRoutes = async (server: FastifyInstance) => { return reply.status(201).send(registration); }); - server.post("/batch-accept", { preHandler: [requireCommittee] }, async (request, reply) => { + server.post("/batch-accept", { preHandler: [requireEventManager] }, async (request, reply) => { const { eventId } = RegistrationEventIdSchema.parse(request.params); + const { role, sigs } = request.user; + + const event = await eventService.getEventForAuth({ + db: server.db, + data: { id: eventId }, + }); + + if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + throw new ForbiddenError("You cannot manage registrations for this event"); + } const result = await registrationService.batchAcceptRegistration({ db: server.db, @@ -49,10 +63,20 @@ export const registrationRoutes = async (server: FastifyInstance) => { server.post( "/batch-update-status", - { preHandler: [requireCommittee] }, + { preHandler: [requireEventManager] }, async (request, reply) => { const { eventId } = RegistrationEventIdSchema.parse(request.params); const dto = RegistrationStatusBatchUpdateSchema.parse(request.body); + const { role, sigs } = request.user; + + const event = await eventService.getEventForAuth({ + db: server.db, + data: { id: eventId }, + }); + + if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + throw new ForbiddenError("You cannot manage registrations for this event"); + } const data = UpdateBatchStatusRegistrationSchema.parse({ eventId, @@ -68,9 +92,19 @@ export const registrationRoutes = async (server: FastifyInstance) => { } ); - server.get("/", { preHandler: [requireCommittee] }, async (request, reply) => { + 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({ + db: server.db, + data: { id: params.eventId }, + }); + + if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + throw new ForbiddenError("You cannot view registrations for this event"); + } const events = await registrationService.getRegistrations({ db: server.db, @@ -98,9 +132,19 @@ export const registrationRoutes = async (server: FastifyInstance) => { return registration ? reply.status(200).send(registration) : reply.status(204).send(); }); - server.put("/:userId", { preHandler: [requireCommittee] }, async (request, reply) => { + server.put("/:userId", { preHandler: [requireEventManager] }, async (request, reply) => { const dto = RegistrationUpdateContractSchema.parse(request.body); const params = RegistrationParamsSchema.parse(request.params); + const { role, sigs } = request.user; + + const event = await eventService.getEventForAuth({ + db: server.db, + data: { id: params.eventId }, + }); + + if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + throw new ForbiddenError("You cannot manage registrations for this event"); + } const data = UpdateRegistrationSchema.parse({ ...dto, @@ -116,8 +160,18 @@ export const registrationRoutes = async (server: FastifyInstance) => { return reply.status(200).send(updatedRegistration); }); - server.get("/analytics", { preHandler: [requireCommittee] }, async (request, reply) => { + server.get("/analytics", { preHandler: [requireEventManager] }, async (request, reply) => { const { eventId } = RegistrationEventIdSchema.parse(request.params); + const { role, sigs } = request.user; + + const event = await eventService.getEventForAuth({ + db: server.db, + data: { id: eventId }, + }); + + if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + throw new ForbiddenError("You cannot view analytics for this event"); + } const analytics = await registrationService.getRegistrationAnalytics({ db: server.db, @@ -128,7 +182,7 @@ export const registrationRoutes = async (server: FastifyInstance) => { }); server.delete("/:userId", { preHandler: [requireAuth] }, async (request, reply) => { - const { userId, role } = request.user; + const { userId, role, sigs } = request.user; const data = RegistrationParamsSchema.parse(request.params); const deletedRegistration = await registrationService.deleteRegistration({ @@ -136,6 +190,7 @@ export const registrationRoutes = async (server: FastifyInstance) => { data, userId, role, + sigs, }); return reply.status(200).send(deletedRegistration); diff --git a/apps/api/src/modules/registration/service.ts b/apps/api/src/modules/registration/service.ts index 7b4a115..fff5f80 100644 --- a/apps/api/src/modules/registration/service.ts +++ b/apps/api/src/modules/registration/service.ts @@ -8,7 +8,7 @@ import { UpdateRegistration, } from "./schema.js"; import { eventStore } from "../events/store.js"; -import { UserRole } from "@events.comp-soc.com/shared"; +import { UserRole, Sigs, 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"; @@ -166,11 +166,13 @@ export const registrationService = { data, userId, role, + sigs, }: { db: SqlContext; data: RegistrationParams; userId: string; role: UserRole; + sigs?: Sigs[]; }) { const registration = await registrationStore.getByUserAndEvent({ db, data }); if (!registration) { @@ -178,10 +180,16 @@ export const registrationService = { } const isOwner = data.userId === userId; - const isCommittee = role === "committee"; - if (!isOwner && !isCommittee) { - throw new UnauthorizedError("You do not have permission to delete this registration"); + if (!isOwner) { + const event = await eventStore.findById({ + db, + data: { id: data.eventId }, + }); + + if (!event || !canManageSig(role, sigs, event.organiser as Sigs)) { + throw new UnauthorizedError("You do not have permission to delete this registration"); + } } return await registrationStore.delete({ db, data }); diff --git a/apps/api/src/modules/webhooks/clerk.ts b/apps/api/src/modules/webhooks/clerk.ts index 7edf5d2..f5262c8 100644 --- a/apps/api/src/modules/webhooks/clerk.ts +++ b/apps/api/src/modules/webhooks/clerk.ts @@ -2,7 +2,12 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { Webhook } from "svix"; import { clerkClient } from "@clerk/fastify"; import { userService } from "../users/service.js"; -import { Nullable } from "@events.comp-soc.com/shared"; +import { Nullable, Sigs, UserRole } from "@events.comp-soc.com/shared"; + +interface ClerkPublicMetadata { + role?: UserRole; + sigs?: Sigs[]; +} interface ClerkUserEventData { id: string; @@ -13,6 +18,7 @@ interface ClerkUserEventData { first_name: Nullable; last_name: Nullable; primary_email_address_id: string; + public_metadata?: ClerkPublicMetadata; } interface ClerkWebhookEvent { @@ -72,11 +78,16 @@ export const clerkWebhookRoutes = async (server: FastifyInstance) => { return reply.status(400).send({ error: "No primary email found" }); } - await clerkClient.users.updateUserMetadata(data.id, { - publicMetadata: { - role: "member", - }, - }); + const existingRole = data.public_metadata?.role; + const existingSigs = data.public_metadata?.sigs; + + if (!existingRole) { + await clerkClient.users.updateUserMetadata(data.id, { + publicMetadata: { + role: UserRole.Member, + }, + }); + } await userService.createUser({ db: server.db, @@ -85,10 +96,13 @@ export const clerkWebhookRoutes = async (server: FastifyInstance) => { email: primaryEmail.email_address, firstName: data.first_name || "", lastName: data.last_name || "", + sigs: existingSigs, }, }); - server.log.info(`Created user: ${data.id} with role: member`); + server.log.info( + `Created user: ${data.id} with role: ${existingRole || UserRole.Member}` + ); break; } diff --git a/apps/api/src/types/globals.d.ts b/apps/api/src/types/globals.d.ts index ad0b59d..383363d 100644 --- a/apps/api/src/types/globals.d.ts +++ b/apps/api/src/types/globals.d.ts @@ -1,4 +1,4 @@ -import { UserRole } from "@events.comp-soc.com/shared"; +import { UserRole, Sigs } from "@events.comp-soc.com/shared"; export {}; @@ -6,6 +6,7 @@ declare global { interface CustomJwtSessionClaims { metadata: { role?: UserRole; + sigs?: Sigs[]; }; } } @@ -15,6 +16,7 @@ declare module "fastify" { user: { userId: string; role: UserRole; + sigs?: Sigs[]; }; rawBody?: string; } diff --git a/apps/api/tests/mock-auth.ts b/apps/api/tests/mock-auth.ts index aa61d5b..c97b980 100644 --- a/apps/api/tests/mock-auth.ts +++ b/apps/api/tests/mock-auth.ts @@ -1,8 +1,15 @@ -import { Nullable } from "@events.comp-soc.com/shared"; +import { Nullable, UserRole, Sigs } from "@events.comp-soc.com/shared"; + +export type MockSessionClaims = { + metadata: { + role: UserRole; + sigs?: Sigs[]; + }; +} | null; export type MockAuthState = { userId: Nullable; - sessionClaims: unknown; + sessionClaims: MockSessionClaims; }; export let activeMockAuthState: MockAuthState = { @@ -13,3 +20,26 @@ export let activeMockAuthState: MockAuthState = { export const setMockAuth = (payload: Partial) => { activeMockAuthState = { ...activeMockAuthState, ...payload }; }; + +export const setSigExecutiveAuth = (userId: string, sigs: Sigs[]) => { + setMockAuth({ + userId, + sessionClaims: { + metadata: { + role: UserRole.SigExecutive, + sigs, + }, + }, + }); +}; + +export const setMemberAuth = (userId: string) => { + setMockAuth({ + userId, + sessionClaims: { + metadata: { + role: UserRole.Member, + }, + }, + }); +}; diff --git a/apps/shared/src/core/constants.ts b/apps/shared/src/core/constants.ts index 5544437..63ba486 100644 --- a/apps/shared/src/core/constants.ts +++ b/apps/shared/src/core/constants.ts @@ -10,6 +10,7 @@ export const Sigs = { SigInt: "sigInt", GameDevSig: "gameDevSig", EdinburghAI: "edinburghAI", + NeuroTechSig: "neuroTechSig", QuantSig: "quantSig", } as const; diff --git a/apps/shared/src/users/constants.ts b/apps/shared/src/users/constants.ts index 4e4d597..d24ea71 100644 --- a/apps/shared/src/users/constants.ts +++ b/apps/shared/src/users/constants.ts @@ -1,5 +1,6 @@ export const UserRole = { Member: "member", + SigExecutive: "sig_executive", Committee: "committee", } as const; diff --git a/apps/shared/src/users/schemas.ts b/apps/shared/src/users/schemas.ts index bf6fc0b..65365de 100644 --- a/apps/shared/src/users/schemas.ts +++ b/apps/shared/src/users/schemas.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { Sigs } from "../core/constants.js"; +import { UserRole } from "./constants.js"; export const UserContractSchema = z.object({ email: z.email("Invalid email address"), @@ -13,3 +15,16 @@ export const UserResponseSchema = UserContractSchema.extend({ 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/public/sigs/neurotech.webp b/apps/web/public/sigs/neurotech.webp new file mode 100644 index 0000000..c0ac3ff Binary files /dev/null and b/apps/web/public/sigs/neurotech.webp differ diff --git a/apps/web/src/components/forms/modify-event-form.tsx b/apps/web/src/components/forms/modify-event-form.tsx index 203b669..7e6ab28 100644 --- a/apps/web/src/components/forms/modify-event-form.tsx +++ b/apps/web/src/components/forms/modify-event-form.tsx @@ -29,6 +29,7 @@ import { SelectValue, } from '@/components/ui/select.tsx' import { ALL_SIGS } from '@/config/sigs.ts' +import { useEventManagerAuth } from '@/lib/auth.ts' import { Popover, PopoverContent, @@ -122,6 +123,11 @@ function ModifyEventForm({ isLoading?: boolean }) { const [open, setOpen] = useState(false) + const { isCommittee, sigs: userSigs } = useEventManagerAuth() + + const availableSigs = isCommittee + ? ALL_SIGS + : ALL_SIGS.filter((sig) => userSigs?.includes(sig.id as Sigs)) const form = useForm({ defaultValues, @@ -262,7 +268,7 @@ function ModifyEventForm({ Organiser + {!isCommittee && ( + + You can only create events for your assigned SIGs + + )} {isInvalid && ( )} diff --git a/apps/web/src/components/layout/protected-route.tsx b/apps/web/src/components/layout/protected-route.tsx index eadd2f3..606fcbb 100644 --- a/apps/web/src/components/layout/protected-route.tsx +++ b/apps/web/src/components/layout/protected-route.tsx @@ -1,6 +1,6 @@ import { LogIn, ShieldX } from 'lucide-react' import type { ReactNode } from 'react' -import { useCommitteeAuth } from '@/lib/auth.ts' +import { useCommitteeAuth, useEventManagerAuth } from '@/lib/auth.ts' import { StatusCard } from '@/components/ui/status-card.tsx' import Window from '@/components/layout/window/window.tsx' import { EmptySheet } from '@/components/layout/sheet.tsx' @@ -9,15 +9,16 @@ import { Spinner } from '@/components/ui/spinner.tsx' interface ProtectedRouteProps { children: ReactNode activeTab?: string - isRequireCommittee?: boolean + requireEventManager?: boolean } function ProtectedRoute({ children, activeTab, - isRequireCommittee = true, + requireEventManager = false, }: ProtectedRouteProps) { - const { isLoaded, isAuthenticated, isCommittee } = useCommitteeAuth() + const { isLoaded, isAuthenticated } = useCommitteeAuth() + const { canManageEvents } = useEventManagerAuth() if (!isLoaded) { return ( @@ -35,7 +36,7 @@ function ProtectedRoute({ } /> @@ -43,13 +44,13 @@ function ProtectedRoute({ ) } - if (!isCommittee && isRequireCommittee) { + if (requireEventManager && !canManageEvents) { return ( } /> diff --git a/apps/web/src/config/sigs.ts b/apps/web/src/config/sigs.ts index 045d3de..1b1bfc8 100644 --- a/apps/web/src/config/sigs.ts +++ b/apps/web/src/config/sigs.ts @@ -47,6 +47,11 @@ export const SIG_COLORS: Record = { }, [Sigs.BitSig]: { border: '#ff0000', background: '#ffffff', text: '#000000' }, [Sigs.Compsoc]: { border: '#737373', background: '#262626', text: '#ffffff' }, + [Sigs.NeuroTechSig]: { + border: '#ae4f94', + background: '#242324', + text: '#ffffff', + }, } export const ALL_SIGS: Array = [ @@ -122,6 +127,12 @@ export const ALL_SIGS: Array = [ logo: '/sigs/ccsig.webp', colors: SIG_COLORS[Sigs.CCSig], }, + { + id: Sigs.NeuroTechSig, + name: 'Edinburgh Neurotech', + logo: '/sigs/neurotech.webp', + colors: SIG_COLORS[Sigs.NeuroTechSig], + }, ] export const getSigById = (sigId: string): Nullable => { diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index e13dc03..89c7fbe 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,5 +1,15 @@ import { useAuth, useUser } from '@clerk/tanstack-react-start' -import { UserRole } from '@events.comp-soc.com/shared' +import { + UserRole, + canManageSig, + isEventManager, +} from '@events.comp-soc.com/shared' +import type { Sigs } from '@events.comp-soc.com/shared' + +interface PublicMetadata { + role?: UserRole + sigs?: Array +} export function useCommitteeAuth() { const { userId, isLoaded: isAuthLoaded } = useAuth() @@ -7,7 +17,8 @@ export function useCommitteeAuth() { const isLoaded = isAuthLoaded && isUserLoaded const isAuthenticated = !!userId - const isCommittee = user?.publicMetadata.role === UserRole.Committee + const metadata = user?.publicMetadata as PublicMetadata | undefined + const isCommittee = metadata?.role === UserRole.Committee return { isLoaded, @@ -17,3 +28,44 @@ export function useCommitteeAuth() { user, } } + +export function useEventManagerAuth() { + const { userId, isLoaded: isAuthLoaded } = useAuth() + const { user, isLoaded: isUserLoaded } = useUser() + + const isLoaded = isAuthLoaded && isUserLoaded + const isAuthenticated = !!userId + const metadata = user?.publicMetadata as PublicMetadata | undefined + const role = metadata?.role ?? UserRole.Member + const sigs = metadata?.sigs + + const isCommittee = role === UserRole.Committee + const isSigExecutive = role === UserRole.SigExecutive + const canManageEvents = isEventManager(role) + + const canManage = (targetSig: Sigs): boolean => { + return canManageSig(role, sigs, targetSig) + } + + const getManageableSigs = (): Array | null => { + if (isCommittee) return null + if (isSigExecutive && sigs) return sigs + return [] + } + + return { + isLoaded, + isAuthenticated, + isCommittee, + isSigExecutive, + canManageEvents, + hasAccess: isAuthenticated && canManageEvents, + role, + sigs, + canManage, + getManageableSigs, + user, + } +} + +export { canManageSig, isEventManager } diff --git a/apps/web/src/routes/events/$eventId/analytics.tsx b/apps/web/src/routes/events/$eventId/analytics.tsx index d028502..32937ab 100644 --- a/apps/web/src/routes/events/$eventId/analytics.tsx +++ b/apps/web/src/routes/events/$eventId/analytics.tsx @@ -64,7 +64,7 @@ function AnalyticsRoute() { }) }) return ( - +
diff --git a/apps/web/src/routes/events/$eventId/edit.tsx b/apps/web/src/routes/events/$eventId/edit.tsx index 5e06a4d..a1444dd 100644 --- a/apps/web/src/routes/events/$eventId/edit.tsx +++ b/apps/web/src/routes/events/$eventId/edit.tsx @@ -80,16 +80,16 @@ function EditEventRoute() { } return ( - - + +
- Create Event + Edit Event
- Fill in the details below to create a new event. You can either - publish it or save it as a draft. + Update the event details below. You can save changes or cancel to + go back.
diff --git a/apps/web/src/routes/events/$eventId/index.tsx b/apps/web/src/routes/events/$eventId/index.tsx index e3d2b99..08a3a5f 100644 --- a/apps/web/src/routes/events/$eventId/index.tsx +++ b/apps/web/src/routes/events/$eventId/index.tsx @@ -8,6 +8,7 @@ import { UserIcon, } from 'lucide-react' import { useQuery, useSuspenseQuery } from '@tanstack/react-query' +import type { Sigs } from '@events.comp-soc.com/shared' import Window from '@/components/layout/window/window.tsx' import Sheet, { EmptySheet } from '@/components/layout/sheet.tsx' import { Markdown } from '@/components/markdown.tsx' @@ -17,7 +18,7 @@ import { Button } from '@/components/ui/button.tsx' import { StatusCard } from '@/components/ui/status-card.tsx' import { eventQueryOption } from '@/lib/data/event.ts' import DraftBadge from '@/components/draft-badge.tsx' -import { useCommitteeAuth } from '@/lib/auth.ts' +import { useEventManagerAuth } from '@/lib/auth.ts' import { formatEventDate } from '@/lib/utils.ts' import DeleteEventButton from '@/components/controlls/delete-event-button.tsx' import PublishEventButton from '@/components/controlls/publish-event-button.tsx' @@ -54,7 +55,7 @@ export const Route = createFileRoute('/events/$eventId/')({ function EventRoute() { const navigate = useNavigate({ from: '/events/$eventId' }) const { eventId } = Route.useParams() - const { isCommittee } = useCommitteeAuth() + const { canManage } = useEventManagerAuth() const { data: event } = useSuspenseQuery(eventQueryOption(eventId)) const { data: registration, isLoading: isRegistrationLoading } = useQuery( registrationQueryByUserOption(eventId), @@ -64,11 +65,13 @@ function EventRoute() { const isDraft = event.state === 'draft' const isRegistered = !!registration + const canManageEvent = canManage(event.organiser as Sigs) + return ( @@ -110,7 +113,7 @@ function EventRoute() {
- ) : undefined + ) : null } > diff --git a/apps/web/src/routes/events/create.tsx b/apps/web/src/routes/events/create.tsx index 4d65ce0..df8263c 100644 --- a/apps/web/src/routes/events/create.tsx +++ b/apps/web/src/routes/events/create.tsx @@ -11,6 +11,7 @@ import Sheet from '@/components/layout/sheet.tsx' import { ProtectedRoute } from '@/components/layout/protected-route.tsx' import { Separator } from '@/components/ui/separator.tsx' import { useCreateEvent } from '@/lib/hooks/events/use-create-event.tsx' +import { useEventManagerAuth } from '@/lib/auth.ts' export const Route = createFileRoute('/events/create')({ component: CreateRoute, @@ -19,10 +20,17 @@ export const Route = createFileRoute('/events/create')({ function CreateRoute() { const navigate = useNavigate({ from: '/events/create' }) const { createEvent, isCreating } = useCreateEvent() + const { isCommittee, sigs } = useEventManagerAuth() + + const defaultOrganiser = isCommittee + ? Sigs.Compsoc + : sigs && sigs.length > 0 + ? sigs[0] + : Sigs.Compsoc const defaultValues = { title: '', - organiser: Sigs.Compsoc, + organiser: defaultOrganiser, state: EventState.Draft, priority: EventPriority.Default, date: new Date(), @@ -48,7 +56,7 @@ function CreateRoute() { } return ( - +
diff --git a/apps/web/src/routes/events/draft.tsx b/apps/web/src/routes/events/draft.tsx index 80b034a..2cde132 100644 --- a/apps/web/src/routes/events/draft.tsx +++ b/apps/web/src/routes/events/draft.tsx @@ -35,7 +35,7 @@ function DraftRoute() { const { data: events } = useSuspenseQuery(eventsQueryOptions('draft')) return ( - +