Skip to content

fix: all booking and duration limits#10480

Merged
zomars merged 13 commits intocalcom:mainfrom
nicktrn:fix-availability-limits
Aug 9, 2023
Merged

fix: all booking and duration limits#10480
zomars merged 13 commits intocalcom:mainfrom
nicktrn:fix-availability-limits

Conversation

@nicktrn
Copy link
Copy Markdown
Contributor

@nicktrn nicktrn commented Jul 31, 2023

What does this PR do?

Fixes #10361 🙏

This builds on #10435 to also fix the following issues:

  • yearly duration limits
  • yearly booking limits if event booked in multiple years
  • yearly limits if the checked date range is in multiple years (31 Dec or 1 Jan in view with diff TZ)
  • performance: re-checking of known busy periods
  • some unexpected type 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

  • Bug fix (non-breaking change which fixes an issue)
  • Chore (refactoring code, technical debt, workflow improvements)

How should this be tested?

Yearly Duration Limits

  1. Set yearly duration limit
  2. Make some bookings
  3. Issue: year NOT busy
  4. Fix: year busy

Yearly Booking Limits

  1. Set yearly booking limit to 1
  2. Make one booking this year
  3. See that the whole year is now busy
  4. Make one booking next year
  5. Issue: BOTH years are now available for bookings
  6. Fix: both years busy

Multiple 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

  • Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.

Checklist

- I haven't added tests that prove my fix is effective or that my feature works

@vercel
Copy link
Copy Markdown

vercel Bot commented Jul 31, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
ui ✅ Ready (Inspect) Visit Preview 💬 Add feedback Aug 9, 2023 10:49pm
1 Ignored Deployment
Name Status Preview Comments Updated (UTC)
cal ⬜️ Ignored (Inspect) Visit Preview Aug 9, 2023 10:49pm

@vercel
Copy link
Copy Markdown

vercel Bot commented Jul 31, 2023

@nicktrn is attempting to deploy a commit to the cal Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added bookings area: bookings, availability, timezones, double booking High priority Created by Linear-GitHub Sync linear Sync Github Issue from community members to Linear.app 🐛 bug Something isn't working labels Jul 31, 2023
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jul 31, 2023

Thank you for following the naming conventions! 🙏

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jul 31, 2023

📦 Next.js Bundle Analysis for @calcom/web

This analysis was generated by the Next.js Bundle Analysis action. 🤖

This PR introduced no changes to the JavaScript bundle! 🙌

Copy link
Copy Markdown
Contributor Author

@nicktrn nicktrn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ready for review and suggestions!

Comment on lines -484 to +483
label: `Per ${key.split("_")[1].toLocaleLowerCase()}`,
label: `Per ${intervalLimitKeyToUnit(key)}`,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was used in a lot of different places, so extracted to lib

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid transformation here and just use plain array with ["day", "week", "month", "year"]? Or there is a reason for this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, just thought it might be easier to extend in the future, e. g. adding "quarter". Looking back should probably localise those strings.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so was this

Comment on lines +213 to +218
export async function getBusyTimesForLimitChecks(params: {
userId: number;
eventTypeId: number;
startDate: Date;
endDate: Date;
}) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows us to only fetch the absolute minimum info required to check limits

Comment on lines +229 to +241
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,
},
},
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably get all bookings that start in that period, not also end. Need some input here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, an event end time typically does not matter unless it's greater than startDate - this probably makes the most sense.

Suggested change
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,
},
},

Comment on lines -169 to +196
// 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,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer required as we get the booking info to apply limits separately now

Comment on lines 20 to 25
// convoluted but typesafe
const limitCalculations = ascendingLimitKeys
.filter((key) => parsedBookingLimits[key])
.map((key) =>
checkBookingLimit({ key, limitingNumber: parsedBookingLimits[key], eventStartDate, eventId })
);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to hide that limitingNumber could sometimes be undefined, as greater and lesser than always evaluate to false in that case

