Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2965450929 | ||
|
|
db9b9c45dc | ||
|
|
37f500526c | ||
|
|
006aecb066 | ||
|
|
0829929e98 | ||
|
|
a61148cb51 | ||
|
|
593c48c8b4 | ||
|
|
fb364971fa | ||
|
|
a2205d497d | ||
|
|
cf4e7a5d2c | ||
|
|
0e2ee3adb2 | ||
|
|
dbe8a85cf5 | ||
|
|
84210dc1b4 | ||
|
|
4061bfb3fe | ||
|
|
18059e50fa | ||
|
|
852eb346ed | ||
|
|
b7e11dc112 | ||
|
|
35a373e70a | ||
|
|
02c0b721b9 | ||
|
|
29349845f8 | ||
|
|
28cb2cff64 | ||
|
|
9cb2eb3b8c | ||
|
|
6ff20b98f4 | ||
|
|
aa618dbaa2 | ||
|
|
6e3de84067 | ||
|
|
c6b19200a9 | ||
|
|
91932212e3 | ||
|
|
f81bb92beb | ||
|
|
1d1a242a72 | ||
|
|
ad4af64036 | ||
|
+5 |
2cc7827b0e | ||
|
|
ef6618743d | ||
|
|
3601a9adf1 | ||
|
|
0cc2367556 | ||
|
|
f7b201b0b5 | ||
|
|
82063cc9a1 | ||
|
|
4b2f89d6e8 | ||
|
|
5360cb511d | ||
|
|
704bbf2757 | ||
|
|
7fef683abf | ||
|
|
63df3d9c14 | ||
|
|
32d2fd51e5 | ||
|
|
016a966057 | ||
|
|
dc967cffbe | ||
|
|
4489cb8fde | ||
|
|
babd514c64 | ||
|
|
2e5cc11f29 | ||
|
|
84bc330fa8 | ||
|
|
4cb7e4a91a | ||
|
|
d1bd05a791 | ||
|
|
96a6350143 | ||
|
|
c8bea5b143 | ||
|
|
1cdd8ebc53 | ||
|
|
d78ed21a9f | ||
|
|
4e92b6edeb | ||
|
|
7b2f811bfe | ||
|
|
7b6d5d6de3 | ||
|
|
12b418b532 | ||
|
|
3e299f9511 | ||
|
|
1b9691ddaf | ||
|
|
a11ea3dd1e | ||
|
|
b21400c613 | ||
|
|
6c936472d2 | ||
|
|
3b823670ea | ||
|
|
6b10127d97 | ||
|
|
9cd806ff61 | ||
|
|
628b169f53 | ||
|
|
19d7764e9d | ||
|
|
30061c0d58 |
@@ -34,4 +34,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
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"
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
undecided:
|
||||||
|
- "@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,9 +101,13 @@ 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,
|
||||||
@@ -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,9 +276,12 @@ 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,
|
||||||
@@ -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
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
|
|
||||||
export async function revalidateWebhooksList() {
|
export async function revalidateWebhooksListGetByViewer() {
|
||||||
revalidatePath("/settings/developer/webhooks");
|
revalidateTag("viewer.webhook.getByViewer");
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -1,13 +1,13 @@
|
|||||||
import { createRouterCaller } from "app/_trpc/context";
|
|
||||||
import { _generateMetadata } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -20,6 +20,17 @@ export const generateMetadata = async () =>
|
|||||||
"/settings/developer/webhooks"
|
"/settings/developer/webhooks"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getCachedWebhooksList = unstable_cache(
|
||||||
|
async ({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) => {
|
||||||
|
return await WebhookRepository.getAllWebhooksByUserId({
|
||||||
|
userId,
|
||||||
|
userRole,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ revalidate: 3600, tags: ["viewer.webhook.getByViewer"] }
|
||||||
|
);
|
||||||
|
|
||||||
const WebhooksViewServerWrapper = async () => {
|
const WebhooksViewServerWrapper = async () => {
|
||||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@@ -27,8 +38,9 @@ const WebhooksViewServerWrapper = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||||
const caller = await createRouterCaller(webhookRouter);
|
const userRole = session.user.role !== "INACTIVE_ADMIN" ? session.user.role : undefined;
|
||||||
const data = await caller.getByViewer();
|
|
||||||
|
const data = await getCachedWebhooksList({ userId: session.user.id, userRole });
|
||||||
|
|
||||||
return <WebhooksView data={data} isAdmin={isAdmin} />;
|
return <WebhooksView data={data} isAdmin={isAdmin} />;
|
||||||
};
|
};
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidateTag } from "next/cache";
|
||||||
|
|
||||||
|
export async function revalidateWebhookById(id: string) {
|
||||||
|
revalidateTag(`viewer.webhook.get:${id}`);
|
||||||
|
}
|
||||||
+14
-1
@@ -1,5 +1,6 @@
|
|||||||
import type { PageProps } from "app/_types";
|
import type { PageProps } from "app/_types";
|
||||||
import { getTranslate, _generateMetadata } from "app/_utils";
|
import { getTranslate, _generateMetadata } from "app/_utils";
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
|
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
||||||
@@ -15,12 +16,24 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
|||||||
`/settings/developer/webhooks/${(await params).id}`
|
`/settings/developer/webhooks/${(await params).id}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getCachedWebhook = (id?: string) => {
|
||||||
|
const fn = unstable_cache(
|
||||||
|
async () => {
|
||||||
|
return await WebhookRepository.findByWebhookId(id);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ revalidate: 3600, tags: [`viewer.webhook.get:${id}`] }
|
||||||
|
);
|
||||||
|
|
||||||
|
return fn();
|
||||||
|
};
|
||||||
|
|
||||||
const Page = async ({ params: _params }: PageProps) => {
|
const Page = async ({ params: _params }: PageProps) => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const params = await _params;
|
const params = await _params;
|
||||||
const id = typeof params?.id === "string" ? params.id : undefined;
|
const id = typeof params?.id === "string" ? params.id : undefined;
|
||||||
|
|
||||||
const webhook = await WebhookRepository.findByWebhookId(id);
|
const webhook = await getCachedWebhook(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidateTag } from "next/cache";
|
||||||
|
|
||||||
|
export async function revalidateWebhookList() {
|
||||||
|
revalidateTag("viewer.webhook.list");
|
||||||
|
}
|
||||||
+22
-6
@@ -1,10 +1,16 @@
|
|||||||
import { createRouterCaller } from "app/_trpc/context";
|
import { createRouterCaller } from "app/_trpc/context";
|
||||||
import { _generateMetadata } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||||
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
||||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
|
||||||
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -15,15 +21,25 @@ export const generateMetadata = async () =>
|
|||||||
"/settings/developer/webhooks/new"
|
"/settings/developer/webhooks/new"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getCachedWebhooksList = unstable_cache(
|
||||||
|
async ({ userId }: { userId: number }) => {
|
||||||
|
return await WebhookRepository.findWebhooksByFilters({ userId });
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ revalidate: 3600, tags: ["viewer.webhook.list"] }
|
||||||
|
);
|
||||||
|
|
||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const [appsCaller, webhookCaller] = await Promise.all([
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
createRouterCaller(appsRouter),
|
if (!session?.user?.id) {
|
||||||
createRouterCaller(webhookRouter),
|
redirect("/auth/login");
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
const appsCaller = await createRouterCaller(appsRouter);
|
||||||
|
|
||||||
const [installedApps, webhooks] = await Promise.all([
|
const [installedApps, webhooks] = await Promise.all([
|
||||||
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
|
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
|
||||||
webhookCaller.list(),
|
getCachedWebhooksList({ userId: session.user.id }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
|
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -124,8 +124,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
|||||||
autoComplete="none"
|
autoComplete="none"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect="none"
|
autoCorrect="none"
|
||||||
|
containerClassName="[&>div]:gap-0"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mb-0 mt-0 rounded-md rounded-l-none",
|
"mb-0 mt-0 rounded-md rounded-l-none pl-0",
|
||||||
markAsError
|
markAsError
|
||||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||||
: ""
|
: ""
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ const checkIfUserIsHost = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// If eventType exists, it means user is either owner, host or user
|
// If eventType exists, it means user is either owner, host or user
|
||||||
return !eventType;
|
return !!eventType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
@@ -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,8 +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 { 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";
|
||||||
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
|
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ProgressBar } from "@tremor/react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
@@ -16,6 +15,7 @@ import { trpc } from "@calcom/trpc/react";
|
|||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { Select } from "@calcom/ui/components/form";
|
import { Select } from "@calcom/ui/components/form";
|
||||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||||
|
import { ProgressBar } from "@calcom/ui/components/progress-bar";
|
||||||
import { showToast } from "@calcom/ui/toast";
|
import { showToast } from "@calcom/ui/toast";
|
||||||
|
|
||||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||||
|
|||||||
@@ -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.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -76,7 +76,6 @@
|
|||||||
"@stripe/stripe-js": "^1.35.0",
|
"@stripe/stripe-js": "^1.35.0",
|
||||||
"@tanstack/react-query": "^5.17.15",
|
"@tanstack/react-query": "^5.17.15",
|
||||||
"@team-plain/typescript-sdk": "^5.9.0",
|
"@team-plain/typescript-sdk": "^5.9.0",
|
||||||
"@tremor/react": "^2.11.0",
|
|
||||||
"@types/turndown": "^5.0.1",
|
"@types/turndown": "^5.0.1",
|
||||||
"@unkey/ratelimit": "^0.1.1",
|
"@unkey/ratelimit": "^0.1.1",
|
||||||
"@upstash/redis": "^1.21.0",
|
"@upstash/redis": "^1.21.0",
|
||||||
@@ -195,7 +194,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", () => {
|
||||||
|
|||||||
@@ -203,32 +203,24 @@ test.describe("Insights", async () => {
|
|||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
// Choose User filter item from dropdown
|
// Choose User filter item from dropdown
|
||||||
await addFilter(page, "bookingUserId");
|
await addFilter(page, "userId");
|
||||||
|
|
||||||
// Wait for the URL to include bookingUserId
|
// Wait for the URL to include userId
|
||||||
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
|
await page.waitForURL((url) => url.toString().includes("userId"));
|
||||||
|
|
||||||
// Click User filter to see a user list
|
// Click User filter to see a user list
|
||||||
await openFilter(page, "bookingUserId");
|
await openFilter(page, "userId");
|
||||||
|
|
||||||
await page
|
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
|
||||||
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
|
||||||
.getByRole("option")
|
|
||||||
.nth(0)
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page
|
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
|
||||||
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
|
||||||
.getByRole("option")
|
|
||||||
.nth(1)
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// press escape button to close the filter
|
// press escape button to close the filter
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
await clearFilters(page);
|
await clearFilters(page);
|
||||||
|
|
||||||
await expect(page.url()).not.toContain("bookingUserId");
|
await expect(page.url()).not.toContain("userId");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should test download button", async ({ page, users }) => {
|
test("should test download button", async ({ page, users }) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -3391,6 +3402,7 @@
|
|||||||
"license_key_saved": "License key saved successfully",
|
"license_key_saved": "License key saved successfully",
|
||||||
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
|
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
|
||||||
"failed_bookings_by_field": "Failed Bookings By Field",
|
"failed_bookings_by_field": "Failed Bookings By Field",
|
||||||
|
"failed_bookings": "Failed bookings",
|
||||||
"event_type_no_hosts": "No hosts are assigned to event type",
|
"event_type_no_hosts": "No hosts are assigned to event type",
|
||||||
"cache_status": "Cache Status",
|
"cache_status": "Cache Status",
|
||||||
"cache_last_updated": "Last updated: {{timestamp}}",
|
"cache_last_updated": "Last updated: {{timestamp}}",
|
||||||
@@ -3418,5 +3430,7 @@
|
|||||||
"usage_based_expiration_description": "This link can be used for {{count}} booking",
|
"usage_based_expiration_description": "This link can be used for {{count}} booking",
|
||||||
"usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
|
"usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
|
||||||
"usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
|
"usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
|
||||||
|
"stats": "Stats",
|
||||||
|
"booking_status": "Booking status",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type UserPageProps = {
|
|||||||
| "length"
|
| "length"
|
||||||
| "hidden"
|
| "hidden"
|
||||||
| "lockTimeZoneToggleOnBookingPage"
|
| "lockTimeZoneToggleOnBookingPage"
|
||||||
|
| "lockedTimeZone"
|
||||||
| "requiresConfirmation"
|
| "requiresConfirmation"
|
||||||
| "canSendCalVideoTranscriptionEmails"
|
| "canSendCalVideoTranscriptionEmails"
|
||||||
| "requiresBookerEmailVerification"
|
| "requiresBookerEmailVerification"
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ const base = require("@calcom/config/tailwind-preset");
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...base,
|
...base,
|
||||||
content: [
|
content: [...base.content, "../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}"],
|
||||||
...base.content,
|
|
||||||
"../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}",
|
|
||||||
"../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
plugins: [...base.plugins, require("tailwindcss-animate")],
|
plugins: [...base.plugins, require("tailwindcss-animate")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ vi.mock("@calcom/emails/email-manager", () => {
|
|||||||
|
|
||||||
vi.mock("@calcom/lib/server/i18n", () => {
|
vi.mock("@calcom/lib/server/i18n", () => {
|
||||||
return {
|
return {
|
||||||
getTranslation: (key: string) => key,
|
getTranslation: async (locale: string, namespace: string) => {
|
||||||
|
const t = (key: string) => key;
|
||||||
|
t.locale = locale;
|
||||||
|
t.namespace = namespace;
|
||||||
|
return t;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -438,7 +443,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 { ... }
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,11 @@
|
|||||||
"group": "Embeds",
|
"group": "Embeds",
|
||||||
"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,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user