fix: Set teamMemberEmail server side for CRM RR Skip (#16367)

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Joe Au-Yeung
2024-08-29 14:16:48 -07:00
committed by GitHub
co-authored by Keith Williams zomars
parent ae55e5e264
commit 3aa6878c27
17 changed files with 140 additions and 114 deletions
@@ -1,14 +1,16 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
@@ -23,7 +25,12 @@ const paramsSchema = z.object({
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getServerSession(context);
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
const { rescheduleUid, duration: queryDuration, isInstantMeeting: queryIsInstantMeeting } = context.query;
const {
rescheduleUid,
duration: queryDuration,
isInstantMeeting: queryIsInstantMeeting,
email,
} = context.query;
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
@@ -97,6 +104,29 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
isInstantMeeting: eventData.isInstantEvent && queryIsInstantMeeting ? true : false,
themeBasis: null,
orgBannerUrl: eventData?.team?.parent?.bannerUrl ?? "",
teamMemberEmail: await getTeamMemberEmail(eventData, email as string),
},
};
};
type EventData = RouterOutputs["viewer"]["public"]["event"];
async function getTeamMemberEmail(eventData: EventData, email?: string): Promise<string | null> {
// Pre-requisites
if (!eventData || !email || eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.id);
if (!crmContactOwnerEmail) return null;
// Determine if the contactOwner is a part of the event type
const contactOwnerQuery = await prisma.user.findFirst({
where: {
email: crmContactOwnerEmail,
hosts: {
some: {
eventTypeId: eventData.id,
},
},
},
});
if (!contactOwnerQuery) return null;
return crmContactOwnerEmail;
}
+2
View File
@@ -35,6 +35,7 @@ export default function Type({
eventData,
isInstantMeeting,
orgBannerUrl,
teamMemberEmail,
}: PageProps) {
const searchParams = useSearchParams();
@@ -67,6 +68,7 @@ export default function Type({
eventData.length
)}
orgBannerUrl={orgBannerUrl}
teamMemberEmail={teamMemberEmail}
/>
</main>
);
+3 -12
View File
@@ -175,12 +175,10 @@ describe("getSchedule", () => {
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
bookerEmail: "test@test.com",
teamMemberEmail: "example@example.com",
},
});
expect(scheduleWithLeadSkip.teamMember).toBe("example@example.com");
// only slots where example@example.com is available
expect(scheduleWithLeadSkip).toHaveTimeSlots(
[`11:30:00.000Z`, `12:30:00.000Z`, `13:30:00.000Z`, `14:30:00.000Z`, `15:30:00.000Z`],
@@ -197,12 +195,9 @@ describe("getSchedule", () => {
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
bookerEmail: "testtest@test.com",
},
});
expect(scheduleWithoutLeadSkip.teamMember).toBe(undefined);
// slots where either one of the rr hosts is available
expect(scheduleWithoutLeadSkip).toHaveTimeSlots(
[
@@ -325,12 +320,10 @@ describe("getSchedule", () => {
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
bookerEmail: "test@test.com",
teamMemberEmail: "example@example.com",
},
});
expect(scheduleFixedHostLead.teamMember).toBe("example@example.com");
// show normal slots, example@example + one RR host needs to be available
expect(scheduleFixedHostLead).toHaveTimeSlots(
[
@@ -355,12 +348,10 @@ describe("getSchedule", () => {
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
bookerEmail: "test1@test.com",
teamMemberEmail: "example1@example.com",
},
});
expect(scheduleRRHostLead.teamMember).toBe("example1@example.com");
// slots where example@example (fixed host) + example1@example.com are available together
expect(scheduleRRHostLead).toHaveTimeSlots(
[`07:30:00.000Z`, `08:30:00.000Z`, `09:30:00.000Z`, `10:30:00.000Z`, `11:30:00.000Z`],
@@ -0,0 +1,61 @@
import type { z } from "zod";
import CrmManager from "@calcom/core/crmManager/crmManager";
import { prisma } from "@calcom/prisma";
import type { EventTypeAppMetadataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
export async function getCRMContactOwnerForRRLeadSkip(
bookerEmail: string,
eventTypeId: number
): Promise<string | undefined> {
const eventTypeMetadataQuery = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
select: { metadata: true },
});
const eventTypeMetadata = EventTypeMetaDataSchema.safeParse(eventTypeMetadataQuery?.metadata);
if (!eventTypeMetadata.success || !eventTypeMetadata.data?.apps) return;
const crm = await getCRMManagerWithRRLeadSkip(eventTypeMetadata.data.apps);
if (!crm) return;
const contact = await crm.getContacts(bookerEmail, true);
if (!contact?.length) return;
return contact[0].ownerEmail;
}
async function getCRMManagerWithRRLeadSkip(apps: z.infer<typeof EventTypeAppMetadataSchema>) {
let crmRoundRobinLeadSkip;
for (const appKey in apps) {
const app = apps[appKey as keyof typeof apps];
if (
app.enabled &&
typeof app.appCategories === "object" &&
app.appCategories.some((category: string) => category === "crm") &&
app.roundRobinLeadSkip
) {
crmRoundRobinLeadSkip = app;
break;
}
}
if (!crmRoundRobinLeadSkip) return;
const crmCredential = await prisma.credential.findUnique({
where: {
id: crmRoundRobinLeadSkip.credentialId,
},
include: {
user: {
select: {
email: true,
},
},
},
});
if (!crmCredential) return;
return new CrmManager(crmCredential);
}
+3 -3
View File
@@ -39,7 +39,7 @@ export default class CrmManager {
const contactsToCreate = event.attendees.filter(
(attendee) => !contacts.some((contact) => contact.email === attendee.email)
);
const createdContacts = await this.createContacts(contactsToCreate);
const createdContacts = await this.createContacts(contactsToCreate, event.organizer?.email);
contacts = contacts.concat(createdContacts);
return await crmService?.createEvent(event, contacts);
}
@@ -60,9 +60,9 @@ export default class CrmManager {
return contacts;
}
public async createContacts(contactsToCreate: ContactCreateInput[]) {
public async createContacts(contactsToCreate: ContactCreateInput[], organizerEmail?: string) {
const crmService = await this.getCrmService(this.credential);
const createdContacts = (await crmService?.createContacts(contactsToCreate)) || [];
const createdContacts = (await crmService?.createContacts(contactsToCreate, organizerEmail)) || [];
return createdContacts;
}
}
+2 -1
View File
@@ -839,13 +839,14 @@ const _getOutOfOfficeDays = async ({
type GetUserAvailabilityQuery = Parameters<typeof getUserAvailability>[0];
type GetUserAvailabilityInitialData = NonNullable<Parameters<typeof getUserAvailability>[1]>;
export type GetAvailabilityUser = NonNullable<GetUserAvailabilityInitialData["user"]>;
const _getUsersAvailability = async ({
users,
query,
initialData,
}: {
users: (NonNullable<GetUserAvailabilityInitialData["user"]> & {
users: (GetAvailabilityUser & {
currentBookings?: GetUserAvailabilityInitialData["currentBookings"];
})[];
query: Omit<GetUserAvailabilityQuery, "userId" | "username">;
@@ -47,7 +47,7 @@ export interface IUseBookings {
hashedLink?: string | null;
bookingForm: UseBookingFormReturnType["bookingForm"];
metadata: Record<string, string>;
teamMemberEmail?: string;
teamMemberEmail?: string | null;
}
const getBookingSuccessfulEventPayload = (booking: {
@@ -310,7 +310,6 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
bookingForm,
hashedLink,
metadata,
teamMemberEmail,
handleInstantBooking: createInstantBookingMutation.mutate,
handleRecBooking: createRecurringBookingMutation.mutate,
handleBooking: createBookingMutation.mutate,
+10 -1
View File
@@ -32,6 +32,7 @@ type StoreInitializeType = {
durationConfig?: number[] | null;
org?: string | null;
isInstantMeeting?: boolean;
teamMemberEmail?: string | null;
};
type SeatedEventData = {
@@ -148,6 +149,8 @@ export type BookerStore = {
org?: string | null;
setOrg: (org: string | null | undefined) => void;
teamMemberEmail?: string | null;
};
/**
@@ -253,6 +256,7 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
durationConfig,
org,
isInstantMeeting,
teamMemberEmail,
}: StoreInitializeType) => {
const selectedDateInStore = get().selectedDate;
@@ -265,7 +269,8 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
get().bookingUid === bookingUid &&
get().bookingData?.responses.email === bookingData?.responses.email &&
get().layout === layout &&
get().rescheduledBy === rescheduledBy
get().rescheduledBy === rescheduledBy &&
get().teamMemberEmail
)
return;
set({
@@ -284,6 +289,7 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
selectedDate:
selectedDateInStore ||
(["week_view", "column_view"].includes(layout) ? dayjs().format("YYYY-MM-DD") : null),
teamMemberEmail,
});
if (durationConfig?.includes(Number(getQueryParam("duration")))) {
@@ -372,6 +378,7 @@ export const useInitializeBookerStore = ({
durationConfig,
org,
isInstantMeeting,
teamMemberEmail,
}: StoreInitializeType) => {
const initializeStore = useBookerStore((state) => state.initialize);
useEffect(() => {
@@ -389,6 +396,7 @@ export const useInitializeBookerStore = ({
verifiedEmail,
durationConfig,
isInstantMeeting,
teamMemberEmail,
});
}, [
initializeStore,
@@ -405,5 +413,6 @@ export const useInitializeBookerStore = ({
verifiedEmail,
durationConfig,
isInstantMeeting,
teamMemberEmail,
]);
};
@@ -85,6 +85,7 @@ export interface BookerProps {
*/
hashedLink?: string | null;
isInstantMeeting?: boolean;
teamMemberEmail?: string | null;
}
export type WrappedBookerPropsMain = {
@@ -65,7 +65,7 @@ export const useScheduleForEvent = ({
dayCount,
selectedDate,
orgSlug,
bookerEmail,
teamMemberEmail,
}: {
prefetchNextMonth?: boolean;
username?: string | null;
@@ -77,7 +77,7 @@ export const useScheduleForEvent = ({
dayCount?: number | null;
selectedDate?: string | null;
orgSlug?: string;
bookerEmail?: string;
teamMemberEmail?: string | null;
} = {}) => {
const { timezone } = useTimePreferences();
const event = useEvent();
@@ -107,7 +107,7 @@ export const useScheduleForEvent = ({
duration: durationFromStore ?? duration,
isTeamEvent: pathname?.indexOf("/team/") !== -1 || isTeam,
orgSlug,
bookerEmail,
teamMemberEmail,
});
return {
@@ -20,7 +20,7 @@ export type BookingOptions = {
bookingUid?: string;
seatReferenceUid?: string;
hashedLink?: string | null;
teamMemberEmail?: string;
teamMemberEmail?: string | null;
orgSlug?: string;
};
@@ -16,7 +16,7 @@ export type UseScheduleWithCacheArgs = {
rescheduleUid?: string | null;
isTeamEvent?: boolean;
orgSlug?: string;
bookerEmail?: string;
teamMemberEmail?: string | null;
};
export const useSchedule = ({
@@ -33,7 +33,7 @@ export const useSchedule = ({
rescheduleUid,
isTeamEvent,
orgSlug,
bookerEmail,
teamMemberEmail,
}: UseScheduleWithCacheArgs) => {
const [startTime, endTime] = useTimesForSchedule({
month,
@@ -60,7 +60,7 @@ export const useSchedule = ({
duration: duration ? `${duration}` : undefined,
rescheduleUid,
orgSlug,
bookerEmail,
teamMemberEmail,
},
{
trpc: {
@@ -21,7 +21,6 @@ import { useBookerStore, useInitializeBookerStore } from "@calcom/features/booki
import { useEvent, useScheduleForEvent } from "@calcom/features/bookings/Booker/utils/event";
import { useBrandColors } from "@calcom/features/bookings/Booker/utils/use-brand-colors";
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
@@ -122,7 +121,6 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
* Prioritize dateSchedule load
* Component will render but use data already fetched from here, and no duplicate requests will be made
* */
const debouncedFormEmail = useDebounce(bookerForm.formEmail, 600);
const schedule = useScheduleForEvent({
prefetchNextMonth,
username: props.username,
@@ -132,14 +130,14 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
month: props.month,
duration: props.duration,
selectedDate,
bookerEmail: debouncedFormEmail,
teamMemberEmail: props.teamMemberEmail,
});
const bookings = useBookings({
event,
hashedLink: props.hashedLink,
bookingForm: bookerForm.bookingForm,
metadata: metadata ?? {},
teamMemberEmail: schedule.data?.teamMember,
teamMemberEmail: props.teamMemberEmail,
});
const verifyCode = useVerifyCode({
@@ -21,7 +21,6 @@ type UseHandleBookingProps = {
};
metadata: Record<string, string>;
hashedLink?: string | null;
teamMemberEmail?: string;
handleBooking: (input: UseCreateBookingInput) => void;
handleInstantBooking: (input: BookingCreateBody) => void;
handleRecBooking: (input: BookingCreateBody[]) => void;
@@ -33,7 +32,6 @@ export const useHandleBookEvent = ({
event,
metadata,
hashedLink,
teamMemberEmail,
handleBooking,
handleInstantBooking,
handleRecBooking,
@@ -52,6 +50,7 @@ export const useHandleBookEvent = ({
const seatedEventData = useBookerStore((state) => state.seatedEventData);
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const orgSlug = useBookerStore((state) => state.org);
const teamMemberEmail = useBookerStore((state) => state.teamMemberEmail);
const handleBookEvent = () => {
const values = bookingForm.getValues();
+1 -2
View File
@@ -246,7 +246,7 @@ export const bookingCreateBodySchema = z.object({
hashedLink: z.string().nullish(),
seatReferenceUid: z.string().optional(),
orgSlug: z.string().optional(),
teamMemberEmail: z.string().optional(),
teamMemberEmail: z.string().nullish(),
});
export const requiredCustomInputSchema = z.union([
@@ -293,7 +293,6 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge(
.optional(),
luckyUsers: z.array(z.number()).optional(),
customInputs: z.undefined().optional(),
teamMemberEmail: z.string().optional(),
})
);
@@ -24,7 +24,7 @@ export const getScheduleSchema = z
// whether to do team event or user event
isTeamEvent: z.boolean().optional().default(false),
orgSlug: z.string().optional(),
bookerEmail: z.string().optional(),
teamMemberEmail: z.string().nullable().optional(),
})
.transform((val) => {
// Need this so we can pass a single username in the query string form public API
@@ -1,12 +1,10 @@
// eslint-disable-next-line no-restricted-imports
import { countBy } from "lodash";
import { v4 as uuid } from "uuid";
import type z from "zod";
import CrmManager from "@calcom/core/crmManager/crmManager";
import { getAggregatedAvailability } from "@calcom/core/getAggregatedAvailability";
import { getBusyTimesForLimitChecks } from "@calcom/core/getBusyTimes";
import type { CurrentSeats, IFromUser, IToUser } from "@calcom/core/getUserAvailability";
import type { CurrentSeats, IFromUser, IToUser, GetAvailabilityUser } from "@calcom/core/getUserAvailability";
import { getUsersAvailability } from "@calcom/core/getUserAvailability";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
@@ -32,7 +30,6 @@ import { SchedulingType } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { EventTypeAppMetadataSchema } from "@calcom/prisma/zod-utils";
import type { EventBusyDate } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
@@ -322,57 +319,6 @@ export interface IGetAvailableSlots {
emoji?: string | undefined;
}[]
>;
teamMember?: string | undefined;
}
async function getCRMContactOwnerForRRLeadSkip(
bookerEmail: string,
apps?: z.infer<typeof EventTypeAppMetadataSchema>
) {
if (!apps) return;
const crm = await getCRMManagerWithRRLeadSkip(apps);
if (!crm) return;
const contact = await crm.getContacts(bookerEmail, true);
if (contact?.length) {
return contact[0].ownerEmail;
}
}
async function getCRMManagerWithRRLeadSkip(apps: z.infer<typeof EventTypeAppMetadataSchema>) {
let crmRoundRobinLeadSkip;
for (const appKey in apps) {
const app = apps[appKey as keyof typeof apps];
if (
app.enabled &&
typeof app.appCategories === "object" &&
app.appCategories.some((category: string) => category === "crm") &&
app.roundRobinLeadSkip
) {
crmRoundRobinLeadSkip = app;
break;
}
}
if (crmRoundRobinLeadSkip) {
const crmCredential = await prisma.credential.findUnique({
where: {
id: crmRoundRobinLeadSkip.credentialId,
},
include: {
user: {
select: {
email: true,
},
},
},
});
if (crmCredential) {
return new CrmManager(crmCredential);
}
}
return;
}
export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Promise<IGetAvailableSlots> {
@@ -435,8 +381,6 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
}
let currentSeats: CurrentSeats | undefined;
let teamMember: string | undefined;
let hosts =
eventType.hosts?.length && eventType.schedulingType
? eventType.hosts
@@ -466,25 +410,18 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
hosts = hosts.filter((host) => host.user.id === originalRescheduledBooking?.userId || 0);
}
let usersWithCredentials = hosts.map(({ isFixed, user }) => ({ isFixed, ...user }));
const teamMemberHost = hosts.find((host) => host.user.email === input?.teamMemberEmail);
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN && input.bookerEmail) {
const crmContactOwner = await getCRMContactOwnerForRRLeadSkip(
input.bookerEmail,
eventType?.metadata?.apps
);
const contactOwnerHost = hosts.find((host) => host.user.email === crmContactOwner);
// If the requested team member is a fixed host proceed as normal else get availability like the requested member is a fixed host
const usersWithCredentials =
!input.teamMemberEmail || !teamMemberHost || teamMemberHost.isFixed
? hosts.map(({ isFixed, user }) => ({ isFixed, ...user }))
: hosts.reduce((usersArray, host) => {
if (host.isFixed || host.user.email === input.teamMemberEmail)
usersArray.push({ ...host.user, isFixed: host.isFixed });
if (contactOwnerHost) {
teamMember = contactOwnerHost.user.email;
const contactOwnerIsRRHost = !contactOwnerHost.isFixed;
usersWithCredentials = usersWithCredentials.filter(
(user) => user.email !== contactOwnerHost.user.email && (!contactOwnerIsRRHost || user.isFixed)
);
usersWithCredentials.push({ ...contactOwnerHost.user, isFixed: true });
}
}
return usersArray;
}, [] as (GetAvailabilityUser & { isFixed: boolean })[]);
const durationToUse = input.duration || 0;
@@ -894,7 +831,6 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
return {
slots: withinBoundsSlotsMappedToDate,
teamMember,
};
}