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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 116 additions & 92 deletions apps/api/src/modules/registration/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ describe("Registration route", () => {
expect(response.statusCode).toBe(201);
const data = response.json();

Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test comment says "CRITICAL CHANGE: Status should be pending, not waitlist" was removed. This suggests there was an important behavioral change related to registration status that should be documented. The removal of this comment loses important context about why this assertion exists.

Suggested change
// CRITICAL CHANGE: Registrations should now be created with status "pending"
// (even when the event is at or over capacity). Previously this was "waitlist";
// this assertion protects that behavior from being accidentally reverted.

Copilot uses AI. Check for mistakes.
// CRITICAL CHANGE: Status should be pending, not waitlist
expect(data.status).toBe("pending");
expect(data.userId).toBe("test-user");
});
Expand Down Expand Up @@ -307,23 +306,6 @@ describe("Registration route", () => {
expect(data[0].userId).toBe("user-2");
});

it("should respect pagination limits", async () => {
setMockAuth({
userId: "committee-user",
sessionClaims: { metadata: { role: "committee" } },
});

const response = await app.inject({
method: "GET",
url: "/v1/events/test-event/registrations",
query: { limit: "1" },
});

expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.length).toBeLessThanOrEqual(1);
});

it("should return empty array if event has no registrations", async () => {
setMockAuth({
userId: "committee-user",
Expand Down Expand Up @@ -481,130 +463,172 @@ describe("Registration route", () => {
});
});

describe("POST /v1/events/:eventId/registrations/promote-from-waitlist", () => {
describe("GET /v1/events/:eventId/registrations/analytics", () => {
const analyticsEventId = "analytics-event";

beforeEach(async () => {
await db.insert(eventsTable).values({
id: "promote-event",
capacity: 1,
id: analyticsEventId,
title: "Analytics Test Event",
state: "published",
title: "Promote",
aboutMarkdown: "md",
organiser: "projectShare",
date: new Date(),
organiser: "projectShare",
aboutMarkdown: "md",
form: [
{
id: "size-field",
type: "select",
label: "T-Shirt Size",
required: true,
options: ["Small", "Medium", "Large"],
},
{
id: "diet-field",
type: "select",
label: "Dietary",
required: true,
options: ["None", "Vegan"],
},
{
id: "input-field",
type: "input",
label: "Name",
required: true,
},
],
});

await db
.insert(usersTable)
.values([{ id: "waitlist-1", email: "w1@ex.com", firstName: "W1", lastName: "U" }]);
await db.insert(usersTable).values([
{ id: "u1", email: "u1@test.com", firstName: "A", lastName: "A" },
{ id: "u2", email: "u2@test.com", firstName: "B", lastName: "B" },
{ id: "u3", email: "u3@test.com", firstName: "C", lastName: "C" },
]);

await db.insert(registrationsTable).values({
userId: "waitlist-1",
eventId: "promote-event",
status: "waitlist",
});
const today = new Date();
const yesterday = new Date(Date.now() - 86400000);

await db.insert(registrationsTable).values([
{
userId: "u1",
eventId: analyticsEventId,
status: "accepted",
createdAt: today,
answers: { "size-field": "Medium", "diet-field": "Vegan" },
},
{
userId: "u2",
eventId: analyticsEventId,
status: "accepted",
createdAt: today,
answers: { "size-field": "Medium", "diet-field": "None" },
},
{
userId: "u3",
eventId: analyticsEventId,
status: "pending",
createdAt: yesterday,
answers: { "size-field": "Small", "diet-field": "None" },
},
]);
});

it("should promote the oldest waitlisted user if capacity allows", async () => {
it("should return correct aggregated data for committee members", async () => {
setMockAuth({
userId: "committee-user",
sessionClaims: { metadata: { role: "committee" } },
});

const response = await app.inject({
method: "POST",
url: "/v1/events/promote-event/registrations/promote-from-waitlist",
method: "GET",
url: `/v1/events/${analyticsEventId}/registrations/analytics`,
});

expect(response.statusCode).toBe(200);
expect(response.json()).toHaveProperty("message", "waitlist-1");
});
const data = response.json();

it("should return 409 if event is already full", async () => {
await db
.insert(usersTable)
.values({ id: "filler", email: "f@ex.com", firstName: "F", lastName: "U" });
await db.insert(registrationsTable).values({
userId: "filler",
eventId: "promote-event",
status: "accepted",
expect(data.totalCount).toBe(3);
expect(data.countByStatus).toEqual({
accepted: 2,
pending: 1,
});

setMockAuth({
userId: "committee-user",
sessionClaims: { metadata: { role: "committee" } },
});

const response = await app.inject({
method: "POST",
url: "/v1/events/promote-event/registrations/promote-from-waitlist",
});
const dateKeys = Object.keys(data.countByDate);
expect(dateKeys.length).toBeGreaterThanOrEqual(2);

expect(response.statusCode).toBe(409);
});
const sizeAnalytics = data.countByAnswers["size-field"];
expect(sizeAnalytics.label).toBe("T-Shirt Size");

it("should return a clean message if the waitlist is empty", async () => {
setMockAuth({
userId: "committee-user",
sessionClaims: { metadata: { role: "committee" } },
});
type DataOption = {
option: string;
count: number;
};

await db.delete(registrationsTable).where(eq(registrationsTable.status, "waitlist"));
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;

const response = await app.inject({
method: "POST",
url: "/v1/events/promote-event/registrations/promote-from-waitlist",
});
expect(mediumCount).toBe(2);
expect(smallCount).toBe(1);
expect(largeCount).toBe(0);

expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ message: "Waitlist is empty" });
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);
});
});

describe("POST /v1/events/:eventId/registrations/batch-update-status", () => {
it("should allow moving all pending to waitlist", async () => {
it("should initialize zero-counts for events with no registrations", async () => {
await db.insert(eventsTable).values({
id: "cleanup-event",
id: "empty-event",
title: "Empty",
state: "published",
title: "T",
aboutMarkdown: "md",
organiser: "p",
date: new Date(),
organiser: "p",
aboutMarkdown: "",
form: [{ id: "sel", type: "select", label: "Sel", options: ["A", "B"], required: false }],
});
await db
.insert(usersTable)
.values({ id: "u1", email: "u1@ex.com", firstName: "U", lastName: "1" });
await db
.insert(registrationsTable)
.values({ userId: "u1", eventId: "cleanup-event", status: "pending" });

setMockAuth({
userId: "committee-user",
sessionClaims: { metadata: { role: "committee" } },
});

const response = await app.inject({
method: "POST",
url: "/v1/events/cleanup-event/registrations/batch-update-status",
payload: { fromStatus: "pending", toStatus: "waitlist" },
method: "GET",
url: "/v1/events/empty-event/registrations/analytics",
});

expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ updatedCount: 1 });
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 403 if trying to mass-accept", async () => {
it("should return 401 for non-committee members", async () => {
setMockAuth({
userId: "committee-user",
sessionClaims: { metadata: { role: "committee" } },
userId: "u1", // Regular user
sessionClaims: { metadata: { role: "member" } },
});

const response = await app.inject({
method: "POST",
url: "/v1/events/any/registrations/batch-update-status",
payload: { fromStatus: "pending", toStatus: "accepted" },
method: "GET",
url: `/v1/events/${analyticsEventId}/registrations/analytics`,
});

expect(response.statusCode).toBe(403);
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);
});
});

Expand Down
37 changes: 19 additions & 18 deletions apps/api/src/modules/registration/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,24 +60,6 @@ export const registrationRoutes = async (server: FastifyInstance) => {
return reply.status(200).send(result);
});

server.post("/promote-from-waitlist", async (request, reply) => {
const { userId, sessionClaims } = getAuth(request);
const role = sessionClaims?.metadata?.role;

if (!userId || role !== "committee") {
return reply.status(401).send({ message: "Unauthorised" });
}

const { eventId } = RegistrationEventIdSchema.parse(request.params);
const result = await registrationService.promoteNextFromWaitlist({
db: server.db,
data: { eventId },
role,
});

return reply.status(200).send(result);
});

server.post("/batch-update-status", async (request, reply) => {
const { userId, sessionClaims } = getAuth(request);
const role = sessionClaims?.metadata?.role;
Expand Down Expand Up @@ -168,6 +150,25 @@ export const registrationRoutes = async (server: FastifyInstance) => {
return reply.status(200).send(updatedRegistration);
});

server.get("/analytics", async (request, reply) => {
const { userId, sessionClaims } = getAuth(request);
const role = sessionClaims?.metadata?.role;

if (!userId || role !== "committee") {
return reply.status(401).send({ message: "Unauthorised" });
}

const { eventId } = RegistrationEventIdSchema.parse(request.params);

const analytics = await registrationService.getRegistrationAnalytics({
db: server.db,
eventId,
role,
});

return reply.status(200).send(analytics);
});

server.delete("/:userId", async (request, reply) => {
const { userId, sessionClaims } = getAuth(request);
const role = sessionClaims?.metadata?.role;
Expand Down
13 changes: 2 additions & 11 deletions apps/api/src/modules/registration/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,14 @@ export const UpdateRegistrationSchema = BaseRegistrationSchema.pick({
status: z.enum(RegistrationStatus).default("pending"),
});

export const UpdateBatchRegistrationSchema = z.object({
export const UpdateBatchStatusRegistrationSchema = z.object({
eventId: BaseRegistrationSchema.shape.eventId,
userIds: z.array(BaseRegistrationSchema.shape.userId),
status: z.enum(RegistrationStatus).default("pending"),
});

export const UpdateBatchStatusRegistrationSchema = z.object({
eventId: BaseRegistrationSchema.shape.eventId,
fromStatus: z.enum(RegistrationStatus),
toStatus: z.enum(RegistrationStatus),
});

export const RegistrationsQueryFilterSchema = z.object({
userId: UserIdSchema.shape.id.optional(),
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
status: z.enum(RegistrationStatus).optional(),
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removal of pagination parameters (page and limit) from RegistrationsQueryFilterSchema changes the API behavior. The get method in the store no longer respects pagination, which could cause performance issues if an event has thousands of registrations. Consider if this removal is intentional or if pagination should be maintained.

Suggested change
status: z.enum(RegistrationStatus).optional(),
status: z.enum(RegistrationStatus).optional(),
page: z.number().int().positive().optional(),
limit: z.number().int().positive().optional(),

Copilot uses AI. Check for mistakes.
});

Expand All @@ -55,5 +47,4 @@ export type UpdateRegistration = z.infer<typeof UpdateRegistrationSchema>;
export type RegistrationParams = z.infer<typeof RegistrationParamsSchema>;
export type RegistrationsQueryFilter = z.infer<typeof RegistrationsQueryFilterSchema>;
export type RegistrationEventId = z.infer<typeof RegistrationEventIdSchema>;
export type UpdateBatchRegistration = z.infer<typeof UpdateBatchRegistrationSchema>;
export type UpdateBatchStatusRegistration = z.infer<typeof UpdateBatchStatusRegistrationSchema>;
export type UpdateBatchRegistration = z.infer<typeof UpdateBatchStatusRegistrationSchema>;
Loading
Loading