fix: disregarding already booked spots or blocked calendar times (#2029)

* fix: double booking

* fix: update double-booking error response code

* fix: update double-booking error response code

* test: add test

* fix: check availability before creating booking

* Update apps/web/playwright/booking-pages.test.ts

* Update yarn.lock

* Restored missing fix

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
Louis Haftmann 2022-03-07 18:55:24 +01:00 committed by GitHub
parent 322a845a17
commit b143498393
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 177 additions and 85 deletions

View file

@ -540,7 +540,9 @@ const BookingPage = (props: BookingPageProps) => {
</div> </div>
</Form> </Form>
{mutation.isError && ( {mutation.isError && (
<div className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4"> <div
data-testid="booking-fail"
className="mt-2 border-l-4 border-yellow-400 bg-yellow-50 p-4">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" /> <ExclamationIcon className="h-5 w-5 text-yellow-400" aria-hidden="true" />

View file

@ -21,6 +21,7 @@ import { getErrorFromUnknown } from "@lib/errors";
import { getEventName } from "@lib/event"; import { getEventName } from "@lib/event";
import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager"; import EventManager, { EventResult, PartialReference } from "@lib/events/EventManager";
import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager"; import { getBusyCalendarTimes } from "@lib/integrations/calendar/CalendarManager";
import { EventBusyDate } from "@lib/integrations/calendar/constants/types";
import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/interfaces/Calendar"; import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/interfaces/Calendar";
import { BufferedBusyTime } from "@lib/integrations/calendar/interfaces/Office365Calendar"; import { BufferedBusyTime } from "@lib/integrations/calendar/interfaces/Office365Calendar";
import logger from "@lib/logger"; import logger from "@lib/logger";
@ -408,27 +409,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}); });
} }
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
let booking: Booking | null = null;
try {
booking = await createBooking();
evt.uid = booking.uid;
} catch (_err) {
const err = getErrorFromUnknown(_err);
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
if (err.code === "P2002") {
res.status(409).json({ message: "booking.conflict" });
return;
}
res.status(500).end();
return;
}
let results: EventResult[] = []; let results: EventResult[] = [];
let referencesToCreate: PartialReference[] = []; let referencesToCreate: PartialReference[] = [];
let user: User | null = null; let user: User | null = null;
/** Let's start cheking for availability */ /** Let's start checking for availability */
for (const currentUser of users) { for (const currentUser of users) {
if (!currentUser) { if (!currentUser) {
console.error(`currentUser not found`); console.error(`currentUser not found`);
@ -443,16 +428,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}); });
const credentials = currentUser.credentials; const credentials = currentUser.credentials;
const calendarBusyTimes: EventBusyDate[] = await prisma.booking
.findMany({
where: {
userId: currentUser.id,
eventTypeId: eventTypeId,
},
})
.then((bookings) => bookings.map((booking) => ({ end: booking.endTime, start: booking.startTime })));
if (credentials) { if (credentials) {
const calendarBusyTimes = await getBusyCalendarTimes( await getBusyCalendarTimes(credentials, reqBody.start, reqBody.end, selectedCalendars).then(
credentials, (busyTimes) => calendarBusyTimes.push(...busyTimes)
reqBody.start,
reqBody.end,
selectedCalendars
); );
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty); const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
calendarBusyTimes.push(...videoBusyTimes); calendarBusyTimes.push(...videoBusyTimes);
}
console.log("calendarBusyTimes==>>>", calendarBusyTimes); console.log("calendarBusyTimes==>>>", calendarBusyTimes);
const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({ const bufferedBusyTimes: BufferedBusyTimes = calendarBusyTimes.map((a) => ({
@ -476,6 +469,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}; };
log.debug(`Booking ${currentUser.name} failed`, error); log.debug(`Booking ${currentUser.name} failed`, error);
res.status(409).json(error);
return;
} }
let timeOutOfBounds = false; let timeOutOfBounds = false;
@ -503,8 +498,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
log.debug(`Booking ${currentUser.name} failed`, error); log.debug(`Booking ${currentUser.name} failed`, error);
res.status(400).json(error); res.status(400).json(error);
return;
} }
} }
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
let booking: Booking | null = null;
try {
booking = await createBooking();
evt.uid = booking.uid;
} catch (_err) {
const err = getErrorFromUnknown(_err);
log.error(`Booking ${eventTypeId} failed`, "Error when saving booking to db", err.message);
if (err.code === "P2002") {
res.status(409).json({ message: "booking.conflict" });
return;
}
res.status(500).end();
return;
} }
if (!user) throw Error("Can't continue, user not found."); if (!user) throw Error("Can't continue, user not found.");

