Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions packages/platform/atoms/event-types/hooks/useEventTypeForm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, it, expect } from "vitest";

import type { ChildrenEventType } from "@calcom/features/eventtypes/lib/childrenEventType";
import { MembershipRole } from "@calcom/prisma/enums";

/**
* Extracts only the fields needed by the server from a ChildrenEventType array.
* This mirrors the stripping logic in useEventTypeForm's handleSubmit to ensure
* that display-only fields (avatar, profile, etc.) are not sent in the payload.
*/
function stripChildrenForPayload(children: ChildrenEventType[]) {
return children.map((child) => ({
hidden: child.hidden,
owner: {
id: child.owner.id,
name: child.owner.name,
email: child.owner.email,
eventTypeSlugs: child.owner.eventTypeSlugs,
},
}));
}

describe("useEventTypeForm - children payload stripping", () => {
it("should strip avatar, profile, username, and membership from children payload", () => {
const children: ChildrenEventType[] = [
{
value: "1",
label: "Alice",
created: true,
slug: "test-event",
hidden: false,
owner: {
id: 1,
name: "Alice",
email: "alice@example.com",
username: "alice",
avatar: "data:image/png;base64," + "A".repeat(50000), // Large base64 avatar
membership: MembershipRole.MEMBER,
eventTypeSlugs: ["meeting", "consultation"],
profile: {
id: 1,
username: "alice",
upId: "usr_1",
organizationId: null,
organization: null,
},
},
},
{
value: "2",
label: "Bob",
created: false,
slug: "test-event",
hidden: true,
owner: {
id: 2,
name: "Bob",
email: "bob@example.com",
username: "bob",
avatar: "https://example.com/avatars/bob.png",
membership: MembershipRole.OWNER,
eventTypeSlugs: [],
profile: {
id: 2,
username: "bob",
upId: "usr_2",
organizationId: 10,
organization: {
id: 10,
slug: "org",
name: "Org",
calVideoLogo: null,
bannerUrl: "",
isPlatform: false,
},
},
},
},
];

const stripped = stripChildrenForPayload(children);

// Should only contain server-needed fields
expect(stripped).toEqual([
{
hidden: false,
owner: {
id: 1,
name: "Alice",
email: "alice@example.com",
eventTypeSlugs: ["meeting", "consultation"],
},
},
{
hidden: true,
owner: {
id: 2,
name: "Bob",
email: "bob@example.com",
eventTypeSlugs: [],
},
},
]);

// Verify avatar is not present
for (const child of stripped) {
expect(child.owner).not.toHaveProperty("avatar");
expect(child.owner).not.toHaveProperty("profile");
expect(child.owner).not.toHaveProperty("username");
expect(child.owner).not.toHaveProperty("membership");
}
});

it("should significantly reduce payload size for large teams", () => {
// Simulate 85 users with base64 avatars (~10KB each)
const largeBase64Avatar = "data:image/png;base64," + "A".repeat(10000);

const children: ChildrenEventType[] = Array.from({ length: 85 }, (_, i) => ({
value: String(i + 1),
label: `User ${i + 1}`,
created: true,
slug: "managed-event",
hidden: false,
owner: {
id: i + 1,
name: `User ${i + 1}`,
email: `user${i + 1}@example.com`,
username: `user${i + 1}`,
avatar: largeBase64Avatar,
membership: MembershipRole.MEMBER,
eventTypeSlugs: ["event-a", "event-b"],
profile: {
id: i + 1,
username: `user${i + 1}`,
upId: `usr_${i + 1}`,
organizationId: 1,
organization: {
id: 1,
slug: "org",
name: "Large Org",
calVideoLogo: null,
bannerUrl: "",
isPlatform: false,
},
},
},
}));

const fullPayloadSize = JSON.stringify(children).length;
const strippedPayloadSize = JSON.stringify(stripChildrenForPayload(children)).length;

// The stripped payload should be dramatically smaller
expect(strippedPayloadSize).toBeLessThan(fullPayloadSize * 0.1);

// Full payload with 85 users and 10KB avatars should be around 850KB+
expect(fullPayloadSize).toBeGreaterThan(800000);

// Stripped payload should be well under 1MB
expect(strippedPayloadSize).toBeLessThan(100000);
});

it("should handle undefined children gracefully", () => {
const children: ChildrenEventType[] = [];
const stripped = stripChildrenForPayload(children);
expect(stripped).toEqual([]);
});
});
35 changes: 24 additions & 11 deletions packages/platform/atoms/event-types/hooks/useEventTypeForm.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMemo, useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";

import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import { locationsResolver } from "@calcom/app-store/locations";
import { DEFAULT_PROMPT_VALUE, DEFAULT_BEGIN_MESSAGE } from "@calcom/features/calAIPhone/promptTemplates";
import { DEFAULT_BEGIN_MESSAGE, DEFAULT_PROMPT_VALUE } from "@calcom/features/calAIPhone/promptTemplates";
import type { TemplateType } from "@calcom/features/calAIPhone/zod-utils";
import { validateCustomEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import { sortHosts } from "@calcom/lib/bookings/hostGroupUtils";
import type {
FormValues,
EventTypeSetupProps,
EventTypeUpdateInput,
FormValues,
} from "@calcom/features/eventtypes/lib/types";
import { sortHosts } from "@calcom/lib/bookings/hostGroupUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import { eventTypeBookingFields as eventTypeBookingFieldsSchema } from "@calcom/prisma/zod-utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";

type Fields = z.infer<typeof eventTypeBookingFieldsSchema>;

Expand Down Expand Up @@ -288,12 +287,12 @@ export const useEventTypeForm = ({
Object.keys(dirtyFields).forEach((key) => {
const typedKey = key as keyof typeof dirtyFields;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// @ts-expect-error
updatedFields[typedKey] = undefined;
const isDirty = isFieldDirty(typedKey);
if (isDirty) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// @ts-expect-error
updatedFields[typedKey] = values[typedKey];
}
});
Expand Down Expand Up @@ -381,6 +380,20 @@ export const useEventTypeForm = ({

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, users, scheduleName, disabledCancelling, disabledRescheduling, ...rest } = input;
// Strip children down to only the fields the server schema expects.
// The full children objects contain avatar, profile, and other display-only
// data that bloats the request payload. With many assigned users (~85+),
// this can push the request body over the 1MB server limit.
const strippedChildren = children?.map((child) => ({
hidden: child.hidden,
owner: {
id: child.owner.id,
name: child.owner.name,
email: child.owner.email,
eventTypeSlugs: child.owner.eventTypeSlugs,
},
}));

const payload = {
...rest,
length,
Expand All @@ -402,7 +415,7 @@ export const useEventTypeForm = ({
seatsShowAvailabilityCount,
metadata,
customInputs,
children,
children: strippedChildren,
assignAllTeamMembers,
multiplePrivateLinks: values.multiplePrivateLinks,
disableCancelling: disabledCancelling,
Expand Down
Loading