diff --git a/server/src/data-layer/models/common.ts b/server/src/data-layer/models/common.ts index 5ce05a94d..84afed258 100644 --- a/server/src/data-layer/models/common.ts +++ b/server/src/data-layer/models/common.ts @@ -4,3 +4,17 @@ export type DocumentDataWithUid = T & { */ id: string } + +/** + * Utility type for functions that return cursor-based pages + */ +export type PaginatedFirebaseResponse = { + /** + * The current "page" of data returned from querying + */ + data: T[] + /** + * The cursor of the next page, is `undefined` if no such cursor exists + */ + nextCursor?: string +} diff --git a/server/src/data-layer/services/BookingHistoryService.test.ts b/server/src/data-layer/services/BookingHistoryService.test.ts new file mode 100644 index 000000000..2c564961a --- /dev/null +++ b/server/src/data-layer/services/BookingHistoryService.test.ts @@ -0,0 +1,184 @@ +import { dateToFirestoreTimeStamp } from "data-layer/adapters/DateUtils" +import BookingHistoryService from "./BookingHistoryService" +import { Timestamp } from "firebase-admin/firestore" +import db from "data-layer/adapters/FirestoreCollections" +import { cleanFirestore } from "test-config/TestUtils" + +const bookingHistoryService = new BookingHistoryService() +describe("BookingHistoryService integration tests", () => { + afterEach(async () => { + await cleanFirestore() + }) + + it("Should be able to add an event for deleted bookings", async () => { + const startDate = dateToFirestoreTimeStamp(new Date(2002, 10, 8)) + const endDate = dateToFirestoreTimeStamp(new Date(2002, 10, 10)) + const currentTime = dateToFirestoreTimeStamp(new Date()) + + const event = { + uid: "user-removed-from-booking", + start_date: startDate as Timestamp, + end_date: endDate as Timestamp, + event_type: "removed_user_from_booking", + timestamp: currentTime + } as const + + const newEvent = await bookingHistoryService.addBookingDeletedEvent(event) + + const result = (await db.bookingHistory.doc(newEvent.id).get()).data() + + expect(result).toEqual(event) + }) + + it("Should be able to add an event for created bookings", async () => { + const startDate = dateToFirestoreTimeStamp(new Date(2002, 10, 8)) + const endDate = dateToFirestoreTimeStamp(new Date(2002, 10, 10)) + const currentTime = dateToFirestoreTimeStamp(new Date()) + + const event = { + uid: "user-added-to-booking", + start_date: startDate as Timestamp, + end_date: endDate as Timestamp, + event_type: "added_user_to_booking", + timestamp: currentTime + } as const + + const newEvent = await bookingHistoryService.addBookingAddedEvent(event) + + const result = (await db.bookingHistory.doc(newEvent.id).get()).data() + + expect(result).toEqual(event) + }) + + it("Should be able to add an event for availability changes", async () => { + const startDate = dateToFirestoreTimeStamp(new Date(2002, 10, 8)) + const endDate = dateToFirestoreTimeStamp(new Date(2002, 10, 10)) + const currentTime = dateToFirestoreTimeStamp(new Date()) + + const event = { + start_date: startDate as Timestamp, + end_date: endDate as Timestamp, + event_type: "changed_date_availability", + timestamp: currentTime, + change: -69 + } as const + + const newEvent = + await bookingHistoryService.addAvailibilityChangeEvent(event) + + const result = await (await db.bookingHistory.doc(newEvent.id).get()).data() + + expect(result).toEqual(event) + }) + describe("Fetching events", () => { + /** + * In these tests we don't care about these + */ + const startDate = dateToFirestoreTimeStamp(new Date(2002, 10, 8)) + const endDate = dateToFirestoreTimeStamp(new Date(2002, 10, 10)) + + const availabilityEvent = { + timestamp: Timestamp.fromDate(new Date(2001, 10, 9)), + change: 69, + start_date: startDate, + end_date: endDate, + event_type: "changed_date_availability" + } as const + + const deletedEvent = { + timestamp: Timestamp.fromDate(new Date(2001, 10, 9)), + start_date: startDate, + end_date: endDate, + event_type: "removed_user_from_booking", + uid: "deleted-user" + } as const + + const addedEvent = { + timestamp: Timestamp.fromDate(new Date(2001, 10, 9)), + start_date: startDate, + end_date: endDate, + event_type: "added_user_to_booking", + uid: "added-user" + } as const + + const notIncludedEvent = { + timestamp: Timestamp.fromDate(new Date(2001, 10, 10)), + start_date: startDate, + end_date: endDate, + event_type: "added_user_to_booking", + uid: "unincluded-user" + } as const + + it("Should be able to fetch history in between a range of dates", async () => { + const searchStartDate = dateToFirestoreTimeStamp(new Date(2001, 10, 6)) + const searchEndDate = dateToFirestoreTimeStamp(new Date(2001, 10, 9)) + + const { id: availabilityId } = + await bookingHistoryService.addAvailibilityChangeEvent( + availabilityEvent + ) + + const { id: deletedId } = + await bookingHistoryService.addBookingDeletedEvent(deletedEvent) + + const { id: addedId } = + await bookingHistoryService.addBookingAddedEvent(addedEvent) + + const { id: notIncludedId } = + await bookingHistoryService.addBookingAddedEvent(notIncludedEvent) + + const foundEvents = + await bookingHistoryService.getAllHistoryBetweenDateRange( + searchStartDate, + searchEndDate + ) + + expect(foundEvents).toContainEqual({ ...addedEvent, id: addedId }) + expect(foundEvents).toContainEqual({ ...deletedEvent, id: deletedId }) + expect(foundEvents).toContainEqual({ + ...availabilityEvent, + id: availabilityId + }) + expect(foundEvents).not.toContainEqual({ + ...notIncludedEvent, + id: notIncludedId + }) + }) + + it("Should be able to fetch the latest X events", async () => { + // Ordering matters! Earlier addition means it should be the first out + const { id: notIncludedId } = + await bookingHistoryService.addBookingAddedEvent(notIncludedEvent) + + const { id: availabilityId } = + await bookingHistoryService.addAvailibilityChangeEvent( + availabilityEvent + ) + + const { id: deletedId } = + await bookingHistoryService.addBookingDeletedEvent(deletedEvent) + + const { id: addedId } = + await bookingHistoryService.addBookingAddedEvent(addedEvent) + + const PAGE_LENGTH = 3 as const + + const { data: foundEvents, nextCursor } = + await bookingHistoryService.getLatestHistory(PAGE_LENGTH) + + expect(nextCursor).not.toBeUndefined() + expect(foundEvents).toHaveLength(PAGE_LENGTH) + + expect(foundEvents).toContainEqual({ ...addedEvent, id: addedId }) + expect(foundEvents).toContainEqual({ ...deletedEvent, id: deletedId }) + expect(foundEvents).toContainEqual({ + ...availabilityEvent, + id: availabilityId + }) + expect(foundEvents).not.toContainEqual({ + ...notIncludedEvent, + id: notIncludedId + }) + }) + }) +}) diff --git a/server/src/data-layer/services/BookingHistoryService.ts b/server/src/data-layer/services/BookingHistoryService.ts new file mode 100644 index 000000000..8eadb7ebe --- /dev/null +++ b/server/src/data-layer/services/BookingHistoryService.ts @@ -0,0 +1,104 @@ +import { firestoreTimestampToDate } from "data-layer/adapters/DateUtils" +import db from "data-layer/adapters/FirestoreCollections" +import { + DocumentDataWithUid, + PaginatedFirebaseResponse +} from "data-layer/models/common" +import { + BookingAddedEvent, + BookingAvailabilityChangeEvent, + BookingDeletedEvent, + BookingHistoryEvent +} from "data-layer/models/firebase" +import { Timestamp } from "firebase-admin/firestore" + +class BookingHistoryService { + /** + * Stores a manual deletion of a booking (by admin) into the booking history collection + * + * @param event the required parameters defined by {@link BookingDeletedEvent} + * @returns the created document reference + */ + public async addBookingDeletedEvent(event: BookingDeletedEvent) { + return await db.bookingHistory.add(event) + } + + /** + * Stores a manual creation of a booking (by admin) into the booking history collection + * + * @param event the required parameters defined by {@link BookingAddedEvent} + * @returns the created document reference + */ + public async addBookingAddedEvent(event: BookingAddedEvent) { + return await db.bookingHistory.add(event) + } + + /** + * Stores a modification to the booking availability into the booking history collection + * + * @param event the required parameters defined by {@link BookingAvailabilityChangeEvent} + * @returns the created document reference + */ + public async addAvailibilityChangeEvent( + event: BookingAvailabilityChangeEvent + ) { + return await db.bookingHistory.add(event) + } + + /** + * Returns all history events whose timestamps fall between the given timestamps + * + * @param startDate the first date to return history events for + * @param endDate the last date to return history events for + * @returns a list of all events that fall between the specified date range + */ + public async getAllHistoryBetweenDateRange( + startDate: Timestamp, + endDate: Timestamp + ): Promise[]> { + const res = await db.bookingHistory + .where("timestamp", ">=", firestoreTimestampToDate(startDate)) + .where("timestamp", "<=", firestoreTimestampToDate(endDate)) + .get() + + return res.docs.map((doc) => { + return { ...doc.data(), id: doc.id } + }) + } + + /** + * Fetches the **latest** page of booking history events. + * + * @param limit how many history events to fetch, defaults to `100` + * @param startAfter the firebase document snapshot to paginate from + * @returns the page of booking history items and a cursor pointing to the + * last `id` to use for pagination + */ + public async getLatestHistory( + limit: number = 100, + startAfter?: FirebaseFirestore.DocumentSnapshot< + BookingHistoryEvent, + FirebaseFirestore.DocumentData + > + ): Promise< + PaginatedFirebaseResponse> + > { + const res = await db.bookingHistory + .orderBy("timestamp") + .startAfter(startAfter || 0) + .limit(limit) + .get() + + const historyPage: DocumentDataWithUid[] = + res.docs.map((event) => { + return { ...event.data(), id: event.id } + }) + + return { + data: historyPage, + nextCursor: res.docs[res.docs.length - 1]?.id || undefined + } + } +} + +export default BookingHistoryService