Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18059e50fa | ||
|
|
852eb346ed | ||
|
|
b7e11dc112 | ||
|
|
35a373e70a | ||
|
|
02c0b721b9 | ||
|
|
29349845f8 | ||
|
|
28cb2cff64 | ||
|
|
9cb2eb3b8c | ||
|
|
6ff20b98f4 | ||
|
|
aa618dbaa2 | ||
|
|
6e3de84067 | ||
|
|
c6b19200a9 | ||
|
|
91932212e3 | ||
|
|
f81bb92beb | ||
|
|
1d1a242a72 | ||
|
|
ad4af64036 | ||
|
+5 |
2cc7827b0e | ||
|
|
ef6618743d | ||
|
|
3601a9adf1 | ||
|
|
0cc2367556 | ||
|
|
f7b201b0b5 | ||
|
|
82063cc9a1 | ||
|
|
4b2f89d6e8 | ||
|
|
5360cb511d | ||
|
|
704bbf2757 | ||
|
|
7fef683abf | ||
|
|
63df3d9c14 | ||
|
|
32d2fd51e5 | ||
|
|
016a966057 | ||
|
|
dc967cffbe | ||
|
|
4489cb8fde | ||
|
|
babd514c64 | ||
|
|
2e5cc11f29 | ||
|
|
84bc330fa8 | ||
|
|
4cb7e4a91a | ||
|
|
d1bd05a791 | ||
|
|
96a6350143 | ||
|
|
c8bea5b143 | ||
|
|
1cdd8ebc53 | ||
|
|
d78ed21a9f | ||
|
|
4e92b6edeb | ||
|
|
7b2f811bfe | ||
|
|
7b6d5d6de3 | ||
|
|
12b418b532 | ||
|
|
3e299f9511 | ||
|
|
1b9691ddaf | ||
|
|
a11ea3dd1e | ||
|
|
b21400c613 | ||
|
|
6c936472d2 | ||
|
|
3b823670ea | ||
|
|
6b10127d97 | ||
|
|
9cd806ff61 | ||
|
|
628b169f53 | ||
|
|
19d7764e9d | ||
|
|
30061c0d58 |
@@ -34,4 +34,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
VITE_BOOKER_EMBED_OAUTH_CLIENT_ID: ${{ secrets.VITE_BOOKER_EMBED_OAUTH_CLIENT_ID }}
|
||||
VITE_BOOKER_EMBED_API_URL: ${{ secrets.VITE_BOOKER_EMBED_API_URL }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ secrets.NEXT_PUBLIC_WEBAPP_URL }}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -38,7 +38,7 @@
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
"next-validations": "^0.2.0",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.9.0-beta",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.22.4"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"moduleNameMapper": {
|
||||
"@/(.*)": "<rootDir>/src/$1",
|
||||
"test/(.*)": "<rootDir>/test/$1"
|
||||
},
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
|
||||
"reporters": ["default", "jest-summarizing-reporter"],
|
||||
"workerIdleMemoryLimit": "512MB",
|
||||
"maxWorkers": 8
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Config } from "jest";
|
||||
|
||||
const config: Config = {
|
||||
preset: "ts-jest",
|
||||
moduleFileExtensions: ["js", "json", "ts"],
|
||||
rootDir: ".",
|
||||
moduleNameMapper: {
|
||||
"@/(.*)": "<rootDir>/src/$1",
|
||||
"test/(.*)": "<rootDir>/test/$1",
|
||||
},
|
||||
testEnvironment: "node",
|
||||
testRegex: ".e2e-spec.ts$",
|
||||
transform: {
|
||||
"^.+\\.ts$": "ts-jest",
|
||||
},
|
||||
setupFiles: ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||
setupFilesAfterEnv: ["<rootDir>/test/jest.setup-e2e.ts"],
|
||||
reporters: ["default", "jest-summarizing-reporter"],
|
||||
workerIdleMemoryLimit: "512MB",
|
||||
maxWorkers: 8,
|
||||
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||
transformIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"moduleNameMapper": {
|
||||
"@/(.*)": "<rootDir>/src/$1",
|
||||
"test/(.*)": "<rootDir>/test/$1"
|
||||
},
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.ts$": "ts-jest"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Config } from "jest";
|
||||
|
||||
const config: Config = {
|
||||
preset: "ts-jest",
|
||||
moduleFileExtensions: ["ts", "js", "json"],
|
||||
rootDir: ".",
|
||||
moduleNameMapper: {
|
||||
"@/(.*)": "<rootDir>/src/$1",
|
||||
"test/(.*)": "<rootDir>/test/$1",
|
||||
},
|
||||
testEnvironment: "node",
|
||||
testRegex: ".*\\.spec\\.ts$",
|
||||
transform: {
|
||||
"^.+\\.ts$": "ts-jest",
|
||||
},
|
||||
setupFiles: ["<rootDir>/test/setEnvVars.ts"],
|
||||
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||
transformIgnorePatterns: ["/dist/", "/node_modules/"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -25,9 +25,9 @@
|
||||
"test:watch": "yarn dev:build && jest --watch",
|
||||
"test:cov": "yarn dev:build && jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json",
|
||||
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.ts",
|
||||
"test:e2e:local": "yarn test:e2e --maxWorkers=4",
|
||||
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
|
||||
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
|
||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.267",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
@@ -108,7 +108,7 @@
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.1.0",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "../../../packages/prisma/schema.prisma"
|
||||
|
||||
@@ -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<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
return keyData?.userId;
|
||||
} else {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -1834,6 +1834,88 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should return who rescheduled the booking, in the new booking", async () => {
|
||||
const rescheduledByEmail = `user-bookings-rescheduler-${randomString()}@rescheduler.com`;
|
||||
// Create the original booking that will be rescheduled
|
||||
const originalBooking = await bookingsRepositoryFixture.create({
|
||||
uid: `original-booking-uid-${eventTypeId}`,
|
||||
title: "original booking title",
|
||||
startTime: "2050-09-05T10:00:00.000Z",
|
||||
endTime: "2050-09-05T11:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
status: "CANCELLED",
|
||||
rescheduledBy: rescheduledByEmail,
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "original tester",
|
||||
email: "original@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create the new booking that is the result of the reschedule
|
||||
const newBooking = await bookingsRepositoryFixture.create({
|
||||
uid: `new-booking-uid-${eventTypeId}`,
|
||||
title: "rescheduled booking title",
|
||||
startTime: "2050-09-05T14:00:00.000Z",
|
||||
endTime: "2050-09-05T15:00:00.000Z",
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
status: "ACCEPTED",
|
||||
fromReschedule: originalBooking.uid,
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "new tester",
|
||||
email: "newtester@example.com",
|
||||
guests: [],
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${newBooking.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
// Fetch the original booking to get its rescheduledBy value
|
||||
const originalBookingFromDb = await bookingsRepositoryFixture.getByUid(originalBooking.uid);
|
||||
const expectedRescheduledBy = originalBookingFromDb?.rescheduledBy;
|
||||
|
||||
await bookingsRepositoryFixture.deleteById(originalBooking.id);
|
||||
await bookingsRepositoryFixture.deleteById(newBooking.id);
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.uid).toEqual(newBooking.uid);
|
||||
expect(rescheduledByEmail).toEqual(expectedRescheduledBy);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("book by username and event type slug", () => {
|
||||
|
||||
@@ -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<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
||||
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||
return keyData?.userId;
|
||||
} else {
|
||||
|
||||
@@ -101,9 +101,13 @@ export class OutputBookingsService_2024_08_13 {
|
||||
);
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
const rescheduledToUid = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
||||
const rescheduledToInfo = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||
: undefined;
|
||||
|
||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
||||
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
@@ -116,7 +120,6 @@ export class OutputBookingsService_2024_08_13 {
|
||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
@@ -142,6 +145,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
rescheduledToUid,
|
||||
rescheduledByEmail,
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
@@ -151,11 +155,15 @@ export class OutputBookingsService_2024_08_13 {
|
||||
return bookingTransformed;
|
||||
}
|
||||
|
||||
async getRescheduledToUid(bookingUid: string) {
|
||||
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
|
||||
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
||||
return rescheduledTo?.uid;
|
||||
return {
|
||||
uid: rescheduledTo?.uid,
|
||||
rescheduledBy: rescheduledTo?.rescheduledBy
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||
if (databaseMetadata === null) return {};
|
||||
|
||||
@@ -218,7 +226,6 @@ export class OutputBookingsService_2024_08_13 {
|
||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
@@ -269,9 +276,12 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
const rescheduledToUid = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
||||
const rescheduledToInfo = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||
: undefined;
|
||||
|
||||
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
@@ -299,6 +309,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
rescheduledToUid,
|
||||
rescheduledByEmail,
|
||||
};
|
||||
|
||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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_");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||
@@ -7,6 +8,8 @@ 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 { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@@ -21,6 +24,9 @@ import { Module } from "@nestjs/common";
|
||||
PrismaEventTypeRepository,
|
||||
PrismaRoutingFormResponseRepository,
|
||||
PrismaTeamRepository,
|
||||
PrismaFeaturesRepository,
|
||||
CheckBookingLimitsService,
|
||||
CacheService,
|
||||
AvailableSlotsService,
|
||||
],
|
||||
exports: [AvailableSlotsService],
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PrismaFeaturesRepository as PrismaFeaturesRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaFeaturesRepository extends PrismaFeaturesRepositoryLib {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||
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 { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
||||
@@ -20,7 +23,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
bookingRepository: PrismaBookingRepository,
|
||||
selectedSlotRepository: PrismaSelectedSlotRepository,
|
||||
eventTypeRepository: PrismaEventTypeRepository,
|
||||
userRepository: PrismaUserRepository
|
||||
userRepository: PrismaUserRepository,
|
||||
featuresRepository: PrismaFeaturesRepository
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
@@ -31,6 +35,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
selectedSlotRepo: selectedSlotRepository,
|
||||
eventTypeRepo: eventTypeRepository,
|
||||
userRepo: userRepository,
|
||||
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
|
||||
cacheService: new CacheService(featuresRepository),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { CacheService as BaseCacheService } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class CacheService extends BaseCacheService {
|
||||
constructor(featuresRepository: PrismaFeaturesRepository) {
|
||||
super({ featuresRepository });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<string>("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.");
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.s
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
@@ -21,6 +22,7 @@ import { Module } from "@nestjs/common";
|
||||
@Module({
|
||||
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule],
|
||||
providers: [
|
||||
OrganizationsTeamsRepository,
|
||||
EventTypesAtomService,
|
||||
ConferencingAtomsService,
|
||||
AttributesAtomsService,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
|
||||
@@ -53,7 +54,8 @@ export class EventTypesAtomService {
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly eventTypeService: EventTypesService_2024_06_14,
|
||||
private readonly teamEventTypeService: TeamsEventTypesService
|
||||
private readonly teamEventTypeService: TeamsEventTypesService,
|
||||
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository
|
||||
) {}
|
||||
|
||||
private async getTeamSlug(teamId: number): Promise<string> {
|
||||
@@ -88,10 +90,12 @@ export class EventTypesAtomService {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
if (eventType?.team?.id) {
|
||||
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
|
||||
} else {
|
||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
if (!isUserOrganizationAdmin) {
|
||||
if (eventType?.team?.id) {
|
||||
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
|
||||
} else {
|
||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
}
|
||||
}
|
||||
|
||||
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
|
||||
@@ -115,7 +119,8 @@ export class EventTypesAtomService {
|
||||
user: UserWithProfile,
|
||||
teamId: number
|
||||
) {
|
||||
await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId);
|
||||
await this.checkCanUpdateTeamEventType(user, eventTypeId, teamId, body.scheduleId);
|
||||
|
||||
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
|
||||
const bookingFields = body.bookingFields ? [...body.bookingFields] : undefined;
|
||||
|
||||
@@ -175,14 +180,31 @@ export class EventTypesAtomService {
|
||||
}
|
||||
|
||||
async checkCanUpdateTeamEventType(
|
||||
userId: number,
|
||||
user: UserWithProfile,
|
||||
eventTypeId: number,
|
||||
teamId: number,
|
||||
scheduleId: number | null | undefined
|
||||
) {
|
||||
await this.checkTeamOwnsEventType(userId, eventTypeId, teamId);
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
if (organizationId) {
|
||||
const isUserOrganizationAdmin = await this.membershipsRepository.isUserOrganizationAdmin(
|
||||
user.id,
|
||||
organizationId
|
||||
);
|
||||
|
||||
if (isUserOrganizationAdmin) {
|
||||
const orgTeam = await this.organizationsTeamsRepository.findOrgTeam(organizationId, teamId);
|
||||
if (orgTeam) {
|
||||
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.checkTeamOwnsEventType(user.id, eventTypeId, teamId);
|
||||
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
|
||||
await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId);
|
||||
await this.eventTypeService.checkUserOwnsSchedule(user.id, scheduleId);
|
||||
}
|
||||
|
||||
async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) {
|
||||
|
||||
@@ -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<string>("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");
|
||||
|
||||
+11
@@ -27,6 +27,17 @@ export class OrganizationsMembershipService {
|
||||
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
|
||||
}
|
||||
|
||||
async isOrgAdminOrOwner(organizationId: number, userId: number) {
|
||||
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
|
||||
organizationId,
|
||||
userId
|
||||
);
|
||||
if (!membership) {
|
||||
return false;
|
||||
}
|
||||
return membership.role === "ADMIN" || membership.role === "OWNER";
|
||||
}
|
||||
|
||||
async getOrgMembershipByUserId(organizationId: number, userId: number) {
|
||||
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
|
||||
organizationId,
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+10
-7
@@ -14,6 +14,7 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input";
|
||||
import { UpdateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/update-organization-team.input";
|
||||
import {
|
||||
@@ -56,7 +57,10 @@ import { Team } from "@calcom/prisma/client";
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsTeamsController {
|
||||
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
|
||||
constructor(
|
||||
private organizationsTeamsService: OrganizationsTeamsService,
|
||||
private organizationsMembershipService: OrganizationsMembershipService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Get all teams" })
|
||||
@@ -84,12 +88,11 @@ export class OrganizationsTeamsController {
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<OrgMeTeamsOutputResponseDto> {
|
||||
const { skip, take } = queryParams;
|
||||
const teams = await this.organizationsTeamsService.getPaginatedOrgUserTeams(
|
||||
orgId,
|
||||
user.id,
|
||||
skip ?? 0,
|
||||
take ?? 250
|
||||
);
|
||||
const isOrgAdminOrOwner = await this.organizationsMembershipService.isOrgAdminOrOwner(orgId, user.id);
|
||||
const teams = isOrgAdminOrOwner
|
||||
? await this.organizationsTeamsService.getPaginatedOrgTeamsWithMembers(orgId, skip ?? 0, take ?? 250)
|
||||
: await this.organizationsTeamsService.getPaginatedOrgUserTeams(orgId, user.id, skip ?? 0, take ?? 250);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: teams.map((team) => {
|
||||
|
||||
@@ -107,4 +107,17 @@ export class OrganizationsTeamsRepository {
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
async findOrgTeamsPaginatedWithMembers(organizationId: number, skip: number, take: number) {
|
||||
return this.dbRead.prisma.team.findMany({
|
||||
where: {
|
||||
parentId: organizationId,
|
||||
},
|
||||
include: {
|
||||
members: { select: { accepted: true, userId: true, role: true } },
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -24,6 +24,15 @@ export class OrganizationsTeamsService {
|
||||
return teams;
|
||||
}
|
||||
|
||||
async getPaginatedOrgTeamsWithMembers(organizationId: number, skip = 0, take = 250) {
|
||||
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginatedWithMembers(
|
||||
organizationId,
|
||||
skip,
|
||||
take
|
||||
);
|
||||
return teams;
|
||||
}
|
||||
|
||||
async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) {
|
||||
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take);
|
||||
return teams;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,6 +34,6 @@
|
||||
"eslint-config-next": "15.1.6",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.9.0-beta"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cookies, headers } from "next/headers";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||
@@ -17,7 +18,7 @@ import type { PageProps as LegacyPageProps } from "~/team/type-view";
|
||||
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
|
||||
|
||||
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"team-booking-page-cache"
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function getEnrichedEventType({
|
||||
}
|
||||
|
||||
export async function shouldUseApiV2ForTeamSlots(teamId: number): Promise<boolean> {
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const featureRepo = new FeaturesRepository(prisma);
|
||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(teamId, "use-api-v2-for-team-slots");
|
||||
const useApiV2 = teamHasApiV2Route && Boolean(process.env.NEXT_PUBLIC_API_V2_URL);
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import { notFound } from "next/navigation";
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import UpgradeTipWrapper from "./UpgradeTipWrapper";
|
||||
|
||||
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||
|
||||
if (!insightsEnabled) {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { BookingsByHourChartContent } from "@calcom/features/insights/components/BookingsByHourChart";
|
||||
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
|
||||
import { BookingsByHourChartContent } from "@calcom/features/insights/components/booking/BookingsByHourChart";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
// Sample data for playground testing
|
||||
|
||||
+4
-1
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
|
||||
import { RoutingFunnelContent, legend } from "@calcom/features/insights/components/RoutingFunnelContent";
|
||||
import {
|
||||
RoutingFunnelContent,
|
||||
legend,
|
||||
} from "@calcom/features/insights/components/routing/RoutingFunnelContent";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
// Random sample data for playground testing
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -17,7 +18,7 @@ import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
|
||||
|
||||
const getTeamFeatures = unstable_cache(
|
||||
async (teamId: number) => {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
return await featuresRepository.getTeamFeatures(teamId);
|
||||
},
|
||||
["team-features"],
|
||||
|
||||
+39
-1
@@ -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 (
|
||||
<SettingsHeader title={t("attributes")} description={t("attribute_meta_description")}>
|
||||
<OrgSettingsAttributesPage />
|
||||
<OrgSettingsAttributesPage permissions={{ canEdit, canDelete, canCreate }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+26
-1
@@ -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 (
|
||||
<SettingsHeader title={t("directory_sync")} description={t("directory_sync_description")}>
|
||||
<DirectorySyncTeamView />
|
||||
<DirectorySyncTeamView permissions={{ canEdit }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+34
-1
@@ -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 (
|
||||
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
|
||||
<PrivacyView />
|
||||
<PrivacyView permissions={{ canRead, canEdit }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+26
-1
@@ -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 (
|
||||
<SettingsHeader title={t("sso_configuration")} description={t("sso_configuration_description_orgs")}>
|
||||
<OrgSSOView />
|
||||
<OrgSSOView permissions={{ canEdit }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+30
-1
@@ -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 (
|
||||
<SettingsHeader title={t("general")} description={t("general_description")} borderInShellHeader={true}>
|
||||
<LegacyPage />
|
||||
<LegacyPage permissions={{ canRead, canEdit }} />
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+43
-1
@@ -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 (
|
||||
<SettingsHeader
|
||||
title={t("profile")}
|
||||
description={t("profile_org_description")}
|
||||
borderInShellHeader={true}>
|
||||
<LegacyPage />
|
||||
<LegacyPage
|
||||
permissions={{
|
||||
canEdit,
|
||||
canRead,
|
||||
canDelete,
|
||||
}}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
);
|
||||
};
|
||||
|
||||
+12
@@ -44,5 +44,17 @@ describe("usePermissions", () => {
|
||||
|
||||
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
|
||||
});
|
||||
|
||||
it("should return 'all' for resource with manage permission", () => {
|
||||
const permissions = ["eventType.manage"];
|
||||
|
||||
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
|
||||
});
|
||||
|
||||
it("should return 'all' for resource with manage permission even if other permissions are missing", () => {
|
||||
const permissions = ["eventType.manage", "eventType.read"]; // Has manage and read, but missing create, update, delete
|
||||
|
||||
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+11
-5
@@ -51,14 +51,20 @@ export function usePermissions(): UsePermissionsReturn {
|
||||
return "all";
|
||||
}
|
||||
|
||||
// Filter out internal keys like _resource when checking permissions
|
||||
const allResourcePerms = Object.keys(resourceConfig)
|
||||
.filter((action) => !action.startsWith("_"))
|
||||
// Check if user has manage permission for this resource
|
||||
const hasManagePermission = permissions.includes(`${resource}.manage`);
|
||||
if (hasManagePermission) {
|
||||
return "all";
|
||||
}
|
||||
|
||||
// Filter out internal keys like _resource and manage when checking for individual permissions
|
||||
const crudPermissions = Object.keys(resourceConfig)
|
||||
.filter((action) => !action.startsWith("_") && action !== "manage")
|
||||
.map((action) => `${resource}.${action}`);
|
||||
const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p));
|
||||
const hasAllCrudPerms = crudPermissions.every((p) => permissions.includes(p));
|
||||
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
|
||||
|
||||
if (hasAllPerms) return "all";
|
||||
if (hasAllCrudPerms) return "all";
|
||||
if (hasReadPerm) return "read";
|
||||
return "none";
|
||||
};
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permiss
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -29,7 +30,7 @@ const getCachedTeamRoles = unstable_cache(
|
||||
|
||||
const getCachedTeamFeature = unstable_cache(
|
||||
async (teamId: number, feature: keyof AppFlags) => {
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const featureRepo = new FeaturesRepository(prisma);
|
||||
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
||||
return res;
|
||||
},
|
||||
|
||||
+2
-2
@@ -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 [];
|
||||
}
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
|
||||
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -32,7 +33,7 @@ const getCachedTeamRoles = (teamId: number) =>
|
||||
const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) =>
|
||||
unstable_cache(
|
||||
async () => {
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const featureRepo = new FeaturesRepository(prisma);
|
||||
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
||||
return res;
|
||||
},
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ import getIP from "@calcom/lib/getIP";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
async function ensureSignupIsEnabled(body: Record<string, string>) {
|
||||
@@ -22,7 +23,7 @@ async function ensureSignupIsEnabled(body: Record<string, string>) {
|
||||
// Still allow signups if there is a team invite
|
||||
if (token) return;
|
||||
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
|
||||
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
|
||||
|
||||
@@ -208,7 +208,7 @@ async function getHandler(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Create a new response with the image buffer
|
||||
const imageResponse = new NextResponse(buffer);
|
||||
const imageResponse = new NextResponse(buffer as BodyInit);
|
||||
|
||||
// Set the appropriate headers
|
||||
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
|
||||
|
||||
@@ -515,7 +515,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
{!isPending && (
|
||||
<div>
|
||||
{(provider?.label || locationToDisplay?.startsWith("https://")) &&
|
||||
{(provider?.label ||
|
||||
(typeof locationToDisplay === "string" &&
|
||||
locationToDisplay?.startsWith("https://"))) &&
|
||||
locationToDisplay.startsWith("http") && (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
|
||||
@@ -120,7 +120,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
// Check if team has API v2 feature flag enabled (same logic as team pages)
|
||||
let useApiV2 = false;
|
||||
if (isTeamEvent && hashedLink.eventType.team?.id) {
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const featureRepo = new FeaturesRepository(prisma);
|
||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(
|
||||
hashedLink.eventType.team.id,
|
||||
"use-api-v2-for-team-slots"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { GetServerSidePropsResult } from "next";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||
// Check if organizations are enabled
|
||||
if (!organizations) {
|
||||
|
||||
@@ -24,7 +24,7 @@ const querySchema = z.object({
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"email-verification"
|
||||
);
|
||||
|
||||
@@ -127,7 +127,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const organizationSettings = getOrganizationSEOSettings(team);
|
||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
||||
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const featureRepo = new FeaturesRepository(prisma);
|
||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
||||
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
// Provided by Rewrite from next.config.js
|
||||
const isOrgProfile = context.query?.isOrgProfile === "1";
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||
|
||||
log.debug("getServerSideProps", {
|
||||
|
||||
@@ -247,7 +247,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const videoReference = getCalVideoReference(bookingObj.references);
|
||||
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const featureRepo = new FeaturesRepository(prisma);
|
||||
const displayLogInOverlay = profile?.organizationId
|
||||
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
|
||||
: false;
|
||||
|
||||
@@ -1229,8 +1229,21 @@ function RecurringBookings({
|
||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
||||
<br />
|
||||
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
|
||||
{formatToLocalizedTime(dayjs(dateStr).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
|
||||
{formatToLocalizedTime({
|
||||
date: dayjs(dateStr),
|
||||
locale: language,
|
||||
timeStyle: undefined,
|
||||
hour12: !is24h,
|
||||
timeZone: tz,
|
||||
})}{" "}
|
||||
-{" "}
|
||||
{formatToLocalizedTime({
|
||||
date: dayjs(dateStr).add(duration, "m"),
|
||||
locale: language,
|
||||
timeStyle: undefined,
|
||||
hour12: !is24h,
|
||||
timeZone: tz,
|
||||
})}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||
</span>
|
||||
@@ -1249,14 +1262,19 @@ function RecurringBookings({
|
||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
||||
<br />
|
||||
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
|
||||
{formatToLocalizedTime(
|
||||
dayjs(dateStr).add(duration, "m"),
|
||||
language,
|
||||
undefined,
|
||||
!is24h,
|
||||
tz
|
||||
)}{" "}
|
||||
{formatToLocalizedTime({
|
||||
date: dayjs(dateStr),
|
||||
locale: language,
|
||||
hour12: !is24h,
|
||||
timeZone: tz,
|
||||
})}{" "}
|
||||
-{" "}
|
||||
{formatToLocalizedTime({
|
||||
date: dayjs(dateStr).add(duration, "m"),
|
||||
locale: language,
|
||||
hour12: !is24h,
|
||||
timeZone: tz,
|
||||
})}{" "}
|
||||
<span className="text-bookinglight">
|
||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||
</span>
|
||||
@@ -1273,8 +1291,13 @@ function RecurringBookings({
|
||||
<div className={classNames(isCancelled ? "line-through" : "")}>
|
||||
{formatToLocalizedDate(date, language, "full", tz)}
|
||||
<br />
|
||||
{formatToLocalizedTime(date, language, undefined, !is24h, tz)} -{" "}
|
||||
{formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
|
||||
{formatToLocalizedTime({ date, locale: language, hour12: !is24h, timeZone: tz })} -{" "}
|
||||
{formatToLocalizedTime({
|
||||
date: dayjs(date).add(duration, "m"),
|
||||
locale: language,
|
||||
hour12: !is24h,
|
||||
timeZone: tz,
|
||||
})}{" "}
|
||||
<span className="text-bookinglight">({formatToLocalizedTimezone(date, language, tz)})</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
FailedBookingsByField,
|
||||
RoutedToPerPeriod,
|
||||
RoutingFunnel,
|
||||
} from "@calcom/features/insights/components";
|
||||
} from "@calcom/features/insights/components/routing";
|
||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
BookingKPICards,
|
||||
BookingsByHourChart,
|
||||
EventTrendsChart,
|
||||
HighestNoShowHostTable,
|
||||
HighestRatedMembersTable,
|
||||
BookingsByHourChart,
|
||||
LeastBookedTeamMembersTable,
|
||||
LowestRatedMembersTable,
|
||||
MostBookedTeamMembersTable,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
PopularEventsTable,
|
||||
RecentFeedbackTable,
|
||||
TimezoneBadge,
|
||||
} from "@calcom/features/insights/components";
|
||||
} from "@calcom/features/insights/components/booking";
|
||||
import "@calcom/features/insights/components/tremor.css";
|
||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||
import { Download } from "@calcom/features/insights/filters/Download";
|
||||
|
||||
@@ -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
|
||||
</label>
|
||||
<ul className="mb-8 space-y-4">
|
||||
{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 (
|
||||
<li key={team.id}>
|
||||
<Controller
|
||||
@@ -167,9 +172,11 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CheckboxField
|
||||
defaultValue={value}
|
||||
checked={value}
|
||||
checked={value || slugConflictsWithOrg}
|
||||
onChange={onChange}
|
||||
description={teams.find((t) => t.id === team.id)?.name ?? ""}
|
||||
description={currentTeam?.name ?? ""}
|
||||
// Must not allow toggling off if the slug conflicts with the org slug
|
||||
disabled={slugConflictsWithOrg}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -288,6 +288,7 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
||||
|
||||
const endTime = new Date(booking.endTime);
|
||||
const startTime = new Date(booking.startTime);
|
||||
const timeZone = booking.user?.timeZone;
|
||||
|
||||
useDailyEvent("left-meeting", () => {
|
||||
if (rediectAttendeeToOnExit) {
|
||||
@@ -306,11 +307,11 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
||||
<h3>{t("what")}:</h3>
|
||||
<p>{booking.title}</p>
|
||||
<h3>{t("invitee_timezone")}:</h3>
|
||||
<p>{booking.user?.timeZone}</p>
|
||||
<p>{timeZone}</p>
|
||||
<h3>{t("when")}:</h3>
|
||||
<p suppressHydrationWarning={true}>
|
||||
{formatToLocalizedDate(startTime)} <br />
|
||||
{formatToLocalizedTime(startTime)}
|
||||
{formatToLocalizedTime({ date: startTime, timeZone })}
|
||||
</p>
|
||||
<h3>{t("time_left")}</h3>
|
||||
<ProgressBar
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.5.7",
|
||||
"version": "5.5.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -195,7 +195,7 @@
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
},
|
||||
"nextBundleAnalysis": {
|
||||
"budget": 358400,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default, config } from "@calcom/app-store/btcpayserver/api/webhook";
|
||||
@@ -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<typeof teamMetadataSchema> };
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -420,6 +420,33 @@ test.describe("Event Types tests", () => {
|
||||
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
|
||||
await expect(offerSeatsToggle).toBeDisabled();
|
||||
});
|
||||
test("should enable timezone lock in event advanced settings and verify disabled timezone selector on booking page", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
await gotoFirstEventType(page);
|
||||
await expect(page.locator("[data-testid=event-title]")).toBeVisible();
|
||||
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
|
||||
await page.click("[data-testid=lock-timezone-toggle]");
|
||||
await page.click("[data-testid=timezone-select]");
|
||||
await page.locator('[aria-label="Timezone Select"]').fill("New York");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await submitAndWaitForResponse(page, "/api/trpc/eventTypes/update?batch=1", {
|
||||
action: () => page.locator("[data-testid=update-eventtype]").click(),
|
||||
});
|
||||
await page.goto("/event-types");
|
||||
const previewLink = await page
|
||||
.locator("[data-testid=preview-link-button]")
|
||||
.first()
|
||||
.getAttribute("href");
|
||||
|
||||
await page.goto(previewLink ?? "");
|
||||
const currentTimezone = page.locator('[data-testid="event-meta-current-timezone"]');
|
||||
await expect(currentTimezone).toBeVisible();
|
||||
await expect(currentTimezone).toHaveClass(/cursor-not-allowed/);
|
||||
await expect(page.getByText("New York")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Interface Language Tests", () => {
|
||||
|
||||
@@ -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,15 +3308,20 @@
|
||||
"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",
|
||||
"pbac_desc_manage_roles": "All actions on roles",
|
||||
"pbac_desc_manage_roles": "All actions on roles across organization teams",
|
||||
"pbac_desc_create_event_types": "Create event types",
|
||||
"pbac_desc_view_event_types": "View event types",
|
||||
"pbac_desc_update_event_types": "Update event types",
|
||||
"pbac_desc_delete_event_types": "Delete event types",
|
||||
"pbac_desc_manage_event_types": "All actions on event types",
|
||||
"pbac_desc_manage_event_types": "All actions on event types across organization teams",
|
||||
"pbac_desc_create_teams": "Create teams",
|
||||
"pbac_desc_view_team_details": "View team details",
|
||||
"pbac_desc_update_team_settings": "Update team settings",
|
||||
@@ -3318,7 +3329,7 @@
|
||||
"pbac_desc_invite_team_members": "Invite team members",
|
||||
"pbac_desc_remove_team_members": "Remove team members",
|
||||
"pbac_desc_change_team_member_role": "Change role of team members",
|
||||
"pbac_desc_manage_teams": "All actions on teams",
|
||||
"pbac_desc_manage_teams": "All actions on teams across organization teams",
|
||||
"pbac_desc_create_organization": "Create organization",
|
||||
"pbac_desc_view_organization_details": "View organization details",
|
||||
"pbac_desc_list_organization_members": "List organization members",
|
||||
@@ -3333,7 +3344,7 @@
|
||||
"pbac_desc_view_organization_bookings": "View organization bookings",
|
||||
"pbac_desc_view_booking_recordings": "View booking recordings",
|
||||
"pbac_desc_update_bookings": "Update bookings",
|
||||
"pbac_desc_manage_bookings": "All actions on bookings",
|
||||
"pbac_desc_manage_bookings": "All actions on bookings across organization teams",
|
||||
"pbac_desc_view_team_insights": "View team insights",
|
||||
"pbac_desc_manage_team_insights": "Manage team insights",
|
||||
"read_permission_auto_enabled_tooltip": "Read permission is automatically enabled when creating, updating, or deleting a resource",
|
||||
|
||||
@@ -60,6 +60,7 @@ type UserPageProps = {
|
||||
| "length"
|
||||
| "hidden"
|
||||
| "lockTimeZoneToggleOnBookingPage"
|
||||
| "lockedTimeZone"
|
||||
| "requiresConfirmation"
|
||||
| "canSendCalVideoTranscriptionEmails"
|
||||
| "requiresBookerEmailVerification"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -438,7 +438,6 @@ describe("handleChildrenEventTypes", () => {
|
||||
schedulingType: SchedulingType.MANAGED,
|
||||
requiresBookerEmailVerification: false,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
lockedTimeZone: "Europe/London",
|
||||
useEventTypeDestinationCalendarEmail: false,
|
||||
workflows: [],
|
||||
parentId: 1,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
title: "How to Add a New Booking Chart to Cal.com Insights Page"
|
||||
---
|
||||
|
||||
This guide walks you through creating a new booking chart component for the insights page, covering the entire stack from UI component to backend service.
|
||||
|
||||
## Overview
|
||||
|
||||
The insights booking system follows this architecture:
|
||||
|
||||
```
|
||||
UI Component → tRPC Handler → Insights Service → Database Query → Response
|
||||
```
|
||||
|
||||
<Steps>
|
||||
<Step title="Create the UI Component">
|
||||
|
||||
Create your chart component in `packages/features/insights/components/booking/`:
|
||||
|
||||
```typescript
|
||||
// packages/features/insights/components/booking/MyNewChart.tsx
|
||||
import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer } from "recharts";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
export const MyNewChart = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.myNewChartData.useQuery(insightsBookingParams, {
|
||||
staleTime: 180000, // 3 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
trpc: { context: { skipBatch: true } },
|
||||
});
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("my_new_chart_title")}>
|
||||
{isSuccess && data?.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="value" stroke="#8884d8" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-gray-500">{t("no_data_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
```
|
||||
</Step>
|
||||
<Step title="Add Component to Barrel Export">
|
||||
Update the booking components index file:
|
||||
|
||||
```typescript
|
||||
// packages/features/insights/components/booking/index.ts
|
||||
export { AverageEventDurationChart } from "./AverageEventDurationChart";
|
||||
export { BookingKPICards } from "./BookingKPICards";
|
||||
// ... existing exports
|
||||
export { MyNewChart } from "./MyNewChart"; // Add this line
|
||||
```
|
||||
</Step>
|
||||
<Step title="Add Component to Insights View">
|
||||
|
||||
Add your component to the main insights page:
|
||||
|
||||
```typescript
|
||||
// apps/web/modules/insights/insights-view.tsx
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
BookingKPICards, // ... existing imports
|
||||
MyNewChart, // Add this import
|
||||
} from "@calcom/features/insights/components/booking";
|
||||
|
||||
export default function InsightsPage() {
|
||||
// ... existing code
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Existing components */}
|
||||
<BookingKPICards />
|
||||
<EventTrendsChart />
|
||||
|
||||
{/* Add your new chart */}
|
||||
<MyNewChart />
|
||||
|
||||
{/* Other existing components */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
<Step title="Create tRPC Handler">
|
||||
|
||||
Add the tRPC endpoint in the insights router using the `getInsightsBookingService()` DI container function:
|
||||
|
||||
```typescript
|
||||
// packages/features/insights/server/trpc-router.ts
|
||||
import { bookingRepositoryBaseInputSchema } from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { userBelongsToTeamProcedure } from "@calcom/trpc/server/procedures/authedProcedure";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const insightsRouter = router({
|
||||
// ... existing procedures
|
||||
|
||||
myNewChartData: userBelongsToTeamProcedure
|
||||
.input(bookingRepositoryBaseInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// `createInsightsBookingService` is defined at the root level in this file
|
||||
const insightsBookingService = createInsightsBookingService(ctx, input);
|
||||
|
||||
try {
|
||||
return await insightsBookingService.getMyNewChartData();
|
||||
} catch (e) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
<Step title="Add Service Method to InsightsBookingBaseService">
|
||||
Add your new method to the `InsightsBookingBaseService` class:
|
||||
|
||||
```typescript
|
||||
// packages/lib/server/service/InsightsBookingBaseService.ts
|
||||
export class InsightsBookingBaseService {
|
||||
// ... existing methods
|
||||
|
||||
async getMyNewChartData() {
|
||||
const baseConditions = await this.getBaseConditions();
|
||||
|
||||
// Example: Get booking counts by day using raw SQL for performance
|
||||
const data = await this.prisma.$queryRaw<
|
||||
Array<{
|
||||
date: Date;
|
||||
bookingsCount: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
DATE("createdAt") as date,
|
||||
COUNT(*)::int as "bookingsCount"
|
||||
FROM "BookingTimeStatusDenormalized"
|
||||
WHERE ${baseConditions}
|
||||
GROUP BY DATE("createdAt")
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
|
||||
// Transform the data for the chart
|
||||
return data.map((item) => ({
|
||||
date: item.date.toISOString().split("T")[0], // Format as YYYY-MM-DD
|
||||
value: item.bookingsCount,
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `getInsightsBookingService()`**: Always use the DI container function for consistent service creation
|
||||
2. **Raw SQL for Performance**: Use `$queryRaw` for complex aggregations and better performance
|
||||
3. **Base Conditions**: Always use `await this.getBaseConditions()` for proper filtering and permissions
|
||||
4. **Error Handling**: Wrap service calls in try-catch blocks with `TRPCError`
|
||||
5. **Loading States**: Always show loading indicators with `LoadingInsight`
|
||||
6. **Consistent Styling**: Use `recharts` for new charts.
|
||||
7. **Date Handling**: Use `getDateRanges()` and `getTimeView()` for time-based charts
|
||||
@@ -56,11 +56,11 @@ To ensure consistency and make files easy to fuzzy-find, we follow the naming co
|
||||
|
||||
**Pattern:**
|
||||
|
||||
Prisma<Entity>Repository.ts
|
||||
Prisma`<Entity>`Repository.ts
|
||||
|
||||
**Examples:**
|
||||
|
||||
```ts
|
||||
```js
|
||||
// File: PrismaAppRepository.ts
|
||||
export class PrismaAppRepository { ... }
|
||||
|
||||
@@ -80,11 +80,11 @@ Keep naming specific — avoid generic names like AppService.ts.
|
||||
|
||||
**Pattern:**
|
||||
|
||||
<Entity>Service.ts
|
||||
`<Entity>`Service.ts
|
||||
|
||||
**Examples:**
|
||||
|
||||
```ts
|
||||
```js
|
||||
// File: MembershipService.ts
|
||||
export class MembershipService { ... }
|
||||
|
||||
|
||||
@@ -110,6 +110,11 @@
|
||||
"icon": "code-simple",
|
||||
"pages": ["developing/guides/embeds/embed-events"]
|
||||
}
|
||||
{
|
||||
"group": "Insights",
|
||||
"icon": "chart-bar",
|
||||
"pages": ["developing/guides/insights/add-new-booking-charts"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -90,6 +90,7 @@ Below is a list of props that can be passed to the booker atom.
|
||||
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
|
||||
| month | No | The exact month for displaying a user's availability; defaults to the current month |
|
||||
| selectedDate | No | Default selected date for which the slot picker opens |
|
||||
| startTime | No | Custom start time for the Booker that allows users to decide the first available date. Accepts JavaScript Date object or date string in format `YYYY-MM-DD` (e.g., `"2025-08-20"` or `new Date("2025-08-20")`) |
|
||||
| hideBranding | No | For hiding any branding on the booker |
|
||||
| isAway | No | Sets the booker component to the away state |
|
||||
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
|
||||
|
||||
+2
-1
@@ -109,7 +109,7 @@
|
||||
"prismock": "^1.33.4",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"tsc-absolute": "^1.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "5.9.0-beta",
|
||||
"vitest": "^2.1.1",
|
||||
"vitest-fetch-mock": "^0.3.0",
|
||||
"vitest-mock-extended": "^2.0.2"
|
||||
@@ -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"
|
||||
|
||||
@@ -31,6 +31,6 @@
|
||||
"eslint-plugin-react": "^7.30.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
items:
|
||||
- website.png
|
||||
- integrations.png
|
||||
- checkout.png
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as webhook, config } from "./webhook";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 = (
|
||||
<>
|
||||
<p className="mt-3 text-center">Couldn't obtain payment URL</p>
|
||||
</>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="mb-4 mt-8 flex h-full w-full flex-col items-center justify-center gap-4">
|
||||
<PaymentChecker {...props.paymentPageProps} />
|
||||
|
||||
{!iframeLoaded && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Spinner className="mr-2 h-5 w-5" />
|
||||
<p>Loading payment page...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`w-full ${iframeLoaded ? "block" : "hidden"}`}>
|
||||
<iframe
|
||||
src={checkoutUrl}
|
||||
title="BTCPay Payment"
|
||||
className="h-[1000px] w-full rounded-md border-0"
|
||||
onLoad={() => setIframeLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-wrap items-center justify-center gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={() => copyToClipboard(checkoutUrl)}
|
||||
className="text-subtle rounded-md"
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}>
|
||||
Copy Payment Link
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleOpenInNewTab}>Open in New Tab</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<typeof appDataSchema>();
|
||||
const [requirePayment, setRequirePayment] = useState(getAppData("enabled"));
|
||||
const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata);
|
||||
const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled;
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
returnTo={`${WEBAPP_URL}${pathname}?tabName=apps`}
|
||||
app={app}
|
||||
switchChecked={requirePayment}
|
||||
switchOnClick={(e) => {
|
||||
setRequirePayment(e);
|
||||
}}
|
||||
description={<>Add lightning payments to your events and booking</>}
|
||||
disableSwitch={shouldDisableSwitch}
|
||||
switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}>
|
||||
<EventTypeAppSettingsInterface
|
||||
eventType={eventType}
|
||||
slug={app.slug}
|
||||
disabled={disabled}
|
||||
getAppData={getAppData}
|
||||
setAppData={setAppData}
|
||||
/>
|
||||
</AppCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeAppCard;
|
||||
@@ -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 ? (
|
||||
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
|
||||
) : (
|
||||
requirePayment && (
|
||||
<>
|
||||
<div className="mt-4 block items-center justify-start sm:flex sm:space-x-2">
|
||||
<TextField
|
||||
label={t("price")}
|
||||
className="text-black"
|
||||
addOnClassname="h-[38px]"
|
||||
min="1"
|
||||
type="number"
|
||||
required
|
||||
placeholder="Price"
|
||||
onChange={(e) => {
|
||||
setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency));
|
||||
}}
|
||||
value={
|
||||
price && price > 0
|
||||
? disableDecimalPlace(convertFromSmallestToPresentableCurrencyUnit(price, currency))
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="currency">
|
||||
{t("currency")}
|
||||
</label>
|
||||
<Select
|
||||
variant="default"
|
||||
options={currencyOptions}
|
||||
value={selectedCurrency}
|
||||
className="text-black"
|
||||
defaultValue={selectedCurrency}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setSelectedCurrency(e);
|
||||
setAppData("currency", e.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 w-60">
|
||||
<label className="text-default block text-sm font-medium" htmlFor="paymentOption">
|
||||
{t("payment_option")}
|
||||
</label>
|
||||
<Select<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}
|
||||
/>
|
||||
</div>
|
||||
{seatsEnabled && paymentOption === "HOLD" && (
|
||||
<Alert className="mt-2" severity="warning" title={t("seats_and_no_show_fee_error")} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypeAppSettingsInterface;
|
||||
@@ -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) => (
|
||||
<div
|
||||
className={classNames(
|
||||
"addon-wrapper border-default [input:hover_+_&]:border-emphasis [input:hover_+_&]:border-l-default [&:has(+_input:hover)]:border-emphasis [&:has(+_input:hover)]:border-r-default h-9 border px-3 transition disabled:hover:cursor-not-allowed",
|
||||
className
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"min-h-9 flex flex-col justify-center text-sm leading-7",
|
||||
error ? "text-error" : "text-default"
|
||||
)}>
|
||||
<span
|
||||
className="flex max-w-2xl overflow-y-auto whitespace-nowrap"
|
||||
style={{
|
||||
WebkitOverflowScrolling: "touch",
|
||||
scrollbarWidth: "none",
|
||||
overflow: "-ms-scroll-chaining",
|
||||
msOverflowStyle: "-ms-autohiding-scrollbar",
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const KeyField: React.FC<InputFieldProps & { defaultValue: string }> = forwardRef<
|
||||
HTMLInputElement,
|
||||
InputFieldProps & { defaultValue: string }
|
||||
>(function KeyField(props, ref) {
|
||||
const id = useId();
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [currentValue, setCurrentValue] = useState<string>("");
|
||||
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<HTMLInputElement>) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const fullValue = target.value;
|
||||
setCurrentValue(fullValue);
|
||||
target.value = fullValue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(containerClassName)}>
|
||||
{!!label && !noLabel && (
|
||||
<Skeleton
|
||||
as={Label}
|
||||
htmlFor={id}
|
||||
loadingClassName="w-16"
|
||||
{...labelProps}
|
||||
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
|
||||
{label}
|
||||
{showAsteriskIndicator && !readOnly && passThrough.required ? (
|
||||
<span className="text-default ml-1 font-medium">*</span>
|
||||
) : null}
|
||||
{LockedIcon}
|
||||
</Skeleton>
|
||||
)}
|
||||
|
||||
<div
|
||||
dir="ltr"
|
||||
className="focus-within:ring-brand-default group relative mb-1 flex items-center rounded-md transition focus-within:outline-none focus-within:ring-2">
|
||||
{addOnLeading && (
|
||||
<Addon className={classNames("ltr:rounded-l-md rtl:rounded-r-md", addOnClassname)}>
|
||||
{addOnLeading}
|
||||
</Addon>
|
||||
)}
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
isFullWidth={inputIsFullWidth}
|
||||
className={classNames(
|
||||
className,
|
||||
"disabled:bg-subtle disabled:hover:border-subtle mb-0 rounded-r-none border-r-0 disabled:cursor-not-allowed",
|
||||
addOnLeading && "rounded-l-none border-l-0",
|
||||
isPasswordVisible && "inline-block",
|
||||
!isPasswordVisible && "hidden",
|
||||
"!my-0 !ring-0"
|
||||
)}
|
||||
{...passThrough}
|
||||
ref={ref}
|
||||
onInput={onInput}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
isFullWidth={inputIsFullWidth}
|
||||
className={classNames(
|
||||
className,
|
||||
"disabled:bg-subtle disabled:hover:border-subtle mb-0 rounded-r-none border-r-0 disabled:cursor-not-allowed",
|
||||
addOnLeading && "rounded-l-none border-l-0",
|
||||
!isPasswordVisible && "inline-block",
|
||||
isPasswordVisible && "hidden",
|
||||
"!my-0 !ring-0"
|
||||
)}
|
||||
disabled
|
||||
value={getHiddenKey()}
|
||||
/>
|
||||
<Addon className={classNames("ltr:rounded-r-md rtl:rounded-l-md", addOnClassname)}>
|
||||
<button
|
||||
className="text-emphasis h-9"
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
onClick={() => toggleIsPasswordVisible()}>
|
||||
{isPasswordVisible ? (
|
||||
<Icon name="eye-off" className="h-4 w-4 stroke-[2.5px]" />
|
||||
) : (
|
||||
<Icon name="eye" className="h-4 w-4 stroke-[2.5px]" />
|
||||
)}
|
||||
</button>
|
||||
</Addon>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default KeyField;
|
||||
@@ -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"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user