Skip to content

Commit

Permalink
feat: availability in instant meeting (#16424)
Browse files Browse the repository at this point in the history
* chore: save progress

* feat: add isAvailable functionality

* fix: type error

* chore: add in builder

* tests: add unit tests

* chore: improvements

* chore: tests

* chore

* chore: remove log

* fix: tets

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
  • Loading branch information
2 people authored and zomars committed Sep 4, 2024
1 parent 90bf083 commit 8603bff
Show file tree
Hide file tree
Showing 17 changed files with 351 additions and 12 deletions.
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 && (
<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({
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

0 comments on commit 8603bff

Please sign in to comment.