diff --git a/.changeset/short-foxes-crash.md b/.changeset/short-foxes-crash.md
new file mode 100644
index 00000000000000..a7b452fe8f5297
--- /dev/null
+++ b/.changeset/short-foxes-crash.md
@@ -0,0 +1,6 @@
+---
+"@calcom/atoms": patch
+---
+
+This change fixes an issue in the CalendarSettings atom where the redirect urls were getting only one search param
+
diff --git a/.changeset/twenty-comics-join.md b/.changeset/twenty-comics-join.md
new file mode 100644
index 00000000000000..6c84da25c63873
--- /dev/null
+++ b/.changeset/twenty-comics-join.md
@@ -0,0 +1,5 @@
+---
+"@calcom/atoms": patch
+---
+
+This change fixes date overrides breaking for availability settings atom
diff --git a/.yarn/versions/68190699.yml b/.yarn/versions/68190699.yml
new file mode 100644
index 00000000000000..dd2ce8f8224c20
--- /dev/null
+++ b/.yarn/versions/68190699.yml
@@ -0,0 +1,3 @@
+undecided:
+ - calcom-monorepo
+ - "@calcom/prisma"
diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json
index faffb6192c4416..ce0adc2d5d5a89 100644
--- a/apps/api/v2/package.json
+++ b/apps/api/v2/package.json
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
- "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.268",
+ "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.271",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts
index e02e617577e31a..c23d85ca08b7d7 100644
--- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts
+++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts
@@ -5,7 +5,7 @@ import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ge
import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
-import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
+import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -386,7 +386,7 @@ export class BookingsController_2024_04_15 {
if (bearerToken) {
if (isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get("api.keyPrefix"));
- const apiKeyHash = hashAPIKey(strippedApiKey);
+ const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
return keyData?.userId;
} else {
diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts
index 2325ce36cbefb6..da7cc4c641a72b 100644
--- a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts
+++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts
@@ -31,12 +31,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) {
return value.trim().length > 0;
}
if (typeof value === "object" && value !== null) {
- return (
- typeof value.firstName === "string" &&
- typeof value.lastName === "string" &&
- value.firstName.trim().length > 0 &&
- value.lastName.trim().length > 0
- );
+ return typeof value.firstName === "string" && value.firstName.trim().length > 0;
}
return false;
},
diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts
index ed430045b39a0a..04f67dc900a9e4 100644
--- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts
+++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts
@@ -11,7 +11,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { apiToInternalintegrationsMapping } from "@/ee/event-types/event-types_2024_06_14/transformers";
-import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
+import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking";
import { safeParse } from "@/lib/safe-parse/safe-parse";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
@@ -675,7 +675,7 @@ export class InputBookingsService_2024_08_13 {
if (bearerToken) {
if (isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get("api.keyPrefix"));
- const apiKeyHash = hashAPIKey(strippedApiKey);
+ const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
return keyData?.userId;
} else {
diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts
index 50d88e272209e0..c57608c903b48a 100644
--- a/apps/api/v2/src/ee/calendars/services/calendars.service.ts
+++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts
@@ -83,32 +83,31 @@ export class CalendarsService {
calendarsToLoad,
userId
);
- try {
- const calendarBusyTimes = await getBusyCalendarTimes(
- this.buildNonDelegationCredentials(credentials),
- dateFrom,
- dateTo,
- composedSelectedCalendars
- );
- const calendarBusyTimesConverted = calendarBusyTimes.map(
- (busyTime: EventBusyDate & { timeZone?: string }) => {
- const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
- const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
- const busyTimeStartDate = busyTimeStart.toJSDate();
- const busyTimeEndDate = busyTimeEnd.toJSDate();
- return {
- ...busyTime,
- start: busyTimeStartDate,
- end: busyTimeEndDate,
- };
- }
- );
- return calendarBusyTimesConverted;
- } catch (error) {
+ const calendarBusyTimesQuery = await getBusyCalendarTimes(
+ this.buildNonDelegationCredentials(credentials),
+ dateFrom,
+ dateTo,
+ composedSelectedCalendars
+ );
+ if (!calendarBusyTimesQuery.success) {
throw new InternalServerErrorException(
"Unable to fetch connected calendars events. Please try again later."
);
}
+ const calendarBusyTimesConverted = calendarBusyTimesQuery.data.map(
+ (busyTime: EventBusyDate & { timeZone?: string }) => {
+ const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone);
+ const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone);
+ const busyTimeStartDate = busyTimeStart.toJSDate();
+ const busyTimeEndDate = busyTimeEnd.toJSDate();
+ return {
+ ...busyTime,
+ start: busyTimeStartDate,
+ end: busyTimeEndDate,
+ };
+ }
+ );
+ return calendarBusyTimesConverted;
}
async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) {
diff --git a/apps/api/v2/src/lib/api-key/index.ts b/apps/api/v2/src/lib/api-key/index.ts
index ad7ec0d2a9f3e7..b580c5c29dea31 100644
--- a/apps/api/v2/src/lib/api-key/index.ts
+++ b/apps/api/v2/src/lib/api-key/index.ts
@@ -1,6 +1,6 @@
import { createHash } from "crypto";
-export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
+export const sha256Hash = (token: string): string => createHash("sha256").update(token).digest("hex");
export const isApiKey = (authString: string, prefix: string): boolean =>
authString?.startsWith(prefix ?? "cal_");
diff --git a/apps/api/v2/src/lib/modules/available-slots.module.ts b/apps/api/v2/src/lib/modules/available-slots.module.ts
index c1f55f55f3fc70..0eb223fc73fd0d 100644
--- a/apps/api/v2/src/lib/modules/available-slots.module.ts
+++ b/apps/api/v2/src/lib/modules/available-slots.module.ts
@@ -7,6 +7,7 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
+import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@@ -21,6 +22,7 @@ import { Module } from "@nestjs/common";
PrismaEventTypeRepository,
PrismaRoutingFormResponseRepository,
PrismaTeamRepository,
+ CheckBookingLimitsService,
AvailableSlotsService,
],
exports: [AvailableSlotsService],
diff --git a/apps/api/v2/src/lib/services/available-slots.service.ts b/apps/api/v2/src/lib/services/available-slots.service.ts
index 7f6ce1725c70f4..df2850364d59c0 100644
--- a/apps/api/v2/src/lib/services/available-slots.service.ts
+++ b/apps/api/v2/src/lib/services/available-slots.service.ts
@@ -6,6 +6,7 @@ import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.rep
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
+import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { Injectable } from "@nestjs/common";
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
@@ -31,6 +32,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepo: selectedSlotRepository,
eventTypeRepo: eventTypeRepository,
userRepo: userRepository,
+ checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
});
}
}
diff --git a/apps/api/v2/src/lib/services/check-booking-and-duration-limits.service.ts b/apps/api/v2/src/lib/services/check-booking-and-duration-limits.service.ts
new file mode 100644
index 00000000000000..8ac610ceaad697
--- /dev/null
+++ b/apps/api/v2/src/lib/services/check-booking-and-duration-limits.service.ts
@@ -0,0 +1,13 @@
+import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
+import { Injectable } from "@nestjs/common";
+
+import { CheckBookingAndDurationLimitsService as BaseCheckBookingAndDurationLimitsService } from "@calcom/platform-libraries/bookings";
+
+@Injectable()
+export class CheckBookingAndDurationLimitsService extends BaseCheckBookingAndDurationLimitsService {
+ constructor(checkBookingLimitsService: CheckBookingLimitsService) {
+ super({
+ checkBookingLimitsService: checkBookingLimitsService,
+ });
+ }
+}
diff --git a/apps/api/v2/src/lib/services/check-booking-limits.service.ts b/apps/api/v2/src/lib/services/check-booking-limits.service.ts
new file mode 100644
index 00000000000000..2e034149b70451
--- /dev/null
+++ b/apps/api/v2/src/lib/services/check-booking-limits.service.ts
@@ -0,0 +1,13 @@
+import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
+import { Injectable } from "@nestjs/common";
+
+import { CheckBookingLimitsService as BaseCheckBookingLimitsService } from "@calcom/platform-libraries/bookings";
+
+@Injectable()
+export class CheckBookingLimitsService extends BaseCheckBookingLimitsService {
+ constructor(bookingRepository: PrismaBookingRepository) {
+ super({
+ bookingRepo: bookingRepository,
+ });
+ }
+}
diff --git a/apps/api/v2/src/lib/throttler-guard.ts b/apps/api/v2/src/lib/throttler-guard.ts
index 042ec9ceeff849..e19b599cc24cc2 100644
--- a/apps/api/v2/src/lib/throttler-guard.ts
+++ b/apps/api/v2/src/lib/throttler-guard.ts
@@ -1,5 +1,5 @@
import { getEnv } from "@/env";
-import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
+import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
@@ -10,7 +10,6 @@ import {
ThrottlerException,
ThrottlerRequest,
ThrottlerModuleOptions,
- seconds,
} from "@nestjs/throttler";
import { Request, Response } from "express";
import { z } from "zod";
@@ -218,18 +217,18 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
if (authorizationHeader) {
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
return isApiKey(authorizationHeader, apiKeyPrefix)
- ? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
- : `access_token_${authorizationHeader}`;
+ ? `api_key_${sha256Hash(stripApiKey(authorizationHeader, apiKeyPrefix))}`
+ : `access_token_${sha256Hash(authorizationHeader)}`;
}
const oauthClientId = request.get(X_CAL_CLIENT_ID);
if (oauthClientId) {
- return `oauth_client_${oauthClientId}`;
+ return `oauth_client_${sha256Hash(oauthClientId)}`;
}
if (IP) {
- return `ip_${IP}`;
+ return `ip_${sha256Hash(IP.toString())}`;
}
this.logger.verbose(`no tracker found: ${request.url}`);
diff --git a/apps/api/v2/src/modules/api-keys/services/api-keys.service.ts b/apps/api/v2/src/modules/api-keys/services/api-keys.service.ts
index 79f3a3bb3fcd7e..45547d868a4239 100644
--- a/apps/api/v2/src/modules/api-keys/services/api-keys.service.ts
+++ b/apps/api/v2/src/modules/api-keys/services/api-keys.service.ts
@@ -1,4 +1,4 @@
-import { hashAPIKey, stripApiKey } from "@/lib/api-key";
+import { sha256Hash, stripApiKey } from "@/lib/api-key";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { CreateApiKeyInput } from "@/modules/api-keys/inputs/create-api-key.input";
@@ -61,7 +61,7 @@ export class ApiKeysService {
async refreshApiKey(authUserId: number, apiKey: string, refreshApiKeyInput: RefreshApiKeyInput) {
const strippedApiKey = stripApiKey(apiKey, this.config.get("api.keyPrefix"));
- const apiKeyHash = hashAPIKey(strippedApiKey);
+ const apiKeyHash = sha256Hash(strippedApiKey);
const apiKeyInDb = await this.apiKeysRepository.getApiKeyFromHash(apiKeyHash);
if (!apiKeyInDb) {
throw new UnauthorizedException("ApiKeysService - provided api key is not valid.");
diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts
index c04c5d82388f83..b1688e69b94f34 100644
--- a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts
+++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts
@@ -1,4 +1,4 @@
-import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
+import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { AuthMethods } from "@/lib/enums/auth-methods";
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { BaseStrategy } from "@/lib/passport/strategies/types";
@@ -226,7 +226,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
);
}
const strippedApiKey = stripApiKey(apiKey, this.config.get("api.keyPrefix"));
- const apiKeyHash = hashAPIKey(strippedApiKey);
+ const apiKeyHash = sha256Hash(strippedApiKey);
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
if (!keyData) {
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid");
diff --git a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts
index 3dcbb33ce0ef5b..d949e3ea92fabc 100644
--- a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts
+++ b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts
@@ -1,7 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { getEnv } from "@/env";
-import { hashAPIKey, stripApiKey } from "@/lib/api-key";
+import { sha256Hash, stripApiKey } from "@/lib/api-key";
import { RefreshApiKeyOutput } from "@/modules/api-keys/outputs/refresh-api-key.output";
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
@@ -283,7 +283,7 @@ describe("Organizations Organizations Endpoints", () => {
expect(managedOrgApiKeys?.length).toEqual(1);
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
- const hashedApiKey = `${hashAPIKey(stripApiKey(managedOrg?.apiKey, apiKeyPrefix))}`;
+ const hashedApiKey = `${sha256Hash(stripApiKey(managedOrg?.apiKey, apiKeyPrefix))}`;
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 30 }).toJSDate();
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
@@ -484,7 +484,7 @@ describe("Organizations Organizations Endpoints", () => {
expect(managedOrgApiKeys?.length).toEqual(1);
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
- const hashedApiKey = `${hashAPIKey(stripApiKey(newApiKey, apiKeyPrefix))}`;
+ const hashedApiKey = `${sha256Hash(stripApiKey(newApiKey, apiKeyPrefix))}`;
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 60 }).toJSDate();
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
diff --git a/apps/api/v2/tsconfig.json b/apps/api/v2/tsconfig.json
index b4eb8fc148566a..8460e8e22c5a8e 100644
--- a/apps/api/v2/tsconfig.json
+++ b/apps/api/v2/tsconfig.json
@@ -26,7 +26,8 @@
"@calcom/platform-libraries/app-store": ["../../../packages/platform/libraries/app-store.ts"],
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"],
- "@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"]
+ "@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"],
+ "@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"]
},
"incremental": true,
"skipLibCheck": true,
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/api-keys/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/api-keys/page.tsx
index 50743a9339d29a..cd85c1122348ee 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/api-keys/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/api-keys/page.tsx
@@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
-import { ApiKeyRepository } from "@calcom/lib/server/repository/apiKey";
+import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -22,7 +22,7 @@ export const generateMetadata = async () =>
const getCachedApiKeys = unstable_cache(
async (userId: number) => {
- return await ApiKeyRepository.findApiKeysFromUserId({ userId });
+ return await PrismaApiKeyRepository.findApiKeysFromUserId({ userId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.apiKeys.list"] } // Cache for 1 hour
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx
index 55cbeac844c307..cfa0900043cfb7 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
+import { headers, cookies } from "next/headers";
+import { redirect } from "next/navigation";
import OrgSettingsAttributesPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-list-view";
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
+import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,40 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
+ return redirect("/settings/profile");
+ }
+
+ const { canRead, canEdit, canDelete, canCreate } = await getResourcePermissions({
+ userId: session.user.id,
+ teamId: session.user.profile.organizationId,
+ resource: Resource.Attributes,
+ userRole: session.user.org.role,
+ fallbackRoles: {
+ read: {
+ roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ update: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ delete: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ create: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ });
+
+ if (!canRead) {
+ return redirect("/settings/profile");
+ }
return (
-
+
);
};
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/dsync/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/dsync/page.tsx
index a53e48fd1ec599..161ce65275a78c 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/dsync/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/dsync/page.tsx
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
+import { headers, cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import DirectorySyncTeamView from "@calcom/features/ee/dsync/page/team-dsync-view";
+import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
+import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
+ return redirect("/settings/organizations/general");
+ }
+
+ const { canEdit } = await getResourcePermissions({
+ userId: session.user.id,
+ teamId: session.user.profile.organizationId,
+ resource: Resource.Organization,
+ userRole: session.user.org.role,
+ fallbackRoles: {
+ update: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ });
return (
-
+
);
};
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx
index 8900c77c70038b..7a4e75a2f43e0a 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
+import { headers, cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import PrivacyView from "@calcom/features/ee/organizations/pages/settings/privacy";
+import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
+import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,9 +23,34 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
+ return redirect("/settings/profile");
+ }
+
+ const { canRead, canEdit } = await getResourcePermissions({
+ userId: session.user.id,
+ teamId: session.user.profile.organizationId,
+ resource: Resource.Organization,
+ userRole: session.user.org.role,
+ fallbackRoles: {
+ read: {
+ roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ update: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ });
+
+ if (!canRead) {
+ return redirect("/settings/profile");
+ }
+
return (
-
+
);
};
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx
index 93750ac2fdadfe..a3fcf936b1cb6a 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
+import { headers, cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import OrgSSOView from "@calcom/features/ee/sso/page/orgs-sso-view";
+import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
+import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
+ return redirect("/settings/organizations/general");
+ }
+
+ const { canEdit } = await getResourcePermissions({
+ userId: session.user.id,
+ teamId: session.user.profile.organizationId,
+ resource: Resource.Organization,
+ userRole: session.user.org.role,
+ fallbackRoles: {
+ update: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ });
return (
-
+
);
};
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx
index 75aeaf2f6e8b36..5ce47fad0ba9b1 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx
@@ -1,7 +1,15 @@
import { _generateMetadata, getTranslate } from "app/_utils";
+import { headers, cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/general";
+import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
+import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,9 +23,30 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
+
+ if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
+ return redirect("/settings/profile");
+ }
+
+ const { canRead, canEdit } = await getResourcePermissions({
+ userId: session.user.id,
+ teamId: session.user.profile.organizationId,
+ resource: Resource.Organization,
+ userRole: session.user.org.role,
+ fallbackRoles: {
+ read: {
+ roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ update: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ });
+
return (
-
+
);
};
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx
index 3849a833050756..4b58650b45d306 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx
@@ -1,7 +1,16 @@
import { _generateMetadata, getTranslate } from "app/_utils";
+import { cookies, headers } from "next/headers";
+import { redirect } from "next/navigation";
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/profile";
+import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
+import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
+import type { Membership } from "@calcom/prisma/client";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -13,14 +22,47 @@ export const generateMetadata = async () =>
);
const Page = async () => {
+ const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const t = await getTranslate();
+ const orgRole = session?.user.profile?.organization.members?.find(
+ (member: Membership) => member.userId === session?.user.id
+ )?.role;
+
+ if (!session?.user.id || !session?.user.profile?.organizationId || !orgRole) {
+ return redirect("/settings/profile");
+ }
+
+ const { canRead, canEdit, canDelete } = await getResourcePermissions({
+ userId: session.user.id,
+ teamId: session?.user.profile?.organizationId,
+ resource: Resource.Organization,
+ userRole: orgRole,
+ fallbackRoles: {
+ read: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ update: {
+ roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ delete: {
+ roles: [MembershipRole.OWNER],
+ },
+ },
+ });
+
return (
-
+
);
};
diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx
index 3db242a724e1d3..dc582367e0fd55 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx
@@ -4,7 +4,7 @@ import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
-import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
+import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { TeamMembersView } from "~/teams/team-members-view";
@@ -37,7 +37,7 @@ const getCachedTeamAttributes = unstable_cache(
async (organizationId?: number) => {
if (!organizationId) return [];
try {
- return await AttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
+ return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
} catch (error) {
return [];
}
diff --git a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx
index 4fbd5875586e5f..eeb9b26b1bce58 100644
--- a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx
@@ -3,7 +3,7 @@ import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
-import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
+import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { MembersView } from "~/members/members-view";
@@ -19,7 +19,7 @@ export const generateMetadata = async () =>
const getCachedAttributes = unstable_cache(
async (orgId: number) => {
- return await AttributeRepository.findAllByOrgIdWithOptions({ orgId });
+ return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
diff --git a/apps/web/modules/settings/organizations/new/_components/AddNewTeamsForm.tsx b/apps/web/modules/settings/organizations/new/_components/AddNewTeamsForm.tsx
index 8adb8ce9a8ebd0..529363abab48aa 100644
--- a/apps/web/modules/settings/organizations/new/_components/AddNewTeamsForm.tsx
+++ b/apps/web/modules/settings/organizations/new/_components/AddNewTeamsForm.tsx
@@ -78,9 +78,11 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
teams: teamsToCreateFromStore.length ? teamsToCreateFromStore : [{ name: "" }],
moveTeams: teams.map((team) => {
const teamToMigrateInStore = teamsToMigrateFromStore.find((t) => t.id === team.id);
+ const slugConflictsWithOrg = team.slug === orgSlug;
return {
id: team.id,
- shouldMove: !!teamToMigrateInStore,
+ // The team with conflicting slug must be moved
+ shouldMove: slugConflictsWithOrg || !!teamToMigrateInStore,
newSlug: teamToMigrateInStore?.slug || getSuggestedSlug({ teamSlug: team.slug, orgSlug }),
name: team.name,
};
@@ -160,6 +162,9 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
{moveTeams.map((team, index) => {
+ const currentTeam = teams.find((t) => t.id === team.id);
+ // If the team slug conflicts with the org slug, this team must be moved
+ const slugConflictsWithOrg = currentTeam?.slug === orgSlug;
return (
(
t.id === team.id)?.name ?? ""}
+ description={currentTeam?.name ?? ""}
+ // Must not allow toggling off if the slug conflicts with the org slug
+ disabled={slugConflictsWithOrg}
/>
)}
/>
diff --git a/apps/web/package.json b/apps/web/package.json
index 4f948a64479c9c..b96da6ce6a8805 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
- "version": "5.5.8",
+ "version": "5.5.9",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
diff --git a/apps/web/pages/api/integrations/btcpayserver/webhook.ts b/apps/web/pages/api/integrations/btcpayserver/webhook.ts
new file mode 100644
index 00000000000000..bdca1082c8a30c
--- /dev/null
+++ b/apps/web/pages/api/integrations/btcpayserver/webhook.ts
@@ -0,0 +1 @@
+export { default, config } from "@calcom/app-store/btcpayserver/api/webhook";
diff --git a/apps/web/playwright/booking-race-condition.e2e.ts b/apps/web/playwright/booking-race-condition.e2e.ts
new file mode 100644
index 00000000000000..caea55a3c0009a
--- /dev/null
+++ b/apps/web/playwright/booking-race-condition.e2e.ts
@@ -0,0 +1,444 @@
+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, selectFirstAvailableTimeSlotNextMonth } 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.skip("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 { selectedDate, selectedDateISO } = await getDynamicBookingDate(page, org, team, teamEvent);
+
+ const { targetHost, calendarCacheHits } = await setupCalendarCache(teamMembers, selectedDateISO);
+ await enableCalendarCacheFeatures(team.id);
+
+ const { firstResponse, secondResponse } = await performConcurrentBookings(
+ page,
+ browser,
+ org,
+ team,
+ teamEvent,
+ selectedDate
+ );
+
+ const bookingResults = await analyzeBookingResults(teamEvent.id, firstResponse, secondResponse);
+
+ expect(bookingResults.isRaceConditionPrevented).toBe(true);
+
+ console.log("Race condition prevention test results:", {
+ totalBookings: bookingResults.bookings.length,
+ responseStatuses: bookingResults.responseStatuses,
+ sameHostSelected: bookingResults.sameHostSelected,
+ preventionMechanism: bookingResults.preventionMechanism,
+ });
+ });
+});
+
+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 },
+ select: {
+ user: true,
+ },
+ });
+
+ const teamMembers = teamMemberships.map((membership) => membership.user);
+
+ return { org, team, teamEvent, teamMembers };
+}
+
+async function setupGoogleCalendarCredentials(teamMembers: User[]) {
+ const googleCalendarApp = await prisma.app.findFirst({
+ where: { slug: "google-calendar" },
+ });
+
+ if (!googleCalendarApp) {
+ await prisma.app.create({
+ data: {
+ slug: "google-calendar",
+ dirName: "google-calendar",
+ },
+ });
+ }
+
+ 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) {
+ const identicalTimestamp = new Date(Date.now() - 24 * 60 * 60 * 1000);
+
+ for (const member of teamMembers) {
+ 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 getDynamicBookingDate(
+ page: Page,
+ org: any,
+ team: any,
+ teamEvent: EventType
+): Promise<{ selectedDate: string; selectedDateISO: string }> {
+ await doOnOrgDomain({ orgSlug: org.slug, page }, async () => {
+ await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
+
+ await page.waitForSelector('[data-testid="day"]');
+
+ await page.getByTestId("incrementMonth").click();
+
+ await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor();
+ });
+
+ const firstAvailableDayText = await page
+ .locator('[data-testid="day"][data-disabled="false"]')
+ .nth(0)
+ .textContent();
+
+ if (!firstAvailableDayText) {
+ throw new Error("No available day found");
+ }
+
+ const currentDate = new Date();
+ const nextMonth = new Date(
+ currentDate.getFullYear(),
+ currentDate.getMonth() + 1,
+ parseInt(firstAvailableDayText)
+ );
+ const selectedDateISO = nextMonth.toISOString();
+
+ return {
+ selectedDate: firstAvailableDayText,
+ selectedDateISO,
+ };
+}
+
+async function setupCalendarCache(teamMembers: User[], selectedDateISO: string) {
+ const cacheTimeRange = {
+ timeMin: getTimeMin(selectedDateISO),
+ timeMax: getTimeMax(selectedDateISO),
+ };
+
+ 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];
+ 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! }],
+ };
+
+ const availabilityData = {
+ kind: "calendar#freeBusy",
+ calendars: {
+ [member.email!]: {
+ busy:
+ member.id === targetHost.id
+ ? []
+ : [
+ {
+ start: `${selectedDateISO.slice(0, 10)}T08:00:00.000Z`,
+ end: `${selectedDateISO.slice(0, 10)}T08: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, selectedDateISO: string) {
+ const busyStart = `${selectedDateISO.slice(0, 10)}T08:00:00.000Z`;
+ const busyEnd = `${selectedDateISO.slice(0, 10)}T09:00:00.000Z`;
+ await page.route("**/calendar/v3/freeBusy**", async (route: Route) => {
+ const mockResponse = {
+ kind: "calendar#freeBusy",
+ calendars: {
+ "pro-user@example.com": {
+ busy: [
+ {
+ start: busyStart,
+ end: busyEnd,
+ },
+ ],
+ },
+ "teammate-1@example.com": {
+ busy: [
+ {
+ start: busyStart,
+ end: busyEnd,
+ },
+ ],
+ },
+ "teammate-2@example.com": {
+ busy: [
+ {
+ start: busyStart,
+ end: busyEnd,
+ },
+ ],
+ },
+ },
+ };
+
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(mockResponse),
+ });
+ });
+}
+
+export type TeamWithMetadata = Team & { metadata: z.infer };
+
+async function performConcurrentBookings(
+ page: Page,
+ browser: Browser,
+ org: TeamWithMetadata,
+ team: TeamWithMetadata,
+ teamEvent: EventType,
+ selectedDate: string
+) {
+ 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();
+
+ const currentDate = new Date();
+ const nextMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, parseInt(selectedDate));
+ const selectedDateISO = nextMonth.toISOString();
+
+ await mockGoogleCalendarAPI(page1, selectedDateISO);
+ await mockGoogleCalendarAPI(page2, selectedDateISO);
+
+ await page1.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
+ await page2.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
+
+ await selectFirstAvailableTimeSlotNextMonth(page1);
+ await selectFirstAvailableTimeSlotNextMonth(page2);
+
+ const responsePromise1 = page1.waitForResponse(
+ (response) => response.url().includes("/api/book/event") && response.request().method() === "POST"
+ );
+ const responsePromise2 = page2.waitForResponse(
+ (response) => response.url().includes("/api/book/event") && response.request().method() === "POST"
+ );
+
+ const bookingPromise1 = bookTimeSlot(page1, {
+ name: "Test User 1",
+ email: "test1@example.com",
+ });
+ const bookingPromise2 = bookTimeSlot(page2, {
+ name: "Test User 2",
+ email: "test2@example.com",
+ });
+
+ const [response1, response2] = await Promise.all([responsePromise1, responsePromise2]);
+ await Promise.allSettled([bookingPromise1, bookingPromise2]);
+
+ firstResponse = response1;
+ secondResponse = response2;
+
+ await context1.close();
+ await context2.close();
+ });
+
+ return { firstResponse, secondResponse };
+}
+
+async function analyzeBookingResults(
+ eventTypeId: number,
+ firstResponse?: Response,
+ secondResponse?: Response
+) {
+ const bookings = await prisma.booking.findMany({
+ where: { eventTypeId },
+ include: { attendees: true },
+ orderBy: { createdAt: "desc" },
+ take: 10,
+ });
+
+ const responseStatuses = [firstResponse?.status(), secondResponse?.status()].filter(Boolean);
+
+ const recentBookings = bookings.slice(0, 2);
+ const sameHostSelected =
+ recentBookings.length === 2 && recentBookings[0].userId === recentBookings[1].userId;
+
+ let preventionMechanism = "unknown";
+ if (responseStatuses.includes(409)) {
+ preventionMechanism = "database-constraint";
+ } else if (responseStatuses.every((status) => status === 200) && !sameHostSelected) {
+ preventionMechanism = "round-robin-distribution";
+ } else if (sameHostSelected) {
+ preventionMechanism = "race-condition-occurred";
+ }
+
+ const isRaceConditionPrevented = preventionMechanism !== "race-condition-occurred";
+
+ return {
+ bookings,
+ responseStatuses,
+ sameHostSelected,
+ preventionMechanism,
+ isRaceConditionPrevented,
+ };
+}
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 11a55d364c781e..fe8f9ffdd56610 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -871,6 +871,10 @@
"create_team": "Create Team",
"name": "Name",
"nameless_team": "Nameless Team",
+ "oauth_clients": "OAuth Clients",
+ "oauth_clients_description": "Manage OAuth clients for your organization",
+ "create_oauth_client": "Create OAuth Client",
+ "create_oauth_client_description": "Create a new OAuth client for third-party integrations",
"oauth_client_deletion_message": "OAuth client deleted successfully",
"create_new_team_description": "Create a new team to collaborate with users.",
"create_new_team": "Create a new team",
@@ -1836,6 +1840,8 @@
"edit_event_type": "Edit event type",
"only_admin_can_see_members_of_org": "This Organization is private, and only the organization's admin or owner can view its members.",
"only_admin_can_manage_sso_org": "Only the organization's admin or owner can manage SSO settings",
+ "only_admin_can_manage_directory_sync": "Only the organization's admin or owner can manage directory sync settings",
+ "only_admin_can_manage_oauth_clients": "Only the organization's admin or owner can manage OAuth clients",
"collective_scheduling": "Collective Scheduling",
"make_it_easy_to_book": "Make it easy to book your team when everyone is available.",
"find_the_best_person": "Find the best person available and cycle through your team.",
@@ -3302,6 +3308,11 @@
"error_creating_role": "Error creating role",
"error_updating_role": "Error updating role",
"pbac_desc_create_roles": "Create roles",
+ "pbac_resource_attributes": "Attributes",
+ "pbac_desc_view_organization_attributes": "View organization attributes",
+ "pbac_desc_update_organization_attributes": "Update organization attributes",
+ "pbac_desc_delete_organization_attributes": "Delete organization attributes",
+ "pbac_desc_create_organization_attributes": "Create organization attributes",
"pbac_desc_view_roles": "View roles",
"pbac_desc_update_roles": "Update roles",
"pbac_desc_delete_roles": "Delete roles",
diff --git a/apps/web/test/lib/checkBookingLimits.test.ts b/apps/web/test/lib/checkBookingLimits.test.ts
index f7061da0a69f9d..24b2bfa5bc65ff 100644
--- a/apps/web/test/lib/checkBookingLimits.test.ts
+++ b/apps/web/test/lib/checkBookingLimits.test.ts
@@ -3,8 +3,8 @@ import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { describe, expect, it } from "vitest";
import dayjs from "@calcom/dayjs";
+import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/booking-limits";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
-import { checkBookingLimits, checkBookingLimit } from "@calcom/lib/intervalLimits/server/checkBookingLimits";
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
type Mockdata = {
@@ -21,25 +21,27 @@ const MOCK_DATA: Mockdata = {
},
};
+const checkBookingLimitsService = getCheckBookingLimitsService();
+
describe("Check Booking Limits Tests", () => {
it("Should return no errors", async () => {
prismaMock.booking.count.mockResolvedValue(0);
expect(
- checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
+ checkBookingLimitsService.checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
).resolves.toBeTruthy();
});
it("Should throw an error", async () => {
// Mock there being two a day
prismaMock.booking.count.mockResolvedValue(2);
expect(
- checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
+ checkBookingLimitsService.checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
).rejects.toThrowError();
});
it("Should pass with multiple booking limits", async () => {
prismaMock.booking.count.mockResolvedValue(0);
expect(
- checkBookingLimits(
+ checkBookingLimitsService.checkBookingLimits(
{
PER_DAY: 1,
PER_WEEK: 2,
@@ -51,8 +53,9 @@ describe("Check Booking Limits Tests", () => {
});
it("Should pass with multiple booking limits with one undefined", async () => {
prismaMock.booking.count.mockResolvedValue(0);
+
expect(
- checkBookingLimits(
+ checkBookingLimitsService.checkBookingLimits(
{
PER_DAY: 1,
PER_WEEK: undefined,
@@ -65,7 +68,7 @@ describe("Check Booking Limits Tests", () => {
it("Should handle multiple limits correctly", async () => {
prismaMock.booking.count.mockResolvedValue(1);
expect(
- checkBookingLimit({
+ checkBookingLimitsService.checkBookingLimit({
key: "PER_DAY",
limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate,
@@ -74,7 +77,7 @@ describe("Check Booking Limits Tests", () => {
).resolves.not.toThrow();
prismaMock.booking.count.mockResolvedValue(3);
expect(
- checkBookingLimit({
+ checkBookingLimitsService.checkBookingLimit({
key: "PER_WEEK",
limitingNumber: 2,
eventStartDate: MOCK_DATA.startDate,
diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts
index a26d26e72ac99d..b69192904224a2 100644
--- a/apps/web/test/lib/getSchedule.test.ts
+++ b/apps/web/test/lib/getSchedule.test.ts
@@ -1281,12 +1281,15 @@ describe("getSchedule", () => {
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
- CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
- {
- start: `${plus3DateString}T04:00:00.000Z`,
- end: `${plus3DateString}T05:59:59.000Z`,
- },
- ]);
+ CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({
+ success: true,
+ data: [
+ {
+ start: `${plus3DateString}T04:00:00.000Z`,
+ end: `${plus3DateString}T05:59:59.000Z`,
+ },
+ ],
+ });
const scenarioData = {
eventTypes: [
@@ -1347,12 +1350,15 @@ describe("getSchedule", () => {
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
- CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
- {
- start: `${plus3DateString}T04:00:00.000Z`,
- end: `${plus3DateString}T05:59:59.000Z`,
- },
- ]);
+ CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({
+ success: true,
+ data: [
+ {
+ start: `${plus3DateString}T04:00:00.000Z`,
+ end: `${plus3DateString}T05:59:59.000Z`,
+ },
+ ],
+ });
const scenarioData = {
eventTypes: [
@@ -1421,7 +1427,7 @@ describe("getSchedule", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
- CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
+ CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
const scenarioData = {
eventTypes: [
diff --git a/package.json b/package.json
index c1b7805fabf302..5da699585dc675 100644
--- a/package.json
+++ b/package.json
@@ -120,6 +120,7 @@
"@next/third-parties": "^14.2.5",
"@vercel/functions": "^1.4.0",
"city-timezones": "^1.2.1",
+ "date-fns-tz": "^3.2.0",
"eslint": "^8.34.0",
"p-limit": "^6.2.0",
"turbo": "^1.10.1"
diff --git a/packages/app-store/_pages/setup/_getServerSideProps.tsx b/packages/app-store/_pages/setup/_getServerSideProps.tsx
index 36bc532288b6b4..d21415b793c0e1 100644
--- a/packages/app-store/_pages/setup/_getServerSideProps.tsx
+++ b/packages/app-store/_pages/setup/_getServerSideProps.tsx
@@ -6,6 +6,7 @@ export const AppSetupPageMap = {
zapier: import("../../zapier/pages/setup/_getServerSideProps"),
stripe: import("../../stripepayment/pages/setup/_getServerSideProps"),
hitpay: import("../../hitpay/pages/setup/_getServerSideProps"),
+ btcpayserver: import("../../btcpayserver/pages/setup/_getServerSideProps"),
};
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
diff --git a/packages/app-store/_pages/setup/index.tsx b/packages/app-store/_pages/setup/index.tsx
index d7d160b3df16fc..2f57ca3140340c 100644
--- a/packages/app-store/_pages/setup/index.tsx
+++ b/packages/app-store/_pages/setup/index.tsx
@@ -16,6 +16,7 @@ export const AppSetupMap = {
stripe: dynamic(() => import("../../stripepayment/pages/setup")),
paypal: dynamic(() => import("../../paypal/pages/setup")),
hitpay: dynamic(() => import("../../hitpay/pages/setup")),
+ btcpayserver: dynamic(() => import("../../btcpayserver/pages/setup")),
};
export const AppSetupPage = (props: { slug: string }) => {
diff --git a/packages/app-store/_utils/getCalendar.ts b/packages/app-store/_utils/getCalendar.ts
index 399f92ac75b2fc..9fb865c203bd68 100644
--- a/packages/app-store/_utils/getCalendar.ts
+++ b/packages/app-store/_utils/getCalendar.ts
@@ -49,7 +49,6 @@ export const getCalendar = async (
log.warn(`calendar of type ${calendarType} is not implemented`);
return null;
}
- log.info("Got calendarApp", calendarApp.lib.CalendarService);
const CalendarService = calendarApp.lib.CalendarService;
return new CalendarService(credential);
};
diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx
index 01f24bca32499e..8a16b698fb6ffa 100644
--- a/packages/app-store/apps.browser.generated.tsx
+++ b/packages/app-store/apps.browser.generated.tsx
@@ -22,6 +22,7 @@ export const AppSettingsComponentsMap = {
export const EventTypeAddonMap = {
alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")),
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
+ btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppCardInterface")),
closecom: dynamic(() => import("./closecom/components/EventTypeAppCardInterface")),
fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
@@ -54,6 +55,7 @@ export const EventTypeAddonMap = {
export const EventTypeSettingsMap = {
alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")),
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")),
+ btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppSettingsInterface")),
fathom: dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")),
ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")),
giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")),
diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts
index b780d034f134b4..9cf061a4401670 100644
--- a/packages/app-store/apps.keys-schemas.generated.ts
+++ b/packages/app-store/apps.keys-schemas.generated.ts
@@ -4,6 +4,7 @@
**/
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod";
+import { appKeysSchema as btcpayserver_zod_ts } from "./btcpayserver/zod";
import { appKeysSchema as closecom_zod_ts } from "./closecom/zod";
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
import { appKeysSchema as dub_zod_ts } from "./dub/zod";
@@ -54,6 +55,7 @@ import { appKeysSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appKeysSchemas = {
alby: alby_zod_ts,
basecamp3: basecamp3_zod_ts,
+ btcpayserver: btcpayserver_zod_ts,
closecom: closecom_zod_ts,
dailyvideo: dailyvideo_zod_ts,
dub: dub_zod_ts,
diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts
index 9421d124281734..1749ef3c95aa2a 100644
--- a/packages/app-store/apps.metadata.generated.ts
+++ b/packages/app-store/apps.metadata.generated.ts
@@ -10,6 +10,7 @@ import autocheckin_config_json from "./autocheckin/config.json";
import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json";
import basecamp3_config_json from "./basecamp3/config.json";
import bolna_config_json from "./bolna/config.json";
+import btcpayserver_config_json from "./btcpayserver/config.json";
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
import campfire_config_json from "./campfire/config.json";
import chatbase_config_json from "./chatbase/config.json";
@@ -117,6 +118,7 @@ export const appStoreMetadata = {
"baa-for-hipaa": baa_for_hipaa_config_json,
basecamp3: basecamp3_config_json,
bolna: bolna_config_json,
+ btcpayserver: btcpayserver_config_json,
caldavcalendar: caldavcalendar__metadata_ts,
campfire: campfire_config_json,
chatbase: chatbase_config_json,
diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts
index 3970c1a22968cf..f1cad4389f7374 100644
--- a/packages/app-store/apps.schemas.generated.ts
+++ b/packages/app-store/apps.schemas.generated.ts
@@ -4,6 +4,7 @@
**/
import { appDataSchema as alby_zod_ts } from "./alby/zod";
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod";
+import { appDataSchema as btcpayserver_zod_ts } from "./btcpayserver/zod";
import { appDataSchema as closecom_zod_ts } from "./closecom/zod";
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
import { appDataSchema as dub_zod_ts } from "./dub/zod";
@@ -54,6 +55,7 @@ import { appDataSchema as zoomvideo_zod_ts } from "./zoomvideo/zod";
export const appDataSchemas = {
alby: alby_zod_ts,
basecamp3: basecamp3_zod_ts,
+ btcpayserver: btcpayserver_zod_ts,
closecom: closecom_zod_ts,
dailyvideo: dailyvideo_zod_ts,
dub: dub_zod_ts,
diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts
index d3d54d7c894d85..de8307de54f744 100644
--- a/packages/app-store/apps.server.generated.ts
+++ b/packages/app-store/apps.server.generated.ts
@@ -11,6 +11,7 @@ export const apiHandlers = {
"baa-for-hipaa": import("./baa-for-hipaa/api"),
basecamp3: import("./basecamp3/api"),
bolna: import("./bolna/api"),
+ btcpayserver: import("./btcpayserver/api"),
caldavcalendar: import("./caldavcalendar/api"),
campfire: import("./campfire/api"),
chatbase: import("./chatbase/api"),
diff --git a/packages/app-store/btcpayserver/DESCRIPTION.md b/packages/app-store/btcpayserver/DESCRIPTION.md
new file mode 100644
index 00000000000000..f0c5e3587c57fe
--- /dev/null
+++ b/packages/app-store/btcpayserver/DESCRIPTION.md
@@ -0,0 +1,8 @@
+---
+items:
+ - website.png
+ - integrations.png
+ - checkout.png
+---
+
+{DESCRIPTION}
diff --git a/packages/app-store/btcpayserver/api/add.ts b/packages/app-store/btcpayserver/api/add.ts
new file mode 100644
index 00000000000000..8456c349cf5cda
--- /dev/null
+++ b/packages/app-store/btcpayserver/api/add.ts
@@ -0,0 +1,19 @@
+import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
+
+import { createDefaultInstallation } from "../../_utils/installation";
+import appConfig from "../config.json";
+
+const handler: AppDeclarativeHandler = {
+ appType: appConfig.type,
+ variant: appConfig.variant,
+ slug: appConfig.slug,
+ supportsMultipleInstalls: false,
+ handlerType: "add",
+ redirect: {
+ url: "/apps/btcpayserver/setup",
+ },
+ createCredential: ({ appType, user, slug, teamId }) =>
+ createDefaultInstallation({ appType, user: user, slug, key: {}, teamId }),
+};
+
+export default handler;
diff --git a/packages/app-store/btcpayserver/api/index.ts b/packages/app-store/btcpayserver/api/index.ts
new file mode 100644
index 00000000000000..b4b88a12a7920a
--- /dev/null
+++ b/packages/app-store/btcpayserver/api/index.ts
@@ -0,0 +1,2 @@
+export { default as add } from "./add";
+export { default as webhook, config } from "./webhook";
diff --git a/packages/app-store/btcpayserver/api/webhook.ts b/packages/app-store/btcpayserver/api/webhook.ts
new file mode 100644
index 00000000000000..4236f018df1f35
--- /dev/null
+++ b/packages/app-store/btcpayserver/api/webhook.ts
@@ -0,0 +1,100 @@
+import crypto from "crypto";
+import type { NextApiRequest, NextApiResponse } from "next";
+import getRawBody from "raw-body";
+import { z } from "zod";
+
+import { IS_PRODUCTION } from "@calcom/lib/constants";
+import { getErrorFromUnknown } from "@calcom/lib/errors";
+import { HttpError as HttpCode } from "@calcom/lib/http-error";
+import { handlePaymentSuccess } from "@calcom/lib/payment/handlePaymentSuccess";
+import { PrismaBookingPaymentRepository as BookingPaymentRepository } from "@calcom/lib/server/repository/PrismaBookingPaymentRepository";
+
+import appConfig from "../config.json";
+import { btcpayCredentialKeysSchema } from "../lib/btcpayCredentialKeysSchema";
+
+export const config = { api: { bodyParser: false } };
+
+function verifyBTCPaySignature(rawBody: Buffer, expectedSignature: string, webhookSecret: string): string {
+ const hmac = crypto.createHmac("sha256", webhookSecret);
+ hmac.update(rawBody);
+ const computedSignature = hmac.digest("hex");
+ const hexRegex = /^[0-9a-fA-F]+$/;
+ if (!hexRegex.test(computedSignature) || !hexRegex.test(expectedSignature)) {
+ throw new HttpCode({ statusCode: 400, message: "signature mismatch" });
+ }
+ return computedSignature;
+}
+
+const btcpayWebhookSchema = z.object({
+ deliveryId: z.string(),
+ webhookId: z.string(),
+ originalDeliveryId: z.string().optional(),
+ isRedelivery: z.boolean(),
+ type: z.string(),
+ timestamp: z.number(),
+ storeId: z.string(),
+ invoiceId: z.string(),
+ metadata: z.object({}).optional(),
+ manuallyMarked: z.boolean().optional(),
+ overPaid: z.boolean(),
+});
+const SUPPORTED_INVOICE_EVENTS = ["InvoiceSettled", "InvoiceProcessing"];
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ try {
+ if (req.method !== "POST") throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
+ const rawBody = await getRawBody(req);
+ const bodyAsString = rawBody.toString();
+
+ const signature = req.headers["btcpay-sig"] || req.headers["BTCPay-Sig"];
+ if (!signature || typeof signature !== "string" || !signature.startsWith("sha256="))
+ throw new HttpCode({ statusCode: 401, message: "Missing or invalid signature format" });
+
+ const webhookData = btcpayWebhookSchema.safeParse(JSON.parse(bodyAsString));
+ if (!webhookData.success) return res.status(400).json({ message: "Invalid webhook payload" });
+
+ const data = webhookData.data;
+ if (!SUPPORTED_INVOICE_EVENTS.includes(data.type))
+ return res.status(200).send({ message: "Webhook received but ignored" });
+
+ const bookingPaymentRepository = new BookingPaymentRepository();
+ const payment = await bookingPaymentRepository.findByExternalIdIncludeBookingUserCredentials(
+ data.invoiceId,
+ appConfig.type
+ );
+ if (!payment) throw new HttpCode({ statusCode: 404, message: "Cal.com: payment not found" });
+ if (payment.success) return res.status(200).send({ message: "Payment already registered" });
+ const key = payment.booking?.user?.credentials?.[0].key;
+ if (!key) throw new HttpCode({ statusCode: 404, message: "Cal.com: credentials not found" });
+
+ const parsedKey = btcpayCredentialKeysSchema.safeParse(key);
+ if (!parsedKey.success)
+ throw new HttpCode({ statusCode: 400, message: "Cal.com: Invalid BTCPay credentials" });
+
+ const { webhookSecret, storeId } = parsedKey.data;
+ if (storeId !== data.storeId)
+ throw new HttpCode({ statusCode: 400, message: "Cal.com: Store ID mismatch" });
+
+ const expectedSignature = signature.split("=")[1];
+ const computedSignature = verifyBTCPaySignature(rawBody, expectedSignature, webhookSecret);
+
+ if (computedSignature.length !== expectedSignature.length) {
+ throw new HttpCode({ statusCode: 400, message: "signature mismatch" });
+ }
+ const isValid = crypto.timingSafeEqual(
+ Buffer.from(computedSignature, "hex"),
+ Buffer.from(expectedSignature, "hex")
+ );
+ if (!isValid) throw new HttpCode({ statusCode: 400, message: "signature mismatch" });
+
+ await handlePaymentSuccess(payment.id, payment.bookingId);
+ return res.status(200).json({ success: true });
+ } catch (_err) {
+ const err = getErrorFromUnknown(_err);
+ const statusCode = err instanceof HttpCode ? err.statusCode : 500;
+ return res.status(statusCode).send({
+ message: err.message,
+ stack: IS_PRODUCTION ? undefined : err.stack,
+ });
+ }
+}
diff --git a/packages/app-store/btcpayserver/components/BtcpayPaymentComponent.tsx b/packages/app-store/btcpayserver/components/BtcpayPaymentComponent.tsx
new file mode 100644
index 00000000000000..1a07443012e67e
--- /dev/null
+++ b/packages/app-store/btcpayserver/components/BtcpayPaymentComponent.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import z from "zod";
+
+import type { PaymentPageProps } from "@calcom/features/ee/payments/pages/payment";
+import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
+import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
+import { useCopy } from "@calcom/lib/hooks/useCopy";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc";
+import { Button } from "@calcom/ui/components/button";
+import { Spinner } from "@calcom/ui/components/icon";
+import { showToast } from "@calcom/ui/components/toast";
+
+interface IPaymentComponentProps {
+ payment: {
+ // Will be parsed on render
+ data: unknown;
+ };
+ paymentPageProps: PaymentPageProps;
+}
+
+// Create zod schema for data
+const PaymentBTCPayDataSchema = z.object({
+ invoice: z.object({ checkoutLink: z.string() }).required(),
+});
+
+export const BtcpayPaymentComponent = (props: IPaymentComponentProps) => {
+ const { payment } = props;
+ const { data } = payment;
+ const [iframeLoaded, setIframeLoaded] = useState(false);
+ const { copyToClipboard, isCopied } = useCopy();
+ const wrongUrl = (
+ <>
+ Couldn't obtain payment URL
+ >
+ );
+
+ const parsedData = PaymentBTCPayDataSchema.safeParse(data);
+ if (!parsedData.success || !parsedData.data?.invoice?.checkoutLink) return wrongUrl;
+ const checkoutUrl = parsedData.data.invoice.checkoutLink;
+ const handleOpenInNewTab = () => {
+ window.open(checkoutUrl, "_blank", "noopener,noreferrer");
+ };
+
+ return (
+
+
+
+ {!iframeLoaded && (
+
+
+
Loading payment page...
+
+ )}
+
+
+
+
+
+ copyToClipboard(checkoutUrl)}
+ className="text-subtle rounded-md"
+ StartIcon={isCopied ? "clipboard-check" : "clipboard"}>
+ Copy Payment Link
+
+
+ Open in New Tab
+
+
+ );
+};
+
+type PaymentCheckerProps = PaymentPageProps;
+
+function PaymentChecker(props: PaymentCheckerProps) {
+ // TODO: move booking success code to a common lib function
+ // TODO: subscribe rather than polling
+ const searchParams = useCompatSearchParams();
+ const bookingSuccessRedirect = useBookingSuccessRedirect();
+ const utils = trpc.useUtils();
+ const { t } = useLocale();
+
+ useEffect(() => {
+ if (searchParams === null) {
+ return;
+ }
+
+ // use closure to ensure non-nullability
+ const sp = searchParams;
+ const interval = setInterval(() => {
+ (async () => {
+ try {
+ if (props.booking.status === "ACCEPTED") {
+ return;
+ }
+ const { booking: bookingResult } = await utils.viewer.bookings.find.fetch({
+ bookingUid: props.booking.uid,
+ });
+
+ if (bookingResult?.paid) {
+ showToast("Payment successful", "success");
+
+ const params: {
+ uid: string;
+ email: string | null;
+ location: string;
+ } = {
+ uid: props.booking.uid,
+ email: sp.get("email"),
+ location: t("web_conferencing_details_to_follow"),
+ };
+
+ bookingSuccessRedirect({
+ successRedirectUrl: props.eventType.successRedirectUrl,
+ query: params,
+ booking: props.booking,
+ forwardParamsSuccessRedirect: props.eventType.forwardParamsSuccessRedirect,
+ });
+ }
+ } catch (e) {}
+ })();
+ }, 2000);
+
+ return () => clearInterval(interval);
+ }, [
+ bookingSuccessRedirect,
+ props.booking,
+ props.booking.id,
+ props.booking.status,
+ props.eventType.id,
+ props.eventType.successRedirectUrl,
+ props.eventType.forwardParamsSuccessRedirect,
+ props.payment.success,
+ searchParams,
+ t,
+ utils.viewer.bookings,
+ ]);
+
+ return null;
+}
diff --git a/packages/app-store/btcpayserver/components/EventTypeAppCardInterface.tsx b/packages/app-store/btcpayserver/components/EventTypeAppCardInterface.tsx
new file mode 100644
index 00000000000000..97d94727224b31
--- /dev/null
+++ b/packages/app-store/btcpayserver/components/EventTypeAppCardInterface.tsx
@@ -0,0 +1,50 @@
+import { usePathname } from "next/navigation";
+import { useState } from "react";
+
+import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
+import AppCard from "@calcom/app-store/_components/AppCard";
+import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+
+import checkForMultiplePaymentApps from "../../_utils/payments/checkForMultiplePaymentApps";
+import type { appDataSchema } from "../zod";
+import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface";
+
+type Option = { value: string; label: string };
+
+const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
+ eventType,
+ app,
+ eventTypeFormMetadata,
+}) {
+ const { t } = useLocale();
+ const pathname = usePathname();
+ const { getAppData, setAppData, disabled } = useAppContextWithSchema();
+ const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
+ const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata);
+ const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled;
+
+ return (
+ {
+ setRequirePayment(e);
+ }}
+ description={<>Add lightning payments to your events and booking>}
+ disableSwitch={shouldDisableSwitch}
+ switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}>
+
+
+ );
+};
+
+export default EventTypeAppCard;
diff --git a/packages/app-store/btcpayserver/components/EventTypeAppSettingsInterface.tsx b/packages/app-store/btcpayserver/components/EventTypeAppSettingsInterface.tsx
new file mode 100644
index 00000000000000..9db586113c8f40
--- /dev/null
+++ b/packages/app-store/btcpayserver/components/EventTypeAppSettingsInterface.tsx
@@ -0,0 +1,134 @@
+import { useState, useEffect } from "react";
+
+import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Alert } from "@calcom/ui/components/alert";
+import { Select } from "@calcom/ui/components/form";
+import { TextField } from "@calcom/ui/components/form";
+
+import {
+ currencyOptions,
+ convertToSmallestCurrencyUnit,
+ convertFromSmallestToPresentableCurrencyUnit,
+} from "../lib/currencyOptions";
+import { BTCPayPaymentOptions as paymentOptions } from "../zod";
+
+type Option = { value: string; label: string };
+
+const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
+ eventType,
+ getAppData,
+ setAppData,
+}) => {
+ const { t } = useLocale();
+ const price = getAppData("price");
+ const currency = getAppData("currency") || (currencyOptions.length > 0 ? currencyOptions[0].value : "");
+ const [selectedCurrency, setSelectedCurrency] = useState(
+ currencyOptions.find((c) => c.value === currency) ||
+ (currencyOptions.length > 0
+ ? {
+ label: currencyOptions[0].label,
+ value: currencyOptions[0].value,
+ }
+ : null)
+ );
+
+ const paymentOption = getAppData("paymentOption");
+ const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
+ label: paymentOptions.length > 0 ? paymentOptions[0].label : "",
+ value: paymentOptions.length > 0 ? paymentOptions[0].value : "",
+ };
+ const seatsEnabled = !!eventType.seatsPerTimeSlot;
+ const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
+ const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
+
+ // make sure a currency is selected
+ useEffect(() => {
+ if (requirePayment && !getAppData("currency")) {
+ setAppData("currency", currencyOptions[0].value);
+ }
+ }, [requirePayment, getAppData, setAppData]);
+
+ const disableDecimalPlace = (value: number) => {
+ return Math.floor(value);
+ };
+
+ return (
+ <>
+ {recurringEventDefined ? (
+
+ ) : (
+ requirePayment && (
+ <>
+
+ {
+ setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency));
+ }}
+ value={
+ price && price > 0
+ ? disableDecimalPlace(convertFromSmallestToPresentableCurrencyUnit(price, currency))
+ : undefined
+ }
+ />
+
+
+
+
+ {t("currency")}
+
+ {
+ if (e) {
+ setSelectedCurrency(e);
+ setAppData("currency", e.value);
+ }
+ }}
+ />
+
+
+
+
+ {t("payment_option")}
+
+
+ defaultValue={
+ paymentOptionSelectValue
+ ? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
+ : paymentOptions.length > 0
+ ? { ...paymentOptions[0], label: t(paymentOptions[0].label) }
+ : undefined
+ }
+ options={paymentOptions.map((option) => {
+ return { ...option, label: t(option.label) || option.label };
+ })}
+ onChange={(input) => {
+ if (input) setAppData("paymentOption", input.value);
+ }}
+ className="mb-1 h-[38px] w-full"
+ isDisabled={seatsEnabled}
+ />
+
+ {seatsEnabled && paymentOption === "HOLD" && (
+
+ )}
+ >
+ )
+ )}
+ >
+ );
+};
+
+export default EventTypeAppSettingsInterface;
diff --git a/packages/app-store/btcpayserver/components/KeyInput.tsx b/packages/app-store/btcpayserver/components/KeyInput.tsx
new file mode 100644
index 00000000000000..e28084995067b4
--- /dev/null
+++ b/packages/app-store/btcpayserver/components/KeyInput.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import classNames from "classnames";
+import type { FormEvent } from "react";
+import React, { forwardRef, useState, useEffect, useId, useCallback } from "react";
+
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Label } from "@calcom/ui/components/form";
+import { Input } from "@calcom/ui/components/form";
+import type { InputFieldProps } from "@calcom/ui/components/form";
+import { Icon } from "@calcom/ui/components/icon";
+import { Skeleton } from "@calcom/ui/components/skeleton";
+
+type AddonProps = {
+ children: React.ReactNode;
+ className?: string;
+ error?: boolean;
+ onClickAddon?: () => void;
+};
+
+const Addon = ({ children, className, error }: AddonProps) => (
+
+);
+
+export const KeyField: React.FC = forwardRef<
+ HTMLInputElement,
+ InputFieldProps & { defaultValue: string }
+>(function KeyField(props, ref) {
+ const id = useId();
+ const [isPasswordVisible, setIsPasswordVisible] = useState(false);
+ const [currentValue, setCurrentValue] = useState("");
+ const toggleIsPasswordVisible = useCallback(
+ () => setIsPasswordVisible(!isPasswordVisible),
+ [isPasswordVisible, setIsPasswordVisible]
+ );
+
+ const { t: _t, isLocaleReady, i18n } = useLocale();
+ const t = props.t || _t;
+ const name = props.name || "";
+ const {
+ label = t(name),
+ labelProps,
+ labelClassName,
+ LockedIcon,
+ placeholder = isLocaleReady && i18n.exists(`${name}_placeholder`) ? t(`${name}_placeholder`) : "",
+ className,
+ addOnLeading,
+ addOnClassname,
+ inputIsFullWidth,
+ labelSrOnly,
+ noLabel,
+ containerClassName,
+ readOnly,
+ showAsteriskIndicator,
+ defaultValue,
+ ...passThrough
+ } = props;
+
+ useEffect(() => {
+ if (currentValue.trim().length === 0) {
+ setIsPasswordVisible(true);
+ }
+ }, [currentValue]);
+
+ useEffect(() => {
+ setCurrentValue(defaultValue);
+ if (defaultValue.length > 0) {
+ setIsPasswordVisible(false);
+ }
+ }, [defaultValue]);
+
+ const getHiddenKey = (): string => {
+ let hiddenKey = currentValue;
+ const length = currentValue.length;
+ if (length > 6) {
+ const start = currentValue.slice(0, 3);
+ const end = currentValue.slice(length - 3);
+ hiddenKey = `${start}${"*".repeat(length - 6)}${end}`;
+ }
+
+ return hiddenKey;
+ };
+
+ const onInput = (event: FormEvent) => {
+ const target = event.target as HTMLInputElement;
+ const fullValue = target.value;
+ setCurrentValue(fullValue);
+ target.value = fullValue;
+ };
+
+ return (
+
+ {!!label && !noLabel && (
+
+ {label}
+ {showAsteriskIndicator && !readOnly && passThrough.required ? (
+ *
+ ) : null}
+ {LockedIcon}
+
+ )}
+
+
+
+ );
+});
+
+export default KeyField;
diff --git a/packages/app-store/btcpayserver/config.json b/packages/app-store/btcpayserver/config.json
new file mode 100644
index 00000000000000..bb96e7a7a48693
--- /dev/null
+++ b/packages/app-store/btcpayserver/config.json
@@ -0,0 +1,16 @@
+{
+ "name": "BTCPayServer",
+ "slug": "btcpayserver",
+ "type": "btcpayserver_payment",
+ "logo": "icon.svg",
+ "url": "https://btcpayserver.org",
+ "variant": "payment",
+ "categories": ["payment"],
+ "publisher": "BTCPay Server Team",
+ "email": "chat.btcpayserver.org",
+ "description": "BTCPay Server is a self-hosted open source Bitcoin payment processor. Start receiving bitcoin payments for your events and bookings.",
+ "extendsFeature": "EventType",
+ "isTemplate": false,
+ "__createdUsingCli": true,
+ "__template": "event-type-app-card"
+}
diff --git a/packages/app-store/btcpayserver/index.ts b/packages/app-store/btcpayserver/index.ts
new file mode 100644
index 00000000000000..e2e9d7b029c031
--- /dev/null
+++ b/packages/app-store/btcpayserver/index.ts
@@ -0,0 +1,2 @@
+export * as api from "./api";
+export * as lib from "./lib";
diff --git a/packages/app-store/btcpayserver/lib/PaymentService.ts b/packages/app-store/btcpayserver/lib/PaymentService.ts
new file mode 100644
index 00000000000000..7b59e63f82bbe3
--- /dev/null
+++ b/packages/app-store/btcpayserver/lib/PaymentService.ts
@@ -0,0 +1,204 @@
+import type { Booking, Payment, PaymentOption, Prisma } from "@prisma/client";
+import { v4 as uuidv4 } from "uuid";
+import type z from "zod";
+
+import { ErrorCode } from "@calcom/lib/errorCodes";
+import logger from "@calcom/lib/logger";
+import { safeStringify } from "@calcom/lib/safeStringify";
+import type { IBookingPaymentRepository } from "@calcom/lib/server/repository/BookingPaymentRepository.interface";
+import { PrismaBookingPaymentRepository } from "@calcom/lib/server/repository/PrismaBookingPaymentRepository";
+import type { CalendarEvent } from "@calcom/types/Calendar";
+import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
+
+import appConfig from "../config.json";
+import { btcpayCredentialKeysSchema } from "./btcpayCredentialKeysSchema";
+import { convertFromSmallestToPresentableCurrencyUnit } from "./currencyOptions";
+
+const log = logger.getSubLogger({ prefix: ["payment-service:btcpayserver"] });
+
+interface BTCPayInvoice {
+ id: string;
+ checkoutLink: string;
+ status: string;
+ amount: string;
+ currency: string;
+ createdTime: number;
+ expirationTime: number;
+ metadata?: Record;
+ checkout?: Record;
+ receipt?: Record;
+ payments?: Array<{
+ id: string;
+ amount: string;
+ paymentMethod: string;
+ }>;
+ [key: string]: any;
+}
+
+export class PaymentService implements IAbstractPaymentService {
+ private credentials: z.infer | null;
+ private bookingPaymentRepository: IBookingPaymentRepository;
+
+ constructor(
+ credentials: { key: Prisma.JsonValue },
+ bookingPaymentRepository: IBookingPaymentRepository = new PrismaBookingPaymentRepository()
+ ) {
+ const keyParsing = btcpayCredentialKeysSchema.safeParse(credentials.key);
+ if (keyParsing.success) {
+ this.credentials = keyParsing.data;
+ } else {
+ this.credentials = null;
+ }
+ this.bookingPaymentRepository = bookingPaymentRepository;
+ }
+
+ private async BTCPayApiCall(endpoint: string, options: RequestInit = {}) {
+ if (!this.credentials) throw new Error("BTCPay server credentials not found");
+
+ const serverUrl = this.credentials.serverUrl.endsWith("/")
+ ? this.credentials.serverUrl.slice(0, -1)
+ : this.credentials.serverUrl;
+ const url = `${serverUrl}${endpoint}`;
+ const headers = {
+ Authorization: `token ${this.credentials.apiKey}`,
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ ...options.headers,
+ };
+
+ try {
+ const response = await fetch(url, { ...options, headers });
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`BTCPay server API error (${response.status}): ${errorText}`);
+ }
+ return await response.json();
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ async create(
+ payment: Pick,
+ bookingId: Booking["id"],
+ userId: Booking["userId"],
+ username: string | null,
+ bookerName: string,
+ paymentOption: PaymentOption,
+ bookerEmail: string,
+ bookerPhoneNumber?: string | null,
+ eventTitle?: string,
+ bookingTitle?: string
+ ) {
+ try {
+ if (!this.credentials?.storeId) {
+ throw new Error("BTCPay server: Store ID not found");
+ }
+
+ const uid = uuidv4();
+ const invoiceRequest = {
+ metadata: {
+ orderId: `cal-booking-${bookingId}`,
+ itemDesc: bookingTitle || `Booking #${bookingId}`,
+ appId: "cal.com",
+ referenceId: uid,
+ customerName: bookerName,
+ customerEmail: bookerEmail,
+ bookingDescription: bookingTitle || `Booking with ${bookerName}`,
+ },
+ checkout: {
+ buyerEmail: bookerEmail,
+ },
+ receipt: {
+ enabled: true,
+ },
+ amount: convertFromSmallestToPresentableCurrencyUnit(payment.amount, payment.currency),
+ currency: payment.currency === "BTC" ? "SATS" : payment.currency,
+ additionalSearchTerms: [`cal-booking-${bookingId}`, bookerName, bookerEmail],
+ };
+ const invoiceResponse = (await this.BTCPayApiCall(
+ `/api/v1/stores/${this.credentials.storeId}/invoices`,
+ { method: "POST", body: JSON.stringify(invoiceRequest) }
+ )) as BTCPayInvoice;
+
+ const paymentData = await this.bookingPaymentRepository.createPaymentRecord({
+ uid,
+ app: { connect: { slug: appConfig.slug } },
+ booking: { connect: { id: bookingId } },
+ amount: payment.amount,
+ externalId: invoiceResponse.id,
+ currency: payment.currency,
+ fee: 0,
+ success: false,
+ refunded: false,
+ data: Object.assign(
+ {},
+ {
+ invoice: {
+ ...invoiceResponse,
+ isPaid: false,
+ attendee: { name: bookerName, email: bookerEmail },
+ },
+ }
+ ),
+ });
+ if (!paymentData) throw new Error("Failed to store Payment data");
+ return paymentData;
+ } catch (error) {
+ log.error("BTCPay server: Payment could not be created", bookingId, safeStringify(error));
+ throw new Error(ErrorCode.PaymentCreationFailure);
+ }
+ }
+
+ async update(): Promise {
+ throw new Error("Method not implemented.");
+ }
+ async refund(): Promise {
+ throw new Error("BTCPay Server does not support automatic refunds for Bitcoin payments");
+ }
+
+ async collectCard(
+ _payment: Pick,
+ _bookingId: number,
+ _bookerEmail: string,
+ _paymentOption: PaymentOption
+ ): Promise {
+ throw new Error("Method not implemented");
+ }
+
+ chargeCard(
+ _payment: Pick,
+ _bookingId: number
+ ): Promise {
+ throw new Error("Method not implemented.");
+ }
+
+ async getPaymentPaidStatus(): Promise {
+ throw new Error("Method not implemented.");
+ }
+
+ async getPaymentDetails(): Promise {
+ throw new Error("Method not implemented.");
+ }
+
+ async afterPayment(
+ _event: CalendarEvent,
+ _booking: {
+ user: { email: string | null; name: string | null; timeZone: string } | null;
+ id: number;
+ startTime: { toISOString: () => string };
+ uid: string;
+ },
+ _paymentData: Payment
+ ): Promise {
+ return Promise.resolve();
+ }
+
+ deletePayment(_paymentId: number): Promise {
+ return Promise.resolve(false);
+ }
+
+ isSetupAlready(): boolean {
+ return !!this.credentials;
+ }
+}
diff --git a/packages/app-store/btcpayserver/lib/btcpayCredentialKeysSchema.ts b/packages/app-store/btcpayserver/lib/btcpayCredentialKeysSchema.ts
new file mode 100644
index 00000000000000..fb74f8a63b516d
--- /dev/null
+++ b/packages/app-store/btcpayserver/lib/btcpayCredentialKeysSchema.ts
@@ -0,0 +1,8 @@
+import z from "zod";
+
+export const btcpayCredentialKeysSchema = z.object({
+ serverUrl: z.string().url(),
+ storeId: z.string(),
+ apiKey: z.string(),
+ webhookSecret: z.string(),
+});
diff --git a/packages/app-store/btcpayserver/lib/currencyOptions.ts b/packages/app-store/btcpayserver/lib/currencyOptions.ts
new file mode 100644
index 00000000000000..266dbc46b1b7bc
--- /dev/null
+++ b/packages/app-store/btcpayserver/lib/currencyOptions.ts
@@ -0,0 +1,20 @@
+export const currencyOptions = [
+ { label: "SATS", value: "BTC", unit: "SATS" },
+ { label: "USD", value: "USD", unit: "USD" },
+];
+
+const zeroDecimalCurrencies = ["SATS", "BTC"];
+
+export const convertToSmallestCurrencyUnit = (amount: number, currency: string) => {
+ if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
+ return amount;
+ }
+ return Math.round(amount * 100);
+};
+
+export const convertFromSmallestToPresentableCurrencyUnit = (amount: number, currency: string) => {
+ if (zeroDecimalCurrencies.includes(currency.toUpperCase())) {
+ return amount;
+ }
+ return amount / 100;
+};
diff --git a/packages/app-store/btcpayserver/lib/index.ts b/packages/app-store/btcpayserver/lib/index.ts
new file mode 100644
index 00000000000000..30894fcab96cfb
--- /dev/null
+++ b/packages/app-store/btcpayserver/lib/index.ts
@@ -0,0 +1,2 @@
+export * from "./PaymentService";
+export * from "./btcpayCredentialKeysSchema";
diff --git a/packages/app-store/btcpayserver/package.json b/packages/app-store/btcpayserver/package.json
new file mode 100644
index 00000000000000..1b1394a748b5a3
--- /dev/null
+++ b/packages/app-store/btcpayserver/package.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "private": true,
+ "name": "@calcom/btcpayserver",
+ "version": "0.0.0",
+ "main": "./index.ts",
+ "dependencies": {
+ "@calcom/lib": "*"
+ },
+ "devDependencies": {
+ "@calcom/types": "*"
+ },
+ "description": "BTCPay Server is a self-hosted open source Bitcoin payment processor. Start receiving bitcoin payments for your events and bookings."
+}
diff --git a/packages/app-store/btcpayserver/pages/setup/_getServerSideProps.tsx b/packages/app-store/btcpayserver/pages/setup/_getServerSideProps.tsx
new file mode 100644
index 00000000000000..796eb62c7971d7
--- /dev/null
+++ b/packages/app-store/btcpayserver/pages/setup/_getServerSideProps.tsx
@@ -0,0 +1,36 @@
+import type { GetServerSidePropsContext } from "next";
+
+import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { CredentialRepository } from "@calcom/lib/server/repository/credential";
+
+import { btcpayCredentialKeysSchema } from "../../lib/btcpayCredentialKeysSchema";
+import type { IBTCPaySetupProps } from "./index";
+
+export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
+ try {
+ const notFound = { notFound: true } as const;
+ if (typeof ctx.params?.slug !== "string") return notFound;
+
+ const { req } = ctx;
+ const session = await getServerSession({ req });
+ if (!session?.user?.id) return { redirect: { permanent: false, destination: "/auth/login" } };
+
+ const credential = await CredentialRepository.findFirstByUserIdAndType({
+ userId: session.user.id,
+ type: "btcpayserver_payment",
+ });
+
+ let props: IBTCPaySetupProps | undefined;
+ if (credential?.key) {
+ const keyParsing = btcpayCredentialKeysSchema.safeParse(credential.key);
+ if (keyParsing.success) {
+ props = keyParsing.data;
+ }
+ }
+ return { props: props ?? {} };
+ } catch (error) {
+ return {
+ props: {},
+ };
+ }
+};
diff --git a/packages/app-store/btcpayserver/pages/setup/index.tsx b/packages/app-store/btcpayserver/pages/setup/index.tsx
new file mode 100644
index 00000000000000..da867304dda810
--- /dev/null
+++ b/packages/app-store/btcpayserver/pages/setup/index.tsx
@@ -0,0 +1,354 @@
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useSession } from "next-auth/react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useState, useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { Toaster } from "sonner";
+import { z } from "zod";
+
+import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage";
+import { WEBAPP_URL } from "@calcom/lib/constants";
+import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc";
+import { Button } from "@calcom/ui/components/button";
+import { Icon } from "@calcom/ui/components/icon";
+import { showToast } from "@calcom/ui/components/toast";
+
+import KeyField from "../../components/KeyInput";
+import { btcpayCredentialKeysSchema } from "../../lib/btcpayCredentialKeysSchema";
+
+export type IBTCPaySetupProps = z.infer;
+
+export default function BTCPaySetup(props: IBTCPaySetupProps) {
+ const params = useCompatSearchParams();
+ if (params?.get("callback") === "true") {
+ return ;
+ }
+ return ;
+}
+
+enum BTCPayOAuthError {
+ Declined = "declined",
+ Unknown = "unknown",
+}
+
+function BTCPaySetupCallback() {
+ const [error, setError] = useState(null);
+ const searchParams = useCompatSearchParams();
+
+ useEffect(() => {
+ if (!searchParams) {
+ return;
+ }
+ if (!window.opener) {
+ setError("Something went wrong. Opener not available. Please contact support");
+ return;
+ }
+ const code = searchParams?.get("code");
+ const error = searchParams?.get("error");
+
+ if (!code) {
+ setError(BTCPayOAuthError.Declined);
+ }
+ if (error) {
+ setError(error);
+ return;
+ }
+
+ window.opener.postMessage({
+ type: "btcpayserver:oauth:success",
+ payload: { code },
+ });
+ window.close();
+ }, [searchParams]);
+
+ return (
+
+ {error &&
Authorization failed: {error}
}
+ {!error &&
Connecting...
}
+
+ );
+}
+
+function BTCPaySetupPage(props: IBTCPaySetupProps) {
+ const router = useRouter();
+ const { t } = useLocale();
+ const session = useSession();
+ const [loading, setLoading] = useState(false);
+ const [validating, setValidating] = useState(false);
+ const [updatable, setUpdatable] = useState(false);
+ const [keyData, setKeyData] = useState<
+ | {
+ storeId: string;
+ serverUrl: string;
+ apiKey: string;
+ webhookSecret: string;
+ }
+ | undefined
+ >();
+ const settingsSchema = z.object({
+ storeId: z.string().trim(),
+ serverUrl: z.string().trim(),
+ apiKey: z.string().trim(),
+ webhookSecret: z.string().optional(),
+ });
+ const integrations = trpc.viewer.apps.integrations.useQuery({ variant: "payment", appId: "btcpayserver" });
+ const [btcPayPaymentAppCredentials] = integrations.data?.items || [];
+ const [credentialId] = btcPayPaymentAppCredentials?.userCredentialIds || [-1];
+ const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
+
+ const saveKeysMutation = trpc.viewer.apps.updateAppCredentials.useMutation({
+ onSuccess: () => {
+ showToast(t("keys_have_been_saved"), "success");
+ router.push("/event-types");
+ },
+ onError: (error) => {
+ showToast(error.message, "error");
+ },
+ });
+ const deleteMutation = trpc.viewer.credentials.delete.useMutation({
+ onSuccess: () => {
+ router.push("/apps/btcpayserver");
+ },
+ onError: () => {
+ showToast(t("error_removing_app"), "error");
+ },
+ });
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ watch,
+ reset,
+ } = useForm>({
+ reValidateMode: "onChange",
+ resolver: zodResolver(settingsSchema),
+ });
+
+ useEffect(() => {
+ const _keyData = {
+ storeId: props?.storeId || "",
+ serverUrl: props?.serverUrl || "",
+ apiKey: props?.apiKey || "",
+ webhookSecret: props?.webhookSecret || "",
+ };
+ setKeyData(_keyData);
+ }, [props]);
+
+ useEffect(() => {
+ const subscription = watch((value) => {
+ const { serverUrl, storeId, apiKey, webhookSecret } = value;
+ if (
+ serverUrl &&
+ storeId &&
+ apiKey &&
+ (keyData?.serverUrl !== serverUrl || keyData?.storeId !== storeId || keyData?.apiKey !== apiKey)
+ ) {
+ setUpdatable(true);
+ } else {
+ setUpdatable(false);
+ }
+ });
+ return () => subscription.unsubscribe();
+ }, [watch, keyData]);
+
+ const configureBTCPayWebhook = async (data: z.infer) => {
+ setValidating(true);
+ const specificEvents = ["InvoiceSettled", "InvoiceProcessing"];
+ const serverUrl = data.serverUrl.endsWith("/") ? data.serverUrl.slice(0, -1) : data.serverUrl;
+ const endpoint = `${serverUrl}/api/v1/stores/${data.storeId}/webhooks`;
+ const webhookUrl = `${WEBAPP_URL}/api/integrations/btcpayserver/webhook`;
+ const requestBody = {
+ enabled: true,
+ automaticRedelivery: false,
+ url: webhookUrl,
+ authorizedEvents: {
+ everything: false,
+ specificEvents: specificEvents,
+ },
+ secret: null,
+ };
+
+ try {
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `token ${data.apiKey}`,
+ },
+ body: JSON.stringify(requestBody),
+ });
+ if (!response.ok) {
+ const errorBody = await response.text();
+ showToast(`Failed to configure webhook: ${errorBody}`, "error");
+ return false;
+ }
+ const webhookResponse = await response.json();
+ saveKeysMutation.mutate({
+ credentialId,
+ key: btcpayCredentialKeysSchema.parse({
+ ...data,
+ webhookSecret: webhookResponse.secret,
+ }),
+ });
+ return true;
+ } catch (error) {
+ if (error instanceof Error) {
+ showToast(error.message || "Failed to configure BTCPay webhook", "error");
+ } else {
+ showToast("An unknown error occurred while configuring BTCPay webhook", "error");
+ }
+ return false;
+ } finally {
+ setValidating(false);
+ }
+ };
+
+ const onSubmit = handleSubmit(async (data) => {
+ if (loading) return;
+ setLoading(true);
+
+ try {
+ const isValid = await configureBTCPayWebhook(data);
+ if (!isValid) {
+ setLoading(false);
+ return;
+ }
+ } catch (error: unknown) {
+ let message = "";
+ if (error instanceof Error) {
+ message = error.message;
+ }
+ showToast(message, "error");
+ } finally {
+ setLoading(false);
+ }
+ });
+
+ const onCancel = () => {
+ deleteMutation.mutate({ id: credentialId });
+ };
+
+ const btcpayIcon = (
+ <>
+
+ >
+ );
+
+ if (session.status === "loading") return <>>;
+
+ if (integrations.isPending) {
+ return
;
+ }
+
+ const isNewCredential = !props.serverUrl && !props.storeId && !props.webhookSecret && !props.apiKey;
+ const webhookUri = `${WEBAPP_URL}/api/integrations/btcpayserver/webhook`;
+
+ return (
+ <>
+
+ {showContent ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+}
diff --git a/packages/app-store/btcpayserver/static/checkout.png b/packages/app-store/btcpayserver/static/checkout.png
new file mode 100644
index 00000000000000..866ab9e8227b96
Binary files /dev/null and b/packages/app-store/btcpayserver/static/checkout.png differ
diff --git a/packages/app-store/btcpayserver/static/icon.svg b/packages/app-store/btcpayserver/static/icon.svg
new file mode 100644
index 00000000000000..7c7fcf6237bea9
--- /dev/null
+++ b/packages/app-store/btcpayserver/static/icon.svg
@@ -0,0 +1,97 @@
+
+image/svg+xml
diff --git a/packages/app-store/btcpayserver/static/integrations.png b/packages/app-store/btcpayserver/static/integrations.png
new file mode 100644
index 00000000000000..057d24b41ea50b
Binary files /dev/null and b/packages/app-store/btcpayserver/static/integrations.png differ
diff --git a/packages/app-store/btcpayserver/static/website.png b/packages/app-store/btcpayserver/static/website.png
new file mode 100644
index 00000000000000..f7ab97d66e1c7d
Binary files /dev/null and b/packages/app-store/btcpayserver/static/website.png differ
diff --git a/packages/app-store/btcpayserver/zod.ts b/packages/app-store/btcpayserver/zod.ts
new file mode 100644
index 00000000000000..69ebb6bcaf0701
--- /dev/null
+++ b/packages/app-store/btcpayserver/zod.ts
@@ -0,0 +1,36 @@
+import { z } from "zod";
+
+import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
+
+const paymentOptionSchema = z.object({
+ label: z.string(),
+ value: z.string(),
+});
+
+export const paymentOptionsSchema = z.array(paymentOptionSchema);
+
+export const BTCPayPaymentOptions = [
+ {
+ label: "on_booking_option",
+ value: "ON_BOOKING",
+ },
+];
+
+type PaymentOption = (typeof BTCPayPaymentOptions)[number]["value"];
+const VALUES: [PaymentOption, ...PaymentOption[]] = [
+ BTCPayPaymentOptions[0].value,
+ ...BTCPayPaymentOptions.slice(1).map((option) => option.value),
+];
+export const paymentOptionEnum = z.enum(VALUES);
+
+export const appDataSchema = eventTypeAppCardZod.merge(
+ z.object({
+ price: z.number(),
+ currency: z.string(),
+ paymentOption: z.string().optional(),
+ enabled: z.boolean().optional(),
+ credentialId: z.number().optional(),
+ })
+);
+
+export const appKeysSchema = z.object({});
diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts
index 031681ce0eaf1d..8ddea439c5fe0d 100644
--- a/packages/app-store/index.ts
+++ b/packages/app-store/index.ts
@@ -42,6 +42,7 @@ const appStore = {
telegramvideo: createCachedImport(() => import("./telegram")),
shimmervideo: createCachedImport(() => import("./shimmervideo")),
hitpay: createCachedImport(() => import("./hitpay")),
+ btcpayserver: createCachedImport(() => import("./btcpayserver")),
};
function createCachedImport(importFunc: () => Promise): () => Promise {
diff --git a/packages/app-store/paypal/pages/setup/index.tsx b/packages/app-store/paypal/pages/setup/index.tsx
index 5717ab385c6437..99398f019a82a1 100644
--- a/packages/app-store/paypal/pages/setup/index.tsx
+++ b/packages/app-store/paypal/pages/setup/index.tsx
@@ -5,8 +5,8 @@ import { Toaster } from "sonner";
import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
-import { TextField } from "@calcom/ui/components/form";
import { Button } from "@calcom/ui/components/button";
+import { TextField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
diff --git a/packages/app-store/salesforce/lib/CrmService.ts b/packages/app-store/salesforce/lib/CrmService.ts
index 6e789474b5ab01..68c334464512a0 100644
--- a/packages/app-store/salesforce/lib/CrmService.ts
+++ b/packages/app-store/salesforce/lib/CrmService.ts
@@ -9,8 +9,8 @@ import { RetryableError } from "@calcom/lib/crmManager/errors";
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
+import { PrismaAssignmentReasonRepository } from "@calcom/lib/server/repository/PrismaAssignmentReasonRepository";
import { PrismaRoutingFormResponseRepository as RoutingFormResponseRepository } from "@calcom/lib/server/repository/PrismaRoutingFormResponseRepository";
-import { AssignmentReasonRepository } from "@calcom/lib/server/repository/assignmentReason";
import { RoutingFormResponseDataFactory } from "@calcom/lib/server/service/routingForm/RoutingFormResponseDataFactory";
import { findFieldValueByIdentifier } from "@calcom/lib/server/service/routingForm/responseData/findFieldValueByIdentifier";
import { prisma } from "@calcom/prisma";
@@ -1342,7 +1342,7 @@ export default class SalesforceCRMService implements CRM {
}
private async getAssignmentReason(bookingId: string) {
- const assignmentReason = await AssignmentReasonRepository.findLatestReasonFromBookingUid(bookingId);
+ const assignmentReason = await PrismaAssignmentReasonRepository.findLatestReasonFromBookingUid(bookingId);
return assignmentReason?.reasonString ?? "";
}
diff --git a/packages/features/bookings/Booker/components/DatePicker.tsx b/packages/features/bookings/Booker/components/DatePicker.tsx
index 544d0d929e4069..1ed5f7fe396fe1 100644
--- a/packages/features/bookings/Booker/components/DatePicker.tsx
+++ b/packages/features/bookings/Booker/components/DatePicker.tsx
@@ -74,7 +74,10 @@ export const DatePicker = ({
scrollToTimeSlots?: () => void;
}) => {
const { i18n } = useLocale();
- const [month, selectedDate] = useBookerStore((state) => [state.month, state.selectedDate], shallow);
+ const [month, selectedDate, layout] = useBookerStore(
+ (state) => [state.month, state.selectedDate, state.layout],
+ shallow
+ );
const [setSelectedDate, setMonth, setDayCount] = useBookerStore(
(state) => [state.setSelectedDate, state.setMonth, state.setDayCount],
@@ -83,7 +86,7 @@ export const DatePicker = ({
const onMonthChange = (date: Dayjs) => {
setMonth(date.format("YYYY-MM"));
- setSelectedDate(date.format("YYYY-MM-DD"));
+ setSelectedDate({ date: date.format("YYYY-MM-DD") });
setDayCount(null); // Whenever the month is changed, we nullify getting X days
};
@@ -98,6 +101,9 @@ export const DatePicker = ({
});
moveToNextMonthOnNoAvailability();
+ // Determine if this is a compact sidebar view based on layout
+ const isCompact = layout !== "month_view";
+
const periodData: PeriodData = {
...{
periodType: "UNLIMITED",
@@ -126,7 +132,11 @@ export const DatePicker = ({
className={classNames?.datePickerContainer}
isLoading={isLoading}
onChange={(date: Dayjs | null, omitUpdatingParams?: boolean) => {
- setSelectedDate(date === null ? date : date.format("YYYY-MM-DD"), omitUpdatingParams);
+ setSelectedDate({
+ date: date === null ? date : date.format("YYYY-MM-DD"),
+ omitUpdatingParams,
+ preventMonthSwitching: !isCompact, // Prevent month switching when in monthly view
+ });
}}
onMonthChange={onMonthChange}
includedDates={nonEmptyScheduleDays}
@@ -137,6 +147,7 @@ export const DatePicker = ({
slots={slots}
scrollToTimeSlots={scrollToTimeSlots}
periodData={periodData}
+ isCompact={isCompact}
/>
);
};
diff --git a/packages/features/bookings/Booker/components/Header.tsx b/packages/features/bookings/Booker/components/Header.tsx
index b76ac83e0a8cc9..238bc3228132d3 100644
--- a/packages/features/bookings/Booker/components/Header.tsx
+++ b/packages/features/bookings/Booker/components/Header.tsx
@@ -130,7 +130,7 @@ export function Header({
setSelectedDate(today.format("YYYY-MM-DD"))}>
+ onClick={() => setSelectedDate({ date: today.format("YYYY-MM-DD") })}>
{t("today")}
)}
diff --git a/packages/features/bookings/Booker/store.ts b/packages/features/bookings/Booker/store.ts
index 3f7409caa17224..0a9eb641295e1e 100644
--- a/packages/features/bookings/Booker/store.ts
+++ b/packages/features/bookings/Booker/store.ts
@@ -83,7 +83,11 @@ export type BookerStore = {
* Date selected by user (exact day). Format is YYYY-MM-DD.
*/
selectedDate: string | null;
- setSelectedDate: (date: string | null, omitUpdatingParams?: boolean) => void;
+ setSelectedDate: (params: {
+ date: string | null;
+ omitUpdatingParams?: boolean;
+ preventMonthSwitching?: boolean;
+ }) => void;
addToSelectedDate: (days: number) => void;
/**
* Multiple Selected Dates and Times
@@ -192,7 +196,7 @@ export const useBookerStore = createWithEqualityFn((set, get) => ({
return set({ layout });
},
selectedDate: getQueryParam("date") || null,
- setSelectedDate: (selectedDate: string | null, omitUpdatingParams = false) => {
+ setSelectedDate: ({ date: selectedDate, omitUpdatingParams = false, preventMonthSwitching = false }) => {
// unset selected date
if (!selectedDate) {
removeQueryParam("date");
@@ -207,7 +211,8 @@ export const useBookerStore = createWithEqualityFn((set, get) => ({
}
// Setting month make sure small calendar in fullscreen layouts also updates.
- if (newSelection.month() !== currentSelection.month()) {
+ // preventMonthSwitching is true in monthly view
+ if (!preventMonthSwitching && newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) {
updateQueryParam("month", newSelection.format("YYYY-MM"));
@@ -264,7 +269,7 @@ export const useBookerStore = createWithEqualityFn((set, get) => ({
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("month", month ?? "");
}
- get().setSelectedDate(null);
+ get().setSelectedDate({ date: null });
},
dayCount: BOOKER_NUMBER_OF_DAYS_TO_LOAD > 0 ? BOOKER_NUMBER_OF_DAYS_TO_LOAD : null,
setDayCount: (dayCount: number | null) => {
diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts
index 0a031843960f8c..109028e358764a 100644
--- a/packages/features/bookings/lib/handleNewBooking.ts
+++ b/packages/features/bookings/lib/handleNewBooking.ts
@@ -51,6 +51,7 @@ import {
enrichHostsWithDelegationCredentials,
getFirstDelegationConferencingCredentialAppLocation,
} from "@calcom/lib/delegationCredential/server";
+import { getCheckBookingAndDurationLimitsService } from "@calcom/lib/di/containers/booking-limits";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
@@ -90,7 +91,6 @@ import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCr
import getBookingDataSchema from "./getBookingDataSchema";
import { addVideoCallDataToEvent } from "./handleNewBooking/addVideoCallDataToEvent";
import { checkActiveBookingsLimitForBooker } from "./handleNewBooking/checkActiveBookingsLimitForBooker";
-import { checkBookingAndDurationLimits } from "./handleNewBooking/checkBookingAndDurationLimits";
import { checkIfBookerEmailIsBlocked } from "./handleNewBooking/checkIfBookerEmailIsBlocked";
import { createBooking } from "./handleNewBooking/createBooking";
import type { Booking } from "./handleNewBooking/createBooking";
@@ -650,7 +650,8 @@ async function handler(
location,
});
- await checkBookingAndDurationLimits({
+ const checkBookingAndDurationLimitsService = getCheckBookingAndDurationLimitsService();
+ await checkBookingAndDurationLimitsService.checkBookingAndDurationLimits({
eventType,
reqBodyStart: reqBody.start,
reqBodyRescheduleUid: reqBody.rescheduleUid,
@@ -658,6 +659,7 @@ async function handler(
let luckyUserResponse;
let isFirstSeat = true;
+ let availableUsers: IsFixedAwareUser[] = [];
if (eventType.seatsPerTimeSlot) {
const booking = await prisma.booking.findFirst({
@@ -757,7 +759,6 @@ async function handler(
}
if (!input.bookingData.allRecurringDates || input.bookingData.isFirstRecurringSlot) {
- let availableUsers: IsFixedAwareUser[] = [];
try {
availableUsers = await ensureAvailableUsers(
{ ...eventTypeWithUsers, users: [...qualifiedRRUsers, ...fixedUsers] as IsFixedAwareUser[] },
@@ -1391,6 +1392,12 @@ async function handler(
if (booking?.userId) {
const usersRepository = new UsersRepository();
await usersRepository.updateLastActiveAt(booking.userId);
+ const organizerUserAvailability = availableUsers.find((user) => user.id === booking?.userId);
+
+ logger.info(`Booking created`, {
+ bookingUid: booking.uid,
+ availabilitySnapshot: organizerUserAvailability?.availabilityData,
+ });
}
// If it's a round robin event, record the reason for the host assignment
diff --git a/packages/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits.ts b/packages/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits.ts
index 7963c3b5f60902..fc7775d1bef1db 100644
--- a/packages/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits.ts
+++ b/packages/features/bookings/lib/handleNewBooking/checkBookingAndDurationLimits.ts
@@ -1,6 +1,6 @@
import dayjs from "@calcom/dayjs";
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
-import { checkBookingLimits } from "@calcom/lib/intervalLimits/server/checkBookingLimits";
+import type { CheckBookingLimitsService } from "@calcom/lib/intervalLimits/server/checkBookingLimits";
import { checkDurationLimits } from "@calcom/lib/intervalLimits/server/checkDurationLimits";
import { withReporting } from "@calcom/lib/sentryWrapper";
@@ -14,37 +14,41 @@ type InputProps = {
reqBodyRescheduleUid?: string;
};
-const _checkBookingAndDurationLimits = async ({
- eventType,
- reqBodyStart,
- reqBodyRescheduleUid,
-}: InputProps) => {
- if (
- Object.prototype.hasOwnProperty.call(eventType, "bookingLimits") ||
- Object.prototype.hasOwnProperty.call(eventType, "durationLimits")
- ) {
- const startAsDate = dayjs(reqBodyStart).toDate();
- if (eventType.bookingLimits && Object.keys(eventType.bookingLimits).length > 0) {
- await checkBookingLimits(
- eventType.bookingLimits as IntervalLimit,
- startAsDate,
- eventType.id,
- reqBodyRescheduleUid,
- eventType.schedule?.timeZone
- );
- }
- if (eventType.durationLimits) {
- await checkDurationLimits(
- eventType.durationLimits as IntervalLimit,
- startAsDate,
- eventType.id,
- reqBodyRescheduleUid
- );
+export interface ICheckBookingAndDurationLimitsService {
+ checkBookingLimitsService: CheckBookingLimitsService;
+}
+
+export class CheckBookingAndDurationLimitsService {
+ constructor(private readonly dependencies: ICheckBookingAndDurationLimitsService) {}
+
+ checkBookingAndDurationLimits = withReporting(
+ this._checkBookingAndDurationLimits.bind(this),
+ "checkBookingAndDurationLimits"
+ );
+
+ async _checkBookingAndDurationLimits({ eventType, reqBodyStart, reqBodyRescheduleUid }: InputProps) {
+ if (
+ Object.prototype.hasOwnProperty.call(eventType, "bookingLimits") ||
+ Object.prototype.hasOwnProperty.call(eventType, "durationLimits")
+ ) {
+ const startAsDate = dayjs(reqBodyStart).toDate();
+ if (eventType.bookingLimits && Object.keys(eventType.bookingLimits).length > 0) {
+ await this.dependencies.checkBookingLimitsService.checkBookingLimits(
+ eventType.bookingLimits as IntervalLimit,
+ startAsDate,
+ eventType.id,
+ reqBodyRescheduleUid,
+ eventType.schedule?.timeZone
+ );
+ }
+ if (eventType.durationLimits) {
+ await checkDurationLimits(
+ eventType.durationLimits as IntervalLimit,
+ startAsDate,
+ eventType.id,
+ reqBodyRescheduleUid
+ );
+ }
}
}
-};
-
-export const checkBookingAndDurationLimits = withReporting(
- _checkBookingAndDurationLimits,
- "checkBookingAndDurationLimits"
-);
+}
diff --git a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts
index 6c9a85b2d5366c..439257c3a09242 100644
--- a/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts
+++ b/packages/features/bookings/lib/handleNewBooking/ensureAvailableUsers.ts
@@ -99,6 +99,7 @@ const _ensureAvailableUsers = async (
afterEventBuffer: eventType.afterEventBuffer,
bypassBusyCalendarTimes: false,
shouldServeCache,
+ withSource: true,
},
initialData: {
eventType,
@@ -210,7 +211,8 @@ const _ensureAvailableUsers = async (
}
}
- usersAvailability.forEach(({ oooExcludedDateRanges: dateRanges, busy: bufferedBusyTimes }, index) => {
+ usersAvailability.forEach((userAvailability, index) => {
+ const { oooExcludedDateRanges: dateRanges, busy: bufferedBusyTimes } = userAvailability;
const user = eventType.users[index];
loggerWithEventDetails.debug(
@@ -220,7 +222,7 @@ const _ensureAvailableUsers = async (
if (!dateRanges.length) {
loggerWithEventDetails.error(
- `User does not have availability at this time.`,
+ `User ${user.id} does not have availability at this time.`,
piiFreeInputDataForLogging
);
return;
@@ -239,7 +241,7 @@ const _ensureAvailableUsers = async (
eventLength: duration,
});
if (!foundConflict) {
- availableUsers.push(user);
+ availableUsers.push({ ...user, availabilityData: userAvailability });
}
} catch (error) {
loggerWithEventDetails.error("Unable set isAvailableToBeBooked. Using true. ", error);
diff --git a/packages/features/bookings/lib/handleNewBooking/types.ts b/packages/features/bookings/lib/handleNewBooking/types.ts
index 83f40475bcd4c5..5456ccfece89ed 100644
--- a/packages/features/bookings/lib/handleNewBooking/types.ts
+++ b/packages/features/bookings/lib/handleNewBooking/types.ts
@@ -4,6 +4,7 @@ import type { TFunction } from "i18next";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import type { PaymentAppData } from "@calcom/lib/getPaymentAppData";
+import type { GetUserAvailabilityResult } from "@calcom/lib/getUserAvailability";
import type { userSelect } from "@calcom/prisma";
import type { SelectedCalendar } from "@calcom/prisma/client";
import type { CredentialForCalendarService } from "@calcom/types/Credential";
@@ -40,6 +41,7 @@ export type IsFixedAwareUser = User & {
weight?: number;
userLevelSelectedCalendars: SelectedCalendar[];
allSelectedCalendars: SelectedCalendar[];
+ availabilityData?: GetUserAvailabilityResult;
};
export type { PaymentAppData };
diff --git a/packages/features/calendars/DatePicker.tsx b/packages/features/calendars/DatePicker.tsx
index 97c981b9bc9162..644361d72414a0 100644
--- a/packages/features/calendars/DatePicker.tsx
+++ b/packages/features/calendars/DatePicker.tsx
@@ -14,6 +14,7 @@ import type { PeriodData } from "@calcom/types/Event";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { SkeletonText } from "@calcom/ui/components/skeleton";
+import { Tooltip } from "@calcom/ui/components/tooltip";
import NoAvailabilityDialog from "./NoAvailabilityDialog";
@@ -56,6 +57,8 @@ export type DatePickerProps = {
}[]
>;
periodData?: PeriodData;
+ // Whether this is a compact sidebar view or main monthly view
+ isCompact?: boolean;
};
const Day = ({
@@ -65,6 +68,8 @@ const Day = ({
away,
emoji,
customClassName,
+ showMonthTooltip,
+ isFirstDayOfNextMonth,
...props
}: JSX.IntrinsicElements["button"] & {
active: boolean;
@@ -75,12 +80,14 @@ const Day = ({
dayContainer?: string;
dayActive?: string;
};
+ showMonthTooltip?: boolean;
+ isFirstDayOfNextMonth?: boolean;
}) => {
const { t } = useLocale();
const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton");
const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton");
- return (
+ const buttonContent = (
);
+
+ const content = showMonthTooltip ? (
+ {buttonContent}
+ ) : (
+ buttonContent
+ );
+
+ return (
+ <>
+ {isFirstDayOfNextMonth && (
+
+ {date.format("MMM")}
+
+ )}
+ {content}
+ >
+ );
};
const Days = ({
@@ -129,6 +163,7 @@ const Days = ({
customClassName,
isBookingInPast,
periodData,
+ isCompact,
...props
}: Omit & {
DayComponent?: React.FC>;
@@ -143,20 +178,48 @@ const Days = ({
scrollToTimeSlots?: () => void;
isBookingInPast: boolean;
periodData: PeriodData;
+ isCompact?: boolean;
}) => {
- // Create placeholder elements for empty days in first week
- const weekdayOfFirst = browsingDate.date(1).day();
-
const includedDates = getAvailableDatesInMonth({
browsingDate: browsingDate.toDate(),
minDate,
includedDates: props.includedDates,
});
- const days: (Dayjs | null)[] = Array((weekdayOfFirst - weekStart + 7) % 7).fill(null);
- for (let day = 1, dayCount = daysInMonth(browsingDate); day <= dayCount; day++) {
- const date = browsingDate.set("date", day);
- days.push(date);
+ const today = dayjs();
+ const firstDayOfMonth = browsingDate.startOf("month");
+ const isSecondWeekOver = today.isAfter(firstDayOfMonth.add(2, "week"));
+ let days: (Dayjs | null)[] = [];
+
+ const getPadding = (day: number) => (browsingDate.set("date", day).day() - weekStart + 7) % 7;
+ const totalDays = daysInMonth(browsingDate);
+
+ // Only apply end-of-month logic for main monthly view (not compact sidebar)
+ if (isSecondWeekOver && !isCompact) {
+ const startDay = 8;
+ const pad = getPadding(startDay);
+ days = Array(pad).fill(null);
+
+ for (let day = startDay; day <= totalDays; day++) {
+ days.push(browsingDate.set("date", day));
+ }
+
+ const remainingInRow = days.length % 7;
+ const extraDays = (remainingInRow > 0 ? 7 - remainingInRow : 0) + 7;
+ const nextMonth = browsingDate.add(1, "month");
+
+ // Add days starting from day 1 of next month
+ for (let i = 0; i < extraDays; i++) {
+ days.push(nextMonth.set("date", 1 + i));
+ }
+ } else {
+ // Traditional calendar grid logic for compact sidebar or early in month
+ const pad = getPadding(1);
+ days = Array(pad).fill(null);
+
+ for (let day = 1; day <= totalDays; day++) {
+ days.push(browsingDate.set("date", day));
+ }
}
const [selectedDatesAndTimes] = useBookerStore((state) => [state.selectedDatesAndTimes], shallow);
@@ -188,20 +251,29 @@ const Days = ({
const daysToRenderForTheMonth = days.map((day) => {
if (!day) return { day: null, disabled: true };
+
const dateKey = yyyymmdd(day);
- const oooInfo = slots && slots?.[dateKey] ? slots?.[dateKey]?.find((slot) => slot.away) : null;
+ const daySlots = slots?.[dateKey] || [];
+ const oooInfo = daySlots.find((slot) => slot.away) || null;
+
+ const isNextMonth = day.month() !== browsingDate.month();
+ const isFirstDayOfNextMonth = isSecondWeekOver && !isCompact && isNextMonth && day.date() === 1;
+
const included = includedDates?.includes(dateKey);
const excluded = excludedDates.includes(dateKey);
- const isOOOAllDay = !!(slots && slots[dateKey] && slots[dateKey].every((slot) => slot.away));
+ const hasAvailableSlots = daySlots.some((slot) => !slot.away);
+ const isOOOAllDay = daySlots.length > 0 && daySlots.every((slot) => slot.away);
const away = isOOOAllDay;
- const disabled = away ? !oooInfo?.toUser : !included || excluded;
+
+ const disabled = away ? !oooInfo?.toUser : isNextMonth ? !hasAvailableSlots : !included || excluded;
return {
- day: day,
+ day,
disabled,
away,
emoji: oooInfo?.emoji,
+ isFirstDayOfNextMonth,
};
});
@@ -239,7 +311,7 @@ const Days = ({
return (
<>
- {daysToRenderForTheMonth.map(({ day, disabled, away, emoji }, idx) => (
+ {daysToRenderForTheMonth.map(({ day, disabled, away, emoji, isFirstDayOfNextMonth }, idx) => (
{day === null ? (
@@ -265,6 +337,8 @@ const Days = ({
active={isActive(day)}
away={away}
emoji={emoji}
+ showMonthTooltip={isSecondWeekOver && !isCompact}
+ isFirstDayOfNextMonth={isFirstDayOfNextMonth}
/>
)}
@@ -297,6 +371,7 @@ const DatePicker = ({
periodDays: null,
periodType: "UNLIMITED",
},
+ isCompact,
...passThroughProps
}: DatePickerProps &
Partial> & {
@@ -406,6 +481,7 @@ const DatePicker = ({
includedDates={includedDates}
isBookingInPast={isBookingInPast}
periodData={periodData}
+ isCompact={isCompact}
/>
diff --git a/packages/features/calendars/__tests__/DatePicker.test.tsx b/packages/features/calendars/__tests__/DatePicker.test.tsx
index 634064a56e2019..4fa6791f6eb4bd 100644
--- a/packages/features/calendars/__tests__/DatePicker.test.tsx
+++ b/packages/features/calendars/__tests__/DatePicker.test.tsx
@@ -1,4 +1,7 @@
+import { TooltipProvider } from "@radix-ui/react-tooltip";
import { render } from "@testing-library/react";
+import React from "react";
+import { vi } from "vitest";
import dayjs from "@calcom/dayjs";
import { PeriodType } from "@calcom/prisma/enums";
@@ -13,18 +16,20 @@ describe("Tests for DatePicker Component", () => {
test("Should render correctly with default date", async () => {
const testDate = dayjs("2024-02-20");
const { getByTestId } = render(
-
+
+
+
);
const selectedMonthLabel = getByTestId("selected-month-label");
@@ -35,7 +40,9 @@ describe("Tests for DatePicker Component", () => {
const testDate = dayjs("2024-02-20");
const minDate = dayjs("2025-02-10");
const { getByTestId } = render(
-
+
+
+
);
const selectedMonthLabel = getByTestId("selected-month-label");
@@ -46,10 +53,141 @@ describe("Tests for DatePicker Component", () => {
const testDate = dayjs("2025-03-20");
const minDate = dayjs("2025-02-10");
const { getByTestId } = render(
-
+
+
+
);
const selectedMonthLabel = getByTestId("selected-month-label");
await expect(selectedMonthLabel).toHaveAttribute("dateTime", testDate.format("YYYY-MM"));
});
+
+ describe("End-of-Month UI Improvements", () => {
+ const createMockSlots = (dates: string[]) => {
+ const slots: Record = {};
+ dates.forEach((date) => {
+ slots[date] = [{ time: `${date}T10:00:00` }];
+ });
+ return slots;
+ };
+
+ test("Should show traditional calendar view before second week of month", async () => {
+ // Set test date to early in month (January 10th, 2024)
+ const earlyMonthDate = dayjs("2024-01-10");
+
+ // Mock current date to also be early in month so isSecondWeekOver is false
+ vi.useFakeTimers();
+ vi.setSystemTime(earlyMonthDate.toDate());
+
+ const slots = createMockSlots([
+ "2024-01-15", // Available date in current month
+ "2024-01-20",
+ ]);
+
+ const { getAllByTestId } = render(
+
+
+
+ );
+
+ const dayElements = getAllByTestId("day");
+
+ // Should show full month starting from day 1
+ const firstAvailableDay = dayElements.find((day) => day.textContent && day.textContent.trim() !== "");
+ expect(firstAvailableDay?.textContent).toBe("1");
+
+ vi.useRealTimers();
+ });
+
+ test("Should show end-of-month view after second week (monthly view)", async () => {
+ // Mock current date to ensure we're after second week
+ const mockDate = dayjs("2024-01-20");
+ vi.useFakeTimers();
+ vi.setSystemTime(mockDate.toDate());
+
+ const lateMonthDate = dayjs("2024-01-20");
+
+ const slots = createMockSlots([
+ "2024-01-25", // Available in current month
+ "2024-02-01", // Available in next month
+ "2024-02-05",
+ ]);
+
+ const { getAllByTestId, queryByText } = render(
+
+
+
+ );
+
+ const dayElements = getAllByTestId("day");
+
+ const firstAvailableDay = dayElements.find((day) => day.textContent && day.textContent.trim() !== "");
+
+ // Should show days from day 8 onwards of current month (the main change in end-of-month view)
+ expect(firstAvailableDay?.textContent).toBe("8");
+
+ // Should show next month days (February days when browsing January)
+ // In end-of-month view, the first day of next month gets a month label
+ const febLabel = queryByText("Feb");
+ expect(febLabel).toBeTruthy();
+
+ vi.useRealTimers();
+ });
+
+ test("Should show traditional view when compact=true (not monthly view) even after second week", async () => {
+ const lateMonthDate = dayjs("2024-01-20");
+ const slots = createMockSlots(["2024-01-25", "2024-02-01"]);
+
+ const { getAllByTestId } = render(
+
+
+
+ );
+
+ const dayElements = getAllByTestId("day");
+
+ // Should show day 1 even in compact mode after second week
+ const firstDayOfMonth = dayElements.find((day) => day.textContent === "1");
+ expect(firstDayOfMonth).toBeTruthy();
+ });
+ });
});
diff --git a/packages/features/credentials/deleteCredential.test.ts b/packages/features/credentials/deleteCredential.test.ts
index 09524ee22acbdf..c30570bc78f316 100644
--- a/packages/features/credentials/deleteCredential.test.ts
+++ b/packages/features/credentials/deleteCredential.test.ts
@@ -5,7 +5,7 @@ import {
import { describe, test, expect, beforeEach } from "vitest";
-import { AppRepository } from "@calcom/lib/server/repository/app";
+import { PrismaAppRepository } from "@calcom/lib/server/repository/PrismaAppRepository";
import { CredentialRepository } from "@calcom/lib/server/repository/credential";
import { DestinationCalendarRepository } from "@calcom/lib/server/repository/destinationCalendar";
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
@@ -56,7 +56,7 @@ describe("deleteCredential", () => {
},
]);
- await AppRepository.seedApp("zoomvideo");
+ await PrismaAppRepository.seedApp("zoomvideo");
await setupCredential({ userId: user.id, type: "zoom_video", appId: "zoom" });
@@ -87,7 +87,7 @@ describe("deleteCredential", () => {
},
]);
- await AppRepository.seedApp("googlecalendar");
+ await PrismaAppRepository.seedApp("googlecalendar");
const credential = await setupCredential({
userId: user.id,
diff --git a/packages/features/ee/dsync/page/team-dsync-view.tsx b/packages/features/ee/dsync/page/team-dsync-view.tsx
index c8a453ba2e1a8c..bf8202f3af2e25 100644
--- a/packages/features/ee/dsync/page/team-dsync-view.tsx
+++ b/packages/features/ee/dsync/page/team-dsync-view.tsx
@@ -12,7 +12,7 @@ import { showToast } from "@calcom/ui/components/toast";
import ConfigureDirectorySync from "../components/ConfigureDirectorySync";
// For Hosted Cal - Team view
-const DirectorySync = () => {
+const DirectorySync = ({ permissions }: { permissions?: { canEdit: boolean } }) => {
const { t } = useLocale();
const router = useRouter();
@@ -36,6 +36,10 @@ const DirectorySync = () => {
showToast(error.message, "error");
}
+ if (!permissions?.canEdit) {
+ router.push("/404");
+ }
+
return (
{HOSTED_CAL_FEATURES && }
diff --git a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts
index f32cdfed2faade..f13fd58ea00df8 100644
--- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts
+++ b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.test.ts
@@ -348,6 +348,34 @@ describe("createOrganizationFromOnboarding", () => {
);
});
+ it("should invite members with isDirectUserAction set to false", async () => {
+ const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
+ user: {
+ username: "org-owner",
+ email: "owner@example.com",
+ },
+ });
+
+ // Call createOrganizationFromOnboarding
+ const result = await createOrganizationFromOnboarding({
+ organizationOnboarding,
+ paymentSubscriptionId: "sub_123",
+ paymentSubscriptionItemId: "si_123",
+ });
+
+ // Verify inviteMembersWithNoInviterPermissionCheck was called with isDirectUserAction: false
+ expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
+ expect.objectContaining({
+ teamId: result.organization.id,
+ invitations: [
+ { usernameOrEmail: "member1@example.com", role: MembershipRole.MEMBER },
+ { usernameOrEmail: "member2@example.com", role: MembershipRole.MEMBER },
+ ],
+ isDirectUserAction: false,
+ })
+ );
+ });
+
it("should update stripe customer ID for existing user", async () => {
const { user: existingUser, organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
user: {
diff --git a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts
index 5708d581df2822..9aec6570e569e7 100644
--- a/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts
+++ b/packages/features/ee/organizations/lib/server/createOrganizationFromOnboarding.ts
@@ -305,6 +305,7 @@ async function inviteMembers(invitedMembers: InvitedMember[], organization: Team
usernameOrEmail: member.email,
role: MembershipRole.MEMBER,
})),
+ isDirectUserAction: false,
});
}
@@ -548,8 +549,17 @@ export const createOrganizationFromOnboarding = async ({
} catch (error) {
// Almost always the reason would be that the organization's slug conflicts with a team's slug
// The owner might not have chosen the conflicting team for migration - Can be confirmed by checking `teams` column in the database.
- log.error("RecoverableError: Error while setting slug for organization", safeStringify(error));
- throw new Error("Unable to set slug for organization");
+ log.error(
+ "RecoverableError: Error while setting slug for organization",
+ safeStringify(error),
+ safeStringify({
+ attemptedSlug: organizationOnboarding.slug,
+ organizationId: organization.id,
+ })
+ );
+ throw new Error(
+ `Unable to set slug '${organizationOnboarding.slug}' for organization ${organization.id}`
+ );
}
}
diff --git a/packages/features/ee/organizations/pages/components/DisablePhoneOnlySMSNotificationsSwitch.tsx b/packages/features/ee/organizations/pages/components/DisablePhoneOnlySMSNotificationsSwitch.tsx
index 9e499828e8debd..b1852f86857098 100644
--- a/packages/features/ee/organizations/pages/components/DisablePhoneOnlySMSNotificationsSwitch.tsx
+++ b/packages/features/ee/organizations/pages/components/DisablePhoneOnlySMSNotificationsSwitch.tsx
@@ -10,10 +10,9 @@ import { showToast } from "@calcom/ui/components/toast";
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
- isAdminOrOwner: boolean;
}
-export const DisablePhoneOnlySMSNotificationsSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewProps) => {
+export const DisablePhoneOnlySMSNotificationsSwitch = ({ currentOrg }: GeneralViewProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [disablePhoneOnlySMSNotificationsActive, setDisablePhoneOnlySMSNotificationsActive] = useState(
@@ -32,8 +31,6 @@ export const DisablePhoneOnlySMSNotificationsSwitch = ({ currentOrg, isAdminOrOw
},
});
- if (!isAdminOrOwner) return null;
-
return (
<>
{
+export const LockEventTypeSwitch = ({ currentOrg }: GeneralViewProps) => {
const [lockEventTypeCreationForUsers, setLockEventTypeCreationForUsers] = useState(
!!currentOrg.organizationSettings.lockEventTypeCreationForUsers
);
@@ -50,8 +49,6 @@ export const LockEventTypeSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewP
},
});
- if (!isAdminOrOwner) return null;
-
const currentLockedOption = formMethods.watch("currentEventTypeOptions");
const { reset, getValues } = formMethods;
@@ -69,7 +66,7 @@ export const LockEventTypeSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewP
{
@@ -124,9 +121,7 @@ export const LockEventTypeSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewP
-
- {t("submit")}
-
+ {t("submit")}
diff --git a/packages/features/ee/organizations/pages/components/NoSlotsNotificationSwitch.tsx b/packages/features/ee/organizations/pages/components/NoSlotsNotificationSwitch.tsx
index 4494251490179d..4d714409df8416 100644
--- a/packages/features/ee/organizations/pages/components/NoSlotsNotificationSwitch.tsx
+++ b/packages/features/ee/organizations/pages/components/NoSlotsNotificationSwitch.tsx
@@ -8,10 +8,9 @@ import { showToast } from "@calcom/ui/components/toast";
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
- isAdminOrOwner: boolean;
}
-export const NoSlotsNotificationSwitch = ({ currentOrg, isAdminOrOwner }: GeneralViewProps) => {
+export const NoSlotsNotificationSwitch = ({ currentOrg }: GeneralViewProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [notificationActive, setNotificationActive] = useState(
@@ -30,8 +29,6 @@ export const NoSlotsNotificationSwitch = ({ currentOrg, isAdminOrOwner }: Genera
},
});
- if (!isAdminOrOwner) return null;
-
return (
<>
>;
+ permissions: { canEdit: boolean; canDelete: boolean };
}) {
const { t } = useLocale();
const [isEnabled, setIsEnabled] = useState(attribute.enabled);
@@ -87,44 +89,54 @@ function AttributeItem({
-
-
-
-
-
-
-
- }
+ {(permissions.canEdit || permissions.canDelete) && (
+
+
+
- {t("edit")}
-
-
-
- setAttributeToDelete(attribute)}>
- {t("delete")}
-
-
-
-
+ variant="icon"
+ color="secondary"
+ StartIcon="ellipsis"
+ className="ltr:radix-state-open:rounded-r-md rtl:radix-state-open:rounded-l-md"
+ />
+
+
+ {permissions.canEdit && (
+
+
+ {t("edit")}
+
+
+ )}
+ {permissions.canDelete && (
+
+ setAttributeToDelete(attribute)}>
+ {t("delete")}
+
+
+ )}
+
+
+ )}
);
}
-function OrganizationAttributesPage() {
+function OrganizationAttributesPage({
+ permissions,
+}: {
+ permissions: { canEdit: boolean; canDelete: boolean; canCreate: boolean };
+}) {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.attributes.list.useQuery();
const [attributeToDelete, setAttributeToDelete] = useState();
@@ -149,17 +161,20 @@ function OrganizationAttributesPage() {
))}
-
- {t("add")}
-
+ {permissions.canCreate && (
+
+ {t("add")}
+
+ )}
>
) : (
@@ -173,13 +188,15 @@ function OrganizationAttributesPage() {
{t("add_attributes_description")}
-
- {t("new_attribute")}
-
+ {permissions.canCreate && (
+
+ {t("new_attribute")}
+
+ )}
)}
diff --git a/packages/features/ee/organizations/pages/settings/general.tsx b/packages/features/ee/organizations/pages/settings/general.tsx
index b4862548c67395..98141bab962bdf 100644
--- a/packages/features/ee/organizations/pages/settings/general.tsx
+++ b/packages/features/ee/organizations/pages/settings/general.tsx
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
-import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { TimezoneSelect } from "@calcom/features/components/timezone-select";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
@@ -42,22 +41,31 @@ const SkeletonLoader = () => {
interface GeneralViewProps {
currentOrg: RouterOutputs["viewer"]["organizations"]["listCurrent"];
- isAdminOrOwner: boolean;
localeProp: string;
+
+ permissions: {
+ canRead: boolean;
+ canEdit: boolean;
+ };
}
-const OrgGeneralView = () => {
+const OrgGeneralView = ({
+ permissions,
+}: {
+ permissions: {
+ canRead: boolean;
+ canEdit: boolean;
+ };
+}) => {
const { t } = useLocale();
const router = useRouter();
const session = useSession();
- const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
const {
data: currentOrg,
isPending,
error,
} = trpc.viewer.organizations.listCurrent.useQuery(undefined, {});
- const { data: user } = trpc.viewer.me.get.useQuery();
useEffect(
function refactorMeWithoutEffect() {
@@ -77,18 +85,22 @@ const OrgGeneralView = () => {
-
-
-
+ {permissions.canEdit && (
+ <>
+
+
+
+ >
+ )}
);
};
-const GeneralView = ({ currentOrg, isAdminOrOwner, localeProp }: GeneralViewProps) => {
+const GeneralView = ({ currentOrg, permissions, localeProp }: GeneralViewProps) => {
const { t } = useLocale();
const mutation = trpc.viewer.organizations.update.useMutation({
@@ -136,7 +148,7 @@ const GeneralView = ({ currentOrg, isAdminOrOwner, localeProp }: GeneralViewProp
reset,
getValues,
} = formMethods;
- const isDisabled = isSubmitting || !isDirty || !isAdminOrOwner;
+ const isDisabled = isSubmitting || !isDirty || !permissions.canEdit;
return (