View file

@ -1,16 +1,80 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import prisma from "@lib/prisma";
import { todo } from "./lib/testUtils"; import { todo } from "./lib/testUtils";
const deleteBookingsByEmail = async (email: string) =>
prisma.booking.deleteMany({
where: {
user: {
email,
},
},
});
test.describe("free user", () => { test.describe("free user", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/free"); await page.goto("/free");
}); });
test.afterEach(async () => {
// delete test bookings
await deleteBookingsByEmail("free@example.com");
});
test("only one visible event", async ({ page }) => { test("only one visible event", async ({ page }) => {
await expect(page.locator(`[href="/free/30min"]`)).toBeVisible(); await expect(page.locator(`[href="/free/30min"]`)).toBeVisible();
await expect(page.locator(`[href="/free/60min"]`)).not.toBeVisible(); await expect(page.locator(`[href="/free/60min"]`)).not.toBeVisible();
}); });
test("cannot book same slot multiple times", async ({ page }) => {
// Click first event type
await page.click('[data-testid="event-type-link"]');
// Click [data-testid="incrementMonth"]
await page.click('[data-testid="incrementMonth"]');
// Click [data-testid="day"]
await page.click('[data-testid="day"][data-disabled="false"]');
// Click [data-testid="time"]
await page.click('[data-testid="time"]');
// Navigate to book page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/book");
},
});
// save booking url
const bookingUrl: string = page.url();
const bookTimeSlot = async () => {
// --- fill form
await page.fill('[name="name"]', "Test Testson");
await page.fill('[name="email"]', "test@example.com");
await page.press('[name="email"]', "Enter");
};
// book same time spot twice
await bookTimeSlot();
// Make sure we're navigated to the success page
await page.waitForNavigation({
url(url) {
return url.pathname.endsWith("/success");
},
});
// return to same time spot booking page
await page.goto(bookingUrl);
// book same time spot again
await bookTimeSlot();
// check for error message
await expect(page.locator("[data-testid=booking-fail]")).toBeVisible();
});
todo("`/free/30min` is bookable"); todo("`/free/30min` is bookable");
todo("`/free/60min` is not bookable"); todo("`/free/60min` is not bookable");
@ -21,6 +85,11 @@ test.describe("pro user", () => {
await page.goto("/pro"); await page.goto("/pro");
}); });
test.afterEach(async () => {
// delete test bookings
await deleteBookingsByEmail("pro@example.com");
});
test("pro user's page has at least 2 visible events", async ({ page }) => { test("pro user's page has at least 2 visible events", async ({ page }) => {
const $eventTypes = await page.$$("[data-testid=event-types] > *"); const $eventTypes = await page.$$("[data-testid=event-types] > *");
expect($eventTypes.length).toBeGreaterThanOrEqual(2); expect($eventTypes.length).toBeGreaterThanOrEqual(2);

View file

@ -1012,7 +1012,12 @@
resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.4.3.tgz#f77c6bb5cb4a614a5d730fb880cab502d48abf37" resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.4.3.tgz#f77c6bb5cb4a614a5d730fb880cab502d48abf37"
integrity sha512-n2IQkaaw0aAAlQS5MEXsM4uRK+w18CrM72EqnGRl/UBOQeQajad8oiKXR9Nk15jOzTFQjpxzrZMf1NxHidFBiw== integrity sha512-n2IQkaaw0aAAlQS5MEXsM4uRK+w18CrM72EqnGRl/UBOQeQajad8oiKXR9Nk15jOzTFQjpxzrZMf1NxHidFBiw==
"@heroicons/react@^1.0.4", "@heroicons/react@^1.0.5": "@heroicons/react@^1.0.4":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.6.tgz#35dd26987228b39ef2316db3b1245c42eb19e324"
integrity sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ==
"@heroicons/react@^1.0.5":
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.5.tgz#2fe4df9d33eb6ce6d5178a0f862e97b61c01e27d" resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.5.tgz#2fe4df9d33eb6ce6d5178a0f862e97b61c01e27d"
integrity sha512-UDMyLM2KavIu2vlWfMspapw9yii7aoLwzI2Hudx4fyoPwfKfxU8r3cL8dEBXOjcLG0/oOONZzbT14M1HoNtEcg== integrity sha512-UDMyLM2KavIu2vlWfMspapw9yii7aoLwzI2Hudx4fyoPwfKfxU8r3cL8dEBXOjcLG0/oOONZzbT14M1HoNtEcg==
@ -2590,9 +2595,9 @@
integrity sha512-fm8TR8r4LwbXgBIYdPmeMjJJkxxFC66tvoliNnmXOpUgZSgQKoNPW3ON0ZphZIiif1oqWNhAaSrr7tOvGu+AFg== integrity sha512-fm8TR8r4LwbXgBIYdPmeMjJJkxxFC66tvoliNnmXOpUgZSgQKoNPW3ON0ZphZIiif1oqWNhAaSrr7tOvGu+AFg==
"@stripe/stripe-js@^1.17.1": "@stripe/stripe-js@^1.17.1":
version "1.23.0" version "1.24.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.23.0.tgz#62eed14e83c63c3e8c27f14f6b1e6feb8496c867" resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.24.0.tgz#d23977f364565981f8ab30b1b540e367f72abc5c"
integrity sha512-+7w4rVs71Fk8/8uzyzQB5GotHSH9mjOjxM3EYDq/3MR3I2ewELHtvWVMOqfS/9WSKCaKv7h7eFLsMZGpK5jApQ== integrity sha512-8CEILOpzoRhGwvgcf6y+BlPyEq1ZqxAv3gsX7LvokFYvbcyH72GRcHQMGXuZS3s7HqfYQuTSFrvZNL/qdkgA9Q==
"@szmarczak/http-timer@^1.1.2": "@szmarczak/http-timer@^1.1.2":
version "1.1.2" version "1.1.2"
@ -5072,11 +5077,16 @@ dayjs-business-time@^1.0.4:
dependencies: dependencies:
dayjs "^1.10.4" dayjs "^1.10.4"
dayjs@^1.10.4, dayjs@^1.10.6, dayjs@^1.10.7: dayjs@^1.10.4, dayjs@^1.10.6:
version "1.10.7" version "1.10.7"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468"
integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig== integrity sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==
dayjs@^1.10.7:
version "1.10.8"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.8.tgz#267df4bc6276fcb33c04a6735287e3f429abec41"
integrity sha512-wbNwDfBHHur9UOzNUjeKUOJ0fCb0a52Wx0xInmQ7Y8FstyajiV1NmK1e00cxsr9YrE9r7yAChE0VvpuY5Rnlow==
debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
version "2.6.9" version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@ -11520,9 +11530,9 @@ postcss@8.4.5:
source-map-js "^1.0.1" source-map-js "^1.0.1"
postcss@^8.1.6, postcss@^8.3.5, postcss@^8.3.6: postcss@^8.1.6, postcss@^8.3.5, postcss@^8.3.6:
version "8.4.7" version "8.4.8"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.7.tgz#f99862069ec4541de386bf57f5660a6c7a0875a8" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.8.tgz#dad963a76e82c081a0657d3a2f3602ce10c2e032"
integrity sha512-L9Ye3r6hkkCeOETQX6iOaWZgjp3LL6Lpqm6EtgbKrgqGGteRMNb9vzBfRL96YOSu8o7x3MfIH9Mo5cPJFGrW6A== integrity sha512-2tXEqGxrjvAO6U+CJzDL2Fk2kPHTv1jQsYkSoMeOis2SsYaXRO2COxTdQp99cYvif9JTXaAk9lYGc3VhJt7JPQ==
dependencies: dependencies:
nanoid "^3.3.1" nanoid "^3.3.1"
picocolors "^1.0.0" picocolors "^1.0.0"
@ -15400,9 +15410,9 @@ zod@^3.8.2:
integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg== integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg==
zod@^3.9.5: zod@^3.9.5:
version "3.12.0" version "3.13.4"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.12.0.tgz#84ba9f6bdb7835e2483982d5f52cfffcb6a00346" resolved "https://registry.yarnpkg.com/zod/-/zod-3.13.4.tgz#5d6fe03ef4824a637d7ef50b5441cf6ab3acede0"
integrity sha512-w+mmntgEL4hDDL5NLFdN6Fq2DSzxfmlSoJqiYE1/CApO8EkOCxvJvRYEVf8Vr/lRs3i6gqoiyFM6KRcWqqdBzQ== integrity sha512-LZRucWt4j/ru5azOkJxCfpR87IyFDn8h2UODdqvXzZLb3K7bb9chUrUIGTy3BPsr8XnbQYfQ5Md5Hu2OYIo1mg==
zwitch@^1.0.0: zwitch@^1.0.0:
version "1.0.5" version "1.0.5"