Skip to content

Commit 517541f

Browse files
authored
Merge branch 'main' into allow-team-with-same-slug
2 parents 07846ec + b223937 commit 517541f

File tree

18 files changed

+1738
-102
lines changed

18 files changed

+1738
-102
lines changed

apps/web/test/lib/handleChildrenEventTypes.test.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,10 @@ describe("handleChildrenEventTypes", () => {
143143
profileId: null,
144144
updatedValues: {},
145145
});
146+
const { createdAt, updatedAt, ...expectedEvType } = evType;
146147
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
147148
data: {
148-
...evType,
149+
...expectedEvType,
149150
parentId: 1,
150151
users: { connect: [{ id: 4 }] },
151152
lockTimeZoneToggleOnBookingPage: false,
@@ -206,7 +207,7 @@ describe("handleChildrenEventTypes", () => {
206207
bookingLimits: undefined,
207208
},
208209
});
209-
const { profileId, autoTranslateDescriptionEnabled, ...rest } = evType;
210+
const { profileId, autoTranslateDescriptionEnabled, createdAt, updatedAt, ...rest } = evType;
210211
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
211212
data: {
212213
...rest,
@@ -317,9 +318,10 @@ describe("handleChildrenEventTypes", () => {
317318
profileId: null,
318319
updatedValues: {},
319320
});
321+
const { createdAt, updatedAt, ...expectedEvType } = evType;
320322
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
321323
data: {
322-
...evType,
324+
...expectedEvType,
323325
parentId: 1,
324326
users: { connect: [{ id: 4 }] },
325327
bookingLimits: undefined,
@@ -383,7 +385,7 @@ describe("handleChildrenEventTypes", () => {
383385
length: 30,
384386
},
385387
});
386-
const { profileId, autoTranslateDescriptionEnabled, ...rest } = evType;
388+
const { profileId, autoTranslateDescriptionEnabled, createdAt, updatedAt, ...rest } = evType;
387389
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
388390
data: {
389391
...rest,
@@ -464,7 +466,11 @@ describe("handleChildrenEventTypes", () => {
464466
...evType,
465467
};
466468

467-
prismaMock.eventType.update.mockResolvedValue(mockUpdatedEventType);
469+
prismaMock.eventType.update.mockResolvedValue({
470+
...mockUpdatedEventType,
471+
createdAt: new Date(),
472+
updatedAt: new Date(),
473+
});
468474

469475
await updateChildrenEventTypes({
470476
eventTypeId: 1,
@@ -480,9 +486,10 @@ describe("handleChildrenEventTypes", () => {
480486
updatedValues: {},
481487
});
482488

489+
const { createdAt, updatedAt, ...expectedEvType } = evType;
483490
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
484491
data: {
485-
...evType,
492+
...expectedEvType,
486493
bookingLimits: undefined,
487494
durationLimits: undefined,
488495
recurringEvent: undefined,
@@ -507,7 +514,7 @@ describe("handleChildrenEventTypes", () => {
507514
allowReschedulingCancelledBookings: false,
508515
},
509516
});
510-
const { profileId, rrSegmentQueryValue, ...rest } = evType;
517+
const { profileId, rrSegmentQueryValue, createdAt: _, updatedAt: __, ...rest } = evType;
511518
if ("workflows" in rest) delete rest.workflows;
512519
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
513520
data: {

packages/features/eventtypes/lib/defaultEvents.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ const commons = {
149149
eventTypeColor: null,
150150
hostGroups: [],
151151
bookingRequiresAuthentication: false,
152+
createdAt: null,
153+
updatedAt: null,
152154
};
153155

154156
export const dynamicEvent = {

packages/lib/test/builder.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
162162
restrictionScheduleId: null,
163163
useBookerTimezone: false,
164164
bookingRequiresAuthentication: false,
165+
createdAt: null,
166+
updatedAt: null,
165167
...eventType,
166168
};
167169
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Prisma } from "@calcom/prisma/client";
2+
3+
export function eventTypeTimestampsExtension() {
4+
return Prisma.defineExtension({
5+
query: {
6+
eventType: {
7+
async create({ args, query }) {
8+
const now = new Date();
9+
args.data.createdAt = now;
10+
return query(args);
11+
},
12+
async createMany({ args, query }) {
13+
const now = new Date();
14+
if (Array.isArray(args.data)) {
15+
args.data = args.data.map((item) => ({
16+
...item,
17+
createdAt: now,
18+
}));
19+
} else {
20+
args.data.createdAt = now;
21+
}
22+
return query(args);
23+
},
24+
},
25+
},
26+
});
27+
}

packages/prisma/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PrismaClient, type Prisma } from "@calcom/prisma/client";
22

33
import { bookingIdempotencyKeyExtension } from "./extensions/booking-idempotency-key";
44
import { disallowUndefinedDeleteUpdateManyExtension } from "./extensions/disallow-undefined-delete-update-many";
5+
import { eventTypeTimestampsExtension } from "./extensions/event-type-timestamps";
56
import { excludeLockedUsersExtension } from "./extensions/exclude-locked-users";
67
import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams";
78
import { usageTrackingExtention } from "./extensions/usage-tracking";
@@ -42,6 +43,7 @@ export const customPrisma = (options?: Prisma.PrismaClientOptions) =>
4243
.$extends(excludeLockedUsersExtension())
4344
.$extends(excludePendingPaymentsExtension())
4445
.$extends(bookingIdempotencyKeyExtension())
46+
.$extends(eventTypeTimestampsExtension())
4547
.$extends(disallowUndefinedDeleteUpdateManyExtension()) as unknown as PrismaClient;
4648

4749
// If any changed on middleware server restart is required
@@ -58,6 +60,7 @@ export const prisma: PrismaClient = baseClient
5860
.$extends(excludeLockedUsersExtension())
5961
.$extends(excludePendingPaymentsExtension())
6062
.$extends(bookingIdempotencyKeyExtension())
63+
.$extends(eventTypeTimestampsExtension())
6164
.$extends(disallowUndefinedDeleteUpdateManyExtension()) as unknown as PrismaClient;
6265

6366
// This prisma instance is meant to be used only for READ operations.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- AlterTable
2+
ALTER TABLE "EventType" ADD COLUMN "createdAt" TIMESTAMP(3),
3+
ADD COLUMN "updatedAt" TIMESTAMP(3);

packages/prisma/schema.prisma

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ model EventType {
244244
245245
bookingRequiresAuthentication Boolean @default(false)
246246
247+
createdAt DateTime?
248+
updatedAt DateTime? @updatedAt
249+
247250
@@unique([userId, slug])
248251
@@unique([teamId, slug])
249252
@@unique([userId, parentId])
@@ -1747,7 +1750,7 @@ model BookingDenormalized {
17471750
}
17481751

17491752
view BookingTimeStatusDenormalized {
1750-
id Int @id @unique
1753+
id Int @unique
17511754
uid String
17521755
eventTypeId Int?
17531756
title String
Lines changed: 45 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,121 +1,72 @@
1-
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
2-
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
3-
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
41
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
5-
import { isTeamOwner } from "@calcom/features/ee/teams/lib/queries";
6-
import { TeamService } from "@calcom/lib/server/service/teamService";
7-
import { prisma } from "@calcom/prisma";
8-
import { MembershipRole } from "@calcom/prisma/enums";
9-
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
102

113
import { TRPCError } from "@trpc/server";
124

135
import type { TRemoveMemberInputSchema } from "./removeMember.schema";
6+
import { RemoveMemberServiceFactory } from "./removeMember/RemoveMemberServiceFactory";
147

158
type RemoveMemberOptions = {
169
ctx: {
17-
user: NonNullable<TrpcSessionUser>;
18-
sourceIp?: string;
10+
user: {
11+
id: number;
12+
organization?: {
13+
isOrgAdmin: boolean;
14+
};
15+
};
1916
};
2017
input: TRemoveMemberInputSchema;
2118
};
2219

23-
export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) => {
20+
export const removeMemberHandler = async ({
21+
ctx: {
22+
user: { id: userId, organization },
23+
},
24+
input,
25+
}: RemoveMemberOptions) => {
2426
await checkRateLimitAndThrowError({
25-
identifier: `removeMember.${ctx.user.id}`,
27+
identifier: `removeMember.${userId}`,
2628
});
2729

2830
const { memberIds, teamIds, isOrg } = input;
31+
const isOrgAdmin = organization?.isOrgAdmin ?? false;
32+
33+
// Note: This assumes that all teams in the request have the same PBAC setting 9999% chance they do.
34+
const primaryTeamId = teamIds[0];
35+
if (!primaryTeamId) {
36+
throw new TRPCError({
37+
code: "BAD_REQUEST",
38+
message: "At least one team ID must be provided",
39+
});
40+
}
2941

30-
// Check PBAC permissions for each team
31-
const hasRemovePermission = await Promise.all(
32-
teamIds.map(async (teamId) => {
33-
// Get user's membership role in this team
34-
const membership = await prisma.membership.findFirst({
35-
where: {
36-
userId: ctx.user.id,
37-
teamId: teamId,
38-
},
39-
select: {
40-
role: true,
41-
},
42-
});
43-
44-
if (!membership) return false;
45-
46-
// Check PBAC permissions for removing team members
47-
const permissions = await getSpecificPermissions({
48-
userId: ctx.user.id,
49-
teamId: teamId,
50-
resource: isOrg ? Resource.Organization : Resource.Team,
51-
userRole: membership.role,
52-
actions: [CustomAction.Remove],
53-
fallbackRoles: {
54-
[CustomAction.Remove]: {
55-
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
56-
},
57-
},
58-
});
59-
60-
return permissions[CustomAction.Remove];
61-
})
62-
).then((results) => results.every((result) => result));
42+
// Get the appropriate service based on feature flag
43+
const service = await RemoveMemberServiceFactory.create(primaryTeamId);
6344

64-
// Check if user is trying to remove themselves (allowed for non-owners)
65-
const isRemovingSelf = memberIds.length === 1 && memberIds[0] === ctx.user.id;
45+
const { hasPermission } = await service.checkRemovePermissions({
46+
userId,
47+
isOrgAdmin,
48+
memberIds,
49+
teamIds,
50+
isOrg,
51+
});
6652

67-
// Allow if user has remove permission OR if they're removing themselves
68-
if (!hasRemovePermission && !isRemovingSelf) {
53+
if (!hasPermission) {
6954
throw new TRPCError({ code: "UNAUTHORIZED" });
7055
}
7156

72-
// TODO(SEAN): Remove this after PBAC is rolled out.
73-
// Check if any team has PBAC enabled
74-
const featuresRepository = new FeaturesRepository(prisma);
75-
const pbacEnabledForTeams = await Promise.all(
76-
teamIds.map(async (teamId) => await featuresRepository.checkIfTeamHasFeature(teamId, "pbac"))
57+
await service.validateRemoval(
58+
{
59+
userId,
60+
isOrgAdmin,
61+
memberIds,
62+
teamIds,
63+
isOrg,
64+
},
65+
hasPermission
7766
);
78-
const isAnyTeamPBACEnabled = pbacEnabledForTeams.some((enabled) => enabled);
79-
80-
// Only apply traditional owner-based logic if PBAC is not enabled for any teams
81-
if (!isAnyTeamPBACEnabled) {
82-
// Only a team owner can remove another team owner.
83-
const isAnyMemberOwnerAndCurrentUserNotOwner = await Promise.all(
84-
memberIds.map(async (memberId) => {
85-
const isAnyTeamOwnerAndCurrentUserNotOwner = await Promise.all(
86-
teamIds.map(async (teamId) => {
87-
return (await isTeamOwner(memberId, teamId)) && !(await isTeamOwner(ctx.user.id, teamId));
88-
})
89-
).then((results) => results.some((result) => result));
90-
91-
return isAnyTeamOwnerAndCurrentUserNotOwner;
92-
})
93-
).then((results) => results.some((result) => result));
94-
95-
if (isAnyMemberOwnerAndCurrentUserNotOwner) {
96-
throw new TRPCError({
97-
code: "UNAUTHORIZED",
98-
message: "Only a team owner can remove another team owner.",
99-
});
100-
}
101-
102-
// Check if user is trying to remove themselves from a team they own (prevent this)
103-
if (isRemovingSelf && hasRemovePermission) {
104-
// Additional check: ensure they're not an owner trying to remove themselves
105-
const isOwnerOfAnyTeam = await Promise.all(
106-
teamIds.map(async (teamId) => await isTeamOwner(ctx.user.id, teamId))
107-
).then((results) => results.some((result) => result));
108-
109-
if (isOwnerOfAnyTeam) {
110-
throw new TRPCError({
111-
code: "FORBIDDEN",
112-
message: "You can not remove yourself from a team you own.",
113-
});
114-
}
115-
}
116-
}
11767

118-
await TeamService.removeMembers({ teamIds, userIds: memberIds, isOrg });
68+
// Perform the removal
69+
await service.removeMembers(memberIds, teamIds, isOrg);
11970
};
12071

12172
export default removeMemberHandler;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { TeamService } from "@calcom/lib/server/service/teamService";
2+
3+
import type {
4+
IRemoveMemberService,
5+
RemoveMemberContext,
6+
RemoveMemberPermissionResult,
7+
} from "./IRemoveMemberService";
8+
9+
/**
10+
* Base abstract class for remove member services
11+
* Provides common functionality and defines abstract methods for specific implementations
12+
*/
13+
export abstract class BaseRemoveMemberService implements IRemoveMemberService {
14+
abstract checkRemovePermissions(context: RemoveMemberContext): Promise<RemoveMemberPermissionResult>;
15+
16+
abstract validateRemoval(context: RemoveMemberContext, hasPermission: boolean): Promise<void>;
17+
18+
async removeMembers(memberIds: number[], teamIds: number[], isOrg: boolean): Promise<void> {
19+
await TeamService.removeMembers({ teamIds, userIds: memberIds, isOrg });
20+
}
21+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { MembershipRole } from "@calcom/prisma/enums";
2+
3+
export interface RemoveMemberContext {
4+
userId: number;
5+
isOrgAdmin: boolean;
6+
memberIds: number[];
7+
teamIds: number[];
8+
isOrg: boolean;
9+
}
10+
11+
export interface RemoveMemberPermissionResult {
12+
hasPermission: boolean;
13+
userRoles?: Map<number, MembershipRole | null>;
14+
}
15+
16+
export interface IRemoveMemberService {
17+
/**
18+
* Checks if the user has permission to remove members from teams
19+
*/
20+
checkRemovePermissions(context: RemoveMemberContext): Promise<RemoveMemberPermissionResult>;
21+
22+
/**
23+
* Validates that the removal can proceed (e.g., owner checks)
24+
*/
25+
validateRemoval(context: RemoveMemberContext, hasPermission: boolean): Promise<void>;
26+
27+
/**
28+
* Performs the actual removal of members from teams
29+
*/
30+
removeMembers(memberIds: number[], teamIds: number[], isOrg: boolean): Promise<void>;
31+
}

0 commit comments

Comments
 (0)