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:
co-authored by
Alex van Andel
parent
2fb4289798
commit
c8e20b0c57
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "requiresConfirmationWillBlockSlot" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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?
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
Reference in New Issue
Block a user