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...

    +
    + )} + +
    +