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 |
@@ -35,3 +35,6 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_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-axiom": "^0.17.0",
|
||||||
"next-swagger-doc": "^0.3.6",
|
"next-swagger-doc": "^0.3.6",
|
||||||
"next-validations": "^0.2.0",
|
"next-validations": "^0.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.9.0-beta",
|
||||||
"tzdata": "^1.0.30",
|
"tzdata": "^1.0.30",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"zod": "^3.22.4"
|
"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:watch": "yarn dev:build && jest --watch",
|
||||||
"test:cov": "yarn dev:build && jest --coverage",
|
"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: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: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",
|
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@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-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "*",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"ts-loader": "^9.4.3",
|
"ts-loader": "^9.4.3",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsconfig-paths": "^4.1.0",
|
"tsconfig-paths": "^4.1.0",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.9.0-beta"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"schema": "../../../packages/prisma/schema.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 { 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 { 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 { 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 { 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 { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
@@ -386,7 +386,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
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);
|
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
return keyData?.userId;
|
return keyData?.userId;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -31,12 +31,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) {
|
|||||||
return value.trim().length > 0;
|
return value.trim().length > 0;
|
||||||
}
|
}
|
||||||
if (typeof value === "object" && value !== null) {
|
if (typeof value === "object" && value !== null) {
|
||||||
return (
|
return typeof value.firstName === "string" && value.firstName.trim().length > 0;
|
||||||
typeof value.firstName === "string" &&
|
|
||||||
typeof value.lastName === "string" &&
|
|
||||||
value.firstName.trim().length > 0 &&
|
|
||||||
value.lastName.trim().length > 0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return false;
|
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", () => {
|
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 { 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 { 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 { 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 { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking";
|
||||||
import { safeParse } from "@/lib/safe-parse/safe-parse";
|
import { safeParse } from "@/lib/safe-parse/safe-parse";
|
||||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
@@ -675,7 +675,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
if (bearerToken) {
|
if (bearerToken) {
|
||||||
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
if (isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")) {
|
||||||
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("api.keyPrefix"));
|
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);
|
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
return keyData?.userId;
|
return keyData?.userId;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -101,10 +101,14 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
);
|
);
|
||||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||||
const rescheduledToUid = databaseBooking.rescheduled
|
const rescheduledToInfo = databaseBooking.rescheduled
|
||||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||||
|
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
||||||
|
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
uid: databaseBooking.uid,
|
uid: databaseBooking.uid,
|
||||||
@@ -116,7 +120,6 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
end: databaseBooking.endTime,
|
end: databaseBooking.endTime,
|
||||||
@@ -142,6 +145,7 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
rating: databaseBooking.rating,
|
rating: databaseBooking.rating,
|
||||||
icsUid: databaseBooking.iCalUID,
|
icsUid: databaseBooking.iCalUID,
|
||||||
rescheduledToUid,
|
rescheduledToUid,
|
||||||
|
rescheduledByEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||||
@@ -151,11 +155,15 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
return bookingTransformed;
|
return bookingTransformed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRescheduledToUid(bookingUid: string) {
|
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
|
||||||
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
||||||
return rescheduledTo?.uid;
|
return {
|
||||||
|
uid: rescheduledTo?.uid,
|
||||||
|
rescheduledBy: rescheduledTo?.rescheduledBy
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||||
if (databaseMetadata === null) return {};
|
if (databaseMetadata === null) return {};
|
||||||
|
|
||||||
@@ -218,7 +226,6 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
|
||||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||||
start: databaseBooking.startTime,
|
start: databaseBooking.startTime,
|
||||||
end: databaseBooking.endTime,
|
end: databaseBooking.endTime,
|
||||||
@@ -269,10 +276,13 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||||
const rescheduledToUid = databaseBooking.rescheduled
|
const rescheduledToInfo = databaseBooking.rescheduled
|
||||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
? await this.getRescheduledToInfo(databaseBooking.uid)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const rescheduledToUid = rescheduledToInfo?.uid;
|
||||||
|
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
|
||||||
|
|
||||||
const booking = {
|
const booking = {
|
||||||
id: databaseBooking.id,
|
id: databaseBooking.id,
|
||||||
uid: databaseBooking.uid,
|
uid: databaseBooking.uid,
|
||||||
@@ -299,6 +309,7 @@ export class OutputBookingsService_2024_08_13 {
|
|||||||
rating: databaseBooking.rating,
|
rating: databaseBooking.rating,
|
||||||
icsUid: databaseBooking.iCalUID,
|
icsUid: databaseBooking.iCalUID,
|
||||||
rescheduledToUid,
|
rescheduledToUid,
|
||||||
|
rescheduledByEmail,
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||||
|
|||||||
@@ -83,32 +83,31 @@ export class CalendarsService {
|
|||||||
calendarsToLoad,
|
calendarsToLoad,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
try {
|
const calendarBusyTimesQuery = await getBusyCalendarTimes(
|
||||||
const calendarBusyTimes = await getBusyCalendarTimes(
|
this.buildNonDelegationCredentials(credentials),
|
||||||
this.buildNonDelegationCredentials(credentials),
|
dateFrom,
|
||||||
dateFrom,
|
dateTo,
|
||||||
dateTo,
|
composedSelectedCalendars
|
||||||
composedSelectedCalendars
|
);
|
||||||
);
|
if (!calendarBusyTimesQuery.success) {
|
||||||
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) {
|
|
||||||
throw new InternalServerErrorException(
|
throw new InternalServerErrorException(
|
||||||
"Unable to fetch connected calendars events. Please try again later."
|
"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"]) {
|
async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from "crypto";
|
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 =>
|
export const isApiKey = (authString: string, prefix: string): boolean =>
|
||||||
authString?.startsWith(prefix ?? "cal_");
|
authString?.startsWith(prefix ?? "cal_");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.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 { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.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 { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
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 { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@@ -21,6 +24,9 @@ import { Module } from "@nestjs/common";
|
|||||||
PrismaEventTypeRepository,
|
PrismaEventTypeRepository,
|
||||||
PrismaRoutingFormResponseRepository,
|
PrismaRoutingFormResponseRepository,
|
||||||
PrismaTeamRepository,
|
PrismaTeamRepository,
|
||||||
|
PrismaFeaturesRepository,
|
||||||
|
CheckBookingLimitsService,
|
||||||
|
CacheService,
|
||||||
AvailableSlotsService,
|
AvailableSlotsService,
|
||||||
],
|
],
|
||||||
exports: [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 { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.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 { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||||
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
|
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
|
||||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.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 { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
||||||
@@ -20,7 +23,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
|||||||
bookingRepository: PrismaBookingRepository,
|
bookingRepository: PrismaBookingRepository,
|
||||||
selectedSlotRepository: PrismaSelectedSlotRepository,
|
selectedSlotRepository: PrismaSelectedSlotRepository,
|
||||||
eventTypeRepository: PrismaEventTypeRepository,
|
eventTypeRepository: PrismaEventTypeRepository,
|
||||||
userRepository: PrismaUserRepository
|
userRepository: PrismaUserRepository,
|
||||||
|
featuresRepository: PrismaFeaturesRepository
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
oooRepo: oooRepoDependency,
|
oooRepo: oooRepoDependency,
|
||||||
@@ -31,6 +35,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
|||||||
selectedSlotRepo: selectedSlotRepository,
|
selectedSlotRepo: selectedSlotRepository,
|
||||||
eventTypeRepo: eventTypeRepository,
|
eventTypeRepo: eventTypeRepository,
|
||||||
userRepo: userRepository,
|
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 { 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 { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
ThrottlerException,
|
ThrottlerException,
|
||||||
ThrottlerRequest,
|
ThrottlerRequest,
|
||||||
ThrottlerModuleOptions,
|
ThrottlerModuleOptions,
|
||||||
seconds,
|
|
||||||
} from "@nestjs/throttler";
|
} from "@nestjs/throttler";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -218,18 +217,18 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
if (authorizationHeader) {
|
if (authorizationHeader) {
|
||||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
||||||
return isApiKey(authorizationHeader, apiKeyPrefix)
|
return isApiKey(authorizationHeader, apiKeyPrefix)
|
||||||
? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
|
? `api_key_${sha256Hash(stripApiKey(authorizationHeader, apiKeyPrefix))}`
|
||||||
: `access_token_${authorizationHeader}`;
|
: `access_token_${sha256Hash(authorizationHeader)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oauthClientId = request.get(X_CAL_CLIENT_ID);
|
const oauthClientId = request.get(X_CAL_CLIENT_ID);
|
||||||
|
|
||||||
if (oauthClientId) {
|
if (oauthClientId) {
|
||||||
return `oauth_client_${oauthClientId}`;
|
return `oauth_client_${sha256Hash(oauthClientId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IP) {
|
if (IP) {
|
||||||
return `ip_${IP}`;
|
return `ip_${sha256Hash(IP.toString())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.verbose(`no tracker found: ${request.url}`);
|
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 { AuthMethods } from "@/lib/enums/auth-methods";
|
||||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
import { CreateApiKeyInput } from "@/modules/api-keys/inputs/create-api-key.input";
|
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) {
|
async refreshApiKey(authUserId: number, apiKey: string, refreshApiKeyInput: RefreshApiKeyInput) {
|
||||||
const strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
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);
|
const apiKeyInDb = await this.apiKeysRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
if (!apiKeyInDb) {
|
if (!apiKeyInDb) {
|
||||||
throw new UnauthorizedException("ApiKeysService - provided api key is not valid.");
|
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 { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
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 { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { RedisService } from "@/modules/redis/redis.service";
|
import { RedisService } from "@/modules/redis/redis.service";
|
||||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||||
@@ -21,6 +22,7 @@ import { Module } from "@nestjs/common";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule],
|
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule, TeamsEventTypesModule],
|
||||||
providers: [
|
providers: [
|
||||||
|
OrganizationsTeamsRepository,
|
||||||
EventTypesAtomService,
|
EventTypesAtomService,
|
||||||
ConferencingAtomsService,
|
ConferencingAtomsService,
|
||||||
AttributesAtomsService,
|
AttributesAtomsService,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { systemBeforeFieldEmail } from "@/ee/event-types/event-types_2024_06_14/
|
|||||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.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 { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.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 dbWrite: PrismaWriteService,
|
||||||
private readonly dbRead: PrismaReadService,
|
private readonly dbRead: PrismaReadService,
|
||||||
private readonly eventTypeService: EventTypesService_2024_06_14,
|
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> {
|
private async getTeamSlug(teamId: number): Promise<string> {
|
||||||
@@ -88,10 +90,12 @@ export class EventTypesAtomService {
|
|||||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType?.team?.id) {
|
if (!isUserOrganizationAdmin) {
|
||||||
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
|
if (eventType?.team?.id) {
|
||||||
} else {
|
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
|
||||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
} 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
|
// 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,
|
user: UserWithProfile,
|
||||||
teamId: number
|
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 eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
|
||||||
const bookingFields = body.bookingFields ? [...body.bookingFields] : undefined;
|
const bookingFields = body.bookingFields ? [...body.bookingFields] : undefined;
|
||||||
|
|
||||||
@@ -175,14 +180,31 @@ export class EventTypesAtomService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async checkCanUpdateTeamEventType(
|
async checkCanUpdateTeamEventType(
|
||||||
userId: number,
|
user: UserWithProfile,
|
||||||
eventTypeId: number,
|
eventTypeId: number,
|
||||||
teamId: number,
|
teamId: number,
|
||||||
scheduleId: number | null | undefined
|
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.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) {
|
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 { AuthMethods } from "@/lib/enums/auth-methods";
|
||||||
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
|
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
|
||||||
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
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 strippedApiKey = stripApiKey(apiKey, this.config.get<string>("api.keyPrefix"));
|
||||||
const apiKeyHash = hashAPIKey(strippedApiKey);
|
const apiKeyHash = sha256Hash(strippedApiKey);
|
||||||
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash);
|
||||||
if (!keyData) {
|
if (!keyData) {
|
||||||
throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid");
|
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);
|
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) {
|
async getOrgMembershipByUserId(organizationId: number, userId: number) {
|
||||||
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
|
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
import { getEnv } from "@/env";
|
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 { RefreshApiKeyOutput } from "@/modules/api-keys/outputs/refresh-api-key.output";
|
||||||
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
|
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
|
||||||
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.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?.length).toEqual(1);
|
||||||
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
|
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
|
||||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
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);
|
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
|
||||||
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 30 }).toJSDate();
|
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 30 }).toJSDate();
|
||||||
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
|
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
|
||||||
@@ -484,7 +484,7 @@ describe("Organizations Organizations Endpoints", () => {
|
|||||||
expect(managedOrgApiKeys?.length).toEqual(1);
|
expect(managedOrgApiKeys?.length).toEqual(1);
|
||||||
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
|
expect(managedOrgApiKeys?.[0]?.id).toBeDefined();
|
||||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
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);
|
expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey);
|
||||||
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 60 }).toJSDate();
|
const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 60 }).toJSDate();
|
||||||
expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt);
|
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 { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.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 { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input";
|
||||||
import { UpdateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/update-organization-team.input";
|
import { UpdateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/update-organization-team.input";
|
||||||
import {
|
import {
|
||||||
@@ -56,7 +57,10 @@ import { Team } from "@calcom/prisma/client";
|
|||||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||||
export class OrganizationsTeamsController {
|
export class OrganizationsTeamsController {
|
||||||
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
|
constructor(
|
||||||
|
private organizationsTeamsService: OrganizationsTeamsService,
|
||||||
|
private organizationsMembershipService: OrganizationsMembershipService
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: "Get all teams" })
|
@ApiOperation({ summary: "Get all teams" })
|
||||||
@@ -84,12 +88,11 @@ export class OrganizationsTeamsController {
|
|||||||
@GetUser() user: UserWithProfile
|
@GetUser() user: UserWithProfile
|
||||||
): Promise<OrgMeTeamsOutputResponseDto> {
|
): Promise<OrgMeTeamsOutputResponseDto> {
|
||||||
const { skip, take } = queryParams;
|
const { skip, take } = queryParams;
|
||||||
const teams = await this.organizationsTeamsService.getPaginatedOrgUserTeams(
|
const isOrgAdminOrOwner = await this.organizationsMembershipService.isOrgAdminOrOwner(orgId, user.id);
|
||||||
orgId,
|
const teams = isOrgAdminOrOwner
|
||||||
user.id,
|
? await this.organizationsTeamsService.getPaginatedOrgTeamsWithMembers(orgId, skip ?? 0, take ?? 250)
|
||||||
skip ?? 0,
|
: await this.organizationsTeamsService.getPaginatedOrgUserTeams(orgId, user.id, skip ?? 0, take ?? 250);
|
||||||
take ?? 250
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
data: teams.map((team) => {
|
data: teams.map((team) => {
|
||||||
|
|||||||
@@ -107,4 +107,17 @@ export class OrganizationsTeamsRepository {
|
|||||||
take,
|
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;
|
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) {
|
async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) {
|
||||||
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take);
|
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take);
|
||||||
return teams;
|
return teams;
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
"@calcom/platform-libraries/app-store": ["../../../packages/platform/libraries/app-store.ts"],
|
"@calcom/platform-libraries/app-store": ["../../../packages/platform/libraries/app-store.ts"],
|
||||||
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
|
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
|
||||||
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.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,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
@@ -34,6 +34,6 @@
|
|||||||
"eslint-config-next": "15.1.6",
|
"eslint-config-next": "15.1.6",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"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 { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||||
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
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";
|
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
|
||||||
|
|
||||||
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
|
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||||
"team-booking-page-cache"
|
"team-booking-page-cache"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export async function getEnrichedEventType({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function shouldUseApiV2ForTeamSlots(teamId: number): Promise<boolean> {
|
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 teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(teamId, "use-api-v2-for-team-slots");
|
||||||
const useApiV2 = teamHasApiV2Route && Boolean(process.env.NEXT_PUBLIC_API_V2_URL);
|
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 { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
|
||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
import Shell from "@calcom/features/shell/Shell";
|
import Shell from "@calcom/features/shell/Shell";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import UpgradeTipWrapper from "./UpgradeTipWrapper";
|
import UpgradeTipWrapper from "./UpgradeTipWrapper";
|
||||||
|
|
||||||
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
|
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
|
||||||
|
|
||||||
if (!insightsEnabled) {
|
if (!insightsEnabled) {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BookingsByHourChartContent } from "@calcom/features/insights/components/BookingsByHourChart";
|
|
||||||
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
|
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
|
||||||
|
import { BookingsByHourChartContent } from "@calcom/features/insights/components/booking/BookingsByHourChart";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
// Sample data for playground testing
|
// Sample data for playground testing
|
||||||
|
|||||||
+4
-1
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
|
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";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
// Random sample data for playground testing
|
// 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 { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
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";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export const generateMetadata = async () =>
|
|||||||
|
|
||||||
const getCachedApiKeys = unstable_cache(
|
const getCachedApiKeys = unstable_cache(
|
||||||
async (userId: number) => {
|
async (userId: number) => {
|
||||||
return await ApiKeyRepository.findApiKeysFromUserId({ userId });
|
return await PrismaApiKeyRepository.findApiKeysFromUserId({ userId });
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
{ revalidate: 3600, tags: ["viewer.apiKeys.list"] } // Cache for 1 hour
|
{ 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 { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||||
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
|
|||||||
|
|
||||||
const getTeamFeatures = unstable_cache(
|
const getTeamFeatures = unstable_cache(
|
||||||
async (teamId: number) => {
|
async (teamId: number) => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
return await featuresRepository.getTeamFeatures(teamId);
|
return await featuresRepository.getTeamFeatures(teamId);
|
||||||
},
|
},
|
||||||
["team-features"],
|
["team-features"],
|
||||||
|
|||||||
+39
-1
@@ -1,7 +1,15 @@
|
|||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
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 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 SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -14,10 +22,40 @@ export const generateMetadata = async () =>
|
|||||||
|
|
||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const t = await getTranslate();
|
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 (
|
return (
|
||||||
<SettingsHeader title={t("attributes")} description={t("attribute_meta_description")}>
|
<SettingsHeader title={t("attributes")} description={t("attribute_meta_description")}>
|
||||||
<OrgSettingsAttributesPage />
|
<OrgSettingsAttributesPage permissions={{ canEdit, canDelete, canCreate }} />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+26
-1
@@ -1,7 +1,15 @@
|
|||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
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 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 SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
|
|||||||
|
|
||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const t = await getTranslate();
|
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 (
|
return (
|
||||||
<SettingsHeader title={t("directory_sync")} description={t("directory_sync_description")}>
|
<SettingsHeader title={t("directory_sync")} description={t("directory_sync_description")}>
|
||||||
<DirectorySyncTeamView />
|
<DirectorySyncTeamView permissions={{ canEdit }} />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+34
-1
@@ -1,7 +1,15 @@
|
|||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
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 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 SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -15,9 +23,34 @@ export const generateMetadata = async () =>
|
|||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const t = await getTranslate();
|
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 (
|
return (
|
||||||
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
|
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
|
||||||
<PrivacyView />
|
<PrivacyView permissions={{ canRead, canEdit }} />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+26
-1
@@ -1,7 +1,15 @@
|
|||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
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 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 SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -14,10 +22,27 @@ export const generateMetadata = async () =>
|
|||||||
|
|
||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const t = await getTranslate();
|
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 (
|
return (
|
||||||
<SettingsHeader title={t("sso_configuration")} description={t("sso_configuration_description_orgs")}>
|
<SettingsHeader title={t("sso_configuration")} description={t("sso_configuration_description_orgs")}>
|
||||||
<OrgSSOView />
|
<OrgSSOView permissions={{ canEdit }} />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+30
-1
@@ -1,7 +1,15 @@
|
|||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
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 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 SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -15,9 +23,30 @@ export const generateMetadata = async () =>
|
|||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const t = await getTranslate();
|
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 (
|
return (
|
||||||
<SettingsHeader title={t("general")} description={t("general_description")} borderInShellHeader={true}>
|
<SettingsHeader title={t("general")} description={t("general_description")} borderInShellHeader={true}>
|
||||||
<LegacyPage />
|
<LegacyPage permissions={{ canRead, canEdit }} />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+43
-1
@@ -1,7 +1,16 @@
|
|||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
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 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 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 () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -13,14 +22,47 @@ export const generateMetadata = async () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
const t = await getTranslate();
|
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 (
|
return (
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={t("profile")}
|
title={t("profile")}
|
||||||
description={t("profile_org_description")}
|
description={t("profile_org_description")}
|
||||||
borderInShellHeader={true}>
|
borderInShellHeader={true}>
|
||||||
<LegacyPage />
|
<LegacyPage
|
||||||
|
permissions={{
|
||||||
|
canEdit,
|
||||||
|
canRead,
|
||||||
|
canDelete,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+12
@@ -44,5 +44,17 @@ describe("usePermissions", () => {
|
|||||||
|
|
||||||
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
|
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";
|
return "all";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out internal keys like _resource when checking permissions
|
// Check if user has manage permission for this resource
|
||||||
const allResourcePerms = Object.keys(resourceConfig)
|
const hasManagePermission = permissions.includes(`${resource}.manage`);
|
||||||
.filter((action) => !action.startsWith("_"))
|
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}`);
|
.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}`);
|
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
|
||||||
|
|
||||||
if (hasAllPerms) return "all";
|
if (hasAllCrudPerms) return "all";
|
||||||
if (hasReadPerm) return "read";
|
if (hasReadPerm) return "read";
|
||||||
return "none";
|
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 { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||||
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ const getCachedTeamRoles = unstable_cache(
|
|||||||
|
|
||||||
const getCachedTeamFeature = unstable_cache(
|
const getCachedTeamFeature = unstable_cache(
|
||||||
async (teamId: number, feature: keyof AppFlags) => {
|
async (teamId: number, feature: keyof AppFlags) => {
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
||||||
return res;
|
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 { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
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 { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||||
|
|
||||||
import { TeamMembersView } from "~/teams/team-members-view";
|
import { TeamMembersView } from "~/teams/team-members-view";
|
||||||
@@ -37,7 +37,7 @@ const getCachedTeamAttributes = unstable_cache(
|
|||||||
async (organizationId?: number) => {
|
async (organizationId?: number) => {
|
||||||
if (!organizationId) return [];
|
if (!organizationId) return [];
|
||||||
try {
|
try {
|
||||||
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
|
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return [];
|
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 { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ const getCachedTeamRoles = (teamId: number) =>
|
|||||||
const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) =>
|
const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) =>
|
||||||
unstable_cache(
|
unstable_cache(
|
||||||
async () => {
|
async () => {
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@ import { _generateMetadata } from "app/_utils";
|
|||||||
import { unstable_cache } from "next/cache";
|
import { unstable_cache } from "next/cache";
|
||||||
|
|
||||||
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
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 { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||||
|
|
||||||
import { MembersView } from "~/members/members-view";
|
import { MembersView } from "~/members/members-view";
|
||||||
@@ -19,7 +19,7 @@ export const generateMetadata = async () =>
|
|||||||
|
|
||||||
const getCachedAttributes = unstable_cache(
|
const getCachedAttributes = unstable_cache(
|
||||||
async (orgId: number) => {
|
async (orgId: number) => {
|
||||||
return await AttributeRepository.findAllByOrgIdWithOptions({ orgId });
|
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId });
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
|
{ 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 { HttpError } from "@calcom/lib/http-error";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
import { signupSchema } from "@calcom/prisma/zod-utils";
|
import { signupSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
async function ensureSignupIsEnabled(body: Record<string, string>) {
|
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
|
// Still allow signups if there is a team invite
|
||||||
if (token) return;
|
if (token) return;
|
||||||
|
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
|
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
|
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
|
// Create a new response with the image buffer
|
||||||
const imageResponse = new NextResponse(buffer);
|
const imageResponse = new NextResponse(buffer as BodyInit);
|
||||||
|
|
||||||
// Set the appropriate headers
|
// Set the appropriate headers
|
||||||
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
|
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
|
||||||
|
|||||||
@@ -515,7 +515,9 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
{!isPending && (
|
{!isPending && (
|
||||||
<div>
|
<div>
|
||||||
{(provider?.label || locationToDisplay?.startsWith("https://")) &&
|
{(provider?.label ||
|
||||||
|
(typeof locationToDisplay === "string" &&
|
||||||
|
locationToDisplay?.startsWith("https://"))) &&
|
||||||
locationToDisplay.startsWith("http") && (
|
locationToDisplay.startsWith("http") && (
|
||||||
<a
|
<a
|
||||||
href={locationToDisplay}
|
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)
|
// Check if team has API v2 feature flag enabled (same logic as team pages)
|
||||||
let useApiV2 = false;
|
let useApiV2 = false;
|
||||||
if (isTeamEvent && hashedLink.eventType.team?.id) {
|
if (isTeamEvent && hashedLink.eventType.team?.id) {
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(
|
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(
|
||||||
hashedLink.eventType.team.id,
|
hashedLink.eventType.team.id,
|
||||||
"use-api-v2-for-team-slots"
|
"use-api-v2-for-team-slots"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { GetServerSidePropsResult } from "next";
|
import type { GetServerSidePropsResult } from "next";
|
||||||
|
|
||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
|
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||||
// Check if organizations are enabled
|
// Check if organizations are enabled
|
||||||
if (!organizations) {
|
if (!organizations) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const querySchema = z.object({
|
|||||||
|
|
||||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||||
"email-verification"
|
"email-verification"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
const organizationSettings = getOrganizationSEOSettings(team);
|
const organizationSettings = getOrganizationSEOSettings(team);
|
||||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
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 teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
||||||
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
|
|
||||||
// Provided by Rewrite from next.config.js
|
// Provided by Rewrite from next.config.js
|
||||||
const isOrgProfile = context.query?.isOrgProfile === "1";
|
const isOrgProfile = context.query?.isOrgProfile === "1";
|
||||||
const featuresRepository = new FeaturesRepository();
|
const featuresRepository = new FeaturesRepository(prisma);
|
||||||
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
|
||||||
|
|
||||||
log.debug("getServerSideProps", {
|
log.debug("getServerSideProps", {
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
|
|
||||||
const videoReference = getCalVideoReference(bookingObj.references);
|
const videoReference = getCalVideoReference(bookingObj.references);
|
||||||
|
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository(prisma);
|
||||||
const displayLogInOverlay = profile?.organizationId
|
const displayLogInOverlay = profile?.organizationId
|
||||||
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
|
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
|
||||||
: false;
|
: false;
|
||||||
|
|||||||
@@ -1229,8 +1229,21 @@ function RecurringBookings({
|
|||||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||||
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
||||||
<br />
|
<br />
|
||||||
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
|
{formatToLocalizedTime({
|
||||||
{formatToLocalizedTime(dayjs(dateStr).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
|
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">
|
<span className="text-bookinglight">
|
||||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||||
</span>
|
</span>
|
||||||
@@ -1249,14 +1262,19 @@ function RecurringBookings({
|
|||||||
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
|
||||||
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
|
||||||
<br />
|
<br />
|
||||||
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
|
{formatToLocalizedTime({
|
||||||
{formatToLocalizedTime(
|
date: dayjs(dateStr),
|
||||||
dayjs(dateStr).add(duration, "m"),
|
locale: language,
|
||||||
language,
|
hour12: !is24h,
|
||||||
undefined,
|
timeZone: tz,
|
||||||
!is24h,
|
})}{" "}
|
||||||
tz
|
-{" "}
|
||||||
)}{" "}
|
{formatToLocalizedTime({
|
||||||
|
date: dayjs(dateStr).add(duration, "m"),
|
||||||
|
locale: language,
|
||||||
|
hour12: !is24h,
|
||||||
|
timeZone: tz,
|
||||||
|
})}{" "}
|
||||||
<span className="text-bookinglight">
|
<span className="text-bookinglight">
|
||||||
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
|
||||||
</span>
|
</span>
|
||||||
@@ -1273,8 +1291,13 @@ function RecurringBookings({
|
|||||||
<div className={classNames(isCancelled ? "line-through" : "")}>
|
<div className={classNames(isCancelled ? "line-through" : "")}>
|
||||||
{formatToLocalizedDate(date, language, "full", tz)}
|
{formatToLocalizedDate(date, language, "full", tz)}
|
||||||
<br />
|
<br />
|
||||||
{formatToLocalizedTime(date, language, undefined, !is24h, tz)} -{" "}
|
{formatToLocalizedTime({ date, locale: language, hour12: !is24h, timeZone: tz })} -{" "}
|
||||||
{formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
|
{formatToLocalizedTime({
|
||||||
|
date: dayjs(date).add(duration, "m"),
|
||||||
|
locale: language,
|
||||||
|
hour12: !is24h,
|
||||||
|
timeZone: tz,
|
||||||
|
})}{" "}
|
||||||
<span className="text-bookinglight">({formatToLocalizedTimezone(date, language, tz)})</span>
|
<span className="text-bookinglight">({formatToLocalizedTimezone(date, language, tz)})</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
FailedBookingsByField,
|
FailedBookingsByField,
|
||||||
RoutedToPerPeriod,
|
RoutedToPerPeriod,
|
||||||
RoutingFunnel,
|
RoutingFunnel,
|
||||||
} from "@calcom/features/insights/components";
|
} from "@calcom/features/insights/components/routing";
|
||||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
AverageEventDurationChart,
|
AverageEventDurationChart,
|
||||||
BookingKPICards,
|
BookingKPICards,
|
||||||
|
BookingsByHourChart,
|
||||||
EventTrendsChart,
|
EventTrendsChart,
|
||||||
HighestNoShowHostTable,
|
HighestNoShowHostTable,
|
||||||
HighestRatedMembersTable,
|
HighestRatedMembersTable,
|
||||||
BookingsByHourChart,
|
|
||||||
LeastBookedTeamMembersTable,
|
LeastBookedTeamMembersTable,
|
||||||
LowestRatedMembersTable,
|
LowestRatedMembersTable,
|
||||||
MostBookedTeamMembersTable,
|
MostBookedTeamMembersTable,
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
PopularEventsTable,
|
PopularEventsTable,
|
||||||
RecentFeedbackTable,
|
RecentFeedbackTable,
|
||||||
TimezoneBadge,
|
TimezoneBadge,
|
||||||
} from "@calcom/features/insights/components";
|
} from "@calcom/features/insights/components/booking";
|
||||||
import "@calcom/features/insights/components/tremor.css";
|
import "@calcom/features/insights/components/tremor.css";
|
||||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||||
import { Download } from "@calcom/features/insights/filters/Download";
|
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: "" }],
|
teams: teamsToCreateFromStore.length ? teamsToCreateFromStore : [{ name: "" }],
|
||||||
moveTeams: teams.map((team) => {
|
moveTeams: teams.map((team) => {
|
||||||
const teamToMigrateInStore = teamsToMigrateFromStore.find((t) => t.id === team.id);
|
const teamToMigrateInStore = teamsToMigrateFromStore.find((t) => t.id === team.id);
|
||||||
|
const slugConflictsWithOrg = team.slug === orgSlug;
|
||||||
return {
|
return {
|
||||||
id: team.id,
|
id: team.id,
|
||||||
shouldMove: !!teamToMigrateInStore,
|
// The team with conflicting slug must be moved
|
||||||
|
shouldMove: slugConflictsWithOrg || !!teamToMigrateInStore,
|
||||||
newSlug: teamToMigrateInStore?.slug || getSuggestedSlug({ teamSlug: team.slug, orgSlug }),
|
newSlug: teamToMigrateInStore?.slug || getSuggestedSlug({ teamSlug: team.slug, orgSlug }),
|
||||||
name: team.name,
|
name: team.name,
|
||||||
};
|
};
|
||||||
@@ -160,6 +162,9 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
|
|||||||
</label>
|
</label>
|
||||||
<ul className="mb-8 space-y-4">
|
<ul className="mb-8 space-y-4">
|
||||||
{moveTeams.map((team, index) => {
|
{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 (
|
return (
|
||||||
<li key={team.id}>
|
<li key={team.id}>
|
||||||
<Controller
|
<Controller
|
||||||
@@ -167,9 +172,11 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
|
|||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
defaultValue={value}
|
defaultValue={value}
|
||||||
checked={value}
|
checked={value || slugConflictsWithOrg}
|
||||||
onChange={onChange}
|
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 endTime = new Date(booking.endTime);
|
||||||
const startTime = new Date(booking.startTime);
|
const startTime = new Date(booking.startTime);
|
||||||
|
const timeZone = booking.user?.timeZone;
|
||||||
|
|
||||||
useDailyEvent("left-meeting", () => {
|
useDailyEvent("left-meeting", () => {
|
||||||
if (rediectAttendeeToOnExit) {
|
if (rediectAttendeeToOnExit) {
|
||||||
@@ -306,11 +307,11 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
|
|||||||
<h3>{t("what")}:</h3>
|
<h3>{t("what")}:</h3>
|
||||||
<p>{booking.title}</p>
|
<p>{booking.title}</p>
|
||||||
<h3>{t("invitee_timezone")}:</h3>
|
<h3>{t("invitee_timezone")}:</h3>
|
||||||
<p>{booking.user?.timeZone}</p>
|
<p>{timeZone}</p>
|
||||||
<h3>{t("when")}:</h3>
|
<h3>{t("when")}:</h3>
|
||||||
<p suppressHydrationWarning={true}>
|
<p suppressHydrationWarning={true}>
|
||||||
{formatToLocalizedDate(startTime)} <br />
|
{formatToLocalizedDate(startTime)} <br />
|
||||||
{formatToLocalizedTime(startTime)}
|
{formatToLocalizedTime({ date: startTime, timeZone })}
|
||||||
</p>
|
</p>
|
||||||
<h3>{t("time_left")}</h3>
|
<h3>{t("time_left")}</h3>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.5.7",
|
"version": "5.5.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -195,7 +195,7 @@
|
|||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"tailwindcss-animate": "^1.0.6",
|
"tailwindcss-animate": "^1.0.6",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.9.0-beta"
|
||||||
},
|
},
|
||||||
"nextBundleAnalysis": {
|
"nextBundleAnalysis": {
|
||||||
"budget": 358400,
|
"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 page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
|
||||||
await expect(offerSeatsToggle).toBeDisabled();
|
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", () => {
|
test.describe("Interface Language Tests", () => {
|
||||||
|
|||||||
@@ -871,6 +871,10 @@
|
|||||||
"create_team": "Create Team",
|
"create_team": "Create Team",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"nameless_team": "Nameless Team",
|
"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",
|
"oauth_client_deletion_message": "OAuth client deleted successfully",
|
||||||
"create_new_team_description": "Create a new team to collaborate with users.",
|
"create_new_team_description": "Create a new team to collaborate with users.",
|
||||||
"create_new_team": "Create a new team",
|
"create_new_team": "Create a new team",
|
||||||
@@ -1836,6 +1840,8 @@
|
|||||||
"edit_event_type": "Edit event type",
|
"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_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_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",
|
"collective_scheduling": "Collective Scheduling",
|
||||||
"make_it_easy_to_book": "Make it easy to book your team when everyone is available.",
|
"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.",
|
"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_creating_role": "Error creating role",
|
||||||
"error_updating_role": "Error updating role",
|
"error_updating_role": "Error updating role",
|
||||||
"pbac_desc_create_roles": "Create roles",
|
"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_view_roles": "View roles",
|
||||||
"pbac_desc_update_roles": "Update roles",
|
"pbac_desc_update_roles": "Update roles",
|
||||||
"pbac_desc_delete_roles": "Delete 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_create_event_types": "Create event types",
|
||||||
"pbac_desc_view_event_types": "View event types",
|
"pbac_desc_view_event_types": "View event types",
|
||||||
"pbac_desc_update_event_types": "Update event types",
|
"pbac_desc_update_event_types": "Update event types",
|
||||||
"pbac_desc_delete_event_types": "Delete 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_create_teams": "Create teams",
|
||||||
"pbac_desc_view_team_details": "View team details",
|
"pbac_desc_view_team_details": "View team details",
|
||||||
"pbac_desc_update_team_settings": "Update team settings",
|
"pbac_desc_update_team_settings": "Update team settings",
|
||||||
@@ -3318,7 +3329,7 @@
|
|||||||
"pbac_desc_invite_team_members": "Invite team members",
|
"pbac_desc_invite_team_members": "Invite team members",
|
||||||
"pbac_desc_remove_team_members": "Remove team members",
|
"pbac_desc_remove_team_members": "Remove team members",
|
||||||
"pbac_desc_change_team_member_role": "Change role of 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_create_organization": "Create organization",
|
||||||
"pbac_desc_view_organization_details": "View organization details",
|
"pbac_desc_view_organization_details": "View organization details",
|
||||||
"pbac_desc_list_organization_members": "List organization members",
|
"pbac_desc_list_organization_members": "List organization members",
|
||||||
@@ -3333,7 +3344,7 @@
|
|||||||
"pbac_desc_view_organization_bookings": "View organization bookings",
|
"pbac_desc_view_organization_bookings": "View organization bookings",
|
||||||
"pbac_desc_view_booking_recordings": "View booking recordings",
|
"pbac_desc_view_booking_recordings": "View booking recordings",
|
||||||
"pbac_desc_update_bookings": "Update bookings",
|
"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_view_team_insights": "View team insights",
|
||||||
"pbac_desc_manage_team_insights": "Manage 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",
|
"read_permission_auto_enabled_tooltip": "Read permission is automatically enabled when creating, updating, or deleting a resource",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type UserPageProps = {
|
|||||||
| "length"
|
| "length"
|
||||||
| "hidden"
|
| "hidden"
|
||||||
| "lockTimeZoneToggleOnBookingPage"
|
| "lockTimeZoneToggleOnBookingPage"
|
||||||
|
| "lockedTimeZone"
|
||||||
| "requiresConfirmation"
|
| "requiresConfirmation"
|
||||||
| "canSendCalVideoTranscriptionEmails"
|
| "canSendCalVideoTranscriptionEmails"
|
||||||
| "requiresBookerEmailVerification"
|
| "requiresBookerEmailVerification"
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/booking-limits";
|
||||||
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||||
import { checkBookingLimits, checkBookingLimit } from "@calcom/lib/intervalLimits/server/checkBookingLimits";
|
|
||||||
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
|
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
|
||||||
|
|
||||||
type Mockdata = {
|
type Mockdata = {
|
||||||
@@ -21,25 +21,27 @@ const MOCK_DATA: Mockdata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkBookingLimitsService = getCheckBookingLimitsService();
|
||||||
|
|
||||||
describe("Check Booking Limits Tests", () => {
|
describe("Check Booking Limits Tests", () => {
|
||||||
it("Should return no errors", async () => {
|
it("Should return no errors", async () => {
|
||||||
prismaMock.booking.count.mockResolvedValue(0);
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
checkBookingLimitsService.checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
||||||
).resolves.toBeTruthy();
|
).resolves.toBeTruthy();
|
||||||
});
|
});
|
||||||
it("Should throw an error", async () => {
|
it("Should throw an error", async () => {
|
||||||
// Mock there being two a day
|
// Mock there being two a day
|
||||||
prismaMock.booking.count.mockResolvedValue(2);
|
prismaMock.booking.count.mockResolvedValue(2);
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
checkBookingLimitsService.checkBookingLimits(MOCK_DATA.bookingLimits, MOCK_DATA.startDate, MOCK_DATA.id)
|
||||||
).rejects.toThrowError();
|
).rejects.toThrowError();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Should pass with multiple booking limits", async () => {
|
it("Should pass with multiple booking limits", async () => {
|
||||||
prismaMock.booking.count.mockResolvedValue(0);
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimits(
|
checkBookingLimitsService.checkBookingLimits(
|
||||||
{
|
{
|
||||||
PER_DAY: 1,
|
PER_DAY: 1,
|
||||||
PER_WEEK: 2,
|
PER_WEEK: 2,
|
||||||
@@ -51,8 +53,9 @@ describe("Check Booking Limits Tests", () => {
|
|||||||
});
|
});
|
||||||
it("Should pass with multiple booking limits with one undefined", async () => {
|
it("Should pass with multiple booking limits with one undefined", async () => {
|
||||||
prismaMock.booking.count.mockResolvedValue(0);
|
prismaMock.booking.count.mockResolvedValue(0);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimits(
|
checkBookingLimitsService.checkBookingLimits(
|
||||||
{
|
{
|
||||||
PER_DAY: 1,
|
PER_DAY: 1,
|
||||||
PER_WEEK: undefined,
|
PER_WEEK: undefined,
|
||||||
@@ -65,7 +68,7 @@ describe("Check Booking Limits Tests", () => {
|
|||||||
it("Should handle multiple limits correctly", async () => {
|
it("Should handle multiple limits correctly", async () => {
|
||||||
prismaMock.booking.count.mockResolvedValue(1);
|
prismaMock.booking.count.mockResolvedValue(1);
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimit({
|
checkBookingLimitsService.checkBookingLimit({
|
||||||
key: "PER_DAY",
|
key: "PER_DAY",
|
||||||
limitingNumber: 2,
|
limitingNumber: 2,
|
||||||
eventStartDate: MOCK_DATA.startDate,
|
eventStartDate: MOCK_DATA.startDate,
|
||||||
@@ -74,7 +77,7 @@ describe("Check Booking Limits Tests", () => {
|
|||||||
).resolves.not.toThrow();
|
).resolves.not.toThrow();
|
||||||
prismaMock.booking.count.mockResolvedValue(3);
|
prismaMock.booking.count.mockResolvedValue(3);
|
||||||
expect(
|
expect(
|
||||||
checkBookingLimit({
|
checkBookingLimitsService.checkBookingLimit({
|
||||||
key: "PER_WEEK",
|
key: "PER_WEEK",
|
||||||
limitingNumber: 2,
|
limitingNumber: 2,
|
||||||
eventStartDate: MOCK_DATA.startDate,
|
eventStartDate: MOCK_DATA.startDate,
|
||||||
|
|||||||
@@ -1281,12 +1281,15 @@ describe("getSchedule", () => {
|
|||||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||||
|
|
||||||
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
|
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({
|
||||||
{
|
success: true,
|
||||||
start: `${plus3DateString}T04:00:00.000Z`,
|
data: [
|
||||||
end: `${plus3DateString}T05:59:59.000Z`,
|
{
|
||||||
},
|
start: `${plus3DateString}T04:00:00.000Z`,
|
||||||
]);
|
end: `${plus3DateString}T05:59:59.000Z`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const scenarioData = {
|
const scenarioData = {
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
@@ -1347,12 +1350,15 @@ describe("getSchedule", () => {
|
|||||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||||
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
|
||||||
|
|
||||||
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([
|
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({
|
||||||
{
|
success: true,
|
||||||
start: `${plus3DateString}T04:00:00.000Z`,
|
data: [
|
||||||
end: `${plus3DateString}T05:59:59.000Z`,
|
{
|
||||||
},
|
start: `${plus3DateString}T04:00:00.000Z`,
|
||||||
]);
|
end: `${plus3DateString}T05:59:59.000Z`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const scenarioData = {
|
const scenarioData = {
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
@@ -1421,7 +1427,7 @@ describe("getSchedule", () => {
|
|||||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||||
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
|
||||||
|
|
||||||
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue([]);
|
CalendarManagerMock.getBusyCalendarTimes.mockResolvedValue({ success: true, data: [] });
|
||||||
|
|
||||||
const scenarioData = {
|
const scenarioData = {
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
|
|||||||
@@ -438,7 +438,6 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
schedulingType: SchedulingType.MANAGED,
|
schedulingType: SchedulingType.MANAGED,
|
||||||
requiresBookerEmailVerification: false,
|
requiresBookerEmailVerification: false,
|
||||||
lockTimeZoneToggleOnBookingPage: false,
|
lockTimeZoneToggleOnBookingPage: false,
|
||||||
lockedTimeZone: "Europe/London",
|
|
||||||
useEventTypeDestinationCalendarEmail: false,
|
useEventTypeDestinationCalendarEmail: false,
|
||||||
workflows: [],
|
workflows: [],
|
||||||
parentId: 1,
|
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:**
|
**Pattern:**
|
||||||
|
|
||||||
Prisma<Entity>Repository.ts
|
Prisma`<Entity>`Repository.ts
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```ts
|
```js
|
||||||
// File: PrismaAppRepository.ts
|
// File: PrismaAppRepository.ts
|
||||||
export class PrismaAppRepository { ... }
|
export class PrismaAppRepository { ... }
|
||||||
|
|
||||||
@@ -80,11 +80,11 @@ Keep naming specific — avoid generic names like AppService.ts.
|
|||||||
|
|
||||||
**Pattern:**
|
**Pattern:**
|
||||||
|
|
||||||
<Entity>Service.ts
|
`<Entity>`Service.ts
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
|
|
||||||
```ts
|
```js
|
||||||
// File: MembershipService.ts
|
// File: MembershipService.ts
|
||||||
export class MembershipService { ... }
|
export class MembershipService { ... }
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,11 @@
|
|||||||
"icon": "code-simple",
|
"icon": "code-simple",
|
||||||
"pages": ["developing/guides/embeds/embed-events"]
|
"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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| hideBranding | No | For hiding any branding on the booker |
|
||||||
| isAway | No | Sets the booker component to the away state |
|
| isAway | No | Sets the booker component to the away state |
|
||||||
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
|
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
|
||||||
|
|||||||
+2
-1
@@ -109,7 +109,7 @@
|
|||||||
"prismock": "^1.33.4",
|
"prismock": "^1.33.4",
|
||||||
"resize-observer-polyfill": "^1.5.1",
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
"tsc-absolute": "^1.0.0",
|
"tsc-absolute": "^1.0.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "5.9.0-beta",
|
||||||
"vitest": "^2.1.1",
|
"vitest": "^2.1.1",
|
||||||
"vitest-fetch-mock": "^0.3.0",
|
"vitest-fetch-mock": "^0.3.0",
|
||||||
"vitest-mock-extended": "^2.0.2"
|
"vitest-mock-extended": "^2.0.2"
|
||||||
@@ -120,6 +120,7 @@
|
|||||||
"@next/third-parties": "^14.2.5",
|
"@next/third-parties": "^14.2.5",
|
||||||
"@vercel/functions": "^1.4.0",
|
"@vercel/functions": "^1.4.0",
|
||||||
"city-timezones": "^1.2.1",
|
"city-timezones": "^1.2.1",
|
||||||
|
"date-fns-tz": "^3.2.0",
|
||||||
"eslint": "^8.34.0",
|
"eslint": "^8.34.0",
|
||||||
"p-limit": "^6.2.0",
|
"p-limit": "^6.2.0",
|
||||||
"turbo": "^1.10.1"
|
"turbo": "^1.10.1"
|
||||||
|
|||||||
@@ -31,6 +31,6 @@
|
|||||||
"eslint-plugin-react": "^7.30.1",
|
"eslint-plugin-react": "^7.30.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"ts-node": "^10.9.1",
|
"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"),
|
zapier: import("../../zapier/pages/setup/_getServerSideProps"),
|
||||||
stripe: import("../../stripepayment/pages/setup/_getServerSideProps"),
|
stripe: import("../../stripepayment/pages/setup/_getServerSideProps"),
|
||||||
hitpay: import("../../hitpay/pages/setup/_getServerSideProps"),
|
hitpay: import("../../hitpay/pages/setup/_getServerSideProps"),
|
||||||
|
btcpayserver: import("../../btcpayserver/pages/setup/_getServerSideProps"),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const AppSetupMap = {
|
|||||||
stripe: dynamic(() => import("../../stripepayment/pages/setup")),
|
stripe: dynamic(() => import("../../stripepayment/pages/setup")),
|
||||||
paypal: dynamic(() => import("../../paypal/pages/setup")),
|
paypal: dynamic(() => import("../../paypal/pages/setup")),
|
||||||
hitpay: dynamic(() => import("../../hitpay/pages/setup")),
|
hitpay: dynamic(() => import("../../hitpay/pages/setup")),
|
||||||
|
btcpayserver: dynamic(() => import("../../btcpayserver/pages/setup")),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AppSetupPage = (props: { slug: string }) => {
|
export const AppSetupPage = (props: { slug: string }) => {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export const getCalendar = async (
|
|||||||
log.warn(`calendar of type ${calendarType} is not implemented`);
|
log.warn(`calendar of type ${calendarType} is not implemented`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
log.info("Got calendarApp", calendarApp.lib.CalendarService);
|
|
||||||
const CalendarService = calendarApp.lib.CalendarService;
|
const CalendarService = calendarApp.lib.CalendarService;
|
||||||
return new CalendarService(credential);
|
return new CalendarService(credential);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const AppSettingsComponentsMap = {
|
|||||||
export const EventTypeAddonMap = {
|
export const EventTypeAddonMap = {
|
||||||
alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")),
|
alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")),
|
||||||
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
|
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
|
||||||
|
btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppCardInterface")),
|
||||||
closecom: dynamic(() => import("./closecom/components/EventTypeAppCardInterface")),
|
closecom: dynamic(() => import("./closecom/components/EventTypeAppCardInterface")),
|
||||||
fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
|
fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
|
||||||
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
|
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
|
||||||
@@ -54,6 +55,7 @@ export const EventTypeAddonMap = {
|
|||||||
export const EventTypeSettingsMap = {
|
export const EventTypeSettingsMap = {
|
||||||
alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")),
|
alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")),
|
||||||
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")),
|
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")),
|
||||||
|
btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppSettingsInterface")),
|
||||||
fathom: dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")),
|
fathom: dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")),
|
||||||
ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")),
|
ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")),
|
||||||
giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")),
|
giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
**/
|
**/
|
||||||
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
|
import { appKeysSchema as alby_zod_ts } from "./alby/zod";
|
||||||
import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/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 closecom_zod_ts } from "./closecom/zod";
|
||||||
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||||
import { appKeysSchema as dub_zod_ts } from "./dub/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 = {
|
export const appKeysSchemas = {
|
||||||
alby: alby_zod_ts,
|
alby: alby_zod_ts,
|
||||||
basecamp3: basecamp3_zod_ts,
|
basecamp3: basecamp3_zod_ts,
|
||||||
|
btcpayserver: btcpayserver_zod_ts,
|
||||||
closecom: closecom_zod_ts,
|
closecom: closecom_zod_ts,
|
||||||
dailyvideo: dailyvideo_zod_ts,
|
dailyvideo: dailyvideo_zod_ts,
|
||||||
dub: dub_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 baa_for_hipaa_config_json from "./baa-for-hipaa/config.json";
|
||||||
import basecamp3_config_json from "./basecamp3/config.json";
|
import basecamp3_config_json from "./basecamp3/config.json";
|
||||||
import bolna_config_json from "./bolna/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 { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
|
||||||
import campfire_config_json from "./campfire/config.json";
|
import campfire_config_json from "./campfire/config.json";
|
||||||
import chatbase_config_json from "./chatbase/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,
|
"baa-for-hipaa": baa_for_hipaa_config_json,
|
||||||
basecamp3: basecamp3_config_json,
|
basecamp3: basecamp3_config_json,
|
||||||
bolna: bolna_config_json,
|
bolna: bolna_config_json,
|
||||||
|
btcpayserver: btcpayserver_config_json,
|
||||||
caldavcalendar: caldavcalendar__metadata_ts,
|
caldavcalendar: caldavcalendar__metadata_ts,
|
||||||
campfire: campfire_config_json,
|
campfire: campfire_config_json,
|
||||||
chatbase: chatbase_config_json,
|
chatbase: chatbase_config_json,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
**/
|
**/
|
||||||
import { appDataSchema as alby_zod_ts } from "./alby/zod";
|
import { appDataSchema as alby_zod_ts } from "./alby/zod";
|
||||||
import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/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 closecom_zod_ts } from "./closecom/zod";
|
||||||
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod";
|
||||||
import { appDataSchema as dub_zod_ts } from "./dub/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 = {
|
export const appDataSchemas = {
|
||||||
alby: alby_zod_ts,
|
alby: alby_zod_ts,
|
||||||
basecamp3: basecamp3_zod_ts,
|
basecamp3: basecamp3_zod_ts,
|
||||||
|
btcpayserver: btcpayserver_zod_ts,
|
||||||
closecom: closecom_zod_ts,
|
closecom: closecom_zod_ts,
|
||||||
dailyvideo: dailyvideo_zod_ts,
|
dailyvideo: dailyvideo_zod_ts,
|
||||||
dub: dub_zod_ts,
|
dub: dub_zod_ts,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const apiHandlers = {
|
|||||||
"baa-for-hipaa": import("./baa-for-hipaa/api"),
|
"baa-for-hipaa": import("./baa-for-hipaa/api"),
|
||||||
basecamp3: import("./basecamp3/api"),
|
basecamp3: import("./basecamp3/api"),
|
||||||
bolna: import("./bolna/api"),
|
bolna: import("./bolna/api"),
|
||||||
|
btcpayserver: import("./btcpayserver/api"),
|
||||||
caldavcalendar: import("./caldavcalendar/api"),
|
caldavcalendar: import("./caldavcalendar/api"),
|
||||||
campfire: import("./campfire/api"),
|
campfire: import("./campfire/api"),
|
||||||
chatbase: import("./chatbase/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