Skip to content

Commit 0103321

Browse files
feat: managed event reassignment (calcom#24809)
* init * -- * update dialog * reassignment * further changes * add unit test * fix * type fix?? * fix type?? * emails * workflows * reason recorder * refactor reassigned email * fix reassigned reason * fix type * improve dialog * add integration tests * - * remove unnecessary comments --1 * removed unnecessary comments * fix type? * address cubic * address feedback * -- * fix type? * address cubic * type-fix? * fix type * further fixes * fix location update * type fix * propagate error to top * fix mocking * fix reported bugs * fix * fix success page video URL * remove PII from logs * persist video URL * better audit trail * revert email function name change * fix test * fix flake * di * fixes * extract logic and other repo access from BookingRepository * fixes * (☞゚∀゚)☞ udit * integration test fixes * mroe fixes * extract to repo * extract to repo --2 * fix type * cubic * address feedback --1 * --2 * wip * addressed feedback * type fixes * type fixes * fix issues * feedback --1 * feedback --2 * BookingAccessService DI * fix * break down function into small functions * fixes --1 * fixes * typefix * addressing feedback * fix merge conflict lost change
1 parent 38890da commit 0103321

54 files changed

Lines changed: 4257 additions & 441 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/v1/lib/validations/booking.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ export const schemaBookingReadPublic = Booking.extend({
113113
)
114114
.optional(),
115115
responses: z.record(z.any()).nullable(),
116+
// Override metadata to handle reassignment objects from Round Robin/Managed Events
117+
// Safe to use z.any() here because:
118+
// 1. API v1 POST only accepts z.record(z.string()) for metadata (user input restricted)
119+
// 2. API v1 PATCH does not accept metadata changes at all
120+
// 3. Complex metadata (objects) are only set by trusted internal features
121+
metadata: z.record(z.any()).nullable(),
116122
}).pick({
117123
id: true,
118124
userId: true,

apps/api/v1/test/lib/bookings/[id]/_patch.integration-test.ts

Lines changed: 85 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Request, Response } from "express";
22
import type { NextApiRequest, NextApiResponse } from "next";
33
import { createMocks } from "node-mocks-http";
4-
import { describe, it, expect, beforeAll } from "vitest";
4+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
55

66
import prisma from "@calcom/prisma";
77

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

1313
describe("PATCH /api/bookings", () => {
14+
let member1Booking: Awaited<ReturnType<typeof prisma.booking.create>>;
15+
let member0Booking: Awaited<ReturnType<typeof prisma.booking.create>>;
16+
const createdBookingIds: number[] = [];
17+
let testAdminUserId: number | null = null;
18+
1419
beforeAll(async () => {
15-
const acmeOrg = await prisma.team.findFirst({
16-
where: {
17-
slug: "acme",
18-
isOrganization: true,
20+
const member1 = await prisma.user.findFirstOrThrow({
21+
where: { email: "member1-acme@example.com" },
22+
});
23+
24+
const member0 = await prisma.user.findFirstOrThrow({
25+
where: { email: "member0-acme@example.com" },
26+
});
27+
28+
// Create bookings for testing
29+
member1Booking = await prisma.booking.create({
30+
data: {
31+
uid: `test-member1-booking-${Date.now()}`,
32+
title: "Member 1 Test Booking",
33+
startTime: new Date(Date.now() + 86400000), // Tomorrow
34+
endTime: new Date(Date.now() + 90000000), // Tomorrow + 1 hour
35+
userId: member1.id,
36+
status: "ACCEPTED",
37+
},
38+
});
39+
createdBookingIds.push(member1Booking.id);
40+
41+
member0Booking = await prisma.booking.create({
42+
data: {
43+
uid: `test-member0-booking-${Date.now()}`,
44+
title: "Member 0 Test Booking",
45+
startTime: new Date(Date.now() + 172800000), // Day after tomorrow
46+
endTime: new Date(Date.now() + 176400000), // Day after tomorrow + 1 hour
47+
userId: member0.id,
48+
status: "ACCEPTED",
1949
},
2050
});
51+
createdBookingIds.push(member0Booking.id);
52+
});
53+
54+
afterAll(async () => {
55+
if (createdBookingIds.length > 0) {
56+
await prisma.booking.deleteMany({
57+
where: { id: { in: createdBookingIds } },
58+
});
59+
}
2160

22-
if (acmeOrg) {
23-
await prisma.organizationSettings.upsert({
24-
where: {
25-
organizationId: acmeOrg.id,
26-
},
27-
update: {
28-
isAdminAPIEnabled: true,
29-
},
30-
create: {
31-
organizationId: acmeOrg.id,
32-
orgAutoAcceptEmail: "acme.com",
33-
isAdminAPIEnabled: true,
34-
},
61+
// Clean up test admin user if created
62+
if (testAdminUserId) {
63+
await prisma.user.delete({
64+
where: { id: testAdminUserId },
3565
});
3666
}
3767
});
3868
it("Returns 403 when user has no permission to the booking", async () => {
39-
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
40-
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
41-
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
69+
// Member2 tries to access Member0's booking - should fail
70+
const member2 = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
4271

4372
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
4473
method: "PATCH",
4574
body: {
46-
title: booking.title,
47-
startTime: booking.startTime.toISOString(),
48-
endTime: booking.endTime.toISOString(),
49-
userId: memberUser.id,
75+
title: member0Booking.title,
76+
startTime: member0Booking.startTime.toISOString(),
77+
endTime: member0Booking.endTime.toISOString(),
78+
userId: member2.id,
5079
},
5180
query: {
52-
id: booking.id,
81+
id: member0Booking.id,
5382
},
5483
});
5584

56-
req.userId = memberUser.id;
85+
req.userId = member2.id;
5786

5887
await handler(req, res);
5988
expect(res.statusCode).toBe(403);
6089
});
6190

6291
it("Allows PATCH when user is system-wide admin", async () => {
63-
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
64-
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
65-
const booking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
92+
// Check if admin user already exists before upserting
93+
const existingAdmin = await prisma.user.findUnique({ where: { email: "test-admin@example.com" } });
94+
95+
// Create a system-wide admin user for this test
96+
const adminUser = await prisma.user.upsert({
97+
where: { email: "test-admin@example.com" },
98+
update: { role: "ADMIN" },
99+
create: {
100+
email: "test-admin@example.com",
101+
username: "test-admin",
102+
name: "Test Admin",
103+
role: "ADMIN",
104+
},
105+
});
106+
107+
// Only track for cleanup if we created it (not if it already existed)
108+
if (!existingAdmin) {
109+
testAdminUserId = adminUser.id;
110+
}
66111

67112
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
68113
method: "PATCH",
69114
body: {
70-
title: booking.title,
71-
startTime: booking.startTime.toISOString(),
72-
endTime: booking.endTime.toISOString(),
73-
userId: proUser.id,
115+
title: member0Booking.title,
116+
startTime: member0Booking.startTime.toISOString(),
117+
endTime: member0Booking.endTime.toISOString(),
118+
userId: member0Booking.userId,
74119
},
75120
query: {
76-
id: booking.id,
121+
id: member0Booking.id,
77122
},
78123
});
79124

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

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

92135
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
93136
method: "PATCH",
94137
body: {
95-
title: booking.title,
96-
startTime: booking.startTime.toISOString(),
97-
endTime: booking.endTime.toISOString(),
98-
userId: memberUser.id,
138+
title: member1Booking.title,
139+
startTime: member1Booking.startTime.toISOString(),
140+
endTime: member1Booking.endTime.toISOString(),
141+
userId: member1Booking.userId,
99142
},
100143
query: {
101-
id: booking.id,
144+
id: member1Booking.id,
102145
},
103146
});
104147

apps/api/v1/test/lib/bookings/_get.integration-test.ts

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Request, Response } from "express";
22
import type { NextApiRequest, NextApiResponse } from "next";
33
import { createMocks } from "node-mocks-http";
4-
import { describe, expect, it, beforeAll } from "vitest";
4+
import { describe, expect, it, beforeAll, afterAll } from "vitest";
55
import { ZodError } from "zod";
66

77
import { prisma } from "@calcom/prisma";
@@ -16,51 +16,67 @@ const DefaultPagination = {
1616
skip: 0,
1717
};
1818

19-
describe("GET /api/bookings", async () => {
19+
describe("GET /api/bookings", () => {
20+
let proUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
21+
let proUserBooking: Awaited<ReturnType<typeof prisma.booking.findFirstOrThrow>>;
22+
let memberUser: Awaited<ReturnType<typeof prisma.user.findFirstOrThrow>>;
23+
let memberUserBooking: Awaited<ReturnType<typeof prisma.booking.create>>;
24+
2025
beforeAll(async () => {
21-
const acmeOrg = await prisma.team.findFirst({
26+
proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
27+
proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
28+
29+
memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
30+
31+
// Find an event type for memberUser or use a simple booking
32+
const memberEventType = await prisma.eventType.findFirst({
2233
where: {
23-
slug: "acme",
24-
isOrganization: true,
34+
OR: [
35+
{ userId: memberUser.id },
36+
{ team: { members: { some: { userId: memberUser.id } } } }
37+
]
38+
}
39+
});
40+
41+
memberUserBooking = await prisma.booking.create({
42+
data: {
43+
uid: `test-member-booking-${Date.now()}`,
44+
title: "Member Test Booking",
45+
startTime: new Date(Date.now() + 86400000), // Tomorrow
46+
endTime: new Date(Date.now() + 90000000), // Tomorrow + 1 hour
47+
userId: memberUser.id,
48+
eventTypeId: memberEventType?.id,
49+
status: "ACCEPTED",
2550
},
2651
});
52+
});
2753

28-
if (acmeOrg) {
29-
await prisma.organizationSettings.upsert({
30-
where: {
31-
organizationId: acmeOrg.id,
32-
},
33-
update: {
34-
isAdminAPIEnabled: true,
35-
},
36-
create: {
37-
organizationId: acmeOrg.id,
38-
orgAutoAcceptEmail: "acme.com",
39-
isAdminAPIEnabled: true,
40-
},
54+
afterAll(async () => {
55+
// Clean up the test booking created in beforeAll
56+
if (memberUserBooking?.id) {
57+
await prisma.booking.delete({
58+
where: { id: memberUserBooking.id },
4159
});
4260
}
4361
});
44-
const proUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
45-
const proUserBooking = await prisma.booking.findFirstOrThrow({ where: { userId: proUser.id } });
4662

4763
it("Does not return bookings of other users when user has no permission", async () => {
48-
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
49-
5064
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
5165
method: "GET",
5266
query: {
53-
userId: proUser.id,
67+
userId: proUser.id, // Try to access proUser's bookings
5468
},
5569
pagination: DefaultPagination,
5670
});
5771

58-
req.userId = memberUser.id;
72+
req.userId = memberUser.id; // But request is from memberUser
5973

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

77+
// Should only return memberUser's own bookings, not proUser's
6378
expect(responseData.bookings.find((b) => b.userId === memberUser.id)).toBeDefined();
79+
expect(responseData.bookings.find((b) => b.id === memberUserBooking.id)).toBeDefined();
6480
expect(groupedUsers.size).toBe(1);
6581
const firstEntry = groupedUsers.entries().next().value;
6682
expect(firstEntry?.[0]).toBe(memberUser.id);

apps/web/components/booking/actions/BookingActionsDropdown.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ export function BookingActionsDropdown({
427427
bookingId={booking.id}
428428
teamId={booking.eventType?.team?.id || 0}
429429
bookingFromRoutingForm={isBookingFromRoutingForm}
430+
isManagedEvent={booking.eventType?.parentId != null}
430431
/>
431432
)}
432433
<EditLocationDialog

apps/web/components/booking/actions/bookingActions.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
109109
} = context;
110110
const seatReferenceUid = getSeatReferenceUid();
111111

112+
const isReassignableRoundRobin =
113+
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
114+
(!booking.eventType.hostGroups || booking.eventType.hostGroups.length <= 1);
115+
const isManagedChildEvent = booking.eventType.parentId != null;
116+
const isReassignable = isReassignableRoundRobin || isManagedChildEvent;
117+
112118
const actions: (ActionType | null)[] = [
113119
{
114120
id: "reschedule",
@@ -154,9 +160,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
154160
icon: "user-plus",
155161
disabled: false,
156162
},
157-
// Reassign if round robin with no or one host groups
158-
booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
159-
(!booking.eventType.hostGroups || booking.eventType.hostGroups?.length <= 1)
163+
isReassignable
160164
? {
161165
id: "reassign",
162166
label: t("reassign"),

0 commit comments

Comments
 (0)