Comment on lines 57 to -71
const bookingsInPeriod = await prisma.booking.count({
where: {
status: "ACCEPTED",
eventType: {
AND: [
{
id: eventId,
bookings: {
every: {
startTime: {
gte: startDate,
},
endTime: {
lte: endDate,
},
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a strange recursion that would not count bookings if not ALL bookings for that event were in the same period

Comment on lines +41 to +39
key: keyof IntervalLimit;
limitingNumber: number | undefined;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type safety

Comment on lines +13 to 20
// 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};
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

@nicktrn nicktrn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added some more comments for the recent push

Comment on lines -52 to -56
if (returnBusyTimes) {
return {
start: startDate,
end: endDate,
};
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now unused, caller already has this

Comment on lines 18 to 21

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 })
);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not preserving types caused a hidden undefined comparison in the callee

Comment on lines -13 to +14
const [totalBookingTime] = (await prisma.$queryRaw`
// FIXME: bookings that overlap on one side will never be counted
const [totalBookingTime] = await prisma.$queryRaw<[{ totalMinutes: number | null }]>`
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

previous type assertion missed this could be null which caused issues when comparing to numbers

Comment on lines -123 to +125
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>>;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

abstracted limit units as they were repeatedly asserted in many different places

@nicktrn
Copy link
Copy Markdown
Contributor Author

nicktrn commented Aug 2, 2023

@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:

  • weekly limit issues were fixed by extending the date range of bookings we fetch fix: weekly limits #10404
  • monthly limit issues were pointed out for dates in the past, but simply extending the date range further would have been quite bad for performance
  • looked into yearly limits and found more issues

})
).rejects.toThrowError();
});
it("Should return busyTimes when set", async () => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to return busyTimes anymore after refactor

})
).resolves.toBeUndefined();
});
it("Should return busyTimes when set and limit is reached", async () => {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to return busyTimes anymore after refactor

bookingLimits: IntervalLimit,
eventStartDate: Date,
eventId: number,
returnBusyTimes?: boolean
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed anymore

Comment on lines -15 to -31
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;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refactor, same behaviour except removal of busyTimes, added type safety

Comment on lines -43 to +40
key: string;
limitingNumber: number;
returnBusyTimes?: boolean;
key: keyof IntervalLimit;
limitingNumber: number | undefined;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type safety

Comment on lines -80 to -86
// This is used when getting availability
if (returnBusyTimes) {
return {
start: startDate,
end: endDate,
};
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now unused, as we already have that data when checking availability for specific periods

Comment on lines 16 to +18
FROM "Booking"
WHERE "status" = 'accepted'
AND "id" = ${eventId}
AND "eventTypeId" = ${eventId}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

@zomars zomars left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Impressive work so far @nicktrn 🙏🏽 I love myself a good refactor. Will test locally and get back to you.

@nicktrn
Copy link
Copy Markdown
Contributor Author

nicktrn commented Aug 9, 2023

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!

Copy link
Copy Markdown
Contributor

@zomars zomars left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 🙏

@zomars zomars merged commit 37ce886 into calcom:main Aug 9, 2023
@zomars zomars mentioned this pull request Aug 9, 2023
2 tasks
@nicktrn
Copy link
Copy Markdown
Contributor Author

nicktrn commented Aug 9, 2023

Wow, that was quick! I was getting concerned with merges too, glad it was spotted.

id: true,
startTime: true,
endTime: true,
eventType: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

welp this got merged 😆

const busyTimes = await getBusyTimes({
credentials: user.credentials,
// needed to correctly apply limits (weeks can be part of two months)
startTime: dateFrom.startOf("week").toISOString(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙌 - disliked this very much

try {
return !!(await Promise.all(limitCalculations));
} catch (error) {
throw new HttpError({ message: getErrorFromUnknown(error).message, statusCode: 401 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

401 Unauthorized?

@emrysal
Copy link
Copy Markdown
Contributor

emrysal commented Aug 9, 2023

Amazing work done here! 👍

@nicktrn
Copy link
Copy Markdown
Contributor Author

nicktrn commented Aug 9, 2023

Thanks to everyone for the thorough review, this really helps!

@PeerRich
Copy link
Copy Markdown
Member

/tip 50 thank you for this awesome PR!

@algora-pbc
Copy link
Copy Markdown

algora-pbc Bot commented Aug 10, 2023

@nicktrn: You just got a $50 tip! 👉 Complete your Algora onboarding to collect your payment.

@algora-pbc
Copy link
Copy Markdown

algora-pbc Bot commented Aug 11, 2023

🎉🎈 @nicktrn has been awarded $50! 🎈🎊

@algora-pbc algora-pbc Bot added the 💰 Rewarded Rewarded bounties on Algora.io label Aug 11, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bookings area: bookings, availability, timezones, double booking 🐛 bug Something isn't working High priority Created by Linear-GitHub Sync linear Sync Github Issue from community members to Linear.app 💰 Rewarded Rewarded bounties on Algora.io

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[CAL-2237] "Limit booking frequency" setting doesn't work

5 participants