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:
Alex van Andel
2023-01-12 14:09:12 -07:00
committed by GitHub
co-authored by gitstart-calcom Udit Takkar kodiakhq[bot] Thomas Brodusch GitStart-Cal.com sean-brydon zomars
parent 100035cb89
commit 99287d9eb4
17 changed files with 406 additions and 151 deletions
+135 -36
View File
@@ -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 -30
View File
@@ -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 = {
+15 -3
View File
@@ -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,
+19 -14
View File
@@ -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",
+23 -7
View File
@@ -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;
+2
View File
@@ -82,8 +82,10 @@ const commons = {
bookingLimits: null,
hidden: false,
userId: 0,
owner: null,
workflows: [],
users: [user],
hosts: [],
metadata: EventTypeMetaDataSchema.parse({}),
};
+6
View File
@@ -125,6 +125,12 @@ export default async function getEventTypeById({
id: true,
},
},
hosts: {
select: {
isFixed: true,
userId: true,
},
},
userId: true,
price: true,
destinationCalendar: true,
+1 -1
View File
@@ -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;
+11
View File
@@ -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;
+46 -30
View File
@@ -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:
+3 -3
View File
@@ -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
{