diff --git a/client/src/models/__generated__/schema.d.ts b/client/src/models/__generated__/schema.d.ts index 00518f901..1568af09d 100644 --- a/client/src/models/__generated__/schema.d.ts +++ b/client/src/models/__generated__/schema.d.ts @@ -48,6 +48,10 @@ export interface paths { */ post: operations["GetBookingPayment"]; }; + "/events/signup": { + /** @description Signs up for an event */ + post: operations["EventSignup"]; + }; "/bookings": { /** @description Fetches all bookings for a user based on their UID. */ get: operations["GetAllBookings"]; @@ -267,6 +271,32 @@ export interface components { /** @description Firestore timestamp, should represent a UTC date that is set to exactly midnight */ endDate?: components["schemas"]["FirebaseFirestore.Timestamp"]; }; + EventSignupResponse: { + error?: string; + message?: string; + data?: { + email: string; + last_name: string; + first_name: string; + }; + }; + EventReservation: { + /** @description The first name of the user who made this event reservation */ + first_name: string; + /** @description The last name of the user who made this event reservation */ + last_name: string; + /** @description The email of the user who made this even reservation */ + email: string; + /** + * @description Boolean to check if the user is a member + * @example true + */ + is_member: boolean; + }; + EventSignupBody: { + event_id: string; + reservation: components["schemas"]["EventReservation"]; + }; AllUserBookingSlotsResponse: { error?: string; message?: string; @@ -772,6 +802,22 @@ export interface operations { }; }; }; + /** @description Signs up for an event */ + EventSignup: { + requestBody: { + content: { + "application/json": components["schemas"]["EventSignupBody"]; + }; + }; + responses: { + /** @description Successfully signed up for Event */ + 200: { + content: { + "application/json": components["schemas"]["EventSignupResponse"]; + }; + }; + }; + }; /** @description Fetches all bookings for a user based on their UID. */ GetAllBookings: { responses: { diff --git a/server/src/data-layer/services/EventService.test.ts b/server/src/data-layer/services/EventService.test.ts index c37909e83..6842daed0 100644 --- a/server/src/data-layer/services/EventService.test.ts +++ b/server/src/data-layer/services/EventService.test.ts @@ -107,12 +107,12 @@ describe("EventService integration tests", () => { await eventService.deleteEvent(newEvent.id) - const fetchedReservation1 = await eventService.getReservation( + const fetchedReservation1 = await eventService.getReservationById( newEvent.id, newReservation1.id ) expect(fetchedReservation1).toBe(undefined) - const fetchedReservation2 = await eventService.getReservation( + const fetchedReservation2 = await eventService.getReservationById( newEvent.id, newReservation2.id ) @@ -134,12 +134,12 @@ describe("EventService integration tests", () => { ) await eventService.deleteEvent(newEvent.id) - const fetchedReservation3 = await eventService.getReservation( + const fetchedReservation3 = await eventService.getReservationById( newEvent2.id, newReservation3.id ) expect(fetchedReservation3).toEqual(reservation1) - const fetchedReservation4 = await eventService.getReservation( + const fetchedReservation4 = await eventService.getReservationById( newEvent2.id, newReservation4.id ) @@ -171,13 +171,23 @@ describe("EventService integration tests", () => { newEvent.id, reservation1 ) - const fetchedReservation = await eventService.getReservation( + const fetchedReservation = await eventService.getReservationById( newEvent.id, reservation.id ) expect(fetchedReservation).toEqual(reservation1) }) + it("Should get all event reservations", async () => { + const newEvent = await eventService.createEvent(event1) + await eventService.addReservation(newEvent.id, reservation1) + await eventService.addReservation(newEvent.id, reservation2) + const reservations = await eventService.getAllReservations(newEvent.id) + expect(reservations.length).toBe(2) + expect(reservations).toContainEqual(reservation1) + expect(reservations).toContainEqual(reservation2) + }) + it("Should be able to update an event reservation", async () => { const newEvent = await eventService.createEvent(event1) diff --git a/server/src/data-layer/services/EventService.ts b/server/src/data-layer/services/EventService.ts index d91076dd6..c0af72859 100644 --- a/server/src/data-layer/services/EventService.ts +++ b/server/src/data-layer/services/EventService.ts @@ -72,7 +72,7 @@ class EventService { * @param reservationId the ID of the reservation document * @returns the reservation document */ - public async getReservation(eventId: string, reservationId: string) { + public async getReservationById(eventId: string, reservationId: string) { const result = await FirestoreSubcollections.reservations(eventId) .doc(reservationId) .get() @@ -80,6 +80,16 @@ class EventService { return result.data() } + /** + * Gets all reservations for an event. + * @param eventId the ID of the event document + * @returns an array of all the event reservation documents + */ + public async getAllReservations(eventId: string) { + const result = await FirestoreSubcollections.reservations(eventId).get() + return result.docs.map((doc) => doc.data()) + } + /** * Updates an existing reservation document by ID with new EventReservation data. * diff --git a/server/src/middleware/__generated__/routes.ts b/server/src/middleware/__generated__/routes.ts index 049aee8eb..75dd6b398 100644 --- a/server/src/middleware/__generated__/routes.ts +++ b/server/src/middleware/__generated__/routes.ts @@ -11,6 +11,8 @@ import { UserSignup } from './../../service-layer/controllers/SignupController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { PaymentController } from './../../service-layer/controllers/PaymentController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa +import { EventController } from './../../service-layer/controllers/EventController'; +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { BookingController } from './../../service-layer/controllers/BookingController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { AdminController } from './../../service-layer/controllers/AdminController'; @@ -163,6 +165,36 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "EventSignupResponse": { + "dataType": "refObject", + "properties": { + "error": {"dataType":"string"}, + "message": {"dataType":"string"}, + "data": {"dataType":"nestedObjectLiteral","nestedProperties":{"email":{"dataType":"string","required":true},"last_name":{"dataType":"string","required":true},"first_name":{"dataType":"string","required":true}}}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "EventReservation": { + "dataType": "refObject", + "properties": { + "first_name": {"dataType":"string","required":true}, + "last_name": {"dataType":"string","required":true}, + "email": {"dataType":"string","required":true}, + "is_member": {"dataType":"boolean","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "EventSignupBody": { + "dataType": "refObject", + "properties": { + "event_id": {"dataType":"string","required":true}, + "reservation": {"ref":"EventReservation","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "AllUserBookingSlotsResponse": { "dataType": "refObject", "properties": { @@ -773,6 +805,36 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post('/events/signup', + ...(fetchMiddlewares(EventController)), + ...(fetchMiddlewares(EventController.prototype.eventSignup)), + + function EventController_eventSignup(request: ExRequest, response: ExResponse, next: any) { + const args: Record = { + requestBody: {"in":"body","name":"requestBody","required":true,"ref":"EventSignupBody"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = templateService.getValidatedArgs({ args, request, response }); + + const controller = new EventController(); + + templateService.apiHandler({ + methodName: 'eventSignup', + controller, + response, + next, + validatedArgs, + successStatus: 200, + }); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/bookings', authenticateMiddleware([{"jwt":["member"]}]), ...(fetchMiddlewares(BookingController)), diff --git a/server/src/middleware/__generated__/swagger.json b/server/src/middleware/__generated__/swagger.json index 61a737b8c..27405b340 100644 --- a/server/src/middleware/__generated__/swagger.json +++ b/server/src/middleware/__generated__/swagger.json @@ -344,6 +344,82 @@ "type": "object", "additionalProperties": false }, + "EventSignupResponse": { + "properties": { + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "properties": { + "email": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "first_name": { + "type": "string" + } + }, + "required": [ + "email", + "last_name", + "first_name" + ], + "type": "object" + } + }, + "type": "object", + "additionalProperties": false + }, + "EventReservation": { + "properties": { + "first_name": { + "type": "string", + "description": "The first name of the user who made this event reservation" + }, + "last_name": { + "type": "string", + "description": "The last name of the user who made this event reservation" + }, + "email": { + "type": "string", + "description": "The email of the user who made this even reservation" + }, + "is_member": { + "type": "boolean", + "description": "Boolean to check if the user is a member", + "example": true + } + }, + "required": [ + "first_name", + "last_name", + "email", + "is_member" + ], + "type": "object", + "additionalProperties": false + }, + "EventSignupBody": { + "properties": { + "event_id": { + "type": "string" + }, + "reservation": { + "$ref": "#/components/schemas/EventReservation" + } + }, + "required": [ + "event_id", + "reservation" + ], + "type": "object", + "additionalProperties": false + }, "AllUserBookingSlotsResponse": { "properties": { "error": { @@ -1569,6 +1645,36 @@ } } }, + "/events/signup": { + "post": { + "operationId": "EventSignup", + "responses": { + "200": { + "description": "Successfully signed up for Event", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventSignupResponse" + } + } + } + } + }, + "description": "Signs up for an event", + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventSignupBody" + } + } + } + } + } + }, "/bookings": { "get": { "operationId": "GetAllBookings", diff --git a/server/src/middleware/tests/EventController.test.ts b/server/src/middleware/tests/EventController.test.ts new file mode 100644 index 000000000..0954d0d0b --- /dev/null +++ b/server/src/middleware/tests/EventController.test.ts @@ -0,0 +1,73 @@ +import EventService from "data-layer/services/EventService" +import { request } from "../routes.setup" +import { Event, EventReservation } from "../../data-layer/models/firebase" +import { dateToFirestoreTimeStamp } from "data-layer/adapters/DateUtils" + +const startDate = dateToFirestoreTimeStamp(new Date(2024, 1, 1)) +const endDate = dateToFirestoreTimeStamp(new Date(2024, 1, 2)) +const event1: Event = { + title: "UASC New event", + location: "UASC", + start_date: startDate, + end_date: endDate +} +const reservation1: EventReservation = { + first_name: "John", + last_name: "Doe", + email: "test@email.com", + is_member: true +} + +describe("EventController endpoint tests", () => { + const eventService = new EventService() + describe("/events/signup", () => { + it("should return 404 if the event does not exist", async () => { + const res = await request.post("/events/signup").send({ + event_id: "non-existent-event", + reservation: reservation1 + }) + expect(res.status).toEqual(404) + }) + + it("should return 400 if the event is full", async () => { + const event = await eventService.createEvent({ + ...event1, + max_occupancy: 0 + }) + const res = await request.post("/events/signup").send({ + event_id: event.id, + reservation: reservation1 + }) + expect(res.status).toEqual(400) + expect(res.body.error).toEqual("Maximum event occupancy reached.") + }) + + it("should return 400 if already signed up to event", async () => { + const event = await eventService.createEvent(event1) + await eventService.addReservation(event.id, reservation1) + const res = await request.post("/events/signup").send({ + event_id: event.id, + reservation: reservation1 + }) + expect(res.status).toEqual(400) + expect(res.body.error).toEqual( + "You have already signed up for this event." + ) + }) + + it("should allow user to signup to an event", async () => { + const event = await eventService.createEvent(event1) + const res = await request.post("/events/signup").send({ + event_id: event.id, + reservation: reservation1 + }) + expect(res.status).toEqual(200) + expect(res.body.message).toEqual("Successfully signed up for event.") + expect(res.body.data).toEqual({ + first_name: reservation1.first_name, + last_name: reservation1.last_name, + email: reservation1.email + }) + }) + }) +}) diff --git a/server/src/service-layer/controllers/EventController.ts b/server/src/service-layer/controllers/EventController.ts new file mode 100644 index 000000000..d753f470c --- /dev/null +++ b/server/src/service-layer/controllers/EventController.ts @@ -0,0 +1,61 @@ +import EventService from "data-layer/services/EventService" +import { EventSignupBody } from "service-layer/request-models/EventRequests" +import { EventSignupResponse } from "service-layer/response-models/EventResponse" +import { Body, Controller, Post, Route, SuccessResponse } from "tsoa" + +@Route("events") +export class EventController extends Controller { + /** + * Signs up for an event + */ + @SuccessResponse("200", "Successfully signed up for Event") + @Post("signup") + public async eventSignup( + @Body() requestBody: EventSignupBody + ): Promise { + const { event_id, reservation } = requestBody + const eventService = new EventService() + // Check if the event exists + const fetchedEvent = await eventService.getEventById(event_id) + if (!fetchedEvent) { + this.setStatus(404) + return { error: "Event not found." } + } + // Check if the event is full + const reservations = await eventService.getAllReservations(event_id) + if ( + fetchedEvent.max_occupancy !== undefined && + reservations.length >= fetchedEvent.max_occupancy + ) { + this.setStatus(400) + return { error: "Maximum event occupancy reached." } + } + // Check if the user is already signed up + if ( + reservations.some( + (r) => + r.email.trim().toLowerCase() === + reservation.email.trim().toLowerCase() + ) + ) { + this.setStatus(400) + return { error: "You have already signed up for this event." } + } + // Sign up the user + try { + await eventService.addReservation(event_id, reservation) + this.setStatus(200) + return { + message: "Successfully signed up for event.", + data: { + first_name: reservation.first_name, + last_name: reservation.last_name, + email: reservation.email + } + } + } catch (e) { + this.setStatus(500) + return { error: "Failed to sign up for event." } + } + } +} diff --git a/server/src/service-layer/request-models/EventRequests.ts b/server/src/service-layer/request-models/EventRequests.ts new file mode 100644 index 000000000..74ae6ebdc --- /dev/null +++ b/server/src/service-layer/request-models/EventRequests.ts @@ -0,0 +1,6 @@ +import { EventReservation } from "data-layer/models/firebase" + +export interface EventSignupBody { + event_id: string + reservation: EventReservation +} diff --git a/server/src/service-layer/response-models/EventResponse.ts b/server/src/service-layer/response-models/EventResponse.ts new file mode 100644 index 000000000..9137e14a4 --- /dev/null +++ b/server/src/service-layer/response-models/EventResponse.ts @@ -0,0 +1,9 @@ +import { CommonResponse } from "./CommonResponse" + +export interface EventSignupResponse extends CommonResponse { + data?: { + first_name: string + last_name: string + email: string + } +}