@@ -391,9 +399,9 @@ const Route = ({
) : null}
- {route.attributesQueryBuilderState && attributesQueryBuilderConfig && (
+ {route.attributesQueryBuilderState && attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
{
onChangeTeamMembersQuery(
@@ -416,20 +424,21 @@ const Route = ({
{t("fallback_attribute_logic_description")}
- {route.fallbackAttributesQueryBuilderState && attributesQueryBuilderConfig && (
- {
- onChangeFallbackTeamMembersQuery(
- route,
- immutableTree,
- attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
- );
- }}
- renderBuilder={renderBuilder}
- />
- )}
+ {route.fallbackAttributesQueryBuilderState &&
+ attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
+ {
+ onChangeFallbackTeamMembersQuery(
+ route,
+ immutableTree,
+ attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
+ );
+ }}
+ renderBuilder={renderBuilder}
+ />
+ )}
) : null;
@@ -600,7 +609,7 @@ const buildState = <
queryValue,
config,
}: T) => ({
- tree: QbUtils.checkTree(QbUtils.loadTree(queryValue), config as unknown as Config),
+ tree: QbUtils.checkTree(QbUtils.loadTree(queryValue as JsonTree), config as unknown as Config),
config,
});
@@ -736,7 +745,7 @@ const Routes = ({
const attributesQueryBuilderConfig = attributes
? getQueryBuilderConfigForAttributes({
attributes: attributes,
- form: hookForm.getValues(),
+ dynamicOperandFields: hookForm.getValues().fields,
})
: null;
diff --git a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts
index d95fc18a14..0e92529ea6 100644
--- a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts
+++ b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts
@@ -61,11 +61,10 @@ async function addAttributeRoutingRule(page: Page) {
});
const attributeValueSelector = attributeQueryBuilder.locator(".rule--value").nth(0);
- const numOfOptionsInAttribute = 3;
await selectFirstValueForAttributeValue({
fromLocator: attributeValueSelector,
// Select 'Value of Field Short Text' option
- option: numOfOptionsInAttribute + 1,
+ option: 1,
});
}
diff --git a/packages/app-store/routing-forms/trpc/formMutation.handler.ts b/packages/app-store/routing-forms/trpc/formMutation.handler.ts
index 08299432b7..8054b14514 100644
--- a/packages/app-store/routing-forms/trpc/formMutation.handler.ts
+++ b/packages/app-store/routing-forms/trpc/formMutation.handler.ts
@@ -13,7 +13,7 @@ import { isFormCreateEditAllowed } from "../lib/isFormCreateEditAllowed";
import isRouter from "../lib/isRouter";
import isRouterLinkedField from "../lib/isRouterLinkedField";
import type { SerializableForm } from "../types/types";
-import { zodFields, zodRouterRoute, zodRoutes, queryValueSaveValidationSchema } from "../zod";
+import { zodFields, zodRouterRoute, zodRoutes } from "../zod";
import type { TFormMutationInputSchema } from "./formMutation.schema";
interface FormMutationHandlerOptions {
@@ -24,58 +24,6 @@ interface FormMutationHandlerOptions {
input: TFormMutationInputSchema;
}
-function throwIfInvalidQueryValueToBeSaved({
- routes,
-}: {
- routes: FormMutationHandlerOptions["input"]["routes"];
-}) {
- if (!routes) {
- return;
- }
- routes.forEach((route, routeIndex) => {
- if (isRouter(route)) {
- return;
- }
- // We use separate schema for queryValye here which is much more strict
- // It allows that we are still lenient with schema while reading the queryValue but while saving it we are strict
- const parsedFormFieldsQueryValue = queryValueSaveValidationSchema.safeParse(route.queryValue);
- if (!parsedFormFieldsQueryValue.success) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: `Route ${routeIndex + 1} form fields: ${getErrorMessageFromZodError(
- parsedFormFieldsQueryValue.error
- )}`,
- });
- }
-
- const parsedAttributesQueryValue = queryValueSaveValidationSchema.safeParse(route.attributesQueryValue);
- if (!parsedAttributesQueryValue.success) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: `Route ${routeIndex + 1} attributes: ${getErrorMessageFromZodError(
- parsedAttributesQueryValue.error
- )}`,
- });
- }
-
- const parsedFallbackAttributesQueryValue = queryValueSaveValidationSchema.safeParse(
- route.fallbackAttributesQueryValue
- );
- if (!parsedFallbackAttributesQueryValue.success) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: `Route ${routeIndex + 1} fallback attributes: ${getErrorMessageFromZodError(
- parsedFallbackAttributesQueryValue.error
- )}`,
- });
- }
- });
-
- function getErrorMessageFromZodError(zodError: Zod.ZodError) {
- return zodError.errors.map((err) => err.message).join(", ");
- }
-}
-
export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOptions) => {
const { user, prisma } = ctx;
const { name, id, description, disabled, addFallback, duplicateFrom, shouldConnect } = input;
@@ -86,16 +34,9 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
code: "FORBIDDEN",
});
}
- let { routes: inputRoutes } = input;
- // Ensures that wrong queryValue is not saved
- // This is super useful when we make some wrong change in RAQB config accidentally and end up
- // - Removing an operator support from a rule
- // - Populating things wrong for any reason in RAQB
- // It would just ensure that the wrong queryValue is not saved. Because it is impossible to fix it once saved. User would have to manually fix it then.
- throwIfInvalidQueryValueToBeSaved({ routes: inputRoutes });
+ let { routes: inputRoutes, fields: inputFields } = input;
- let { fields: inputFields } = input;
inputFields = inputFields || [];
inputRoutes = inputRoutes || [];
type InputFields = typeof inputFields;
diff --git a/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts
index 5ebe7aa17e..9ebbcaeedf 100644
--- a/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts
+++ b/packages/app-store/routing-forms/trpc/getAttributesForTeam.handler.ts
@@ -3,7 +3,7 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
-import { getAttributesForTeam } from "../lib/getAttributes";
+import { getAttributesForTeam } from "@calcom/lib/raqb/getAttributes";
import type { TGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
type GetAttributesForTeamHandlerOptions = {
diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts
index fd63c3ac41..4a87665d76 100644
--- a/packages/app-store/routing-forms/trpc/response.handler.ts
+++ b/packages/app-store/routing-forms/trpc/response.handler.ts
@@ -3,14 +3,14 @@ import { z } from "zod";
import { emailSchema } from "@calcom/lib/emailSchema";
import logger from "@calcom/lib/logger";
+import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { PrismaClient } from "@calcom/prisma";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@calcom/trpc/server";
-// import { RoutingFormFieldType } from "../lib/FieldTypes";
-import { findTeamMembersMatchingAttributeLogicOfRoute } from "../lib/findTeamMembersMatchingAttributeLogicOfRoute";
import { getSerializableForm } from "../lib/getSerializableForm";
+import isRouter from "../lib/isRouter";
import type { FormResponse } from "../types/types";
import type { TResponseInputSchema } from "./response.schema";
import { onFormSubmission } from "./utils";
@@ -119,34 +119,41 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
}
const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId);
- if (!chosenRoute) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Chosen route not found",
- });
- }
+ let teamMemberIdsMatchingAttributeLogic: number[] | null = null;
- const teamMembersMatchingAttributeLogicWithResult =
- form.teamId && chosenRouteId
- ? await findTeamMembersMatchingAttributeLogicOfRoute({
- response,
- route: chosenRoute,
- form: serializableForm,
+ if (chosenRoute) {
+ if (isRouter(chosenRoute)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Chosen route is a router",
+ });
+ }
+ const teamMembersMatchingAttributeLogicWithResult = form.teamId
+ ? await findTeamMembersMatchingAttributeLogic({
+ dynamicFieldValueOperands: {
+ response,
+ fields: serializableForm.fields || [],
+ },
+ attributesQueryValue: chosenRoute.attributesQueryValue ?? null,
+ fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue,
teamId: form.teamId,
})
: null;
- moduleLogger.debug(
- "teamMembersMatchingAttributeLogic",
- safeStringify({ teamMembersMatchingAttributeLogicWithResult })
- );
+ moduleLogger.debug(
+ "teamMembersMatchingAttributeLogic",
+ safeStringify({ teamMembersMatchingAttributeLogicWithResult })
+ );
- const teamMemberIdsMatchingAttributeLogic =
- teamMembersMatchingAttributeLogicWithResult?.teamMembersMatchingAttributeLogic
- ? teamMembersMatchingAttributeLogicWithResult.teamMembersMatchingAttributeLogic.map(
- (member) => member.userId
- )
- : null;
+ teamMemberIdsMatchingAttributeLogic =
+ teamMembersMatchingAttributeLogicWithResult?.teamMembersMatchingAttributeLogic
+ ? teamMembersMatchingAttributeLogicWithResult.teamMembersMatchingAttributeLogic.map(
+ (member) => member.userId
+ )
+ : null;
+ } else {
+ // It currently happens for a Router route. Such a route id isn't present in the form.routes
+ }
// const chosenRouteName = `Route ${chosenRouteIndex + 1}`;
@@ -218,15 +225,18 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
{ ...serializableFormWithFields, userWithEmails },
dbFormResponse.response as FormResponse,
dbFormResponse.id,
- "action" in chosenRoute ? chosenRoute.action : undefined
+ chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : undefined) : undefined
);
return {
isPreview: false,
formResponse: dbFormResponse,
teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic,
- attributeRoutingConfig:
- "attributeRoutingConfig" in chosenRoute ? chosenRoute.attributeRoutingConfig ?? null : null,
+ attributeRoutingConfig: chosenRoute
+ ? "attributeRoutingConfig" in chosenRoute
+ ? chosenRoute.attributeRoutingConfig
+ : null
+ : null,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
diff --git a/packages/app-store/routing-forms/zod.ts b/packages/app-store/routing-forms/zod.ts
index 04a0012b13..5402e6ba68 100644
--- a/packages/app-store/routing-forms/zod.ts
+++ b/packages/app-store/routing-forms/zod.ts
@@ -1,5 +1,7 @@
import { z } from "zod";
+import { raqbQueryValueSchema } from "@calcom/lib/raqb/zod";
+
import { routingFormAppDataSchemas } from "./appDataSchemas";
export const zodNonRouterField = z.object({
@@ -50,12 +52,6 @@ export const zodRouterFieldView = zodRouterField.extend({
export const zodFieldView = z.union([zodNonRouterFieldView, zodRouterFieldView]);
export const zodFieldsView = z.array(zodFieldView).optional();
-const queryValueSchema = z.object({
- id: z.string().optional(),
- type: z.union([z.literal("group"), z.literal("switch_group")]),
- children1: z.any(),
- properties: z.any(),
-});
export enum RouteActionType {
CustomPageMessage = "customPageMessage",
@@ -65,71 +61,6 @@ export enum RouteActionType {
export const routeActionTypeSchema = z.nativeEnum(RouteActionType);
-export const children1Schema = z.object({
- children1: z
- .record(
- z.object({
- type: z.string().optional(),
- properties: z
- .object({
- field: z.any().optional(),
- operator: z.any().optional(),
- value: z.any().optional(),
- valueSrc: z.any().optional(),
- })
- .optional(),
- })
- )
- .optional()
- // Be very careful and lenient here. Just ensure that the rule isn't invalid without breaking anything
- .superRefine((children1, ctx) => {
- if (!children1) return;
- const isObject = (value: unknown): value is Record
=>
- typeof value === "object" && value !== null;
- Object.entries(children1).forEach(([, _rule]) => {
- const rule = _rule as unknown;
- if (!isObject(rule) || rule.type !== "rule") return;
- if (!isObject(rule.properties)) return;
-
- const value = rule.properties.value || [];
- const valueSrc = rule.properties.valueSrc;
- if (!(value instanceof Array) || !(valueSrc instanceof Array)) {
- return;
- }
-
- if (!valueSrc.length) {
- // If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty
- return;
- }
-
- // MultiSelect array can be 2D array
- const flattenedValues = value.flat();
-
- const validValues = flattenedValues.filter((value: unknown) => {
- // Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field
- // Also, it is possible that RAQB has some requirements to support null or empty string values.
- if (value === undefined) return false;
- return true;
- });
-
- if (!validValues.length) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Looks like you are trying to create a rule with no value",
- });
- }
- });
- }),
-});
-/**
- * Stricter schema for validating before saving to DB
- * It doesn't decide what will be saved, it is just to validate the data before saving
- */
-export const queryValueSaveValidationSchema = queryValueSchema
- .omit({ children1: true })
- .merge(children1Schema)
- .nullish();
-
export const attributeRoutingConfigSchema = z
.object({
skipContactOwner: z.boolean().optional(),
@@ -147,17 +78,17 @@ export const zodNonRouterRoute = z.object({
* RAQB query value for form fields
* BRANDED to ensure we don't give it Attributes
*/
- queryValue: queryValueSchema.brand<"formFieldsQueryValue">(),
+ queryValue: raqbQueryValueSchema.brand<"formFieldsQueryValue">(),
/**
* RAQB query value for attributes. It is only applicable for Team Events as it is used to find matching team members
* BRANDED to ensure we don't give it Form Fields
*/
- attributesQueryValue: queryValueSchema.brand<"attributesQueryValue">().optional(),
+ attributesQueryValue: raqbQueryValueSchema.optional(),
/**
* RAQB query value for fallback of `attributesQueryValue`
* BRANDED to ensure we don't give it Form Fields, It needs Attributes
*/
- fallbackAttributesQueryValue: queryValueSchema.brand<"attributesQueryValue">().optional(),
+ fallbackAttributesQueryValue: raqbQueryValueSchema.optional(),
/**
* Whether the route is a fallback if no other routes match
*/
diff --git a/packages/features/bookings/Booker/components/hooks/useBookings.ts b/packages/features/bookings/Booker/components/hooks/useBookings.ts
index 9480f3fb1d..208ab06b85 100644
--- a/packages/features/bookings/Booker/components/hooks/useBookings.ts
+++ b/packages/features/bookings/Booker/components/hooks/useBookings.ts
@@ -180,6 +180,10 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
const createBookingMutation = useMutation({
mutationFn: createBooking,
onSuccess: (booking) => {
+ if (booking.isDryRun) {
+ showToast(t("booking_dry_run_successful"), "success");
+ return;
+ }
const { uid, paymentUid } = booking;
const fullName = getFullName(bookingForm.getValues("responses.name"));
@@ -305,6 +309,12 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb
mutationFn: createRecurringBooking,
onSuccess: async (bookings) => {
const booking = bookings[0] || {};
+
+ if (booking.isDryRun) {
+ showToast(t("booking_dry_run_successful"), "success");
+ return;
+ }
+
const { uid } = booking;
if (!uid) {
diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx
index c98e490bfa..0a362eccd0 100644
--- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx
+++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx
@@ -48,6 +48,8 @@ export const mapBookingToMutationInput = ({
const routingFormResponseId = routingFormResponseIdParam ? Number(routingFormResponseIdParam) : undefined;
const skipContactOwner = searchParams.get("cal.skipContactOwner") === "true";
const reroutingFormResponses = searchParams.get("cal.reroutingFormResponses");
+ const isBookingDryRun = searchParams.get("cal.isBookingDryRun") === "true";
+
return {
...values,
user: username,
@@ -74,6 +76,7 @@ export const mapBookingToMutationInput = ({
skipContactOwner,
// In case of rerouting, the form responses are actually the responses that we need to update.
reroutingFormResponses: reroutingFormResponses ? JSON.parse(reroutingFormResponses) : undefined,
+ _isDryRun: isBookingDryRun,
};
};
diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts
index f7955d5556..6912b023b7 100644
--- a/packages/features/bookings/lib/handleNewBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking.ts
@@ -188,6 +188,155 @@ function buildLuckyUsersWithJustContactOwner({
return luckyUsers;
}
+type CreatedBooking = Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number };
+
+const buildDryRunBooking = ({
+ eventTypeId,
+ organizerUser,
+ eventName,
+ startTime,
+ endTime,
+ contactOwnerFromReq,
+ contactOwnerEmail,
+ allHostUsers,
+ isManagedEventType,
+}: {
+ eventTypeId: number;
+ organizerUser: {
+ id: number;
+ name: string | null;
+ username: string | null;
+ email: string;
+ timeZone: string;
+ };
+ eventName: string;
+ startTime: string;
+ endTime: string;
+ contactOwnerFromReq: string | null;
+ contactOwnerEmail: string | null;
+ allHostUsers: { id: number }[];
+ isManagedEventType: boolean;
+}) => {
+ const booking = {
+ id: -101,
+ uid: "DRY_RUN_UID",
+ iCalUID: "DRY_RUN_ICAL_UID",
+ status: BookingStatus.ACCEPTED,
+ eventTypeId: eventTypeId,
+ user: organizerUser,
+ userId: organizerUser.id,
+ title: eventName,
+ startTime: new Date(startTime),
+ endTime: new Date(endTime),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ attendees: [],
+ references: [],
+ payment: [],
+ oneTimePassword: null,
+ smsReminderNumber: null,
+ metadata: {},
+ idempotencyKey: null,
+ userPrimaryEmail: null,
+ description: null,
+ customInputs: null,
+ responses: null,
+ location: null,
+ paid: false,
+ destinationCalendar: null,
+ cancellationReason: null,
+ rejectionReason: null,
+ dynamicEventSlugRef: null,
+ dynamicGroupSlugRef: null,
+ rescheduledFrom: null,
+ fromReschedule: null,
+ recurringEventId: null,
+ seatsReferences: [],
+ workflowReminders: [],
+ scheduledJobs: [],
+ rescheduledTo: null,
+ rescheduledBy: null,
+ destinationCalendarId: null,
+ reassignReason: null,
+ reassignById: null,
+ rescheduled: false,
+ confirmed: false,
+ isRecurringEvent: false,
+ isRecorded: false,
+ iCalSequence: 0,
+ rating: null,
+ ratingFeedback: null,
+ noShowHost: null,
+ cancelledBy: null,
+ } as CreatedBooking;
+
+ /**
+ * Troubleshooting data
+ */
+ const troubleshooterData = {
+ organizerUserId: organizerUser.id,
+ eventTypeId,
+ askedContactOwnerEmail: contactOwnerFromReq,
+ usedContactOwnerEmail: contactOwnerEmail,
+ allHostUsers: allHostUsers.map((user) => user.id),
+ isManagedEventType: isManagedEventType,
+ };
+
+ return {
+ booking,
+ troubleshooterData,
+ };
+};
+
+const buildDryRunEventManager = () => {
+ return {
+ create: async () => ({ results: [], referencesToCreate: [] }),
+ reschedule: async () => ({ results: [], referencesToCreate: [] }),
+ };
+};
+
+function buildTroubleshooterData({
+ eventType,
+}: {
+ eventType: {
+ id: number;
+ slug: string;
+ };
+}) {
+ const troubleshooterData: {
+ organizerUser: {
+ id: number;
+ } | null;
+ eventType: {
+ id: number;
+ slug: string;
+ };
+ allHostUsers: number[];
+ luckyUsers: number[];
+ luckyUserPool: number[];
+ fixedUsers: number[];
+ luckyUsersFromFirstBooking: number[];
+ usedContactOwnerEmail: string | null;
+ askedContactOwnerEmail: string | null;
+ isManagedEventType: boolean;
+ } = {
+ organizerUser: null,
+ eventType: {
+ id: eventType.id,
+ slug: eventType.slug,
+ },
+ luckyUsers: [],
+ luckyUserPool: [],
+ fixedUsers: [],
+ luckyUsersFromFirstBooking: [],
+ usedContactOwnerEmail: null,
+ allHostUsers: [],
+ askedContactOwnerEmail: null,
+ isManagedEventType: false,
+ };
+ return troubleshooterData;
+}
+
async function handler(
req: NextApiRequest & {
userId?: number | undefined;
@@ -243,9 +392,14 @@ async function handler(
routedTeamMemberIds,
reroutingFormResponses,
routingFormResponseId,
+ _isDryRun: isDryRun = false,
...reqBody
} = bookingData;
+ let troubleshooterData = buildTroubleshooterData({
+ eventType,
+ });
+
const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug);
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
@@ -315,7 +469,7 @@ async function handler(
const skipContactOwner = reqBody.skipContactOwner ?? false;
const contactOwnerEmail = skipContactOwner ? null : contactOwnerFromReq;
- let users = await loadAndValidateUsers({
+ const allHostUsers = await loadAndValidateUsers({
req,
eventType,
eventTypeId,
@@ -325,6 +479,9 @@ async function handler(
contactOwnerEmail,
});
+ // We filter out users but ensure allHostUsers remain same.
+ let users = allHostUsers;
+
let { locationBodyString, organizerOrFirstDynamicGroupMemberDefaultLocationUrl } = getLocationValuesForDb(
dynamicUserList,
users,
@@ -545,6 +702,12 @@ async function handler(
// Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer.
users = [...fixedUserPool, ...luckyUsers];
luckyUserResponse = { luckyUsers: luckyUsers.map((u) => u.id) };
+ troubleshooterData = {
+ ...troubleshooterData,
+ luckyUsers: luckyUsers.map((u) => u.id),
+ fixedUsers: fixedUserPool.map((u) => u.id),
+ luckyUserPool: luckyUserPool.map((u) => u.id),
+ };
} else if (req.body.allRecurringDates && eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
// all recurring slots except the first one
const luckyUsersFromFirstBooking = luckyUsers
@@ -552,6 +715,11 @@ async function handler(
: [];
const fixedHosts = eventTypeWithUsers.users.filter((user: IsFixedAwareUser) => user.isFixed);
users = [...fixedHosts, ...luckyUsersFromFirstBooking];
+ troubleshooterData = {
+ ...troubleshooterData,
+ luckyUsersFromFirstBooking: luckyUsersFromFirstBooking.map((u) => u.id),
+ fixedUsers: fixedHosts.map((u) => u.id),
+ };
}
}
@@ -755,10 +923,12 @@ async function handler(
value: platformBookingLocation ?? bookingLocation,
optionValue: "",
};
+
+ const eventName = getEventName(eventNameObject);
let evt: CalendarEvent = {
bookerUrl,
type: eventType.slug,
- title: getEventName(eventNameObject), //this needs to be either forced in english, or fetched for each attendee and organizer separately
+ title: eventName, //this needs to be either forced in english, or fetched for each attendee and organizer separately
description: eventType.description,
additionalNotes,
customInputs,
@@ -913,6 +1083,8 @@ async function handler(
email: null,
},
paymentRequired: false,
+ isDryRun: isDryRun,
+ ...(isDryRun ? { troubleshooterData } : {}),
};
return {
...bookingResponse,
@@ -943,8 +1115,7 @@ async function handler(
let results: EventResult[] = [];
let referencesToCreate: PartialReference[] = [];
- let booking: (Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number }) | null =
- null;
+ let booking: CreatedBooking | null = null;
loggerWithEventDetails.debug(
"Going to create booking in DB now",
@@ -958,73 +1129,91 @@ async function handler(
);
try {
- booking = await createBooking({
- uid,
- rescheduledBy: reqBody.rescheduledBy,
- routingFormResponseId: routingFormResponseId,
- reroutingFormResponses: reroutingFormResponses ?? null,
- reqBody: {
- user: reqBody.user,
- metadata: reqBody.metadata,
- recurringEventId: reqBody.recurringEventId,
- },
- eventType: {
- eventTypeData: eventType,
- id: eventTypeId,
- slug: eventTypeSlug,
- organizerUser,
- isConfirmedByDefault,
- paymentAppData,
- },
- input: {
- bookerEmail,
- rescheduleReason,
- changedOrganizer,
- smsReminderNumber,
- responses,
- },
- evt,
- originalRescheduledBooking,
- });
-
- if (booking?.userId) {
- const usersRepository = new UsersRepository();
- await usersRepository.updateLastActiveAt(booking.userId);
- }
-
- evt.uid = booking?.uid ?? null;
- evt.oneTimePassword = booking?.oneTimePassword ?? null;
-
- if (booking && booking.id && eventType.seatsPerTimeSlot) {
- const currentAttendee = booking.attendees.find(
- (attendee) =>
- attendee.email === req.body.responses.email ||
- (req.body.responses.attendeePhoneNumber &&
- attendee.phoneNumber === req.body.responses.attendeePhoneNumber)
- );
-
- // Save description to bookingSeat
- const uniqueAttendeeId = uuid();
- await prisma.bookingSeat.create({
- data: {
- referenceUid: uniqueAttendeeId,
- data: {
- description: additionalNotes,
- responses,
- },
- booking: {
- connect: {
- id: booking.id,
- },
- },
- attendee: {
- connect: {
- id: currentAttendee?.id,
- },
- },
+ if (!isDryRun) {
+ booking = await createBooking({
+ uid,
+ rescheduledBy: reqBody.rescheduledBy,
+ routingFormResponseId: routingFormResponseId,
+ reroutingFormResponses: reroutingFormResponses ?? null,
+ reqBody: {
+ user: reqBody.user,
+ metadata: reqBody.metadata,
+ recurringEventId: reqBody.recurringEventId,
},
+ eventType: {
+ eventTypeData: eventType,
+ id: eventTypeId,
+ slug: eventTypeSlug,
+ organizerUser,
+ isConfirmedByDefault,
+ paymentAppData,
+ },
+ input: {
+ bookerEmail,
+ rescheduleReason,
+ changedOrganizer,
+ smsReminderNumber,
+ responses,
+ },
+ evt,
+ originalRescheduledBooking,
});
- evt.attendeeSeatId = uniqueAttendeeId;
+
+ if (booking?.userId) {
+ const usersRepository = new UsersRepository();
+ await usersRepository.updateLastActiveAt(booking.userId);
+ }
+
+ evt.uid = booking.uid ?? null;
+ evt.oneTimePassword = booking.oneTimePassword ?? null;
+ if (booking && booking.id && eventType.seatsPerTimeSlot) {
+ const currentAttendee = booking.attendees.find(
+ (attendee) =>
+ attendee.email === req.body.responses.email ||
+ (req.body.responses.attendeePhoneNumber &&
+ attendee.phoneNumber === req.body.responses.attendeePhoneNumber)
+ );
+
+ // Save description to bookingSeat
+ const uniqueAttendeeId = uuid();
+ await prisma.bookingSeat.create({
+ data: {
+ referenceUid: uniqueAttendeeId,
+ data: {
+ description: additionalNotes,
+ responses,
+ },
+ booking: {
+ connect: {
+ id: booking.id,
+ },
+ },
+ attendee: {
+ connect: {
+ id: currentAttendee?.id,
+ },
+ },
+ },
+ });
+ evt.attendeeSeatId = uniqueAttendeeId;
+ }
+ } else {
+ const { booking: dryRunBooking, troubleshooterData: _troubleshooterData } = buildDryRunBooking({
+ eventTypeId,
+ organizerUser,
+ eventName,
+ startTime: reqBody.start,
+ endTime: reqBody.end,
+ contactOwnerFromReq,
+ contactOwnerEmail,
+ allHostUsers,
+ isManagedEventType,
+ });
+ booking = dryRunBooking;
+ troubleshooterData = {
+ ...troubleshooterData,
+ ..._troubleshooterData,
+ };
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
@@ -1041,7 +1230,9 @@ async function handler(
// After polling videoBusyTimes, credentials might have been changed due to refreshment, so query them again.
const credentials = await refreshCredentials(allCredentials);
- const eventManager = new EventManager({ ...organizerUser, credentials }, eventType?.metadata?.apps);
+ const eventManager = !isDryRun
+ ? new EventManager({ ...organizerUser, credentials }, eventType?.metadata?.apps)
+ : buildDryRunEventManager();
let videoCallUrl;
@@ -1258,28 +1449,32 @@ async function handler(
)
);
- sendRoundRobinRescheduledEmailsAndSMS(
- copyEventAdditionalInfo,
- rescheduledMembers,
- eventType.metadata
- );
- sendRoundRobinScheduledEmailsAndSMS({
- calEvent: copyEventAdditionalInfo,
- members: newBookedMembers,
- eventTypeMetadata: eventType.metadata,
- });
- sendRoundRobinCancelledEmailsAndSMS(copyEventAdditionalInfo, cancelledMembers, eventType.metadata);
+ if (!isDryRun) {
+ sendRoundRobinRescheduledEmailsAndSMS(
+ copyEventAdditionalInfo,
+ rescheduledMembers,
+ eventType.metadata
+ );
+ sendRoundRobinScheduledEmailsAndSMS({
+ calEvent: copyEventAdditionalInfo,
+ members: newBookedMembers,
+ eventTypeMetadata: eventType.metadata,
+ });
+ sendRoundRobinCancelledEmailsAndSMS(copyEventAdditionalInfo, cancelledMembers, eventType.metadata);
+ }
} else {
- // send normal rescheduled emails (non round robin event, where organizers stay the same)
- await sendRescheduledEmailsAndSMS(
- {
- ...copyEvent,
- additionalInformation: metadata,
- additionalNotes, // Resets back to the additionalNote input and not the override value
- cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
- },
- eventType?.metadata
- );
+ if (!isDryRun) {
+ // send normal rescheduled emails (non round robin event, where organizers stay the same)
+ await sendRescheduledEmailsAndSMS(
+ {
+ ...copyEvent,
+ additionalInformation: metadata,
+ additionalNotes, // Resets back to the additionalNote input and not the override value
+ cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
+ },
+ eventType?.metadata
+ );
+ }
}
}
// If it's not a reschedule, doesn't require confirmation and there's no price,
@@ -1373,7 +1568,7 @@ async function handler(
organizerOrFirstDynamicGroupMemberDefaultLocationUrl ||
videoCallUrl;
- if (evt.iCalUID !== booking.iCalUID) {
+ if (!isDryRun && evt.iCalUID !== booking.iCalUID) {
// The eventManager could change the iCalUID. At this point we can update the DB record
await prisma.booking.update({
where: {
@@ -1409,18 +1604,20 @@ async function handler(
})
);
- await sendScheduledEmailsAndSMS(
- {
- ...evt,
- additionalInformation,
- additionalNotes,
- customInputs,
- },
- eventNameObject,
- isHostConfirmationEmailsDisabled,
- isAttendeeConfirmationEmailDisabled,
- eventType.metadata
- );
+ if (!isDryRun) {
+ await sendScheduledEmailsAndSMS(
+ {
+ ...evt,
+ additionalInformation,
+ additionalNotes,
+ customInputs,
+ },
+ eventNameObject,
+ isHostConfirmationEmailsDisabled,
+ isAttendeeConfirmationEmailDisabled,
+ eventType.metadata
+ );
+ }
}
}
} else {
@@ -1448,8 +1645,10 @@ async function handler(
calEvent: getPiiFreeCalendarEvent(evt),
})
);
- await sendOrganizerRequestEmail({ ...evt, additionalNotes }, eventType.metadata);
- await sendAttendeeRequestEmailAndSMS({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata);
+ if (!isDryRun) {
+ await sendOrganizerRequestEmail({ ...evt, additionalNotes }, eventType.metadata);
+ await sendAttendeeRequestEmailAndSMS({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata);
+ }
}
if (booking.location?.startsWith("http")) {
@@ -1558,6 +1757,8 @@ async function handler(
paymentRequired: true,
paymentUid: payment?.uid,
paymentId: payment?.id,
+ isDryRun,
+ ...(isDryRun ? { troubleshooterData } : {}),
};
}
@@ -1620,7 +1821,7 @@ async function handler(
}
try {
- if (hasHashedBookingLink && reqBody.hashedLink) {
+ if (hasHashedBookingLink && reqBody.hashedLink && !isDryRun) {
await prisma.hashedLink.delete({
where: {
link: reqBody.hashedLink as string,
@@ -1634,20 +1835,22 @@ async function handler(
if (!booking) throw new HttpError({ statusCode: 400, message: "Booking failed" });
try {
- await prisma.booking.update({
- where: {
- uid: booking.uid,
- },
- data: {
- location: evt.location,
- metadata: { ...(typeof booking.metadata === "object" && booking.metadata), ...metadata },
- references: {
- createMany: {
- data: referencesToCreate,
+ if (!isDryRun) {
+ await prisma.booking.update({
+ where: {
+ uid: booking.uid,
+ },
+ data: {
+ location: evt.location,
+ metadata: { ...(typeof booking.metadata === "object" && booking.metadata), ...metadata },
+ references: {
+ createMany: {
+ data: referencesToCreate,
+ },
},
},
- },
- });
+ });
+ }
} catch (error) {
loggerWithEventDetails.error("Error while creating booking references", JSON.stringify({ error }));
}
@@ -1716,6 +1919,8 @@ async function handler(
return {
...bookingResponse,
...luckyUserResponse,
+ isDryRun,
+ ...(isDryRun ? { troubleshooterData } : {}),
references: referencesToCreate,
seatReferenceUid: evt.attendeeSeatId,
};
diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
index f508894ae7..a13f3085e7 100644
--- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
+++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts
@@ -4,7 +4,11 @@ import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/
import { parseRecurringEvent } from "@calcom/lib";
import prisma, { userSelect } from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
-import { EventTypeMetaDataSchema, customInputSchema } from "@calcom/prisma/zod-utils";
+import {
+ EventTypeMetaDataSchema,
+ customInputSchema,
+ rrSegmentQueryValueSchema,
+} from "@calcom/prisma/zod-utils";
export const getEventTypesFromDB = async (eventTypeId: number) => {
const eventType = await prisma.eventType.findUniqueOrThrow({
@@ -153,6 +157,8 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
email: true,
},
},
+ assignRRMembersUsingSegment: true,
+ rrSegmentQueryValue: true,
},
});
@@ -166,6 +172,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
customInputs: customInputSchema.array().parse(eventType?.customInputs || []),
locations: (eventType?.locations ?? []) as LocationObject[],
bookingFields: getBookingFieldsWithSystemFields({ ...restEventType, isOrgTeamEvent } || {}),
+ rrSegmentQueryValue: rrSegmentQueryValueSchema.parse(eventType.rrSegmentQueryValue) ?? null,
isDynamic: false,
};
};
diff --git a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts
index d51bb7e683..2a2e6e1a70 100644
--- a/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts
+++ b/packages/features/bookings/lib/handleNewBooking/loadAndValidateUsers.ts
@@ -22,7 +22,16 @@ type Users = (Awaited>[number] & {
type EventType = Pick<
NewBookingEventType,
- "hosts" | "users" | "id" | "userId" | "schedulingType" | "maxLeadThreshold"
+ | "hosts"
+ | "users"
+ | "id"
+ | "userId"
+ | "schedulingType"
+ | "maxLeadThreshold"
+ | "team"
+ | "assignAllTeamMembers"
+ | "assignRRMembersUsingSegment"
+ | "rrSegmentQueryValue"
>;
type InputProps = {
diff --git a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts
index fe8c890663..3327602179 100644
--- a/packages/features/bookings/lib/handleNewBooking/loadUsers.ts
+++ b/packages/features/bookings/lib/handleNewBooking/loadUsers.ts
@@ -2,7 +2,11 @@ import { Prisma } from "@prisma/client";
import type { IncomingMessage } from "http";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
-import { getRoutedUsersWithContactOwnerAndFixedUsers } from "@calcom/lib/bookings/getRoutedUsers";
+import {
+ getRoutedUsersWithContactOwnerAndFixedUsers,
+ findMatchingHostsWithEventSegment,
+ getNormalizedHosts,
+} from "@calcom/lib/bookings/getRoutedUsers";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -14,7 +18,17 @@ import type { NewBookingEventType } from "./types";
const log = logger.getSubLogger({ prefix: ["[loadUsers]:handleNewBooking "] });
-type EventType = Pick;
+type EventType = Pick<
+ NewBookingEventType,
+ | "hosts"
+ | "users"
+ | "id"
+ | "schedulingType"
+ | "team"
+ | "assignAllTeamMembers"
+ | "assignRRMembersUsingSegment"
+ | "rrSegmentQueryValue"
+>;
export const loadUsers = async ({
eventType,
@@ -34,6 +48,7 @@ export const loadUsers = async ({
const users = eventType.id
? await loadUsersByEventType(eventType)
: await loadDynamicUsers(dynamicUserList, currentOrgDomain);
+
return getRoutedUsersWithContactOwnerAndFixedUsers({ users, routedTeamMemberIds, contactOwnerEmail });
} catch (error) {
log.error("Unable to load users", safeStringify(error));
@@ -45,20 +60,20 @@ export const loadUsers = async ({
};
const loadUsersByEventType = async (eventType: EventType): Promise => {
- const hosts = eventType.hosts || [];
- const users = hosts.map(({ user, isFixed, priority, weight, createdAt }) => ({
+ const { hosts, fallbackHosts } = getNormalizedHosts({
+ eventType: { ...eventType, hosts: eventType.hosts.filter(Boolean) },
+ });
+ const matchingHosts = await findMatchingHostsWithEventSegment({
+ eventType,
+ normalizedHosts: hosts ?? fallbackHosts,
+ });
+ return matchingHosts.map(({ user, isFixed, priority, weight, createdAt }) => ({
...user,
isFixed,
priority,
weight,
createdAt,
}));
- return users.length
- ? users
- : eventType.users.map((user) => ({
- ...user,
- createdAt: null,
- }));
};
const loadDynamicUsers = async (dynamicUserList: string[], currentOrgDomain: string | null) => {
diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts
index ca2408a0e8..56f5bd9776 100644
--- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts
+++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts
@@ -180,6 +180,12 @@ export default async function handleChildrenEventTypes({
workflows: currentWorkflowIds && {
create: currentWorkflowIds.map((wfId) => ({ workflowId: wfId })),
},
+ /**
+ * RR Segment isn't applicable for managed event types.
+ */
+ rrSegmentQueryValue: undefined,
+ assignRRMembersUsingSegment: false,
+
// Reserved for future releases
/*
webhooks: eventType.webhooks && {
diff --git a/packages/features/eventtypes/components/AddMembersWithSwitch.tsx b/packages/features/eventtypes/components/AddMembersWithSwitch.tsx
index 1fcae7f35a..0272263726 100644
--- a/packages/features/eventtypes/components/AddMembersWithSwitch.tsx
+++ b/packages/features/eventtypes/components/AddMembersWithSwitch.tsx
@@ -1,11 +1,15 @@
import type { ComponentProps, Dispatch, SetStateAction } from "react";
import { useFormContext } from "react-hook-form";
+import { Controller } from "react-hook-form";
import type { Options } from "react-select";
import type { FormValues, Host, TeamMember } from "@calcom/features/eventtypes/lib/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
-import { Label } from "@calcom/ui";
+import type { AttributesQueryValue } from "@calcom/lib/raqb/types";
+import { trpc } from "@calcom/trpc";
+import { Label, SettingsToggle } from "@calcom/ui";
+import { Segment } from "../../../../apps/web/components/Segment";
import AssignAllTeamMembers from "./AssignAllTeamMembers";
import CheckedTeamSelect from "./CheckedTeamSelect";
import type { CheckedSelectOption } from "./CheckedTeamSelect";
@@ -99,7 +103,117 @@ const CheckedHostField = ({
);
};
-const AddMembersWithSwitch = ({
+function MembersSegmentWithToggle({
+ teamId,
+ assignRRMembersUsingSegment,
+ setAssignRRMembersUsingSegment,
+ rrSegmentQueryValue,
+ setRrSegmentQueryValue,
+ className,
+}: {
+ teamId: number;
+ assignRRMembersUsingSegment: boolean;
+ setAssignRRMembersUsingSegment: (value: boolean) => void;
+ rrSegmentQueryValue: AttributesQueryValue | null;
+ setRrSegmentQueryValue: (value: AttributesQueryValue) => void;
+ className?: string;
+}) {
+ const { t } = useLocale();
+ const onQueryValueChange = ({ queryValue }: { queryValue: AttributesQueryValue }) => {
+ setRrSegmentQueryValue(queryValue);
+ };
+
+ return (
+
+ name="assignRRMembersUsingSegment"
+ render={() => (
+ {
+ setAssignRRMembersUsingSegment(active);
+ }}>
+
+
+ )}
+ />
+ );
+}
+
+type AddMembersWithSwitchProps = {
+ teamMembers: TeamMember[];
+ value: Host[];
+ onChange: (hosts: Host[]) => void;
+ assignAllTeamMembers: boolean;
+ setAssignAllTeamMembers: Dispatch>;
+ automaticAddAllEnabled: boolean;
+ onActive: () => void;
+ isFixed: boolean;
+ placeholder?: string;
+ isRRWeightsEnabled?: boolean;
+ teamId: number;
+ isSegmentApplicable?: boolean;
+ "data-testid"?: string;
+};
+
+const enum AssignmentState {
+ TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE = "TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE",
+ TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE = "TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE",
+ ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE = "ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE",
+ ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE = "ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE",
+ TEAM_MEMBERS_IN_SEGMENT_ENABLED = "TEAM_MEMBERS_IN_SEGMENT_ENABLED",
+}
+
+function getAssignmentState({
+ assignAllTeamMembers,
+ assignRRMembersUsingSegment,
+ isAssigningAllTeamMembersApplicable,
+ isSegmentApplicable,
+}: {
+ assignAllTeamMembers: boolean;
+ assignRRMembersUsingSegment: boolean;
+ isAssigningAllTeamMembersApplicable: boolean;
+ isSegmentApplicable?: boolean;
+}) {
+ if (assignAllTeamMembers) {
+ return isSegmentApplicable
+ ? AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE
+ : AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE;
+ }
+ if (assignRRMembersUsingSegment && isSegmentApplicable)
+ return AssignmentState.TEAM_MEMBERS_IN_SEGMENT_ENABLED;
+ if (isAssigningAllTeamMembersApplicable) return AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE;
+ return AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE;
+}
+
+function useSegmentState() {
+ const { getValues, setValue, watch } = useFormContext();
+ const assignRRMembersUsingSegment = watch("assignRRMembersUsingSegment");
+
+ const setAssignRRMembersUsingSegment = (value: boolean) =>
+ setValue("assignRRMembersUsingSegment", value, { shouldDirty: true });
+
+ const rrSegmentQueryValue = getValues("rrSegmentQueryValue");
+ const setRrSegmentQueryValue = (value: AttributesQueryValue) =>
+ setValue("rrSegmentQueryValue", value, { shouldDirty: true });
+
+ return {
+ assignRRMembersUsingSegment,
+ setAssignRRMembersUsingSegment,
+ rrSegmentQueryValue,
+ setRrSegmentQueryValue,
+ };
+}
+
+function AddMembersWithSwitch({
teamMembers,
value,
onChange,
@@ -109,57 +223,109 @@ const AddMembersWithSwitch = ({
onActive,
isFixed,
placeholder = "",
- containerClassName = "",
isRRWeightsEnabled,
+ teamId,
+ isSegmentApplicable,
...rest
-}: {
- value: Host[];
- onChange: (hosts: Host[]) => void;
- teamMembers: TeamMember[];
- assignAllTeamMembers: boolean;
- setAssignAllTeamMembers: Dispatch>;
- automaticAddAllEnabled: boolean;
- onActive: () => void;
- isFixed: boolean;
- placeholder?: string;
- containerClassName?: string;
- isRRWeightsEnabled?: boolean;
- "data-testid"?: string;
-}) => {
+}: AddMembersWithSwitchProps) {
const { t } = useLocale();
const { setValue } = useFormContext();
+ const {
+ assignRRMembersUsingSegment,
+ setAssignRRMembersUsingSegment,
+ rrSegmentQueryValue,
+ setRrSegmentQueryValue,
+ } = useSegmentState();
+ const assignmentState = getAssignmentState({
+ assignAllTeamMembers,
+ assignRRMembersUsingSegment,
+ isAssigningAllTeamMembersApplicable: automaticAddAllEnabled,
+ isSegmentApplicable,
+ });
+ const utils = trpc.useUtils();
+
+ utils.viewer.appRoutingForms.getAttributesForTeam.prefetch({
+ teamId,
+ });
+
+ const onAssignAllTeamMembersInactive = () => {
+ setValue("hosts", [], { shouldDirty: true });
+ setAssignRRMembersUsingSegment(false);
+ };
+
+ switch (assignmentState) {
+ case AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_APPLICABLE:
+ case AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE:
+ case AssignmentState.TEAM_MEMBERS_IN_SEGMENT_ENABLED:
+ return (
+ <>
+ {
+ onActive();
+ }}
+ onInactive={onAssignAllTeamMembersInactive}
+ />
+ {assignmentState !== AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE && (
+
+
+
+ )}
+ >
+ );
+
+ case AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE:
+ case AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE:
+ return (
+ <>
+
+ {assignmentState === AssignmentState.TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE && (
+
+ )}
+
+
+
+
+ >
+ );
+ }
+}
+
+const AddMembersWithSwitchWrapper = ({
+ containerClassName,
+ ...props
+}: AddMembersWithSwitchProps & {
+ containerClassName?: string;
+}) => {
return (
- {automaticAddAllEnabled ? (
-
-
setValue("hosts", [], { shouldDirty: true })}
- />
-
- ) : (
- <>>
- )}
- {!assignAllTeamMembers || !automaticAddAllEnabled ? (
-
- ) : (
- <>>
- )}
+
);
};
-export default AddMembersWithSwitch;
+export default AddMembersWithSwitchWrapper;
diff --git a/packages/features/eventtypes/components/AssignAllTeamMembers.tsx b/packages/features/eventtypes/components/AssignAllTeamMembers.tsx
index 8ba190b001..cb9c214670 100644
--- a/packages/features/eventtypes/components/AssignAllTeamMembers.tsx
+++ b/packages/features/eventtypes/components/AssignAllTeamMembers.tsx
@@ -24,6 +24,7 @@ const AssignAllTeamMembers = ({
name="assignAllTeamMembers"
render={() => (
({
+ useAutoAnimate: () => [null],
+}));
+
+// Mock Segment component
+vi.mock("@calcom/web/components/Segment", () => ({
+ Segment: vi.fn().mockImplementation(({ onQueryValueChange }) => (
+
+ onQueryValueChange({ queryValue: { id: "test" } })}>Update Query
+
+ )),
+}));
+
+const mockTeamMembers: TeamMember[] = [
+ {
+ value: "1",
+ label: "John Doe",
+ avatar: "avatar1.jpg",
+ email: "john@example.com",
+ defaultScheduleId: 1,
+ },
+ {
+ value: "2",
+ label: "Jane Smith",
+ avatar: "avatar2.jpg",
+ email: "jane@example.com",
+ defaultScheduleId: 2,
+ },
+];
+
+// Mock trpc
+vi.mock("@calcom/trpc", () => ({
+ trpc: {
+ useUtils: () => ({
+ viewer: {
+ appRoutingForms: {
+ getAttributesForTeam: {
+ prefetch: vi.fn(),
+ },
+ },
+ },
+ }),
+ },
+}));
+
+const renderComponent = ({
+ componentProps,
+ formDefaultValues = {
+ assignRRMembersUsingSegment: false,
+ rrSegmentQueryValue: null,
+ hosts: [],
+ },
+}: {
+ componentProps: Parameters[0];
+ formDefaultValues?: Record;
+}) => {
+ const Wrapper = ({ children }: { children: React.ReactNode }) => {
+ const methods = useForm({
+ defaultValues: formDefaultValues,
+ });
+ const [assignAllTeamMembers, setAssignAllTeamMembers] = React.useState(false);
+ console.log(methods.getValues());
+ return (
+
+ {React.cloneElement(children as React.ReactElement, {
+ assignAllTeamMembers,
+ setAssignAllTeamMembers,
+ })}
+
+ );
+ };
+
+ return render( , { wrapper: Wrapper });
+};
+
+describe("AddMembersWithSwitch", () => {
+ const defaultProps = {
+ teamMembers: mockTeamMembers,
+ value: [] as Host[],
+ onChange: vi.fn(),
+ onActive: vi.fn(),
+ isFixed: false,
+ teamId: 1,
+ };
+
+ it("should render in TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_NOT_APPLICABLE state", () => {
+ renderComponent({
+ componentProps: {
+ ...defaultProps,
+ assignAllTeamMembers: false,
+ isSegmentApplicable: false,
+ automaticAddAllEnabled: false,
+ },
+ });
+ expect(screen.queryByTestId("assign-all-team-members-toggle")).not.toBeInTheDocument();
+ expectManualHostListToBeThere();
+ });
+
+ it("should render in TOGGLES_OFF_AND_ALL_TEAM_MEMBERS_APPLICABLE state", () => {
+ renderComponent({
+ componentProps: {
+ ...defaultProps,
+ assignAllTeamMembers: false,
+ isSegmentApplicable: false,
+ automaticAddAllEnabled: true,
+ },
+ });
+
+ expect(screen.getByTestId("assign-all-team-members-toggle")).toBeInTheDocument();
+ expect(screen.getByTestId("assign-all-team-members-toggle").getAttribute("aria-checked")).toBe("false");
+ expectManualHostListToBeThere();
+ });
+
+ it("should render in ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE state", () => {
+ renderComponent({
+ componentProps: {
+ ...defaultProps,
+ assignAllTeamMembers: true,
+ isSegmentApplicable: false,
+ },
+ formDefaultValues: {
+ assignRRMembersUsingSegment: true,
+ rrSegmentQueryValue: null,
+ hosts: [],
+ },
+ });
+
+ expect(screen.queryByTestId("assign-all-team-members-toggle")).not.toBeInTheDocument();
+ expect(screen.queryByText("filter_by_attributes")).not.toBeInTheDocument();
+ });
+
+ it("should render segment toggle in enabled state when isSegmentApplicable is true and assignRRMembersUsingSegment is also true(even if assignAllTeamMembers is false)", () => {
+ renderComponent({
+ componentProps: {
+ ...defaultProps,
+ assignAllTeamMembers: false,
+ isSegmentApplicable: true,
+ },
+ formDefaultValues: {
+ assignRRMembersUsingSegment: true,
+ rrSegmentQueryValue: null,
+ hosts: [],
+ },
+ });
+
+ expect(screen.getByTestId("segment-toggle")).toBeInTheDocument();
+ expect(screen.getByTestId("segment-toggle").getAttribute("aria-checked")).toBe("true");
+ });
+
+ it("should call onChange when team members are selected", () => {
+ renderComponent({ componentProps: defaultProps });
+
+ const combobox = screen.getByRole("combobox");
+ fireEvent.focus(combobox);
+ fireEvent.keyDown(combobox, { key: "ArrowDown" });
+ fireEvent.click(screen.getByText("John Doe"));
+
+ expect(defaultProps.onChange).toHaveBeenCalled();
+ });
+
+ it("should show Segment when 'Automatically add all team members' is toggled on and then segment toggle is switched on", () => {
+ renderComponent({
+ componentProps: {
+ ...defaultProps,
+ assignAllTeamMembers: true,
+ isSegmentApplicable: true,
+ automaticAddAllEnabled: true,
+ },
+ formDefaultValues: {
+ assignRRMembersUsingSegment: false,
+ rrSegmentQueryValue: null,
+ hosts: [],
+ },
+ });
+
+ expect(screen.queryByTestId("segment-toggle")).not.toBeInTheDocument();
+
+ act(() => {
+ screen.getByTestId("assign-all-team-members-toggle").click();
+ });
+
+ expect(screen.queryByTestId("mock-segment")).not.toBeInTheDocument();
+
+ act(() => {
+ screen.getByTestId("segment-toggle").click();
+ });
+
+ expect(screen.getByTestId("mock-segment")).toBeInTheDocument();
+ });
+});
+
+function expectManualHostListToBeThere() {
+ expect(screen.getByRole("combobox")).toBeInTheDocument();
+}
diff --git a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx
index ffb431e951..c7046d46ba 100644
--- a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx
+++ b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx
@@ -21,7 +21,12 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Label, Select, SettingsToggle, RadioGroup as RadioArea } from "@calcom/ui";
-export type EventTeamAssignmentTabBaseProps = Pick;
+export type EventTeamAssignmentTabBaseProps = Pick<
+ EventTypeSetupProps,
+ "teamMembers" | "team" | "eventType"
+> & {
+ orgId: number | null;
+};
export const mapMemberToChildrenOption = (
member: EventTypeSetupProps["teamMembers"][number],
@@ -96,6 +101,7 @@ const FixedHostHelper = (
);
const FixedHosts = ({
+ teamId,
teamMembers,
value,
onChange,
@@ -103,6 +109,7 @@ const FixedHosts = ({
setAssignAllTeamMembers,
isRoundRobinEvent = false,
}: {
+ teamId: number;
value: Host[];
onChange: (hosts: Host[]) => void;
teamMembers: TeamMember[];
@@ -127,6 +134,7 @@ const FixedHosts = ({