From 03b02bee9025d983e33f1d4783db0858b8e6efcb Mon Sep 17 00:00:00 2001 From: Lars Waage <46653859+larwaa@users.noreply.github.com> Date: Tue, 5 Mar 2024 18:49:01 +0100 Subject: [PATCH] :sparkles: feat(cabins): add occupied days query (#510) * :sparkles: feat(cabins): add occupied days query * chore: address code smell --- schema.graphql | 1 + src/graphql/cabins/resolvers/Cabin.ts | 10 +++ .../resolvers/Query/cabins.unit.test.ts | 44 ++++++++++ src/graphql/cabins/schema.graphql | 1 + src/lib/server.ts | 4 + .../integration/find-many-bookings.test.ts | 88 +++++++++++++++++++ src/repositories/cabins/repository.ts | 54 +++++++++++- src/repositories/cabins/seed.ts | 36 ++++++++ .../__tests__/unit/get-occupied-dates.test.ts | 60 +++++++++++++ src/services/cabins/service.ts | 59 ++++++++++++- 10 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 src/repositories/cabins/__tests__/integration/find-many-bookings.test.ts create mode 100644 src/services/cabins/__tests__/unit/get-occupied-dates.test.ts diff --git a/schema.graphql b/schema.graphql index fb12ee8a..6c460529 100644 --- a/schema.graphql +++ b/schema.graphql @@ -62,6 +62,7 @@ type Cabin { id: ID! internalPrice: Int! name: String! + occupiedDays: [DateTime!]! } input CabinInput { diff --git a/src/graphql/cabins/resolvers/Cabin.ts b/src/graphql/cabins/resolvers/Cabin.ts index 8386c048..cb44f71d 100644 --- a/src/graphql/cabins/resolvers/Cabin.ts +++ b/src/graphql/cabins/resolvers/Cabin.ts @@ -1,4 +1,14 @@ import type { CabinResolvers } from "./../../types.generated.js"; export const Cabin: CabinResolvers = { /* Implement Cabin resolver logic here */ + occupiedDays: async ({ id }, _args, ctx) => { + const occupiedDates = await ctx.cabins.getOccupiedDates(ctx, { + cabinId: id, + }); + if (!occupiedDates.ok) { + throw occupiedDates.error; + } + + return occupiedDates.data.days; + }, }; diff --git a/src/graphql/cabins/resolvers/Query/cabins.unit.test.ts b/src/graphql/cabins/resolvers/Query/cabins.unit.test.ts index 904108fb..5dce5d39 100644 --- a/src/graphql/cabins/resolvers/Query/cabins.unit.test.ts +++ b/src/graphql/cabins/resolvers/Query/cabins.unit.test.ts @@ -35,5 +35,49 @@ describe("Cabin queries", () => { expect(errors).toBeUndefined(); expect(cabinService.findManyCabins).toHaveBeenCalled(); }); + + it("resolves occupied days for the cabins", async () => { + const { client, cabinService } = createMockApolloServer(); + const oksen = mock({ + id: faker.string.uuid(), + name: "Oksen", + }); + const bjørnen = mock({ + id: faker.string.uuid(), + name: "Bjørnen", + }); + cabinService.findManyCabins.mockResolvedValue([oksen, bjørnen]); + cabinService.getOccupiedDates.mockResolvedValue({ + ok: true, + data: { + days: [], + }, + }); + + const { errors } = await client.query({ + query: graphql(` + query CabinsOccupiedDays { + cabins { + cabins { + id + name + occupiedDays + } + } + } + `), + }); + + expect(errors).toBeUndefined(); + expect(cabinService.findManyCabins).toHaveBeenCalled(); + expect(cabinService.getOccupiedDates).toHaveBeenCalledWith( + expect.anything(), + { cabinId: oksen.id }, + ); + expect(cabinService.getOccupiedDates).toHaveBeenCalledWith( + expect.anything(), + { cabinId: bjørnen.id }, + ); + }); }); }); diff --git a/src/graphql/cabins/schema.graphql b/src/graphql/cabins/schema.graphql index e35e6898..30aefdae 100644 --- a/src/graphql/cabins/schema.graphql +++ b/src/graphql/cabins/schema.graphql @@ -4,6 +4,7 @@ type Cabin { internalPrice: Int! externalPrice: Int! capacity: Int! + occupiedDays: [DateTime!]! } type Booking { diff --git a/src/lib/server.ts b/src/lib/server.ts index 86279276..cbf152c7 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -281,6 +281,10 @@ interface ICabinService { { totalCost: number }, InternalServerError | NotFoundError | InvalidArgumentError >; + getOccupiedDates( + ctx: Context, + params: { cabinId: string }, + ): ResultAsync<{ days: Date[] }, NotFoundError | InternalServerError>; } interface IEventService { diff --git a/src/repositories/cabins/__tests__/integration/find-many-bookings.test.ts b/src/repositories/cabins/__tests__/integration/find-many-bookings.test.ts new file mode 100644 index 00000000..e5dfc5af --- /dev/null +++ b/src/repositories/cabins/__tests__/integration/find-many-bookings.test.ts @@ -0,0 +1,88 @@ +import { faker } from "@faker-js/faker"; +import { DateTime } from "luxon"; +import { makeMockContext } from "~/lib/context.js"; +import { makeDependencies } from "./dependencies.js"; + +describe("CabinRepository", () => { + describe("#findManyBookings", () => { + it("returns all bookings for a cabin", async () => { + const { + cabinRepository, + oksen, + oksenBooking, + bothBooking, + bjørnenBooking, + } = await makeDependencies(); + + const result = await cabinRepository.findManyBookings(makeMockContext(), { + cabinId: oksen.id, + }); + + expect(result).toEqual({ + ok: true, + data: { + bookings: expect.arrayContaining([oksenBooking, bothBooking]), + }, + }); + + expect(result).toEqual({ + ok: true, + data: { + bookings: expect.not.arrayContaining([bjørnenBooking]), + }, + }); + }); + + it("returns empty array if cabin with the id does not exist", async () => { + const { cabinRepository } = await makeDependencies(); + + const result = await cabinRepository.findManyBookings(makeMockContext(), { + cabinId: faker.string.uuid(), + }); + + expect(result).toEqual({ + ok: true, + data: { + bookings: [], + }, + }); + }); + + it("returns only bookings matching status", async () => { + const { cabinRepository, makeBooking, oksen } = await makeDependencies(); + + const confirmedBooking = await makeBooking({ + cabins: [{ id: oksen.id }], + status: "CONFIRMED", + endDate: DateTime.now().plus({ days: 4 }).toJSDate(), + startDate: DateTime.now().plus({ days: 1 }).toJSDate(), + }); + + const pendingBooking = await makeBooking({ + cabins: [{ id: oksen.id }], + status: "PENDING", + endDate: DateTime.now().plus({ days: 4 }).toJSDate(), + startDate: DateTime.now().plus({ days: 1 }).toJSDate(), + }); + + const result = await cabinRepository.findManyBookings(makeMockContext(), { + cabinId: oksen.id, + bookingStatus: "CONFIRMED", + }); + + expect(result).toEqual({ + ok: true, + data: { + bookings: expect.arrayContaining([confirmedBooking]), + }, + }); + + expect(result).toEqual({ + ok: true, + data: { + bookings: expect.not.arrayContaining([pendingBooking]), + }, + }); + }); + }); +}); diff --git a/src/repositories/cabins/repository.ts b/src/repositories/cabins/repository.ts index 8eeaa81c..5b2ade1e 100644 --- a/src/repositories/cabins/repository.ts +++ b/src/repositories/cabins/repository.ts @@ -6,12 +6,17 @@ import type { Semester, } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library.js"; -import { Booking, type BookingType } from "~/domain/cabins.js"; +import { + Booking, + type BookingStatus, + type BookingType, +} from "~/domain/cabins.js"; import { InternalServerError, InvalidArgumentError, NotFoundError, } from "~/domain/errors.js"; +import type { Context } from "~/lib/context.js"; import { prismaKnownErrorCodes } from "~/lib/prisma.js"; import type { ResultAsync } from "~/lib/result.js"; import type { ICabinRepository } from "~/services/cabins/service.js"; @@ -19,6 +24,53 @@ import type { ICabinRepository } from "~/services/cabins/service.js"; export class CabinRepository implements ICabinRepository { constructor(private db: PrismaClient) {} + async findManyBookings( + ctx: Context, + params: { + cabinId: string; + endAtGte?: Date; + bookingStatus?: BookingStatus; + }, + ): ResultAsync<{ bookings: BookingType[] }, InternalServerError> { + ctx.log.info(params, "Finding bookings for cabin"); + try { + const bookings = await this.db.booking.findMany({ + include: { + cabins: { + select: { + id: true, + }, + }, + }, + where: { + status: params.bookingStatus, + cabins: { + some: { + id: params.cabinId, + }, + }, + endDate: { + gte: params.endAtGte, + }, + }, + }); + return { + ok: true, + data: { + bookings: bookings.map((booking) => new Booking(booking)), + }, + }; + } catch (err) { + return { + ok: false, + error: new InternalServerError( + "Failed to find bookings for cabin", + err, + ), + }; + } + } + async createCabin(params: { name: string; capacity: number; diff --git a/src/repositories/cabins/seed.ts b/src/repositories/cabins/seed.ts index 721e156b..1c3a11e9 100644 --- a/src/repositories/cabins/seed.ts +++ b/src/repositories/cabins/seed.ts @@ -119,6 +119,42 @@ const bookingCreateInput: Prisma.BookingCreateInput[] = [ }, }, }, + { + id: faker.string.uuid(), + startDate: DateTime.now().plus({ weeks: 1 }).toJSDate(), + endDate: DateTime.now().plus({ weeks: 2 }).toJSDate(), + status: BookingStatus.CONFIRMED, + email: faker.internet.exampleEmail(), + phoneNumber: faker.phone.number(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + internalParticipantsCount: 5, + externalParticipantsCount: 10, + totalCost: 1 * 250 + 5 * 200 + 1 * 2500 + 5 * 2000, + cabins: { + connect: { + id: oksenId, + }, + }, + }, + { + id: faker.string.uuid(), + startDate: DateTime.now().plus({ weeks: 3 }).toJSDate(), + endDate: DateTime.now().plus({ weeks: 4 }).toJSDate(), + status: BookingStatus.CONFIRMED, + email: faker.internet.exampleEmail(), + phoneNumber: faker.phone.number(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + internalParticipantsCount: 5, + externalParticipantsCount: 10, + totalCost: 1 * 250 + 5 * 200 + 1 * 2500 + 5 * 2000, + cabins: { + connect: { + id: bjornenId, + }, + }, + }, ]; const createBookingSemesterInput: Prisma.BookingSemesterCreateInput[] = [ diff --git a/src/services/cabins/__tests__/unit/get-occupied-dates.test.ts b/src/services/cabins/__tests__/unit/get-occupied-dates.test.ts new file mode 100644 index 00000000..9c308c26 --- /dev/null +++ b/src/services/cabins/__tests__/unit/get-occupied-dates.test.ts @@ -0,0 +1,60 @@ +import { faker } from "@faker-js/faker"; +import { type DeepMockProxy, mock, mockDeep } from "jest-mock-extended"; +import { range } from "lodash-es"; +import { DateTime } from "luxon"; +import type { BookingType } from "~/domain/cabins.js"; +import { makeMockContext } from "~/lib/context.js"; +import { CabinService, type ICabinRepository } from "../../service.js"; + +describe("CabinService", () => { + let cabinService: CabinService; + let mockCabinRepository: DeepMockProxy; + + beforeAll(() => { + mockCabinRepository = mockDeep(); + cabinService = new CabinService(mockCabinRepository, mock(), mock()); + }); + + describe("#getOccupiedDates", () => { + it("returns all occupied dates for a cabin", async () => { + const booking1 = { + ...mock(), + startDate: DateTime.now().plus({ days: 1 }).toJSDate(), + endDate: DateTime.now().plus({ days: 3 }).toJSDate(), + }; + + const booking2 = { + ...mock(), + startDate: DateTime.now().plus({ days: 5 }).toJSDate(), + endDate: DateTime.now().plus({ days: 7 }).toJSDate(), + }; + + mockCabinRepository.findManyBookings.mockResolvedValue({ + ok: true, + data: { + bookings: [booking1, booking2], + }, + }); + + const result = await cabinService.getOccupiedDates(makeMockContext(), { + cabinId: faker.string.uuid(), + }); + + if (!result.ok) throw result.error; + + const { days } = result.data; + + for (const day of range(1, 9)) { + const date = DateTime.now() + .plus({ days: day }) + .startOf("day") + .toJSDate(); + if (day === 4 || day === 8) { + expect(days).not.toContainEqual(date); + } else { + expect(days).toContainEqual(date); + } + } + }); + }); +}); diff --git a/src/services/cabins/service.ts b/src/services/cabins/service.ts index af9c9d7d..e0723613 100644 --- a/src/services/cabins/service.ts +++ b/src/services/cabins/service.ts @@ -7,7 +7,7 @@ import { Semester, } from "@prisma/client"; import { sumBy } from "lodash-es"; -import { DateTime } from "luxon"; +import { DateTime, Interval } from "luxon"; import { z } from "zod"; import { Booking, BookingStatus, type BookingType } from "~/domain/cabins.js"; import { @@ -74,6 +74,15 @@ export interface ICabinRepository { internalPriceWeekend: number; externalPriceWeekend: number; }): ResultAsync<{ cabin: Cabin }, InternalServerError>; + findManyBookings( + ctx: Context, + params: { cabinId: string; endAtGte?: Date; bookingStatus?: BookingStatus }, + ): ResultAsync< + { + bookings: BookingType[]; + }, + InternalServerError + >; } export interface PermissionService { @@ -95,6 +104,54 @@ export class CabinService implements ICabinService { private mailService: MailService, private permissionService: PermissionService, ) {} + async getOccupiedDates( + ctx: Context, + params: { cabinId: string }, + ): ResultAsync<{ days: Date[] }, NotFoundError | InternalServerError> { + const findManyBookingsResult = await this.cabinRepository.findManyBookings( + ctx, + { + cabinId: params.cabinId, + endAtGte: new Date(), + bookingStatus: "CONFIRMED", + }, + ); + + if (!findManyBookingsResult.ok) { + return findManyBookingsResult; + } + + const { bookings } = findManyBookingsResult.data; + + const intervals = bookings.map((booking) => { + const startAt = DateTime.fromJSDate(booking.startDate).startOf("day"); + const endAt = DateTime.fromJSDate(booking.endDate).startOf("day"); + const interval = Interval.fromDateTimes(startAt, endAt.plus({ days: 1 })); + return interval; + }); + + const mergedIntervals = Interval.merge(intervals); + + function isNotUndefined(date: Date | undefined): date is Date { + return date !== null; + } + const days = mergedIntervals + .flatMap((interval) => { + if (interval.isValid) { + const split = interval + .splitBy({ days: 1 }) + .map((interval) => interval.start?.toJSDate()); + return split; + } + return []; + }) + .filter(isNotUndefined); + + return { + ok: true, + data: { days }, + }; + } async createCabin( ctx: Context,