feat: block pending meetings for requires confirmation (#16335)

* feat: block pending meetings for requires confirmation

* add i18n

* fix:typecheck

* fix(requires-confirmation-block): Fixes type error

* fix(requires-confirmation-block): Fixes type error

* Tweak to set BookingWhereInput as the explicit type instead of as

* fix: type error

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
sean-brydon
2024-08-26 09:54:58 -04:00
committed by GitHub
co-authored by Alex van Andel
parent 2fb4289798
commit c8e20b0c57
14 changed files with 213 additions and 62 deletions
@@ -279,6 +279,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
seatsEnabled={seatsEnabled}
metadata={formMethods.getValues("metadata")}
requiresConfirmation={requiresConfirmation}
requiresConfirmationWillBlockSlot={formMethods.getValues("requiresConfirmationWillBlockSlot")}
onRequiresConfirmation={setRequiresConfirmation}
/>
<Controller
@@ -12,11 +12,12 @@ import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Input, SettingsToggle, RadioField, Select } from "@calcom/ui";
import { Input, SettingsToggle, RadioField, Select, CheckboxField } from "@calcom/ui";
type RequiresConfirmationControllerProps = {
metadata: z.infer<typeof EventTypeMetaDataSchema>;
requiresConfirmation: boolean;
requiresConfirmationWillBlockSlot: boolean;
onRequiresConfirmation: Dispatch<SetStateAction<boolean>>;
seatsEnabled: boolean;
eventType: EventTypeSetup;
@@ -56,6 +57,8 @@ export default function RequiresConfirmationController({
opt.value === (metadata?.requiresConfirmationThreshold?.unit ?? defaultRequiresConfirmationSetup.unit)
);
const requiresConfirmationWillBlockSlot = formMethods.getValues("requiresConfirmationWillBlockSlot");
return (
<div className="block items-start sm:flex">
<div className="w-full">
@@ -80,6 +83,10 @@ export default function RequiresConfirmationController({
LockedIcon={requiresConfirmationLockedProps.LockedIcon}
onCheckedChange={(val) => {
formMethods.setValue("requiresConfirmation", val, { shouldDirty: true });
// If we uncheck requires confirmation, we also uncheck the "will block slot" checkbox
if (!val) {
formMethods.setValue("requiresConfirmationWillBlockSlot", false, { shouldDirty: true });
}
onRequiresConfirmation(val);
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
@@ -121,73 +128,86 @@ export default function RequiresConfirmationController({
)}
{(requiresConfirmationSetup !== undefined ||
!requiresConfirmationLockedProps.disabled) && (
<RadioField
disabled={requiresConfirmationLockedProps.disabled}
className="items-center"
label={
<>
<Trans
i18nKey="when_booked_with_less_than_notice"
defaults="When booked with less than <time></time> notice"
components={{
time: (
<div className="mx-2 inline-flex">
<Input
type="number"
min={1}
disabled={requiresConfirmationLockedProps.disabled}
onChange={(evt) => {
const val = Number(evt.target?.value);
setRequiresConfirmationSetup({
unit:
requiresConfirmationSetup?.unit ??
defaultRequiresConfirmationSetup.unit,
time: val,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.time",
val,
{ shouldDirty: true }
);
}}
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield] focus:z-10 focus:border-r"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
/>
<label
className={classNames(
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
)}>
<Select
inputId="notice"
options={options}
isSearchable={false}
isDisabled={requiresConfirmationLockedProps.disabled}
innerClassNames={{ control: "rounded-l-none bg-subtle" }}
onChange={(opt) => {
<>
<RadioField
disabled={requiresConfirmationLockedProps.disabled}
className="items-center"
label={
<>
<Trans
i18nKey="when_booked_with_less_than_notice"
defaults="When booked with less than <time></time> notice"
components={{
time: (
<div className="mx-2 inline-flex">
<Input
type="number"
min={1}
disabled={requiresConfirmationLockedProps.disabled}
onChange={(evt) => {
const val = Number(evt.target?.value);
setRequiresConfirmationSetup({
time:
requiresConfirmationSetup?.time ??
defaultRequiresConfirmationSetup.time,
unit: opt?.value as UnitTypeLongPlural,
unit:
requiresConfirmationSetup?.unit ??
defaultRequiresConfirmationSetup.unit,
time: val,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.unit",
opt?.value as UnitTypeLongPlural,
"metadata.requiresConfirmationThreshold.time",
val,
{ shouldDirty: true }
);
}}
defaultValue={defaultValue}
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield] focus:z-10 focus:border-r"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
/>
</label>
</div>
),
}}
/>
</>
}
id="notice"
value="notice"
/>
<label
className={classNames(
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
)}>
<Select
inputId="notice"
options={options}
isSearchable={false}
isDisabled={requiresConfirmationLockedProps.disabled}
innerClassNames={{ control: "rounded-l-none bg-subtle" }}
onChange={(opt) => {
setRequiresConfirmationSetup({
time:
requiresConfirmationSetup?.time ??
defaultRequiresConfirmationSetup.time,
unit: opt?.value as UnitTypeLongPlural,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.unit",
opt?.value as UnitTypeLongPlural,
{ shouldDirty: true }
);
}}
defaultValue={defaultValue}
/>
</label>
</div>
),
}}
/>
</>
}
id="notice"
value="notice"
/>
<CheckboxField
checked={requiresConfirmationWillBlockSlot}
descriptionAsLabel
description={t("requires_confirmation_will_block_slot_description")}
onChange={(e) => {
// We set should dirty to properly detect when we can submit the form
formMethods.setValue("requiresConfirmationWillBlockSlot", e.target.checked, {
shouldDirty: true,
});
}}
/>
</>
)}
</div>
</RadioGroup.Root>
@@ -301,6 +301,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
periodCountCalendarDays: eventType.periodCountCalendarDays ? true : false,
schedulingType: eventType.schedulingType,
requiresConfirmation: eventType.requiresConfirmation,
requiresConfirmationWillBlockSlot: eventType.requiresConfirmationWillBlockSlot,
slotInterval: eventType.slotInterval,
minimumBookingNotice: eventType.minimumBookingNotice,
metadata: eventType.metadata,
@@ -846,6 +846,7 @@
"disable_notes": "Hide notes in calendar",
"disable_notes_description": "For privacy reasons, additional inputs and notes will be hidden in the calendar entry. They will still be sent to your email.",
"requires_confirmation_description": "The booking needs to be manually confirmed before it is pushed to the integrations and a confirmation mail is sent.",
"requires_confirmation_will_block_slot_description": "Unconfirmed bookings still block calendar slots.",
"recurring_event": "Recurring Event",
"recurring_event_description": "People can subscribe for recurring events",
"cannot_be_used_with_paid_event_types": "It cannot be used with paid event types",
+93
View File
@@ -1141,6 +1141,99 @@ describe("getSchedule", () => {
}
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(23); // 2 booking per day as limit, only one booking on that
});
test("a slot counts as being busy when the eventType is requiresConfirmation and requiresConfirmationWillBlockSlot", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
await createBookingScenario({
eventTypes: [
// A default Event Type which this user owns
{
id: 2,
length: 15,
slotInterval: 45,
users: [{ id: 101 }],
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
},
{
id: 3,
length: 15,
slotInterval: 45,
users: [{ id: 101 }],
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: false,
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [TestData.schedules.IstWorkHours],
},
],
bookings: [
{
userId: 101,
attendees: [
{
email: "IntegrationTestUser102@example.com",
},
],
eventTypeId: 2,
status: "PENDING",
startTime: `${plus2DateString}T04:00:00.000Z`,
endTime: `${plus2DateString}T04:15:00.000Z`,
},
{
userId: 101,
attendees: [
{
email: "IntegrationTestUser103@example.com",
},
],
eventTypeId: 3,
status: "PENDING",
startTime: `${plus2DateString}T05:00:00.000Z`,
endTime: `${plus2DateString}T05:15:00.000Z`,
},
],
});
// Requesting this user's availability for their
// individual Event Type
const thisUserAvailability = await getSchedule({
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
},
});
expect(thisUserAvailability).toHaveTimeSlots(
[
// `04:00:00.000Z`, // <- This slot should be occupied by the Pending Requires Confirmation booking blocking this slot
`04:15:00.000Z`,
`05:00:00.000Z`, // <- This slot should be availble to book as this event type (id: 3) has requires confirmation without blocking the slo
`05:45:00.000Z`,
`06:30:00.000Z`,
`07:15:00.000Z`,
`08:00:00.000Z`,
`08:45:00.000Z`,
`09:30:00.000Z`,
`10:15:00.000Z`,
`11:00:00.000Z`,
`11:45:00.000Z`,
],
{
dateString: plus2DateString,
}
);
});
});
describe("Team Event", () => {
+14 -1
View File
@@ -94,13 +94,14 @@ export async function getBusyTimes(params: {
const endTimeAdjustedWithMaxBuffer = dayjs(endTimeDate).add(maxBuffer, "minute").toDate();
// startTime is less than endTimeDate and endTime grater than startTimeDate
const sharedQuery = {
const sharedQuery: Prisma.BookingWhereInput = {
startTime: { lte: endTimeAdjustedWithMaxBuffer },
endTime: { gte: startTimeAdjustedWithMaxBuffer },
status: {
in: [BookingStatus.ACCEPTED],
},
};
// INFO: Refactored to allow this method to take in a list of current bookings for the user.
// Will keep support for retrieving a user's bookings if the caller does not already supply them.
// This function is called from multiple places but we aren't refactoring all of them at this moment
@@ -124,6 +125,18 @@ export async function getBusyTimes(params: {
},
},
},
{
startTime: { lte: endTimeDate },
endTime: { gte: startTimeDate },
eventType: {
id: eventTypeId,
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
},
status: {
in: [BookingStatus.PENDING],
},
},
],
},
select: {
@@ -48,6 +48,7 @@ export type FormValues = {
disableGuests: boolean;
lockTimeZoneToggleOnBookingPage: boolean;
requiresConfirmation: boolean;
requiresConfirmationWillBlockSlot: boolean;
requiresBookerEmailVerification: boolean;
recurringEvent: RecurringEvent | null;
schedulingType: SchedulingType | null;
+1
View File
@@ -12,6 +12,7 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
length: true,
title: true,
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
position: true,
offsetStart: true,
profileId: true,
@@ -443,6 +443,7 @@ export class EventTypeRepository {
periodCountCalendarDays: true,
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
requiresBookerEmailVerification: true,
recurringEvent: true,
hideCalendarNotes: true,
+1
View File
@@ -104,6 +104,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
recurringEvent: null,
lockTimeZoneToggleOnBookingPage: false,
requiresConfirmation: false,
requiresConfirmationWillBlockSlot: false,
disableGuests: false,
hideCalendarNotes: false,
minimumBookingNotice: 120,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "requiresConfirmationWillBlockSlot" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -104,6 +104,7 @@ model EventType {
periodCountCalendarDays Boolean?
lockTimeZoneToggleOnBookingPage Boolean @default(false)
requiresConfirmation Boolean @default(false)
requiresConfirmationWillBlockSlot Boolean @default(false)
requiresBookerEmailVerification Boolean @default(false)
/// @zod.custom(imports.recurringEventType)
recurringEvent Json?
+1
View File
@@ -643,6 +643,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
customInputs: true,
disableGuests: true,
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
eventName: true,
metadata: true,
children: true,
@@ -527,6 +527,18 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
},
},
},
{
startTime: { lte: endTimeDate },
endTime: { gte: startTimeDate },
eventType: {
id: eventType.id,
requiresConfirmation: true,
requiresConfirmationWillBlockSlot: true,
},
status: {
in: [BookingStatus.PENDING],
},
},
],
},
select: {
@@ -544,6 +556,8 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
afterEventBuffer: true,
beforeEventBuffer: true,
seatsPerTimeSlot: true,
requiresConfirmationWillBlockSlot: true,
requiresConfirmation: true,
},
},
...(!!eventType?.seatsPerTimeSlot && {