Compare commits

...
18 Commits
Author SHA1 Message Date
keithwillcode 260f4e1b51 chore: release v5.4.19
Post release / release (push) Canceled after 0s
2025-07-11 01:25:57 +00:00
Keith Williams e6c36af3b4 revert: "fix: Add new route to create team routing-form response (#22347)" (#22399)
This reverts commit 89e7fca183.
2025-07-11 01:02:11 +00:00
Omar López 2fb2458eee Revert "feat: add race condition test for calendar-cache-serve booking flow (…" (#22400) 2025-07-10 17:43:41 -07:00
keithwillcode b340b0edbe chore: release v5.4.18 2025-07-10 22:59:01 +00:00
f7fd1c4402 feat: remove unused x-cal-timezone header and middleware matcher (#22398)
* feat: remove unused x-cal-timezone header and middleware matcher

- Remove x-cal-timezone header setting from middleware.ts
- Remove x-cal-timezone header setting from createNextApiHandler.ts
- Remove /api/trpc/:path* from middleware matcher
- Eliminates ~50M edge requests per month for unused functionality
- All timezone handling uses browser detection and localStorage instead

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* update POST_METHODS_ALLOWED_API_ROUTES

* test: remove api/trpc POST validation test

- Remove test for /api/trpc/book/event POST requests since /api/trpc/:path* was removed from middleware matcher
- Keep test for /api/auth/signup which is still processed by middleware

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
2025-07-10 22:16:25 +00:00
devin-ai-integration[bot]andmorgan@cal.com c316721051 refactor: convert BookingRepository to use dependency injection pattern (#22389)
* refactor: convert BookingRepository to use dependency injection pattern

- Add constructor injection for PrismaClient to BookingRepository
- Convert all static methods to instance methods using this.prismaClient
- Update all usage sites to use two-step instantiation pattern:
  const bookingRepo = new BookingRepository(prisma); bookingRepo.method(...)
- Apply same dependency injection pattern as UserRepository, TeamRepository, and SelectedSlotsRepository
- Update test files to use correct prismaMock import paths
- Maintain all existing functionality and type safety

Files updated:
- BookingRepository class structure and all 15 static methods
- Video meeting pages and booking views
- Booking utilities and services (handleNewBooking, originalRescheduledBookingUtils)
- Round robin handlers and reassignment logic
- Interval limits and booking limits checking
- Test files with proper mocking setup

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

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-07-10 18:06:57 +00:00
80ac118242 refactor: convert SelectedSlotsRepository to use dependency injection pattern (#22387)
* refactor: convert TeamRepository to use dependency injection pattern

- Add constructor injection for PrismaClient in TeamRepository
- Convert all static methods to instance methods using this.prismaClient
- Update all usage sites to use two-step pattern: const teamRepo = new TeamRepository(prisma); teamRepo.method(...)
- Optimize instance reuse within same function scopes where possible
- Update test files to work with new instance pattern
- Preserve all existing functionality and method signatures

Follows the same dependency injection pattern as UserRepository refactoring

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

* refactor: convert SelectedSlotsRepository to use dependency injection pattern

- Add constructor injection for PrismaClient/PrismaTransaction
- Convert all static methods to instance methods using this.prismaClient
- Update usage sites to use two-step instantiation pattern:
  const slotsRepo = new SelectedSlotsRepository(prisma); slotsRepo.method(...)
- Optimize instance reuse in util.ts for multiple method calls
- Update test mocks to handle new constructor-based pattern
- Add explicit type annotations to resolve TypeScript inference issues

Follows same dependency injection pattern as UserRepository and TeamRepository refactorings.

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

* chore: bump platform libs

* fix: devin mistake prisma client

* fix: devin typing mistakes

* chore: bump platform libs

---------

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: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-10 15:14:26 +00:00
efd15c5a3e refactor: convert TeamRepository to use dependency injection pattern (#22383)
* refactor: convert TeamRepository to use dependency injection pattern

- Add constructor injection for PrismaClient in TeamRepository
- Convert all static methods to instance methods using this.prismaClient
- Update all usage sites to use two-step pattern: const teamRepo = new TeamRepository(prisma); teamRepo.method(...)
- Optimize instance reuse within same function scopes where possible
- Update test files to work with new instance pattern
- Preserve all existing functionality and method signatures

Follows the same dependency injection pattern as UserRepository refactoring

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

* chore: bump platform libs

---------

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: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-10 14:03:50 +00:00
devin-ai-integration[bot]andBailey 7d02d1bb23 fix: improve layout in copy times popup (#22368)
- Remove justify-between from checkbox labels to fix uneven padding
- Use CheckboxField with descriptionAsLabel for consistent spacing
- Add flex justify-end to button container for right alignment
- Maintain existing keyboard navigation functionality

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Bailey <bailey@pumfleet.co.uk>
2025-07-10 13:21:38 +00:00
Dhairyashil ShindeandAnik Dhabal Babu 0d877e7be3 fix: Always return cancellationReason and cancelledByEmail for cancelled bookings (#22082)
* fix: always return cancellationReason and cancelledByEmail for cancelled bookings

* Implemented reviewed changes

* Updated ternary conditional logic for cancelled booking

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-07-10 18:45:46 +05:30
e4c47640fc refactor: convert UserRepository to use dependency injection pattern (#22360)
* refactor: convert UserRepository to use dependency injection pattern

- Convert all static methods to public instance methods
- Add constructor that takes PrismaClient parameter
- Update all usage sites to use new instantiation pattern: new UserRepository(prisma).method()
- Follow same pattern as PrismaOOORepository for consistency
- Maintain all existing method logic and signatures unchanged
- Update 125+ files across the codebase to adapt to new pattern

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

* optimize: reuse UserRepository instances within same function scope

- Create single UserRepository instance per function scope
- Reuse instance for multiple method calls within same function
- Reduces object instantiation overhead and improves performance
- Apply optimization pattern consistently across codebase

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

* fix: repository

* fixup! fix: repository

* fixup! fixup! fix: repository

* fixup! fixup! fixup! fix: repository

* fix: update test mocking strategies for UserRepository dependency injection

- Convert static method mocks to instance method mocks in userCreationService.test.ts
- Update vi.spyOn calls to work with constructor injection pattern in getAllCredentials.test.ts
- Fix UserRepository mocking in getRoutedUrl.test.ts to use constructor injection
- Ensure consistent mocking approach across all test files
- Fix 'UserRepository is not a constructor' errors in tests

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

* feat: optimize UserRepository instance reuse and add SessionUser type

- Reuse UserRepository instance in OrganizationRepository.createWithNonExistentOwner
- Add comprehensive SessionUser type definition for type safety
- Improve type constraints in enrichUserWithTheProfile and enrichUserWithItsProfile
- Ensure proper return types with profile information

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

* fix: make UserRepository mocking strategy more robust for CI environments

- Add defensive checks for vi.mocked() to handle CI environment differences
- Ensure mockImplementation is available before calling it
- Maintain consistent mocking pattern across all test files
- Fix 'Cannot read properties of undefined' error in CI

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

* fixup! fix: make UserRepository mocking strategy more robust for CI environments

* refactor: convert direct UserRepository instantiations to two-step pattern

- Change await new UserRepository(prisma).method(...) to const userRepo = new UserRepository(prisma); await userRepo.method(...)
- Optimize instance reuse within same function scopes
- Apply pattern consistently across all modified files in PR
- Fix type errors in organization.ts and sessionMiddleware.ts

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

* refactor: complete two-step UserRepository pattern for remaining files

- Apply two-step instantiation pattern to all remaining modified files in PR
- Ensure consistent UserRepository usage across entire codebase
- Maintain instance reuse optimization within function scopes

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

* chore: bump platform libs

---------

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: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-10 12:11:14 +00:00
Hariom Balhara 89e7fca183 fix: Add new route to create team routing-form response (#22347)
* fix: Add formId ownership with org guard

* Don't allow user routing form endpoint to create response for team
routing form

* rename to or guard
2025-07-10 11:56:45 +00:00
sean-brydon 33b5bf1db8 fix: Move pbac to prisma from kysley (#22318)
* fixes

* fix prisma mock to include transaction callbacj

* remove redudant transactions

* remove hard requirement on transactions in services

* remove mocktrx

* fix cubic feedback

* correct permission updating logic of roles. Implement diff service + domain types

* remove role mapper
2025-07-10 10:24:50 +00:00
Devanshu Sharma f69c4a900e refactor: enhance UI spacing and text styling in Locations component and Switch component (#22328) 2025-07-10 08:34:32 +00:00
Sarthak Kapila 1da25cc85f docs: booking attendee absent (#22094)
* refactor: booking attendee absent

* docs: swagger generate
2025-07-10 07:52:10 +00:00
Akash Kinkar Pandey 3a65b372c5 perf:Remove Redundant Keys in common.json for english (#22362) 2025-07-10 13:19:38 +05:30
sean-brydon 9892d521fb fix: use domain errors instead of trpc (#22341) 2025-07-10 08:23:50 +01:00
Alex van Andel 3285b252c2 chore: Add High-Load warning for user availability checks with many users (#22369) 2025-07-10 01:09:21 +00:00
126 changed files with 1456 additions and 1359 deletions
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
@@ -105,7 +105,9 @@ async function getHandler(req: NextApiRequest) {
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
const usersWithCalendars = await UserRepository.findManyByIdsIncludeDestinationAndSelectedCalendars({
const usersWithCalendars = await new UserRepository(
prisma
).findManyByIdsIncludeDestinationAndSelectedCalendars({
ids: userIds,
});
+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.249",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.256",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -109,8 +109,9 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
cancelledByEmail: databaseBooking.cancelledBy || undefined,
cancellationReason:
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
@@ -204,8 +205,9 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
cancelledByEmail: databaseBooking.cancelledBy || undefined,
cancellationReason:
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
@@ -266,7 +268,8 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
cancellationReason:
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -375,7 +378,8 @@ export class OutputBookingsService_2024_08_13 {
description: databaseBooking.description,
hosts: [this.getHost(databaseBooking.user)],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
cancellationReason:
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
+105 -9
View File
@@ -21402,7 +21402,8 @@
"OOO_CREATED",
"AFTER_HOSTS_CAL_VIDEO_NO_SHOW",
"AFTER_GUESTS_CAL_VIDEO_NO_SHOW",
"FORM_SUBMITTED_NO_EVENT"
"FORM_SUBMITTED_NO_EVENT",
"RESERVATION_EXPIRED"
]
},
"secret": {
@@ -21479,7 +21480,8 @@
"OOO_CREATED",
"AFTER_HOSTS_CAL_VIDEO_NO_SHOW",
"AFTER_GUESTS_CAL_VIDEO_NO_SHOW",
"FORM_SUBMITTED_NO_EVENT"
"FORM_SUBMITTED_NO_EVENT",
"RESERVATION_EXPIRED"
]
},
"secret": {
@@ -23975,7 +23977,7 @@
"type": "object",
"properties": {}
},
"Attendee": {
"CreateBookingAttendee": {
"type": "object",
"properties": {
"name": {
@@ -24112,7 +24114,7 @@
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/CreateBookingAttendee"
}
]
},
@@ -24239,7 +24241,7 @@
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/CreateBookingAttendee"
}
]
},
@@ -24372,7 +24374,7 @@
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/CreateBookingAttendee"
}
]
},
@@ -24541,6 +24543,85 @@
"slug"
]
},
"BookingAttendee": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "John Doe"
},
"email": {
"type": "string",
"example": "john@example.com"
},
"timeZone": {
"type": "string",
"example": "America/New_York"
},
"language": {
"type": "string",
"enum": [
"ar",
"ca",
"de",
"es",
"eu",
"he",
"id",
"ja",
"lv",
"pl",
"ro",
"sr",
"th",
"vi",
"az",
"cs",
"el",
"es-419",
"fi",
"hr",
"it",
"km",
"nl",
"pt",
"ru",
"sv",
"tr",
"zh-CN",
"bg",
"da",
"en",
"et",
"fr",
"hu",
"iw",
"ko",
"no",
"pt-BR",
"sk",
"ta",
"uk",
"zh-TW"
],
"example": "en"
},
"absent": {
"type": "boolean",
"example": false
},
"phoneNumber": {
"type": "string",
"example": "+1234567890"
}
},
"required": [
"name",
"email",
"timeZone",
"absent"
]
},
"BookingOutput_2024_08_13": {
"type": "object",
"properties": {
@@ -24657,7 +24738,7 @@
"attendees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/BookingAttendee"
}
},
"guests": {
@@ -24814,7 +24895,7 @@
"attendees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/BookingAttendee"
}
},
"guests": {
@@ -25925,6 +26006,21 @@
"data"
]
},
"MarkAbsentAttendee": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"absent": {
"type": "boolean"
}
},
"required": [
"email",
"absent"
]
},
"MarkAbsentBookingInput_2024_08_13": {
"type": "object",
"properties": {
@@ -25936,7 +26032,7 @@
"attendees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/MarkAbsentAttendee"
}
}
}
@@ -6,6 +6,7 @@ import { unstable_cache } from "next/cache";
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { TeamService } from "@calcom/lib/server/service/team";
import prisma from "@calcom/prisma";
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
import { TRPCError } from "@trpc/server";
@@ -14,7 +15,8 @@ import { TeamsCTA } from "./CTA";
const getCachedTeams = unstable_cache(
async (userId: number) => {
return await TeamRepository.findTeamsByUserId({
const teamRepo = new TeamRepository(prisma);
return await teamRepo.findTeamsByUserId({
userId,
includeOrgs: true,
});
@@ -4,6 +4,7 @@ import { cookies, headers } from "next/headers";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import type { AppCategories } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -25,7 +26,8 @@ const ServerPage = async () => {
const session = await getServerSession({ req });
let appStore, userAdminTeamsIds: number[];
if (session?.user?.id) {
const userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
const userRepo = new UserRepository(prisma);
const userAdminTeams = await userRepo.getUserAdminTeams({ userId: session.user.id });
userAdminTeamsIds = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeamsIds);
} else {
@@ -6,6 +6,7 @@ import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequir
import { UsersEditView } from "@calcom/features/ee/users/pages/users-edit-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
const userIdSchema = z.object({ id: z.coerce.number() });
@@ -21,7 +22,8 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
);
}
const user = await UserRepository.adminFindById(input.data.id);
const userRepo = new UserRepository(prisma);
const user = await userRepo.adminFindById(input.data.id);
return await _generateMetadata(
(t) => `${t("editing_user")}: ${user.username}`,
@@ -37,7 +39,8 @@ const Page = async ({ params }: { params: Params }) => {
if (!input.success) throw new Error("Invalid access");
const user = await UserRepository.adminFindById(input.data.id);
const userRepo = new UserRepository(prisma);
const user = await userRepo.adminFindById(input.data.id);
const t = await getTranslate();
return (
@@ -6,6 +6,7 @@ import { notFound } from "next/navigation";
import { z } from "zod";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { prisma } from "@calcom/prisma";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/video/meeting-not-started/[uid]/getServerSideProps";
@@ -22,7 +23,8 @@ export const generateMetadata = async ({ params }: ServerPageProps) => {
if (!parsed.success) {
notFound();
}
const booking = await BookingRepository.findBookingByUid({
const bookingRepo = new BookingRepository(prisma);
const booking = await bookingRepo.findBookingByUid({
bookingUid: parsed.data.uid,
});
@@ -11,6 +11,7 @@ import { HttpError } from "@calcom/lib/http-error";
import notEmpty from "@calcom/lib/notEmpty";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -29,7 +30,10 @@ async function authMiddleware() {
throw new HttpError({ statusCode: 401, message: "Not authenticated" });
}
const userWithCredentials = await UserRepository.findUserWithCredentials({ id: session.user.id });
const userRepo = new UserRepository(prisma);
const userWithCredentials = await userRepo.findUserWithCredentials({
id: session.user.id,
});
if (!userWithCredentials) {
throw new HttpError({ statusCode: 401, message: "Not authenticated" });
+4 -2
View File
@@ -8,6 +8,7 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { CardComponent } from "@lib/plain/card-components";
@@ -474,7 +475,8 @@ async function handler(request: NextRequest) {
// Validate request body
const { cardKeys, customer } = inputSchema.parse(requestBody);
const user = await UserRepository.findByEmail({ email: customer.email });
const userRepo = new UserRepository(prisma);
const user = await userRepo.findByEmail({ email: customer.email });
if (!user) {
return NextResponse.json({
@@ -502,7 +504,7 @@ async function handler(request: NextRequest) {
}
// Fetch team details including userId and team name
const teamMemberships = await UserRepository.findTeamsByUserId({ userId: user.id });
const teamMemberships = await userRepo.findTeamsByUserId({ userId: user.id });
const firstTeam = teamMemberships.teams[0] ?? null;
// Parse user metadata
@@ -18,7 +18,8 @@ import { STEPS } from "~/apps/installation/[[...step]]/constants";
import type { OnboardingPageProps, TEventTypeGroup } from "~/apps/installation/[[...step]]/step-view";
const getUser = async (userId: number) => {
const userAdminTeams = await UserRepository.getUserAdminTeams(userId);
const userRepo = new UserRepository(prisma);
const userAdminTeams = await userRepo.getUserAdminTeams({ userId });
if (!userAdminTeams?.id) {
return null;
@@ -101,7 +101,8 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
name = profileUsername || username;
const [user] = await UserRepository.findUsersByUsername({
const userRepo = new UserRepository(prisma);
const [user] = await userRepo.findUsersByUsername({
usernameList: [name],
orgSlug: org,
});
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const { req } = context;
@@ -12,7 +13,8 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return { redirect: { permanent: false, destination: "/auth/login" } };
}
const user = await UserRepository.findUserTeams({
const userRepo = new UserRepository(prisma);
const user = await userRepo.findUserTeams({
id: session.user.id,
});
@@ -101,8 +101,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const eventType = booking.eventType ? booking.eventType : getDefaultEvent(dynamicEventSlugRef);
const userRepo = new UserRepository(prisma);
const enrichedBookingUser = booking.user
? await UserRepository.enrichUserWithItsProfile({ user: booking.user })
? await userRepo.enrichUserWithItsProfile({ user: booking.user })
: null;
const eventUrl = await buildEventUrlFromBooking({
@@ -106,7 +106,8 @@ const checkIfUserIsHost = async ({
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { req } = context;
const booking = await BookingRepository.findBookingForMeetingPage({
const bookingRepo = new BookingRepository(prisma);
const booking = await bookingRepo.findBookingForMeetingPage({
bookingUid: context.query.uid as string,
});
@@ -140,9 +141,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
})
: false;
const userRepo = new UserRepository(prisma);
const profile = booking.user
? (
await UserRepository.enrichUserWithItsProfile({
await userRepo.enrichUserWithItsProfile({
user: booking.user,
})
).profile
@@ -1,12 +1,14 @@
import type { GetServerSidePropsContext } from "next";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { prisma } from "@calcom/prisma";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export async function getServerSideProps(context: GetServerSidePropsContext) {
const booking = await BookingRepository.findBookingForMeetingEndedPage({
const bookingRepo = new BookingRepository(prisma);
const booking = await bookingRepo.findBookingForMeetingEndedPage({
bookingUid: context.query.uid as string,
});
@@ -1,10 +1,12 @@
import type { GetServerSidePropsContext } from "next";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { prisma } from "@calcom/prisma";
// change the type
export async function getServerSideProps(context: GetServerSidePropsContext) {
const booking = await BookingRepository.findBookingByUid({
const bookingRepo = new BookingRepository(prisma);
const booking = await bookingRepo.findBookingByUid({
bookingUid: context.query.uid as string,
});
-4
View File
@@ -18,10 +18,6 @@ describe("Middleware - POST requests restriction", () => {
const req1 = createRequest("/api/auth/signup", "POST");
const res1 = checkPostMethod(req1);
expect(res1).toBeNull();
const req2 = createRequest("/api/trpc/book/event", "POST");
const res2 = checkPostMethod(req2);
expect(res2).toBeNull();
});
it("should block POST requests to not-allowed app routes", async () => {
+1 -6
View File
@@ -15,7 +15,7 @@ const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
}
};
export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/auth/signup", "/api/trpc/"];
export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/auth/signup"];
export function checkPostMethod(req: NextRequest) {
const pathname = req.nextUrl.pathname;
if (!POST_METHODS_ALLOWED_API_ROUTES.some((route) => pathname.startsWith(route)) && req.method === "POST") {
@@ -69,10 +69,6 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
return responseWithHeaders({ url, res: routingFormRewriteResponse, req });
}
if (url.pathname.startsWith("/api/trpc/")) {
requestHeaders.set("x-cal-timezone", req.headers.get("x-vercel-ip-timezone") ?? "");
}
if (url.pathname.startsWith("/api/auth/signup")) {
const isSignupDisabled = await safeGet<boolean>("isSignupDisabled");
// If is in maintenance mode, point the url pathname to the maintenance page
@@ -178,7 +174,6 @@ export const config = {
"/:path*/embed",
// API routes
"/api/auth/signup",
"/api/trpc/:path*",
],
};
@@ -77,7 +77,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
let rescheduledToUid: string | null = null;
if (bookingInfo.rescheduled) {
const rescheduledTo = await BookingRepository.findFirstBookingByReschedule({
const bookingRepo = new BookingRepository(prisma);
const rescheduledTo = await bookingRepo.findFirstBookingByReschedule({
originalBookingUid: bookingInfo.uid,
});
rescheduledToUid = rescheduledTo?.uid ?? null;
@@ -89,7 +90,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
} | null = null;
if (bookingInfo.fromReschedule) {
previousBooking = await BookingRepository.findReschedulerByUid({
const bookingRepo = new BookingRepository(prisma);
previousBooking = await bookingRepo.findReschedulerByUid({
uid: bookingInfo.fromReschedule,
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.4.17",
"version": "5.4.19",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -1,455 +0,0 @@
import { expect } from "@playwright/test";
import type { Page, Browser, Route, Response } from "@playwright/test";
import type { Team, EventType, User } from "@prisma/client";
import type { z } from "zod";
import { CalendarCacheRepository } from "@calcom/features/calendar-cache/calendar-cache.repository";
import { getTimeMin, getTimeMax } from "@calcom/features/calendar-cache/lib/datesForCache";
import { prisma } from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { test } from "./lib/fixtures";
import type { Fixtures } from "./lib/fixtures";
import { bookTimeSlot, doOnOrgDomain } from "./lib/testUtils";
/**
* Booking Race Condition Prevention Test
*
* This test validates that the calendar caching system and booking logic correctly
* prevent double-booking race conditions that occurred in production. It serves as
* a regression test to ensure the following mechanisms work properly:
*
* 1. **Race Condition Prevention**: Ensures concurrent booking requests for the same
* time slot don't result in double bookings to the same host.
*
* 2. **Calendar Cache Functionality**: Validates that the calendar cache system works
* correctly with proper cache key generation using expanded date ranges.
*
* 3. **Round-Robin Distribution**: Verifies that when both bookings succeed, they are
* distributed to different hosts as expected in round-robin scheduling.
*
* 4. **Database Constraint Protection**: Confirms that unique constraints prevent
* duplicate bookings when race conditions are detected.
*
* **Test Setup**:
* - Creates a round-robin team with multiple hosts
* - Populates calendar cache with deterministic availability data
* - Mocks Google Calendar API to simulate real-world scenarios
* - Executes concurrent booking requests at the same time slot
*
* **Expected Outcomes**:
* - [200, 409]: One booking succeeds, one fails due to constraint violation (preferred)
* - [200, 200]: Both succeed but distributed to different hosts (acceptable)
* - [200, 200] with same host: Double booking occurred (test failure - race condition)
*
* **Production Context**:
* The original race condition was caused by:
* - Stale calendar cache showing hosts as available
* - Microsecond timing differences in Date.valueOf() for idempotency keys
* - Both requests selecting the same host due to identical cache state
*
* This test recreates similar conditions but in a controlled way to verify
* the prevention mechanisms work correctly.
*/
test.describe("Booking Race Condition Prevention", () => {
test("Prevents double-booking race condition and validates cache functionality", async ({
page,
users,
orgs,
browser,
}) => {
const { org, team, teamEvent, teamMembers } = await setupTeamWithRoundRobin(users, orgs);
await setupGoogleCalendarCredentials(teamMembers);
await createIdenticalBookingHistories(teamMembers, teamEvent.id);
const { targetHost, calendarCacheHits } = await setupCalendarCache(teamMembers);
await enableCalendarCacheFeatures(team.id);
const { firstResponse, secondResponse } = await performConcurrentBookings(
page,
browser,
org,
team,
teamEvent
);
const bookingResults = await analyzeBookingResults(teamEvent.id, firstResponse, secondResponse);
// Validate race condition prevention
expect(bookingResults.isRaceConditionPrevented).toBe(true);
// Log results for monitoring
console.log("Race condition prevention test results:", {
totalBookings: bookingResults.bookings.length,
responseStatuses: bookingResults.responseStatuses,
sameHostSelected: bookingResults.sameHostSelected,
preventionMechanism: bookingResults.preventionMechanism,
});
});
});
// Helper Functions
async function setupTeamWithRoundRobin(users: Fixtures["users"], orgs: Fixtures["orgs"]) {
const org = await orgs.create({ name: "TestOrg" });
const teamMatesObj = [{ name: "teammate-1" }, { name: "teammate-2" }];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeamMembership();
const teamEvent = await owner.getFirstTeamEvent(team.id);
const teamMemberships = await prisma.membership.findMany({
where: { teamId: team.id },
include: { user: true },
});
const teamMembers = teamMemberships.map((membership) => membership.user);
return { org, team, teamEvent, teamMembers };
}
async function setupGoogleCalendarCredentials(teamMembers: User[]) {
// Ensure Google Calendar app exists
const googleCalendarApp = await prisma.app.findFirst({
where: { slug: "google-calendar" },
});
if (!googleCalendarApp) {
await prisma.app.create({
data: {
slug: "google-calendar",
dirName: "google-calendar",
},
});
}
// Create credentials for each team member
for (const member of teamMembers) {
await prisma.credential.create({
data: {
type: "google_calendar",
key: {
access_token: "test_access_token",
refresh_token: "test_refresh_token",
scope: "https://www.googleapis.com/auth/calendar.events",
token_type: "Bearer",
expiry_date: Date.now() + 3600000,
},
userId: member.id,
appId: "google-calendar",
invalid: false,
},
});
}
}
async function createIdenticalBookingHistories(teamMembers: User[], eventTypeId: number) {
// Create identical booking histories to ensure deterministic round-robin behavior
// This prevents random host selection from interfering with race condition testing
const identicalTimestamp = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const member of teamMembers) {
// Create multiple bookings with identical timestamps
for (let i = 0; i < 3; i++) {
await prisma.booking.create({
data: {
uid: `test-booking-${member.id}-${i}-${Date.now()}`,
title: `Test booking ${i}`,
startTime: new Date(Date.now() - (i + 2) * 24 * 60 * 60 * 1000),
endTime: new Date(Date.now() - (i + 2) * 24 * 60 * 60 * 1000 + 30 * 60 * 1000),
eventTypeId,
userId: member.id,
status: "ACCEPTED",
createdAt: identicalTimestamp,
updatedAt: identicalTimestamp,
attendees: {
create: {
email: `test${i}@example.com`,
name: `Test Attendee ${i}`,
timeZone: "UTC",
},
},
},
});
}
}
}
async function setupCalendarCache(teamMembers: User[]) {
// Set up calendar cache with stale data to test cache functionality
// This simulates the production scenario where cache shows availability
// but real calendar API might show different data
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
const cacheTimeRange = {
timeMin: getTimeMin(tomorrow.toISOString()),
timeMax: getTimeMax(tomorrow.toISOString()),
};
const credentials = await prisma.credential.findMany({
where: {
userId: { in: teamMembers.map((m) => m.id) },
type: "google_calendar",
},
});
const calendarCacheRepo = new CalendarCacheRepository(null);
const targetHost = teamMembers[0]; // First host should be selected by round-robin
const calendarCacheHits: string[] = [];
for (let i = 0; i < credentials.length; i++) {
const credential = credentials[i];
const member = teamMembers[i];
const cacheArgs = {
timeMin: cacheTimeRange.timeMin,
timeMax: cacheTimeRange.timeMax,
items: [{ id: member.email! }],
};
// Cache shows target host as available, others as busy
// This ensures deterministic host selection during concurrent requests
const availabilityData = {
kind: "calendar#freeBusy",
calendars: {
[member.email!]: {
busy:
member.id === targetHost.id
? []
: [
{
start: "2025-07-02T08:00:00.000Z",
end: "2025-07-02T08:30:00.000Z",
},
],
},
},
};
await calendarCacheRepo.upsertCachedAvailability({
credentialId: credential.id,
userId: member.id,
args: cacheArgs,
value: availabilityData,
});
calendarCacheHits.push(`${member.email}-${credential.id}`);
}
return { targetHost, calendarCacheHits };
}
async function enableCalendarCacheFeatures(teamId: number) {
await prisma.teamFeatures.createMany({
data: [
{
teamId,
featureId: "calendar-cache",
assignedAt: new Date(),
assignedBy: "race-condition-test",
},
{
teamId,
featureId: "calendar-cache-serve",
assignedAt: new Date(),
assignedBy: "race-condition-test",
},
],
});
}
async function mockGoogleCalendarAPI(page: Page) {
// Mock Google Calendar API to simulate real-world calendar data
// This creates a mismatch with cache data to test cache functionality
await page.route("**/calendar/v3/freeBusy**", async (route: Route) => {
const mockResponse = {
kind: "calendar#freeBusy",
calendars: {
"pro-user@example.com": {
busy: [
{
start: "2025-07-02T08:00:00.000Z",
end: "2025-07-02T09:00:00.000Z",
},
],
},
"teammate-1@example.com": {
busy: [
{
start: "2025-07-02T08:00:00.000Z",
end: "2025-07-02T09:00:00.000Z",
},
],
},
"teammate-2@example.com": {
busy: [
{
start: "2025-07-02T08:00:00.000Z",
end: "2025-07-02T09:00:00.000Z",
},
],
},
},
};
await route.fulfill({
status: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(mockResponse),
});
});
}
// Local type for org/team with parsed metadata
export type TeamWithMetadata = Team & { metadata: z.infer<typeof teamMetadataSchema> };
async function performConcurrentBookings(
page: Page,
browser: Browser,
org: TeamWithMetadata,
team: TeamWithMetadata,
teamEvent: EventType
) {
let firstResponse: Response | undefined;
let secondResponse: Response | undefined;
await doOnOrgDomain({ orgSlug: org.slug, page }, async () => {
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
// Apply API mocking
await mockGoogleCalendarAPI(page1);
await mockGoogleCalendarAPI(page2);
// Navigate to booking page
await page1.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await page2.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
// Select tomorrow's date
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
const targetDay = tomorrow.getDate().toString();
await page1
.locator(`[data-testid="day"][data-disabled="false"]`)
.filter({ hasText: new RegExp(`^${targetDay}$`) })
.click();
await page2
.locator(`[data-testid="day"][data-disabled="false"]`)
.filter({ hasText: new RegExp(`^${targetDay}$`) })
.click();
// Select first available time slot
await page1.locator('[data-testid="time"]').nth(0).waitFor();
await page2.locator('[data-testid="time"]').nth(0).waitFor();
await page1.locator('[data-testid="time"]').nth(0).click();
await page2.locator('[data-testid="time"]').nth(0).click();
// Set up response listeners
const responsePromise1 = page1.waitForResponse((response) => response.url().includes("/api/book/event"));
const responsePromise2 = page2.waitForResponse((response) => response.url().includes("/api/book/event"));
// Execute concurrent bookings
const bookingPromise1 = bookTimeSlot(page1, {
name: "Guest A",
email: "guest-a@test.com",
expectedStatusCode: undefined,
});
const bookingPromise2 = bookTimeSlot(page2, {
name: "Guest B",
email: "guest-b@test.com",
expectedStatusCode: undefined,
});
// Wait for both bookings to complete
await Promise.allSettled([bookingPromise1, bookingPromise2]);
const [firstResponseLocal, secondResponseLocal] = await Promise.all([responsePromise1, responsePromise2]);
firstResponse = firstResponseLocal;
secondResponse = secondResponseLocal;
// Verify success pages for successful bookings
if (firstResponse && firstResponse.status() === 200) {
await expect(page1.getByTestId("success-page")).toBeVisible();
}
if (secondResponse && secondResponse.status() === 200) {
await expect(page2.getByTestId("success-page")).toBeVisible();
}
await context1.close();
await context2.close();
});
return { firstResponse, secondResponse };
}
async function analyzeBookingResults(
eventTypeId: number,
firstResponse: Response | undefined,
secondResponse: Response | undefined
) {
if (!firstResponse || !secondResponse) {
throw new Error("Booking response(s) missing");
}
const bookings = await prisma.booking.findMany({
where: {
eventTypeId,
status: "ACCEPTED",
attendees: {
some: {
email: {
in: ["guest-a@test.com", "guest-b@test.com"],
},
},
},
},
include: {
attendees: true,
user: true,
},
});
const responseStatuses = [firstResponse.status(), secondResponse.status()].sort();
const bothSucceeded = responseStatuses.every((s) => s === 200);
const hasConflict = responseStatuses.includes(200) && responseStatuses.includes(409);
const sameHostSelected = bookings.length === 2 && bookings[0].userId === bookings[1].userId;
// Determine prevention mechanism
const isRaceConditionPrevented = hasConflict || (bothSucceeded && !sameHostSelected);
let preventionMechanism = "unknown";
if (hasConflict) {
preventionMechanism = "unique_constraint_violation";
} else if (bothSucceeded && !sameHostSelected) {
preventionMechanism = "round_robin_distribution";
} else if (bothSucceeded && sameHostSelected) {
preventionMechanism = "none_detected";
}
return {
bookings,
responseStatuses,
bothSucceeded,
hasConflict,
sameHostSelected,
isRaceConditionPrevented,
preventionMechanism,
};
}
@@ -60,7 +60,6 @@
"participants": "Participants",
"anonymous_user": "Anonymous User",
"session_duration": "Session Duration",
"minutes": "minutes",
"no_meeting_sessions_found": "No meeting sessions found",
"not_available": "Not available",
"disable_recording_for_organizer": "Disable recording for organizer",
@@ -135,7 +135,8 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
}
}
const usersInOrgContext = await UserRepository.findUsersByUsername({
const userRepo = new UserRepository(prisma);
const usersInOrgContext = await userRepo.findUsersByUsername({
usernameList: usernames,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
});
@@ -13,6 +13,7 @@ import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
import prisma from "@calcom/prisma";
import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { UserProfile } from "@calcom/types/UserProfile";
@@ -199,7 +200,9 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
};
export async function getUsersInOrgContext(usernameList: string[], orgSlug: string | null) {
const usersInOrgContext = await UserRepository.findUsersByUsername({
const userRepo = new UserRepository(prisma);
const usersInOrgContext = await userRepo.findUsersByUsername({
usernameList,
orgSlug,
});
@@ -212,7 +215,7 @@ export async function getUsersInOrgContext(usernameList: string[], orgSlug: stri
// the platform organization does not have a domain. In this case there is no org domain but also platform member
// "User.organization" is not null so "UserRepository.findUsersByUsername" returns empty array and we do this as a last resort
// call to find platform member.
return await UserRepository.findPlatformMembersByUsernames({
return await userRepo.findPlatformMembersByUsernames({
usernameList,
});
}
+97 -9
View File
@@ -19759,7 +19759,8 @@
"OOO_CREATED",
"AFTER_HOSTS_CAL_VIDEO_NO_SHOW",
"AFTER_GUESTS_CAL_VIDEO_NO_SHOW",
"FORM_SUBMITTED_NO_EVENT"
"FORM_SUBMITTED_NO_EVENT",
"RESERVATION_EXPIRED"
]
},
"secret": {
@@ -19826,7 +19827,8 @@
"OOO_CREATED",
"AFTER_HOSTS_CAL_VIDEO_NO_SHOW",
"AFTER_GUESTS_CAL_VIDEO_NO_SHOW",
"FORM_SUBMITTED_NO_EVENT"
"FORM_SUBMITTED_NO_EVENT",
"RESERVATION_EXPIRED"
]
},
"secret": {
@@ -21957,7 +21959,7 @@
"type": "object",
"properties": {}
},
"Attendee": {
"CreateBookingAttendee": {
"type": "object",
"properties": {
"name": {
@@ -22085,7 +22087,7 @@
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/CreateBookingAttendee"
}
]
},
@@ -22203,7 +22205,7 @@
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/CreateBookingAttendee"
}
]
},
@@ -22326,7 +22328,7 @@
"description": "The attendee's details.",
"allOf": [
{
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/CreateBookingAttendee"
}
]
},
@@ -22477,6 +22479,80 @@
},
"required": ["id", "slug"]
},
"BookingAttendee": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "John Doe"
},
"email": {
"type": "string",
"example": "john@example.com"
},
"timeZone": {
"type": "string",
"example": "America/New_York"
},
"language": {
"type": "string",
"enum": [
"ar",
"ca",
"de",
"es",
"eu",
"he",
"id",
"ja",
"lv",
"pl",
"ro",
"sr",
"th",
"vi",
"az",
"cs",
"el",
"es-419",
"fi",
"hr",
"it",
"km",
"nl",
"pt",
"ru",
"sv",
"tr",
"zh-CN",
"bg",
"da",
"en",
"et",
"fr",
"hu",
"iw",
"ko",
"no",
"pt-BR",
"sk",
"ta",
"uk",
"zh-TW"
],
"example": "en"
},
"absent": {
"type": "boolean",
"example": false
},
"phoneNumber": {
"type": "string",
"example": "+1234567890"
}
},
"required": ["name", "email", "timeZone", "absent"]
},
"BookingOutput_2024_08_13": {
"type": "object",
"properties": {
@@ -22588,7 +22664,7 @@
"attendees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/BookingAttendee"
}
},
"guests": {
@@ -22737,7 +22813,7 @@
"attendees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/BookingAttendee"
}
},
"guests": {
@@ -23758,6 +23834,18 @@
},
"required": ["status", "data"]
},
"MarkAbsentAttendee": {
"type": "object",
"properties": {
"email": {
"type": "string"
},
"absent": {
"type": "boolean"
}
},
"required": ["email", "absent"]
},
"MarkAbsentBookingInput_2024_08_13": {
"type": "object",
"properties": {
@@ -23769,7 +23857,7 @@
"attendees": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attendee"
"$ref": "#/components/schemas/MarkAbsentAttendee"
}
}
}
@@ -5,6 +5,7 @@ import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import prisma from "@calcom/prisma";
export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userId: number) {
try {
@@ -25,11 +26,13 @@ export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userI
avatar: await resizeBase64Image(avatarUrl),
userId,
});
await UserRepository.updateAvatar({ id: userId, avatarUrl: resizedAvatarUrl });
const userRepo = new UserRepository(prisma);
await userRepo.updateAvatar({ id: userId, avatarUrl: resizedAvatarUrl });
return;
}
await UserRepository.updateAvatar({ id: userId, avatarUrl });
const userRepo = new UserRepository(prisma);
await userRepo.updateAvatar({ id: userId, avatarUrl });
} catch (error) {
logger.error("Error updating avatarUrl from google calendar connect", error);
}
@@ -1,5 +1,6 @@
import { HttpError } from "@calcom/lib/http-error";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
export const throwIfNotHaveAdminAccessToTeam = async ({
teamId,
@@ -11,7 +12,8 @@ export const throwIfNotHaveAdminAccessToTeam = async ({
if (!teamId) {
return;
}
const userAdminTeams = await UserRepository.getUserAdminTeams(userId);
const userRepo = new UserRepository(prisma);
const userAdminTeams = await userRepo.getUserAdminTeams({ userId });
const teamsUserHasAdminAccessFor = userAdminTeams?.teams?.map(({ team }) => team.id) ?? [];
const hasAdminAccessToTeam = teamsUserHasAdminAccessFor.some((id) => id === teamId);
@@ -99,9 +99,10 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
const { UserRepository } = await import("@calcom/lib/server/repository/user");
const userRepo = new UserRepository(prisma);
const formWithUserInfoProfile = {
...form,
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
user: await userRepo.enrichUserWithItsProfile({ user: form.user }),
};
return {
@@ -67,9 +67,10 @@ export const getServerSideProps = async function getServerSideProps(
}
const { UserRepository } = await import("@calcom/lib/server/repository/user");
const userRepo = new UserRepository(prisma);
const formWithUserProfile = {
...form,
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
user: await userRepo.enrichUserWithItsProfile({ user: form.user }),
};
if (
@@ -83,9 +83,10 @@ async function getResponseWithFormFieldsHandler({ ctx, input }: GetResponseWithF
}
const { UserRepository } = await import("@calcom/lib/server/repository/user");
const userRepo = new UserRepository(prisma);
const formWithUserProfile = {
...form,
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
user: await userRepo.enrichUserWithItsProfile({ user: form.user }),
};
return {
@@ -80,7 +80,8 @@ export async function getServerSession(options: {
return null;
}
const user = await UserRepository.enrichUserWithTheProfile({
const userRepository = new UserRepository(prisma);
const user = await userRepository.enrichUserWithTheProfile({
user: userFromDb,
upId,
});
@@ -116,7 +116,8 @@ const providers: Provider[] = [
throw new Error(ErrorCode.InternalServerError);
}
const user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({
const userRepo = new UserRepository(prisma);
const user = await userRepo.findByEmailAndIncludeProfilesAndPassword({
email: credentials.email,
});
// Don't leak information about it being username or password that is invalid
@@ -291,7 +292,8 @@ if (isSAMLLoginEnabled) {
locale?: string;
}) => {
log.debug("BoxyHQ:profile", safeStringify({ profile }));
const user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({
const userRepo = new UserRepository(prisma);
const user = await userRepo.findByEmailAndIncludeProfilesAndPassword({
email: profile.email || "",
});
return {
@@ -355,9 +357,8 @@ if (isSAMLLoginEnabled) {
const { id, firstName, lastName } = userInfo;
const email = userInfo.email.toLowerCase();
let user = !email
? undefined
: await UserRepository.findByEmailAndIncludeProfilesAndPassword({ email });
const userRepo = new UserRepository(prisma);
let user = !email ? undefined : await userRepo.findByEmailAndIncludeProfilesAndPassword({ email });
if (!user) {
const hostedCal = Boolean(HOSTED_CAL_FEATURES);
if (hostedCal && email) {
@@ -373,7 +374,7 @@ if (isSAMLLoginEnabled) {
createUsersAndConnectToOrgProps,
org,
});
user = await UserRepository.findByEmailAndIncludeProfilesAndPassword({
user = await userRepo.findByEmailAndIncludeProfilesAndPassword({
email: email,
});
}
@@ -9,18 +9,30 @@ import { describe, test, expect, vi } from "vitest";
import { UserRepository } from "@calcom/lib/server/repository/user";
// vi.mock("@calcom/lib/server/repository/user", () => {
// return {
// enrichUserWithItsProfile
// }
// })
vi.mock("@calcom/lib/server/repository/user", () => {
return {
UserRepository: vi.fn().mockImplementation(() => ({
enrichUserWithItsProfile: vi.fn(),
})),
};
});
describe("getAllCredentialsIncludeServiceAccountKey", () => {
test("Get an individual's credentials", async () => {
vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({
const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({
profile: null,
});
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
enrichUserWithItsProfile: mockEnrichUserWithItsProfile,
} as any)
);
}
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
@@ -62,10 +74,20 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => {
describe("If CRM is enabled on the event type", () => {
describe("With _crm credentials", () => {
test("For users", async () => {
vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({
const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({
profile: null,
});
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
enrichUserWithItsProfile: mockEnrichUserWithItsProfile,
} as any)
);
}
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
@@ -136,10 +158,20 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => {
expect(credentials).toContainEqual(expect.objectContaining({ userId: 1, type: "salesforce_crm" }));
});
test("For teams", async () => {
vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({
const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({
profile: null,
});
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
enrichUserWithItsProfile: mockEnrichUserWithItsProfile,
} as any)
);
}
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
@@ -203,10 +235,20 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => {
expect(credentials).toContainEqual(expect.objectContaining({ teamId: 1, type: "salesforce_crm" }));
});
test("For child of managed event type", async () => {
vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({
const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({
profile: null,
});
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
enrichUserWithItsProfile: mockEnrichUserWithItsProfile,
} as any)
);
}
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
@@ -298,10 +340,20 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => {
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
const orgId = 3;
vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({
const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({
profile: { organizationId: orgId },
});
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
enrichUserWithItsProfile: mockEnrichUserWithItsProfile,
} as any)
);
}
const crmCredential = {
id: 1,
type: "salesforce_crm",
@@ -611,10 +663,20 @@ describe("getAllCredentialsIncludeServiceAccountKey", () => {
const getAllCredentialsIncludeServiceAccountKey = (await import("./getAllCredentials"))
.getAllCredentialsIncludeServiceAccountKey;
const orgId = 3;
vi.spyOn(UserRepository, "enrichUserWithItsProfile").mockReturnValue({
const mockEnrichUserWithItsProfile = vi.fn().mockReturnValue({
profile: { organizationId: orgId },
});
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
enrichUserWithItsProfile: mockEnrichUserWithItsProfile,
} as any)
);
}
const crmCredential = {
id: 1,
type: "salesforce_crm",
@@ -57,7 +57,7 @@ export const getAllCredentialsIncludeServiceAccountKey = async (
}
}
const { profile } = await UserRepository.enrichUserWithItsProfile({
const { profile } = await new UserRepository(prisma).enrichUserWithItsProfile({
user: user,
});
@@ -506,7 +506,8 @@ async function handler(
(!isConfirmedByDefault && !userReschedulingIsOwner) ||
eventType.schedulingType === SchedulingType.ROUND_ROBIN
) {
const existingBooking = await BookingRepository.getValidBookingFromEventTypeForAttendee({
const bookingRepo = new BookingRepository(prisma);
const existingBooking = await bookingRepo.getValidBookingFromEventTypeForAttendee({
eventTypeId,
bookerEmail,
bookerPhoneNumber,
@@ -116,7 +116,7 @@ export const findUsersByUsername = async ({
usernameList: string[];
}) => {
log.debug("findUsersByUsername", { usernameList, orgSlug });
const { where, profiles } = await UserRepository._getWhereClauseForFindingUsersByUsername({
const { where, profiles } = await new UserRepository(prisma)._getWhereClauseForFindingUsersByUsername({
orgSlug,
usernameList,
});
@@ -3,10 +3,12 @@ import type { Prisma } from "@prisma/client";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { prisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
export async function getOriginalRescheduledBooking(uid: string, seatsEventType?: boolean) {
const originalBooking = await BookingRepository.findOriginalRescheduledBooking(uid, seatsEventType);
const bookingRepo = new BookingRepository(prisma);
const originalBooking = await bookingRepo.findOriginalRescheduledBooking(uid, seatsEventType);
if (!originalBooking) {
throw new HttpError({ statusCode: 404, message: "Could not find original booking" });
@@ -10,6 +10,7 @@ import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { DestinationCalendarRepository } from "@calcom/lib/server/repository/destinationCalendar";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
const testUser = {
email: "test@test.com",
@@ -38,7 +39,7 @@ describe("deleteCredential", () => {
test("Delete video credential", async () => {
const handleDeleteCredential = (await import("./handleDeleteCredential")).default;
const user = await UserRepository.create({
const user = await new UserRepository(prisma).create({
...testUser,
});
@@ -74,7 +75,7 @@ describe("deleteCredential", () => {
test("Delete calendar credential", async () => {
const handleDeleteCredential = (await import("./handleDeleteCredential")).default;
const user = await UserRepository.create({
const user = await new UserRepository(prisma).create({
...testUser,
});
@@ -388,10 +388,13 @@ describe("CreditService", () => {
describe("getMonthlyCredits", () => {
it("should return 0 if subscription is not active", async () => {
vi.mocked(TeamRepository.findTeamWithMembers).mockResolvedValue({
id: 1,
members: [{ accepted: true }],
} as any);
const mockTeamRepo = {
findTeamWithMembers: vi.fn().mockResolvedValue({
id: 1,
members: [{ accepted: true }],
}),
};
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
const mockTeamBillingService = {
getSubscriptionStatus: vi.fn().mockResolvedValue("trialing"),
@@ -405,10 +408,13 @@ describe("CreditService", () => {
});
it("should calculate credits based on active members and price", async () => {
vi.mocked(TeamRepository.findTeamWithMembers).mockResolvedValue({
id: 1,
members: [{ accepted: true }, { accepted: true }, { accepted: true }],
} as any);
const mockTeamRepo = {
findTeamWithMembers: vi.fn().mockResolvedValue({
id: 1,
members: [{ accepted: true }, { accepted: true }, { accepted: true }],
}),
};
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
const mockTeamBillingService = {
getSubscriptionStatus: vi.fn().mockResolvedValue("active"),
@@ -532,7 +532,8 @@ export class CreditService {
}
async getMonthlyCredits(teamId: number) {
const team = await TeamRepository.findTeamWithMembers(teamId);
const teamRepo = new TeamRepository(prisma);
const team = await teamRepo.findTeamWithMembers(teamId);
if (!team) return 0;
@@ -79,7 +79,7 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe
if (user) {
if (eventData.active) {
if (UserRepository.isAMemberOfOrganization({ user, organizationId })) {
if (await new UserRepository(prisma).isAMemberOfOrganization({ user, organizationId })) {
await syncCustomAttributesToUser({
event,
userEmail,
@@ -105,7 +105,7 @@ export class OrganizationPaymentService {
const stripeCustomerId = customer.stripeCustomerId;
if (existingCustomer && parsedMetadata) {
await UserRepository.updateStripeCustomerId({
await new UserRepository(prisma).updateStripeCustomerId({
id: existingCustomer.id,
stripeCustomerId,
existingMetadata: parsedMetadata,
@@ -317,7 +317,7 @@ async function ensureStripeCustomerIdIsUpdated({
}) {
const parsedMetadata = userMetadata.parse(owner.metadata);
await UserRepository.updateStripeCustomerId({
await new UserRepository(prisma).updateStripeCustomerId({
id: owner.id,
stripeCustomerId: stripeCustomerId,
existingMetadata: parsedMetadata,
@@ -279,7 +279,7 @@ export const findUserToBeOrgOwner = async (email: string) => {
return null;
}
return await UserRepository.enrichUserWithItsProfile({
return await new UserRepository(prisma).enrichUserWithItsProfile({
user,
});
};
@@ -1,3 +1,5 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import {
getDate,
createBookingScenario,
@@ -242,7 +244,8 @@ describe("roundRobinManualReassignment test", () => {
userId: fixedHost.id,
});
const attendees = await BookingRepository.getBookingAttendees(123);
const bookingRepo = new BookingRepository(prismaMock);
const attendees = await bookingRepo.getBookingAttendees(123);
expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false);
expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true);
@@ -1,3 +1,5 @@
import prismaMock from "../../../../tests/libs/__mocks__/prisma";
import {
getDate,
createBookingScenario,
@@ -286,7 +288,8 @@ describe("roundRobinReassignment test", () => {
userId: 1,
});
const attendees = await BookingRepository.getBookingAttendees(123);
const bookingRepo = new BookingRepository(prismaMock);
const attendees = await bookingRepo.getBookingAttendees(123);
expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false);
@@ -344,7 +344,7 @@ const Locations: React.FC<LocationsProps> = ({
{isCalVideo && !isPlatform && (
<div className="bg-muted mt-2 space-y-2 rounded-lg p-4">
<div className="w-full">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-4">
<Controller
name="calVideoSettings.disableRecordingForGuests"
defaultValue={!!eventType.calVideoSettings?.disableRecordingForGuests}
@@ -352,7 +352,7 @@ const Locations: React.FC<LocationsProps> = ({
return (
<SettingsToggle
title={t("disable_recording_for_guests")}
labelClassName="text-sm"
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
/>
@@ -367,7 +367,7 @@ const Locations: React.FC<LocationsProps> = ({
return (
<SettingsToggle
title={t("disable_recording_for_organizer")}
labelClassName="text-sm"
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
/>
@@ -382,7 +382,7 @@ const Locations: React.FC<LocationsProps> = ({
return (
<SettingsToggle
title={t("enable_automatic_transcription")}
labelClassName="text-sm"
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
/>
@@ -398,7 +398,7 @@ const Locations: React.FC<LocationsProps> = ({
return (
<SettingsToggle
title={t("disable_transcription_for_guests")}
labelClassName="text-sm"
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
/>
@@ -414,7 +414,7 @@ const Locations: React.FC<LocationsProps> = ({
return (
<SettingsToggle
title={t("disable_transcription_for_organizer")}
labelClassName="text-sm"
labelClassName="text-sm leading-6 whitespace-normal break-words"
checked={value}
onCheckedChange={onChange}
/>
@@ -427,7 +427,8 @@ const Locations: React.FC<LocationsProps> = ({
label={t("enter_redirect_url_on_exit_description")}
defaultValue={eventType.calVideoSettings?.redirectUrlOnExit || ""}
data-testid="calVideoSettings.redirectUrlOnExit"
containerClassName="mt-2"
containerClassName="mt-4"
className="leading-6"
{...formMethods.register("calVideoSettings.redirectUrlOnExit", {
setValueAs: (v) => (!v || v.trim() === "" ? null : v),
})}
@@ -237,7 +237,7 @@ export const getPublicEvent = async (
const orgQuery = org ? getSlugOrRequestedSlug(org) : null;
// In case of dynamic group event, we fetch user's data and use the default event.
if (usernameList.length > 1) {
const usersInOrgContext = await UserRepository.findUsersByUsername({
const usersInOrgContext = await new UserRepository(prisma).findUsersByUsername({
usernameList,
orgSlug: org,
});
@@ -397,7 +397,7 @@ export const getPublicEvent = async (
const usersAsHosts = event.hosts.map((host) => host.user);
// Enrich users in a single batch call
const enrichedUsers = await UserRepository.enrichUsersWithTheirProfiles(usersAsHosts);
const enrichedUsers = await new UserRepository(prisma).enrichUsersWithTheirProfiles(usersAsHosts);
// Map enriched users back to the hosts
const hosts = event.hosts.map((host, index) => ({
@@ -408,7 +408,7 @@ export const getPublicEvent = async (
const eventWithUserProfiles = {
...event,
owner: event.owner
? await UserRepository.enrichUserWithItsProfile({
? await new UserRepository(prisma).enrichUserWithItsProfile({
user: event.owner,
})
: null,
@@ -640,7 +640,7 @@ async function getOwnerFromUsersArray(prisma: PrismaClient, eventTypeId: number)
if (!users.length) return null;
// Batch enrich users in a single call
const enrichedUsers = await UserRepository.enrichUsersWithTheirProfiles(users);
const enrichedUsers = await new UserRepository(prisma).enrichUsersWithTheirProfiles(users);
// Map the enriched users back to include the organization info
const usersWithUserProfile = enrichedUsers.map((user) => ({
+2 -1
View File
@@ -223,7 +223,8 @@ const getWebhooksService = async (bookingUid: string, platformClientId?: string)
const assertCanAccessBooking = async (bookingUid: string, userId?: number) => {
if (!userId) throw new HttpError({ statusCode: 401 });
const booking = await BookingRepository.findBookingByUidAndUserId({ bookingUid, userId });
const bookingRepo = new BookingRepository(prisma);
const booking = await bookingRepo.findBookingByUidAndUserId({ bookingUid, userId });
if (!booking)
throw new HttpError({ statusCode: 403, message: "You are not allowed to access this booking" });
@@ -0,0 +1,12 @@
export class RoleManagementError extends Error {
constructor(message: string, public readonly code: RoleManagementErrorCode) {
super(message);
this.name = "RoleManagementError";
}
}
export enum RoleManagementErrorCode {
UNAUTHORIZED = "UNAUTHORIZED",
ROLE_NOT_FOUND = "ROLE_NOT_FOUND",
INVALID_ROLE = "INVALID_ROLE",
}
@@ -1,60 +0,0 @@
import type { RoleType } from "@calcom/kysely/types";
import type { Role, RolePermission, RoleType as DomainRoleType } from "../models/Role";
type KyselyRole = {
id: string;
name: string;
color: string | null;
description: string | null;
teamId: number | null;
type: RoleType;
createdAt: Date;
updatedAt: Date;
permissions: Array<{
id: string;
resource: string;
action: string;
}>;
};
export class RoleMapper {
static toDomain(kyselyRole: KyselyRole): Role {
return {
id: kyselyRole.id,
name: kyselyRole.name,
description: kyselyRole.description || undefined,
color: kyselyRole.color || undefined,
teamId: kyselyRole.teamId || undefined,
type: kyselyRole.type as DomainRoleType,
permissions: kyselyRole.permissions.map((p) => ({
id: p.id,
resource: p.resource,
action: p.action,
})),
createdAt: kyselyRole.createdAt,
updatedAt: kyselyRole.updatedAt,
};
}
static toKysely(role: Role): Omit<KyselyRole, "permissions"> {
return {
id: role.id,
name: role.name,
description: role.description || null,
color: role.color || null,
teamId: role.teamId || null,
type: role.type as DomainRoleType,
createdAt: role.createdAt,
updatedAt: role.updatedAt,
};
}
static permissionToKysely(roleId: string, permission: RolePermission) {
return {
roleId,
resource: permission.resource,
action: permission.action,
};
}
}
@@ -9,8 +9,10 @@ export type RoleType = (typeof RoleType)[keyof typeof RoleType];
export interface RolePermission {
id: string;
roleId: string;
resource: string;
action: string;
createdAt: Date;
}
export interface Role {
@@ -43,3 +45,8 @@ export interface UpdateRolePermissionsData {
name?: string;
};
}
export type PermissionChange = {
resource: string;
action: string;
};
@@ -1,9 +1,4 @@
import type { Transaction } from "kysely";
import type { DB } from "@calcom/kysely";
import type { Role, CreateRoleData } from "../models/Role";
import type { PermissionString } from "../types/permission-registry";
import type { Role, CreateRoleData, RolePermission, PermissionChange } from "../models/Role";
export interface IRoleRepository {
findByName(name: string, teamId?: number): Promise<Role | null>;
@@ -14,12 +9,15 @@ export interface IRoleRepository {
delete(id: string): Promise<void>;
update(
roleId: string,
permissions: PermissionString[],
permissionChanges: {
toAdd: PermissionChange[];
toRemove: PermissionChange[];
},
updates?: {
color?: string;
name?: string;
description?: string;
}
): Promise<Role>;
transaction<T>(callback: (repository: IRoleRepository, trx: Transaction<DB>) => Promise<T>): Promise<T>;
getPermissions(roleId: string): Promise<RolePermission[]>;
}
@@ -0,0 +1,33 @@
import type { Role as PrismaRole, RolePermission as PrismaRolePermission } from "@calcom/prisma/client";
import type { Role, RolePermission } from "../../domain/models/Role";
export class RoleOutputMapper {
static toDomain(prismaRole: PrismaRole & { permissions: PrismaRolePermission[] }): Role {
return {
id: prismaRole.id,
name: prismaRole.name,
color: prismaRole.color || undefined,
description: prismaRole.description || undefined,
teamId: prismaRole.teamId || undefined,
type: prismaRole.type,
permissions: prismaRole.permissions.map(this.toDomainPermission),
createdAt: prismaRole.createdAt,
updatedAt: prismaRole.updatedAt,
};
}
static toDomainPermission(prismaPermission: PrismaRolePermission): RolePermission {
return {
id: prismaPermission.id,
roleId: prismaPermission.roleId,
resource: prismaPermission.resource,
action: prismaPermission.action,
createdAt: prismaPermission.createdAt,
};
}
static toDomainList(prismaRoles: (PrismaRole & { permissions: PrismaRolePermission[] })[]): Role[] {
return prismaRoles.map(this.toDomain);
}
}
@@ -1,4 +1,5 @@
import kysely from "@calcom/kysely";
import db from "@calcom/prisma";
import type { PrismaClient as PrismaClientWithExtensions } from "@calcom/prisma";
import { PermissionMapper } from "../../domain/mappers/PermissionMapper";
import type { TeamPermissions } from "../../domain/models/Permission";
@@ -7,114 +8,101 @@ import type { CrudAction, CustomAction } from "../../domain/types/permission-reg
import { Resource, type PermissionString } from "../../domain/types/permission-registry";
export class PermissionRepository implements IPermissionRepository {
private client: PrismaClientWithExtensions;
constructor(client: PrismaClientWithExtensions = db) {
this.client = client;
}
async getUserMemberships(userId: number): Promise<TeamPermissions[]> {
const memberships = await kysely
.selectFrom("Membership")
.innerJoin("Role", "Role.id", "Membership.customRoleId")
.leftJoin("RolePermission", "RolePermission.roleId", "Role.id")
.select(["Membership.teamId", "Role.id as roleId", "RolePermission.resource", "RolePermission.action"])
.where("Membership.userId", "=", userId)
.execute();
// Group permissions by teamId and roleId
const membershipsWithPermissions = memberships.reduce((acc, membership) => {
const key = `${membership.teamId}-${membership.roleId}`;
if (!acc[key]) {
acc[key] = {
teamId: membership.teamId,
role: {
id: membership.roleId,
permissions: [],
const memberships = await this.client.membership.findMany({
where: { userId },
include: {
customRole: {
include: {
permissions: true,
},
};
}
if (membership.resource && membership.action) {
acc[key].role.permissions.push({
resource: membership.resource,
action: membership.action,
});
}
return acc;
}, {} as Record<string, any>);
return PermissionMapper.toDomain(Object.values(membershipsWithPermissions));
},
},
});
// Map to expected structure for PermissionMapper
const mapped = memberships.map((membership) => ({
teamId: membership.teamId,
role: membership.customRole
? {
id: membership.customRole.id,
permissions: membership.customRole.permissions,
}
: null,
}));
return PermissionMapper.toDomain(mapped);
}
async getMembershipByMembershipId(membershipId: number) {
const result = await kysely
.selectFrom("Membership")
.leftJoin("Team", "Team.id", "Membership.teamId")
.select([
"Membership.id",
"Membership.teamId",
"Membership.userId",
"Membership.customRoleId",
"Team.parentId as team_parentId",
])
.where("Membership.id", "=", membershipId)
.executeTakeFirst();
if (!result) return null;
return {
...result,
team_parentId: result.team_parentId || undefined,
};
return this.client.membership.findUnique({
where: { id: membershipId },
select: {
id: true,
teamId: true,
userId: true,
customRoleId: true,
team: {
select: {
parentId: true,
},
},
},
});
}
async getMembershipByUserAndTeam(userId: number, teamId: number) {
const result = await kysely
.selectFrom("Membership")
.leftJoin("Team", "Team.id", "Membership.teamId")
.select([
"Membership.id",
"Membership.teamId",
"Membership.userId",
"Membership.customRoleId",
"Team.parentId as team_parentId",
])
.where("Membership.userId", "=", userId)
.where("Membership.teamId", "=", teamId)
.executeTakeFirst();
if (!result) return null;
return {
...result,
team_parentId: result.team_parentId || undefined,
};
return this.client.membership.findFirst({
where: {
userId,
teamId,
},
select: {
id: true,
teamId: true,
userId: true,
customRoleId: true,
team: {
select: {
parentId: true,
},
},
},
});
}
async getOrgMembership(userId: number, orgId: number) {
const result = await kysely
.selectFrom("Membership")
.select(["id", "teamId", "userId", "customRoleId"])
.where("userId", "=", userId)
.where("teamId", "=", orgId)
.executeTakeFirst();
return result || null;
return this.client.membership.findFirst({
where: {
userId,
teamId: orgId,
},
select: {
id: true,
teamId: true,
userId: true,
customRoleId: true,
},
});
}
async checkRolePermission(roleId: string, permission: PermissionString): Promise<boolean> {
const [resource, action] = permission.split(".");
const hasPermission = await kysely
.selectFrom("RolePermission")
.select("id")
.where("roleId", "=", roleId)
.where((eb) =>
eb.or([
// Global wildcard
eb.and([eb("resource", "=", "*"), eb("action", "=", "*")]),
// Resource wildcard
eb.and([eb("resource", "=", "*"), eb("action", "=", action)]),
// Action wildcard
eb.and([eb("resource", "=", resource), eb("action", "=", "*")]),
// Exact match
eb.and([eb("resource", "=", resource), eb("action", "=", action)]),
])
)
.executeTakeFirst();
const hasPermission = await this.client.rolePermission.findFirst({
where: {
roleId,
OR: [
{ resource: "*", action: "*" },
{ resource: "*", action },
{ resource, action: "*" },
{ resource, action },
],
},
});
return !!hasPermission;
}
@@ -123,42 +111,38 @@ export class PermissionRepository implements IPermissionRepository {
const [resource, action] = p.split(".");
return { resource, action };
});
const resourceActions = permissionPairs.map((p) => [p.resource, p.action]);
const resources = permissionPairs.map((p) => p.resource);
const actions = permissionPairs.map((p) => p.action);
const matchingPermissionsCount = await kysely
.selectFrom("RolePermission")
.select((eb) => eb.fn.countAll().as("count"))
.where("roleId", "=", roleId)
.where((eb) =>
eb.or([
// Global wildcard
eb.and([eb("resource", "=", "*"), eb("action", "=", "*")]),
// Resource wildcards
eb.and([
eb("resource", "=", "*"),
eb(
"action",
"in",
permissionPairs.map((p) => p.action)
),
]),
// Action wildcards
eb.and([
eb(
"resource",
"in",
permissionPairs.map((p) => p.resource)
),
eb("action", "=", "*"),
]),
// Exact matches
eb.or(
permissionPairs.map((p) => eb.and([eb("resource", "=", p.resource), eb("action", "=", p.action)]))
),
])
const matchingPermissions = await this.client.$queryRaw<[{ count: bigint }]>`
WITH permission_checks AS (
-- Universal permission (*,*)
SELECT 1 as match FROM "RolePermission"
WHERE "roleId" = ${roleId} AND "resource" = '*' AND "action" = '*'
UNION ALL
-- Wildcard resource with specific actions
SELECT 1 as match FROM "RolePermission"
WHERE "roleId" = ${roleId} AND "resource" = '*' AND "action" = ANY(${actions})
UNION ALL
-- Specific resources with wildcard action
SELECT 1 as match FROM "RolePermission"
WHERE "roleId" = ${roleId} AND "action" = '*' AND "resource" = ANY(${resources})
UNION ALL
-- Exact resource-action pairs
SELECT 1 as match FROM "RolePermission"
WHERE "roleId" = ${roleId} AND ("resource", "action") = ANY(${resourceActions})
)
.executeTakeFirstOrThrow();
SELECT COUNT(*) as count FROM permission_checks
`;
return Number(matchingPermissionsCount.count) >= permissions.length;
return Number(matchingPermissions[0].count) >= permissions.length;
}
async getResourcePermissions(
@@ -167,22 +151,26 @@ export class PermissionRepository implements IPermissionRepository {
resource: Resource
): Promise<(CrudAction | CustomAction)[]> {
// Get team-level permissions
const membership = await kysely
.selectFrom("Membership")
.select("customRoleId")
.where("userId", "=", userId)
.where("teamId", "=", teamId)
.executeTakeFirst();
const membership = await this.client.membership.findFirst({
where: {
userId,
teamId,
},
select: {
customRoleId: true,
},
});
if (!membership?.customRoleId) return [];
const teamPermissions = await kysely
.selectFrom("RolePermission")
.select(["action", "resource"])
.where("roleId", "=", membership.customRoleId)
.where((eb) => eb.or([eb("resource", "=", resource), eb("resource", "=", Resource.All)]))
.execute();
const teamPermissions = await this.client.rolePermission.findMany({
where: {
roleId: membership.customRoleId,
OR: [{ resource }, { resource: Resource.All }],
},
select: {
action: true,
resource: true,
},
});
return teamPermissions.map((p) => p.action as CrudAction | CustomAction);
}
}
@@ -1,208 +1,149 @@
import type { Transaction } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import { v4 as uuidv4 } from "uuid";
import type { DB } from "@calcom/kysely";
import kysely from "@calcom/kysely";
import type { PrismaClient as PrismaWithExtensions } from "@calcom/prisma";
import db from "@calcom/prisma";
import { RoleMapper } from "../../domain/mappers/RoleMapper";
import type { Role, RolePermission, PermissionChange, CreateRoleData } from "../../domain/models/Role";
import { RoleType } from "../../domain/models/Role";
import type { CreateRoleData } from "../../domain/models/Role";
import type { IRoleRepository } from "../../domain/repositories/IRoleRepository";
import type { PermissionString } from "../../domain/types/permission-registry";
import { RoleOutputMapper } from "../mappers/RoleOutputMapper";
type KyselyRole = {
id: string;
name: string;
color: string | null;
description: string | null;
teamId: number | null;
type: RoleType;
createdAt: Date;
updatedAt: Date;
permissions: Array<{
id: string;
resource: string;
action: string;
}>;
};
export class RoleRepository {
constructor(private readonly client: PrismaWithExtensions = db) {}
export class RoleRepository implements IRoleRepository {
private getRoleSelect() {
return [
"Role.id",
"Role.name",
"Role.description",
"Role.teamId",
"Role.type",
"Role.color",
"Role.createdAt",
"Role.updatedAt",
(eb: any) =>
jsonArrayFrom(
eb
.selectFrom("RolePermission")
.select(["id", "resource", "action"])
.whereRef("RolePermission.roleId", "=", "Role.id")
).as("permissions"),
] as const;
async findByName(name: string, teamId?: number): Promise<Role | null> {
const role = await this.client.role.findFirst({
where: { name, teamId: teamId ?? null },
include: { permissions: true },
});
return role ? RoleOutputMapper.toDomain(role) : null;
}
async findByName(name: string, teamId?: number) {
const role = await kysely
.selectFrom("Role")
.select(this.getRoleSelect())
.where("name", "=", name)
.where((eb) => (teamId ? eb("teamId", "=", teamId) : eb("teamId", "is", null)))
.executeTakeFirst();
return role ? RoleMapper.toDomain(role as KyselyRole) : null;
async findByTeamId(teamId: number): Promise<Role[]> {
const roles = await this.client.role.findMany({
where: { teamId },
include: { permissions: true },
});
return RoleOutputMapper.toDomainList(roles);
}
async findById(id: string) {
const role = await kysely
.selectFrom("Role")
.select(this.getRoleSelect())
.where("id", "=", id)
.executeTakeFirst();
return role ? RoleMapper.toDomain(role as KyselyRole) : null;
async roleBelongsToTeam(roleId: string, teamId: number): Promise<boolean> {
const role = await this.client.role.findUnique({
where: { id: roleId },
select: { teamId: true },
});
return role?.teamId === teamId;
}
async findByTeamId(teamId: number) {
const roles = await kysely
.selectFrom("Role")
.select(this.getRoleSelect())
.where((eb) => eb.or([eb("Role.teamId", "=", teamId), eb("Role.type", "=", RoleType.SYSTEM)]))
.execute();
return roles.map((role) => RoleMapper.toDomain(role as KyselyRole));
}
async create(data: CreateRoleData) {
const roleId = uuidv4();
await kysely.transaction().execute(async (trx) => {
// Create role
await trx
.insertInto("Role")
.values({
id: roleId,
async create(data: CreateRoleData): Promise<Role> {
const roleId = await this.client.$transaction(async (trx) => {
const role = await trx.role.create({
data: {
id: uuidv4(),
name: data.name,
description: data.description || null,
teamId: data.teamId || null,
type: data.type || RoleType.CUSTOM,
color: data.color || null,
createdAt: new Date(),
updatedAt: new Date(),
})
.execute();
color: data.color ?? null,
description: data.description ?? null,
teamId: data.teamId ?? null,
type: data.type ?? RoleType.CUSTOM,
},
});
// Create permissions only if there are any
if (data.permissions.length > 0) {
const permissionData = data.permissions.map((permission) => {
const [resource, action] = permission.split(".");
return {
id: uuidv4(),
roleId,
roleId: role.id,
resource,
action,
};
});
await trx.insertInto("RolePermission").values(permissionData).execute();
await trx.rolePermission.createMany({ data: permissionData });
}
return roleId;
return role.id;
});
// Fetch complete role with permissions
const completeRole = await this.findById(roleId);
// This should never happen
if (!completeRole) throw new Error("Failed to create role");
return completeRole;
}
async delete(id: string) {
await kysely.transaction().execute(async (trx) => {
await trx.deleteFrom("RolePermission").where("roleId", "=", id).execute();
await trx.deleteFrom("Role").where("id", "=", id).execute();
});
async delete(id: string): Promise<void> {
await this.client.$transaction([
this.client.rolePermission.deleteMany({ where: { roleId: id } }),
this.client.role.delete({ where: { id } }),
]);
}
async update(
roleId: string,
permissions: PermissionString[],
permissionChanges: {
toAdd: PermissionChange[];
toRemove: PermissionChange[];
},
updates?: {
color?: string;
name?: string;
description?: string;
}
) {
await kysely.transaction().execute(async (trx) => {
await this.client.$transaction(async (trx) => {
// Update role metadata if provided
if (updates) {
const updateData: Partial<Pick<KyselyRole, "name" | "color" | "description">> = {};
if (updates.color !== undefined) {
updateData.color = updates.color || null;
}
if (updates.name !== undefined) {
updateData.name = updates.name;
}
if (updates.description !== undefined) {
updateData.description = updates.description || null;
}
await trx.updateTable("Role").set(updateData).where("id", "=", roleId).execute();
const updateData: Record<string, any> = {};
if (updates.color !== undefined) updateData.color = updates.color || null;
if (updates.name !== undefined) updateData.name = updates.name;
if (updates.description !== undefined) updateData.description = updates.description || null;
await trx.role.update({ where: { id: roleId }, data: updateData });
}
// Delete existing permissions
await trx.deleteFrom("RolePermission").where("roleId", "=", roleId).execute();
// Remove permissions that are no longer needed
if (permissionChanges.toRemove.length > 0) {
await trx.rolePermission.deleteMany({
where: {
roleId,
AND: permissionChanges.toRemove.map((p) => ({
resource: p.resource,
action: p.action,
})),
},
});
}
// Create new permissions
const permissionData = permissions.map((permission) => {
const [resource, action] = permission.split(".");
return {
// Add new permissions
if (permissionChanges.toAdd.length > 0) {
const permissionData = permissionChanges.toAdd.map((permission) => ({
id: uuidv4(),
roleId,
resource,
action,
};
});
resource: permission.resource,
action: permission.action,
}));
await trx.insertInto("RolePermission").values(permissionData).execute();
return roleId;
await trx.rolePermission.createMany({ data: permissionData });
}
});
// Fetch updated role
const updatedRole = await kysely
.selectFrom("Role")
.select(this.getRoleSelect())
.where("id", "=", roleId)
.executeTakeFirst();
if (!updatedRole) throw new Error("Failed to update role permissions");
return RoleMapper.toDomain(updatedRole as KyselyRole);
// Fetch complete role with permissions
const completeRole = await this.findById(roleId);
if (!completeRole) throw new Error("Failed to update role");
return completeRole;
}
async transaction<T>(
callback: (repository: IRoleRepository, trx: Transaction<DB>) => Promise<T>
): Promise<T> {
return kysely.transaction().execute(async (trx) => {
// Create a new repository instance with the transaction connection
const transactionRepo = new RoleRepository();
// Pass both the repository and the transaction connection
return callback(transactionRepo, trx);
async findById(roleId: string): Promise<Role | null> {
const role = await this.client.role.findUnique({
where: { id: roleId },
include: {
permissions: true,
},
});
return role ? RoleOutputMapper.toDomain(role) : null;
}
async roleBelongsToTeam(roleId: string, teamId: number) {
const role = await this.findById(roleId);
return role?.teamId === teamId;
async getPermissions(roleId: string): Promise<RolePermission[]> {
const permissions = await this.client.rolePermission.findMany({
where: { roleId },
});
return permissions.map(RoleOutputMapper.toDomainPermission);
}
}
@@ -0,0 +1,78 @@
import type { RolePermission } from "@prisma/client";
import { describe, it, expect } from "vitest";
import { PermissionDiffService } from "../permission-diff.service";
describe("PermissionDiffService", () => {
const service = new PermissionDiffService();
describe("calculateDiff", () => {
it("should calculate permissions to add and remove", () => {
const existingPermissions = [
{ id: "1", roleId: "role1", resource: "booking", action: "create" },
{ id: "2", roleId: "role1", resource: "booking", action: "read" },
] as RolePermission[];
const newPermissions = ["booking.read", "booking.update", "event.create"];
const result = service.calculateDiff(newPermissions, existingPermissions);
expect(result.toAdd).toEqual([
{ resource: "booking", action: "update" },
{ resource: "event", action: "create" },
]);
expect(result.toRemove).toEqual([{ id: "1", roleId: "role1", resource: "booking", action: "create" }]);
});
it("should handle empty new permissions", () => {
const existingPermissions = [
{ id: "1", roleId: "role1", resource: "booking", action: "create" },
] as RolePermission[];
const result = service.calculateDiff([], existingPermissions);
expect(result.toAdd).toEqual([]);
expect(result.toRemove).toEqual(existingPermissions);
});
it("should handle empty existing permissions", () => {
const newPermissions = ["booking.create", "booking.read"];
const result = service.calculateDiff(newPermissions, []);
expect(result.toAdd).toEqual([
{ resource: "booking", action: "create" },
{ resource: "booking", action: "read" },
]);
expect(result.toRemove).toEqual([]);
});
it("should filter out internal _resource permissions", () => {
const existingPermissions = [
{ id: "1", roleId: "role1", resource: "booking", action: "create" },
] as RolePermission[];
const newPermissions = ["booking.create", "booking._resource", "event.create"];
const result = service.calculateDiff(newPermissions, existingPermissions);
expect(result.toAdd).toEqual([{ resource: "event", action: "create" }]);
expect(result.toRemove).toEqual([]);
});
it("should handle identical permissions with no changes needed", () => {
const existingPermissions = [
{ id: "1", roleId: "role1", resource: "booking", action: "create" },
{ id: "2", roleId: "role1", resource: "booking", action: "read" },
] as RolePermission[];
const newPermissions = ["booking.create", "booking.read"];
const result = service.calculateDiff(newPermissions, existingPermissions);
expect(result.toAdd).toEqual([]);
expect(result.toRemove).toEqual([]);
});
});
});
@@ -5,8 +5,7 @@ import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { RoleManagementError, RoleManagementErrorCode } from "../../domain/errors/role-management.error";
import { DEFAULT_ROLE_IDS } from "../../lib/constants";
import { PermissionCheckService } from "../permission-check.service";
import { RoleManagementFactory } from "../role-management.factory";
@@ -117,7 +116,12 @@ describe("RoleManagementFactory", () => {
it("should throw UNAUTHORIZED when user lacks permission", async () => {
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
const manager = await factory.createRoleManager(organizationId);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow(TRPCError);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow(
new RoleManagementError(
"You do not have permission to change roles",
RoleManagementErrorCode.UNAUTHORIZED
)
);
});
});
@@ -144,14 +148,16 @@ describe("RoleManagementFactory", () => {
expect(mockRoleService.assignRoleToMember).toHaveBeenCalledWith(customRoleId, membershipId);
});
it("should throw UNAUTHORIZED for invalid custom role", async () => {
it("should throw INVALID_ROLE for invalid custom role", async () => {
const customRoleId = "invalid-role";
mockRoleService.roleBelongsToTeam.mockResolvedValue(false);
const manager = await factory.createRoleManager(organizationId);
await expect(
manager.assignRole(userId, organizationId, customRoleId as MembershipRole, membershipId)
).rejects.toThrow(TRPCError);
).rejects.toThrow(
new RoleManagementError("You do not have access to this role", RoleManagementErrorCode.INVALID_ROLE)
);
});
});
});
@@ -159,13 +165,33 @@ describe("RoleManagementFactory", () => {
describe("LegacyRoleManager", () => {
beforeEach(() => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(false);
vi.mocked(prisma.membership.update).mockResolvedValue({});
vi.mocked(prisma.membership.update).mockResolvedValue({
id: 1,
teamId: organizationId,
createdAt: new Date(),
updatedAt: new Date(),
role: MembershipRole.MEMBER,
userId: userId,
disableImpersonation: false,
accepted: true,
customRoleId: null,
});
vi.mocked(isOrganisationAdmin).mockResolvedValue(false);
});
describe("checkPermissionToChangeRole", () => {
it("should allow role change when user is owner", async () => {
vi.mocked(isOrganisationAdmin).mockResolvedValue(true);
vi.mocked(isOrganisationAdmin).mockResolvedValue({
id: 1,
teamId: organizationId,
userId: userId,
role: MembershipRole.OWNER,
accepted: true,
disableImpersonation: false,
createdAt: new Date(),
updatedAt: new Date(),
customRoleId: null,
});
const manager = await factory.createRoleManager(organizationId);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).resolves.not.toThrow();
});
@@ -173,7 +199,12 @@ describe("RoleManagementFactory", () => {
it("should throw UNAUTHORIZED when user is not owner", async () => {
vi.mocked(isOrganisationAdmin).mockResolvedValue(false);
const manager = await factory.createRoleManager(organizationId);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow(TRPCError);
await expect(manager.checkPermissionToChangeRole(userId, organizationId)).rejects.toThrow(
new RoleManagementError(
"Only owners or admin can update roles",
RoleManagementErrorCode.UNAUTHORIZED
)
);
});
});
@@ -5,9 +5,19 @@ import { RoleType } from "@calcom/prisma/enums";
import type { Role } from "../../domain/models/Role";
import type { IRoleRepository } from "../../domain/repositories/IRoleRepository";
import type { PermissionString } from "../../domain/types/permission-registry";
import type { PermissionDiffService } from "../permission-diff.service";
import { RoleService } from "../role.service";
vi.mock("../../infrastructure/repositories/RoleRepository");
vi.mock("../permission-diff.service");
// Mock db.$transaction
vi.mock("@calcom/prisma", () => ({
default: {
$transaction: vi.fn((cb) => cb({ membership: { update: vi.fn() } })),
membership: { update: vi.fn() },
},
}));
type MockRepository = {
[K in keyof IRoleRepository]: Mock;
@@ -16,6 +26,7 @@ type MockRepository = {
describe("RoleService", () => {
let service: RoleService;
let mockRepository: MockRepository;
let mockPermissionDiffService: { calculateDiff: Mock };
beforeEach(() => {
vi.clearAllMocks();
@@ -26,10 +37,19 @@ describe("RoleService", () => {
create: vi.fn(),
delete: vi.fn(),
update: vi.fn(),
transaction: vi.fn(),
roleBelongsToTeam: vi.fn(),
getPermissions: vi.fn(),
};
service = new RoleService(mockRepository);
mockPermissionDiffService = {
calculateDiff: vi.fn(),
};
service = new RoleService(
mockRepository,
undefined, // permissionService is not mocked as it's not relevant for these tests
mockPermissionDiffService as unknown as PermissionDiffService
);
});
describe("createRole", () => {
@@ -153,9 +173,18 @@ describe("RoleService", () => {
});
describe("update", () => {
it("should update role permissions", async () => {
it("should update role permissions using permission diff", async () => {
const roleId = "role-id";
const permissions = ["eventType.create", "eventType.read"] as PermissionString[];
const existingPermissions = [{ id: "1", roleId, resource: "eventType", action: "delete" }];
const permissionChanges = {
toAdd: [
{ resource: "eventType", action: "create" },
{ resource: "eventType", action: "read" },
],
toRemove: [{ id: "1", roleId, resource: "eventType", action: "delete" }],
};
const role: Role = {
id: roleId,
@@ -172,14 +201,18 @@ describe("RoleService", () => {
};
mockRepository.findById.mockResolvedValueOnce(role);
mockRepository.getPermissions.mockResolvedValueOnce(existingPermissions);
mockPermissionDiffService.calculateDiff.mockReturnValueOnce(permissionChanges);
mockRepository.update.mockResolvedValueOnce(role);
const result = await service.update({ roleId, permissions });
expect(result).toBeDefined();
expect(mockRepository.update).toHaveBeenCalledWith(roleId, permissions, {
expect(mockPermissionDiffService.calculateDiff).toHaveBeenCalledWith(permissions, existingPermissions);
expect(mockRepository.update).toHaveBeenCalledWith(roleId, permissionChanges, {
color: undefined,
name: undefined,
});
expect(result).toBeDefined();
});
it("should throw error if role does not exist", async () => {
@@ -277,45 +310,18 @@ describe("RoleService", () => {
updatedAt: new Date(),
};
const mockTrx = {
selectFrom: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
executeTakeFirst: vi.fn().mockResolvedValue({ id: roleId }),
updateTable: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
execute: vi.fn().mockResolvedValue(undefined),
};
mockRepository.transaction.mockImplementationOnce((callback) => callback(mockRepository, mockTrx));
mockRepository.findById.mockResolvedValueOnce(role);
const result = await service.assignRoleToMember(roleId, membershipId);
expect(result).toEqual({ id: roleId });
expect(mockTrx.selectFrom).toHaveBeenCalledWith("Role");
expect(mockTrx.select).toHaveBeenCalledWith("id");
expect(mockTrx.where).toHaveBeenCalledWith("id", "=", roleId);
expect(mockTrx.executeTakeFirst).toHaveBeenCalled();
expect(mockTrx.updateTable).toHaveBeenCalledWith("Membership");
expect(mockTrx.set).toHaveBeenCalledWith({ customRoleId: roleId });
expect(mockTrx.where).toHaveBeenCalledWith("id", "=", membershipId);
expect(mockTrx.execute).toHaveBeenCalled();
expect(result).toEqual(role); // Check the full role object
});
it("should throw error if role does not exist", async () => {
const mockTrx = {
selectFrom: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
executeTakeFirst: vi.fn().mockResolvedValue(null),
};
mockRepository.transaction.mockImplementationOnce((callback) => callback(mockRepository, mockTrx));
mockRepository.findById.mockResolvedValueOnce(null);
await expect(service.assignRoleToMember(roleId, membershipId)).rejects.toThrow("Role not found");
expect(mockTrx.selectFrom).toHaveBeenCalledWith("Role");
expect(mockTrx.select).toHaveBeenCalledWith("id");
expect(mockTrx.where).toHaveBeenCalledWith("id", "=", roleId);
expect(mockTrx.executeTakeFirst).toHaveBeenCalled();
expect(mockRepository.findById).toHaveBeenCalledWith(roleId);
// Do NOT expect transaction methods to be called, since the repository returns null
});
});
});
@@ -0,0 +1,48 @@
import type { RolePermission } from "../domain/models/Role";
export type PermissionString = string;
export type ParsedPermission = { resource: string; action: string };
export class PermissionDiffService {
private parsePermission(permission: PermissionString): ParsedPermission {
const [resource, action] = permission.split(".");
return { resource, action };
}
private filterInternalPermissions(permissions: PermissionString[]): PermissionString[] {
return permissions.filter((permission) => {
const { action } = this.parsePermission(permission);
return action !== "_resource";
});
}
private createPermissionKey(permission: ParsedPermission): string {
return `${permission.resource}:${permission.action}`;
}
calculateDiff(newPermissions: PermissionString[], existingPermissions: RolePermission[]) {
const filteredPermissions = this.filterInternalPermissions(newPermissions);
// Create sets for comparison
const existingSet = new Set(existingPermissions.map((p) => this.createPermissionKey(p)));
const newSet = new Set(
filteredPermissions.map((p) => {
const parsed = this.parsePermission(p);
return this.createPermissionKey(parsed);
})
);
// Calculate permissions to add and remove
const toAdd = filteredPermissions
.map((p) => this.parsePermission(p))
.filter((p) => !existingSet.has(this.createPermissionKey(p)));
const toRemove = existingPermissions.filter((p) => !newSet.has(this.createPermissionKey(p)));
return {
toAdd,
toRemove,
};
}
}
@@ -3,8 +3,7 @@ import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import { RoleManagementError, RoleManagementErrorCode } from "../domain/errors/role-management.error";
import { DEFAULT_ROLE_IDS } from "../lib/constants";
import { PermissionCheckService } from "./permission-check.service";
import { RoleService } from "./role.service";
@@ -38,7 +37,10 @@ class PBACRoleManager implements IRoleManager {
});
if (!hasPermission) {
throw new TRPCError({ code: "UNAUTHORIZED" });
throw new RoleManagementError(
"You do not have permission to change roles",
RoleManagementErrorCode.UNAUTHORIZED
);
}
}
@@ -55,7 +57,10 @@ class PBACRoleManager implements IRoleManager {
} else {
const roleExists = await this.roleService.roleBelongsToTeam(role, organizationId);
if (!roleExists) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "You do not have access to this role" });
throw new RoleManagementError(
"You do not have access to this role",
RoleManagementErrorCode.INVALID_ROLE
);
}
await this.roleService.assignRoleToMember(role, membershipId);
}
@@ -73,11 +78,14 @@ class PBACRoleManager implements IRoleManager {
class LegacyRoleManager implements IRoleManager {
public isPBACEnabled = false;
async checkPermissionToChangeRole(userId: number, organizationId: number): Promise<void> {
const isUpdaterAnOwner = await isOrganisationAdmin(userId, organizationId);
const membership = await isOrganisationAdmin(userId, organizationId);
// Only OWNER can update role to OWNER
if (!isUpdaterAnOwner) {
throw new TRPCError({ code: "UNAUTHORIZED" });
// Only OWNER/ADMIN can update role
if (!membership) {
throw new RoleManagementError(
"Only owners or admin can update roles",
RoleManagementErrorCode.UNAUTHORIZED
);
}
}
+18 -27
View File
@@ -1,4 +1,4 @@
import kysely from "@calcom/kysely";
import db from "@calcom/prisma";
import type { MembershipRole } from "@calcom/prisma/enums";
import { RoleType as DomainRoleType } from "../domain/models/Role";
@@ -6,14 +6,14 @@ import type { CreateRoleData, UpdateRolePermissionsData } from "../domain/models
import type { IRoleRepository } from "../domain/repositories/IRoleRepository";
import { RoleRepository } from "../infrastructure/repositories/RoleRepository";
import { DEFAULT_ROLE_IDS } from "../lib/constants";
import { PermissionDiffService } from "./permission-diff.service";
import { PermissionService } from "./permission.service";
// These IDs must match the ones in the migration
export class RoleService {
constructor(
private readonly repository: IRoleRepository = new RoleRepository(),
private readonly permissionService: PermissionService = new PermissionService()
private readonly permissionService: PermissionService = new PermissionService(),
private readonly permissionDiffService: PermissionDiffService = new PermissionDiffService()
) {}
async createRole(data: CreateRoleData) {
@@ -37,19 +37,13 @@ export class RoleService {
}
async assignRoleToMember(roleId: string, membershipId: number) {
return this.repository.transaction(async (repo, trx) => {
const role = await trx.selectFrom("Role").select("id").where("id", "=", roleId).executeTakeFirst();
if (!role) throw new Error("Role not found");
// TODO: Move this to a MembershipRepository - bit difficult due to the trx record here.
await trx
.updateTable("Membership")
.set({ customRoleId: roleId })
.where("id", "=", membershipId)
.execute();
return role;
const role = await this.repository.findById(roleId);
if (!role) throw new Error("Role not found");
await db.membership.update({
where: { id: membershipId },
data: { customRoleId: roleId },
});
return role;
}
async getRolePermissions(roleId: string) {
@@ -58,12 +52,10 @@ export class RoleService {
}
async removeRoleFromMember(membershipId: number) {
// TODO: Move this to a MembershipRepository
await kysely
.updateTable("Membership")
.set({ customRoleId: null })
.where("id", "=", membershipId)
.execute();
await db.membership.update({
where: { id: membershipId },
data: { customRoleId: null },
});
}
async getRole(roleId: string) {
@@ -79,12 +71,10 @@ export class RoleService {
if (!role) {
throw new Error("Role not found");
}
// Don't allow deleting default roles
if (role.type === DomainRoleType.SYSTEM) {
throw new Error("Cannot delete default roles");
}
await this.repository.delete(roleId);
}
@@ -93,19 +83,20 @@ export class RoleService {
if (!role) {
throw new Error("Role not found");
}
// Don't allow updating default roles
if (role.type === DomainRoleType.SYSTEM) {
throw new Error("Cannot update default roles");
}
// Validate permissions
const validationResult = this.permissionService.validatePermissions(data.permissions);
if (!validationResult.isValid) {
throw new Error(validationResult.error || "Invalid permissions provided");
}
return this.repository.update(data.roleId, data.permissions, {
const existingPermissions = await this.repository.getPermissions(data.roleId);
const permissionChanges = this.permissionDiffService.calculateDiff(data.permissions, existingPermissions);
return this.repository.update(data.roleId, permissionChanges, {
color: data.updates?.color,
name: data.updates?.name,
});
@@ -556,60 +556,55 @@ const CopyTimes = ({
<p className="h6 text-emphasis pb-3 pl-1 text-xs font-medium uppercase">{t("copy_times_to")}</p>
<ol className="space-y-2">
<li key="select all">
<label className="text-default flex w-full items-center justify-between">
<span className="px-1">{t("select_all")}</span>
<CheckboxField
description=""
value={t("select_all")}
checked={selected.length === 7}
onChange={(e) => {
if (e.target.checked) {
setSelected([0, 1, 2, 3, 4, 5, 6]);
} else if (!e.target.checked) {
setSelected([]);
}
}}
ref={(ref) => {
if (ref) {
itteratablesByKeyRef.current.push(ref as HTMLInputElement);
}
}}
/>
</label>
<CheckboxField
description={t("select_all")}
descriptionAsLabel
value={t("select_all")}
checked={selected.length === 7}
onChange={(e) => {
if (e.target.checked) {
setSelected([0, 1, 2, 3, 4, 5, 6]);
} else if (!e.target.checked) {
setSelected([]);
}
}}
ref={(ref) => {
if (ref) {
itteratablesByKeyRef.current.push(ref as HTMLInputElement);
}
}}
/>
</li>
{weekdayNames(i18n.language, weekStart).map((weekday, num) => {
const weekdayIndex = (num + weekStart) % 7;
return (
<li key={weekday}>
<label className="text-default flex w-full items-center justify-between">
<span className="px-1">{weekday}</span>
<CheckboxField
description=""
value={weekdayIndex}
checked={selected.includes(weekdayIndex) || disabled === weekdayIndex}
disabled={disabled === weekdayIndex}
onChange={(e) => {
if (e.target.checked && !selected.includes(weekdayIndex)) {
setSelected(selected.concat([weekdayIndex]));
} else if (!e.target.checked && selected.includes(weekdayIndex)) {
setSelected(selected.filter((item) => item !== weekdayIndex));
}
}}
ref={(ref) => {
if (ref && disabled !== weekdayIndex) {
//we don't need to iterate over disabled elements
itteratablesByKeyRef.current.push(ref as HTMLInputElement);
}
}}
/>
</label>
<CheckboxField
description={weekday}
descriptionAsLabel
value={weekdayIndex}
checked={selected.includes(weekdayIndex) || disabled === weekdayIndex}
disabled={disabled === weekdayIndex}
onChange={(e) => {
if (e.target.checked && !selected.includes(weekdayIndex)) {
setSelected(selected.concat([weekdayIndex]));
} else if (!e.target.checked && selected.includes(weekdayIndex)) {
setSelected(selected.filter((item) => item !== weekdayIndex));
}
}}
ref={(ref) => {
if (ref && disabled !== weekdayIndex) {
itteratablesByKeyRef.current.push(ref as HTMLInputElement);
}
}}
/>
</li>
);
})}
</ol>
</div>
<hr className="border-subtle" />
<div className="space-x-2 px-2 rtl:space-x-reverse">
<div className="flex justify-end space-x-2 px-2 rtl:space-x-reverse">
<Button
color="minimal"
onClick={() => onCancel()}
@@ -21,7 +21,8 @@ export const createAProfileForAnExistingUser = async ({
};
organizationId: number;
}) => {
const org = await TeamRepository.findById({ id: organizationId });
const teamRepo = new TeamRepository(prisma);
const org = await teamRepo.findById({ id: organizationId });
if (!org) {
throw new Error(`Organization with id ${organizationId} not found`);
}
@@ -47,7 +48,8 @@ export const createAProfileForAnExistingUser = async ({
movedFromUserId: user.id,
});
await UserRepository.updateWhereId({
const userRepo = new UserRepository(prisma);
await userRepo.updateWhereId({
whereId: user.id,
data: {
movedToProfileId: profile.id,
+2 -1
View File
@@ -8,6 +8,7 @@ import { safeStringify } from "@calcom/lib/safeStringify";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import type { ServiceAccountKey } from "@calcom/lib/server/repository/delegationCredential";
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
import prisma from "@calcom/prisma";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
import { UserRepository } from "../server/repository/user";
@@ -593,7 +594,7 @@ export async function findUniqueDelegationCalendarCredential({
}) {
const [delegationCredential, user] = await Promise.all([
DelegationCredentialRepository.findByIdIncludeSensitiveServiceAccountKey({ id: delegationCredentialId }),
UserRepository.findById({ id: userId }),
new UserRepository(prisma).findById({ id: userId }),
]);
if (!delegationCredential) {
+4 -3
View File
@@ -66,11 +66,12 @@ export const getEventTypeById = async ({
const newMetadata = eventTypeMetaDataSchemaWithTypedApps.parse(metadata || {}) || {};
const apps = newMetadata?.apps || {};
const eventTypeWithParsedMetadata = { ...rawEventType, metadata: newMetadata };
const userRepo = new UserRepository(prisma);
const eventTeamMembershipsWithUserProfile = [];
for (const eventTeamMembership of rawEventType.team?.members || []) {
eventTeamMembershipsWithUserProfile.push({
...eventTeamMembership,
user: await UserRepository.enrichUserWithItsProfile({
user: await userRepo.enrichUserWithItsProfile({
user: eventTeamMembership.user,
}),
});
@@ -81,7 +82,7 @@ export const getEventTypeById = async ({
childrenWithUserProfile.push({
...child,
owner: child.owner
? await UserRepository.enrichUserWithItsProfile({
? await userRepo.enrichUserWithItsProfile({
user: child.owner,
})
: null,
@@ -91,7 +92,7 @@ export const getEventTypeById = async ({
const eventTypeUsersWithUserProfile = [];
for (const eventTypeUser of rawEventType.users) {
eventTypeUsersWithUserProfile.push(
await UserRepository.enrichUserWithItsProfile({
await userRepo.enrichUserWithItsProfile({
user: eventTypeUser,
})
);
@@ -13,6 +13,7 @@ import { EventTypeRepository } from "@calcom/lib/server/repository/eventType";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { eventTypeMetaDataSchemaWithUntypedApps } from "@calcom/prisma/zod-utils";
@@ -105,32 +106,35 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo
type UserEventTypes = (typeof profileEventTypes)[number];
const mapEventType = async (eventType: UserEventTypes) => ({
...eventType,
safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined,
users: await Promise.all(
(!!eventType?.hosts?.length ? eventType?.hosts.map((host) => host.user) : eventType.users).map(
async (u) =>
await UserRepository.enrichUserWithItsProfile({
user: u,
})
)
),
metadata: eventType.metadata ? eventTypeMetaDataSchemaWithUntypedApps.parse(eventType.metadata) : null,
children: await Promise.all(
(eventType.children || []).map(async (c) => ({
...c,
users: await Promise.all(
c.users.map(
async (u) =>
await UserRepository.enrichUserWithItsProfile({
user: u,
})
)
),
}))
),
});
const mapEventType = async (eventType: UserEventTypes) => {
const userRepo = new UserRepository(prisma);
return {
...eventType,
safeDescription: eventType?.description ? markdownToSafeHTML(eventType.description) : undefined,
users: await Promise.all(
(!!eventType?.hosts?.length ? eventType?.hosts.map((host) => host.user) : eventType.users).map(
async (u) =>
await userRepo.enrichUserWithItsProfile({
user: u,
})
)
),
metadata: eventType.metadata ? eventTypeMetaDataSchemaWithUntypedApps.parse(eventType.metadata) : null,
children: await Promise.all(
(eventType.children || []).map(async (c) => ({
...c,
users: await Promise.all(
c.users.map(
async (u) =>
await userRepo.enrichUserWithItsProfile({
user: u,
})
)
),
}))
),
};
};
const userEventTypes = (await Promise.all(profileEventTypes.map(mapEventType))).filter((eventType) => {
const isAChildEvent = eventType.parentId;
+3 -2
View File
@@ -18,7 +18,7 @@ import type { EventBusyDetails } from "@calcom/types/Calendar";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
import { getDefinedBufferTimes } from "../features/eventtypes/lib/getDefinedBufferTimes";
import { BookingRepository as BookingRepo } from "./server/repository/booking";
import { BookingRepository } from "./server/repository/booking";
const _getBusyTimes = async (params: {
credentials: CredentialForCalendarService[];
@@ -108,7 +108,8 @@ const _getBusyTimes = async (params: {
let bookings = params.currentBookings;
if (!bookings) {
bookings = await BookingRepo.findAllExistingBookingsForEventTypeBetween({
const bookingRepo = new BookingRepository(prisma);
bookings = await bookingRepo.findAllExistingBookingsForEventTypeBetween({
userIdAndEmailMap: new Map([[userId, userEmail]]),
eventTypeId,
startDate: startTimeAdjustedWithMaxBuffer,
+6
View File
@@ -699,6 +699,12 @@ type GetUsersAvailabilityProps = {
};
const _getUsersAvailability = async ({ users, query, initialData }: GetUsersAvailabilityProps) => {
if (users.length >= 50) {
const userIds = users.map(({ id }) => id).join(", ");
log.warn(
`High-load warning: Attempting to fetch availability for ${users.length} users. User IDs: [${userIds}], EventTypeId: [${query.eventTypeId}]`
);
}
return await Promise.all(
users.map((user) =>
_getUserAvailability(
@@ -77,7 +77,8 @@ async function _checkBookingLimit({
let bookingsInPeriod;
if (teamId && user) {
bookingsInPeriod = await BookingRepository.getAllAcceptedTeamBookingsOfUser({
const bookingRepo = new BookingRepository(prisma);
bookingsInPeriod = await bookingRepo.getAllAcceptedTeamBookingsOfUser({
user: { id: user.id, email: user.email },
teamId,
startDate: startDate,
@@ -7,6 +7,7 @@ import { withReporting } from "@calcom/lib/sentryWrapper";
import { performance } from "@calcom/lib/server/perfObserver";
import { getTotalBookingDuration } from "@calcom/lib/server/queries/booking";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import prisma from "@calcom/prisma";
import type { EventBusyDetails } from "@calcom/types/Calendar";
import { descendingLimitKeys, intervalLimitKeyToUnit } from "../intervalLimit";
@@ -233,7 +234,8 @@ const _getBusyTimesFromTeamLimits = async (
bookingLimits
);
const bookings = await BookingRepository.getAllAcceptedTeamBookingsOfUser({
const bookingRepo = new BookingRepository(prisma);
const bookings = await bookingRepo.getAllAcceptedTeamBookingsOfUser({
user,
teamId,
startDate: limitDateFrom.toDate(),
+2 -1
View File
@@ -490,7 +490,8 @@ async function getBookingsOfInterval({
rrTimestampBasis: RRTimestampBasis;
meetingStartTime?: Date;
}) {
return await BookingRepository.getAllBookingsForRoundRobin({
const bookingRepo = new BookingRepository(prisma);
return await bookingRepo.getAllBookingsForRoundRobin({
eventTypeId: eventTypeId,
users,
startDate: getIntervalStartDate({ interval, rrTimestampBasis, meetingStartTime }),
+18 -2
View File
@@ -23,7 +23,13 @@ import { getRoutedUrl } from "./getRoutedUrl";
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
vi.mock("@calcom/app-store/routing-forms/lib/handleResponse");
vi.mock("@calcom/lib/server/repository/routingForm");
vi.mock("@calcom/lib/server/repository/user");
vi.mock("@calcom/lib/server/repository/user", () => {
return {
UserRepository: vi.fn().mockImplementation(() => ({
enrichUserWithItsProfile: vi.fn(),
})),
};
});
vi.mock("@calcom/features/ee/organizations/lib/orgDomains");
vi.mock("@calcom/features/routing-forms/lib/isAuthorizedToViewForm");
vi.mock("@calcom/app-store/routing-forms/lib/getSerializableForm");
@@ -79,7 +85,17 @@ describe("getRoutedUrl", () => {
// Provide default mock implementations
vi.mocked(orgDomainConfig).mockReturnValue({ currentOrgDomain: null });
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null);
vi.mocked(UserRepository.enrichUserWithItsProfile).mockImplementation(async ({ user }) => user);
const mockEnrichUserWithItsProfile = vi.fn().mockImplementation(async ({ user }) => user);
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
enrichUserWithItsProfile: mockEnrichUserWithItsProfile,
} as any)
);
}
vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(true);
vi.mocked(getSerializableForm).mockResolvedValue(mockSerializableForm as never);
vi.mocked(findMatchingRoute).mockReturnValue(null);
+3 -1
View File
@@ -22,6 +22,7 @@ import logger from "@calcom/lib/logger";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
@@ -102,9 +103,10 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
}
const profileEnrichmentStart = performance.now();
const userRepo = new UserRepository(prisma);
const formWithUserProfile = {
...form,
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
user: await userRepo.enrichUserWithItsProfile({ user: form.user }),
};
timeTaken.profileEnrichment = performance.now() - profileEnrichmentStart;
+3 -2
View File
@@ -183,10 +183,11 @@ export async function getTeamWithMembers(args: {
if (!teamOrOrg) return null;
const teamOrOrgMemberships = [];
const userRepo = new UserRepository(prisma);
for (const membership of teamOrOrg.members) {
teamOrOrgMemberships.push({
...membership,
user: await UserRepository.enrichUserWithItsProfile({
user: await userRepo.enrichUserWithItsProfile({
user: membership.user,
}),
});
@@ -236,7 +237,7 @@ export async function getTeamWithMembers(args: {
const usersWithUserProfile = [];
for (const { user } of eventType.hosts) {
usersWithUserProfile.push(
await UserRepository.enrichUserWithItsProfile({
await userRepo.enrichUserWithItsProfile({
user,
})
);
@@ -6,8 +6,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { TeamRepository } from "./team";
describe("TeamRepository", () => {
let teamRepository: TeamRepository;
beforeEach(() => {
vi.resetAllMocks();
teamRepository = new TeamRepository(prismaMock);
});
afterEach(() => {
@@ -17,7 +20,7 @@ describe("TeamRepository", () => {
describe("findById", () => {
it("should return null if team is not found", async () => {
prismaMock.team.findUnique.mockResolvedValue(null);
const result = await TeamRepository.findById({ id: 1 });
const result = await teamRepository.findById({ id: 1 });
expect(result).toBeNull();
});
@@ -37,7 +40,7 @@ describe("TeamRepository", () => {
requestedSlug: null,
};
prismaMock.team.findUnique.mockResolvedValue(mockTeam as unknown as Team);
const result = await TeamRepository.findById({ id: 1 });
const result = await teamRepository.findById({ id: 1 });
expect(result).toEqual(mockTeam);
});
});
@@ -67,7 +70,7 @@ describe("TeamRepository", () => {
return callback(mockTx);
});
const result = await TeamRepository.deleteById({ id: 1 });
const result = await teamRepository.deleteById({ id: 1 });
expect(deleteManyEventTypeMock).toHaveBeenCalledWith({
where: {
@@ -93,7 +96,7 @@ describe("TeamRepository", () => {
it("should return all teams with given parentId", async () => {
const mockTeams = [{ id: 1 }, { id: 2 }];
prismaMock.team.findMany.mockResolvedValue(mockTeams as unknown as Team[]);
const result = await TeamRepository.findAllByParentId({ parentId: 1 });
const result = await teamRepository.findAllByParentId({ parentId: 1 });
expect(prismaMock.team.findMany).toHaveBeenCalledWith({
where: { parentId: 1 },
select: {
@@ -116,7 +119,7 @@ describe("TeamRepository", () => {
it("should return team with its members", async () => {
const mockTeam = { id: 1, members: [] };
prismaMock.team.findUnique.mockResolvedValue(mockTeam as unknown as Team & { members: [] });
const result = await TeamRepository.findTeamWithMembers(1);
const result = await teamRepository.findTeamWithMembers(1);
expect(prismaMock.team.findUnique).toHaveBeenCalledWith({
where: { id: 1 },
select: {
+14 -7
View File
@@ -104,7 +104,8 @@ describe("BookingRepository", () => {
it("it should not include bookings where the attendee is a no show", async () => {
await createAttendeeNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
@@ -118,7 +119,8 @@ describe("BookingRepository", () => {
it("it should include bookings where the attendee is a no show", async () => {
await createAttendeeNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
@@ -132,7 +134,8 @@ describe("BookingRepository", () => {
it("it should not include bookings where the host is a no show", async () => {
await createHostNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
@@ -146,7 +149,8 @@ describe("BookingRepository", () => {
it("it should include bookings where the host is a no show", async () => {
await createHostNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
@@ -161,7 +165,8 @@ describe("BookingRepository", () => {
await createHostNoShowTestBookings();
await createAttendeeNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
@@ -176,7 +181,8 @@ describe("BookingRepository", () => {
await createHostNoShowTestBookings();
await createAttendeeNoShowTestBookings();
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date(),
@@ -232,7 +238,8 @@ describe("BookingRepository", () => {
}),
]);
const bookings = await BookingRepository.getAllBookingsForRoundRobin({
const bookingRepo = new BookingRepository(prismaMock);
const bookings = await bookingRepo.getAllBookingsForRoundRobin({
users: [{ id: 1, email: "organizer1@example.com" }],
eventTypeId: 1,
startDate: new Date("2025-06-01T00:00:00.000Z"),
+54 -54
View File
@@ -2,7 +2,8 @@ import type { Prisma } from "@prisma/client";
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
import { withReporting } from "@calcom/lib/sentryWrapper";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import { bookingMinimalSelect } from "@calcom/prisma";
import type { Booking } from "@calcom/prisma/client";
import { RRTimestampBasis } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -113,8 +114,10 @@ const buildWhereClauseForActiveBookings = ({
});
export class BookingRepository {
static async getBookingAttendees(bookingId: number) {
return await prisma.attendee.findMany({
constructor(private prismaClient: PrismaClient) {}
async getBookingAttendees(bookingId: number) {
return await this.prismaClient.attendee.findMany({
where: {
bookingId,
},
@@ -122,8 +125,8 @@ export class BookingRepository {
}
/** Determines if the user is the organizer, team admin, or org admin that the booking was created under */
static async doesUserIdHaveAccessToBooking({ userId, bookingId }: { userId: number; bookingId: number }) {
const booking = await prisma.booking.findUnique({
async doesUserIdHaveAccessToBooking({ userId, bookingId }: { userId: number; bookingId: number }) {
const booking = await this.prismaClient.booking.findUnique({
where: {
id: bookingId,
},
@@ -145,7 +148,8 @@ export class BookingRepository {
if (!booking.eventType || !booking.eventType.teamId) return false;
// TODO add checks for team and org
const isAdminOrUser = await UserRepository.isAdminOfTeamOrParentOrg({
const userRepo = new UserRepository(this.prismaClient);
const isAdminOrUser = await userRepo.isAdminOfTeamOrParentOrg({
userId,
teamId: booking.eventType.teamId,
});
@@ -153,8 +157,8 @@ export class BookingRepository {
return isAdminOrUser;
}
static async findFirstBookingByReschedule({ originalBookingUid }: { originalBookingUid: string }) {
return await prisma.booking.findFirst({
async findFirstBookingByReschedule({ originalBookingUid }: { originalBookingUid: string }) {
return await this.prismaClient.booking.findFirst({
where: {
fromReschedule: originalBookingUid,
},
@@ -164,8 +168,8 @@ export class BookingRepository {
});
}
static async findReschedulerByUid({ uid }: { uid: string }) {
return await prisma.booking.findUnique({
async findReschedulerByUid({ uid }: { uid: string }) {
return await this.prismaClient.booking.findUnique({
where: {
uid,
},
@@ -176,7 +180,7 @@ export class BookingRepository {
});
}
private static async _findAllExistingBookingsForEventTypeBetween({
private async _findAllExistingBookingsForEventTypeBetween({
eventTypeId,
seatedEvent = false,
startDate,
@@ -227,7 +231,7 @@ export class BookingRepository {
}),
} satisfies Prisma.BookingSelect;
const currentBookingsAllUsersQueryOne = prisma.booking.findMany({
const currentBookingsAllUsersQueryOne = this.prismaClient.booking.findMany({
where: {
...sharedQuery,
userId: {
@@ -237,7 +241,7 @@ export class BookingRepository {
select: bookingsSelect,
});
const currentBookingsAllUsersQueryTwo = prisma.booking.findMany({
const currentBookingsAllUsersQueryTwo = this.prismaClient.booking.findMany({
where: {
...sharedQuery,
attendees: {
@@ -252,7 +256,7 @@ export class BookingRepository {
});
const currentBookingsAllUsersQueryThree = eventTypeId
? prisma.booking.findMany({
? this.prismaClient.booking.findMany({
where: {
startTime: { lte: endDate },
endTime: { gte: startDate },
@@ -288,12 +292,12 @@ export class BookingRepository {
return [...resultOne, ...resultTwoWithOrganizersRemoved, ...resultThree];
}
static findAllExistingBookingsForEventTypeBetween = withReporting(
BookingRepository._findAllExistingBookingsForEventTypeBetween,
findAllExistingBookingsForEventTypeBetween = withReporting(
this._findAllExistingBookingsForEventTypeBetween.bind(this),
"findAllExistingBookingsForEventTypeBetween"
);
static async getAllBookingsForRoundRobin({
async getAllBookingsForRoundRobin({
users,
eventTypeId,
startDate,
@@ -316,7 +320,7 @@ export class BookingRepository {
includeNoShowInRRCalculation: boolean;
rrTimestampBasis: RRTimestampBasis;
}) {
const allBookings = await prisma.booking.findMany({
const allBookings = await this.prismaClient.booking.findMany({
where: buildWhereClauseForActiveBookings({
eventTypeId,
startDate,
@@ -366,8 +370,8 @@ export class BookingRepository {
return queueBookings;
}
static async findBookingByUid({ bookingUid }: { bookingUid: string }) {
return await prisma.booking.findUnique({
async findBookingByUid({ bookingUid }: { bookingUid: string }) {
return await this.prismaClient.booking.findUnique({
where: {
uid: bookingUid,
},
@@ -375,8 +379,8 @@ export class BookingRepository {
});
}
static async findBookingForMeetingPage({ bookingUid }: { bookingUid: string }) {
return await prisma.booking.findUnique({
async findBookingForMeetingPage({ bookingUid }: { bookingUid: string }) {
return await this.prismaClient.booking.findUnique({
where: {
uid: bookingUid,
},
@@ -430,8 +434,8 @@ export class BookingRepository {
});
}
static async findBookingForMeetingEndedPage({ bookingUid }: { bookingUid: string }) {
return await prisma.booking.findUnique({
async findBookingForMeetingEndedPage({ bookingUid }: { bookingUid: string }) {
return await this.prismaClient.booking.findUnique({
where: {
uid: bookingUid,
},
@@ -454,8 +458,8 @@ export class BookingRepository {
});
}
static async findBookingByUidAndUserId({ bookingUid, userId }: { bookingUid: string; userId: number }) {
return await prisma.booking.findFirst({
async findBookingByUidAndUserId({ bookingUid, userId }: { bookingUid: string; userId: number }) {
return await this.prismaClient.booking.findFirst({
where: {
uid: bookingUid,
OR: [
@@ -515,7 +519,7 @@ export class BookingRepository {
});
}
static async updateLocationById({
async updateLocationById({
where: { id },
data: { location, metadata, referencesToCreate },
}: {
@@ -526,7 +530,7 @@ export class BookingRepository {
referencesToCreate: Prisma.BookingReferenceCreateInput[];
};
}) {
await prisma.booking.update({
await this.prismaClient.booking.update({
where: {
id,
},
@@ -540,13 +544,11 @@ export class BookingRepository {
});
}
static async getAllAcceptedTeamBookingsOfUser(params: TeamBookingsParamsWithCount): Promise<number>;
async getAllAcceptedTeamBookingsOfUser(params: TeamBookingsParamsWithCount): Promise<number>;
static async getAllAcceptedTeamBookingsOfUser(
params: TeamBookingsParamsWithoutCount
): Promise<Array<Booking>>;
async getAllAcceptedTeamBookingsOfUser(params: TeamBookingsParamsWithoutCount): Promise<Array<Booking>>;
static async getAllAcceptedTeamBookingsOfUser(params: TeamBookingsParamsBase) {
async getAllAcceptedTeamBookingsOfUser(params: TeamBookingsParamsBase) {
const { user, teamId, startDate, endDate, excludedUid, shouldReturnCount, includeManagedEvents } = params;
const baseWhere: Prisma.BookingWhereInput = {
@@ -595,18 +597,18 @@ export class BookingRepository {
};
if (shouldReturnCount) {
const collectiveRoundRobinBookingsOwner = await prisma.booking.count({
const collectiveRoundRobinBookingsOwner = await this.prismaClient.booking.count({
where: whereCollectiveRoundRobinOwner,
});
const collectiveRoundRobinBookingsAttendee = await prisma.booking.count({
const collectiveRoundRobinBookingsAttendee = await this.prismaClient.booking.count({
where: whereCollectiveRoundRobinBookingsAttendee,
});
let managedBookings = 0;
if (includeManagedEvents) {
managedBookings = await prisma.booking.count({
managedBookings = await this.prismaClient.booking.count({
where: whereManagedBookings,
});
}
@@ -616,18 +618,18 @@ export class BookingRepository {
return totalNrOfBooking;
}
const collectiveRoundRobinBookingsOwner = await prisma.booking.findMany({
const collectiveRoundRobinBookingsOwner = await this.prismaClient.booking.findMany({
where: whereCollectiveRoundRobinOwner,
});
const collectiveRoundRobinBookingsAttendee = await prisma.booking.findMany({
const collectiveRoundRobinBookingsAttendee = await this.prismaClient.booking.findMany({
where: whereCollectiveRoundRobinBookingsAttendee,
});
let managedBookings: typeof collectiveRoundRobinBookingsAttendee = [];
if (includeManagedEvents) {
managedBookings = await prisma.booking.findMany({
managedBookings = await this.prismaClient.booking.findMany({
where: whereManagedBookings,
});
}
@@ -639,8 +641,8 @@ export class BookingRepository {
];
}
static async findOriginalRescheduledBooking(uid: string, seatsEventType?: boolean) {
return await prisma.booking.findFirst({
async findOriginalRescheduledBooking(uid: string, seatsEventType?: boolean) {
return await this.prismaClient.booking.findFirst({
where: {
uid: uid,
status: {
@@ -692,15 +694,13 @@ export class BookingRepository {
});
}
static async getAllAcceptedTeamBookingsOfUsers(
params: TeamBookingsMultipleUsersParamsWithCount
): Promise<number>;
async getAllAcceptedTeamBookingsOfUsers(params: TeamBookingsMultipleUsersParamsWithCount): Promise<number>;
static async getAllAcceptedTeamBookingsOfUsers(
async getAllAcceptedTeamBookingsOfUsers(
params: TeamBookingsMultipleUsersParamsWithoutCount
): Promise<Array<Booking>>;
static async getAllAcceptedTeamBookingsOfUsers(params: TeamBookingsMultipleUsersParamsBase) {
async getAllAcceptedTeamBookingsOfUsers(params: TeamBookingsMultipleUsersParamsBase) {
const { users, teamId, startDate, endDate, excludedUid, shouldReturnCount, includeManagedEvents } =
params;
@@ -759,18 +759,18 @@ export class BookingRepository {
};
if (shouldReturnCount) {
const collectiveRoundRobinBookingsOwner = await prisma.booking.count({
const collectiveRoundRobinBookingsOwner = await this.prismaClient.booking.count({
where: whereCollectiveRoundRobinOwner,
});
const collectiveRoundRobinBookingsAttendee = await prisma.booking.count({
const collectiveRoundRobinBookingsAttendee = await this.prismaClient.booking.count({
where: whereCollectiveRoundRobinBookingsAttendee,
});
let managedBookings = 0;
if (includeManagedEvents) {
managedBookings = await prisma.booking.count({
managedBookings = await this.prismaClient.booking.count({
where: whereManagedBookings,
});
}
@@ -781,18 +781,18 @@ export class BookingRepository {
return totalNrOfBooking;
}
const collectiveRoundRobinBookingsOwner = await prisma.booking.findMany({
const collectiveRoundRobinBookingsOwner = await this.prismaClient.booking.findMany({
where: whereCollectiveRoundRobinOwner,
});
const collectiveRoundRobinBookingsAttendee = await prisma.booking.findMany({
const collectiveRoundRobinBookingsAttendee = await this.prismaClient.booking.findMany({
where: whereCollectiveRoundRobinBookingsAttendee,
});
let managedBookings: typeof collectiveRoundRobinBookingsAttendee = [];
if (includeManagedEvents) {
managedBookings = await prisma.booking.findMany({
managedBookings = await this.prismaClient.booking.findMany({
where: whereManagedBookings,
});
}
@@ -804,7 +804,7 @@ export class BookingRepository {
];
}
static async getValidBookingFromEventTypeForAttendee({
async getValidBookingFromEventTypeForAttendee({
eventTypeId,
bookerEmail,
bookerPhoneNumber,
@@ -817,7 +817,7 @@ export class BookingRepository {
startTime: Date;
filterForUnconfirmed?: boolean;
}) {
return await prisma.booking.findFirst({
return await this.prismaClient.booking.findFirst({
where: {
eventTypeId,
attendees: {
@@ -93,7 +93,8 @@ export class OrganizationRepository {
logger.debug("createWithNonExistentOwner", safeStringify({ orgData, owner }));
const organization = await this.create(orgData);
const ownerUsernameInOrg = getOrgUsernameFromEmail(owner.email, orgData.autoAcceptEmail);
const ownerInDb = await UserRepository.create({
const userRepo = new UserRepository(prisma);
const ownerInDb = await userRepo.create({
email: owner.email,
username: ownerUsernameInOrg,
organizationId: organization.id,
+13 -11
View File
@@ -1,6 +1,6 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
type WhereCondition = {
slotUtcStartDate?: Date | string;
@@ -23,17 +23,19 @@ export type TimeSlot = {
};
export class SelectedSlotsRepository {
static async findMany({ where, select }: FindManyArgs) {
return await prisma.selectedSlots.findMany({ where, select });
constructor(private prismaClient: PrismaClient) {}
async findMany({ where, select }: FindManyArgs) {
return await this.prismaClient.selectedSlots.findMany({ where, select });
}
static async findFirst({ where }: { where: Prisma.SelectedSlotsWhereInput }) {
return await prisma.selectedSlots.findFirst({
async findFirst({ where }: { where: Prisma.SelectedSlotsWhereInput }) {
return await this.prismaClient.selectedSlots.findFirst({
where,
});
}
static async findReservedByOthers({
async findReservedByOthers({
slot,
eventTypeId,
uid,
@@ -53,7 +55,7 @@ export class SelectedSlotsRepository {
});
}
static async findManyReservedByOthers(slots: TimeSlot[], eventTypeId: number, uid: string) {
async findManyReservedByOthers(slots: TimeSlot[], eventTypeId: number, uid: string) {
return await this.findMany({
where: {
OR: slots.map((slot) => ({
@@ -71,14 +73,14 @@ export class SelectedSlotsRepository {
});
}
static async findManyUnexpiredSlots({
async findManyUnexpiredSlots({
userIds,
currentTimeInUtc,
}: {
userIds: number[];
currentTimeInUtc: string;
}) {
return prisma.selectedSlots.findMany({
return this.prismaClient.selectedSlots.findMany({
where: {
userId: { in: userIds },
releaseAt: { gt: currentTimeInUtc },
@@ -95,14 +97,14 @@ export class SelectedSlotsRepository {
});
}
static async deleteManyExpiredSlots({
async deleteManyExpiredSlots({
eventTypeId,
currentTimeInUtc,
}: {
eventTypeId: number;
currentTimeInUtc: string;
}) {
return prisma.selectedSlots.deleteMany({
return this.prismaClient.selectedSlots.deleteMany({
where: {
eventTypeId: { equals: eventTypeId },
releaseAt: { lt: currentTimeInUtc },
+19 -16
View File
@@ -3,6 +3,7 @@ import type { z } from "zod";
import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -166,8 +167,10 @@ const teamSelect = {
} satisfies Prisma.TeamSelect;
export class TeamRepository {
static async findById({ id }: { id: number }) {
const team = await prisma.team.findUnique({
constructor(private prismaClient: PrismaClient) {}
async findById({ id }: { id: number }) {
const team = await this.prismaClient.team.findUnique({
where: {
id,
},
@@ -179,14 +182,14 @@ export class TeamRepository {
return getParsedTeam(team);
}
static async findAllByParentId({
async findAllByParentId({
parentId,
select = teamSelect,
}: {
parentId: number;
select?: Prisma.TeamSelect;
}) {
return await prisma.team.findMany({
return await this.prismaClient.team.findMany({
where: {
parentId,
},
@@ -194,7 +197,7 @@ export class TeamRepository {
});
}
static async findByIdAndParentId({
async findByIdAndParentId({
id,
parentId,
select = teamSelect,
@@ -203,7 +206,7 @@ export class TeamRepository {
parentId: number;
select?: Prisma.TeamSelect;
}) {
return await prisma.team.findFirst({
return await this.prismaClient.team.findFirst({
where: {
id,
parentId,
@@ -212,7 +215,7 @@ export class TeamRepository {
});
}
static async findFirstBySlugAndParentSlug({
async findFirstBySlugAndParentSlug({
slug,
parentSlug,
select = teamSelect,
@@ -221,7 +224,7 @@ export class TeamRepository {
parentSlug: string | null;
select?: Prisma.TeamSelect;
}) {
return await prisma.team.findFirst({
return await this.prismaClient.team.findFirst({
where: {
slug,
parent: parentSlug ? whereClauseForOrgWithSlugOrRequestedSlug(parentSlug) : null,
@@ -230,8 +233,8 @@ export class TeamRepository {
});
}
static async deleteById({ id }: { id: number }) {
const deletedTeam = await prisma.$transaction(async (tx) => {
async deleteById({ id }: { id: number }) {
const deletedTeam = await this.prismaClient.$transaction(async (tx) => {
await tx.eventType.deleteMany({
where: {
teamId: id,
@@ -258,8 +261,8 @@ export class TeamRepository {
return deletedTeam;
}
static async findTeamWithMembers(teamId: number) {
return await prisma.team.findUnique({
async findTeamWithMembers(teamId: number) {
return await this.prismaClient.team.findUnique({
where: { id: teamId },
select: {
members: {
@@ -275,8 +278,8 @@ export class TeamRepository {
});
}
static async findTeamsByUserId({ userId, includeOrgs }: { userId: number; includeOrgs?: boolean }) {
const memberships = await prisma.membership.findMany({
async findTeamsByUserId({ userId, includeOrgs }: { userId: number; includeOrgs?: boolean }) {
const memberships = await this.prismaClient.membership.findMany({
where: {
// Show all the teams this user belongs to regardless of the team being part of the user's org or not
// We don't want to restrict in the listing here. If we need to restrict a situation where a user is part of the org along with being part of a non-org team, we should do that instead of filtering out from here
@@ -317,8 +320,8 @@ export class TeamRepository {
}));
}
static async findTeamWithOrganizationSettings(teamId: number) {
return await prisma.team.findUnique({
async findTeamWithOrganizationSettings(teamId: number) {
return await this.prismaClient.team.findUnique({
where: { id: teamId },
select: {
parent: {
+3 -3
View File
@@ -22,7 +22,7 @@ describe("UserRepository", () => {
describe("create", () => {
test("Should create a user without a password", async () => {
const user = await UserRepository.create({
const user = await new UserRepository(prismock).create({
username: "test",
email: "test@example.com",
organizationId: null,
@@ -50,7 +50,7 @@ describe("UserRepository", () => {
});
test("If locked param is passed, user should be locked", async () => {
const user = await UserRepository.create({
const user = await new UserRepository(prismock).create({
username: "test",
email: "test@example.com",
organizationId: null,
@@ -78,7 +78,7 @@ describe("UserRepository", () => {
const organizationId = 123;
const username = "test";
const user = await UserRepository.create({
const user = await new UserRepository(prismock).create({
username,
email: "test@example.com",
organizationId,
+114 -78
View File
@@ -4,9 +4,9 @@ import { whereClauseForOrgWithSlugOrRequestedSlug } from "@calcom/ee/organizatio
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { availabilityUserSelect } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { User as UserType } from "@calcom/prisma/client";
import { availabilityUserSelect } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma, User as UserType } from "@calcom/prisma/client";
import type { CreationSource } from "@calcom/prisma/enums";
import { MembershipRole } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -23,6 +23,46 @@ export type { UserWithLegacySelectedCalendars } from "../withSelectedCalendars";
export { withSelectedCalendars };
export type UserAdminTeams = number[];
export type SessionUser = {
id: number;
username: string | null;
name: string | null;
email: string;
emailVerified: Date | null;
bio: string | null;
avatarUrl: string | null;
timeZone: string;
weekStart: string;
startTime: number;
endTime: number;
defaultScheduleId: number | null;
bufferTime: number;
theme: string | null;
appTheme: string | null;
createdDate: Date;
hideBranding: boolean;
twoFactorEnabled: boolean;
disableImpersonation: boolean;
identityProvider: string | null;
identityProviderId: string | null;
brandColor: string | null;
darkBrandColor: string | null;
movedToProfileId: number | null;
completedOnboarding: boolean;
destinationCalendar: any;
locale: string;
timeFormat: number | null;
trialEndsAt: Date | null;
metadata: any;
role: string;
allowDynamicBooking: boolean;
allowSEOIndexing: boolean;
receiveMonthlyDigestEmail: boolean;
profiles: any[];
allSelectedCalendars: any[];
userLevelSelectedCalendars: any[];
};
const log = logger.getSubLogger({ prefix: ["[repository/user]"] });
export const ORGANIZATION_ID_UNKNOWN = "ORGANIZATION_ID_UNKNOWN";
@@ -80,8 +120,10 @@ const userSelect = {
} satisfies Prisma.UserSelect;
export class UserRepository {
static async findTeamsByUserId({ userId }: { userId: UserType["id"] }) {
const teamMemberships = await prisma.membership.findMany({
constructor(private prismaClient: PrismaClient) {}
async findTeamsByUserId({ userId }: { userId: UserType["id"] }) {
const teamMemberships = await this.prismaClient.membership.findMany({
where: {
userId: userId,
},
@@ -103,8 +145,8 @@ export class UserRepository {
};
}
static async findOrganizations({ userId }: { userId: UserType["id"] }) {
const { acceptedTeamMemberships } = await UserRepository.findTeamsByUserId({
async findOrganizations({ userId }: { userId: UserType["id"] }) {
const { acceptedTeamMemberships } = await this.findTeamsByUserId({
userId,
});
@@ -122,20 +164,14 @@ export class UserRepository {
/**
* It is aware of the fact that a user can be part of multiple organizations.
*/
static async findUsersByUsername({
orgSlug,
usernameList,
}: {
orgSlug: string | null;
usernameList: string[];
}) {
const { where, profiles } = await UserRepository._getWhereClauseForFindingUsersByUsername({
async findUsersByUsername({ orgSlug, usernameList }: { orgSlug: string | null; usernameList: string[] }) {
const { where, profiles } = await this._getWhereClauseForFindingUsersByUsername({
orgSlug,
usernameList,
});
return (
await prisma.user.findMany({
await this.prismaClient.user.findMany({
select: userSelect,
where,
})
@@ -161,9 +197,9 @@ export class UserRepository {
});
}
static async findPlatformMembersByUsernames({ usernameList }: { usernameList: string[] }) {
async findPlatformMembersByUsernames({ usernameList }: { usernameList: string[] }) {
return (
await prisma.user.findMany({
await this.prismaClient.user.findMany({
select: userSelect,
where: {
username: {
@@ -187,7 +223,7 @@ export class UserRepository {
});
}
static async _getWhereClauseForFindingUsersByUsername({
async _getWhereClauseForFindingUsersByUsername({
orgSlug,
usernameList,
}: {
@@ -229,8 +265,8 @@ export class UserRepository {
return { where, profiles };
}
static async findByEmail({ email }: { email: string }) {
const user = await prisma.user.findUnique({
async findByEmail({ email }: { email: string }) {
const user = await this.prismaClient.user.findUnique({
where: {
email: email.toLowerCase(),
},
@@ -239,8 +275,8 @@ export class UserRepository {
return user;
}
static async findByEmailAndIncludeProfilesAndPassword({ email }: { email: string }) {
const user = await prisma.user.findUnique({
async findByEmailAndIncludeProfilesAndPassword({ email }: { email: string }) {
const user = await this.prismaClient.user.findUnique({
where: {
email: email.toLowerCase(),
},
@@ -280,8 +316,8 @@ export class UserRepository {
};
}
static async findById({ id }: { id: number }) {
const user = await prisma.user.findUnique({
async findById({ id }: { id: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id,
},
@@ -297,8 +333,8 @@ export class UserRepository {
};
}
static async findByIds({ ids }: { ids: number[] }) {
return prisma.user.findMany({
async findByIds({ ids }: { ids: number[] }) {
return this.prismaClient.user.findMany({
where: {
id: {
in: ids,
@@ -308,20 +344,20 @@ export class UserRepository {
});
}
static async findByIdOrThrow({ id }: { id: number }) {
const user = await UserRepository.findById({ id });
async findByIdOrThrow({ id }: { id: number }) {
const user = await this.findById({ id });
if (!user) {
throw new Error(`User with id ${id} not found`);
}
return user;
}
static async findManyByOrganization({ organizationId }: { organizationId: number }) {
async findManyByOrganization({ organizationId }: { organizationId: number }) {
const profiles = await ProfileRepository.findManyForOrg({ organizationId });
return profiles.map((profile) => profile.user);
}
static isAMemberOfOrganization({
isAMemberOfOrganization({
user,
organizationId,
}: {
@@ -331,7 +367,7 @@ export class UserRepository {
return user.profiles.some((profile) => profile.organizationId === organizationId);
}
static async findIfAMemberOfSomeOrganization({ user }: { user: { id: number } }) {
async findIfAMemberOfSomeOrganization({ user }: { user: { id: number } }) {
return !!(
await ProfileRepository.findManyForUser({
id: user.id,
@@ -339,7 +375,7 @@ export class UserRepository {
).length;
}
static isMigratedToOrganization({
isMigratedToOrganization({
user,
}: {
user: {
@@ -351,11 +387,11 @@ export class UserRepository {
return !!user.metadata?.migratedToOrgFrom;
}
static async isMovedToAProfile({ user }: { user: Pick<UserType, "movedToProfileId"> }) {
async isMovedToAProfile({ user }: { user: Pick<UserType, "movedToProfileId"> }) {
return !!user.movedToProfileId;
}
static async enrichUserWithTheProfile<T extends { username: string | null; id: number }>({
async enrichUserWithTheProfile<T extends { username: string | null; id: number }>({
user,
upId,
}: {
@@ -382,7 +418,13 @@ export class UserRepository {
* 2. While dealing with a User that has been moved to a Profile i.e. he was invited to an organization when he was an existing user.
* 3. We haven't added profileId to all the entities, so they aren't aware of which profile they belong to. So, we still mostly use this function to enrich the user with its profile.
*/
static async enrichUserWithItsProfile<T extends { id: number; username: string | null }>({
async enrichUserWithItsProfile<
T extends {
id: number;
username: string | null;
[key: string]: any;
}
>({
user,
}: {
user: T;
@@ -420,7 +462,7 @@ export class UserRepository {
};
}
static async enrichUsersWithTheirProfiles<T extends { id: number; username: string | null }>(
async enrichUsersWithTheirProfiles<T extends { id: number; username: string | null }>(
users: T[]
): Promise<
Array<
@@ -479,7 +521,7 @@ export class UserRepository {
});
}
static enrichUserWithItsProfileBuiltFromUser<T extends { id: number; username: string | null }>({
enrichUserWithItsProfileBuiltFromUser<T extends { id: number; username: string | null }>({
user,
}: {
user: T;
@@ -495,7 +537,7 @@ export class UserRepository {
};
}
static async enrichEntityWithProfile<
async enrichEntityWithProfile<
T extends
| {
profile: {
@@ -554,7 +596,7 @@ export class UserRepository {
}
}
static async updateWhereId({
async updateWhereId({
whereId,
data,
}: {
@@ -563,7 +605,7 @@ export class UserRepository {
movedToProfileId?: number | null;
};
}) {
return prisma.user.update({
return this.prismaClient.user.update({
where: {
id: whereId,
},
@@ -579,7 +621,7 @@ export class UserRepository {
});
}
static async create(
async create(
data: Omit<Prisma.UserCreateInput, "password" | "organization" | "movedToProfile"> & {
username: string;
hashedPassword?: string;
@@ -595,7 +637,7 @@ export class UserRepository {
const t = await getTranslation("en", "common");
const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE);
const user = await prisma.user.create({
const user = await this.prismaClient.user.create({
data: {
username,
email: email,
@@ -635,8 +677,8 @@ export class UserRepository {
return user;
}
static async getUserAdminTeams(userId: number) {
return prisma.user.findUnique({
async getUserAdminTeams({ userId }: { userId: number }) {
return await this.prismaClient.user.findUnique({
where: {
id: userId,
},
@@ -687,7 +729,7 @@ export class UserRepository {
},
});
}
static async isAdminOfTeamOrParentOrg({ userId, teamId }: { userId: number; teamId: number }) {
async isAdminOfTeamOrParentOrg({ userId, teamId }: { userId: number; teamId: number }) {
const membershipQuery = {
members: {
some: {
@@ -696,7 +738,7 @@ export class UserRepository {
},
},
};
const teams = await prisma.team.findMany({
const teams = await this.prismaClient.team.findMany({
where: {
id: teamId,
OR: [
@@ -712,8 +754,8 @@ export class UserRepository {
});
return !!teams.length;
}
static async isAdminOrOwnerOfTeam({ userId, teamId }: { userId: number; teamId: number }) {
const isAdminOrOwnerOfTeam = await prisma.membership.findUnique({
async isAdminOrOwnerOfTeam({ userId, teamId }: { userId: number; teamId: number }) {
const isAdminOrOwnerOfTeam = await this.prismaClient.membership.findUnique({
where: {
userId_teamId: {
userId,
@@ -728,8 +770,8 @@ export class UserRepository {
});
return !!isAdminOrOwnerOfTeam;
}
static async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) {
return await prisma.user.findUnique({
async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) {
return await this.prismaClient.user.findUnique({
where: {
id: userId,
},
@@ -740,16 +782,16 @@ export class UserRepository {
});
}
static async adminFindById(userId: number) {
return await prisma.user.findUniqueOrThrow({
async adminFindById(userId: number) {
return await this.prismaClient.user.findUniqueOrThrow({
where: {
id: userId,
},
});
}
static async findUserTeams({ id }: { id: number }) {
const user = await prisma.user.findUnique({
async findUserTeams({ id }: { id: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id,
},
@@ -776,10 +818,10 @@ export class UserRepository {
return user;
}
static async updateAvatar({ id, avatarUrl }: { id: number; avatarUrl: string }) {
async updateAvatar({ id, avatarUrl }: { id: number; avatarUrl: string }) {
// Using updateMany here since if the user already has a profile it would throw an error
// because no records were found to update the profile picture
await prisma.user.updateMany({
await this.prismaClient.user.updateMany({
where: {
id,
avatarUrl: {
@@ -791,8 +833,8 @@ export class UserRepository {
},
});
}
static async findUserWithCredentials({ id }: { id: number }) {
const user = await prisma.user.findUnique({
async findUserWithCredentials({ id }: { id: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id,
},
@@ -817,8 +859,8 @@ export class UserRepository {
};
}
static async findUnlockedUserForSession({ userId }: { userId: number }) {
const user = await prisma.user.findUnique({
async findUnlockedUserForSession({ userId }: { userId: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id: userId,
// Locked users can't login
@@ -877,8 +919,8 @@ export class UserRepository {
return withSelectedCalendars(user);
}
static async getUserStats({ userId }: { userId: number }) {
const user = await prisma.user.findUnique({
async getUserStats({ userId }: { userId: number }) {
const user = await this.prismaClient.user.findUnique({
where: {
id: userId,
},
@@ -927,8 +969,8 @@ export class UserRepository {
};
}
static async findManyByIdsIncludeDestinationAndSelectedCalendars({ ids }: { ids: number[] }) {
const users = await prisma.user.findMany({
async findManyByIdsIncludeDestinationAndSelectedCalendars({ ids }: { ids: number[] }) {
const users = await this.prismaClient.user.findMany({
where: { id: { in: ids } },
include: {
selectedCalendars: true,
@@ -938,7 +980,7 @@ export class UserRepository {
return users.map(withSelectedCalendars);
}
static async updateStripeCustomerId({
async updateStripeCustomerId({
id,
stripeCustomerId,
existingMetadata,
@@ -947,39 +989,33 @@ export class UserRepository {
stripeCustomerId: string;
existingMetadata: z.infer<typeof userMetadata>;
}) {
return prisma.user.update({
return this.prismaClient.user.update({
where: { id },
data: { metadata: { ...existingMetadata, stripeCustomerId } },
});
}
static async updateWhitelistWorkflows({
id,
whitelistWorkflows,
}: {
id: number;
whitelistWorkflows: boolean;
}) {
return prisma.user.update({
async updateWhitelistWorkflows({ id, whitelistWorkflows }: { id: number; whitelistWorkflows: boolean }) {
return this.prismaClient.user.update({
where: { id },
data: { whitelistWorkflows },
});
}
static async findManyUsersForDynamicEventType({
async findManyUsersForDynamicEventType({
currentOrgDomain,
usernameList,
}: {
currentOrgDomain: string | null;
usernameList: string[];
}) {
const { where } = await UserRepository._getWhereClauseForFindingUsersByUsername({
const { where } = await this._getWhereClauseForFindingUsersByUsername({
orgSlug: currentOrgDomain,
usernameList,
});
// TODO: Should be moved to UserRepository
return prisma.user.findMany({
return this.prismaClient.user.findMany({
where,
select: {
allowDynamicBooking: true,
@@ -35,14 +35,17 @@ const mockTeamBilling = {
};
// Mock implementations that modify the in-memory database
vi.mocked(TeamRepository.deleteById).mockImplementation(async ({ id }) => {
const team = database.teams.get(id);
if (team) {
database.teams.delete(id);
return team;
}
throw new Error(`Team with id ${id} not found`);
});
const mockTeamRepo = {
deleteById: vi.fn().mockImplementation(async ({ id }) => {
const team = database.teams.get(id);
if (team) {
database.teams.delete(id);
return team;
}
throw new Error(`Team with id ${id} not found`);
}),
};
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
vi.mocked(deleteDomain).mockImplementation(async (slug) => {
database.domains.delete(slug);
@@ -47,14 +47,17 @@ describe("TeamService", () => {
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
vi.mocked(TeamRepository.deleteById).mockResolvedValue(mockDeletedTeam);
const mockTeamRepo = {
deleteById: vi.fn().mockResolvedValue(mockDeletedTeam),
};
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any);
const result = await TeamService.delete({ id: 1 });
expect(TeamBilling.findAndInit).toHaveBeenCalledWith(1);
expect(mockTeamBilling.cancel).toHaveBeenCalled();
expect(WorkflowService.deleteWorkflowRemindersOfRemovedTeam).toHaveBeenCalledWith(1);
expect(TeamRepository.deleteById).toHaveBeenCalledWith({ id: 1 });
expect(mockTeamRepo.deleteById).toHaveBeenCalledWith({ id: 1 });
expect(deleteDomain).toHaveBeenCalledWith("deleted-team");
expect(result).toEqual(mockDeletedTeam);
});
@@ -144,7 +144,8 @@ export class InsightsBookingService {
options: Extract<InsightsBookingServiceOptions, { scope: "org" }>
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
// Get all teams from the organization
const teamsFromOrg = await TeamRepository.findAllByParentId({
const teamRepo = new TeamRepository(this.prisma);
const teamsFromOrg = await teamRepo.findAllByParentId({
parentId: options.orgId,
select: { id: true },
});
@@ -183,7 +184,8 @@ export class InsightsBookingService {
private async buildTeamAuthorizationCondition(
options: Extract<InsightsBookingServiceOptions, { scope: "team" }>
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
const childTeamOfOrg = await TeamRepository.findByIdAndParentId({
const teamRepo = new TeamRepository(this.prisma);
const childTeamOfOrg = await teamRepo.findByIdAndParentId({
id: options.teamId,
parentId: options.orgId,
select: { id: true },
@@ -126,7 +126,8 @@ export class InsightsRoutingService {
options: Extract<InsightsRoutingServiceOptions, { scope: "org" }>
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
// Get all teams from the organization
const teamsFromOrg = await TeamRepository.findAllByParentId({
const teamRepo = new TeamRepository(this.prisma);
const teamsFromOrg = await teamRepo.findAllByParentId({
parentId: options.orgId,
select: { id: true },
});
@@ -141,7 +142,8 @@ export class InsightsRoutingService {
private async buildTeamAuthorizationCondition(
options: Extract<InsightsRoutingServiceOptions, { scope: "team" }>
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
const childTeamOfOrg = await TeamRepository.findByIdAndParentId({
const teamRepo = new TeamRepository(this.prisma);
const childTeamOfOrg = await teamRepo.findByIdAndParentId({
id: options.teamId,
parentId: options.orgId,
select: { id: true },
+2 -1
View File
@@ -34,7 +34,8 @@ export class TeamService {
}
// Step 3: Delete the team from the database. This is the core "commit" point.
const deletedTeam = await TeamRepository.deleteById({ id });
const teamRepo = new TeamRepository(prisma);
const deletedTeam = await teamRepo.deleteById({ id });
// Step 4: Clean up any final, non-critical external state.
if (deletedTeam && deletedTeam.isOrganization && deletedTeam.slug) {
@@ -21,11 +21,11 @@ vi.mock("@calcom/features/auth/lib/hashPassword", () => ({
hashPassword: vi.fn().mockResolvedValue("hashed-password"),
}));
vi.mock("../repository/user", async () => {
vi.mock("../repository/user", () => {
return {
UserRepository: {
UserRepository: vi.fn().mockImplementation(() => ({
create: vi.fn(),
},
})),
};
});
@@ -48,15 +48,25 @@ describe("UserCreationService", () => {
});
test("should create user", async () => {
vi.spyOn(UserRepository, "create").mockResolvedValue({
const mockCreate = vi.fn().mockResolvedValue({
username: "test",
locked: false,
organizationId: null,
} as any);
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
create: mockCreate,
} as any)
);
}
const user = await UserCreationService.createUser({ data: mockUserData });
expect(UserRepository.create).toHaveBeenCalledWith(
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
username: "test",
locked: false,
@@ -70,9 +80,25 @@ describe("UserCreationService", () => {
test("should lock user when email is in watchlist", async () => {
vi.mocked(checkIfEmailIsBlockedInWatchlistController).mockResolvedValue(true);
const mockCreate = vi.fn().mockResolvedValue({
username: "test",
locked: true,
organizationId: null,
} as any);
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
create: mockCreate,
} as any)
);
}
const user = await UserCreationService.createUser({ data: mockUserData });
expect(UserRepository.create).toHaveBeenCalledWith(
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
locked: true,
})
@@ -85,12 +111,28 @@ describe("UserCreationService", () => {
const mockPassword = "password";
vi.mocked(hashPassword).mockResolvedValue("hashed_password");
const mockCreate = vi.fn().mockResolvedValue({
username: "test",
locked: false,
organizationId: null,
} as any);
const mockUserRepository = vi.mocked(UserRepository);
if (mockUserRepository && typeof mockUserRepository.mockImplementation === "function") {
mockUserRepository.mockImplementation(
() =>
({
create: mockCreate,
} as any)
);
}
const user = await UserCreationService.createUser({
data: { ...mockUserData, password: mockPassword },
});
expect(hashPassword).toHaveBeenCalledWith(mockPassword);
expect(UserRepository.create).toHaveBeenCalledWith(
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
hashedPassword: "hashed_password",
})
@@ -1,6 +1,7 @@
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { CreationSource, UserPermissionRole, IdentityProvider } from "@calcom/prisma/enums";
import slugify from "../../slugify";
@@ -37,7 +38,8 @@ export class UserCreationService {
const hashedPassword = password ? await hashPassword(password) : null;
const user = await UserRepository.create({
const userRepo = new UserRepository(prisma);
const user = await userRepo.create({
...data,
username: slugify(username),
...(hashedPassword && { hashedPassword }),
@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-[24px] w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
@@ -80,7 +80,7 @@ function RequireEmailOrPhone(validationOptions?: ValidationOptions) {
constraints: [],
validator: {
validate(_: unknown, args: ValidationArguments) {
const obj = args.object as Attendee;
const obj = args.object as CreateBookingAttendee;
const hasPhoneNumber = !!obj.phoneNumber;
const hasEmail = !!obj.email;
@@ -95,7 +95,7 @@ function RequireEmailOrPhone(validationOptions?: ValidationOptions) {
}
@RequireEmailOrPhone()
class Attendee {
class CreateBookingAttendee {
@ApiProperty({
type: String,
description: "The name of the attendee.",
@@ -222,13 +222,13 @@ export class CreateBookingInput_2024_08_13 {
start!: string;
@ApiProperty({
type: Attendee,
type: CreateBookingAttendee,
description: "The attendee's details.",
})
@IsDefined()
@ValidateNested()
@Type(() => Attendee)
attendee!: Attendee;
@Type(() => CreateBookingAttendee)
attendee!: CreateBookingAttendee;
@ApiPropertyOptional({
type: Object,
@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsOptional, IsBoolean, IsEmail, IsArray, ArrayMinSize, ValidateNested } from "class-validator";
class Attendee {
class MarkAbsentAttendee {
@IsEmail()
@ApiProperty()
email!: string;
@@ -21,8 +21,8 @@ export class MarkAbsentBookingInput_2024_08_13 {
@ArrayMinSize(1)
@IsArray()
@ValidateNested({ each: true })
@Type(() => Attendee)
@Type(() => MarkAbsentAttendee)
@IsOptional()
@ApiPropertyOptional({ type: [Attendee] })
attendees?: Attendee[];
@ApiPropertyOptional({ type: [MarkAbsentAttendee] })
attendees?: MarkAbsentAttendee[];
}
@@ -18,7 +18,7 @@ import {
import type { BookingLanguageType } from "../inputs/language";
import { BookingLanguage } from "../inputs/language";
class Attendee {
class BookingAttendee {
@ApiProperty({ type: String, example: "John Doe" })
@IsString()
@Expose()
@@ -52,7 +52,7 @@ class Attendee {
phoneNumber?: string;
}
export class SeatedAttendee extends Attendee {
export class SeatedAttendee extends BookingAttendee {
@ApiProperty({ type: String, example: "3be561a9-31f1-4b8e-aefc-9d9a085f0dd1" })
@IsString()
@Expose()
@@ -264,11 +264,11 @@ class BaseBookingOutput_2024_08_13 {
}
export class BookingOutput_2024_08_13 extends BaseBookingOutput_2024_08_13 {
@ApiProperty({ type: [Attendee] })
@ApiProperty({ type: [BookingAttendee] })
@ValidateNested({ each: true })
@Type(() => Attendee)
@Type(() => BookingAttendee)
@Expose()
attendees!: Attendee[];
attendees!: BookingAttendee[];
@ApiPropertyOptional({
type: [String],
Binary file not shown.
@@ -1,5 +1,3 @@
import { z } from "zod";
import type { AnyRouter } from "@trpc/server";
import { createNextApiHandler as _createNextApiHandler } from "@trpc/server/adapters/next";
@@ -43,9 +41,6 @@ export function createNextApiHandler(router: AnyRouter, isPublic = false, namesp
headers: {},
};
const timezone = z.string().safeParse(ctx.req?.headers["x-vercel-ip-timezone"]);
if (timezone.success) defaultHeaders.headers["x-cal-timezone"] = timezone.data;
// We need all these conditions to be true to set cache headers
if (!(isPublic && allOk && isQuery)) return defaultHeaders;
@@ -6,6 +6,7 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@@ -24,7 +25,8 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
return null;
}
const userFromDb = await UserRepository.findUnlockedUserForSession({ userId: session.user.id });
const userRepo = new UserRepository(prisma);
const userFromDb = await userRepo.findUnlockedUserForSession({ userId: session.user.id });
// some hacks to make sure `username` and `email` are never inferred as `null`
if (!userFromDb) {
@@ -33,7 +35,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
const upId = session.upId;
const user = await UserRepository.enrichUserWithTheProfile({
const user = await userRepo.enrichUserWithTheProfile({
user: userFromDb,
upId,
});
@@ -54,7 +56,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
const locale = user?.locale ?? ctx.locale;
const { members = [], ..._organization } = user.profile?.organization || {};
const isOrgAdmin = members.some((member) => ["OWNER", "ADMIN"].includes(member.role));
const isOrgAdmin = members.some((member: any) => ["OWNER", "ADMIN"].includes(member.role));
if (isOrgAdmin) {
logger.debug("User is an org admin", safeStringify({ userId: user.id }));

Some files were not shown because too many files have changed in this diff Show More