From a6f80d3f0ecb04fbc1dcd20a8b0b57902c6e4853 Mon Sep 17 00:00:00 2001 From: jeffplays2005 Date: Sun, 1 Sep 2024 22:41:29 +1200 Subject: [PATCH 1/4] Add eventSignup endpoint and new getAllReservations EventService method. Created new eventSignup endpoint and their controller. Takes in a EventSignupBody and returns an EventSignupResponse. Created EventService `getAllReservations` method and test. --- client/src/models/__generated__/schema.d.ts | 46 ++++++++ .../data-layer/services/EventService.test.ts | 20 +++- .../src/data-layer/services/EventService.ts | 12 +- server/src/middleware/__generated__/routes.ts | 63 ++++++++++ .../src/middleware/__generated__/swagger.json | 110 ++++++++++++++++++ .../controllers/EventController.ts | 57 +++++++++ .../request-models/EventRequests.ts | 6 + .../response-models/EventResponse.ts | 9 ++ 8 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 server/src/service-layer/controllers/EventController.ts create mode 100644 server/src/service-layer/request-models/EventRequests.ts create mode 100644 server/src/service-layer/response-models/EventResponse.ts 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..dbba03c07 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,37 @@ 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', + authenticateMiddleware([{"jwt":[]}]), + ...(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..d0850be56 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,40 @@ } } }, + "/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": [ + { + "jwt": [] + } + ], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventSignupBody" + } + } + } + } + } + }, "/bookings": { "get": { "operationId": "GetAllBookings", diff --git a/server/src/service-layer/controllers/EventController.ts b/server/src/service-layer/controllers/EventController.ts new file mode 100644 index 000000000..8f99bc011 --- /dev/null +++ b/server/src/service-layer/controllers/EventController.ts @@ -0,0 +1,57 @@ +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, Security, SuccessResponse } from "tsoa" + +@Route("events") +export class EventController extends Controller { + /** + * Signs up for an event + */ + @SuccessResponse("200", "Successfully signed up for Event") + @Security("jwt") + @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 (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.toLowerCase() === reservation.email.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 + } +} From 70fe97e70b7a863517ecc7842c7efc61ac31bffe Mon Sep 17 00:00:00 2001 From: jeffplays2005 Date: Sun, 1 Sep 2024 23:13:10 +1200 Subject: [PATCH 2/4] Minor updates to eventSignup endpoint and added tests * Removes requirement for JWT in the endpoint. * Checks if `max_occupancy` is undefined and **only** compares occupancy if `max_occupancy` isn't undefined. --- server/src/middleware/__generated__/routes.ts | 1 - .../src/middleware/__generated__/swagger.json | 6 +- .../middleware/tests/EventController.test.ts | 73 +++++++++++++++++++ .../controllers/EventController.ts | 8 +- 4 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 server/src/middleware/tests/EventController.test.ts diff --git a/server/src/middleware/__generated__/routes.ts b/server/src/middleware/__generated__/routes.ts index dbba03c07..75dd6b398 100644 --- a/server/src/middleware/__generated__/routes.ts +++ b/server/src/middleware/__generated__/routes.ts @@ -806,7 +806,6 @@ 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', - authenticateMiddleware([{"jwt":[]}]), ...(fetchMiddlewares(EventController)), ...(fetchMiddlewares(EventController.prototype.eventSignup)), diff --git a/server/src/middleware/__generated__/swagger.json b/server/src/middleware/__generated__/swagger.json index d0850be56..27405b340 100644 --- a/server/src/middleware/__generated__/swagger.json +++ b/server/src/middleware/__generated__/swagger.json @@ -1661,11 +1661,7 @@ } }, "description": "Signs up for an event", - "security": [ - { - "jwt": [] - } - ], + "security": [], "parameters": [], "requestBody": { "required": true, 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 index 8f99bc011..546f94c62 100644 --- a/server/src/service-layer/controllers/EventController.ts +++ b/server/src/service-layer/controllers/EventController.ts @@ -1,7 +1,7 @@ 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, Security, SuccessResponse } from "tsoa" +import { Body, Controller, Post, Route, SuccessResponse } from "tsoa" @Route("events") export class EventController extends Controller { @@ -9,7 +9,6 @@ export class EventController extends Controller { * Signs up for an event */ @SuccessResponse("200", "Successfully signed up for Event") - @Security("jwt") @Post("signup") public async eventSignup( @Body() requestBody: EventSignupBody @@ -24,7 +23,10 @@ export class EventController extends Controller { } // Check if the event is full const reservations = await eventService.getAllReservations(event_id) - if (reservations.length >= fetchedEvent.max_occupancy) { + if ( + fetchedEvent.max_occupancy !== undefined && + reservations.length >= fetchedEvent.max_occupancy + ) { this.setStatus(400) return { error: "Maximum event occupancy reached." } } From b64dba282b4d9ec36342b07a3c5a1bff2b296d21 Mon Sep 17 00:00:00 2001 From: jeffplays2005 Date: Mon, 2 Sep 2024 16:32:11 +1200 Subject: [PATCH 3/4] Use .trim to compare emails and workaround for checking max_occupancy --- server/src/service-layer/controllers/EventController.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/src/service-layer/controllers/EventController.ts b/server/src/service-layer/controllers/EventController.ts index 546f94c62..e575303e7 100644 --- a/server/src/service-layer/controllers/EventController.ts +++ b/server/src/service-layer/controllers/EventController.ts @@ -24,7 +24,7 @@ export class EventController extends Controller { // Check if the event is full const reservations = await eventService.getAllReservations(event_id) if ( - fetchedEvent.max_occupancy !== undefined && + !!fetchedEvent.max_occupancy && reservations.length >= fetchedEvent.max_occupancy ) { this.setStatus(400) @@ -33,7 +33,9 @@ export class EventController extends Controller { // Check if the user is already signed up if ( reservations.some( - (r) => r.email.toLowerCase() === reservation.email.toLowerCase() + (r) => + r.email.trim().toLowerCase() === + reservation.email.trim().toLowerCase() ) ) { this.setStatus(400) From c424bbc01b8da1f48d2516726c68b2415c98c51d Mon Sep 17 00:00:00 2001 From: jeffplays2005 Date: Mon, 2 Sep 2024 17:02:13 +1200 Subject: [PATCH 4/4] Revert check for fetchedEvent.max_occupancy --- server/src/service-layer/controllers/EventController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/service-layer/controllers/EventController.ts b/server/src/service-layer/controllers/EventController.ts index e575303e7..d753f470c 100644 --- a/server/src/service-layer/controllers/EventController.ts +++ b/server/src/service-layer/controllers/EventController.ts @@ -24,7 +24,7 @@ export class EventController extends Controller { // Check if the event is full const reservations = await eventService.getAllReservations(event_id) if ( - !!fetchedEvent.max_occupancy && + fetchedEvent.max_occupancy !== undefined && reservations.length >= fetchedEvent.max_occupancy ) { this.setStatus(400)