fix: all booking and duration limits#10480
Conversation
|
The latest updates on your projects. Learn more about Vercel for Git ↗︎
1 Ignored Deployment
|
|
@nicktrn is attempting to deploy a commit to the cal Team on Vercel. A member of the Team first needs to authorize it. |
|
Thank you for following the naming conventions! 🙏 |
📦 Next.js Bundle Analysis for @calcom/webThis analysis was generated by the Next.js Bundle Analysis action. 🤖 This PR introduced no changes to the JavaScript bundle! 🙌 |
nicktrn
left a comment
There was a problem hiding this comment.
Ready for review and suggestions!
| label: `Per ${key.split("_")[1].toLocaleLowerCase()}`, | ||
| label: `Per ${intervalLimitKeyToUnit(key)}`, |
There was a problem hiding this comment.
this was used in a lot of different places, so extracted to lib
There was a problem hiding this comment.
Can we avoid transformation here and just use plain array with ["day", "week", "month", "year"]? Or there is a reason for this?
There was a problem hiding this comment.
Sure, just thought it might be easier to extend in the future, e. g. adding "quarter". Looking back should probably localise those strings.
There was a problem hiding this comment.
We should be good with plains on low number options.
| intervalOrderKeys.indexOf(limitKeyA as IntervalLimitsKey) - | ||
| intervalOrderKeys.indexOf(limitKeyB as IntervalLimitsKey) | ||
| ascendingLimitKeys.indexOf(limitKeyA as IntervalLimitsKey) - | ||
| ascendingLimitKeys.indexOf(limitKeyB as IntervalLimitsKey) |
| export async function getBusyTimesForLimitChecks(params: { | ||
| userId: number; | ||
| eventTypeId: number; | ||
| startDate: Date; | ||
| endDate: Date; | ||
| }) { |
There was a problem hiding this comment.
This allows us to only fetch the absolute minimum info required to check limits
| const bookings = await prisma.booking.findMany({ | ||
| where: { | ||
| userId, | ||
| eventTypeId, | ||
| status: BookingStatus.ACCEPTED, | ||
| // FIXME: bookings that overlap on one side will never be counted | ||
| startTime: { | ||
| gte: startDate, | ||
| }, | ||
| endTime: { | ||
| lte: endDate, | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Should probably get all bookings that start in that period, not also end. Need some input here.
There was a problem hiding this comment.
True, an event end time typically does not matter unless it's greater than startDate - this probably makes the most sense.
| const bookings = await prisma.booking.findMany({ | |
| where: { | |
| userId, | |
| eventTypeId, | |
| status: BookingStatus.ACCEPTED, | |
| // FIXME: bookings that overlap on one side will never be counted | |
| startTime: { | |
| gte: startDate, | |
| }, | |
| endTime: { | |
| lte: endDate, | |
| }, | |
| }, | |
| const bookings = await prisma.booking.findMany({ | |
| where: { | |
| userId, | |
| eventTypeId, | |
| status: BookingStatus.ACCEPTED, | |
| // FIXME: bookings that overlap on one side will never be counted | |
| startTime: { | |
| gte: startDate, | |
| }, | |
| endTime: { | |
| gte: startDate, | |
| }, | |
| }, |
| // needed to correctly apply limits (weeks can be part of two months) | ||
| startTime: dateFrom.startOf("week").toISOString(), | ||
| endTime: dateTo.endOf("week").toISOString(), | ||
| startTime: getBusyTimesStart, | ||
| endTime: getBusyTimesEnd, |
There was a problem hiding this comment.
No longer required as we get the booking info to apply limits separately now
| // convoluted but typesafe | ||
| const limitCalculations = ascendingLimitKeys | ||
| .filter((key) => parsedBookingLimits[key]) | ||
| .map((key) => | ||
| checkBookingLimit({ key, limitingNumber: parsedBookingLimits[key], eventStartDate, eventId }) | ||
| ); |
There was a problem hiding this comment.
Correct types were previously not preserved and there was an issue further down the chain with this. The entries() helper would have prevented using a simple map function here.
| }, | ||
| }, | ||
| }); | ||
| if (bookingsInPeriod >= limitingNumber) { |
There was a problem hiding this comment.
This used to hide that limitingNumber could sometimes be undefined, as greater and lesser than always evaluate to false in that case
| const bookingsInPeriod = await prisma.booking.count({ | ||
| where: { | ||
| status: "ACCEPTED", | ||
| eventType: { | ||
| AND: [ | ||
| { | ||
| id: eventId, | ||
| bookings: { | ||
| every: { | ||
| startTime: { | ||
| gte: startDate, | ||
| }, | ||
| endTime: { | ||
| lte: endDate, | ||
| }, |
There was a problem hiding this comment.
This is a strange recursion that would not count bookings if not ALL bookings for that event were in the same period
| key: keyof IntervalLimit; | ||
| limitingNumber: number | undefined; |
| // FIXME: bookings that overlap on one side will never be counted | ||
| const [totalBookingTime] = (await prisma.$queryRaw` | ||
| SELECT SUM(EXTRACT(EPOCH FROM ("endTime" - "startTime")) / 60) as "totalMinutes" | ||
| FROM "Booking" | ||
| WHERE "status" = 'accepted' | ||
| AND "id" = ${eventId} | ||
| AND "eventTypeId" = ${eventId} | ||
| AND "startTime" >= ${startDate} | ||
| AND "endTime" <= ${endDate}; |
There was a problem hiding this comment.
Using the wrong key caused this to return 0 most of the time, and a random booking duration at best. Need some input here re counting overlapping bookings, e.g. starts in one week and ends in the next. Currently, those bookings are never considered when checking weekly limits. Same naturally applies to other periods.
511fa48 to
24fee7b
Compare
24fee7b to
9bd9917
Compare
nicktrn
left a comment
There was a problem hiding this comment.
Added some more comments for the recent push
| if (returnBusyTimes) { | ||
| return { | ||
| start: startDate, | ||
| end: endDate, | ||
| }; |
There was a problem hiding this comment.
now unused, caller already has this
|
|
||
| const limitCalculations = Object.entries(parsedDurationLimits).map( | ||
| async ([key, limitingNumber]) => | ||
| await checkDurationLimit({ key, limitingNumber, eventStartDate, eventId }) | ||
| // not iterating entries to preserve types | ||
| const limitCalculations = ascendingLimitKeys.map((key) => | ||
| checkDurationLimit({ key, limitingNumber: parsedDurationLimits[key], eventStartDate, eventId }) | ||
| ); |
There was a problem hiding this comment.
not preserving types caused a hidden undefined comparison in the callee
| const [totalBookingTime] = (await prisma.$queryRaw` | ||
| // FIXME: bookings that overlap on one side will never be counted | ||
| const [totalBookingTime] = await prisma.$queryRaw<[{ totalMinutes: number | null }]>` |
There was a problem hiding this comment.
previous type assertion missed this could be null which caused issues when comparing to numbers
| export interface IntervalLimit { | ||
| PER_DAY?: number | undefined; | ||
| PER_WEEK?: number | undefined; | ||
| PER_MONTH?: number | undefined; | ||
| PER_YEAR?: number | undefined; | ||
| } | ||
| export type IntervalLimitUnit = "day" | "week" | "month" | "year"; | ||
|
|
||
| export type IntervalLimit = Partial<Record<`PER_${Uppercase<IntervalLimitUnit>}`, number | undefined>>; |
There was a problem hiding this comment.
abstracted limit units as they were repeatedly asserted in many different places
|
@emrysal @CarinaWolli hope it's okay to ping you here as this builds on #10435 and probably easier to review together. E2E tests are WIP #10518 Quick recap:
|
| }) | ||
| ).rejects.toThrowError(); | ||
| }); | ||
| it("Should return busyTimes when set", async () => { |
There was a problem hiding this comment.
no need to return busyTimes anymore after refactor
| }) | ||
| ).resolves.toBeUndefined(); | ||
| }); | ||
| it("Should return busyTimes when set and limit is reached", async () => { |
There was a problem hiding this comment.
no need to return busyTimes anymore after refactor
| bookingLimits: IntervalLimit, | ||
| eventStartDate: Date, | ||
| eventId: number, | ||
| returnBusyTimes?: boolean |
| if (parsedBookingLimits) { | ||
| const limitCalculations = Object.entries(parsedBookingLimits).map( | ||
| async ([key, limitingNumber]) => | ||
| await checkBookingLimit({ key, limitingNumber, eventStartDate, eventId, returnBusyTimes }) | ||
| ); | ||
| await Promise.all(limitCalculations) | ||
| .then((res) => { | ||
| if (returnBusyTimes) { | ||
| return res; | ||
| } | ||
| }) | ||
| .catch((error) => { | ||
| throw new HttpError({ message: error.message, statusCode: 401 }); | ||
| }); | ||
| return true; | ||
| if (!parsedBookingLimits) return false; | ||
|
|
||
| // not iterating entries to preserve types | ||
| const limitCalculations = ascendingLimitKeys.map((key) => | ||
| checkBookingLimit({ key, limitingNumber: parsedBookingLimits[key], eventStartDate, eventId }) | ||
| ); | ||
|
|
||
| try { | ||
| return !!(await Promise.all(limitCalculations)); | ||
| } catch (error) { | ||
| throw new HttpError({ message: getErrorFromUnknown(error).message, statusCode: 401 }); | ||
| } | ||
| return false; |
There was a problem hiding this comment.
refactor, same behaviour except removal of busyTimes, added type safety
| key: string; | ||
| limitingNumber: number; | ||
| returnBusyTimes?: boolean; | ||
| key: keyof IntervalLimit; | ||
| limitingNumber: number | undefined; |
| // This is used when getting availability | ||
| if (returnBusyTimes) { | ||
| return { | ||
| start: startDate, | ||
| end: endDate, | ||
| }; | ||
| } |
There was a problem hiding this comment.
Now unused, as we already have that data when checking availability for specific periods
| FROM "Booking" | ||
| WHERE "status" = 'accepted' | ||
| AND "id" = ${eventId} | ||
| AND "eventTypeId" = ${eventId} |
There was a problem hiding this comment.
mixup between booking and event id, caused booking limits to not be enforced here and lead to unnecessary calls to getUserAvailability where those limits were checked again
|
This is what happens when the surgical approach fails. Apologies if the gutting was too extensive. Feel free to keep the knife if you find one in there! |
zomars
left a comment
There was a problem hiding this comment.
Loving the refactor so far. My only concern are the possible git conflicts for other PRs. Not a blocker on my side. Thank you for your contribution 🙏
|
Wow, that was quick! I was getting concerned with merges too, glad it was spotted. |
| id: true, | ||
| startTime: true, | ||
| endTime: true, | ||
| eventType: { |
There was a problem hiding this comment.
I believe what you can do as well is eventTypeId: true - for future reference
|
|
||
| /** This should be called getUsersWorkingHoursAndBusySlots (...and remaining seats, and final timezone) */ | ||
| export async function getUserAvailability( | ||
| export const getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseAndEverythingElse( |
| const busyTimes = await getBusyTimes({ | ||
| credentials: user.credentials, | ||
| // needed to correctly apply limits (weeks can be part of two months) | ||
| startTime: dateFrom.startOf("week").toISOString(), |
There was a problem hiding this comment.
🙌 - disliked this very much
| try { | ||
| return !!(await Promise.all(limitCalculations)); | ||
| } catch (error) { | ||
| throw new HttpError({ message: getErrorFromUnknown(error).message, statusCode: 401 }); |
|
Amazing work done here! 👍 |
|
Thanks to everyone for the thorough review, this really helps! |
|
/tip 50 thank you for this awesome PR! |
|
@nicktrn: You just got a $50 tip! 👉 Complete your Algora onboarding to collect your payment. |
|
🎉🎈 @nicktrn has been awarded $50! 🎈🎊 |
What does this PR do?
Fixes #10361 🙏
This builds on #10435 to also fix the following issues:
Further performance improvements are possible if required. The groundwork is already there.
Current unit and e2e tests are happy so hopefully I didn't break anything. New e2e tests in #10518
Type of change
How should this be tested?
Yearly Duration Limits
Yearly Booking Limits
1Multiple Years
Can currently not be tested as there seems to be a separate bug hiding these slots from view. Set availability to 24h for all days, then check start/end of month (or year). When TZ differs, the slots that cross into the host's next month (or year) will not be shown.
Misc
All duration and booking limits should be tested extensively. None of them were working that well beforehand. New E2E tests just for limits in #10518
Mandatory Tasks
Checklist
- I haven't added tests that prove my fix is effective or that my feature works