Skip to content

Commit a7de64c

Browse files
authored
feat: add registration UI (#36)
1 parent f281362 commit a7de64c

File tree

21 files changed

+849
-31
lines changed

21 files changed

+849
-31
lines changed

apps/api/src/modules/registration/route.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,159 @@ describe("Registration route", () => {
291291
});
292292
});
293293

294+
describe("GET /v1/events/:eventId/registrations", () => {
295+
beforeEach(async () => {
296+
await db.insert(usersTable).values([
297+
{ id: "user-1", email: "[email protected]", firstName: "U", lastName: "1" },
298+
{ id: "user-2", email: "[email protected]", firstName: "U", lastName: "2" },
299+
]);
300+
301+
await db.insert(eventsTable).values({
302+
id: "test-event",
303+
title: "Test Event",
304+
state: "published",
305+
aboutMarkdown: "markdown",
306+
organiser: "projectShare",
307+
date: new Date(),
308+
});
309+
310+
await db.insert(registrationsTable).values([
311+
{ userId: "user-1", eventId: "test-event", status: "accepted" },
312+
{ userId: "user-2", eventId: "test-event", status: "pending" },
313+
]);
314+
});
315+
316+
it("should filter registrations by status query param", async () => {
317+
setMockAuth({
318+
userId: "committee-user",
319+
sessionClaims: { metadata: { role: "committee" } },
320+
});
321+
322+
const response = await app.inject({
323+
method: "GET",
324+
url: "/v1/events/test-event/registrations",
325+
query: { status: "accepted" },
326+
});
327+
328+
expect(response.statusCode).toBe(200);
329+
const data = response.json();
330+
expect(data).toHaveLength(1);
331+
expect(data[0].userId).toBe("user-1");
332+
expect(data[0].status).toBe("accepted");
333+
});
334+
335+
it("should filter registrations by userId query param", async () => {
336+
setMockAuth({
337+
userId: "committee-user",
338+
sessionClaims: { metadata: { role: "committee" } },
339+
});
340+
341+
const response = await app.inject({
342+
method: "GET",
343+
url: "/v1/events/test-event/registrations",
344+
query: { userId: "user-2" },
345+
});
346+
347+
expect(response.statusCode).toBe(200);
348+
const data = response.json();
349+
expect(data).toHaveLength(1);
350+
expect(data[0].userId).toBe("user-2");
351+
});
352+
353+
it("should respect pagination limits", async () => {
354+
setMockAuth({
355+
userId: "committee-user",
356+
sessionClaims: { metadata: { role: "committee" } },
357+
});
358+
359+
const response = await app.inject({
360+
method: "GET",
361+
url: "/v1/events/test-event/registrations",
362+
query: { limit: "1" },
363+
});
364+
365+
expect(response.statusCode).toBe(200);
366+
const data = response.json();
367+
expect(data.length).toBeLessThanOrEqual(1);
368+
});
369+
370+
it("should return empty array if event has no registrations", async () => {
371+
setMockAuth({
372+
userId: "committee-user",
373+
sessionClaims: { metadata: { role: "committee" } },
374+
});
375+
376+
await db.insert(eventsTable).values({
377+
id: "empty-event",
378+
title: "Empty Event",
379+
state: "published",
380+
aboutMarkdown: "md",
381+
organiser: "projectShare",
382+
date: new Date(),
383+
});
384+
385+
const response = await app.inject({
386+
method: "GET",
387+
url: "/v1/events/empty-event/registrations",
388+
});
389+
390+
expect(response.statusCode).toBe(200);
391+
expect(response.json()).toEqual([]);
392+
});
393+
});
394+
395+
describe("GET /v1/events/:eventId/registrations/me", () => {
396+
beforeEach(async () => {
397+
await db.insert(usersTable).values([
398+
{ id: "test-user", email: "[email protected]", firstName: "Test", lastName: "User" },
399+
{ id: "other-user", email: "[email protected]", firstName: "Other", lastName: "User" },
400+
]);
401+
402+
await db.insert(eventsTable).values({
403+
id: "test-event",
404+
title: "Test Event",
405+
state: "published",
406+
aboutMarkdown: "markdown",
407+
organiser: "projectShare",
408+
date: new Date(),
409+
});
410+
411+
await db.insert(registrationsTable).values({
412+
userId: "test-user",
413+
eventId: "test-event",
414+
status: "pending",
415+
});
416+
});
417+
418+
it("should return 401 if user is not authenticated", async () => {
419+
setMockAuth({ userId: null, sessionClaims: null });
420+
421+
const response = await app.inject({
422+
method: "GET",
423+
url: "/v1/events/test-event/registrations/me",
424+
});
425+
426+
expect(response.statusCode).toBe(401);
427+
});
428+
429+
it("should allow a user to fetch their own registration", async () => {
430+
setMockAuth({
431+
userId: "test-user",
432+
sessionClaims: { metadata: { role: "member" } },
433+
});
434+
435+
const response = await app.inject({
436+
method: "GET",
437+
url: "/v1/events/test-event/registrations/me",
438+
});
439+
440+
expect(response.statusCode).toBe(200);
441+
const data = response.json();
442+
expect(data.userId).toBe("test-user");
443+
expect(data.eventId).toBe("test-event");
444+
});
445+
});
446+
294447
describe("DELETE /v1/events/:eventId/registrations/:targetUserId", () => {
295448
beforeEach(async () => {
296449
await db.insert(usersTable).values([

apps/api/src/modules/registration/route.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
CreateRegistrationSchema,
55
RegistrationEventIdSchema,
66
RegistrationParamsSchema,
7+
RegistrationsQueryFilterSchema,
78
UpdateRegistrationSchema,
89
} from "./schema.js";
910
import { registrationService } from "./service.js";
@@ -39,6 +40,45 @@ export const registrationRoutes = async (server: FastifyInstance) => {
3940
return reply.status(201).send(registration);
4041
});
4142

43+
server.get("/", async (request, reply) => {
44+
const { sessionClaims } = getAuth(request);
45+
const role = sessionClaims?.metadata?.role;
46+
47+
const params = RegistrationEventIdSchema.parse(request.params);
48+
const filters = RegistrationsQueryFilterSchema.parse(request.query);
49+
50+
const events = await registrationService.getRegistrations({
51+
db: server.db,
52+
filters: {
53+
id: params.eventId,
54+
...filters,
55+
},
56+
role: role ?? null,
57+
});
58+
59+
return reply.status(200).send(events);
60+
});
61+
62+
server.get("/me", async (request, reply) => {
63+
const { userId, sessionClaims } = getAuth(request);
64+
const role = sessionClaims?.metadata?.role;
65+
66+
if (!userId || !role) {
67+
return reply.status(401).send({ message: "Unauthorised" });
68+
}
69+
70+
const data = RegistrationEventIdSchema.parse(request.params);
71+
const registration = await registrationService.getRegistrationByUser({
72+
db: server.db,
73+
data: {
74+
...data,
75+
userId,
76+
},
77+
});
78+
79+
return registration ? reply.status(200).send(registration) : reply.status(204).send();
80+
});
81+
4282
server.put("/:userId", async (request, reply) => {
4383
const { userId, sessionClaims } = getAuth(request);
4484
const role = sessionClaims?.metadata?.role;

apps/api/src/modules/registration/schema.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createInsertSchema } from "drizzle-zod";
22
import { z } from "zod";
33
import { registrationsTable } from "../../db/schema.js";
44
import { RegistrationStatus } from "@events.comp-soc.com/shared";
5+
import { UserIdSchema } from "../users/schema.js";
56

67
export const BaseRegistrationSchema = createInsertSchema(registrationsTable);
78

@@ -21,6 +22,13 @@ export const UpdateRegistrationSchema = BaseRegistrationSchema.pick({
2122
status: z.enum(RegistrationStatus).default("pending"),
2223
});
2324

25+
export const RegistrationsQueryFilterSchema = z.object({
26+
userId: UserIdSchema.shape.id.optional(),
27+
page: z.coerce.number().min(1).default(1),
28+
limit: z.coerce.number().min(1).max(100).default(20),
29+
status: z.enum(RegistrationStatus).optional(),
30+
});
31+
2432
export const RegistrationParamsSchema = z.object({
2533
userId: BaseRegistrationSchema.shape.userId,
2634
eventId: BaseRegistrationSchema.shape.eventId,
@@ -33,3 +41,4 @@ export const RegistrationEventIdSchema = z.object({
3341
export type CreateRegistration = z.infer<typeof CreateRegistrationSchema>;
3442
export type UpdateRegistration = z.infer<typeof UpdateRegistrationSchema>;
3543
export type RegistrationParams = z.infer<typeof RegistrationParamsSchema>;
44+
export type RegistrationsQueryFilter = z.infer<typeof RegistrationsQueryFilterSchema>;

apps/api/src/modules/registration/service.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { SqlContext } from "../../db/db.js";
2-
import { CreateRegistration, RegistrationParams, UpdateRegistration } from "./schema.js";
2+
import {
3+
CreateRegistration,
4+
RegistrationParams,
5+
RegistrationsQueryFilter,
6+
UpdateRegistration,
7+
} from "./schema.js";
38
import { eventStore } from "../events/store.js";
4-
import { RegistrationStatus, UserRole } from "@events.comp-soc.com/shared";
9+
import { Nullable, RegistrationStatus, UserRole } from "@events.comp-soc.com/shared";
510
import { ConflictError, NotFoundError, UnauthorizedError } from "../../lib/errors.js";
611
import { registrationStore } from "./store.js";
12+
import { EventId } from "../events/schema.js";
713

814
export const registrationService = {
915
async createRegistration({
@@ -53,6 +59,31 @@ export const registrationService = {
5359
});
5460
},
5561

62+
async getRegistrationByUser({ db, data }: { db: SqlContext; data: RegistrationParams }) {
63+
return await registrationStore.getByUserAndEvent({
64+
db,
65+
data,
66+
});
67+
},
68+
69+
async getRegistrations({
70+
db,
71+
filters,
72+
role,
73+
}: {
74+
db: SqlContext;
75+
filters: RegistrationsQueryFilter & Pick<EventId, "id">;
76+
role: Nullable<UserRole>;
77+
}) {
78+
const isCommittee = role === "committee";
79+
80+
if (!isCommittee) {
81+
throw new UnauthorizedError("You do not have permission to view this registration");
82+
}
83+
84+
return registrationStore.get({ db, filters });
85+
},
86+
5687
async updateRegistration({
5788
db,
5889
data,

apps/api/src/modules/registration/store.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,45 @@
11
import { eq, and, count, inArray } from "drizzle-orm";
2-
import { CreateRegistration, RegistrationParams, UpdateRegistration } from "./schema.js";
2+
import {
3+
CreateRegistration,
4+
RegistrationParams,
5+
RegistrationsQueryFilter,
6+
UpdateRegistration,
7+
} from "./schema.js";
38
import { SqlContext } from "../../db/db.js";
4-
import { registrationsTable } from "../../db/schema.js";
9+
import { eventsTable, registrationsTable } from "../../db/schema.js";
510
import { EventId } from "../events/schema.js";
611

12+
export const registrationSelection = {
13+
userId: registrationsTable.userId,
14+
eventId: registrationsTable.eventId,
15+
status: registrationsTable.status,
16+
createdAt: registrationsTable.createdAt,
17+
updatedAt: registrationsTable.updatedAt,
18+
eventTitle: eventsTable.title,
19+
eventDate: eventsTable.date,
20+
eventLocation: eventsTable.location,
21+
};
22+
723
export const registrationStore = {
824
async create({ db, data }: { db: SqlContext; data: CreateRegistration }) {
9-
const [newRegistration] = await db.insert(registrationsTable).values(data).returning();
25+
await db.insert(registrationsTable).values(data).returning();
1026

11-
return newRegistration;
27+
return this.getByUserAndEvent({
28+
db,
29+
data: { userId: data.userId, eventId: data.eventId },
30+
});
1231
},
1332

1433
async update({ db, data }: { db: SqlContext; data: UpdateRegistration }) {
1534
const { eventId, userId, ...payload } = data;
16-
const [updatedRegistration] = await db
35+
36+
await db
1737
.update(registrationsTable)
1838
.set(payload)
1939
.where(and(eq(registrationsTable.userId, userId), eq(registrationsTable.eventId, eventId)))
2040
.returning();
2141

22-
return updatedRegistration;
42+
return this.getByUserAndEvent({ db, data: { userId, eventId } });
2343
},
2444

2545
async countActiveByEventId({ db, data }: { db: SqlContext; data: EventId }) {
@@ -40,20 +60,48 @@ export const registrationStore = {
4060
async getByUserAndEvent({ db, data }: { db: SqlContext; data: RegistrationParams }) {
4161
const { userId, eventId } = data;
4262
const [registration] = await db
43-
.select()
63+
.select(registrationSelection)
4464
.from(registrationsTable)
65+
.innerJoin(eventsTable, eq(registrationsTable.eventId, eventsTable.id))
4566
.where(and(eq(registrationsTable.userId, userId), eq(registrationsTable.eventId, eventId)));
4667

4768
return registration;
4869
},
4970

71+
async get({
72+
db,
73+
filters,
74+
}: {
75+
db: SqlContext;
76+
filters: RegistrationsQueryFilter & Pick<EventId, "id">;
77+
}) {
78+
const { id, page, limit, status, userId } = filters;
79+
const offset = (page - 1) * limit;
80+
81+
return db
82+
.select(registrationSelection)
83+
.from(registrationsTable)
84+
.innerJoin(eventsTable, eq(registrationsTable.eventId, eventsTable.id))
85+
.where(
86+
and(
87+
eq(registrationsTable.eventId, id),
88+
userId ? eq(registrationsTable.userId, userId) : undefined,
89+
status ? eq(registrationsTable.status, status) : undefined
90+
)
91+
)
92+
.limit(limit)
93+
.offset(offset)
94+
.orderBy(registrationsTable.createdAt);
95+
},
96+
5097
async delete({ db, data }: { db: SqlContext; data: RegistrationParams }) {
5198
const { userId, eventId } = data;
52-
const [deletedRegistration] = await db
99+
const recordToDelete = await this.getByUserAndEvent({ db, data });
100+
101+
await db
53102
.delete(registrationsTable)
54-
.where(and(eq(registrationsTable.userId, userId), eq(registrationsTable.eventId, eventId)))
55-
.returning();
103+
.where(and(eq(registrationsTable.userId, userId), eq(registrationsTable.eventId, eventId)));
56104

57-
return deletedRegistration;
105+
return recordToDelete;
58106
},
59107
};

0 commit comments

Comments
 (0)