diff --git a/apps/web/components/eventtype/EventTeamTab.tsx b/apps/web/components/eventtype/EventTeamTab.tsx index 90cc17a0f6..7835c3d000 100644 --- a/apps/web/components/eventtype/EventTeamTab.tsx +++ b/apps/web/components/eventtype/EventTeamTab.tsx @@ -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) => { +const FixedHosts = ({ + control, + labelText, + placeholder, + options = [], + ...rest +}: { + control: Control; + labelText: string; + placeholder: string; + options?: Options; +} & Partial>) => { + return ( +
+
+ + { + return ( + { + 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} + /> + ); + }} + /> +
+
+ ); +}; + +export const EventTeamTab = ({ team, teamMembers }: Pick) => { const formMethods = useFormContext(); 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 (
{team && ( -
-
+
+
-
- - user.id.toString())} - render={({ field: { onChange, value } }) => ( - 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")} - /> - )} /> -
+ )} + + {schedulingType === SchedulingType.ROUND_ROBIN && ( + <> + + !!formMethods.getValues("hosts").find((host) => host.userId.toString() === option.value) + } + placeholder={t("add_fixed_hosts")} + labelText={t("fixed_hosts")} + control={formMethods.control} + /> +
+
+ + ( + + 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")} + /> + )} + /> +
+ {/*{t("hosts")}} + />*/} +
+ + )}
)}
diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/eventtype/EventTypeSingleLayout.tsx index 2e7364e1e2..6eb2fdbb8c 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/eventtype/EventTypeSingleLayout.tsx @@ -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", diff --git a/apps/web/pages/booking/[uid].tsx b/apps/web/pages/booking/[uid].tsx index 41a5687cbc..20e01c3571 100644 --- a/apps/web/pages/booking/[uid].tsx +++ b/apps/web/pages/booking/[uid].tsx @@ -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 = { diff --git a/apps/web/pages/event-types/[type]/index.tsx b/apps/web/pages/event-types/[type]/index.tsx index 193c46a3ac..796a253f7f 100644 --- a/apps/web/pages/event-types/[type]/index.tsx +++ b/apps/web/pages/event-types/[type]/index.tsx @@ -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(); 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: , - team: , + team: , limits: , advanced: , recurring: , @@ -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, diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 9e5108403d..2534d92666 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -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, })), }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b739d8f763..c9ff08313e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/core/getAggregateWorkingHours.ts b/packages/core/getAggregateWorkingHours.ts index 9bc3c68e01..3a3ffbdd22 100644 --- a/packages/core/getAggregateWorkingHours.ts +++ b/packages/core/getAggregateWorkingHours.ts @@ -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["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); }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 74afb2bece..0a2d292d3d 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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> & { - 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>[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; const teamMemberPromises = - eventType.schedulingType === SchedulingType.COLLECTIVE + users.length > 1 ? users.slice(1).map(async function (user) { return { email: user.email || "", diff --git a/packages/features/eventtypes/components/CheckedTeamSelect.tsx b/packages/features/eventtypes/components/CheckedTeamSelect.tsx index c353260184..64255a25eb 100644 --- a/packages/features/eventtypes/components/CheckedTeamSelect.tsx +++ b/packages/features/eventtypes/components/CheckedTeamSelect.tsx @@ -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; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index f2d5cc8a77..bbda3e688a 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -82,8 +82,10 @@ const commons = { bookingLimits: null, hidden: false, userId: 0, + owner: null, workflows: [], users: [user], + hosts: [], metadata: EventTypeMetaDataSchema.parse({}), }; diff --git a/packages/lib/getEventTypeById.ts b/packages/lib/getEventTypeById.ts index 435eed911e..0c32937633 100644 --- a/packages/lib/getEventTypeById.ts +++ b/packages/lib/getEventTypeById.ts @@ -125,6 +125,12 @@ export default async function getEventTypeById({ id: true, }, }, + hosts: { + select: { + isFixed: true, + userId: true, + }, + }, userId: true, price: true, destinationCalendar: true, diff --git a/packages/lib/slots.ts b/packages/lib/slots.ts index c2fa08c2ab..c9344acd3b 100644 --- a/packages/lib/slots.ts +++ b/packages/lib/slots.ts @@ -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); diff --git a/packages/prisma/migrations/20230111183922_add_host_relation/migration.sql b/packages/prisma/migrations/20230111183922_add_host_relation/migration.sql new file mode 100644 index 0000000000..e65204d22b --- /dev/null +++ b/packages/prisma/migrations/20230111183922_add_host_relation/migration.sql @@ -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; \ No newline at end of file diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 9644753ade..f4b4534362 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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") } diff --git a/packages/trpc/server/routers/viewer/eventTypes.tsx b/packages/trpc/server/routers/viewer/eventTypes.tsx index 54cd8a0808..f40960331c 100644 --- a/packages/trpc/server/routers/viewer/eventTypes.tsx +++ b/packages/trpc/server/routers/viewer/eventTypes.tsx @@ -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; diff --git a/packages/trpc/server/routers/viewer/slots.tsx b/packages/trpc/server/routers/viewer/slots.tsx index 0e0e761f8b..1be63e9029 100644 --- a/packages/trpc/server/routers/viewer/slots.tsx +++ b/packages/trpc/server/routers/viewer/slots.tsx @@ -144,6 +144,14 @@ async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer, 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, 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, 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, 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, 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: diff --git a/packages/ui/components/button/Button.tsx b/packages/ui/components/button/Button.tsx index 3e1aac9077..cb22f17c3e 100644 --- a/packages/ui/components/button/Button.tsx +++ b/packages/ui/components/button/Button.tsx @@ -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 {