Compare commits

...
Author SHA1 Message Date
emrysal 9b323cbcc8 chore: release v5.5.18
Post release / release (push) Canceled after 0s
2025-08-15 01:13:15 +00:00
18a0e2d75c fix: event not removed from calendar when reassigned (#23099)
* fix: event not removed from calendar when reassign

* fix test

* tweak

* add test

* test: add comprehensive test for calendar event deletion during round robin reassignment

- Remove existing tests and replace with focused test for organizer change scenario
- Verify deleteEventsAndMeetings is called when organizer changes
- Mock reschedule method to simulate real behavior while tracking deletion calls
- Test verifies original host's calendar events are properly deleted
- Ensures booking reassignment and email notifications work correctly

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* update

* revert roundRobinReassignment.test.ts

* revert

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-14 20:42:36 +00:00
Benny Joo 87c89a9968 perf: check feature flag in layout level for /settings/organizations/new routes (#23057)
* remove getServerSideProps

* add layout

* update pages
2025-08-14 13:12:21 -07:00
e237749abd fix: gracefully handle calendar failures when bypassBusyCalendarTimes is enabled (#23061)
* fix: gracefully handle calendar failures when bypassBusyCalendarTimes is enabled

- When _bypassCalendarBusyTimes parameter is true and third-party calendar fetching fails
- System now falls back to internal bookings only instead of throwing error
- Maintains existing error behavior when bypass is disabled

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* feat: add _silentCalendarFailures parameter to handle calendar failures gracefully

- Add silentlyHandleCalendarFailures parameter to getBusyTimes service
- Add _silentCalendarFailures parameter to getUserAvailability schema
- Add _silentCalendarFailures parameter to tRPC slots endpoint
- Add _silentCalendarFailures parameter to API v2 slots/available endpoint
- Parameter attempts calendar calls first, falls back silently on failure
- Different from bypassCalendarBusyTimes which skips calendar calls entirely

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: remove bypassBusyCalendarTimes logic from error handling

- Keep only silentlyHandleCalendarFailures logic in getBusyTimes error handling
- Remove redundant bypassBusyCalendarTimes condition from calendar failure handling
- Maintain existing bypassBusyCalendarTimes logic that skips calendar calls entirely

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* feat: add silentlyHandleCalendarFailures prop to Booker atom

- Pass silentlyHandleCalendarFailures prop from BookerPlatformWrapper to useAvailableSlots
- Forward prop as _silentCalendarFailures parameter to API v2 slots/available endpoint
- Complete the parameter flow from Booker atom to underlying service layer
- Enables silent handling of calendar provider failures in Booker component

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* chore: fix type and bump library

* chore: bump library

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-08-14 18:41:53 +03:00
Alex van AndelandDevin AI 994a36d039 fix: add authentication guard to /apps/installed routes (#23092)
* fix: add authentication guard to /apps/installed routes

- Add session validation before making tRPC calls in server component
- Redirect unauthenticated users to /auth/login instead of throwing 500 errors
- Follows existing authentication patterns used throughout the codebase
- Fixes 500 error when accessing /apps/installed/calendar and other categories

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* fix: Missing return; was that so hard, DevinAI?

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-14 14:50:56 +00:00
Benny Joo c989817373 fix: more precise error code for event type duplication handler (#23059)
* fix: unique constraint violation

* refactor

* add test

* fix space

* clean up
2025-08-14 11:35:12 -03:00
27 changed files with 340 additions and 383 deletions
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.306",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.311",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -1,13 +1,17 @@
import { createRouterCaller } from "app/_trpc/context";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AppCategories } from "@calcom/prisma/enums";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
import { calendarsRouter } from "@calcom/trpc/server/routers/viewer/calendars/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import InstalledApps from "~/apps/installed/[category]/installed-category-view";
const querySchema = z.object({
@@ -31,6 +35,11 @@ const InstalledAppsWrapper = async ({ params }: PageProps) => {
redirect("/apps/installed/calendar");
}
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const [calendarsCaller, appsCaller] = await Promise.all([
createRouterCaller(calendarsRouter),
createRouterCaller(appsRouter),
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/about-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/about"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/add-teams-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/add-teams"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,11 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies } from "next/headers";
import { headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/onboarding-handover";
@@ -18,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/handover"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,21 +1,16 @@
import type { GetServerSidePropsResult } from "next";
import { _generateMetadata } from "app/_utils";
import { notFound } from "next/navigation";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
export default async function Layout({ children }: { children: React.ReactNode }) {
const featuresRepository = new FeaturesRepository(prisma);
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
// Check if organizations are enabled
if (!organizations) {
return {
notFound: true,
};
return notFound();
}
return {
props: {
isOrg: true,
},
};
};
return children;
}
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/onboard-members-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/onboard-members"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
@@ -1,13 +1,7 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/create-new-view";
export const generateMetadata = async () =>
@@ -19,14 +13,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new"
);
type Props = {
isOrg: boolean;
};
const getData = withAppDirSsr<Props>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LicenseRequired>
@@ -1,10 +1,4 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import LegacyPage, { LayoutWrapper } from "~/settings/organizations/new/payment-status-view";
@@ -17,10 +11,7 @@ export const generateMetadata = async () =>
"/settings/organizations/new/status"
);
const getData = withAppDirSsr(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
const ServerPage = async () => {
return (
<LayoutWrapper>
<LegacyPage />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.17",
"version": "5.5.18",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+10 -242
View File
@@ -4264,9 +4264,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
},
"get": {
"operationId": "OrganizationsEventTypesPrivateLinksController_getPrivateLinks",
@@ -4328,9 +4326,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/event-types/{eventTypeId}/private-links/{linkId}": {
@@ -4402,9 +4398,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
},
"delete": {
"operationId": "OrganizationsEventTypesPrivateLinksController_deletePrivateLink",
@@ -4474,9 +4468,7 @@
}
}
},
"tags": [
"Orgs / Teams / Event Types / Private Links"
]
"tags": ["Orgs / Teams / Event Types / Private Links"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/memberships": {
@@ -22121,13 +22113,7 @@
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"expiresAt"
]
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "expiresAt"]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
@@ -22164,14 +22150,7 @@
"example": 3
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"maxUsageCount",
"usageCount"
]
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "maxUsageCount", "usageCount"]
},
"CreatePrivateLinkOutput": {
"type": "object",
@@ -22193,10 +22172,7 @@
]
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"GetPrivateLinksOutput": {
"type": "object",
@@ -22221,10 +22197,7 @@
}
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"UpdatePrivateLinkOutput": {
"type": "object",
@@ -22246,10 +22219,7 @@
]
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"DeletePrivateLinkOutput": {
"type": "object",
@@ -22274,10 +22244,7 @@
}
}
},
"required": [
"status",
"data"
]
"required": ["status", "data"]
},
"StripConnectOutputDto": {
"type": "object",
@@ -26267,205 +26234,6 @@
},
"required": ["status", "data"]
},
"CreatePrivateLinkInput": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string",
"description": "Expiration date for time-based links",
"format": "date-time",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
"example": 10,
"minimum": 1,
"default": 1
}
}
},
"TimeBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"expiresAt": {
"type": "string",
"description": "Expiration date for this time-based link",
"format": "date-time",
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "expiresAt"]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times this link can be used",
"example": 10
},
"usageCount": {
"type": "number",
"description": "Current usage count for this link",
"example": 3
}
},
"required": ["linkId", "eventTypeId", "isExpired", "bookingUrl", "maxUsageCount", "usageCount"]
},
"CreatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Created private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": ["status", "data"]
},
"GetPrivateLinksOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "array",
"description": "Array of private links for the event type (mix of time-based and usage-based)",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
}
},
"required": ["status", "data"]
},
"UpdatePrivateLinkBody": {
"type": "object",
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
"description": "New expiration date for time-based links",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "New maximum number of times the link can be used",
"example": 10,
"minimum": 1
}
}
},
"UpdatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Updated private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": ["status", "data"]
},
"DeletePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "object",
"description": "Deleted link information",
"properties": {
"linkId": {
"type": "string",
"example": "abc123def456"
},
"message": {
"type": "string",
"example": "Private link deleted successfully"
}
}
}
},
"required": ["status", "data"]
},
"UserWebhookOutputDto": {
"type": "object",
"properties": {
@@ -1430,6 +1430,8 @@ async function handler(
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
originalRescheduledBooking.userId !== evt.organizer.id;
const skipDeleteEventsAndMeetings = changedOrganizer;
const isBookingRequestedReschedule =
!!originalRescheduledBooking &&
!!originalRescheduledBooking.rescheduled &&
@@ -1702,7 +1704,8 @@ async function handler(
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
);
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
@@ -0,0 +1,145 @@
/**
* Tests for calendar event deletion during round robin organizer change operations.
* Covers scenarios where events need to be deleted from original hosts and
* credential handling during organizer changes.
*/
import {
getDate,
createBookingScenario,
getScenarioData,
getMockBookingAttendee,
TestData,
getOrganizer,
getGoogleCalendarCredential,
mockCalendarToHaveNoBusySlots,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import {
expectBookingToBeInDatabase,
expectSuccessfulRoundRobinReschedulingEmails,
} from "@calcom/web/test/utils/bookingScenario/expects";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { SchedulingType, BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
describe("roundRobinReassignment test", () => {
setupAndTeardown();
test("should delete calendar events from original host when round robin reassignment changes organizer", async ({
emails,
}) => {
const roundRobinReassignment = (await import("./roundRobinReassignment")).default;
const originalHost = getOrganizer({
name: "Original Host",
email: "originalhost@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const newHost = getOrganizer({
name: "New Host",
email: "newhost@example.com",
id: 102,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
const bookingToReassignUid = "booking-to-reassign-with-deletion";
await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN,
length: 45,
users: [{ id: 101 }, { id: 102 }],
hosts: [
{ userId: 101, isFixed: false },
{ userId: 102, isFixed: false },
],
},
],
bookings: [
{
id: 123,
eventTypeId: 1,
userId: originalHost.id,
uid: bookingToReassignUid,
status: BookingStatus.ACCEPTED,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:45:00.000Z`,
attendees: [
getMockBookingAttendee({
id: 2,
name: "attendee",
email: "attendee@test.com",
locale: "en",
timeZone: "Asia/Kolkata",
}),
],
references: [
{
id: 1,
type: appStoreMetadata.googlecalendar.type,
uid: "ORIGINAL_EVENT_ID",
meetingId: "ORIGINAL_EVENT_ID",
meetingPassword: null,
meetingUrl: null,
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
credentialId: 1,
deleted: null,
},
],
},
],
organizer: originalHost,
usersApartFromOrganizer: [newHost],
apps: [TestData.apps["google-calendar"]],
})
);
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: { uid: "NEW_EVENT_ID" },
update: { uid: "UPDATED_EVENT_ID" },
});
await roundRobinReassignment({
bookingId: 123,
});
// Verify that calendar deletion occurred (may be called multiple times due to duplicate references)
expect(calendarMock.deleteEventCalls.length).toBeGreaterThanOrEqual(1);
const deleteCall = calendarMock.deleteEventCalls[0];
expect(deleteCall.args.uid).toBe("ORIGINAL_EVENT_ID");
expect(deleteCall.args.externalCalendarId).toBe("MOCK_EXTERNAL_CALENDAR_ID");
expect(deleteCall.args.event.organizer.email).toBe(newHost.email); // Current implementation uses new host credentials
expect(deleteCall.args.event.uid).toBe(bookingToReassignUid);
// Verify that creation occurred with new host credentials
expect(calendarMock.createEventCalls.length).toBe(1);
const createCall = calendarMock.createEventCalls[0];
expect(createCall.args.calEvent.organizer.email).toBe(newHost.email);
// Verify the booking was reassigned to the new host
expectBookingToBeInDatabase({
uid: bookingToReassignUid,
userId: newHost.id,
});
expectSuccessfulRoundRobinReschedulingEmails({
prevOrganizer: originalHost,
newOrganizer: newHost,
emails,
});
});
});
+11 -2
View File
@@ -570,7 +570,8 @@ export default class EventManager {
newBookingId?: number,
changedOrganizer?: boolean,
previousHostDestinationCalendar?: DestinationCalendar[] | null,
isBookingRequestedReschedule?: boolean
isBookingRequestedReschedule?: boolean,
skipDeleteEventsAndMeetings?: boolean
): Promise<CreateUpdateResult> {
const originalEvt = processLocation(event);
const evt = cloneDeep(originalEvt);
@@ -637,7 +638,7 @@ export default class EventManager {
const shouldUpdateBookingReferences =
!!changedOrganizer || isLocationChanged || !!isBookingRequestedReschedule || isDailyVideoRoomExpired;
if (evt.requiresConfirmation && !changedOrganizer) {
if (evt.requiresConfirmation && !skipDeleteEventsAndMeetings) {
log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking");
// As the reschedule requires confirmation, we can't update the events and meetings to new time yet. So, just delete them and let it be handled when organizer confirms the booking.
await this.deleteEventsAndMeetings({
@@ -646,6 +647,14 @@ export default class EventManager {
});
} else {
if (changedOrganizer) {
if (!skipDeleteEventsAndMeetings) {
log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking");
await this.deleteEventsAndMeetings({
event: { ...event, destinationCalendar: previousHostDestinationCalendar },
bookingReferences: booking.references,
});
}
log.debug("RescheduleOrganizerChanged: Creating Event and Meeting for for new booking");
const createdEvent = await this.create(originalEvt);
results.push(...createdEvent.results);
+64 -54
View File
@@ -53,6 +53,7 @@ export class BusyTimesService {
})[]
| null;
bypassBusyCalendarTimes: boolean;
silentlyHandleCalendarFailures?: boolean;
shouldServeCache?: boolean;
}) {
const {
@@ -70,6 +71,7 @@ export class BusyTimesService {
rescheduleUid,
duration,
bypassBusyCalendarTimes = false,
silentlyHandleCalendarFailures = false,
shouldServeCache,
} = params;
@@ -198,63 +200,71 @@ export class BusyTimesService {
);
if (!calendarBusyTimesQuery.success) {
throw new Error(
`Failed to fetch busy calendar times for selected calendars ${selectedCalendars.map(
(calendar) => calendar.id
)}`
if (silentlyHandleCalendarFailures) {
logger.warn(
`Calendar busy times fetch failed but handling silently due to silentlyHandleCalendarFailures flag for user ${username}`,
{
selectedCalendarIds: selectedCalendars.map((calendar) => calendar.id),
}
);
} else {
throw new Error(
`Failed to fetch busy calendar times for selected calendars ${selectedCalendars.map(
(calendar) => calendar.id
)}`
);
}
} else {
const calendarBusyTimes = calendarBusyTimesQuery.data;
const endConnectedCalendarsGet = performance.now();
logger.debug(
`Connected Calendars get took ${
endConnectedCalendarsGet - startConnectedCalendarsGet
} ms for user ${username}`,
JSON.stringify({
eventTypeId,
startTimeDate,
endTimeDate,
calendarBusyTimes,
})
);
const openSeatsDateRanges = Object.keys(bookingSeatCountMap).map((key) => {
const [start, end] = key.split("<>");
return {
start: dayjs(start),
end: dayjs(end),
};
});
if (rescheduleUid) {
const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid);
if (originalRescheduleBooking) {
openSeatsDateRanges.push({
start: dayjs(originalRescheduleBooking.startTime),
end: dayjs(originalRescheduleBooking.endTime),
});
}
}
const result = subtract(
calendarBusyTimes.map((value) => ({
...value,
end: dayjs(value.end),
start: dayjs(value.start),
})),
openSeatsDateRanges
);
busyTimes.push(
...result.map((busyTime) => ({
...busyTime,
start: busyTime.start.subtract(afterEventBuffer || 0, "minute").toDate(),
end: busyTime.end.add(beforeEventBuffer || 0, "minute").toDate(),
}))
);
}
const calendarBusyTimes = calendarBusyTimesQuery.data;
const endConnectedCalendarsGet = performance.now();
logger.debug(
`Connected Calendars get took ${
endConnectedCalendarsGet - startConnectedCalendarsGet
} ms for user ${username}`,
JSON.stringify({
eventTypeId,
startTimeDate,
endTimeDate,
calendarBusyTimes,
})
);
const openSeatsDateRanges = Object.keys(bookingSeatCountMap).map((key) => {
const [start, end] = key.split("<>");
return {
start: dayjs(start),
end: dayjs(end),
};
});
if (rescheduleUid) {
const originalRescheduleBooking = bookings.find((booking) => booking.uid === rescheduleUid);
// calendar busy time from original rescheduled booking should not be blocked
if (originalRescheduleBooking) {
openSeatsDateRanges.push({
start: dayjs(originalRescheduleBooking.startTime),
end: dayjs(originalRescheduleBooking.endTime),
});
}
}
const result = subtract(
calendarBusyTimes.map((value) => ({
...value,
end: dayjs(value.end),
start: dayjs(value.start),
})),
openSeatsDateRanges
);
busyTimes.push(
...result.map((busyTime) => ({
...busyTime,
start: busyTime.start.subtract(afterEventBuffer || 0, "minute").toDate(),
end: busyTime.end.add(beforeEventBuffer || 0, "minute").toDate(),
}))
);
/*
// TODO: Disabled until we can filter Zoom events by date. Also this is adding too much latency.
const videoBusyTimes = (await getBusyVideoTimes(credentials)).filter(notEmpty);
+4
View File
@@ -54,6 +54,7 @@ const availabilitySchema = z
withSource: z.boolean().optional(),
returnDateOverrides: z.boolean(),
bypassBusyCalendarTimes: z.boolean().optional(),
silentlyHandleCalendarFailures: z.boolean().optional(),
shouldServeCache: z.boolean().optional(),
})
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
@@ -110,6 +111,7 @@ type GetUserAvailabilityQuery = {
duration?: number;
returnDateOverrides: boolean;
bypassBusyCalendarTimes: boolean;
silentlyHandleCalendarFailures?: boolean;
shouldServeCache?: boolean;
};
@@ -295,6 +297,7 @@ export class UserAvailabilityService {
duration,
returnDateOverrides,
bypassBusyCalendarTimes = false,
silentlyHandleCalendarFailures = false,
shouldServeCache,
} = availabilitySchema.parse(query);
@@ -457,6 +460,7 @@ export class UserAvailabilityService {
duration,
currentBookings: initialData?.currentBookings,
bypassBusyCalendarTimes,
silentlyHandleCalendarFailures,
shouldServeCache,
});
} catch (error) {
@@ -64,6 +64,7 @@ const BookerPlatformWrapperComponent = (
onTimeslotsLoaded,
startTime: customStartTime,
showNoAvailabilityDialog,
silentlyHandleCalendarFailures = false,
} = props;
const layout = BookerLayouts[view];
@@ -310,6 +311,7 @@ const BookerPlatformWrapperComponent = (
Boolean(event?.data?.id),
orgSlug: props.entity?.orgSlug ?? undefined,
eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "",
_silentCalendarFailures: silentlyHandleCalendarFailures,
...routingParams,
});
+1
View File
@@ -85,6 +85,7 @@ export type BookerPlatformWrapperAtomProps = Omit<
onTimeslotsLoaded?: (slots: Record<string, Slot[]>) => void;
startTime?: string | Date;
roundRobinHideOrgAndTeam?: boolean;
silentlyHandleCalendarFailures?: boolean;
};
type VIEW_TYPE = keyof typeof BookerLayouts;
@@ -136,6 +136,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
: { username: props.calUsername })}
hostsLimit={3}
allowUpdatingUrlParams={true}
silentlyHandleCalendarFailures={false}
/>
</>
)}
@@ -194,6 +194,12 @@ export class GetAvailableSlotsInput_2024_04_15 {
@ApiHideProperty()
_bypassCalendarBusyTimes?: boolean;
@Transform(({ value }) => value && value.toLowerCase() === "true")
@IsBoolean()
@IsOptional()
@ApiHideProperty()
_silentCalendarFailures?: boolean;
@Transform(({ value }) => (value ? value.toLowerCase() === "true" : false))
@IsBoolean()
@IsOptional()
Binary file not shown.
@@ -0,0 +1,46 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
import { duplicateHandler } from "./duplicate.handler";
vi.mock("@calcom/prisma", () => ({
default: prismaMock,
}));
vi.mock("@calcom/lib/server/repository/eventTypeRepository");
describe("duplicateHandler", () => {
const ctx = { user: { id: 1, profile: { id: 1 } } } as any;
const input = { id: 123, slug: "test-event", title: "Test", description: "Test", length: 30, teamId: null };
const eventType = { id: 123, userId: 1, teamId: null, users: [{ id: 1 }] };
beforeEach(() => {
vi.resetAllMocks();
prismaMock.eventType.findUnique.mockResolvedValue(eventType);
});
it("should throw BAD_REQUEST in case of unique constraint violation", async () => {
const { EventTypeRepository } = await import("@calcom/lib/server/repository/eventTypeRepository");
vi.mocked(EventTypeRepository).mockImplementation(
() =>
({
create: vi.fn().mockRejectedValue(
new PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "mockedVersion",
})
),
} as any)
);
await expect(duplicateHandler({ ctx, input })).rejects.toThrow(
new TRPCError({
code: "BAD_REQUEST",
message: "Unique constraint violation while creating a duplicate event.",
})
);
});
});
@@ -226,6 +226,13 @@ export const duplicateHandler = async ({ ctx, input }: DuplicateOptions) => {
eventType: newEventType,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
// unique constraint violation
throw new TRPCError({
code: "BAD_REQUEST",
message: "Unique constraint violation while creating a duplicate event.",
});
}
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Error duplicating event type ${error}` });
}
};
@@ -35,6 +35,7 @@ export const getScheduleSchemaObject = z.object({
skipContactOwner: z.boolean().nullish(),
_enableTroubleshooter: z.boolean().optional(),
_bypassCalendarBusyTimes: z.boolean().optional(),
_silentCalendarFailures: z.boolean().optional(),
_shouldServeCache: z.boolean().optional(),
routingFormResponseId: z.number().optional(),
queuedFormResponseId: z.string().nullish(),
@@ -737,6 +737,7 @@ export class AvailableSlotsService {
startTime,
endTime,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
}: {
input: TGetScheduleInputSchema;
@@ -753,6 +754,7 @@ export class AvailableSlotsService {
startTime: ReturnType<(typeof AvailableSlotsService)["prototype"]["getStartTime"]>;
endTime: Dayjs;
bypassBusyCalendarTimes: boolean;
silentCalendarFailures: boolean;
shouldServeCache?: boolean;
}) {
const usersWithCredentials = this.getUsersWithCredentials({
@@ -887,6 +889,7 @@ export class AvailableSlotsService {
duration: input.duration || 0,
returnDateOverrides: false,
bypassBusyCalendarTimes,
silentlyHandleCalendarFailures: silentCalendarFailures,
shouldServeCache,
},
initialData: {
@@ -958,6 +961,7 @@ export class AvailableSlotsService {
const {
_enableTroubleshooter: enableTroubleshooter = false,
_bypassCalendarBusyTimes: bypassBusyCalendarTimes = false,
_silentCalendarFailures: silentCalendarFailures = false,
_shouldServeCache,
routingFormResponseId,
queuedFormResponseId,
@@ -1069,6 +1073,7 @@ export class AvailableSlotsService {
? this.getStartTime(twoWeeksFromNow.format(), input.timeZone, eventType.minimumBookingNotice)
: endTime,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
});
@@ -1095,6 +1100,7 @@ export class AvailableSlotsService {
startTime: dayjs(),
endTime: twoWeeksFromNow,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
});
if (
@@ -1129,6 +1135,7 @@ export class AvailableSlotsService {
startTime,
endTime,
bypassBusyCalendarTimes,
silentCalendarFailures,
shouldServeCache,
}));
aggregatedAvailability = getAggregatedAvailability(allUsersAvailability, eventType.schedulingType);
+5 -5
View File
@@ -2500,7 +2500,7 @@ __metadata:
"@axiomhq/winston": ^1.2.0
"@calcom/platform-constants": "*"
"@calcom/platform-enums": "*"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.306"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.311"
"@calcom/platform-types": "*"
"@calcom/platform-utils": "*"
"@calcom/prisma": "*"
@@ -3559,13 +3559,13 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.306":
version: 0.0.306
resolution: "@calcom/platform-libraries@npm:0.0.306"
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.311":
version: 0.0.311
resolution: "@calcom/platform-libraries@npm:0.0.311"
dependencies:
"@calcom/features": "*"
"@calcom/lib": "*"
checksum: 602b8b446508429096b8e03e9edbe1ff744cf7c7af3fbe8a683aaaf0d3c96d1cd30ff356f72682449ca072beadaae95fdaa5c44b26e30cfce3bb838f519216f9
checksum: 7a38e8e0478dfe163a97ddab89285b98d7c7e86a73d3659bc29e31c059782708bfe2466e50657f6861e1c67bcfc66e1e3e44803ab3b5f65fd0fe656e880bb916
languageName: node
linkType: hard