Feature/fixed hosts (#6423)
* Save design updates for fixed round robin support * wip - added Host relation * DRY hostsFixed select * Changes to allow isFixed in the Availability page * Allow booking with fixed hosts * Replace users with hosts if hosts is set * Also prefer hosts over users here * Prevent duplicates when hosts is saved * Accidental slot duplication * Attempt at making isFixed optional * Sydney and Shiraz can live in harmony again * No fixed hosts causes every to be true.. * Make hosts undefinable * Small handleNewBooking fixes * Similar fix to the hosts to check for empty-ness * Default to isFixed false instead of true * Fix event type list avatars * Filter availableTimeSlots, collective ts's wont magically re-enable. * (Further) Fixes to getAggregateWorkingHours * Weird userId artifact that preceeds this branch, investigate later * On user delete, remove host, on event type delete, also remove host * Dynamic event types were incorrectly marked as isFixed=false * Fixed notFound error when there are no users (but hosts) * Fixes userIsOwner * Oops, fixed isFixed users being included correctly * Fixed Button styling on secondary darkmode * Create exclusivity in selectable options * fix: Location dropdown is overflowing #6376 (#6415) * add `menuPlacement` react-select's props to `getReactSelectProps` to avoid dropdown overflow on small screen. By default, set to "auto". * CALCOM-6362 - [CAL-728] App Sidebar. Child items aren't sized/spaced properly (#6424) * [CAL-728] App Sidebar. Child items aren't sized/spaced properly * fix: undo logo size Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com> Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> * Fixing colors (#6429) * Update website * Update console * Update yarn.lock * Uses disable instead of filtering to be more clear * Update EventTeamTab.tsx * Merge conflict cleanup * During test cases the dayjs() utcOffset is local time, this fixes that Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: Thomas Brodusch <3238312+thomasbrodusch@users.noreply.github.com> Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com> Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com> Co-authored-by: Udit Takkar <udit.07814802719@cse.mait.ac.in> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
co-authored by
gitstart-calcom
Udit Takkar
kodiakhq[bot]
Thomas Brodusch
GitStart-Cal.com
sean-brydon
zomars
parent
100035cb89
commit
99287d9eb4
@@ -1,9 +1,12 @@
|
||||
import { SchedulingType } from "@prisma/client/";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { ComponentProps } from "react";
|
||||
import { Controller, useFormContext, useWatch, Control } from "react-hook-form";
|
||||
import { Options } from "react-select";
|
||||
|
||||
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import CheckedTeamSelect, {
|
||||
CheckedSelectOption,
|
||||
} from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Label, Select } from "@calcom/ui";
|
||||
@@ -22,14 +25,66 @@ const mapUserToValue = ({ id, name, username, email }: IMemberToValue) => ({
|
||||
email,
|
||||
});
|
||||
|
||||
export const EventTeamTab = ({
|
||||
eventType,
|
||||
team,
|
||||
teamMembers,
|
||||
}: Pick<EventTypeSetupProps, "eventType" | "teamMembers" | "team">) => {
|
||||
const FixedHosts = ({
|
||||
control,
|
||||
labelText,
|
||||
placeholder,
|
||||
options = [],
|
||||
...rest
|
||||
}: {
|
||||
control: Control<FormValues>;
|
||||
labelText: string;
|
||||
placeholder: string;
|
||||
options?: Options<CheckedSelectOption>;
|
||||
} & Partial<ComponentProps<typeof CheckedTeamSelect>>) => {
|
||||
return (
|
||||
<div className="flex flex-col space-y-5 bg-gray-50 p-4">
|
||||
<div>
|
||||
<Label>{labelText}</Label>
|
||||
<Controller
|
||||
name="hostsFixed"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<CheckedTeamSelect
|
||||
isDisabled={false}
|
||||
onChange={(options) => {
|
||||
onChange(
|
||||
options.map((option) => ({
|
||||
isFixed: true,
|
||||
userId: parseInt(option.value, 10),
|
||||
}))
|
||||
);
|
||||
}}
|
||||
value={value
|
||||
.map(
|
||||
(host) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
options.find((member) => member.value === host.userId.toString())!
|
||||
)
|
||||
.filter(Boolean)}
|
||||
controlShouldRenderValue={false}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "teamMembers" | "team">) => {
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { t } = useLocale();
|
||||
|
||||
const schedulingType = useWatch({
|
||||
control: formMethods.control,
|
||||
name: "schedulingType",
|
||||
});
|
||||
|
||||
const schedulingTypeOptions: {
|
||||
value: SchedulingType;
|
||||
label: string;
|
||||
@@ -47,14 +102,13 @@ export const EventTeamTab = ({
|
||||
},
|
||||
];
|
||||
|
||||
const teamMembersToValues = useMemo(() => {
|
||||
return teamMembers.map(mapUserToValue);
|
||||
}, [teamMembers]);
|
||||
const teamMembersOptions = teamMembers.map(mapUserToValue);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{team && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col pb-8">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col">
|
||||
<Label>{t("scheduling_type")}</Label>
|
||||
<Controller
|
||||
name="schedulingType"
|
||||
@@ -72,30 +126,75 @@ export const EventTeamTab = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Label>{t("team")}</Label>
|
||||
<Controller
|
||||
name="users"
|
||||
{schedulingType === SchedulingType.COLLECTIVE && (
|
||||
<FixedHosts
|
||||
options={teamMembersOptions}
|
||||
placeholder={t("add_attendees")}
|
||||
labelText={t("team")}
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.users.map((user) => user.id.toString())}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CheckedTeamSelect
|
||||
isDisabled={false}
|
||||
onChange={(options) => onChange(options.map((user) => user.value))}
|
||||
value={value
|
||||
.map(
|
||||
(userId) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
teamMembers.map(mapUserToValue).find((member) => member.value === userId)!
|
||||
)
|
||||
.filter(Boolean)}
|
||||
controlShouldRenderValue={false}
|
||||
options={teamMembersToValues}
|
||||
placeholder={t("add_attendees")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schedulingType === SchedulingType.ROUND_ROBIN && (
|
||||
<>
|
||||
<FixedHosts
|
||||
options={teamMembersOptions}
|
||||
isOptionDisabled={(option) =>
|
||||
!!formMethods.getValues("hosts").find((host) => host.userId.toString() === option.value)
|
||||
}
|
||||
placeholder={t("add_fixed_hosts")}
|
||||
labelText={t("fixed_hosts")}
|
||||
control={formMethods.control}
|
||||
/>
|
||||
<div className="flex flex-col space-y-5 bg-gray-50 p-4">
|
||||
<div>
|
||||
<Label>{t("round_robin_hosts")}</Label>
|
||||
<Controller
|
||||
name="hosts"
|
||||
control={formMethods.control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CheckedTeamSelect
|
||||
isDisabled={false}
|
||||
onChange={(options) =>
|
||||
onChange(
|
||||
options.map((option) => ({
|
||||
isFixed: false,
|
||||
userId: parseInt(option.value, 10),
|
||||
}))
|
||||
)
|
||||
}
|
||||
value={value
|
||||
.map(
|
||||
(host) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
teamMembers
|
||||
.map(mapUserToValue)
|
||||
.find((member) => member.value === host.userId.toString())!
|
||||
)
|
||||
.filter(Boolean)}
|
||||
controlShouldRenderValue={false}
|
||||
options={teamMembers.map(mapUserToValue)}
|
||||
isOptionDisabled={(option) =>
|
||||
!!formMethods
|
||||
.getValues("hostsFixed")
|
||||
.find((host) => host.userId.toString() === option.value)
|
||||
}
|
||||
placeholder={t("add_attendees")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/*<TextField
|
||||
required
|
||||
type="number"
|
||||
label={t("minimum_round_robin_hosts_count")}
|
||||
defaultValue={1}
|
||||
{...formMethods.register("minimumHostCount")}
|
||||
addOnSuffix={<>{t("hosts")}</>}
|
||||
/>*/}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -151,7 +151,7 @@ function EventTypeSingleLayout({
|
||||
// If there is a team put this navigation item within the tabs
|
||||
if (team) {
|
||||
navigation.splice(2, 0, {
|
||||
name: "scheduling_type",
|
||||
name: "assignment",
|
||||
href: `/event-types/${eventType.id}?tabName=team`,
|
||||
icon: Icon.FiUsers,
|
||||
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
|
||||
|
||||
@@ -32,7 +32,7 @@ import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { localStorage } from "@calcom/lib/webstorage";
|
||||
import prisma, { baseUserSelect } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Button, EmailInput, Icon, HeadSeo } from "@calcom/ui";
|
||||
@@ -322,7 +322,7 @@ export default function Success(props: SuccessProps) {
|
||||
}
|
||||
return t("emailed_you_and_attendees" + titleSuffix);
|
||||
}
|
||||
const userIsOwner = !!(session?.user?.id && eventType.users.find((user) => (user.id = session.user.id)));
|
||||
const userIsOwner = !!(session?.user?.id && eventType.owner?.id === session.user.id);
|
||||
useTheme(isSuccessBookingPage ? props.profile.theme : "light");
|
||||
const title = t(
|
||||
`booking_${needsConfirmation ? "submitted" : "confirmed"}${props.recurringBookings ? "_recurring" : ""}`
|
||||
@@ -845,6 +845,17 @@ export function RecurringBookings({
|
||||
}
|
||||
|
||||
const getEventTypesFromDB = async (id: number) => {
|
||||
const userSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
hideBranding: true,
|
||||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
};
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id,
|
||||
@@ -863,17 +874,17 @@ const getEventTypesFromDB = async (id: number) => {
|
||||
locations: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
owner: {
|
||||
select: userSelect,
|
||||
},
|
||||
users: {
|
||||
select: userSelect,
|
||||
},
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
hideBranding: true,
|
||||
theme: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
user: {
|
||||
select: userSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
@@ -1013,27 +1024,18 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
if (!eventTypeRaw.users.length && eventTypeRaw.userId) {
|
||||
// TODO we should add `user User` relation on `EventType` so this extra query isn't needed
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: eventTypeRaw.userId,
|
||||
},
|
||||
select: baseUserSelect,
|
||||
});
|
||||
if (user) {
|
||||
eventTypeRaw.users.push({
|
||||
...user,
|
||||
avatar: "",
|
||||
allowDynamicBooking: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
eventTypeRaw.users = !!eventTypeRaw.hosts?.length
|
||||
? eventTypeRaw.hosts.map((host) => host.user)
|
||||
: eventTypeRaw.users;
|
||||
|
||||
if (!eventTypeRaw.users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
if (!eventTypeRaw.owner)
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
eventTypeRaw.users.push({
|
||||
...eventTypeRaw.owner,
|
||||
});
|
||||
}
|
||||
|
||||
const eventType = {
|
||||
|
||||
@@ -64,7 +64,6 @@ export type FormValues = {
|
||||
phone?: string;
|
||||
}[];
|
||||
customInputs: CustomInputParsed[];
|
||||
users: string[];
|
||||
schedule: number;
|
||||
periodType: PeriodType;
|
||||
periodDays: number;
|
||||
@@ -85,6 +84,8 @@ export type FormValues = {
|
||||
};
|
||||
successRedirectUrl: string;
|
||||
bookingLimits?: BookingLimit;
|
||||
hosts: { userId: number }[];
|
||||
hostsFixed: { userId: number }[];
|
||||
};
|
||||
|
||||
export type CustomInputParsed = typeof customInputSchema._output;
|
||||
@@ -120,7 +121,6 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
});
|
||||
|
||||
const { eventType, locationOptions, team, teamMembers, currentUserMembership } = props;
|
||||
|
||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
|
||||
@@ -202,6 +202,10 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
eventType.minimumBookingNotice
|
||||
),
|
||||
metadata,
|
||||
hosts: !!eventType.hosts?.length
|
||||
? eventType.hosts.filter((host) => !host.isFixed)
|
||||
: eventType.users.map((user) => ({ userId: user.id })),
|
||||
hostsFixed: eventType.hosts.filter((host) => host.isFixed),
|
||||
},
|
||||
resolver: zodResolver(
|
||||
z
|
||||
@@ -239,7 +243,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
/>
|
||||
),
|
||||
availability: <AvailabilityTab isTeamEvent={!!team} />,
|
||||
team: <EventTeamTab eventType={eventType} teamMembers={teamMembers} team={team} />,
|
||||
team: <EventTeamTab teamMembers={teamMembers} team={team} />,
|
||||
limits: <EventLimitsTab eventType={eventType} />,
|
||||
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
|
||||
recurring: <EventRecurringTab eventType={eventType} />,
|
||||
@@ -280,6 +284,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
locations,
|
||||
metadata,
|
||||
customInputs,
|
||||
hosts: hostsInput,
|
||||
hostsFixed,
|
||||
// We don't need to send send these values to the backend
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
seatsPerTimeSlotEnabled,
|
||||
@@ -288,6 +294,11 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
...input
|
||||
} = values;
|
||||
|
||||
const hosts: (typeof hostsInput[number] & { isFixed?: boolean })[] = [];
|
||||
if (hostsInput || hostsFixed) {
|
||||
hosts.push(...hostsInput.concat(hostsFixed.map((host) => ({ isFixed: true, ...host }))));
|
||||
}
|
||||
|
||||
if (bookingLimits) {
|
||||
const isValid = validateBookingLimitOrder(bookingLimits);
|
||||
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
|
||||
@@ -305,6 +316,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
|
||||
updateMutation.mutate({
|
||||
...input,
|
||||
hosts,
|
||||
locations,
|
||||
recurringEvent,
|
||||
periodStartDate: periodDates.startDate,
|
||||
|
||||
@@ -51,16 +51,21 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
id: true,
|
||||
slug: true,
|
||||
hidden: true,
|
||||
users: {
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
username: true,
|
||||
timeZone: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
isFixed: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
username: true,
|
||||
timeZone: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
title: true,
|
||||
@@ -124,11 +129,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
periodEndDate: eventType.periodEndDate?.toString() ?? null,
|
||||
recurringEvent: parseRecurringEvent(eventType.recurringEvent),
|
||||
locations: privacyFilteredLocations(locations),
|
||||
users: eventType.users.map((user) => ({
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
hideBranding: user.hideBranding,
|
||||
timeZone: user.timeZone,
|
||||
users: eventType.hosts.map(({ user: { name, username, hideBranding, timeZone } }) => ({
|
||||
name,
|
||||
username,
|
||||
hideBranding,
|
||||
timeZone,
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
@@ -1492,6 +1492,12 @@
|
||||
"invalid_admin_password": "You are set as an admin but you do not have a password length of at least 15 characters",
|
||||
"change_password_admin": "Change Password to gain admin access",
|
||||
"username_already_taken": "Username is already taken",
|
||||
"assignment": "Assignment",
|
||||
"fixed_hosts": "Fixed Hosts",
|
||||
"add_fixed_hosts": "Add fixed hosts",
|
||||
"round_robin_hosts": "Round-Robin Hosts",
|
||||
"minimum_round_robin_hosts_count": "Number of hosts required to attend",
|
||||
"hosts": "Hosts",
|
||||
"upgrade_to_enable_feature": "You need to upgrade to enable this feature. Click to upgrade.",
|
||||
"new_attendee": "New Attendee",
|
||||
"awaiting_approval": "Awaiting Approval",
|
||||
|
||||
@@ -7,16 +7,29 @@ import type { WorkingHours } from "@calcom/types/schedule";
|
||||
* offsets them to UTC and intersects them for collective events.
|
||||
**/
|
||||
export const getAggregateWorkingHours = (
|
||||
usersWorkingHoursAndBusySlots: Omit<
|
||||
usersWorkingHoursAndBusySlots: (Omit<
|
||||
Awaited<ReturnType<Awaited<typeof import("./getUserAvailability")>["getUserAvailability"]>>,
|
||||
"currentSeats"
|
||||
>[],
|
||||
> & { user?: { isFixed?: boolean } })[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
schedulingType: SchedulingType | null
|
||||
): WorkingHours[] => {
|
||||
if (schedulingType !== SchedulingType.COLLECTIVE) {
|
||||
// during personal events, just flatMap.
|
||||
if (!schedulingType) {
|
||||
return usersWorkingHoursAndBusySlots.flatMap((s) => s.workingHours);
|
||||
}
|
||||
return usersWorkingHoursAndBusySlots.reduce((currentWorkingHours: WorkingHours[], s) => {
|
||||
const looseHostWorkingHours = usersWorkingHoursAndBusySlots
|
||||
.filter(({ user }) => schedulingType !== SchedulingType.COLLECTIVE && user?.isFixed !== true)
|
||||
.flatMap((s) => s.workingHours);
|
||||
|
||||
const fixedHostSchedules = usersWorkingHoursAndBusySlots.filter(
|
||||
({ user }) => schedulingType === SchedulingType.COLLECTIVE || user?.isFixed
|
||||
);
|
||||
// return early when there are no fixed hosts.
|
||||
if (!fixedHostSchedules.length) {
|
||||
return looseHostWorkingHours;
|
||||
}
|
||||
return fixedHostSchedules.reduce((currentWorkingHours: WorkingHours[], s) => {
|
||||
const updatedWorkingHours: typeof currentWorkingHours = [];
|
||||
|
||||
s.workingHours.forEach((workingHour) => {
|
||||
@@ -31,15 +44,18 @@ export const getAggregateWorkingHours = (
|
||||
updatedWorkingHours.push(
|
||||
...sameDayWorkingHours.map((compare) => {
|
||||
const intersect = workingHour.days.filter((day) => compare.days.includes(day));
|
||||
return {
|
||||
const retVal: WorkingHours & { userId?: number | null } = {
|
||||
days: intersect,
|
||||
startTime: Math.max(workingHour.startTime, compare.startTime),
|
||||
endTime: Math.min(workingHour.endTime, compare.endTime),
|
||||
};
|
||||
if (schedulingType !== SchedulingType.COLLECTIVE) {
|
||||
retVal.userId = compare.userId;
|
||||
}
|
||||
return retVal;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return updatedWorkingHours;
|
||||
}, []);
|
||||
}, looseHostWorkingHours);
|
||||
};
|
||||
|
||||
@@ -196,6 +196,12 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
hosts: {
|
||||
select: {
|
||||
isFixed: true,
|
||||
user: userSelect,
|
||||
},
|
||||
},
|
||||
availability: {
|
||||
select: {
|
||||
date: true,
|
||||
@@ -216,9 +222,11 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
type IsFixedAwareUser = User & { isFixed: boolean };
|
||||
|
||||
async function ensureAvailableUsers(
|
||||
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
|
||||
users: User[];
|
||||
users: IsFixedAwareUser[];
|
||||
},
|
||||
input: { dateFrom: string; dateTo: string },
|
||||
recurringDatesInfo?: {
|
||||
@@ -226,7 +234,7 @@ async function ensureAvailableUsers(
|
||||
currentRecurringIndex: number | undefined;
|
||||
}
|
||||
) {
|
||||
const availableUsers: typeof eventType.users = [];
|
||||
const availableUsers: IsFixedAwareUser[] = [];
|
||||
/** Let's start checking for availability */
|
||||
for (const user of eventType.users) {
|
||||
const { busy: bufferedBusyTimes, workingHours } = await getUserAvailability(
|
||||
@@ -350,16 +358,25 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
throw new HttpError({ statusCode: 400, message: error.message });
|
||||
}
|
||||
|
||||
let users = !eventTypeId
|
||||
? await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
in: dynamicUserList,
|
||||
const loadUsers = async () =>
|
||||
!eventTypeId
|
||||
? await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
in: dynamicUserList,
|
||||
},
|
||||
},
|
||||
},
|
||||
...userSelect,
|
||||
})
|
||||
: eventType.users;
|
||||
...userSelect,
|
||||
})
|
||||
: !!eventType.hosts?.length
|
||||
? eventType.hosts.map(({ user, isFixed }) => ({
|
||||
...user,
|
||||
isFixed,
|
||||
}))
|
||||
: eventType.users;
|
||||
// loadUsers allows type inferring
|
||||
let users: (Awaited<ReturnType<typeof loadUsers>>[number] & { isFixed?: boolean })[] = await loadUsers();
|
||||
|
||||
const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking);
|
||||
if (!isDynamicAllowed && !eventTypeId) {
|
||||
throw new HttpError({
|
||||
@@ -383,6 +400,14 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
|
||||
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
|
||||
|
||||
users = users.map((user) => ({
|
||||
...user,
|
||||
isFixed:
|
||||
user.isFixed === false
|
||||
? false
|
||||
: user.isFixed || eventType.schedulingType !== SchedulingType.ROUND_ROBIN,
|
||||
}));
|
||||
|
||||
if (eventType && eventType.hasOwnProperty("bookingLimits") && eventType?.bookingLimits) {
|
||||
const startAsDate = dayjs(reqBody.start).toDate();
|
||||
await checkBookingLimits(eventType.bookingLimits, startAsDate, eventType.id);
|
||||
@@ -392,7 +417,7 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
const availableUsers = await ensureAvailableUsers(
|
||||
{
|
||||
...eventType,
|
||||
users,
|
||||
users: users as IsFixedAwareUser[],
|
||||
...(eventType.recurringEvent && {
|
||||
recurringEvent: {
|
||||
...eventType.recurringEvent,
|
||||
@@ -410,19 +435,31 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
}
|
||||
);
|
||||
|
||||
// Add an if conditional if there are no seats on the event type
|
||||
// Assign to only one user when ROUND_ROBIN
|
||||
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
|
||||
users = [await getLuckyUser("MAXIMIZE_AVAILABILITY", { availableUsers, eventTypeId: eventType.id })];
|
||||
} else {
|
||||
// excluding ROUND_ROBIN, all users have availability required.
|
||||
if (availableUsers.length !== users.length) {
|
||||
throw new Error("Some users are unavailable for booking.");
|
||||
const luckyUsers: typeof users = [];
|
||||
const luckyUserPool = availableUsers.filter((user) => !user.isFixed);
|
||||
// loop through all non-fixed hosts and get the lucky users
|
||||
while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) {
|
||||
const newLuckyUser = await getLuckyUser("MAXIMIZE_AVAILABILITY", {
|
||||
// find a lucky user that is not already in the luckyUsers array
|
||||
availableUsers: luckyUserPool.filter(
|
||||
(user) => !luckyUsers.find((existing) => existing.id === user.id)
|
||||
),
|
||||
eventTypeId: eventType.id,
|
||||
});
|
||||
if (!newLuckyUser) {
|
||||
break; // prevent infinite loop
|
||||
}
|
||||
users = availableUsers;
|
||||
luckyUsers.push(newLuckyUser);
|
||||
}
|
||||
// ALL fixed users must be available
|
||||
if (
|
||||
availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length
|
||||
) {
|
||||
throw new Error("Some users are unavailable for booking.");
|
||||
}
|
||||
users = [...luckyUsers, ...availableUsers.filter((user) => user.isFixed)];
|
||||
}
|
||||
console.log("available users", users);
|
||||
|
||||
const rainbowAppData = getEventTypeAppData(eventType, "rainbow") || {};
|
||||
|
||||
// @TODO: use the returned address somewhere in booking creation?
|
||||
@@ -451,11 +488,11 @@ async function handler(req: NextApiRequest & { userId?: number | undefined }) {
|
||||
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
|
||||
|
||||
const bookingLocation = getLocationValueForDB(reqBody.location, eventType.locations);
|
||||
console.log(bookingLocation, reqBody.location, eventType.locations);
|
||||
|
||||
const customInputs = {} as NonNullable<CalendarEvent["customInputs"]>;
|
||||
|
||||
const teamMemberPromises =
|
||||
eventType.schedulingType === SchedulingType.COLLECTIVE
|
||||
users.length > 1
|
||||
? users.slice(1).map(async function (user) {
|
||||
return {
|
||||
email: user.email || "",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar, Icon, Select } from "@calcom/ui";
|
||||
|
||||
type CheckedSelectOption = {
|
||||
export type CheckedSelectOption = {
|
||||
avatar: string;
|
||||
label: string;
|
||||
value: string;
|
||||
|
||||
@@ -82,8 +82,10 @@ const commons = {
|
||||
bookingLimits: null,
|
||||
hidden: false,
|
||||
userId: 0,
|
||||
owner: null,
|
||||
workflows: [],
|
||||
users: [user],
|
||||
hosts: [],
|
||||
metadata: EventTypeMetaDataSchema.parse({}),
|
||||
};
|
||||
|
||||
|
||||
@@ -125,6 +125,12 @@ export default async function getEventTypeById({
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
hosts: {
|
||||
select: {
|
||||
isFixed: true,
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
userId: true,
|
||||
price: true,
|
||||
destinationCalendar: true,
|
||||
|
||||
@@ -127,7 +127,7 @@ const getSlots = ({
|
||||
eventLength,
|
||||
}: GetSlots) => {
|
||||
// current date in invitee tz
|
||||
const startDate = dayjs().add(minimumBookingNotice, "minute");
|
||||
const startDate = dayjs().utcOffset(inviteeDate.utcOffset()).add(minimumBookingNotice, "minute");
|
||||
// This code is ran client side, startOf() does some conversions based on the
|
||||
// local tz of the client. Sometimes this shifts the day incorrectly.
|
||||
const startOfDayUTC = dayjs.utc().set("hour", 0).set("minute", 0).set("second", 0);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Host" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"eventTypeId" INTEGER NOT NULL,
|
||||
"isFixed" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "Host_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Host" ADD CONSTRAINT "Host_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Host" ADD CONSTRAINT "Host_eventTypeId_fkey" FOREIGN KEY ("eventTypeId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -29,6 +29,15 @@ enum PeriodType {
|
||||
RANGE @map("range")
|
||||
}
|
||||
|
||||
model Host {
|
||||
id Int @id @default(autoincrement())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
|
||||
eventTypeId Int
|
||||
isFixed Boolean @default(false)
|
||||
}
|
||||
|
||||
model EventType {
|
||||
id Int @id @default(autoincrement())
|
||||
/// @zod.min(1)
|
||||
@@ -41,6 +50,7 @@ model EventType {
|
||||
locations Json?
|
||||
length Int
|
||||
hidden Boolean @default(false)
|
||||
hosts Host[]
|
||||
users User[] @relation("user_eventtype")
|
||||
owner User? @relation("owner", fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int?
|
||||
@@ -187,6 +197,7 @@ model User {
|
||||
workflows Workflow[]
|
||||
routingForms App_RoutingForms_Form[] @relation("routing-form")
|
||||
verifiedNumbers VerifiedNumber[]
|
||||
hosts Host[]
|
||||
|
||||
@@map(name: "users")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MembershipRole, PeriodType, Prisma } from "@prisma/client";
|
||||
import { MembershipRole, PeriodType, Prisma, SchedulingType } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
// REVIEW: From lint error
|
||||
import _ from "lodash";
|
||||
@@ -84,6 +84,14 @@ const EventTypeUpdateInput = _EventTypeModel
|
||||
externalId: true,
|
||||
}),
|
||||
users: z.array(stringOrNumber).optional(),
|
||||
hosts: z
|
||||
.array(
|
||||
z.object({
|
||||
userId: z.number(),
|
||||
isFixed: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
schedule: z.number().optional(),
|
||||
hashedLink: z.string(),
|
||||
})
|
||||
@@ -187,6 +195,13 @@ export const eventTypesRouter = router({
|
||||
users: {
|
||||
select: baseUserSelect,
|
||||
},
|
||||
hosts: {
|
||||
select: {
|
||||
user: {
|
||||
select: baseUserSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
...baseEventTypeSelect,
|
||||
});
|
||||
|
||||
@@ -255,6 +270,7 @@ export const eventTypesRouter = router({
|
||||
|
||||
const mapEventType = (eventType: typeof user.eventTypes[number]) => ({
|
||||
...eventType,
|
||||
users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users,
|
||||
// @FIXME: cc @hariombalhara This is failing with production data
|
||||
// metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
|
||||
});
|
||||
@@ -483,6 +499,7 @@ export const eventTypesRouter = router({
|
||||
customInputs,
|
||||
recurringEvent,
|
||||
users,
|
||||
hosts,
|
||||
id,
|
||||
hashedLink,
|
||||
// Extract this from the input so it doesn't get saved in the db
|
||||
@@ -557,6 +574,17 @@ export const eventTypesRouter = router({
|
||||
connect: users.map((userId: number) => ({ id: userId })),
|
||||
};
|
||||
}
|
||||
if (hosts) {
|
||||
data.hosts = {
|
||||
deleteMany: {
|
||||
eventTypeId: id,
|
||||
},
|
||||
createMany: {
|
||||
// when schedulingType is COLLECTIVE, remove unFixed hosts.
|
||||
data: hosts.filter((host) => !(data.schedulingType === SchedulingType.COLLECTIVE && !host.isFixed)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (input?.price || input.metadata?.apps?.stripe?.price) {
|
||||
data.price = input.price || input.metadata?.apps?.stripe?.price;
|
||||
|
||||
@@ -144,6 +144,14 @@ async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer<typeo
|
||||
days: true,
|
||||
},
|
||||
},
|
||||
hosts: {
|
||||
select: {
|
||||
isFixed: true,
|
||||
user: {
|
||||
select: availabilityUserSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
select: {
|
||||
...availabilityUserSelect,
|
||||
@@ -227,9 +235,17 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
|
||||
}
|
||||
let currentSeats: CurrentSeats | undefined = undefined;
|
||||
|
||||
let users = eventType.users.map((user) => ({
|
||||
isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE,
|
||||
...user,
|
||||
}));
|
||||
// overwrite if it is a team event & hosts is set, otherwise keep using users.
|
||||
if (eventType.schedulingType && !!eventType.hosts?.length) {
|
||||
users = eventType.hosts.map(({ isFixed, user }) => ({ isFixed, ...user }));
|
||||
}
|
||||
/* We get all users working hours and busy slots */
|
||||
const userAvailability = await Promise.all(
|
||||
eventType.users.map(async (currentUser) => {
|
||||
users.map(async (currentUser) => {
|
||||
const {
|
||||
busy,
|
||||
workingHours,
|
||||
@@ -255,16 +271,15 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
|
||||
workingHours,
|
||||
dateOverrides,
|
||||
busy,
|
||||
userId: currentUser.id,
|
||||
user: currentUser,
|
||||
};
|
||||
})
|
||||
);
|
||||
// flattens availability of multiple users
|
||||
const dateOverrides = userAvailability.flatMap((availability) =>
|
||||
availability.dateOverrides.map((override) => ({ userId: availability.userId, ...override }))
|
||||
availability.dateOverrides.map((override) => ({ userId: availability.user.id, ...override }))
|
||||
);
|
||||
const workingHours = getAggregateWorkingHours(userAvailability, eventType.schedulingType);
|
||||
|
||||
const availabilityCheckProps = {
|
||||
eventLength: eventType.length,
|
||||
currentSeats,
|
||||
@@ -304,34 +319,33 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
|
||||
);
|
||||
}
|
||||
|
||||
const isCollective = !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE;
|
||||
|
||||
let availableTimeSlots: typeof timeSlots = [];
|
||||
if (isCollective) {
|
||||
availableTimeSlots = timeSlots.filter((slot) =>
|
||||
userAvailability.every((schedule) => {
|
||||
const startCheckForAvailability = performance.now();
|
||||
const isAvailable = checkIfIsAvailable({
|
||||
time: slot.time,
|
||||
...schedule,
|
||||
...availabilityCheckProps,
|
||||
});
|
||||
const endCheckForAvailability = performance.now();
|
||||
checkForAvailabilityCount++;
|
||||
checkForAvailabilityTime += endCheckForAvailability - startCheckForAvailability;
|
||||
return isAvailable;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
availableTimeSlots = timeSlots
|
||||
availableTimeSlots = timeSlots.filter((slot) => {
|
||||
const fixedHosts = userAvailability.filter((availability) => availability.user.isFixed);
|
||||
return fixedHosts.every((schedule) => {
|
||||
const startCheckForAvailability = performance.now();
|
||||
const isAvailable = checkIfIsAvailable({
|
||||
time: slot.time,
|
||||
...schedule,
|
||||
...availabilityCheckProps,
|
||||
});
|
||||
const endCheckForAvailability = performance.now();
|
||||
checkForAvailabilityCount++;
|
||||
checkForAvailabilityTime += endCheckForAvailability - startCheckForAvailability;
|
||||
return isAvailable;
|
||||
});
|
||||
});
|
||||
// what else are you going to call it?
|
||||
const looseHostAvailability = userAvailability.filter(({ user: { isFixed } }) => !isFixed);
|
||||
if (looseHostAvailability.length > 0) {
|
||||
availableTimeSlots = availableTimeSlots
|
||||
.map((slot) => {
|
||||
slot.userIds = slot.userIds?.filter((slotUserId) => {
|
||||
const userSchedule = userAvailability.find(({ userId }) => userId === slotUserId);
|
||||
const userSchedule = looseHostAvailability.find(
|
||||
({ user: { id: userId } }) => userId === slotUserId
|
||||
);
|
||||
if (!userSchedule) {
|
||||
throw new TRPCError({
|
||||
message: "Shouldn't happen that we don't have a matching user schedule here",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return checkIfIsAvailable({
|
||||
time: slot.time,
|
||||
@@ -341,7 +355,7 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
|
||||
});
|
||||
return slot;
|
||||
})
|
||||
.filter((slot) => slot.userIds && slot.userIds.length > 0);
|
||||
.filter((slot) => !!slot.userIds?.length);
|
||||
}
|
||||
|
||||
availableTimeSlots = availableTimeSlots.filter((slot) => isTimeWithinBounds(slot.time));
|
||||
@@ -355,7 +369,9 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, ctx:
|
||||
r[time.format("YYYY-MM-DD")].push({
|
||||
...passThroughProps,
|
||||
time: time.toISOString(),
|
||||
users: eventType.users.map((user) => user.username || ""),
|
||||
users: (eventType.hosts ? eventType.hosts.map((host) => host.user) : eventType.users).map(
|
||||
(user) => user.username || ""
|
||||
),
|
||||
// Conditionally add the attendees and booking id to slots object if there is already a booking during that time
|
||||
...(currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString()) && {
|
||||
attendees:
|
||||
|
||||
@@ -37,7 +37,7 @@ const buttonClasses = cva(
|
||||
variants: {
|
||||
color: {
|
||||
primary: "text-white dark:text-black",
|
||||
secondary: "text-gray-900 dark:text-darkgray-900 bg-white",
|
||||
secondary: "text-gray-900 dark:text-darkgray-900",
|
||||
minimal: "text-gray-900 dark:text-darkgray-900",
|
||||
destructive: "",
|
||||
},
|
||||
@@ -80,7 +80,7 @@ const buttonClasses = cva(
|
||||
disabled: true,
|
||||
color: "secondary",
|
||||
className:
|
||||
"border border-gray-200 bg-opacity-30 text-gray-900/30 bg-white dark:bg-darkgray-100 dark:text-darkgray-900/30 dark:border-darkgray-200",
|
||||
"border border-gray-200 bg-opacity-30 text-gray-900/30 dark:text-darkgray-900/30 dark:border-darkgray-200",
|
||||
},
|
||||
{
|
||||
loading: true,
|
||||
@@ -92,7 +92,7 @@ const buttonClasses = cva(
|
||||
disabled: [undefined, false],
|
||||
color: "secondary",
|
||||
className:
|
||||
"border border-gray-300 dark:border-darkgray-300 hover:bg-gray-50 hover:border-gray-400 focus:bg-gray-100 dark:hover:bg-darkgray-200 dark:focus:bg-darkgray-200 focus:outline-none focus:ring-2 focus:ring-offset focus:ring-gray-900 dark:focus:ring-white",
|
||||
"border border-gray-300 dark:border-darkgray-300 bg-white dark:bg-darkgray-100 hover:bg-gray-50 hover:border-gray-400 focus:bg-gray-100 dark:hover:bg-darkgray-200 dark:focus:bg-darkgray-200 focus:outline-none focus:ring-2 focus:ring-offset focus:ring-gray-900 dark:focus:ring-white",
|
||||
}),
|
||||
// Minimal variants
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user