diff --git a/.changeset/forty-clubs-mate.md b/.changeset/forty-clubs-mate.md new file mode 100644 index 00000000000000..fc0d7c5884bce5 --- /dev/null +++ b/.changeset/forty-clubs-mate.md @@ -0,0 +1,9 @@ +--- +"@calcom/atoms": minor +--- + +Added new callback functions to the handleFormSubmit method in the EventTypeSettings and AvailabilitySettings atoms. The handleFormSubmit method now accepts an optional callbacks object with the following properties: + +- **onSuccess**: Called when the form submission is successful, allowing additional logic to be executed after the update. + +- **onError**: Called when an error occurs during form submission, providing details about the error to handle specific cases or display custom messages. diff --git a/.changeset/shaggy-goats-flash.md b/.changeset/shaggy-goats-flash.md new file mode 100644 index 00000000000000..329a06e23f0b37 --- /dev/null +++ b/.changeset/shaggy-goats-flash.md @@ -0,0 +1,5 @@ +--- +"@calcom/atoms": minor +--- + +booker atom: allow toggling org and team info when booking round robin diff --git a/.eslintrc.js b/.eslintrc.js index e9484d0397d3fc..926caf716201c7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1 +1,5 @@ -module.exports = require("./packages/config/eslint-preset"); +// This configuration only applies to the package manager root. +/** @type {import("eslint").Linter.Config} */ +module.exports = { + extends: ["./packages/config/eslint-preset.js"], +}; diff --git a/.github/workflows/e2e-api-v2.yml b/.github/workflows/e2e-api-v2.yml index b1df17691f9a14..e2e351bd714767 100644 --- a/.github/workflows/e2e-api-v2.yml +++ b/.github/workflows/e2e-api-v2.yml @@ -22,6 +22,7 @@ env: STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }} STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }} STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }} + SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }} jobs: e2e: timeout-minutes: 20 diff --git a/.yarn/versions/19a4f764.yml b/.yarn/versions/19a4f764.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/19a4f764.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/.yarn/versions/2324e59b.yml b/.yarn/versions/2324e59b.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/2324e59b.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/.yarn/versions/5cae75a1.yml b/.yarn/versions/5cae75a1.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/5cae75a1.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/.yarn/versions/68190699.yml b/.yarn/versions/68190699.yml new file mode 100644 index 00000000000000..dd2ce8f8224c20 --- /dev/null +++ b/.yarn/versions/68190699.yml @@ -0,0 +1,3 @@ +undecided: + - calcom-monorepo + - "@calcom/prisma" diff --git a/.yarn/versions/96bd4a2c.yml b/.yarn/versions/96bd4a2c.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/96bd4a2c.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/.yarn/versions/c2cf509a.yml b/.yarn/versions/c2cf509a.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/c2cf509a.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/.yarn/versions/f7534fef.yml b/.yarn/versions/f7534fef.yml new file mode 100644 index 00000000000000..b98fefe38f8195 --- /dev/null +++ b/.yarn/versions/f7534fef.yml @@ -0,0 +1,2 @@ +undecided: + - "@calcom/prisma" diff --git a/apps/api/v1/package.json b/apps/api/v1/package.json index 935d6925e416a2..f6590507179a14 100644 --- a/apps/api/v1/package.json +++ b/apps/api/v1/package.json @@ -38,7 +38,7 @@ "next-axiom": "^0.17.0", "next-swagger-doc": "^0.3.6", "next-validations": "^0.2.0", - "typescript": "^5.7.2", + "typescript": "^5.9.0-beta", "tzdata": "^1.0.30", "uuid": "^8.3.2", "zod": "^3.22.4" diff --git a/apps/api/v1/pages/api/availability/_get.ts b/apps/api/v1/pages/api/availability/_get.ts index 28567dc387ada5..4dada07b66aaf4 100644 --- a/apps/api/v1/pages/api/availability/_get.ts +++ b/apps/api/v1/pages/api/availability/_get.ts @@ -1,7 +1,7 @@ import type { NextApiRequest } from "next"; import { z } from "zod"; -import { getUserAvailability } from "@calcom/lib/getUserAvailability"; +import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import prisma from "@calcom/prisma"; @@ -191,8 +191,9 @@ const availabilitySchema = z async function handler(req: NextApiRequest) { const { isSystemWideAdmin, userId: reqUserId } = req; const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query); + const userAvailabilityService = getUserAvailabilityService(); if (!teamId) - return getUserAvailability({ + return userAvailabilityService.getUserAvailability({ username, dateFrom, dateTo, @@ -230,7 +231,7 @@ async function handler(req: NextApiRequest) { const availabilities = members.map(async (user) => { return { userId: user.id, - availability: await getUserAvailability({ + availability: await userAvailabilityService.getUserAvailability({ userId: user.id, dateFrom, dateTo, diff --git a/apps/api/v2/README.md b/apps/api/v2/README.md index 8903497ac25cd0..a6dd8ab878c626 100644 --- a/apps/api/v2/README.md +++ b/apps/api/v2/README.md @@ -42,15 +42,17 @@ $ yarn prisma generate Copy `.env.example` to `.env` and fill values. -## Add license Key to deployments table in DB +## Add license Key to Deployment table in DB -id, logo theme licenseKey agreedLicenseAt -1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611' +id, logo, theme, licenseKey, agreedLicenseAt:- +1, null, null, '00000000-0000-0000-0000-000000000000', '2023-05-15 21:39:47.611' + +Replace with your actual license key. your CALCOM_LICENSE_KEY env var need to contain the same value .env -CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb +CALCOM_LICENSE_KEY=00000000-0000-0000-0000-000000000000 ## Running the app diff --git a/apps/api/v2/jest-e2e.json b/apps/api/v2/jest-e2e.json deleted file mode 100644 index 99899c6a15ba30..00000000000000 --- a/apps/api/v2/jest-e2e.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "moduleNameMapper": { - "@/(.*)": "/src/$1", - "test/(.*)": "/test/$1" - }, - "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "setupFiles": ["/test/setEnvVars.ts", "jest-date-mock"], - "setupFilesAfterEnv": ["/test/jest.setup-e2e.ts"], - "reporters": ["default", "jest-summarizing-reporter"], - "workerIdleMemoryLimit": "512MB", - "maxWorkers": 8 -} diff --git a/apps/api/v2/jest-e2e.ts b/apps/api/v2/jest-e2e.ts new file mode 100644 index 00000000000000..6f87c2bc68323d --- /dev/null +++ b/apps/api/v2/jest-e2e.ts @@ -0,0 +1,25 @@ +import type { Config } from "jest"; + +const config: Config = { + preset: "ts-jest", + moduleFileExtensions: ["js", "json", "ts"], + rootDir: ".", + moduleNameMapper: { + "@/(.*)": "/src/$1", + "test/(.*)": "/test/$1", + }, + testEnvironment: "node", + testRegex: ".e2e-spec.ts$", + transform: { + "^.+\\.ts$": "ts-jest", + }, + setupFiles: ["/test/setEnvVars.ts", "jest-date-mock"], + setupFilesAfterEnv: ["/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; diff --git a/apps/api/v2/jest.config.json b/apps/api/v2/jest.config.json deleted file mode 100644 index a7b6a8c8885869..00000000000000 --- a/apps/api/v2/jest.config.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "moduleNameMapper": { - "@/(.*)": "/src/$1", - "test/(.*)": "/test/$1" - }, - "testEnvironment": "node", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.ts$": "ts-jest" - }, - "setupFiles": ["/test/setEnvVars.ts"] -} diff --git a/apps/api/v2/jest.config.ts b/apps/api/v2/jest.config.ts new file mode 100644 index 00000000000000..5ac6804bdc590d --- /dev/null +++ b/apps/api/v2/jest.config.ts @@ -0,0 +1,21 @@ +import type { Config } from "jest"; + +const config: Config = { + preset: "ts-jest", + moduleFileExtensions: ["ts", "js", "json"], + rootDir: ".", + moduleNameMapper: { + "@/(.*)": "/src/$1", + "test/(.*)": "/test/$1", + }, + testEnvironment: "node", + testRegex: ".*\\.spec\\.ts$", + transform: { + "^.+\\.ts$": "ts-jest", + }, + setupFiles: ["/test/setEnvVars.ts"], + testPathIgnorePatterns: ["/dist/", "/node_modules/"], + transformIgnorePatterns: ["/dist/", "/node_modules/"], +}; + +export default config; diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index faffb6192c4416..c7d8abaab28e5c 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -25,9 +25,9 @@ "test:watch": "yarn dev:build && jest --watch", "test:cov": "yarn dev:build && jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json", + "test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.ts", "test:e2e:local": "yarn test:e2e --maxWorkers=4", - "test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch", + "test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch", "prisma": "yarn workspace @calcom/prisma prisma", "generate-schemas": "yarn prisma generate && yarn prisma format", "copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts", @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.268", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.291", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", @@ -98,6 +98,8 @@ "@types/luxon": "^3.3.7", "@types/passport-jwt": "^3.0.13", "@types/supertest": "^2.0.12", + "@typescript-eslint/eslint-plugin": "^6", + "@typescript-eslint/parser": "^6", "jest": "^29.7.0", "jest-date-mock": "^1.0.10", "node-mocks-http": "^1.16.2", @@ -108,7 +110,7 @@ "ts-loader": "^9.4.3", "ts-node": "^10.9.1", "tsconfig-paths": "^4.1.0", - "typescript": "^5.7.2" + "typescript": "^5.9.0-beta" }, "prisma": { "schema": "../../../packages/prisma/schema.prisma" diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index e02e617577e31a..c23d85ca08b7d7 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -5,7 +5,7 @@ import { GetBookingOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ge import { GetBookingsOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/get-bookings.output"; import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/mark-no-show.output"; import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service"; -import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key"; import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions"; import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; @@ -386,7 +386,7 @@ export class BookingsController_2024_04_15 { if (bearerToken) { if (isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_")) { const strippedApiKey = stripApiKey(bearerToken, this.config.get("api.keyPrefix")); - const apiKeyHash = hashAPIKey(strippedApiKey); + const apiKeyHash = sha256Hash(strippedApiKey); const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash); return keyData?.userId; } else { diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts index 2325ce36cbefb6..da7cc4c641a72b 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts @@ -31,12 +31,7 @@ function ValidateBookingName(validationOptions?: ValidationOptions) { return value.trim().length > 0; } if (typeof value === "object" && value !== null) { - return ( - typeof value.firstName === "string" && - typeof value.lastName === "string" && - value.firstName.trim().length > 0 && - value.lastName.trim().length > 0 - ); + return typeof value.firstName === "string" && value.firstName.trim().length > 0; } return false; }, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts index 9ebe42a2b291f5..384b52ab58856e 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts @@ -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", () => { diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index ed430045b39a0a..04f67dc900a9e4 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -11,7 +11,7 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings. import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service"; import { apiToInternalintegrationsMapping } from "@/ee/event-types/event-types_2024_06_14/transformers"; -import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key"; import { defaultBookingResponses } from "@/lib/safe-parse/default-responses-booking"; import { safeParse } from "@/lib/safe-parse/safe-parse"; import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository"; @@ -675,7 +675,7 @@ export class InputBookingsService_2024_08_13 { if (bearerToken) { if (isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_")) { const strippedApiKey = stripApiKey(bearerToken, this.config.get("api.keyPrefix")); - const apiKeyHash = hashAPIKey(strippedApiKey); + const apiKeyHash = sha256Hash(strippedApiKey); const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash); return keyData?.userId; } else { diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts index c22f0081ce92c8..604f7281661750 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts @@ -101,10 +101,15 @@ export class OutputBookingsService_2024_08_13 { ); const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata); const location = metadata?.videoCallUrl || databaseBooking.location; - const rescheduledToUid = databaseBooking.rescheduled - ? await this.getRescheduledToUid(databaseBooking.uid) + const rescheduledToInfo = databaseBooking.rescheduled + ? await this.getRescheduledToInfo(databaseBooking.uid) : undefined; + const rescheduledToUid = rescheduledToInfo?.uid; + const rescheduledByEmail = databaseBooking.rescheduled + ? rescheduledToInfo?.rescheduledBy + : databaseBooking.rescheduledBy; + const booking = { id: databaseBooking.id, uid: databaseBooking.uid, @@ -116,7 +121,6 @@ export class OutputBookingsService_2024_08_13 { databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined, cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined, reschedulingReason: bookingResponses?.rescheduledReason, - rescheduledByEmail: databaseBooking.rescheduledBy || undefined, rescheduledFromUid: databaseBooking.fromReschedule || undefined, start: databaseBooking.startTime, end: databaseBooking.endTime, @@ -142,6 +146,7 @@ export class OutputBookingsService_2024_08_13 { rating: databaseBooking.rating, icsUid: databaseBooking.iCalUID, rescheduledToUid, + rescheduledByEmail, }; const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" }); @@ -151,9 +156,12 @@ export class OutputBookingsService_2024_08_13 { return bookingTransformed; } - async getRescheduledToUid(bookingUid: string) { + async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> { const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid); - return rescheduledTo?.uid; + return { + uid: rescheduledTo?.uid, + rescheduledBy: rescheduledTo?.rescheduledBy, + }; } getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) { @@ -218,7 +226,6 @@ export class OutputBookingsService_2024_08_13 { databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined, cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined, reschedulingReason: bookingResponses?.rescheduledReason, - rescheduledByEmail: databaseBooking.rescheduledBy || undefined, rescheduledFromUid: databaseBooking.fromReschedule || undefined, start: databaseBooking.startTime, end: databaseBooking.endTime, @@ -269,10 +276,15 @@ export class OutputBookingsService_2024_08_13 { const duration = dateEnd.diff(dateStart, "minutes").minutes; const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata); const location = metadata?.videoCallUrl || databaseBooking.location; - const rescheduledToUid = databaseBooking.rescheduled - ? await this.getRescheduledToUid(databaseBooking.uid) + const rescheduledToInfo = databaseBooking.rescheduled + ? await this.getRescheduledToInfo(databaseBooking.uid) : undefined; + const rescheduledToUid = rescheduledToInfo?.uid; + const rescheduledByEmail = databaseBooking.rescheduled + ? rescheduledToInfo?.rescheduledBy + : databaseBooking.rescheduledBy; + const booking = { id: databaseBooking.id, uid: databaseBooking.uid, @@ -299,6 +311,7 @@ export class OutputBookingsService_2024_08_13 { rating: databaseBooking.rating, icsUid: databaseBooking.iCalUID, rescheduledToUid, + rescheduledByEmail, }; const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" }); diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts index 50d88e272209e0..c57608c903b48a 100644 --- a/apps/api/v2/src/ee/calendars/services/calendars.service.ts +++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts @@ -83,32 +83,31 @@ export class CalendarsService { calendarsToLoad, userId ); - try { - const calendarBusyTimes = await getBusyCalendarTimes( - this.buildNonDelegationCredentials(credentials), - dateFrom, - dateTo, - composedSelectedCalendars - ); - const calendarBusyTimesConverted = calendarBusyTimes.map( - (busyTime: EventBusyDate & { timeZone?: string }) => { - const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone); - const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone); - const busyTimeStartDate = busyTimeStart.toJSDate(); - const busyTimeEndDate = busyTimeEnd.toJSDate(); - return { - ...busyTime, - start: busyTimeStartDate, - end: busyTimeEndDate, - }; - } - ); - return calendarBusyTimesConverted; - } catch (error) { + const calendarBusyTimesQuery = await getBusyCalendarTimes( + this.buildNonDelegationCredentials(credentials), + dateFrom, + dateTo, + composedSelectedCalendars + ); + if (!calendarBusyTimesQuery.success) { throw new InternalServerErrorException( "Unable to fetch connected calendars events. Please try again later." ); } + const calendarBusyTimesConverted = calendarBusyTimesQuery.data.map( + (busyTime: EventBusyDate & { timeZone?: string }) => { + const busyTimeStart = DateTime.fromJSDate(new Date(busyTime.start)).setZone(timezone); + const busyTimeEnd = DateTime.fromJSDate(new Date(busyTime.end)).setZone(timezone); + const busyTimeStartDate = busyTimeStart.toJSDate(); + const busyTimeEndDate = busyTimeEnd.toJSDate(); + return { + ...busyTime, + start: busyTimeStartDate, + end: busyTimeEndDate, + }; + } + ); + return calendarBusyTimesConverted; } async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) { diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts index 099526766bed24..4b0300388034f2 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_04_15/controllers/event-types.controller.e2e-spec.ts @@ -290,7 +290,10 @@ describe("Event types Endpoints", () => { expect(responseBookingFields).toBeDefined(); // note(Lauris): response bookingFields are already existing default bookingFields + the new one const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName); - expect(responseBookingField).toEqual(bookingFields[0]); + const fields = responseBookingField + //@ts-ignore + delete fields.labelAsSafeHtml + expect(fields).toEqual(bookingFields[0]); eventType.bookingFields = responseBookingFields; }); }); diff --git a/apps/api/v2/src/lib/api-key/index.ts b/apps/api/v2/src/lib/api-key/index.ts index ad7ec0d2a9f3e7..b580c5c29dea31 100644 --- a/apps/api/v2/src/lib/api-key/index.ts +++ b/apps/api/v2/src/lib/api-key/index.ts @@ -1,6 +1,6 @@ import { createHash } from "crypto"; -export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex"); +export const sha256Hash = (token: string): string => createHash("sha256").update(token).digest("hex"); export const isApiKey = (authString: string, prefix: string): boolean => authString?.startsWith(prefix ?? "cal_"); diff --git a/apps/api/v2/src/lib/modules/available-slots.module.ts b/apps/api/v2/src/lib/modules/available-slots.module.ts index c1f55f55f3fc70..ea2284b5f65e99 100644 --- a/apps/api/v2/src/lib/modules/available-slots.module.ts +++ b/apps/api/v2/src/lib/modules/available-slots.module.ts @@ -1,5 +1,6 @@ import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; +import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository"; import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository"; @@ -7,7 +8,12 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"; import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; import { AvailableSlotsService } from "@/lib/services/available-slots.service"; +import { BusyTimesService } from "@/lib/services/busy-times.service"; +import { CacheService } from "@/lib/services/cache.service"; +import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; +import { UserAvailabilityService } from "@/lib/services/user-availability.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisService } from "@/modules/redis/redis.service"; import { Module } from "@nestjs/common"; @Module({ @@ -21,7 +27,13 @@ import { Module } from "@nestjs/common"; PrismaEventTypeRepository, PrismaRoutingFormResponseRepository, PrismaTeamRepository, + RedisService, + PrismaFeaturesRepository, + CheckBookingLimitsService, + CacheService, AvailableSlotsService, + UserAvailabilityService, + BusyTimesService, ], exports: [AvailableSlotsService], }) diff --git a/apps/api/v2/src/lib/repositories/prisma-features.repository.ts b/apps/api/v2/src/lib/repositories/prisma-features.repository.ts new file mode 100644 index 00000000000000..9039675e107883 --- /dev/null +++ b/apps/api/v2/src/lib/repositories/prisma-features.repository.ts @@ -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); + } +} diff --git a/apps/api/v2/src/lib/services/available-slots.service.ts b/apps/api/v2/src/lib/services/available-slots.service.ts index 7f6ce1725c70f4..474a9acfc92a42 100644 --- a/apps/api/v2/src/lib/services/available-slots.service.ts +++ b/apps/api/v2/src/lib/services/available-slots.service.ts @@ -1,15 +1,22 @@ import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; +import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository"; import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository"; import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository"; import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"; import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository"; +import { BusyTimesService } from "@/lib/services/busy-times.service"; +import { CacheService } from "@/lib/services/cache.service"; +import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; +import { RedisService } from "@/modules/redis/redis.service"; import { Injectable } from "@nestjs/common"; import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots"; +import { UserAvailabilityService } from "./user-availability.service"; + @Injectable() export class AvailableSlotsService extends BaseAvailableSlotsService { constructor( @@ -20,7 +27,9 @@ export class AvailableSlotsService extends BaseAvailableSlotsService { bookingRepository: PrismaBookingRepository, selectedSlotRepository: PrismaSelectedSlotRepository, eventTypeRepository: PrismaEventTypeRepository, - userRepository: PrismaUserRepository + userRepository: PrismaUserRepository, + redisService: RedisService, + featuresRepository: PrismaFeaturesRepository ) { super({ oooRepo: oooRepoDependency, @@ -31,6 +40,16 @@ export class AvailableSlotsService extends BaseAvailableSlotsService { selectedSlotRepo: selectedSlotRepository, eventTypeRepo: eventTypeRepository, userRepo: userRepository, + redisClient: redisService, + checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository), + cacheService: new CacheService(featuresRepository), + userAvailabilityService: new UserAvailabilityService( + oooRepoDependency, + bookingRepository, + eventTypeRepository, + redisService + ), + busyTimesService: new BusyTimesService(bookingRepository), }); } } diff --git a/apps/api/v2/src/lib/services/busy-times.service.ts b/apps/api/v2/src/lib/services/busy-times.service.ts new file mode 100644 index 00000000000000..0f862e6ac3d57b --- /dev/null +++ b/apps/api/v2/src/lib/services/busy-times.service.ts @@ -0,0 +1,13 @@ +import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; +import { Injectable } from "@nestjs/common"; + +import { BusyTimesService as BaseBusyTimesService } from "@calcom/platform-libraries/slots"; + +@Injectable() +export class BusyTimesService extends BaseBusyTimesService { + constructor(bookingRepository: PrismaBookingRepository) { + super({ + bookingRepo: bookingRepository, + }); + } +} diff --git a/apps/api/v2/src/lib/services/cache.service.ts b/apps/api/v2/src/lib/services/cache.service.ts new file mode 100644 index 00000000000000..fd8d8e3b5995e6 --- /dev/null +++ b/apps/api/v2/src/lib/services/cache.service.ts @@ -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 }); + } +} diff --git a/apps/api/v2/src/lib/services/check-booking-and-duration-limits.service.ts b/apps/api/v2/src/lib/services/check-booking-and-duration-limits.service.ts new file mode 100644 index 00000000000000..8ac610ceaad697 --- /dev/null +++ b/apps/api/v2/src/lib/services/check-booking-and-duration-limits.service.ts @@ -0,0 +1,13 @@ +import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service"; +import { Injectable } from "@nestjs/common"; + +import { CheckBookingAndDurationLimitsService as BaseCheckBookingAndDurationLimitsService } from "@calcom/platform-libraries/bookings"; + +@Injectable() +export class CheckBookingAndDurationLimitsService extends BaseCheckBookingAndDurationLimitsService { + constructor(checkBookingLimitsService: CheckBookingLimitsService) { + super({ + checkBookingLimitsService: checkBookingLimitsService, + }); + } +} diff --git a/apps/api/v2/src/lib/services/check-booking-limits.service.ts b/apps/api/v2/src/lib/services/check-booking-limits.service.ts new file mode 100644 index 00000000000000..2e034149b70451 --- /dev/null +++ b/apps/api/v2/src/lib/services/check-booking-limits.service.ts @@ -0,0 +1,13 @@ +import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; +import { Injectable } from "@nestjs/common"; + +import { CheckBookingLimitsService as BaseCheckBookingLimitsService } from "@calcom/platform-libraries/bookings"; + +@Injectable() +export class CheckBookingLimitsService extends BaseCheckBookingLimitsService { + constructor(bookingRepository: PrismaBookingRepository) { + super({ + bookingRepo: bookingRepository, + }); + } +} diff --git a/apps/api/v2/src/lib/services/user-availability.service.ts b/apps/api/v2/src/lib/services/user-availability.service.ts new file mode 100644 index 00000000000000..18796128b9671b --- /dev/null +++ b/apps/api/v2/src/lib/services/user-availability.service.ts @@ -0,0 +1,24 @@ +import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; +import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; +import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable } from "@nestjs/common"; + +import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules"; + +@Injectable() +export class UserAvailabilityService extends BaseUserAvailabilityService { + constructor( + oooRepoDependency: PrismaOOORepository, + bookingRepository: PrismaBookingRepository, + eventTypeRepository: PrismaEventTypeRepository, + redisService: RedisService + ) { + super({ + oooRepo: oooRepoDependency, + bookingRepo: bookingRepository, + eventTypeRepo: eventTypeRepository, + redisClient: redisService, + }); + } +} diff --git a/apps/api/v2/src/lib/throttler-guard.ts b/apps/api/v2/src/lib/throttler-guard.ts index 042ec9ceeff849..e19b599cc24cc2 100644 --- a/apps/api/v2/src/lib/throttler-guard.ts +++ b/apps/api/v2/src/lib/throttler-guard.ts @@ -1,5 +1,5 @@ import { getEnv } from "@/env"; -import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key"; import { Throttle } from "@/lib/endpoint-throttler-decorator"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"; @@ -10,7 +10,6 @@ import { ThrottlerException, ThrottlerRequest, ThrottlerModuleOptions, - seconds, } from "@nestjs/throttler"; import { Request, Response } from "express"; import { z } from "zod"; @@ -218,18 +217,18 @@ export class CustomThrottlerGuard extends ThrottlerGuard { if (authorizationHeader) { const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_"); return isApiKey(authorizationHeader, apiKeyPrefix) - ? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}` - : `access_token_${authorizationHeader}`; + ? `api_key_${sha256Hash(stripApiKey(authorizationHeader, apiKeyPrefix))}` + : `access_token_${sha256Hash(authorizationHeader)}`; } const oauthClientId = request.get(X_CAL_CLIENT_ID); if (oauthClientId) { - return `oauth_client_${oauthClientId}`; + return `oauth_client_${sha256Hash(oauthClientId)}`; } if (IP) { - return `ip_${IP}`; + return `ip_${sha256Hash(IP.toString())}`; } this.logger.verbose(`no tracker found: ${request.url}`); diff --git a/apps/api/v2/src/modules/api-keys/services/api-keys.service.ts b/apps/api/v2/src/modules/api-keys/services/api-keys.service.ts index 79f3a3bb3fcd7e..45547d868a4239 100644 --- a/apps/api/v2/src/modules/api-keys/services/api-keys.service.ts +++ b/apps/api/v2/src/modules/api-keys/services/api-keys.service.ts @@ -1,4 +1,4 @@ -import { hashAPIKey, stripApiKey } from "@/lib/api-key"; +import { sha256Hash, stripApiKey } from "@/lib/api-key"; import { AuthMethods } from "@/lib/enums/auth-methods"; import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository"; import { CreateApiKeyInput } from "@/modules/api-keys/inputs/create-api-key.input"; @@ -61,7 +61,7 @@ export class ApiKeysService { async refreshApiKey(authUserId: number, apiKey: string, refreshApiKeyInput: RefreshApiKeyInput) { const strippedApiKey = stripApiKey(apiKey, this.config.get("api.keyPrefix")); - const apiKeyHash = hashAPIKey(strippedApiKey); + const apiKeyHash = sha256Hash(strippedApiKey); const apiKeyInDb = await this.apiKeysRepository.getApiKeyFromHash(apiKeyHash); if (!apiKeyInDb) { throw new UnauthorizedException("ApiKeysService - provided api key is not valid."); diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts index c04c5d82388f83..b1688e69b94f34 100644 --- a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts @@ -1,4 +1,4 @@ -import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key"; import { AuthMethods } from "@/lib/enums/auth-methods"; import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed"; import { BaseStrategy } from "@/lib/passport/strategies/types"; @@ -226,7 +226,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") ); } const strippedApiKey = stripApiKey(apiKey, this.config.get("api.keyPrefix")); - const apiKeyHash = hashAPIKey(strippedApiKey); + const apiKeyHash = sha256Hash(strippedApiKey); const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash); if (!keyData) { throw new UnauthorizedException("ApiAuthStrategy - api key - Your api key is not valid"); diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts index 4f7057b4cac2f1..181e7fd1f1d8d2 100644 --- a/apps/api/v2/src/modules/billing/services/billing.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -403,6 +403,10 @@ export class BillingService implements OnModuleDestroy { fromReschedule?: string | null; } ) { + + if (this.configService.get("e2e")) { + return true; + } const { uid, startTime, fromReschedule } = booking; const delay = startTime.getTime() - Date.now(); @@ -427,6 +431,9 @@ export class BillingService implements OnModuleDestroy { * Removing an attendee from a booking does not cancel the usage increment job. */ async cancelUsageByBookingUid(bookingUid: string) { + if (this.configService.get("e2e")) { + return true; + } const job = await this.billingQueue.getJob(`increment-${bookingUid}`); if (job) { await job.remove(); @@ -458,6 +465,7 @@ export class BillingService implements OnModuleDestroy { async onModuleDestroy() { try { + await this.billingQueue.close(); } catch (err) { this.logger.error(err); diff --git a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts index 3dcbb33ce0ef5b..d949e3ea92fabc 100644 --- a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts @@ -1,7 +1,7 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; import { getEnv } from "@/env"; -import { hashAPIKey, stripApiKey } from "@/lib/api-key"; +import { sha256Hash, stripApiKey } from "@/lib/api-key"; import { RefreshApiKeyOutput } from "@/modules/api-keys/outputs/refresh-api-key.output"; import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto"; import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto"; @@ -283,7 +283,7 @@ describe("Organizations Organizations Endpoints", () => { expect(managedOrgApiKeys?.length).toEqual(1); expect(managedOrgApiKeys?.[0]?.id).toBeDefined(); const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_"); - const hashedApiKey = `${hashAPIKey(stripApiKey(managedOrg?.apiKey, apiKeyPrefix))}`; + const hashedApiKey = `${sha256Hash(stripApiKey(managedOrg?.apiKey, apiKeyPrefix))}`; expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey); const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 30 }).toJSDate(); expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt); @@ -484,7 +484,7 @@ describe("Organizations Organizations Endpoints", () => { expect(managedOrgApiKeys?.length).toEqual(1); expect(managedOrgApiKeys?.[0]?.id).toBeDefined(); const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_"); - const hashedApiKey = `${hashAPIKey(stripApiKey(newApiKey, apiKeyPrefix))}`; + const hashedApiKey = `${sha256Hash(stripApiKey(newApiKey, apiKeyPrefix))}`; expect(managedOrgApiKeys?.[0]?.hashedKey).toEqual(hashedApiKey); const expectedExpiresAt = DateTime.fromJSDate(newDate).setZone("utc").plus({ days: 60 }).toJSDate(); expect(managedOrgApiKeys?.[0]?.expiresAt).toEqual(expectedExpiresAt); diff --git a/apps/api/v2/src/modules/redis/redis.service.ts b/apps/api/v2/src/modules/redis/redis.service.ts index d683c50a359c81..952ae149184418 100644 --- a/apps/api/v2/src/modules/redis/redis.service.ts +++ b/apps/api/v2/src/modules/redis/redis.service.ts @@ -7,12 +7,32 @@ import { Redis } from "ioredis"; export class RedisService implements OnModuleDestroy { public redis: Redis; private readonly logger = new Logger("RedisService"); + private isReady = false; // Track connection status constructor(readonly configService: ConfigService) { const dbUrl = configService.get("db.redisUrl", { infer: true }); if (!dbUrl) throw new Error("Misconfigured Redis, halting."); this.redis = new Redis(dbUrl); + + this.redis.on("error", (err) => { + this.logger.error(`IoRedis connection error: ${err.message}`); + this.isReady = false; + }); + + this.redis.on("connect", () => { + this.logger.log("IoRedis connected!"); + this.isReady = true; + }); + + this.redis.on("reconnecting", (delay: string) => { + this.logger.warn(`IoRedis reconnecting... next retry in ${delay}ms`); + }); + + this.redis.on("end", () => { + this.logger.warn("IoRedis connection ended."); + this.isReady = false; + }); } async onModuleDestroy() { @@ -22,4 +42,99 @@ export class RedisService implements OnModuleDestroy { this.logger.error(err); } } + + async get(key: string): Promise { + let data = null; + if (!this.isReady) { + return null; + } + + try { + data = await this.redis.get(key); + } catch (err) { + if (err instanceof Error) this.logger.error(`IoRedis get failed: ${err.message}`); + } + + if (data === null) { + return null; + } + + try { + return JSON.parse(data) as TData; + } catch (e) { + return data as TData; + } + } + + async del(key: string): Promise { + if (!this.isReady) { + return 0; + } + try { + return this.redis.del(key); + } catch (err) { + if (err instanceof Error) this.logger.error(`IoRedis del failed: ${err.message}`); + return 0; + } + } + + async set(key: string, value: TData, opts?: { ttl?: number }): Promise<"OK" | TData | null> { + if (!this.isReady) { + return null; + } + + try { + const stringifiedValue = typeof value === "object" ? JSON.stringify(value) : String(value); + if (opts?.ttl) { + await this.redis.set(key, stringifiedValue, "PX", opts.ttl); + } else { + await this.redis.set(key, stringifiedValue); + } + } catch (err) { + if (err instanceof Error) this.logger.error(`IoRedis set failed: ${err.message}`); + return null; + } + + return "OK"; + } + + async expire(key: string, seconds: number): Promise<0 | 1> { + if (!this.isReady) { + return 0; + } + try { + return this.redis.expire(key, seconds) as Promise<0 | 1>; + } catch (err) { + if (err instanceof Error) this.logger.error(`IoRedis expire failed: ${err.message}`); + return 0; + } + } + + async lrange(key: string, start: number, end: number): Promise { + if (!this.isReady) { + return []; + } + try { + const results = await this.redis.lrange(key, start, end); + return results.map((item) => JSON.parse(item) as TResult); + } catch (err) { + if (err instanceof Error) this.logger.error(`IoRedis lrange failed: ${err.message}`); + return []; + } + } + + async lpush(key: string, ...elements: TData[]): Promise { + if (!this.isReady) { + return 0; + } + try { + const stringifiedElements = elements.map((element) => + typeof element === "object" ? JSON.stringify(element) : String(element) + ); + return this.redis.lpush(key, ...stringifiedElements); + } catch (err) { + if (err instanceof Error) this.logger.error(`IoRedis lpush failed: ${err.message}`); + return 0; + } + } } diff --git a/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts b/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts index 9630700bb30622..49988697cf5472 100644 --- a/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts +++ b/apps/api/v2/src/modules/workflows/inputs/workflow-step.input.ts @@ -10,6 +10,7 @@ export const SMS_ATTENDEE = "sms_attendee"; export const SMS_NUMBER = "sms_number"; export const WHATSAPP_ATTENDEE = "whatsapp_attendee"; export const WHATSAPP_NUMBER = "whatsapp_number"; +export const CAL_AI_PHONE_CALL = "cal_ai_phone_call"; export const STEP_ACTIONS = [ EMAIL_HOST, @@ -19,6 +20,7 @@ export const STEP_ACTIONS = [ SMS_NUMBER, WHATSAPP_ATTENDEE, WHATSAPP_NUMBER, + CAL_AI_PHONE_CALL, ] as const; export const STEP_ACTIONS_TO_ENUM = { @@ -29,6 +31,7 @@ export const STEP_ACTIONS_TO_ENUM = { [WHATSAPP_ATTENDEE]: WorkflowActions.WHATSAPP_ATTENDEE, [WHATSAPP_NUMBER]: WorkflowActions.WHATSAPP_NUMBER, [SMS_NUMBER]: WorkflowActions.SMS_NUMBER, + [CAL_AI_PHONE_CALL]: WorkflowActions.CAL_AI_PHONE_CALL, } as const; export const ENUM_TO_STEP_ACTIONS = { @@ -39,6 +42,7 @@ export const ENUM_TO_STEP_ACTIONS = { [WorkflowActions.WHATSAPP_ATTENDEE]: WHATSAPP_ATTENDEE, [WorkflowActions.WHATSAPP_NUMBER]: WHATSAPP_NUMBER, [WorkflowActions.SMS_NUMBER]: SMS_NUMBER, + [WorkflowActions.CAL_AI_PHONE_CALL]: CAL_AI_PHONE_CALL, } as const; export type StepAction = (typeof STEP_ACTIONS)[number]; diff --git a/apps/api/v2/test/setEnvVars.ts b/apps/api/v2/test/setEnvVars.ts index 432517d04cd839..aac391b80cea34 100644 --- a/apps/api/v2/test/setEnvVars.ts +++ b/apps/api/v2/test/setEnvVars.ts @@ -36,4 +36,5 @@ process.env = { CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX", INTEGRATION_TEST_MODE: "true", e2e: "true", + SLOTS_CACHE_TTL: "1" }; diff --git a/apps/api/v2/tsconfig.json b/apps/api/v2/tsconfig.json index b4eb8fc148566a..8460e8e22c5a8e 100644 --- a/apps/api/v2/tsconfig.json +++ b/apps/api/v2/tsconfig.json @@ -26,7 +26,8 @@ "@calcom/platform-libraries/app-store": ["../../../packages/platform/libraries/app-store.ts"], "@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"], "@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"], - "@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"] + "@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"], + "@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"] }, "incremental": true, "skipLibCheck": true, diff --git a/apps/ui-playground/package.json b/apps/ui-playground/package.json index 9b9881c3ad64ad..d54e2c34b97889 100644 --- a/apps/ui-playground/package.json +++ b/apps/ui-playground/package.json @@ -34,6 +34,6 @@ "eslint-config-next": "15.1.6", "postcss": "^8", "tailwindcss": "^3.4.1", - "typescript": "^5" + "typescript": "^5.9.0-beta" } } diff --git a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx index 77b685ffb3224f..25070801c25226 100644 --- a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx +++ b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/page.tsx @@ -7,6 +7,7 @@ import { cookies, headers } from "next/headers"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { loadTranslations } from "@calcom/lib/server/i18n"; +import { prisma } from "@calcom/prisma"; import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx"; import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps"; @@ -17,7 +18,7 @@ import type { PageProps as LegacyPageProps } from "~/team/type-view"; import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData"; async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise { - const featuresRepository = new FeaturesRepository(); + const featuresRepository = new FeaturesRepository(prisma); const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( "team-booking-page-cache" ); diff --git a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/queries.ts b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/queries.ts index ce6ac0701c8a2e..8f4df870a83fff 100644 --- a/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/queries.ts +++ b/apps/web/app/(booking-page-wrapper)/team/[slug]/[type]/queries.ts @@ -102,7 +102,7 @@ export async function getEnrichedEventType({ } export async function shouldUseApiV2ForTeamSlots(teamId: number): Promise { - const featureRepo = new FeaturesRepository(); + const featureRepo = new FeaturesRepository(prisma); const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(teamId, "use-api-v2-for-team-slots"); const useApiV2 = teamHasApiV2Route && Boolean(process.env.NEXT_PUBLIC_API_V2_URL); diff --git a/apps/web/app/(use-page-wrapper)/insights/layout.tsx b/apps/web/app/(use-page-wrapper)/insights/layout.tsx index 7e987011ad26dd..e5518d9fd611f7 100644 --- a/apps/web/app/(use-page-wrapper)/insights/layout.tsx +++ b/apps/web/app/(use-page-wrapper)/insights/layout.tsx @@ -4,11 +4,12 @@ import { notFound } from "next/navigation"; import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import Shell from "@calcom/features/shell/Shell"; +import { prisma } from "@calcom/prisma"; import UpgradeTipWrapper from "./UpgradeTipWrapper"; export default async function InsightsLayout({ children }: { children: React.ReactNode }) { - const featuresRepository = new FeaturesRepository(); + const featuresRepository = new FeaturesRepository(prisma); const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights"); if (!insightsEnabled) { diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/api-keys/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/api-keys/page.tsx index 50743a9339d29a..cd85c1122348ee 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/api-keys/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/api-keys/page.tsx @@ -5,7 +5,7 @@ import { redirect } from "next/navigation"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import { APP_NAME } from "@calcom/lib/constants"; -import { ApiKeyRepository } from "@calcom/lib/server/repository/apiKey"; +import { PrismaApiKeyRepository } from "@calcom/lib/server/repository/PrismaApiKeyRepository"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -22,7 +22,7 @@ export const generateMetadata = async () => const getCachedApiKeys = unstable_cache( async (userId: number) => { - return await ApiKeyRepository.findApiKeysFromUserId({ userId }); + return await PrismaApiKeyRepository.findApiKeysFromUserId({ userId }); }, undefined, { revalidate: 3600, tags: ["viewer.apiKeys.list"] } // Cache for 1 hour diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx index 5082bbcf42300d..e767079a314198 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/layout.tsx @@ -9,6 +9,7 @@ import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper"; import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; +import { prisma } from "@calcom/prisma"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -17,7 +18,7 @@ import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient"; const getTeamFeatures = unstable_cache( async (teamId: number) => { - const featuresRepository = new FeaturesRepository(); + const featuresRepository = new FeaturesRepository(prisma); return await featuresRepository.getTeamFeatures(teamId); }, ["team-features"], diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx index 55cbeac844c307..cfa0900043cfb7 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/page.tsx @@ -1,7 +1,15 @@ import { _generateMetadata, getTranslate } from "app/_utils"; +import { headers, cookies } from "next/headers"; +import { redirect } from "next/navigation"; import OrgSettingsAttributesPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-list-view"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; export const generateMetadata = async () => await _generateMetadata( @@ -14,10 +22,40 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { + return redirect("/settings/profile"); + } + + const { canRead, canEdit, canDelete, canCreate } = await getResourcePermissions({ + userId: session.user.id, + teamId: session.user.profile.organizationId, + resource: Resource.Attributes, + userRole: session.user.org.role, + fallbackRoles: { + read: { + roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], + }, + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + delete: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + create: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }); + + if (!canRead) { + return redirect("/settings/profile"); + } return ( - + ); }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/dsync/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/dsync/page.tsx index a53e48fd1ec599..161ce65275a78c 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/dsync/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/dsync/page.tsx @@ -1,7 +1,15 @@ import { _generateMetadata, getTranslate } from "app/_utils"; +import { headers, cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import DirectorySyncTeamView from "@calcom/features/ee/dsync/page/team-dsync-view"; +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; export const generateMetadata = async () => await _generateMetadata( @@ -14,10 +22,27 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { + return redirect("/settings/organizations/general"); + } + + const { canEdit } = await getResourcePermissions({ + userId: session.user.id, + teamId: session.user.profile.organizationId, + resource: Resource.Organization, + userRole: session.user.org.role, + fallbackRoles: { + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }); return ( - + ); }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx index 8900c77c70038b..7a4e75a2f43e0a 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/privacy/page.tsx @@ -1,7 +1,15 @@ import { _generateMetadata, getTranslate } from "app/_utils"; +import { headers, cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import PrivacyView from "@calcom/features/ee/organizations/pages/settings/privacy"; +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; export const generateMetadata = async () => await _generateMetadata( @@ -15,9 +23,34 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { + return redirect("/settings/profile"); + } + + const { canRead, canEdit } = await getResourcePermissions({ + userId: session.user.id, + teamId: session.user.profile.organizationId, + resource: Resource.Organization, + userRole: session.user.org.role, + fallbackRoles: { + read: { + roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], + }, + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }); + + if (!canRead) { + return redirect("/settings/profile"); + } + return ( - + ); }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx index 93750ac2fdadfe..a3fcf936b1cb6a 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/sso/page.tsx @@ -1,7 +1,15 @@ import { _generateMetadata, getTranslate } from "app/_utils"; +import { headers, cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import OrgSSOView from "@calcom/features/ee/sso/page/orgs-sso-view"; +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; export const generateMetadata = async () => await _generateMetadata( @@ -14,10 +22,27 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { + return redirect("/settings/organizations/general"); + } + + const { canEdit } = await getResourcePermissions({ + userId: session.user.id, + teamId: session.user.profile.organizationId, + resource: Resource.Organization, + userRole: session.user.org.role, + fallbackRoles: { + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }); return ( - + ); }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx index 75aeaf2f6e8b36..5ce47fad0ba9b1 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/general/page.tsx @@ -1,7 +1,15 @@ import { _generateMetadata, getTranslate } from "app/_utils"; +import { headers, cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import LegacyPage from "@calcom/features/ee/organizations/pages/settings/general"; +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; export const generateMetadata = async () => await _generateMetadata( @@ -15,9 +23,30 @@ export const generateMetadata = async () => const Page = async () => { const t = await getTranslate(); + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) { + return redirect("/settings/profile"); + } + + const { canRead, canEdit } = await getResourcePermissions({ + userId: session.user.id, + teamId: session.user.profile.organizationId, + resource: Resource.Organization, + userRole: session.user.org.role, + fallbackRoles: { + read: { + roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], + }, + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }); + return ( - + ); }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx index 3849a833050756..4b58650b45d306 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/profile/page.tsx @@ -1,7 +1,16 @@ import { _generateMetadata, getTranslate } from "app/_utils"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import LegacyPage from "@calcom/features/ee/organizations/pages/settings/profile"; +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import type { Membership } from "@calcom/prisma/client"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; export const generateMetadata = async () => await _generateMetadata( @@ -13,14 +22,47 @@ export const generateMetadata = async () => ); const Page = async () => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); const t = await getTranslate(); + const orgRole = session?.user.profile?.organization.members?.find( + (member: Membership) => member.userId === session?.user.id + )?.role; + + if (!session?.user.id || !session?.user.profile?.organizationId || !orgRole) { + return redirect("/settings/profile"); + } + + const { canRead, canEdit, canDelete } = await getResourcePermissions({ + userId: session.user.id, + teamId: session?.user.profile?.organizationId, + resource: Resource.Organization, + userRole: orgRole, + fallbackRoles: { + read: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + delete: { + roles: [MembershipRole.OWNER], + }, + }, + }); + return ( - + ); }; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx index 7b26a0c3693062..3b5466f46ba8cb 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx @@ -11,6 +11,7 @@ import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permiss import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { RoleService } from "@calcom/features/pbac/services/role.service"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { prisma } from "@calcom/prisma"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -29,7 +30,7 @@ const getCachedTeamRoles = unstable_cache( const getCachedTeamFeature = unstable_cache( async (teamId: number, feature: keyof AppFlags) => { - const featureRepo = new FeaturesRepository(); + const featureRepo = new FeaturesRepository(prisma); const res = await featureRepo.checkIfTeamHasFeature(teamId, feature); return res; }, diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx index 3db242a724e1d3..dc582367e0fd55 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx @@ -4,7 +4,7 @@ import { unstable_cache } from "next/cache"; import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; -import { AttributeRepository } from "@calcom/lib/server/repository/attribute"; +import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository"; import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router"; import { TeamMembersView } from "~/teams/team-members-view"; @@ -37,7 +37,7 @@ const getCachedTeamAttributes = unstable_cache( async (organizationId?: number) => { if (!organizationId) return []; try { - return await AttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId }); + return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId }); } catch (error) { return []; } diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx index 456b180c861333..c89b0e2a62a8bd 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx @@ -12,6 +12,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio import { RoleService } from "@calcom/features/pbac/services/role.service"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { getTeamWithMembers } from "@calcom/lib/server/queries/teams"; +import { prisma } from "@calcom/prisma"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -32,7 +33,7 @@ const getCachedTeamRoles = (teamId: number) => const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) => unstable_cache( async () => { - const featureRepo = new FeaturesRepository(); + const featureRepo = new FeaturesRepository(prisma); const res = await featureRepo.checkIfTeamHasFeature(teamId, feature); return res; }, diff --git a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx index 4fbd5875586e5f..eeb9b26b1bce58 100644 --- a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx @@ -3,7 +3,7 @@ import { _generateMetadata } from "app/_utils"; import { unstable_cache } from "next/cache"; import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory"; -import { AttributeRepository } from "@calcom/lib/server/repository/attribute"; +import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository"; import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router"; import { MembersView } from "~/members/members-view"; @@ -19,7 +19,7 @@ export const generateMetadata = async () => const getCachedAttributes = unstable_cache( async (orgId: number) => { - return await AttributeRepository.findAllByOrgIdWithOptions({ orgId }); + return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId }); }, undefined, { revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour diff --git a/apps/web/app/api/auth/forgot-password/route.ts b/apps/web/app/api/auth/forgot-password/route.ts index 064dfce857e733..9611abdf4bbe69 100644 --- a/apps/web/app/api/auth/forgot-password/route.ts +++ b/apps/web/app/api/auth/forgot-password/route.ts @@ -7,6 +7,7 @@ import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetReq import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import { emailSchema } from "@calcom/lib/emailSchema"; import prisma from "@calcom/prisma"; +import { piiHasher } from "@calcom/lib/server/PiiHasher"; async function handler(req: NextRequest) { const body = await parseRequestData(req); @@ -28,7 +29,7 @@ async function handler(req: NextRequest) { await checkRateLimitAndThrowError({ rateLimitingType: "core", - identifier: ip, + identifier: piiHasher.hash(ip), }); try { diff --git a/apps/web/app/api/auth/oidc/route.ts b/apps/web/app/api/auth/oidc/route.ts index 7e9259587be6af..693ae9a203df8a 100644 --- a/apps/web/app/api/auth/oidc/route.ts +++ b/apps/web/app/api/auth/oidc/route.ts @@ -4,13 +4,16 @@ import { NextResponse } from "next/server"; import jackson from "@calcom/features/ee/sso/lib/jackson"; import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; // This is the callback endpoint for the OIDC provider // A team must set this endpoint in the OIDC provider's configuration async function handler(req: NextRequest) { + const log = logger.getSubLogger({ prefix: ["[ODIC auth]"] }); const { searchParams } = req.nextUrl; const code = searchParams.get("code"); const state = searchParams.get("state"); + const tenant = searchParams.get("tenant"); if (!code || !state) { return NextResponse.json({ message: "Code and state are required" }, { status: 400 }); @@ -30,6 +33,7 @@ async function handler(req: NextRequest) { return NextResponse.redirect(redirect_url, 302); } catch (err) { + log.error(`Error authorizing tenant ${tenant}: ${err}`); const { message, statusCode = 500 } = err as HttpError; return NextResponse.json({ message }, { status: statusCode }); diff --git a/apps/web/app/api/auth/saml/authorize/route.ts b/apps/web/app/api/auth/saml/authorize/route.ts index cb887f6f86943e..663fa296a07c8a 100644 --- a/apps/web/app/api/auth/saml/authorize/route.ts +++ b/apps/web/app/api/auth/saml/authorize/route.ts @@ -5,17 +5,20 @@ import { NextResponse } from "next/server"; import type { OAuthReq } from "@calcom/features/ee/sso/lib/jackson"; import jackson from "@calcom/features/ee/sso/lib/jackson"; import type { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; async function handler(req: NextRequest) { + const log = logger.getSubLogger({ prefix: ["[SAML authorize]"] }); const { oauthController } = await jackson(); + const oAuthReq = Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq; + try { - const { redirect_url } = await oauthController.authorize( - Object.fromEntries(req.nextUrl.searchParams) as unknown as OAuthReq - ); + const { redirect_url } = await oauthController.authorize(oAuthReq); return NextResponse.redirect(redirect_url as string, 302); } catch (err) { + log.error(`Error initaiting SAML login for tenant ${oAuthReq?.tenant}: ${err}`); const { message, statusCode = 500 } = err as HttpError; return NextResponse.json({ message }, { status: statusCode }); diff --git a/apps/web/app/api/auth/saml/callback/route.ts b/apps/web/app/api/auth/saml/callback/route.ts index 7d12536d3153ab..df6d447d9594af 100644 --- a/apps/web/app/api/auth/saml/callback/route.ts +++ b/apps/web/app/api/auth/saml/callback/route.ts @@ -2,19 +2,34 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; import { parseRequestData } from "app/api/parseRequestData"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { uuid } from "short-uuid"; import jackson from "@calcom/features/ee/sso/lib/jackson"; import type { SAMLResponsePayload } from "@calcom/features/ee/sso/lib/jackson"; +import logger from "@calcom/lib/logger"; async function handler(req: NextRequest) { + const uid = uuid(); + const log = logger.getSubLogger({ prefix: ["[SAML callback]", `trace: ${uid}`] }); const { oauthController } = await jackson(); - const { redirect_url } = await oauthController.samlResponse( - (await parseRequestData(req)) as SAMLResponsePayload - ); + const requestData = (await parseRequestData(req)) as SAMLResponsePayload; - if (redirect_url) { - return NextResponse.redirect(redirect_url, 302); + try { + const { redirect_url, error } = await oauthController.samlResponse(requestData); + + if (redirect_url) { + return NextResponse.redirect(redirect_url, 302); + } + + if (error) { + const uid = uuid(); + log.error(`Error authenticating user with error ${error} for relayState ${requestData?.RelayState}`); + return NextResponse.json({ message: `Error authorizing user. trace: ${uid}` }, { status: 400 }); + } + } catch (error) { + log.error(`Error processing SAML response`, error); + return NextResponse.json({ message: `Error processing SAML response. trace: ${uid}` }, { status: 500 }); } return NextResponse.json({ message: "No redirect URL provided" }, { status: 400 }); diff --git a/apps/web/app/api/auth/saml/token/route.ts b/apps/web/app/api/auth/saml/token/route.ts index 6f2fc94f2e67fe..5034ec411df877 100644 --- a/apps/web/app/api/auth/saml/token/route.ts +++ b/apps/web/app/api/auth/saml/token/route.ts @@ -2,14 +2,26 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; import { parseRequestData } from "app/api/parseRequestData"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { uuid } from "short-uuid"; import jackson from "@calcom/features/ee/sso/lib/jackson"; import type { OAuthTokenReq } from "@calcom/features/ee/sso/lib/jackson"; +import logger from "@calcom/lib/logger"; async function handler(req: NextRequest) { const { oauthController } = await jackson(); - const tokenResponse = await oauthController.token((await parseRequestData(req)) as OAuthTokenReq); - return NextResponse.json(tokenResponse); + const log = logger.getSubLogger({ prefix: ["[SAML token]"] }); + + const oauthTokenReq = (await parseRequestData(req)) as OAuthTokenReq; + + try { + const tokenResponse = await oauthController.token(oauthTokenReq); + return NextResponse.json(tokenResponse); + } catch (error) { + const uid = uuid(); + log.error(`Error getting auth token for client id ${oauthTokenReq?.client_id}: ${error} trace: ${uid}`); + throw new Error(`Error getting auth token with error ${error} trace: ${uid}`); + } } export const POST = defaultResponderForAppDir(handler); diff --git a/apps/web/app/api/auth/saml/userinfo/route.ts b/apps/web/app/api/auth/saml/userinfo/route.ts index f752a8881fff9a..8a602f28ef0e21 100644 --- a/apps/web/app/api/auth/saml/userinfo/route.ts +++ b/apps/web/app/api/auth/saml/userinfo/route.ts @@ -1,23 +1,33 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import z from "zod"; +import { uuid } from "short-uuid"; +import { z } from "zod"; import jackson from "@calcom/features/ee/sso/lib/jackson"; import { HttpError } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; const extractAuthToken = (req: NextRequest) => { + const log = logger.getSubLogger({ prefix: ["SAML extractAuthToken"] }); + const uid = uuid(); const authHeader = req.headers.get("authorization"); const parts = (authHeader || "").split(" "); if (parts.length > 1) return parts[1]; // check for query param let arr: string[] = []; - const { access_token } = requestQuery.parse(Object.fromEntries(req.nextUrl.searchParams)); + const tokenParse = requestQuery.safeParse(Object.fromEntries(req.nextUrl.searchParams)); + let access_token; + if (!tokenParse.success) { + log.error(`Error parsing request query: ${tokenParse.error} trace ${uid}`); + throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` }); + } + access_token = tokenParse.data.access_token; arr = arr.concat(access_token); if (arr[0].length > 0) return arr[0]; - throw new HttpError({ statusCode: 401, message: "Unauthorized" }); + throw new HttpError({ statusCode: 401, message: `Unauthorized trace: ${uid}` }); }; const requestQuery = z.object({ @@ -25,10 +35,18 @@ const requestQuery = z.object({ }); async function handler(req: NextRequest) { + const log = logger.getSubLogger({ prefix: ["SAML userinfo"] }); const { oauthController } = await jackson(); const token = extractAuthToken(req); - const userInfo = await oauthController.userInfo(token); - return NextResponse.json(userInfo); + + try { + const userInfo = await oauthController.userInfo(token); + return NextResponse.json(userInfo); + } catch (error) { + const uid = uuid(); + log.error(`trace: ${uid} Error getting user info from token: ${error}`); + throw new Error(`Error getting user info from token. trace: ${uid}`); + } } export const GET = defaultResponderForAppDir(handler); diff --git a/apps/web/app/api/auth/signup/route.ts b/apps/web/app/api/auth/signup/route.ts index bf011382a8f7c2..05326592fc09b4 100644 --- a/apps/web/app/api/auth/signup/route.ts +++ b/apps/web/app/api/auth/signup/route.ts @@ -10,6 +10,7 @@ import getIP from "@calcom/lib/getIP"; import { HttpError } from "@calcom/lib/http-error"; import logger from "@calcom/lib/logger"; import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken"; +import { prisma } from "@calcom/prisma"; import { signupSchema } from "@calcom/prisma/zod-utils"; async function ensureSignupIsEnabled(body: Record) { @@ -22,7 +23,7 @@ async function ensureSignupIsEnabled(body: Record) { // Still allow signups if there is a team invite if (token) return; - const featuresRepository = new FeaturesRepository(); + const featuresRepository = new FeaturesRepository(prisma); const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup"); if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) { diff --git a/apps/web/app/api/cron/queuedFormResponseCleanup/route.ts b/apps/web/app/api/cron/queuedFormResponseCleanup/route.ts new file mode 100644 index 00000000000000..39c593ee6654f5 --- /dev/null +++ b/apps/web/app/api/cron/queuedFormResponseCleanup/route.ts @@ -0,0 +1,5 @@ +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; + +import { handleQueuedFormResponseCleanup } from "@calcom/app-store/routing-forms/cron/queuedFormResponseCleanup"; + +export const POST = defaultResponderForAppDir(handleQueuedFormResponseCleanup); diff --git a/apps/web/app/api/logo/route.ts b/apps/web/app/api/logo/route.ts index 7955704ff083d4..a52d6cd8be328a 100644 --- a/apps/web/app/api/logo/route.ts +++ b/apps/web/app/api/logo/route.ts @@ -208,7 +208,7 @@ async function getHandler(request: NextRequest) { } // Create a new response with the image buffer - const imageResponse = new NextResponse(buffer); + const imageResponse = new NextResponse(buffer as BodyInit); // Set the appropriate headers imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png"); diff --git a/apps/web/app/api/social/og/image/route.tsx b/apps/web/app/api/social/og/image/route.tsx index 710585eec47d55..498e3289cf9b09 100644 --- a/apps/web/app/api/social/og/image/route.tsx +++ b/apps/web/app/api/social/og/image/route.tsx @@ -35,20 +35,31 @@ async function handler(req: NextRequest) { const imageType = searchParams.get("type"); try { - const [calFontData, interFontData, interFontMediumData] = await Promise.all([ + const fontResults = await Promise.allSettled([ fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()), fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()), fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()), ]); + + const fonts: SatoriOptions["fonts"] = []; + + if (fontResults[1].status === "fulfilled") { + fonts.push({ name: "inter", data: fontResults[1].value, weight: 400 }); + } + + if (fontResults[2].status === "fulfilled") { + fonts.push({ name: "inter", data: fontResults[2].value, weight: 500 }); + } + + if (fontResults[0].status === "fulfilled") { + fonts.push({ name: "cal", data: fontResults[0].value, weight: 400 }); + fonts.push({ name: "cal", data: fontResults[0].value, weight: 600 }); + } + const ogConfig = { width: 1200, height: 630, - fonts: [ - { name: "inter", data: interFontData, weight: 400 }, - { name: "inter", data: interFontMediumData, weight: 500 }, - { name: "cal", data: calFontData, weight: 400 }, - { name: "cal", data: calFontData, weight: 600 }, - ] as SatoriOptions["fonts"], + fonts, }; switch (imageType) { diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index f172d15721ab6d..b5c97a7bbb5518 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -546,9 +546,7 @@ function BookingListItem(booking: BookingItemProps) { -
+
{/* Time and Badges for mobile */}
@@ -893,7 +891,7 @@ const FirstAttendee = ({ ) : ( e.stopPropagation()}> {user.name || user.email} @@ -1081,7 +1079,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => { /> ))} -
+