Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: availability in instant meeting #16424

Merged
merged 15 commits into from
Sep 3, 2024
Merged
1 change: 1 addition & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2201,6 +2201,7 @@
"member_removed": "Member removed",
"my_availability": "My Availability",
"team_availability": "Team Availability",
"instant_meeting_availability": "Instant meeting availability",
"backup_code": "Backup Code",
"backup_codes": "Backup Codes",
"backup_code_instructions": "Each backup code can be used exactly once to grant access without your authenticator.",
Expand Down
6 changes: 6 additions & 0 deletions apps/web/test/lib/handleChildrenEventTypes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ describe("handleChildrenEventTypes", () => {
users: { connect: [{ id: 4 }] },
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
instantMeetingScheduleId: undefined,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
Expand Down Expand Up @@ -199,6 +200,7 @@ describe("handleChildrenEventTypes", () => {
scheduleId: null,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
instantMeetingScheduleId: undefined,
hashedLink: { create: { link: expect.any(String) } },
},
where: {
Expand Down Expand Up @@ -304,6 +306,7 @@ describe("handleChildrenEventTypes", () => {
recurringEvent: undefined,
eventTypeColor: undefined,
hashedLink: undefined,
instantMeetingScheduleId: undefined,
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
userId: 4,
Expand Down Expand Up @@ -357,6 +360,7 @@ describe("handleChildrenEventTypes", () => {
locations: [],
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
instantMeetingScheduleId: undefined,
},
where: {
userId_parentId: {
Expand Down Expand Up @@ -421,6 +425,7 @@ describe("handleChildrenEventTypes", () => {
recurringEvent: undefined,
eventTypeColor: undefined,
hashedLink: undefined,
instantMeetingScheduleId: undefined,
locations: [],
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
Expand All @@ -447,6 +452,7 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage: false,
requiresBookerEmailVerification: false,
hashedLink: undefined,
instantMeetingScheduleId: undefined,
},
where: {
userId_parentId: {
Expand Down
2 changes: 1 addition & 1 deletion packages/features/bookings/Booker/Booker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ const BookerComponent = ({
}}
/>

{bookerState !== "booking" && event.data?.isInstantEvent && (
{bookerState !== "booking" && event.data?.showInstantEventConnectNowModal && (
Copy link
Contributor Author

Choose a reason for hiding this comment

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

only display it when available

<div
className={classNames(
"animate-fade-in-up z-40 my-2 opacity-0",
Expand Down
2 changes: 1 addition & 1 deletion packages/features/bookings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export type BookerEvent = Pick<
| "bookingFields"
| "seatsShowAvailabilityCount"
| "isInstantEvent"
> & { users: BookerEventUser[] } & { profile: BookerEventProfile };
> & { users: BookerEventUser[]; showInstantEventConnectNowModal: boolean } & { profile: BookerEventProfile };

export type ValidationErrors<T extends object> = { key: FieldPath<T>; error: ErrorOption }[];

Expand Down
1 change: 1 addition & 0 deletions packages/features/eventtypes/components/EventType.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export const EventType = (props: EventTypeSetupProps & { allActiveWorkflows?: Wo
instantMeetingExpiryTimeOffsetInSeconds: eventType.instantMeetingExpiryTimeOffsetInSeconds,
description: eventType.description ?? undefined,
schedule: eventType.schedule || undefined,
instantMeetingSchedule: eventType.instantMeetingSchedule || undefined,
bookingLimits: eventType.bookingLimits || undefined,
onlyShowFirstAvailableSlot: eventType.onlyShowFirstAvailableSlot || undefined,
durationLimits: eventType.durationLimits || undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import type { Webhook } from "@prisma/client";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { useFormContext, Controller } from "react-hook-form";
import { components } from "react-select";
import type { OptionProps, SingleValueProps } from "react-select";

import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { EventTypeSetup, FormValues } from "@calcom/features/eventtypes/lib/types";
import type { EventTypeSetup, FormValues, AvailabilityOption } from "@calcom/features/eventtypes/lib/types";
import { WebhookForm } from "@calcom/features/webhooks/components";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
Expand All @@ -24,6 +26,8 @@ import {
showToast,
TextField,
Label,
Select,
Badge,
} from "@calcom/ui";

type InstantEventControllerProps = {
Expand All @@ -32,6 +36,36 @@ type InstantEventControllerProps = {
isTeamEvent: boolean;
};

const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
const { label, isDefault } = props.data;
const { t } = useLocale();
return (
<components.Option {...props}>
<span>{label}</span>
{isDefault && (
<Badge variant="blue" className="ml-2">
{t("default")}
</Badge>
)}
</components.Option>
);
};

const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
const { label, isDefault } = props.data;
const { t } = useLocale();
return (
<components.SingleValue {...props}>
<span>{label}</span>
{isDefault && (
<Badge variant="blue" className="ml-2">
{t("default")}
</Badge>
)}
</components.SingleValue>
);
};

export default function InstantEventController({
eventType,
paymentEnabled,
Expand All @@ -42,13 +76,28 @@ export default function InstantEventController({
const [instantEventState, setInstantEventState] = useState<boolean>(eventType?.isInstantEvent ?? false);
const formMethods = useFormContext<FormValues>();

const { shouldLockDisableProps } = useLockedFieldsManager({ eventType, translate: t, formMethods });
const { shouldLockDisableProps } = useLockedFieldsManager({
eventType,
translate: t,
formMethods,
});

const instantLocked = shouldLockDisableProps("isInstantEvent");

const isOrg = !!session.data?.user?.org?.id;

if (session.status === "loading") return <></>;
const { data, isPending } = trpc.viewer.availability.list.useQuery(undefined);

if (session.status === "loading" || isPending || !data) return <></>;

const schedules = data.schedules;

const options = schedules.map((schedule) => ({
value: schedule.id,
label: schedule.name,
isDefault: schedule.isDefault,
isManaged: false,
}));

return (
<LicenseRequired>
Expand Down Expand Up @@ -96,6 +145,32 @@ export default function InstantEventController({
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{instantEventState && (
<div className="flex flex-col gap-2">
<Controller
name="instantMeetingSchedule"
render={({ field: { onChange, value } }) => {
const optionValue: AvailabilityOption | undefined = options.find(
(option) => option.value === value
);
return (
<>
<Label>{t("instant_meeting_availability")}</Label>
<Select
placeholder={t("select")}
options={options}
isDisabled={shouldLockDisableProps("instantMeetingSchedule").disabled}
isSearchable={false}
onChange={(selected) => {
if (selected) onChange(selected.value);
}}
className="mb-4 block w-full min-w-0 flex-1 rounded-sm text-sm"
value={optionValue}
components={{ Option, SingleValue }}
isMulti={false}
/>
</>
);
}}
/>
<Controller
name="instantMeetingExpiryTimeOffsetInSeconds"
render={({ field: { value, onChange } }) => (
Expand Down
90 changes: 90 additions & 0 deletions packages/features/eventtypes/lib/getPublicEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client";
import type { LocationObject } from "@calcom/app-store/locations";
import { privacyFilteredLocations } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { isRecurringEvent, parseRecurringEvent } from "@calcom/lib";
Expand Down Expand Up @@ -120,11 +121,84 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
timeZone: true,
},
},
instantMeetingSchedule: {
select: {
id: true,
timeZone: true,
},
},

hidden: true,
assignAllTeamMembers: true,
rescheduleWithSameRoundRobinHost: true,
});

export async function isCurrentlyAvailable({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This function is just to check if current time is between the available time. It doesn't check if users are already booked, calendar blocked, OOO.

As this is just for instant booking event where user can book for future slots if no one is available currently

prisma,
instantMeetingScheduleId,
availabilityTimezone,
length,
}: {
prisma: PrismaClient;
instantMeetingScheduleId: number;
availabilityTimezone: string;
length: number;
}): Promise<boolean> {
const now = dayjs().tz(availabilityTimezone);
const currentDay = now.day();
const meetingEndTime = now.add(length, "minute");

const res = await prisma.schedule.findUniqueOrThrow({
where: {
id: instantMeetingScheduleId,
},
select: {
availability: true,
},
});

const dateOverride = res.availability.find((a) => a.date && dayjs(a.date).isSame(now, "day"));

if (dateOverride) {
return !isAvailableInTimeSlot(dateOverride, now, meetingEndTime);
}

for (const availability of res.availability) {
if (!availability.date && availability.days.includes(currentDay)) {
const isAvailable = isAvailableInTimeSlot(availability, now, meetingEndTime);
if (isAvailable) {
return true;
}
}
}

return false;
}

function isAvailableInTimeSlot(
availability: { startTime: Date; endTime: Date; days: number[] },
now: dayjs.Dayjs,
meetingEndTime: dayjs.Dayjs
): boolean {
const startTime = dayjs(availability.startTime).utc().format("HH:mm");
const endTime = dayjs(availability.endTime).utc().format("HH:mm");

const periodStart = now
.startOf("day")
.hour(parseInt(startTime.split(":")[0]))
.minute(parseInt(startTime.split(":")[1]));
const periodEnd = now
.startOf("day")
.hour(parseInt(endTime.split(":")[0]))
.minute(parseInt(endTime.split(":")[1]));

const isWithinPeriod =
now.isBetween(periodStart, periodEnd, null, "[)") &&
meetingEndTime.isBetween(periodStart, periodEnd, null, "(]");

return isWithinPeriod;
}

// TODO: Convert it to accept a single parameter with structured data
export const getPublicEvent = async (
username: string,
Expand Down Expand Up @@ -216,6 +290,7 @@ export const getPublicEvent = async (
logoUrl: null,
},
isInstantEvent: false,
showInstantEventConnectNowModal: false,
};
}

Expand Down Expand Up @@ -334,6 +409,20 @@ export const getPublicEvent = async (
},
});
}

let showInstantEventConnectNowModal = eventWithUserProfiles.isInstantEvent;

if (eventWithUserProfiles.isInstantEvent && eventWithUserProfiles.instantMeetingSchedule?.id) {
const { id, timeZone } = eventWithUserProfiles.instantMeetingSchedule;

showInstantEventConnectNowModal = await isCurrentlyAvailable({
prisma,
instantMeetingScheduleId: id,
availabilityTimezone: timeZone ?? "Europe/London",
length: eventWithUserProfiles.length,
});
}

return {
...eventWithUserProfiles,
bookerLayouts: bookerLayoutsSchema.parse(eventMetaData?.bookerLayouts || null),
Expand Down Expand Up @@ -372,6 +461,7 @@ export const getPublicEvent = async (

isDynamic: false,
isInstantEvent: eventWithUserProfiles.isInstantEvent,
showInstantEventConnectNowModal,
aiPhoneCallConfig: eventWithUserProfiles.aiPhoneCallConfig,
assignAllTeamMembers: event.assignAllTeamMembers,
};
Expand Down
Loading
Loading