Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
2e84bcf
init
alishaz-polymath Oct 31, 2025
c8c7527
--
alishaz-polymath Oct 31, 2025
5ad44d4
update dialog
alishaz-polymath Oct 31, 2025
bbcfb47
reassignment
alishaz-polymath Oct 31, 2025
8f8b6a5
further changes
alishaz-polymath Oct 31, 2025
aa0018f
add unit test
alishaz-polymath Oct 31, 2025
74cee60
fix
alishaz-polymath Oct 31, 2025
d238744
type fix??
alishaz-polymath Oct 31, 2025
abcb118
fix type??
alishaz-polymath Nov 2, 2025
d8fb1cf
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 2, 2025
5200df4
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 3, 2025
e1e0f03
emails
alishaz-polymath Nov 3, 2025
a63c419
workflows
alishaz-polymath Nov 3, 2025
a4b6e61
reason recorder
alishaz-polymath Nov 3, 2025
617c738
refactor reassigned email
alishaz-polymath Nov 3, 2025
bb629db
fix reassigned reason
alishaz-polymath Nov 3, 2025
75d390a
fix type
alishaz-polymath Nov 3, 2025
49f5d60
improve dialog
alishaz-polymath Nov 3, 2025
628c51e
add integration tests
alishaz-polymath Nov 3, 2025
d30599d
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 4, 2025
5fb865a
-
alishaz-polymath Nov 4, 2025
549c60b
remove unnecessary comments --1
alishaz-polymath Nov 4, 2025
fcdeb04
removed unnecessary comments
alishaz-polymath Nov 5, 2025
54d6e51
fix type?
alishaz-polymath Nov 5, 2025
d5adcd6
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 5, 2025
77dddc5
address cubic
alishaz-polymath Nov 5, 2025
ea48e7f
address feedback
alishaz-polymath Nov 6, 2025
c049941
--
alishaz-polymath Nov 6, 2025
c3c22bb
fix type?
alishaz-polymath Nov 6, 2025
17bdfd3
address cubic
alishaz-polymath Nov 6, 2025
18d080d
type-fix?
alishaz-polymath Nov 7, 2025
598a548
fix type
alishaz-polymath Nov 7, 2025
0340d9a
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 7, 2025
eeaf977
further fixes
alishaz-polymath Nov 7, 2025
773263b
fix location update
alishaz-polymath Nov 7, 2025
2e06aec
Merge branch 'feat/managed-event-reassignment' of github.com:calcom/c…
alishaz-polymath Nov 7, 2025
3ba681c
type fix
alishaz-polymath Nov 7, 2025
ad2163f
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 10, 2025
a1a030f
propagate error to top
alishaz-polymath Nov 10, 2025
abfca73
fix mocking
alishaz-polymath Nov 10, 2025
db8f2e4
fix reported bugs
alishaz-polymath Nov 10, 2025
e3110d0
fix
alishaz-polymath Nov 10, 2025
74fbed0
fix success page video URL
alishaz-polymath Nov 10, 2025
4dbd306
remove PII from logs
alishaz-polymath Nov 10, 2025
e2147eb
persist video URL
alishaz-polymath Nov 12, 2025
409d9d8
better audit trail
alishaz-polymath Nov 12, 2025
b231d0c
Merge main into feat/managed-event-reassignment
alishaz-polymath Nov 12, 2025
bb06cba
revert email function name change
alishaz-polymath Nov 12, 2025
5546cee
fix test
alishaz-polymath Nov 13, 2025
e1427ef
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 13, 2025
e3f0f14
fix flake
alishaz-polymath Nov 13, 2025
10c6952
di
alishaz-polymath Nov 13, 2025
593069d
fixes
alishaz-polymath Nov 13, 2025
cd5ad5f
extract logic and other repo access from BookingRepository
alishaz-polymath Nov 14, 2025
a797eeb
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 14, 2025
ee1afd9
fixes
alishaz-polymath Nov 14, 2025
4259afd
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 14, 2025
63ad3f2
(☞゚∀゚)☞ udit
alishaz-polymath Nov 14, 2025
89f96bb
integration test fixes
alishaz-polymath Nov 14, 2025
cc947be
mroe fixes
alishaz-polymath Nov 14, 2025
693da4c
extract to repo
alishaz-polymath Nov 14, 2025
a5c92fd
extract to repo --2
alishaz-polymath Nov 14, 2025
d3f5756
fix type
alishaz-polymath Nov 14, 2025
228c35b
cubic
alishaz-polymath Nov 15, 2025
c9a8c6a
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 17, 2025
dd6d311
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 18, 2025
f04e143
address feedback --1
alishaz-polymath Nov 21, 2025
2dd8134
--2
alishaz-polymath Nov 21, 2025
dc75bbb
wip
alishaz-polymath Nov 21, 2025
06432ce
addressed feedback
alishaz-polymath Nov 24, 2025
75eaf54
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 24, 2025
4a38e39
type fixes
alishaz-polymath Nov 24, 2025
6a305b9
type fixes
alishaz-polymath Nov 24, 2025
071d34f
fix issues
alishaz-polymath Nov 24, 2025
50254a0
feedback --1
alishaz-polymath Nov 26, 2025
0033fcf
feedback --2
alishaz-polymath Nov 26, 2025
9d380c9
BookingAccessService DI
alishaz-polymath Nov 26, 2025
590a58f
fix
alishaz-polymath Nov 26, 2025
66509c1
break down function into small functions
alishaz-polymath Nov 26, 2025
7cf0013
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 26, 2025
0d46dab
fixes --1
alishaz-polymath Nov 28, 2025
6c954df
fixes
alishaz-polymath Nov 28, 2025
6d75604
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 28, 2025
e89bc23
typefix
alishaz-polymath Nov 28, 2025
19dbe88
addressing feedback
alishaz-polymath Nov 28, 2025
ed69648
Merge branch 'main' into feat/managed-event-reassignment
alishaz-polymath Nov 28, 2025
46f5302
fix merge conflict lost change
alishaz-polymath Nov 28, 2025
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
6 changes: 6 additions & 0 deletions apps/api/v1/lib/validations/booking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ export const schemaBookingReadPublic = Booking.extend({
)
.optional(),
responses: z.record(z.any()).nullable(),
// Override metadata to handle reassignment objects from Round Robin/Managed Events
// Safe to use z.any() here because:
// 1. API v1 POST only accepts z.record(z.string()) for metadata (user input restricted)
// 2. API v1 PATCH does not accept metadata changes at all
// 3. Complex metadata (objects) are only set by trusted internal features
metadata: z.record(z.any()).nullable(),
}).pick({
id: true,
userId: true,
Expand Down
127 changes: 85 additions & 42 deletions apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll } from "vitest";

import prisma from "@calcom/prisma";

Expand All @@ -11,69 +11,114 @@ type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;

describe("PATCH /api/bookings", () => {
let member1Booking: Awaited<ReturnType<typeof prisma.booking.create>>;
let member0Booking: Awaited<ReturnType<typeof prisma.booking.create>>;
const createdBookingIds: number[] = [];
let testAdminUserId: number | null = null;

beforeAll(async () => {
const acmeOrg = await prisma.team.findFirst({
where: {
slug: "acme",
isOrganization: true,
const member1 = await prisma.user.findFirstOrThrow({
where: { email: "member1-acme@example.com" },
});

const member0 = await prisma.user.findFirstOrThrow({
where: { email: "member0-acme@example.com" },
});

// Create bookings for testing
member1Booking = await prisma.booking.create({
data: {
uid: `test-member1-booking-${Date.now()}`,
title: "Member 1 Test Booking",
startTime: new Date(Date.now() + 86400000), // Tomorrow
endTime: new Date(Date.now() + 90000000), // Tomorrow + 1 hour
userId: member1.id,
status: "ACCEPTED",
},
});
createdBookingIds.push(member1Booking.id);

member0Booking = await prisma.booking.create({
data: {
uid: `test-member0-booking-${Date.now()}`,
title: "Member 0 Test Booking",
startTime: new Date(Date.now() + 172800000), // Day after tomorrow
endTime: new Date(Date.now() + 176400000), // Day after tomorrow + 1 hour
userId: member0.id,
status: "ACCEPTED",
},
});
createdBookingIds.push(member0Booking.id);
});

afterAll(async () => {
if (createdBookingIds.length > 0) {
await prisma.booking.deleteMany({
where: { id: { in: createdBookingIds } },
});
}

if (acmeOrg) {
await prisma.organizationSettings.upsert({
where: {
organizationId: acmeOrg.id,
},
update: {
isAdminAPIEnabled: true,
},
create: {
organizationId: acmeOrg.id,
orgAutoAcceptEmail: "acme.com",
isAdminAPIEnabled: true,
},
// Clean up test admin user if created
if (testAdminUserId) {
await prisma.user.delete({
where: { id: testAdminUserId },
});
}
});
it("Returns 403 when user has no permission to the booking", async () => {
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
// Member2 tries to access Member0's booking - should fail
const member2 = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });

const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "PATCH",
body: {
title: booking.title,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
userId: memberUser.id,
title: member0Booking.title,
startTime: member0Booking.startTime.toISOString(),
endTime: member0Booking.endTime.toISOString(),
userId: member2.id,
},
query: {
id: booking.id,
id: member0Booking.id,
},
});

req.userId = memberUser.id;
req.userId = member2.id;

await handler(req, res);
expect(res.statusCode).toBe(403);
});

it("Allows PATCH when user is system-wide admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
// Check if admin user already exists before upserting
const existingAdmin = await prisma.user.findUnique({ where: { email: "test-admin@example.com" } });

// Create a system-wide admin user for this test
const adminUser = await prisma.user.upsert({
where: { email: "test-admin@example.com" },
update: { role: "ADMIN" },
create: {
email: "test-admin@example.com",
username: "test-admin",
name: "Test Admin",
role: "ADMIN",
},
});

// Only track for cleanup if we created it (not if it already existed)
if (!existingAdmin) {
testAdminUserId = adminUser.id;
}

const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "PATCH",
body: {
title: booking.title,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
userId: proUser.id,
title: member0Booking.title,
startTime: member0Booking.startTime.toISOString(),
endTime: member0Booking.endTime.toISOString(),
userId: member0Booking.userId,
},
query: {
id: booking.id,
id: member0Booking.id,
},
});

Expand All @@ -86,19 +131,17 @@ describe("PATCH /api/bookings", () => {

it("Allows PATCH when user is org-wide admin", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member1-acme@example.com" } });
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: memberUser.id } });

const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "PATCH",
body: {
title: booking.title,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
userId: memberUser.id,
title: member1Booking.title,
startTime: member1Booking.startTime.toISOString(),
endTime: member1Booking.endTime.toISOString(),
userId: member1Booking.userId,
},
query: {
id: booking.id,
id: member1Booking.id,
},
});

Expand Down
64 changes: 40 additions & 24 deletions apps/api/v1/test/lib/bookings/_get.integration-test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, it, beforeAll } from "vitest";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { ZodError } from "zod";

import { prisma } from "@calcom/prisma";
Expand All @@ -16,51 +16,67 @@ const DefaultPagination = {
skip: 0,
};

describe("GET /api/bookings", async () => {
describe("GET /api/bookings", () => {
let proUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
let proUserBooking: Awaited<ReturnType<typeof prisma.booking.findFirstOrThrow>>;
let memberUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
let memberUserBooking: Awaited<ReturnType<typeof prisma.booking.create>>;

beforeAll(async () => {
const acmeOrg = await prisma.team.findFirst({
proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });

memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });

// Find an event type for memberUser or use a simple booking
const memberEventType = await prisma.eventType.findFirst({
where: {
slug: "acme",
isOrganization: true,
OR: [
{ userId: memberUser.id },
{ team: { members: { some: { userId: memberUser.id } } } }
]
}
});

memberUserBooking = await prisma.booking.create({
data: {
uid: `test-member-booking-${Date.now()}`,
title: "Member Test Booking",
startTime: new Date(Date.now() + 86400000), // Tomorrow
endTime: new Date(Date.now() + 90000000), // Tomorrow + 1 hour
userId: memberUser.id,
eventTypeId: memberEventType?.id,
status: "ACCEPTED",
},
});
});

if (acmeOrg) {
await prisma.organizationSettings.upsert({
where: {
organizationId: acmeOrg.id,
},
update: {
isAdminAPIEnabled: true,
},
create: {
organizationId: acmeOrg.id,
orgAutoAcceptEmail: "acme.com",
isAdminAPIEnabled: true,
},
afterAll(async () => {
// Clean up the test booking created in beforeAll
if (memberUserBooking?.id) {
await prisma.booking.delete({
where: { id: memberUserBooking.id },
});
}
});
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });

it("Does not return bookings of other users when user has no permission", async () => {
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });

const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
userId: proUser.id,
userId: proUser.id, // Try to access proUser's bookings
},
pagination: DefaultPagination,
});

req.userId = memberUser.id;
req.userId = memberUser.id; // But request is from memberUser

const responseData = await handler(req);
const groupedUsers = new Set(responseData.bookings.map((b) => b.userId));

// Should only return memberUser's own bookings, not proUser's
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
expect(responseData.bookings.find((b) => b.id === memberUserBooking.id)).toBeDefined();
expect(groupedUsers.size).toBe(1);
const firstEntry = groupedUsers.entries().next().value;
expect(firstEntry?.[0]).toBe(memberUser.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ export function BookingActionsDropdown({
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
bookingFromRoutingForm={isBookingFromRoutingForm}
isManagedEvent={booking.eventType?.parentId != null}
/>
)}
<EditLocationDialog
Expand Down
10 changes: 7 additions & 3 deletions apps/web/components/booking/actions/bookingActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
} = context;
const seatReferenceUid = getSeatReferenceUid();

const isReassignableRoundRobin =
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
(!booking.eventType.hostGroups || booking.eventType.hostGroups.length <= 1);
const isManagedChildEvent = booking.eventType.parentId != null;
const isReassignable = isReassignableRoundRobin || isManagedChildEvent;

const actions: (ActionType | null)[] = [
{
id: "reschedule",
Expand Down Expand Up @@ -154,9 +160,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
icon: "user-plus",
disabled: false,
},
// Reassign if round robin with no or one host groups
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
(!booking.eventType.hostGroups || booking.eventType.hostGroups?.length <= 1)
isReassignable
? {
id: "reassign",
label: t("reassign"),
Expand Down
Loading
Loading