diff --git a/.env.example b/.env.example index 8ccabe455b7f4c..5fccf93ee8f656 100644 --- a/.env.example +++ b/.env.example @@ -342,3 +342,6 @@ APP_ROUTER_TEAMS_ENABLED=0 # disable setry server source maps SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1 + +# api v2 +NEXT_PUBLIC_API_V2_URL="http://localhost:5555/api/v2" \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 7944833941fa19..466e374a2a88d4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,3 +16,4 @@ public packages/prisma/zod packages/prisma/enums apps/web/public/embed +apps/api/v2/swagger/documentation.json diff --git a/apps/api/index.js b/apps/api/index.js new file mode 100644 index 00000000000000..a7e4c44ce3e501 --- /dev/null +++ b/apps/api/index.js @@ -0,0 +1,18 @@ +const http = require("http"); +const connect = require("connect"); +const { createProxyMiddleware } = require("http-proxy-middleware"); + +const apiProxyV1 = createProxyMiddleware({ + target: "http://localhost:3003", +}); + +const apiProxyV2 = createProxyMiddleware({ + target: "http://localhost:3004", +}); + +const app = connect(); +app.use("/", apiProxyV1); + +app.use("/v2", apiProxyV2); + +http.createServer(app).listen(3002); diff --git a/apps/api/package.json b/apps/api/package.json index 75040b614c8b79..252bd4f1772b8f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,46 +1,16 @@ { - "name": "@calcom/api", + "name": "@calcom/api-proxy", "version": "1.0.0", - "description": "Public API for Cal.com", - "main": "index.ts", - "repository": "git@github.com:calcom/api.git", - "author": "Cal.com Inc.", - "private": true, + "description": "", + "main": "index.js", "scripts": { - "build": "next build", - "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", - "dev": "PORT=3002 next dev", - "lint": "eslint . --ignore-path .gitignore", - "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix", - "start": "PORT=3002 next start", - "docker-start-api": "PORT=80 next start", - "type-check": "tsc --pretty --noEmit" - }, - "devDependencies": { - "@calcom/tsconfig": "*", - "@calcom/types": "*", - "node-mocks-http": "^1.11.0" + "dev": "node ./index.js" }, + "author": "", + "license": "ISC", "dependencies": { - "@calcom/app-store": "*", - "@calcom/core": "*", - "@calcom/dayjs": "*", - "@calcom/emails": "*", - "@calcom/features": "*", - "@calcom/lib": "*", - "@calcom/prisma": "*", - "@calcom/trpc": "*", - "@sentry/nextjs": "^7.73.0", - "bcryptjs": "^2.4.3", - "memory-cache": "^0.2.0", - "next": "^13.5.4", - "next-api-middleware": "^1.0.1", - "next-axiom": "^0.17.0", - "next-swagger-doc": "^0.3.6", - "next-validations": "^0.2.0", - "typescript": "^4.9.4", - "tzdata": "^1.0.30", - "uuid": "^8.3.2", - "zod": "^3.22.4" + "connect": "^3.7.0", + "http": "^0.0.1-security", + "http-proxy-middleware": "^2.0.6" } } diff --git a/apps/api/.env.example b/apps/api/v1/.env.example similarity index 100% rename from apps/api/.env.example rename to apps/api/v1/.env.example diff --git a/apps/api/.gitignore b/apps/api/v1/.gitignore similarity index 100% rename from apps/api/.gitignore rename to apps/api/v1/.gitignore diff --git a/apps/api/.gitkeep b/apps/api/v1/.gitkeep similarity index 100% rename from apps/api/.gitkeep rename to apps/api/v1/.gitkeep diff --git a/apps/api/.prettierignore b/apps/api/v1/.prettierignore similarity index 100% rename from apps/api/.prettierignore rename to apps/api/v1/.prettierignore diff --git a/apps/api/LICENSE b/apps/api/v1/LICENSE similarity index 100% rename from apps/api/LICENSE rename to apps/api/v1/LICENSE diff --git a/apps/api/README.md b/apps/api/v1/README.md similarity index 100% rename from apps/api/README.md rename to apps/api/v1/README.md diff --git a/apps/api/lib/constants.ts b/apps/api/v1/lib/constants.ts similarity index 100% rename from apps/api/lib/constants.ts rename to apps/api/v1/lib/constants.ts diff --git a/apps/api/lib/helpers/addRequestid.ts b/apps/api/v1/lib/helpers/addRequestid.ts similarity index 100% rename from apps/api/lib/helpers/addRequestid.ts rename to apps/api/v1/lib/helpers/addRequestid.ts diff --git a/apps/api/lib/helpers/captureErrors.ts b/apps/api/v1/lib/helpers/captureErrors.ts similarity index 100% rename from apps/api/lib/helpers/captureErrors.ts rename to apps/api/v1/lib/helpers/captureErrors.ts diff --git a/apps/api/lib/helpers/extendRequest.ts b/apps/api/v1/lib/helpers/extendRequest.ts similarity index 100% rename from apps/api/lib/helpers/extendRequest.ts rename to apps/api/v1/lib/helpers/extendRequest.ts diff --git a/apps/api/lib/helpers/httpMethods.ts b/apps/api/v1/lib/helpers/httpMethods.ts similarity index 100% rename from apps/api/lib/helpers/httpMethods.ts rename to apps/api/v1/lib/helpers/httpMethods.ts diff --git a/apps/api/lib/helpers/rateLimitApiKey.ts b/apps/api/v1/lib/helpers/rateLimitApiKey.ts similarity index 100% rename from apps/api/lib/helpers/rateLimitApiKey.ts rename to apps/api/v1/lib/helpers/rateLimitApiKey.ts diff --git a/apps/api/lib/helpers/safeParseJSON.ts b/apps/api/v1/lib/helpers/safeParseJSON.ts similarity index 100% rename from apps/api/lib/helpers/safeParseJSON.ts rename to apps/api/v1/lib/helpers/safeParseJSON.ts diff --git a/apps/api/lib/helpers/verifyApiKey.ts b/apps/api/v1/lib/helpers/verifyApiKey.ts similarity index 100% rename from apps/api/lib/helpers/verifyApiKey.ts rename to apps/api/v1/lib/helpers/verifyApiKey.ts diff --git a/apps/api/lib/helpers/verifyCredentialSyncEnabled.ts b/apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts similarity index 100% rename from apps/api/lib/helpers/verifyCredentialSyncEnabled.ts rename to apps/api/v1/lib/helpers/verifyCredentialSyncEnabled.ts diff --git a/apps/api/lib/helpers/withMiddleware.ts b/apps/api/v1/lib/helpers/withMiddleware.ts similarity index 100% rename from apps/api/lib/helpers/withMiddleware.ts rename to apps/api/v1/lib/helpers/withMiddleware.ts diff --git a/apps/api/lib/helpers/withPagination.ts b/apps/api/v1/lib/helpers/withPagination.ts similarity index 100% rename from apps/api/lib/helpers/withPagination.ts rename to apps/api/v1/lib/helpers/withPagination.ts diff --git a/apps/api/lib/types.ts b/apps/api/v1/lib/types.ts similarity index 100% rename from apps/api/lib/types.ts rename to apps/api/v1/lib/types.ts diff --git a/apps/api/lib/utils/extractUserIdsFromQuery.ts b/apps/api/v1/lib/utils/extractUserIdsFromQuery.ts similarity index 100% rename from apps/api/lib/utils/extractUserIdsFromQuery.ts rename to apps/api/v1/lib/utils/extractUserIdsFromQuery.ts diff --git a/apps/api/lib/utils/isAdmin.ts b/apps/api/v1/lib/utils/isAdmin.ts similarity index 100% rename from apps/api/lib/utils/isAdmin.ts rename to apps/api/v1/lib/utils/isAdmin.ts diff --git a/apps/api/lib/utils/isValidBase64Image.ts b/apps/api/v1/lib/utils/isValidBase64Image.ts similarity index 100% rename from apps/api/lib/utils/isValidBase64Image.ts rename to apps/api/v1/lib/utils/isValidBase64Image.ts diff --git a/apps/api/lib/utils/stringifyISODate.ts b/apps/api/v1/lib/utils/stringifyISODate.ts similarity index 100% rename from apps/api/lib/utils/stringifyISODate.ts rename to apps/api/v1/lib/utils/stringifyISODate.ts diff --git a/apps/api/lib/validations/api-key.ts b/apps/api/v1/lib/validations/api-key.ts similarity index 100% rename from apps/api/lib/validations/api-key.ts rename to apps/api/v1/lib/validations/api-key.ts diff --git a/apps/api/lib/validations/attendee.ts b/apps/api/v1/lib/validations/attendee.ts similarity index 100% rename from apps/api/lib/validations/attendee.ts rename to apps/api/v1/lib/validations/attendee.ts diff --git a/apps/api/lib/validations/availability.ts b/apps/api/v1/lib/validations/availability.ts similarity index 100% rename from apps/api/lib/validations/availability.ts rename to apps/api/v1/lib/validations/availability.ts diff --git a/apps/api/lib/validations/booking-reference.ts b/apps/api/v1/lib/validations/booking-reference.ts similarity index 100% rename from apps/api/lib/validations/booking-reference.ts rename to apps/api/v1/lib/validations/booking-reference.ts diff --git a/apps/api/lib/validations/booking.ts b/apps/api/v1/lib/validations/booking.ts similarity index 100% rename from apps/api/lib/validations/booking.ts rename to apps/api/v1/lib/validations/booking.ts diff --git a/apps/api/lib/validations/credential-sync.ts b/apps/api/v1/lib/validations/credential-sync.ts similarity index 100% rename from apps/api/lib/validations/credential-sync.ts rename to apps/api/v1/lib/validations/credential-sync.ts diff --git a/apps/api/lib/validations/destination-calendar.ts b/apps/api/v1/lib/validations/destination-calendar.ts similarity index 100% rename from apps/api/lib/validations/destination-calendar.ts rename to apps/api/v1/lib/validations/destination-calendar.ts diff --git a/apps/api/lib/validations/event-type-custom-input.ts b/apps/api/v1/lib/validations/event-type-custom-input.ts similarity index 100% rename from apps/api/lib/validations/event-type-custom-input.ts rename to apps/api/v1/lib/validations/event-type-custom-input.ts diff --git a/apps/api/lib/validations/event-type.ts b/apps/api/v1/lib/validations/event-type.ts similarity index 100% rename from apps/api/lib/validations/event-type.ts rename to apps/api/v1/lib/validations/event-type.ts diff --git a/apps/api/lib/validations/membership.ts b/apps/api/v1/lib/validations/membership.ts similarity index 100% rename from apps/api/lib/validations/membership.ts rename to apps/api/v1/lib/validations/membership.ts diff --git a/apps/api/lib/validations/payment.ts b/apps/api/v1/lib/validations/payment.ts similarity index 100% rename from apps/api/lib/validations/payment.ts rename to apps/api/v1/lib/validations/payment.ts diff --git a/apps/api/lib/validations/reminder-mail.ts b/apps/api/v1/lib/validations/reminder-mail.ts similarity index 100% rename from apps/api/lib/validations/reminder-mail.ts rename to apps/api/v1/lib/validations/reminder-mail.ts diff --git a/apps/api/lib/validations/schedule.ts b/apps/api/v1/lib/validations/schedule.ts similarity index 100% rename from apps/api/lib/validations/schedule.ts rename to apps/api/v1/lib/validations/schedule.ts diff --git a/apps/api/lib/validations/selected-calendar.ts b/apps/api/v1/lib/validations/selected-calendar.ts similarity index 100% rename from apps/api/lib/validations/selected-calendar.ts rename to apps/api/v1/lib/validations/selected-calendar.ts diff --git a/apps/api/lib/validations/shared/baseApiParams.ts b/apps/api/v1/lib/validations/shared/baseApiParams.ts similarity index 100% rename from apps/api/lib/validations/shared/baseApiParams.ts rename to apps/api/v1/lib/validations/shared/baseApiParams.ts diff --git a/apps/api/lib/validations/shared/jsonSchema.ts b/apps/api/v1/lib/validations/shared/jsonSchema.ts similarity index 100% rename from apps/api/lib/validations/shared/jsonSchema.ts rename to apps/api/v1/lib/validations/shared/jsonSchema.ts diff --git a/apps/api/lib/validations/shared/queryAttendeeEmail.ts b/apps/api/v1/lib/validations/shared/queryAttendeeEmail.ts similarity index 100% rename from apps/api/lib/validations/shared/queryAttendeeEmail.ts rename to apps/api/v1/lib/validations/shared/queryAttendeeEmail.ts diff --git a/apps/api/lib/validations/shared/queryIdString.ts b/apps/api/v1/lib/validations/shared/queryIdString.ts similarity index 100% rename from apps/api/lib/validations/shared/queryIdString.ts rename to apps/api/v1/lib/validations/shared/queryIdString.ts diff --git a/apps/api/lib/validations/shared/queryIdTransformParseInt.ts b/apps/api/v1/lib/validations/shared/queryIdTransformParseInt.ts similarity index 100% rename from apps/api/lib/validations/shared/queryIdTransformParseInt.ts rename to apps/api/v1/lib/validations/shared/queryIdTransformParseInt.ts diff --git a/apps/api/lib/validations/shared/querySlug.ts b/apps/api/v1/lib/validations/shared/querySlug.ts similarity index 100% rename from apps/api/lib/validations/shared/querySlug.ts rename to apps/api/v1/lib/validations/shared/querySlug.ts diff --git a/apps/api/lib/validations/shared/queryTeamId.ts b/apps/api/v1/lib/validations/shared/queryTeamId.ts similarity index 100% rename from apps/api/lib/validations/shared/queryTeamId.ts rename to apps/api/v1/lib/validations/shared/queryTeamId.ts diff --git a/apps/api/lib/validations/shared/queryUserEmail.ts b/apps/api/v1/lib/validations/shared/queryUserEmail.ts similarity index 100% rename from apps/api/lib/validations/shared/queryUserEmail.ts rename to apps/api/v1/lib/validations/shared/queryUserEmail.ts diff --git a/apps/api/lib/validations/shared/queryUserId.ts b/apps/api/v1/lib/validations/shared/queryUserId.ts similarity index 100% rename from apps/api/lib/validations/shared/queryUserId.ts rename to apps/api/v1/lib/validations/shared/queryUserId.ts diff --git a/apps/api/lib/validations/shared/timeZone.ts b/apps/api/v1/lib/validations/shared/timeZone.ts similarity index 100% rename from apps/api/lib/validations/shared/timeZone.ts rename to apps/api/v1/lib/validations/shared/timeZone.ts diff --git a/apps/api/lib/validations/team.ts b/apps/api/v1/lib/validations/team.ts similarity index 100% rename from apps/api/lib/validations/team.ts rename to apps/api/v1/lib/validations/team.ts diff --git a/apps/api/lib/validations/user.ts b/apps/api/v1/lib/validations/user.ts similarity index 100% rename from apps/api/lib/validations/user.ts rename to apps/api/v1/lib/validations/user.ts diff --git a/apps/api/lib/validations/webhook.ts b/apps/api/v1/lib/validations/webhook.ts similarity index 100% rename from apps/api/lib/validations/webhook.ts rename to apps/api/v1/lib/validations/webhook.ts diff --git a/apps/api/next-env.d.ts b/apps/api/v1/next-env.d.ts similarity index 100% rename from apps/api/next-env.d.ts rename to apps/api/v1/next-env.d.ts diff --git a/apps/api/next-i18next.config.js b/apps/api/v1/next-i18next.config.js similarity index 76% rename from apps/api/next-i18next.config.js rename to apps/api/v1/next-i18next.config.js index 402b72363cf401..cab1a8b008039f 100644 --- a/apps/api/next-i18next.config.js +++ b/apps/api/v1/next-i18next.config.js @@ -4,7 +4,7 @@ const i18nConfig = require("@calcom/config/next-i18next.config"); /** @type {import("next-i18next").UserConfig} */ const config = { ...i18nConfig, - localePath: path.resolve("../web/public/static/locales"), + localePath: path.resolve("../../web/public/static/locales"), }; module.exports = config; diff --git a/apps/api/next.config.js b/apps/api/v1/next.config.js similarity index 100% rename from apps/api/next.config.js rename to apps/api/v1/next.config.js diff --git a/apps/api/next.d.ts b/apps/api/v1/next.d.ts similarity index 100% rename from apps/api/next.d.ts rename to apps/api/v1/next.d.ts diff --git a/apps/api/v1/package.json b/apps/api/v1/package.json new file mode 100644 index 00000000000000..5a752cbfbd790d --- /dev/null +++ b/apps/api/v1/package.json @@ -0,0 +1,46 @@ +{ + "name": "@calcom/api", + "version": "1.0.0", + "description": "Public API for Cal.com", + "main": "index.ts", + "repository": "git@github.com:calcom/api.git", + "author": "Cal.com Inc.", + "private": true, + "scripts": { + "build": "next build", + "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", + "dev": "PORT=3003 next dev", + "lint": "eslint . --ignore-path .gitignore", + "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix", + "start": "PORT=3003 next start", + "docker-start-api": "PORT=80 next start", + "type-check": "tsc --pretty --noEmit" + }, + "devDependencies": { + "@calcom/tsconfig": "*", + "@calcom/types": "*", + "node-mocks-http": "^1.11.0" + }, + "dependencies": { + "@calcom/app-store": "*", + "@calcom/core": "*", + "@calcom/dayjs": "*", + "@calcom/emails": "*", + "@calcom/features": "*", + "@calcom/lib": "*", + "@calcom/prisma": "*", + "@calcom/trpc": "*", + "@sentry/nextjs": "^7.73.0", + "bcryptjs": "^2.4.3", + "memory-cache": "^0.2.0", + "next": "^13.5.4", + "next-api-middleware": "^1.0.1", + "next-axiom": "^0.17.0", + "next-swagger-doc": "^0.3.6", + "next-validations": "^0.2.0", + "typescript": "^4.9.4", + "tzdata": "^1.0.30", + "uuid": "^8.3.2", + "zod": "^3.22.4" + } +} diff --git a/apps/api/pages/api/api-keys/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/api-keys/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/api-keys/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/api-keys/[id]/_delete.ts b/apps/api/v1/pages/api/api-keys/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/api-keys/[id]/_delete.ts rename to apps/api/v1/pages/api/api-keys/[id]/_delete.ts diff --git a/apps/api/pages/api/api-keys/[id]/_get.ts b/apps/api/v1/pages/api/api-keys/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/api-keys/[id]/_get.ts rename to apps/api/v1/pages/api/api-keys/[id]/_get.ts diff --git a/apps/api/pages/api/api-keys/[id]/_patch.ts b/apps/api/v1/pages/api/api-keys/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/api-keys/[id]/_patch.ts rename to apps/api/v1/pages/api/api-keys/[id]/_patch.ts diff --git a/apps/api/pages/api/api-keys/[id]/index.ts b/apps/api/v1/pages/api/api-keys/[id]/index.ts similarity index 100% rename from apps/api/pages/api/api-keys/[id]/index.ts rename to apps/api/v1/pages/api/api-keys/[id]/index.ts diff --git a/apps/api/pages/api/api-keys/_get.ts b/apps/api/v1/pages/api/api-keys/_get.ts similarity index 100% rename from apps/api/pages/api/api-keys/_get.ts rename to apps/api/v1/pages/api/api-keys/_get.ts diff --git a/apps/api/pages/api/api-keys/_post.ts b/apps/api/v1/pages/api/api-keys/_post.ts similarity index 100% rename from apps/api/pages/api/api-keys/_post.ts rename to apps/api/v1/pages/api/api-keys/_post.ts diff --git a/apps/api/pages/api/api-keys/index.ts b/apps/api/v1/pages/api/api-keys/index.ts similarity index 100% rename from apps/api/pages/api/api-keys/index.ts rename to apps/api/v1/pages/api/api-keys/index.ts diff --git a/apps/api/pages/api/attendees/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/attendees/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/attendees/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/attendees/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/attendees/[id]/_delete.ts b/apps/api/v1/pages/api/attendees/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/attendees/[id]/_delete.ts rename to apps/api/v1/pages/api/attendees/[id]/_delete.ts diff --git a/apps/api/pages/api/attendees/[id]/_get.ts b/apps/api/v1/pages/api/attendees/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/attendees/[id]/_get.ts rename to apps/api/v1/pages/api/attendees/[id]/_get.ts diff --git a/apps/api/pages/api/attendees/[id]/_patch.ts b/apps/api/v1/pages/api/attendees/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/attendees/[id]/_patch.ts rename to apps/api/v1/pages/api/attendees/[id]/_patch.ts diff --git a/apps/api/pages/api/attendees/[id]/index.ts b/apps/api/v1/pages/api/attendees/[id]/index.ts similarity index 100% rename from apps/api/pages/api/attendees/[id]/index.ts rename to apps/api/v1/pages/api/attendees/[id]/index.ts diff --git a/apps/api/pages/api/attendees/_get.ts b/apps/api/v1/pages/api/attendees/_get.ts similarity index 100% rename from apps/api/pages/api/attendees/_get.ts rename to apps/api/v1/pages/api/attendees/_get.ts diff --git a/apps/api/pages/api/attendees/_post.ts b/apps/api/v1/pages/api/attendees/_post.ts similarity index 100% rename from apps/api/pages/api/attendees/_post.ts rename to apps/api/v1/pages/api/attendees/_post.ts diff --git a/apps/api/pages/api/attendees/index.ts b/apps/api/v1/pages/api/attendees/index.ts similarity index 100% rename from apps/api/pages/api/attendees/index.ts rename to apps/api/v1/pages/api/attendees/index.ts diff --git a/apps/api/pages/api/availabilities/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/availabilities/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/availabilities/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/availabilities/[id]/_delete.ts b/apps/api/v1/pages/api/availabilities/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/availabilities/[id]/_delete.ts rename to apps/api/v1/pages/api/availabilities/[id]/_delete.ts diff --git a/apps/api/pages/api/availabilities/[id]/_get.ts b/apps/api/v1/pages/api/availabilities/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/availabilities/[id]/_get.ts rename to apps/api/v1/pages/api/availabilities/[id]/_get.ts diff --git a/apps/api/pages/api/availabilities/[id]/_patch.ts b/apps/api/v1/pages/api/availabilities/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/availabilities/[id]/_patch.ts rename to apps/api/v1/pages/api/availabilities/[id]/_patch.ts diff --git a/apps/api/pages/api/availabilities/[id]/index.ts b/apps/api/v1/pages/api/availabilities/[id]/index.ts similarity index 100% rename from apps/api/pages/api/availabilities/[id]/index.ts rename to apps/api/v1/pages/api/availabilities/[id]/index.ts diff --git a/apps/api/pages/api/availabilities/_post.ts b/apps/api/v1/pages/api/availabilities/_post.ts similarity index 100% rename from apps/api/pages/api/availabilities/_post.ts rename to apps/api/v1/pages/api/availabilities/_post.ts diff --git a/apps/api/pages/api/availabilities/index.ts b/apps/api/v1/pages/api/availabilities/index.ts similarity index 100% rename from apps/api/pages/api/availabilities/index.ts rename to apps/api/v1/pages/api/availabilities/index.ts diff --git a/apps/api/pages/api/availability/_get.ts b/apps/api/v1/pages/api/availability/_get.ts similarity index 100% rename from apps/api/pages/api/availability/_get.ts rename to apps/api/v1/pages/api/availability/_get.ts diff --git a/apps/api/pages/api/availability/index.ts b/apps/api/v1/pages/api/availability/index.ts similarity index 100% rename from apps/api/pages/api/availability/index.ts rename to apps/api/v1/pages/api/availability/index.ts diff --git a/apps/api/pages/api/booking-references/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/booking-references/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/booking-references/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/booking-references/[id]/_delete.ts b/apps/api/v1/pages/api/booking-references/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/booking-references/[id]/_delete.ts rename to apps/api/v1/pages/api/booking-references/[id]/_delete.ts diff --git a/apps/api/pages/api/booking-references/[id]/_get.ts b/apps/api/v1/pages/api/booking-references/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/booking-references/[id]/_get.ts rename to apps/api/v1/pages/api/booking-references/[id]/_get.ts diff --git a/apps/api/pages/api/booking-references/[id]/_patch.ts b/apps/api/v1/pages/api/booking-references/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/booking-references/[id]/_patch.ts rename to apps/api/v1/pages/api/booking-references/[id]/_patch.ts diff --git a/apps/api/pages/api/booking-references/[id]/index.ts b/apps/api/v1/pages/api/booking-references/[id]/index.ts similarity index 100% rename from apps/api/pages/api/booking-references/[id]/index.ts rename to apps/api/v1/pages/api/booking-references/[id]/index.ts diff --git a/apps/api/pages/api/booking-references/_get.ts b/apps/api/v1/pages/api/booking-references/_get.ts similarity index 100% rename from apps/api/pages/api/booking-references/_get.ts rename to apps/api/v1/pages/api/booking-references/_get.ts diff --git a/apps/api/pages/api/booking-references/_post.ts b/apps/api/v1/pages/api/booking-references/_post.ts similarity index 100% rename from apps/api/pages/api/booking-references/_post.ts rename to apps/api/v1/pages/api/booking-references/_post.ts diff --git a/apps/api/pages/api/booking-references/index.ts b/apps/api/v1/pages/api/booking-references/index.ts similarity index 100% rename from apps/api/pages/api/booking-references/index.ts rename to apps/api/v1/pages/api/booking-references/index.ts diff --git a/apps/api/pages/api/bookings/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/bookings/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/bookings/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/bookings/[id]/_delete.ts b/apps/api/v1/pages/api/bookings/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/bookings/[id]/_delete.ts rename to apps/api/v1/pages/api/bookings/[id]/_delete.ts diff --git a/apps/api/pages/api/bookings/[id]/_get.ts b/apps/api/v1/pages/api/bookings/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/bookings/[id]/_get.ts rename to apps/api/v1/pages/api/bookings/[id]/_get.ts diff --git a/apps/api/pages/api/bookings/[id]/_patch.ts b/apps/api/v1/pages/api/bookings/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/bookings/[id]/_patch.ts rename to apps/api/v1/pages/api/bookings/[id]/_patch.ts diff --git a/apps/api/pages/api/bookings/[id]/cancel.ts b/apps/api/v1/pages/api/bookings/[id]/cancel.ts similarity index 100% rename from apps/api/pages/api/bookings/[id]/cancel.ts rename to apps/api/v1/pages/api/bookings/[id]/cancel.ts diff --git a/apps/api/pages/api/bookings/[id]/index.ts b/apps/api/v1/pages/api/bookings/[id]/index.ts similarity index 100% rename from apps/api/pages/api/bookings/[id]/index.ts rename to apps/api/v1/pages/api/bookings/[id]/index.ts diff --git a/apps/api/pages/api/bookings/_get.ts b/apps/api/v1/pages/api/bookings/_get.ts similarity index 100% rename from apps/api/pages/api/bookings/_get.ts rename to apps/api/v1/pages/api/bookings/_get.ts diff --git a/apps/api/pages/api/bookings/_post.ts b/apps/api/v1/pages/api/bookings/_post.ts similarity index 100% rename from apps/api/pages/api/bookings/_post.ts rename to apps/api/v1/pages/api/bookings/_post.ts diff --git a/apps/api/pages/api/bookings/index.ts b/apps/api/v1/pages/api/bookings/index.ts similarity index 100% rename from apps/api/pages/api/bookings/index.ts rename to apps/api/v1/pages/api/bookings/index.ts diff --git a/apps/api/pages/api/credential-sync/_delete.ts b/apps/api/v1/pages/api/credential-sync/_delete.ts similarity index 100% rename from apps/api/pages/api/credential-sync/_delete.ts rename to apps/api/v1/pages/api/credential-sync/_delete.ts diff --git a/apps/api/pages/api/credential-sync/_get.ts b/apps/api/v1/pages/api/credential-sync/_get.ts similarity index 100% rename from apps/api/pages/api/credential-sync/_get.ts rename to apps/api/v1/pages/api/credential-sync/_get.ts diff --git a/apps/api/pages/api/credential-sync/_patch.ts b/apps/api/v1/pages/api/credential-sync/_patch.ts similarity index 100% rename from apps/api/pages/api/credential-sync/_patch.ts rename to apps/api/v1/pages/api/credential-sync/_patch.ts diff --git a/apps/api/pages/api/credential-sync/_post.ts b/apps/api/v1/pages/api/credential-sync/_post.ts similarity index 100% rename from apps/api/pages/api/credential-sync/_post.ts rename to apps/api/v1/pages/api/credential-sync/_post.ts diff --git a/apps/api/pages/api/credential-sync/index.ts b/apps/api/v1/pages/api/credential-sync/index.ts similarity index 100% rename from apps/api/pages/api/credential-sync/index.ts rename to apps/api/v1/pages/api/credential-sync/index.ts diff --git a/apps/api/pages/api/custom-inputs/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/custom-inputs/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/custom-inputs/[id]/_delete.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/[id]/_delete.ts rename to apps/api/v1/pages/api/custom-inputs/[id]/_delete.ts diff --git a/apps/api/pages/api/custom-inputs/[id]/_get.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/[id]/_get.ts rename to apps/api/v1/pages/api/custom-inputs/[id]/_get.ts diff --git a/apps/api/pages/api/custom-inputs/[id]/_patch.ts b/apps/api/v1/pages/api/custom-inputs/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/[id]/_patch.ts rename to apps/api/v1/pages/api/custom-inputs/[id]/_patch.ts diff --git a/apps/api/pages/api/custom-inputs/[id]/index.ts b/apps/api/v1/pages/api/custom-inputs/[id]/index.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/[id]/index.ts rename to apps/api/v1/pages/api/custom-inputs/[id]/index.ts diff --git a/apps/api/pages/api/custom-inputs/_get.ts b/apps/api/v1/pages/api/custom-inputs/_get.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/_get.ts rename to apps/api/v1/pages/api/custom-inputs/_get.ts diff --git a/apps/api/pages/api/custom-inputs/_post.ts b/apps/api/v1/pages/api/custom-inputs/_post.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/_post.ts rename to apps/api/v1/pages/api/custom-inputs/_post.ts diff --git a/apps/api/pages/api/custom-inputs/index.ts b/apps/api/v1/pages/api/custom-inputs/index.ts similarity index 100% rename from apps/api/pages/api/custom-inputs/index.ts rename to apps/api/v1/pages/api/custom-inputs/index.ts diff --git a/apps/api/pages/api/destination-calendars/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/destination-calendars/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/destination-calendars/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/destination-calendars/[id]/_delete.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/destination-calendars/[id]/_delete.ts rename to apps/api/v1/pages/api/destination-calendars/[id]/_delete.ts diff --git a/apps/api/pages/api/destination-calendars/[id]/_get.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/destination-calendars/[id]/_get.ts rename to apps/api/v1/pages/api/destination-calendars/[id]/_get.ts diff --git a/apps/api/pages/api/destination-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/destination-calendars/[id]/_patch.ts rename to apps/api/v1/pages/api/destination-calendars/[id]/_patch.ts diff --git a/apps/api/pages/api/destination-calendars/[id]/index.ts b/apps/api/v1/pages/api/destination-calendars/[id]/index.ts similarity index 100% rename from apps/api/pages/api/destination-calendars/[id]/index.ts rename to apps/api/v1/pages/api/destination-calendars/[id]/index.ts diff --git a/apps/api/pages/api/destination-calendars/_get.ts b/apps/api/v1/pages/api/destination-calendars/_get.ts similarity index 100% rename from apps/api/pages/api/destination-calendars/_get.ts rename to apps/api/v1/pages/api/destination-calendars/_get.ts diff --git a/apps/api/pages/api/destination-calendars/_post.ts b/apps/api/v1/pages/api/destination-calendars/_post.ts similarity index 100% rename from apps/api/pages/api/destination-calendars/_post.ts rename to apps/api/v1/pages/api/destination-calendars/_post.ts diff --git a/apps/api/pages/api/destination-calendars/index.ts b/apps/api/v1/pages/api/destination-calendars/index.ts similarity index 100% rename from apps/api/pages/api/destination-calendars/index.ts rename to apps/api/v1/pages/api/destination-calendars/index.ts diff --git a/apps/api/pages/api/docs.ts b/apps/api/v1/pages/api/docs.ts similarity index 100% rename from apps/api/pages/api/docs.ts rename to apps/api/v1/pages/api/docs.ts diff --git a/apps/api/pages/api/event-types/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/event-types/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/event-types/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/event-types/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/event-types/[id]/_delete.ts b/apps/api/v1/pages/api/event-types/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/event-types/[id]/_delete.ts rename to apps/api/v1/pages/api/event-types/[id]/_delete.ts diff --git a/apps/api/pages/api/event-types/[id]/_get.ts b/apps/api/v1/pages/api/event-types/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/event-types/[id]/_get.ts rename to apps/api/v1/pages/api/event-types/[id]/_get.ts diff --git a/apps/api/pages/api/event-types/[id]/_patch.ts b/apps/api/v1/pages/api/event-types/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/event-types/[id]/_patch.ts rename to apps/api/v1/pages/api/event-types/[id]/_patch.ts diff --git a/apps/api/pages/api/event-types/[id]/index.ts b/apps/api/v1/pages/api/event-types/[id]/index.ts similarity index 100% rename from apps/api/pages/api/event-types/[id]/index.ts rename to apps/api/v1/pages/api/event-types/[id]/index.ts diff --git a/apps/api/pages/api/event-types/_get.ts b/apps/api/v1/pages/api/event-types/_get.ts similarity index 100% rename from apps/api/pages/api/event-types/_get.ts rename to apps/api/v1/pages/api/event-types/_get.ts diff --git a/apps/api/pages/api/event-types/_post.ts b/apps/api/v1/pages/api/event-types/_post.ts similarity index 100% rename from apps/api/pages/api/event-types/_post.ts rename to apps/api/v1/pages/api/event-types/_post.ts diff --git a/apps/api/pages/api/event-types/_utils/checkParentEventOwnership.ts b/apps/api/v1/pages/api/event-types/_utils/checkParentEventOwnership.ts similarity index 100% rename from apps/api/pages/api/event-types/_utils/checkParentEventOwnership.ts rename to apps/api/v1/pages/api/event-types/_utils/checkParentEventOwnership.ts diff --git a/apps/api/pages/api/event-types/_utils/checkTeamEventEditPermission.ts b/apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts similarity index 100% rename from apps/api/pages/api/event-types/_utils/checkTeamEventEditPermission.ts rename to apps/api/v1/pages/api/event-types/_utils/checkTeamEventEditPermission.ts diff --git a/apps/api/pages/api/event-types/_utils/checkUserMembership.ts b/apps/api/v1/pages/api/event-types/_utils/checkUserMembership.ts similarity index 100% rename from apps/api/pages/api/event-types/_utils/checkUserMembership.ts rename to apps/api/v1/pages/api/event-types/_utils/checkUserMembership.ts diff --git a/apps/api/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts b/apps/api/v1/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts similarity index 100% rename from apps/api/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts rename to apps/api/v1/pages/api/event-types/_utils/ensureOnlyMembersAsHosts.ts diff --git a/apps/api/pages/api/event-types/_utils/getCalLink.ts b/apps/api/v1/pages/api/event-types/_utils/getCalLink.ts similarity index 100% rename from apps/api/pages/api/event-types/_utils/getCalLink.ts rename to apps/api/v1/pages/api/event-types/_utils/getCalLink.ts diff --git a/apps/api/pages/api/event-types/index.ts b/apps/api/v1/pages/api/event-types/index.ts similarity index 100% rename from apps/api/pages/api/event-types/index.ts rename to apps/api/v1/pages/api/event-types/index.ts diff --git a/apps/api/pages/api/index.ts b/apps/api/v1/pages/api/index.ts similarity index 100% rename from apps/api/pages/api/index.ts rename to apps/api/v1/pages/api/index.ts diff --git a/apps/api/pages/api/invites/_post.ts b/apps/api/v1/pages/api/invites/_post.ts similarity index 100% rename from apps/api/pages/api/invites/_post.ts rename to apps/api/v1/pages/api/invites/_post.ts diff --git a/apps/api/pages/api/invites/index.ts b/apps/api/v1/pages/api/invites/index.ts similarity index 100% rename from apps/api/pages/api/invites/index.ts rename to apps/api/v1/pages/api/invites/index.ts diff --git a/apps/api/pages/api/me/_get.ts b/apps/api/v1/pages/api/me/_get.ts similarity index 100% rename from apps/api/pages/api/me/_get.ts rename to apps/api/v1/pages/api/me/_get.ts diff --git a/apps/api/pages/api/me/index.ts b/apps/api/v1/pages/api/me/index.ts similarity index 100% rename from apps/api/pages/api/me/index.ts rename to apps/api/v1/pages/api/me/index.ts diff --git a/apps/api/pages/api/memberships/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/memberships/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/memberships/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/memberships/[id]/_delete.ts b/apps/api/v1/pages/api/memberships/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/memberships/[id]/_delete.ts rename to apps/api/v1/pages/api/memberships/[id]/_delete.ts diff --git a/apps/api/pages/api/memberships/[id]/_get.ts b/apps/api/v1/pages/api/memberships/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/memberships/[id]/_get.ts rename to apps/api/v1/pages/api/memberships/[id]/_get.ts diff --git a/apps/api/pages/api/memberships/[id]/_patch.ts b/apps/api/v1/pages/api/memberships/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/memberships/[id]/_patch.ts rename to apps/api/v1/pages/api/memberships/[id]/_patch.ts diff --git a/apps/api/pages/api/memberships/[id]/index.ts b/apps/api/v1/pages/api/memberships/[id]/index.ts similarity index 100% rename from apps/api/pages/api/memberships/[id]/index.ts rename to apps/api/v1/pages/api/memberships/[id]/index.ts diff --git a/apps/api/pages/api/memberships/_get.ts b/apps/api/v1/pages/api/memberships/_get.ts similarity index 100% rename from apps/api/pages/api/memberships/_get.ts rename to apps/api/v1/pages/api/memberships/_get.ts diff --git a/apps/api/pages/api/memberships/_post.ts b/apps/api/v1/pages/api/memberships/_post.ts similarity index 100% rename from apps/api/pages/api/memberships/_post.ts rename to apps/api/v1/pages/api/memberships/_post.ts diff --git a/apps/api/pages/api/memberships/index.ts b/apps/api/v1/pages/api/memberships/index.ts similarity index 100% rename from apps/api/pages/api/memberships/index.ts rename to apps/api/v1/pages/api/memberships/index.ts diff --git a/apps/api/pages/api/payments/[id].ts b/apps/api/v1/pages/api/payments/[id].ts similarity index 100% rename from apps/api/pages/api/payments/[id].ts rename to apps/api/v1/pages/api/payments/[id].ts diff --git a/apps/api/pages/api/payments/index.ts b/apps/api/v1/pages/api/payments/index.ts similarity index 100% rename from apps/api/pages/api/payments/index.ts rename to apps/api/v1/pages/api/payments/index.ts diff --git a/apps/api/pages/api/schedules/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/schedules/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/schedules/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/schedules/[id]/_delete.ts b/apps/api/v1/pages/api/schedules/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/schedules/[id]/_delete.ts rename to apps/api/v1/pages/api/schedules/[id]/_delete.ts diff --git a/apps/api/pages/api/schedules/[id]/_get.ts b/apps/api/v1/pages/api/schedules/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/schedules/[id]/_get.ts rename to apps/api/v1/pages/api/schedules/[id]/_get.ts diff --git a/apps/api/pages/api/schedules/[id]/_patch.ts b/apps/api/v1/pages/api/schedules/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/schedules/[id]/_patch.ts rename to apps/api/v1/pages/api/schedules/[id]/_patch.ts diff --git a/apps/api/pages/api/schedules/[id]/index.ts b/apps/api/v1/pages/api/schedules/[id]/index.ts similarity index 100% rename from apps/api/pages/api/schedules/[id]/index.ts rename to apps/api/v1/pages/api/schedules/[id]/index.ts diff --git a/apps/api/pages/api/schedules/_get.ts b/apps/api/v1/pages/api/schedules/_get.ts similarity index 100% rename from apps/api/pages/api/schedules/_get.ts rename to apps/api/v1/pages/api/schedules/_get.ts diff --git a/apps/api/pages/api/schedules/_post.ts b/apps/api/v1/pages/api/schedules/_post.ts similarity index 100% rename from apps/api/pages/api/schedules/_post.ts rename to apps/api/v1/pages/api/schedules/_post.ts diff --git a/apps/api/pages/api/schedules/index.ts b/apps/api/v1/pages/api/schedules/index.ts similarity index 100% rename from apps/api/pages/api/schedules/index.ts rename to apps/api/v1/pages/api/schedules/index.ts diff --git a/apps/api/pages/api/selected-calendars/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/selected-calendars/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/selected-calendars/[id]/_delete.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/[id]/_delete.ts rename to apps/api/v1/pages/api/selected-calendars/[id]/_delete.ts diff --git a/apps/api/pages/api/selected-calendars/[id]/_get.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/[id]/_get.ts rename to apps/api/v1/pages/api/selected-calendars/[id]/_get.ts diff --git a/apps/api/pages/api/selected-calendars/[id]/_patch.ts b/apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/[id]/_patch.ts rename to apps/api/v1/pages/api/selected-calendars/[id]/_patch.ts diff --git a/apps/api/pages/api/selected-calendars/[id]/index.ts b/apps/api/v1/pages/api/selected-calendars/[id]/index.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/[id]/index.ts rename to apps/api/v1/pages/api/selected-calendars/[id]/index.ts diff --git a/apps/api/pages/api/selected-calendars/_get.ts b/apps/api/v1/pages/api/selected-calendars/_get.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/_get.ts rename to apps/api/v1/pages/api/selected-calendars/_get.ts diff --git a/apps/api/pages/api/selected-calendars/_post.ts b/apps/api/v1/pages/api/selected-calendars/_post.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/_post.ts rename to apps/api/v1/pages/api/selected-calendars/_post.ts diff --git a/apps/api/pages/api/selected-calendars/index.ts b/apps/api/v1/pages/api/selected-calendars/index.ts similarity index 100% rename from apps/api/pages/api/selected-calendars/index.ts rename to apps/api/v1/pages/api/selected-calendars/index.ts diff --git a/apps/api/pages/api/slots/_get.test.ts b/apps/api/v1/pages/api/slots/_get.test.ts similarity index 97% rename from apps/api/pages/api/slots/_get.test.ts rename to apps/api/v1/pages/api/slots/_get.test.ts index 58e77fe290fe6e..7484d298e08f1b 100644 --- a/apps/api/pages/api/slots/_get.test.ts +++ b/apps/api/v1/pages/api/slots/_get.test.ts @@ -1,4 +1,4 @@ -import prismock from "../../../../../tests/libs/__mocks__/prisma"; +import prismock from "../../../../../../tests/libs/__mocks__/prisma"; import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; diff --git a/apps/api/pages/api/slots/_get.ts b/apps/api/v1/pages/api/slots/_get.ts similarity index 100% rename from apps/api/pages/api/slots/_get.ts rename to apps/api/v1/pages/api/slots/_get.ts diff --git a/apps/api/pages/api/slots/index.ts b/apps/api/v1/pages/api/slots/index.ts similarity index 100% rename from apps/api/pages/api/slots/index.ts rename to apps/api/v1/pages/api/slots/index.ts diff --git a/apps/api/pages/api/teams/[teamId]/_auth-middleware.ts b/apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/_auth-middleware.ts rename to apps/api/v1/pages/api/teams/[teamId]/_auth-middleware.ts diff --git a/apps/api/pages/api/teams/[teamId]/_delete.ts b/apps/api/v1/pages/api/teams/[teamId]/_delete.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/_delete.ts rename to apps/api/v1/pages/api/teams/[teamId]/_delete.ts diff --git a/apps/api/pages/api/teams/[teamId]/_get.ts b/apps/api/v1/pages/api/teams/[teamId]/_get.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/_get.ts rename to apps/api/v1/pages/api/teams/[teamId]/_get.ts diff --git a/apps/api/pages/api/teams/[teamId]/_patch.ts b/apps/api/v1/pages/api/teams/[teamId]/_patch.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/_patch.ts rename to apps/api/v1/pages/api/teams/[teamId]/_patch.ts diff --git a/apps/api/pages/api/teams/[teamId]/availability/index.ts b/apps/api/v1/pages/api/teams/[teamId]/availability/index.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/availability/index.ts rename to apps/api/v1/pages/api/teams/[teamId]/availability/index.ts diff --git a/apps/api/pages/api/teams/[teamId]/event-types/_get.ts b/apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/event-types/_get.ts rename to apps/api/v1/pages/api/teams/[teamId]/event-types/_get.ts diff --git a/apps/api/pages/api/teams/[teamId]/event-types/index.ts b/apps/api/v1/pages/api/teams/[teamId]/event-types/index.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/event-types/index.ts rename to apps/api/v1/pages/api/teams/[teamId]/event-types/index.ts diff --git a/apps/api/pages/api/teams/[teamId]/index.ts b/apps/api/v1/pages/api/teams/[teamId]/index.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/index.ts rename to apps/api/v1/pages/api/teams/[teamId]/index.ts diff --git a/apps/api/pages/api/teams/[teamId]/publish.ts b/apps/api/v1/pages/api/teams/[teamId]/publish.ts similarity index 100% rename from apps/api/pages/api/teams/[teamId]/publish.ts rename to apps/api/v1/pages/api/teams/[teamId]/publish.ts diff --git a/apps/api/pages/api/teams/_get.ts b/apps/api/v1/pages/api/teams/_get.ts similarity index 100% rename from apps/api/pages/api/teams/_get.ts rename to apps/api/v1/pages/api/teams/_get.ts diff --git a/apps/api/pages/api/teams/_post.ts b/apps/api/v1/pages/api/teams/_post.ts similarity index 100% rename from apps/api/pages/api/teams/_post.ts rename to apps/api/v1/pages/api/teams/_post.ts diff --git a/apps/api/pages/api/teams/index.ts b/apps/api/v1/pages/api/teams/index.ts similarity index 100% rename from apps/api/pages/api/teams/index.ts rename to apps/api/v1/pages/api/teams/index.ts diff --git a/apps/api/pages/api/users/[userId]/_delete.ts b/apps/api/v1/pages/api/users/[userId]/_delete.ts similarity index 100% rename from apps/api/pages/api/users/[userId]/_delete.ts rename to apps/api/v1/pages/api/users/[userId]/_delete.ts diff --git a/apps/api/pages/api/users/[userId]/_get.ts b/apps/api/v1/pages/api/users/[userId]/_get.ts similarity index 100% rename from apps/api/pages/api/users/[userId]/_get.ts rename to apps/api/v1/pages/api/users/[userId]/_get.ts diff --git a/apps/api/pages/api/users/[userId]/_patch.ts b/apps/api/v1/pages/api/users/[userId]/_patch.ts similarity index 100% rename from apps/api/pages/api/users/[userId]/_patch.ts rename to apps/api/v1/pages/api/users/[userId]/_patch.ts diff --git a/apps/api/pages/api/users/[userId]/availability/index.ts b/apps/api/v1/pages/api/users/[userId]/availability/index.ts similarity index 100% rename from apps/api/pages/api/users/[userId]/availability/index.ts rename to apps/api/v1/pages/api/users/[userId]/availability/index.ts diff --git a/apps/api/pages/api/users/[userId]/index.ts b/apps/api/v1/pages/api/users/[userId]/index.ts similarity index 100% rename from apps/api/pages/api/users/[userId]/index.ts rename to apps/api/v1/pages/api/users/[userId]/index.ts diff --git a/apps/api/pages/api/users/_get.ts b/apps/api/v1/pages/api/users/_get.ts similarity index 100% rename from apps/api/pages/api/users/_get.ts rename to apps/api/v1/pages/api/users/_get.ts diff --git a/apps/api/pages/api/users/_post.ts b/apps/api/v1/pages/api/users/_post.ts similarity index 100% rename from apps/api/pages/api/users/_post.ts rename to apps/api/v1/pages/api/users/_post.ts diff --git a/apps/api/pages/api/users/index.ts b/apps/api/v1/pages/api/users/index.ts similarity index 100% rename from apps/api/pages/api/users/index.ts rename to apps/api/v1/pages/api/users/index.ts diff --git a/apps/api/pages/api/webhooks/[id]/_auth-middleware.ts b/apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts similarity index 100% rename from apps/api/pages/api/webhooks/[id]/_auth-middleware.ts rename to apps/api/v1/pages/api/webhooks/[id]/_auth-middleware.ts diff --git a/apps/api/pages/api/webhooks/[id]/_delete.ts b/apps/api/v1/pages/api/webhooks/[id]/_delete.ts similarity index 100% rename from apps/api/pages/api/webhooks/[id]/_delete.ts rename to apps/api/v1/pages/api/webhooks/[id]/_delete.ts diff --git a/apps/api/pages/api/webhooks/[id]/_get.ts b/apps/api/v1/pages/api/webhooks/[id]/_get.ts similarity index 100% rename from apps/api/pages/api/webhooks/[id]/_get.ts rename to apps/api/v1/pages/api/webhooks/[id]/_get.ts diff --git a/apps/api/pages/api/webhooks/[id]/_patch.ts b/apps/api/v1/pages/api/webhooks/[id]/_patch.ts similarity index 100% rename from apps/api/pages/api/webhooks/[id]/_patch.ts rename to apps/api/v1/pages/api/webhooks/[id]/_patch.ts diff --git a/apps/api/pages/api/webhooks/[id]/index.ts b/apps/api/v1/pages/api/webhooks/[id]/index.ts similarity index 100% rename from apps/api/pages/api/webhooks/[id]/index.ts rename to apps/api/v1/pages/api/webhooks/[id]/index.ts diff --git a/apps/api/pages/api/webhooks/_get.ts b/apps/api/v1/pages/api/webhooks/_get.ts similarity index 100% rename from apps/api/pages/api/webhooks/_get.ts rename to apps/api/v1/pages/api/webhooks/_get.ts diff --git a/apps/api/pages/api/webhooks/_post.ts b/apps/api/v1/pages/api/webhooks/_post.ts similarity index 100% rename from apps/api/pages/api/webhooks/_post.ts rename to apps/api/v1/pages/api/webhooks/_post.ts diff --git a/apps/api/pages/api/webhooks/index.ts b/apps/api/v1/pages/api/webhooks/index.ts similarity index 100% rename from apps/api/pages/api/webhooks/index.ts rename to apps/api/v1/pages/api/webhooks/index.ts diff --git a/apps/api/scripts/vercel-deploy.sh b/apps/api/v1/scripts/vercel-deploy.sh similarity index 100% rename from apps/api/scripts/vercel-deploy.sh rename to apps/api/v1/scripts/vercel-deploy.sh diff --git a/apps/api/sentry.client.config.ts b/apps/api/v1/sentry.client.config.ts similarity index 100% rename from apps/api/sentry.client.config.ts rename to apps/api/v1/sentry.client.config.ts diff --git a/apps/api/sentry.edge.config.ts b/apps/api/v1/sentry.edge.config.ts similarity index 100% rename from apps/api/sentry.edge.config.ts rename to apps/api/v1/sentry.edge.config.ts diff --git a/apps/api/sentry.server.config.ts b/apps/api/v1/sentry.server.config.ts similarity index 100% rename from apps/api/sentry.server.config.ts rename to apps/api/v1/sentry.server.config.ts diff --git a/apps/api/test/README.md b/apps/api/v1/test/README.md similarity index 100% rename from apps/api/test/README.md rename to apps/api/v1/test/README.md diff --git a/apps/api/test/docker-compose.yml b/apps/api/v1/test/docker-compose.yml similarity index 100% rename from apps/api/test/docker-compose.yml rename to apps/api/v1/test/docker-compose.yml diff --git a/apps/api/test/jest-resolver.js b/apps/api/v1/test/jest-resolver.js similarity index 100% rename from apps/api/test/jest-resolver.js rename to apps/api/v1/test/jest-resolver.js diff --git a/apps/api/test/jest-setup.js b/apps/api/v1/test/jest-setup.js similarity index 100% rename from apps/api/test/jest-setup.js rename to apps/api/v1/test/jest-setup.js diff --git a/apps/api/test/lib/bookings/_post.test.ts b/apps/api/v1/test/lib/bookings/_post.test.ts similarity index 99% rename from apps/api/test/lib/bookings/_post.test.ts rename to apps/api/v1/test/lib/bookings/_post.test.ts index 64abddcfe3462b..e34defc601fe6a 100644 --- a/apps/api/test/lib/bookings/_post.test.ts +++ b/apps/api/v1/test/lib/bookings/_post.test.ts @@ -1,5 +1,5 @@ // TODO: Fix tests (These test were never running due to the vitest workspace config) -import prismaMock from "../../../../../tests/libs/__mocks__/prisma"; +import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock"; import type { Request, Response } from "express"; import type { NextApiRequest, NextApiResponse } from "next"; diff --git a/apps/api/test/lib/middleware/addRequestId.test.ts b/apps/api/v1/test/lib/middleware/addRequestId.test.ts similarity index 100% rename from apps/api/test/lib/middleware/addRequestId.test.ts rename to apps/api/v1/test/lib/middleware/addRequestId.test.ts diff --git a/apps/api/test/lib/middleware/httpMethods.test.ts b/apps/api/v1/test/lib/middleware/httpMethods.test.ts similarity index 100% rename from apps/api/test/lib/middleware/httpMethods.test.ts rename to apps/api/v1/test/lib/middleware/httpMethods.test.ts diff --git a/apps/api/test/lib/middleware/verifyApiKey.test.ts b/apps/api/v1/test/lib/middleware/verifyApiKey.test.ts similarity index 100% rename from apps/api/test/lib/middleware/verifyApiKey.test.ts rename to apps/api/v1/test/lib/middleware/verifyApiKey.test.ts diff --git a/apps/api/tsconfig.json b/apps/api/v1/tsconfig.json similarity index 81% rename from apps/api/tsconfig.json rename to apps/api/v1/tsconfig.json index c6b3666313f6f3..7283f0b3500ee0 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/v1/tsconfig.json @@ -13,8 +13,8 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", - "../../packages/types/*.d.ts", - "../../packages/types/next-auth.d.ts" + "../../../packages/types/*.d.ts", + "../../../packages/types/next-auth.d.ts" ], "exclude": ["node_modules", "templates", "auth"] } diff --git a/apps/api/vercel.json b/apps/api/v1/vercel.json similarity index 100% rename from apps/api/vercel.json rename to apps/api/v1/vercel.json diff --git a/apps/api/v2/.dockerignore b/apps/api/v2/.dockerignore new file mode 100644 index 00000000000000..569ce539708a55 --- /dev/null +++ b/apps/api/v2/.dockerignore @@ -0,0 +1,2 @@ +**/node_modules +**/dist \ No newline at end of file diff --git a/apps/api/v2/.env.example b/apps/api/v2/.env.example new file mode 100644 index 00000000000000..df907991b2a2e3 --- /dev/null +++ b/apps/api/v2/.env.example @@ -0,0 +1,13 @@ +NODE_ENV= +API_PORT= +API_URL= +DATABASE_READ_URL= +DATABASE_WRITE_URL= +LOG_LEVEL= +NEXTAUTH_SECRET= +DATABASE_URL= +JWT_SECRET= +SENTRY_DNS= + +# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2 +NEXT_PUBLIC_SENTRY_DSN= \ No newline at end of file diff --git a/apps/api/v2/.eslintrc.js b/apps/api/v2/.eslintrc.js new file mode 100644 index 00000000000000..1c4289dc6b6b03 --- /dev/null +++ b/apps/api/v2/.eslintrc.js @@ -0,0 +1,31 @@ +module.exports = { + parser: "@typescript-eslint/parser", + parserOptions: { + project: "tsconfig.json", + tsconfigRootDir: __dirname, + sourceType: "module", + }, + plugins: ["@typescript-eslint/eslint-plugin"], + extends: ["plugin:@typescript-eslint/recommended"], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: [".eslintrc.js"], + rules: { + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "off", + }, + overrides: [ + { + files: ["./src/**/*.controller.ts"], + excludedFiles: "*.spec.js", + rules: { + "@typescript-eslint/explicit-function-return-type": "error", + }, + }, + ], +}; diff --git a/apps/api/v2/.gitignore b/apps/api/v2/.gitignore new file mode 100644 index 00000000000000..0cf21bfcd21152 --- /dev/null +++ b/apps/api/v2/.gitignore @@ -0,0 +1,44 @@ +# compiled output +/dist +/node_modules + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.* +!.env.example +!.env.appStore.example \ No newline at end of file diff --git a/apps/api/v2/.prettierrc.js b/apps/api/v2/.prettierrc.js new file mode 100644 index 00000000000000..ba4100a4efe045 --- /dev/null +++ b/apps/api/v2/.prettierrc.js @@ -0,0 +1,6 @@ +const rootConfig = require("../../../packages/config/prettier-preset"); + +module.exports = { + ...rootConfig, + importOrderParserPlugins: ["typescript", "decorators-legacy"], +}; diff --git a/apps/api/v2/Dockerfile b/apps/api/v2/Dockerfile new file mode 100644 index 00000000000000..79626c1ac9a536 --- /dev/null +++ b/apps/api/v2/Dockerfile @@ -0,0 +1,28 @@ +FROM node:18-alpine as build + +ARG DATABASE_DIRECT_URL +ARG DATABASE_URL + +WORKDIR /calcom + +RUN set -eux; + +ENV NODE_ENV="production" +ENV NODE_OPTIONS="--max-old-space-size=8192" +ENV DATABASE_DIRECT_URL=${DATABASE_DIRECT_URL} +ENV DATABASE_URL=${DATABASE_URL} + +COPY . . + +RUN yarn install + +# Build prisma schema and make sure that it is linked to v2 node_modules +RUN yarn workspace @calcom/api-v2 run generate-schemas +RUN rm -rf apps/api/v2/node_modules +RUN yarn install + +RUN yarn workspace @calcom/api-v2 run build + +EXPOSE 80 + +CMD [ "yarn", "workspace", "@calcom/api-v2", "start:prod"] diff --git a/apps/api/v2/README.md b/apps/api/v2/README.md new file mode 100644 index 00000000000000..64af00f7fb28dd --- /dev/null +++ b/apps/api/v2/README.md @@ -0,0 +1,83 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Coverage +Discord +Backers on Open Collective +Sponsors on Open Collective + + Support us + +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Installation + +```bash +$ yarn install +``` + +## Prisma setup + +```bash +$ yarn prisma generate +``` + +## Env setup + +Copy `.env.example` to `.env` and fill values. + +## Running the app + +```bash +# development +$ yarn run start + +# watch mode +$ yarn run start:dev + +# production mode +$ yarn run start:prod +``` + +## Test + +```bash +# unit tests +$ yarn run test + +# e2e tests +$ yarn run test:e2e + +# test coverage +$ yarn run test:cov +``` + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myƛliwiec](https://kamilmysliwiec.com) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](LICENSE). diff --git a/apps/api/v2/docker-compose.yaml b/apps/api/v2/docker-compose.yaml new file mode 100644 index 00000000000000..cd30f07b5665b8 --- /dev/null +++ b/apps/api/v2/docker-compose.yaml @@ -0,0 +1,14 @@ +version: '3.8' + +services: + redis: + image: redis:latest + container_name: redis_container + ports: + - "6379:6379" + command: redis-server --appendonly yes + volumes: + - redis_data:/data + +volumes: + redis_data: diff --git a/apps/api/v2/jest-e2e.json b/apps/api/v2/jest-e2e.json new file mode 100644 index 00000000000000..3f5b2573324b3b --- /dev/null +++ b/apps/api/v2/jest-e2e.json @@ -0,0 +1,14 @@ +{ + "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"] +} diff --git a/apps/api/v2/jest.config.json b/apps/api/v2/jest.config.json new file mode 100644 index 00000000000000..a7b6a8c8885869 --- /dev/null +++ b/apps/api/v2/jest.config.json @@ -0,0 +1,14 @@ +{ + "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/nest-cli.json b/apps/api/v2/nest-cli.json new file mode 100644 index 00000000000000..69cce1547ba0f5 --- /dev/null +++ b/apps/api/v2/nest-cli.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "plugins": [ + { + "name": "@nestjs/swagger", + "options": { "dtoFileNameSuffix": ".input.ts", "classValidatorShim": true } + } + ] + } +} diff --git a/apps/api/v2/next-i18next.config.js b/apps/api/v2/next-i18next.config.js new file mode 100644 index 00000000000000..a07cf209817826 --- /dev/null +++ b/apps/api/v2/next-i18next.config.js @@ -0,0 +1,11 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const path = require("path"); +const i18nConfig = require("@calcom/config/next-i18next.config"); + +/** @type {import("next-i18next").UserConfig} */ +const config = { + ...i18nConfig, + localePath: path.resolve("../../web/public/static/locales"), +}; + +module.exports = config; diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json new file mode 100644 index 00000000000000..d88131c3e10fa1 --- /dev/null +++ b/apps/api/v2/package.json @@ -0,0 +1,88 @@ +{ + "name": "@calcom/api-v2", + "version": "0.0.1", + "description": "Platform API for Cal.com", + "author": "Cal.com Inc.", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "yarn dev:build && nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch & yarn workspace @calcom/platform-libraries build:watch", + "dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build", + "dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/src/main", + "test": "yarn dev:build && jest", + "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 && jest --runInBand --config ./jest-e2e.json", + "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" + }, + "dependencies": { + "@calcom/platform-constants": "*", + "@calcom/platform-libraries": "*", + "@calcom/platform-types": "*", + "@calcom/platform-utils": "*", + "@calcom/prisma": "*", + "@golevelup/ts-jest": "^0.4.0", + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.1.1", + "@nestjs/core": "^10.0.0", + "@nestjs/jwt": "^10.2.0", + "@nestjs/passport": "^10.0.2", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.3.0", + "@nestjs/throttler": "^5.1.1", + "@sentry/node": "^7.86.0", + "@sentry/tracing": "^7.86.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "cookie-parser": "^1.4.6", + "dotenv": "^16.3.1", + "fs-extra": "^11.2.0", + "googleapis": "^84.0.0", + "helmet": "^7.1.0", + "ioredis": "^5.3.2", + "luxon": "^3.4.4", + "nest-winston": "^1.9.4", + "nestjs-throttler-storage-redis": "^0.4.1", + "next-auth": "^4.22.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1", + "uuid": "^8.3.2", + "winston": "^3.11.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/cookie-parser": "^1.4.6", + "@types/express": "^4.17.21", + "@types/fs-extra": "^11.0.4", + "@types/jest": "^29.5.10", + "@types/luxon": "^3.3.7", + "@types/node": "^20.3.1", + "@types/passport-jwt": "^3.0.13", + "@types/supertest": "^2.0.12", + "jest": "^29.7.0", + "prettier": "^2.8.6", + "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.1", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.1.0", + "typescript": "^4.9.4" + }, + "prisma": { + "schema": "../../../packages/prisma/schema.prisma" + } +} diff --git a/apps/api/v2/src/app.controller.ts b/apps/api/v2/src/app.controller.ts new file mode 100644 index 00000000000000..6e6e7535ab86e8 --- /dev/null +++ b/apps/api/v2/src/app.controller.ts @@ -0,0 +1,10 @@ +import { Controller, Get, Version, VERSION_NEUTRAL } from "@nestjs/common"; + +@Controller() +export class AppController { + @Get("health") + @Version(VERSION_NEUTRAL) + getHealth(): "OK" { + return "OK"; + } +} diff --git a/apps/api/v2/src/app.e2e-spec.ts b/apps/api/v2/src/app.e2e-spec.ts new file mode 100644 index 00000000000000..41bc985250b380 --- /dev/null +++ b/apps/api/v2/src/app.e2e-spec.ts @@ -0,0 +1,26 @@ +import { AppModule } from "@/app.module"; +import { INestApplication } from "@nestjs/common"; +import { TestingModule } from "@nestjs/testing"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; + +describe("AppController (e2e)", () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it("/ (GET)", () => { + return request(app.getHttpServer()).get("/health").expect("OK"); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/apps/api/v2/src/app.logger.middleware.ts b/apps/api/v2/src/app.logger.middleware.ts new file mode 100644 index 00000000000000..80ce4cef3e3f26 --- /dev/null +++ b/apps/api/v2/src/app.logger.middleware.ts @@ -0,0 +1,21 @@ +import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; +import { Request, NextFunction } from "express"; + +import { Response } from "@calcom/platform-types"; + +@Injectable() +export class AppLoggerMiddleware implements NestMiddleware { + private logger = new Logger("HTTP"); + + use(request: Request, response: Response, next: NextFunction): void { + const { ip, method, protocol, originalUrl, path: url } = request; + const userAgent = request.get("user-agent") || ""; + + response.on("close", () => { + const { statusCode } = response; + const contentLength = response.get("content-length"); + this.logger.log(`${method} ${originalUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}`); + }); + next(); + } +} diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts new file mode 100644 index 00000000000000..6d66e0a9fe46a9 --- /dev/null +++ b/apps/api/v2/src/app.module.ts @@ -0,0 +1,44 @@ +import { AppLoggerMiddleware } from "@/app.logger.middleware"; +import appConfig from "@/config/app"; +import { AuthModule } from "@/modules/auth/auth.module"; +import { EndpointsModule } from "@/modules/endpoints.module"; +import { JwtModule } from "@/modules/jwt/jwt.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; + +import { AppController } from "./app.controller"; + +@Module({ + imports: [ + ConfigModule.forRoot({ + ignoreEnvFile: true, + isGlobal: true, + load: [appConfig], + }), + // ThrottlerModule.forRootAsync({ + // imports: [ConfigModule], + // inject: [ConfigService], + // useFactory: (config: ConfigService) => ({ + // throttlers: [ + // { + // name: "short", + // ttl: seconds(10), + // limit: 3, + // }, + // ], + // storage: new ThrottlerStorageRedisService(config.get("db.redisUrl", { infer: true })), + // }), + // }), + PrismaModule, + EndpointsModule, + AuthModule, + JwtModule, + ], + controllers: [AppController], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer): void { + consumer.apply(AppLoggerMiddleware).forRoutes("*"); + } +} diff --git a/apps/api/v2/src/app.ts b/apps/api/v2/src/app.ts new file mode 100644 index 00000000000000..84ec3593c00be1 --- /dev/null +++ b/apps/api/v2/src/app.ts @@ -0,0 +1,68 @@ +import { getEnv } from "@/env"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { SentryFilter } from "@/filters/sentry-exception.filter"; +import { ZodExceptionFilter } from "@/filters/zod-exception.filter"; +import type { ValidationError } from "@nestjs/common"; +import { BadRequestException, RequestMethod, ValidationPipe, VersioningType } from "@nestjs/common"; +import { HttpAdapterHost } from "@nestjs/core"; +import type { NestExpressApplication } from "@nestjs/platform-express"; +import * as Sentry from "@sentry/node"; +import * as cookieParser from "cookie-parser"; +import helmet from "helmet"; + +import { TRPCExceptionFilter } from "./filters/trpc-exception.filter"; + +export const bootstrap = (app: NestExpressApplication): NestExpressApplication => { + app.enableShutdownHooks(); + app.enableVersioning({ + type: VersioningType.URI, + prefix: "v", + defaultVersion: "1", + }); + + app.use(helmet()); + + app.enableCors({ + origin: "*", + methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"], + allowedHeaders: ["Accept", "Authorization", "Content-Type", "Origin"], + maxAge: 86_400, + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + validationError: { + target: true, + value: true, + }, + exceptionFactory(errors: ValidationError[]) { + return new BadRequestException({ errors }); + }, + }) + ); + + if (process.env.SENTRY_DNS) { + Sentry.init({ + dsn: getEnv("SENTRY_DNS"), + }); + } + + // Exception filters, new filters go at the bottom, keep the order + const { httpAdapter } = app.get(HttpAdapterHost); + app.useGlobalFilters(new SentryFilter(httpAdapter)); + app.useGlobalFilters(new PrismaExceptionFilter()); + app.useGlobalFilters(new ZodExceptionFilter()); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalFilters(new TRPCExceptionFilter()); + + app.setGlobalPrefix("api", { + exclude: [{ path: "health", method: RequestMethod.GET }], + }); + + app.use(cookieParser()); + + return app; +}; diff --git a/apps/api/v2/src/config/app.ts b/apps/api/v2/src/config/app.ts new file mode 100644 index 00000000000000..f863dd953fd3ed --- /dev/null +++ b/apps/api/v2/src/config/app.ts @@ -0,0 +1,28 @@ +import { getEnv } from "@/env"; + +import type { AppConfig } from "./type"; + +const loadConfig = (): AppConfig => { + return { + env: { + type: getEnv("NODE_ENV", "development"), + }, + api: { + port: Number(getEnv("API_PORT", "5555")), + path: getEnv("API_URL", "http://localhost"), + url: `${getEnv("API_URL", "http://localhost")}${ + process.env.API_PORT ? `:${Number(getEnv("API_PORT", "5555"))}` : "" + }/api/v2`, + }, + db: { + readUrl: getEnv("DATABASE_READ_URL"), + writeUrl: getEnv("DATABASE_WRITE_URL"), + redisUrl: getEnv("REDIS_URL"), + }, + next: { + authSecret: getEnv("NEXTAUTH_SECRET"), + }, + }; +}; + +export default loadConfig; diff --git a/apps/api/v2/src/config/type.ts b/apps/api/v2/src/config/type.ts new file mode 100644 index 00000000000000..e7690ac63fd675 --- /dev/null +++ b/apps/api/v2/src/config/type.ts @@ -0,0 +1,18 @@ +export type AppConfig = { + env: { + type: "production" | "development"; + }; + api: { + port: number; + path: string; + url: string; + }; + db: { + readUrl: string; + writeUrl: string; + redisUrl: string; + }; + next: { + authSecret: string; + }; +}; diff --git a/apps/api/v2/src/ee/LICENSE b/apps/api/v2/src/ee/LICENSE new file mode 100644 index 00000000000000..a8c6744758303a --- /dev/null +++ b/apps/api/v2/src/ee/LICENSE @@ -0,0 +1,42 @@ +The Cal.com Commercial License (the “Commercial License”) +Copyright (c) 2020-present Cal.com, Inc + +With regard to the Cal.com Software: + +This software and associated documentation files (the "Software") may only be +used in production, if you (and any entity that you represent) have agreed to, +and are in compliance with, the Cal.com Subscription Terms available +at https://cal.com/terms, or other agreements governing +the use of the Software, as mutually agreed by you and Cal.com, Inc ("Cal.com"), +and otherwise have a valid Cal.com Enterprise Edition subscription ("Commercial Subscription") +for the correct number of hosts as defined in the "Commercial Terms ("Hosts"). Subject to the foregoing sentence, +you are free to modify this Software and publish patches to the Software. You agree +that Cal.com and/or its licensors (as applicable) retain all right, title and interest in +and to all such modifications and/or patches, and all such modifications and/or +patches may only be used, copied, modified, displayed, distributed, or otherwise +exploited with a valid Commercial Subscription for the correct number of hosts. +Notwithstanding the foregoing, you may copy and modify the Software for development +and testing purposes, without requiring a subscription. You agree that Cal.com and/or +its licensors (as applicable) retain all right, title and interest in and to all such +modifications. You are not granted any other rights beyond what is expressly stated herein. +Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, +and/or sell the Software. + +This Commercial License applies only to the part of this Software that is not distributed under +the AGPLv3 license. Any part of this Software distributed under the MIT license or which +is served client-side as an image, font, cascading stylesheet (CSS), file which produces +or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or +in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall +be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +For all third party components incorporated into the Cal.com Software, those +components are licensed under the original license provided by the owner of the +applicable component. diff --git a/apps/api/v2/src/ee/bookings/bookings.module.ts b/apps/api/v2/src/ee/bookings/bookings.module.ts new file mode 100644 index 00000000000000..8b52876bfa1323 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/bookings.module.ts @@ -0,0 +1,13 @@ +import { BookingsController } from "@/ee/bookings/controllers/bookings.controller"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, TokensModule], + providers: [TokensRepository, OAuthFlowService], + controllers: [BookingsController], +}) +export class BookingsModule {} diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts new file mode 100644 index 00000000000000..1377970f95976e --- /dev/null +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -0,0 +1,128 @@ +import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; +import { CreateReccuringBookingInput } from "@/ee/bookings/inputs/create-reccuring-booking.input"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { + Controller, + Post, + Logger, + Req, + InternalServerErrorException, + Body, + HttpException, + UseGuards, +} from "@nestjs/common"; +import { Request } from "express"; +import { NextApiRequest } from "next/types"; + +import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + handleNewBooking, + BookingResponse, + HttpError, + handleNewRecurringBooking, + handleInstantMeeting, +} from "@calcom/platform-libraries"; +import { ApiResponse } from "@calcom/platform-types"; + +@Controller({ + path: "ee/bookings", + version: "2", +}) +@UseGuards(PermissionsGuard) +export class BookingsController { + private readonly logger = new Logger("ee bookings controller"); + + constructor(private readonly oAuthFlowService: OAuthFlowService) {} + + @Post("/") + @Permissions([BOOKING_WRITE]) + async createBooking( + @Req() req: Request & { userId?: number }, + @Body() _: CreateBookingInput + ): Promise> { + req.userId = await this.getOwnerId(req); + req.body = { ...req.body, noEmail: true }; + try { + const booking = await handleNewBooking(req as unknown as NextApiRequest & { userId?: number }); + return { + status: SUCCESS_STATUS, + data: booking, + }; + } catch (err) { + handleBookingErrors(err); + } + throw new InternalServerErrorException("Could not create booking."); + } + + @Post("/reccuring") + @Permissions([BOOKING_WRITE]) + async createReccuringBooking( + @Req() req: Request & { userId?: number }, + @Body() _: CreateReccuringBookingInput[] + ): Promise> { + req.userId = await this.getOwnerId(req); + req.body = { ...req.body, noEmail: true }; + try { + const createdBookings: BookingResponse[] = await handleNewRecurringBooking( + req as unknown as NextApiRequest & { userId?: number } + ); + return { + status: SUCCESS_STATUS, + data: createdBookings, + }; + } catch (err) { + handleBookingErrors(err, "recurring"); + } + throw new InternalServerErrorException("Could not create recurring booking."); + } + + @Post("/instant") + @Permissions([BOOKING_WRITE]) + async createInstantBooking( + @Req() req: Request & { userId?: number }, + @Body() _: CreateBookingInput + ): Promise>>> { + req.userId = await this.getOwnerId(req); + req.body = { ...req.body, noEmail: true }; + try { + const instantMeeting = await handleInstantMeeting( + req as unknown as NextApiRequest & { userId?: number } + ); + return { + status: SUCCESS_STATUS, + data: instantMeeting, + }; + } catch (err) { + handleBookingErrors(err, "instant"); + } + throw new InternalServerErrorException("Could not create instant booking."); + } + + async getOwnerId(req: Request): Promise { + try { + const accessToken = req.get("Authorization")?.replace("Bearer ", ""); + if (accessToken) { + return this.oAuthFlowService.getOwnerId(accessToken); + } + } catch (err) { + this.logger.error(err); + } + } +} + +function handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void { + const errMsg = `Error while creating ${type ? type + " " : ""}booking.`; + if (err instanceof HttpError) { + const httpError = err as HttpError; + throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500); + } + + if (err instanceof Error) { + const error = err as Error; + throw new InternalServerErrorException(error?.message ?? errMsg); + } + + throw new InternalServerErrorException(errMsg); +} diff --git a/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts new file mode 100644 index 00000000000000..2e1d7965cfaffc --- /dev/null +++ b/apps/api/v2/src/ee/bookings/inputs/create-booking.input.ts @@ -0,0 +1,58 @@ +import { Transform } from "class-transformer"; +import { IsBoolean, IsTimeZone, IsNumber, IsString, IsOptional, IsArray } from "class-validator"; + +export class CreateBookingInput { + @IsString() + @IsOptional() + end?: string; + + @IsString() + start!: string; + + @IsNumber() + eventTypeId!: number; + + @IsString() + @IsOptional() + eventTypeSlug?: string; + + @IsString() + @IsOptional() + rescheduleUid?: string; + + @IsString() + @IsOptional() + recurringEventId?: string; + + @IsTimeZone() + timeZone!: string; + + @Transform(({ value }: { value: string | string[] }) => { + return typeof value === "string" ? [value] : value; + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + user?: string[]; + + @IsString() + language!: string; + + @IsString() + @IsOptional() + bookingUid?: string; + + metadata!: Record; + + @IsBoolean() + @IsOptional() + hasHashedBookingLink?: boolean; + + @IsString() + @IsOptional() + hashedLink!: string | null; + + @IsString() + @IsOptional() + seatReferenceUid?: string; +} diff --git a/apps/api/v2/src/ee/bookings/inputs/create-reccuring-booking.input.ts b/apps/api/v2/src/ee/bookings/inputs/create-reccuring-booking.input.ts new file mode 100644 index 00000000000000..1d36e5d6fe0d46 --- /dev/null +++ b/apps/api/v2/src/ee/bookings/inputs/create-reccuring-booking.input.ts @@ -0,0 +1,24 @@ +import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input"; +import { IsBoolean, IsNumber, IsString, IsOptional, IsArray } from "class-validator"; + +import type { AppsStatus } from "@calcom/platform-libraries"; + +export class CreateReccuringBookingInput extends CreateBookingInput { + @IsBoolean() + @IsOptional() + noEmail?: boolean; + + @IsOptional() + @IsNumber() + recurringCount?: number; + + @IsOptional() + appsStatus?: AppsStatus[] | undefined; + + @IsOptional() + allRecurringDates?: Record[]; + + @IsOptional() + @IsNumber() + currentRecurringIndex?: number; +} diff --git a/apps/api/v2/src/ee/calendars/calendars.module.ts b/apps/api/v2/src/ee/calendars/calendars.module.ts new file mode 100644 index 00000000000000..fe6b382605aace --- /dev/null +++ b/apps/api/v2/src/ee/calendars/calendars.module.ts @@ -0,0 +1,14 @@ +import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, UsersModule], + providers: [CredentialsRepository, CalendarsService], + controllers: [CalendarsController], + exports: [CalendarsService], +}) +export class CalendarsModule {} diff --git a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts new file mode 100644 index 00000000000000..829cfa944c3388 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts @@ -0,0 +1,59 @@ +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Controller, Get, Logger, UseGuards, Query } from "@nestjs/common"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ConnectedDestinationCalendars } from "@calcom/platform-libraries"; +import { CalendarBusyTimesInput } from "@calcom/platform-types"; +import { ApiResponse } from "@calcom/platform-types"; +import { EventBusyDate } from "@calcom/types/Calendar"; + +@Controller({ + path: "ee/calendars", + version: "2", +}) +@UseGuards(AccessTokenGuard) +export class CalendarsController { + private readonly logger = new Logger("ee overlay calendars controller"); + + constructor(private readonly calendarsService: CalendarsService) {} + + @Get("/busy-times") + async getBusyTimes( + @Query() queryParams: CalendarBusyTimesInput, + @GetUser() user: UserWithProfile + ): Promise> { + const { loggedInUsersTz, dateFrom, dateTo, calendarsToLoad } = queryParams; + if (!dateFrom || !dateTo) { + return { + status: SUCCESS_STATUS, + data: [], + }; + } + + const busyTimes = await this.calendarsService.getBusyTimes( + calendarsToLoad, + user.id, + dateFrom, + dateTo, + loggedInUsersTz + ); + + return { + status: SUCCESS_STATUS, + data: busyTimes, + }; + } + + @Get("/") + async getCalendars(@GetUser("id") userId: number): Promise> { + const calendars = await this.calendarsService.getCalendars(userId); + + return { + status: SUCCESS_STATUS, + data: calendars, + }; + } +} diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts new file mode 100644 index 00000000000000..5ad1297bddbc51 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts @@ -0,0 +1,111 @@ +import { + CredentialsRepository, + CredentialsWithUserEmail, +} from "@/modules/credentials/credentials.repository"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { + Injectable, + InternalServerErrorException, + UnauthorizedException, + NotFoundException, +} from "@nestjs/common"; +import { User } from "@prisma/client"; +import { DateTime } from "luxon"; + +import { getConnectedDestinationCalendars } from "@calcom/platform-libraries"; +import { getBusyCalendarTimes } from "@calcom/platform-libraries"; +import { Calendar } from "@calcom/platform-types"; +import { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class CalendarsService { + constructor( + private readonly usersRepository: UsersRepository, + private readonly credentialsRepository: CredentialsRepository, + private readonly dbRead: PrismaReadService + ) {} + + async getCalendars(userId: number) { + const userWithCalendars = await this.usersRepository.findByIdWithCalendars(userId); + if (!userWithCalendars) { + throw new NotFoundException("User not found"); + } + + return getConnectedDestinationCalendars( + userWithCalendars, + false, + this.dbRead.prisma as unknown as PrismaClient + ); + } + + async getBusyTimes( + calendarsToLoad: Calendar[], + userId: User["id"], + dateFrom: string, + dateTo: string, + timezone: string + ) { + const credentials = await this.getUniqCalendarCredentials(calendarsToLoad, userId); + const composedSelectedCalendars = await this.getCalendarsWithCredentials( + credentials, + calendarsToLoad, + userId + ); + try { + const calendarBusyTimes = await getBusyCalendarTimes( + "", + credentials, + dateFrom, + dateTo, + composedSelectedCalendars + ); + const calendarBusyTimesConverted = calendarBusyTimes.map((busyTime) => { + 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( + "Unable to fetch connected calendars events. Please try again later." + ); + } + } + + async getUniqCalendarCredentials(calendarsToLoad: Calendar[], userId: User["id"]) { + const uniqueCredentialIds = Array.from(new Set(calendarsToLoad.map((item) => item.credentialId))); + const credentials = await this.credentialsRepository.getUserCredentialsByIds(userId, uniqueCredentialIds); + + if (credentials.length !== uniqueCredentialIds.length) { + throw new UnauthorizedException("These credentials do not belong to you"); + } + + return credentials; + } + + async getCalendarsWithCredentials( + credentials: CredentialsWithUserEmail, + calendarsToLoad: Calendar[], + userId: User["id"] + ) { + const composedSelectedCalendars = calendarsToLoad.map((calendar) => { + const credential = credentials.find((item) => item.id === calendar.credentialId); + if (!credential) { + throw new UnauthorizedException("These credentials do not belong to you"); + } + return { + ...calendar, + userId, + integration: credential.type, + }; + }); + return composedSelectedCalendars; + } +} diff --git a/apps/api/v2/src/ee/event-types/constants/constants.ts b/apps/api/v2/src/ee/event-types/constants/constants.ts new file mode 100644 index 00000000000000..690c1b4deceb6d --- /dev/null +++ b/apps/api/v2/src/ee/event-types/constants/constants.ts @@ -0,0 +1,16 @@ +export const DEFAULT_EVENT_TYPES = { + thirtyMinutes: { length: 30, slug: "thirty-minutes", title: "30 Minutes" }, + thirtyMinutesVideo: { + length: 30, + slug: "thirty-minutes-video", + title: "30 Minutes", + locations: [{ type: "integrations:daily" }], + }, + sixtyMinutes: { length: 60, slug: "sixty-minutes", title: "60 Minutes" }, + sixtyMinutesVideo: { + length: 60, + slug: "sixty-minutes-video", + title: "60 Minutes", + locations: [{ type: "integrations:daily" }], + }, +}; diff --git a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts new file mode 100644 index 00000000000000..821458499e7176 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts @@ -0,0 +1,160 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { EventTypesModule } from "@/ee/event-types/event-types.module"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { EventType, PlatformOAuthClient, Team, User } from "@prisma/client"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +describe("Event types Endpoints", () => { + describe("Not authenticated", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, EventTypesModule, TokensModule], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + it(`/GET/:id`, () => { + return request(app.getHttpServer()).get("/api/v2/event-types/100").expect(401); + }); + + afterAll(async () => { + await app.close(); + }); + }); + + describe("User Authenticated", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + + const userEmail = "test-e2e@api.com"; + let eventType: EventType; + let user: User; + + beforeAll(async () => { + const moduleRef = await withAccessTokenAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, EventTypesModule, TokensModule], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + + organization = await teamRepositoryFixture.create({ name: "organization" }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + + eventType = await eventTypesRepositoryFixture.create( + { + length: 60, + title: "peer coding session", + slug: "peer-coding", + }, + user.id + ); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(user).toBeDefined(); + }); + + it(`/GET/:id`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/event-types/${eventType.id}`) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toEqual(eventType.id); + expect(responseBody.data.title).toEqual(eventType.title); + expect(responseBody.data.slug).toEqual(eventType.slug); + expect(responseBody.data.userId).toEqual(user.id); + }); + + it(`/GET/:id not existing`, async () => { + await request(app.getHttpServer()) + .get(`/api/v2/event-types/1000`) + // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + .set("Authorization", `Bearer whatever`) + .expect(404); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await eventTypesRepositoryFixture.delete(eventType.id); + + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts new file mode 100644 index 00000000000000..3ecd826a79fd0c --- /dev/null +++ b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts @@ -0,0 +1,58 @@ +import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; +import { EventTypesService } from "@/ee/event-types/services/event-types.service"; +import { ForAtom } from "@/lib/atoms/decorators/for-atom.decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Controller, UseGuards, Get, Param, Post, Body, NotFoundException } from "@nestjs/common"; +import { EventType } from "@prisma/client"; + +import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { EventType as AtomEventType } from "@calcom/platform-libraries"; +import { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types"; + +@Controller({ + path: "event-types", + version: "2", +}) +@UseGuards(AccessTokenGuard, PermissionsGuard) +export class EventTypesController { + constructor(private readonly eventTypesService: EventTypesService) {} + + @Post("/") + @Permissions([EVENT_TYPE_WRITE]) + async createEventType( + @Body() body: CreateEventTypeInput, + @GetUser() user: UserWithProfile + ): Promise> { + const eventType = await this.eventTypesService.createUserEventType(user.id, body); + + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } + + @Get("/:eventTypeId") + @Permissions([EVENT_TYPE_READ]) + async getEventType( + @Param("eventTypeId") eventTypeId: string, + @ForAtom() forAtom: boolean, + @GetUser() user: UserWithProfile + ): Promise> { + const eventType = forAtom + ? await this.eventTypesService.getUserEventTypeForAtom(user, Number(eventTypeId)) + : await this.eventTypesService.getUserEventType(user.id, Number(eventTypeId)); + + if (!eventType) { + throw new NotFoundException(`Event type with id ${eventTypeId} not found`); + } + + return { + status: SUCCESS_STATUS, + data: eventType, + }; + } +} diff --git a/apps/api/v2/src/ee/event-types/event-types.module.ts b/apps/api/v2/src/ee/event-types/event-types.module.ts new file mode 100644 index 00000000000000..fa48ca0bfd0e1d --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types.module.ts @@ -0,0 +1,15 @@ +import { EventTypesController } from "@/ee/event-types/controllers/event-types.controller"; +import { EventTypesRepository } from "@/ee/event-types/event-types.repository"; +import { EventTypesService } from "@/ee/event-types/services/event-types.service"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, MembershipsModule, TokensModule], + providers: [EventTypesRepository, EventTypesService], + controllers: [EventTypesController], + exports: [EventTypesService, EventTypesRepository], +}) +export class EventTypesModule {} diff --git a/apps/api/v2/src/ee/event-types/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types.repository.ts new file mode 100644 index 00000000000000..ce658bf562b263 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/event-types.repository.ts @@ -0,0 +1,62 @@ +import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable, NotFoundException } from "@nestjs/common"; + +import { getEventTypeById } from "@calcom/platform-libraries"; + +@Injectable() +export class EventTypesRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createUserEventType(userId: number, body: CreateEventTypeInput) { + return this.dbWrite.prisma.eventType.create({ + data: { + ...body, + userId, + users: { connect: { id: userId } }, + }, + }); + } + + async getEventTypeWithSeats(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ + where: { id: eventTypeId }, + select: { users: { select: { id: true } }, seatsPerTimeSlot: true }, + }); + } + + async getUserEventType(userId: number, eventTypeId: number) { + return this.dbRead.prisma.eventType.findFirst({ + where: { + id: eventTypeId, + userId, + }, + }); + } + + async getUserEventTypeForAtom( + user: UserWithProfile, + isUserOrganizationAdmin: boolean, + eventTypeId: number + ) { + try { + return getEventTypeById({ + currentOrganizationId: user.movedToProfile?.organizationId || user.organizationId, + eventTypeId, + userId: user.id, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + prisma: this.dbRead.prisma, + isUserOrganizationAdmin, + }); + } catch (error) { + throw new NotFoundException(`User with id ${user.id} has no event type with id ${eventTypeId}`); + } + } + + async getEventTypeById(eventTypeId: number) { + return this.dbRead.prisma.eventType.findUnique({ where: { id: eventTypeId } }); + } +} diff --git a/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts b/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts new file mode 100644 index 00000000000000..9e58159fba8f5c --- /dev/null +++ b/apps/api/v2/src/ee/event-types/inputs/create-event-type.input.ts @@ -0,0 +1,13 @@ +import { IsNumber, IsString, Min } from "class-validator"; + +export class CreateEventTypeInput { + @IsNumber() + @Min(1) + length!: number; + + @IsString() + slug!: string; + + @IsString() + title!: string; +} diff --git a/apps/api/v2/src/ee/event-types/services/event-types.service.ts b/apps/api/v2/src/ee/event-types/services/event-types.service.ts new file mode 100644 index 00000000000000..aefe23944d81e9 --- /dev/null +++ b/apps/api/v2/src/ee/event-types/services/event-types.service.ts @@ -0,0 +1,49 @@ +import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants"; +import { EventTypesRepository } from "@/ee/event-types/event-types.repository"; +import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class EventTypesService { + constructor( + private readonly eventTypesRepository: EventTypesRepository, + private readonly membershipsRepository: MembershipsRepository + ) {} + + async createUserEventType(userId: number, body: CreateEventTypeInput) { + return this.eventTypesRepository.createUserEventType(userId, body); + } + + async getUserEventType(userId: number, eventTypeId: number) { + return this.eventTypesRepository.getUserEventType(userId, eventTypeId); + } + + async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) { + const organizationId = user.movedToProfile?.organizationId || user.organizationId; + + const isUserOrganizationAdmin = organizationId + ? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId) + : false; + + return this.eventTypesRepository.getUserEventTypeForAtom(user, isUserOrganizationAdmin, eventTypeId); + } + + async createUserDefaultEventTypes(userId: number) { + const thirtyMinutes = DEFAULT_EVENT_TYPES.thirtyMinutes; + const thirtyMinutesVideo = DEFAULT_EVENT_TYPES.thirtyMinutesVideo; + + const sixtyMinutes = DEFAULT_EVENT_TYPES.sixtyMinutes; + const sixtyMinutesVideo = DEFAULT_EVENT_TYPES.sixtyMinutesVideo; + + const defaultEventTypes = await Promise.all([ + this.eventTypesRepository.createUserEventType(userId, thirtyMinutes), + this.eventTypesRepository.createUserEventType(userId, sixtyMinutes), + this.eventTypesRepository.createUserEventType(userId, thirtyMinutesVideo), + this.eventTypesRepository.createUserEventType(userId, sixtyMinutesVideo), + ]); + + return defaultEventTypes; + } +} diff --git a/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts b/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts new file mode 100644 index 00000000000000..470a25cd247726 --- /dev/null +++ b/apps/api/v2/src/ee/gcal/gcal.controller.e2e-spec.ts @@ -0,0 +1,166 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client"; +import * as request from "supertest"; +import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; + +const CLIENT_REDIRECT_URI = "http://localhost:5555"; + +describe("Platform Gcal Endpoints", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let tokensRepositoryFixture: TokensRepositoryFixture; + let credentialsRepositoryFixture: CredentialsRepositoryFixture; + let user: User; + let gcalCredentials: Credential; + let accessTokenSecret: string; + let refreshTokenSecret: string; + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, TokensModule], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef); + credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef); + organization = await teamRepositoryFixture.create({ name: "organization" }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.createOAuthManagedUser("gcal-connect@gmail.com", oAuthClient.id); + const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id); + accessTokenSecret = tokens.accessToken; + refreshTokenSecret = tokens.refreshToken; + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(accessTokenSecret).toBeDefined(); + expect(refreshTokenSecret).toBeDefined(); + expect(user).toBeDefined(); + }); + + it(`/GET/platform/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => { + await request(app.getHttpServer()) + .get(`/api/v2/platform/gcal/oauth/auth-url`) + .set("Authorization", `Bearer invalid_access_token`) + .expect(401); + }); + + it(`/GET/platform/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/platform/gcal/oauth/auth-url`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + const data = response.body.data; + expect(data.authUrl).toBeDefined(); + }); + + it(`/GET/platform/gcal/oauth/save: without oauth code`, async () => { + await request(app.getHttpServer()) + .get( + `/api/v2/platform/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events` + ) + .expect(400); + }); + + it(`/GET/platform/gcal/oauth/save: without access token`, async () => { + await request(app.getHttpServer()) + .get( + `/api/v2/platform/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events` + ) + .expect(400); + }); + + it(`/GET/platform/gcal/oauth/save: without origin`, async () => { + await request(app.getHttpServer()) + .get( + `/api/v2/platform/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events` + ) + .expect(400); + }); + + it(`/GET/platform/gcal/check with access token but without origin`, async () => { + await request(app.getHttpServer()) + .get(`/api/v2/platform/gcal/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .expect(400); + }); + + it(`/GET/platform/gcal/check without access token`, async () => { + await request(app.getHttpServer()).get(`/api/v2/platform/gcal/check`).expect(401); + }); + + it(`/GET/platform/gcal/check with access token and origin but no credentials`, async () => { + await request(app.getHttpServer()) + .get(`/api/v2/platform/gcal/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(400); + }); + + it(`/GET/platform/gcal/check with access token and origin and gcal credentials`, async () => { + gcalCredentials = await credentialsRepositoryFixture.create( + "google_calendar", + {}, + user.id, + "google-calendar" + ); + await request(app.getHttpServer()) + .get(`/api/v2/platform/gcal/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await credentialsRepositoryFixture.delete(gcalCredentials.id); + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/ee/gcal/gcal.controller.ts b/apps/api/v2/src/ee/gcal/gcal.controller.ts new file mode 100644 index 00000000000000..9d5e1a76e2ac8c --- /dev/null +++ b/apps/api/v2/src/ee/gcal/gcal.controller.ts @@ -0,0 +1,151 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { GcalService } from "@/modules/apps/services/gcal.service"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { + BadRequestException, + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + Query, + Redirect, + Req, + UnauthorizedException, + UseGuards, + Headers, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Request } from "express"; +import { google } from "googleapis"; +import { z } from "zod"; + +import { + APPS_READ, + GOOGLE_CALENDAR_ID, + GOOGLE_CALENDAR_TYPE, + SUCCESS_STATUS, +} from "@calcom/platform-constants"; +import { ApiRedirectResponseType, ApiResponse } from "@calcom/platform-types"; + +const CALENDAR_SCOPES = [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", +]; + +@Controller({ + path: "platform/gcal", + version: "2", +}) +export class GcalController { + private readonly logger = new Logger("Platform Gcal Provider"); + + constructor( + private readonly appRepository: AppsRepository, + private readonly credentialRepository: CredentialsRepository, + private readonly tokensRepository: TokensRepository, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository, + private readonly config: ConfigService, + private readonly gcalService: GcalService + ) {} + + private redirectUri = `${this.config.get("api.url")}/platform/gcal/oauth/save`; + + @Get("/oauth/auth-url") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard) + async redirect( + @Headers("Authorization") authorization: string, + @Req() req: Request + ): Promise> { + const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri); + const accessToken = authorization.replace("Bearer ", ""); + const origin = req.get("origin") ?? req.get("host"); + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: CALENDAR_SCOPES, + prompt: "consent", + state: `accessToken=${accessToken}&origin=${origin}`, + }); + return { status: SUCCESS_STATUS, data: { authUrl } }; + } + + @Get("/oauth/save") + @Redirect(undefined, 301) + @HttpCode(HttpStatus.OK) + async save(@Query("state") state: string, @Query("code") code: string): Promise { + const stateParams = new URLSearchParams(state); + const { accessToken, origin } = z + .object({ accessToken: z.string(), origin: z.string() }) + .parse({ accessToken: stateParams.get("accessToken"), origin: stateParams.get("origin") }); + + // User chose not to authorize your app or didn't authorize your app + // redirect directly without oauth code + if (!code) { + return { url: origin }; + } + + const parsedCode = z.string().parse(code); + + const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!ownerId) { + throw new UnauthorizedException("Invalid Access token."); + } + + const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri); + const token = await oAuth2Client.getToken(parsedCode); + const key = token.res?.data; + const credential = await this.credentialRepository.createAppCredential( + GOOGLE_CALENDAR_TYPE, + key, + ownerId + ); + + oAuth2Client.setCredentials(key); + + const calendar = google.calendar({ + version: "v3", + auth: oAuth2Client, + }); + + const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" }); + + const primaryCal = cals.data.items?.find((cal) => cal.primary); + + if (primaryCal?.id) { + await this.selectedCalendarsRepository.createSelectedCalendar( + primaryCal.id, + credential.id, + ownerId, + GOOGLE_CALENDAR_ID + ); + } + + return { url: origin }; + } + + @Get("/check") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard, PermissionsGuard) + @Permissions([APPS_READ]) + async check(@GetUser("id") userId: number): Promise { + const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId); + + if (!gcalCredentials) { + throw new BadRequestException("Credentials for google_calendar not found."); + } + + if (gcalCredentials.invalid) { + throw new BadRequestException("Invalid google oauth credentials."); + } + + return { status: SUCCESS_STATUS }; + } +} diff --git a/apps/api/v2/src/ee/gcal/gcal.module.ts b/apps/api/v2/src/ee/gcal/gcal.module.ts new file mode 100644 index 00000000000000..238fb90ca29c7d --- /dev/null +++ b/apps/api/v2/src/ee/gcal/gcal.module.ts @@ -0,0 +1,17 @@ +import { GcalController } from "@/ee/gcal/gcal.controller"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { GcalService } from "@/modules/apps/services/gcal.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +@Module({ + imports: [PrismaModule, TokensModule, OAuthClientModule], + providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository, GcalService], + controllers: [GcalController], +}) +export class GcalModule {} diff --git a/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts new file mode 100644 index 00000000000000..b5cf3c8e4b4ef0 --- /dev/null +++ b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts @@ -0,0 +1,131 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { SchedulesRepository } from "@/ee/schedules/schedules.repository"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UpdateUserInput } from "@/modules/users/inputs/update-user.input"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { UserResponse } from "@calcom/platform-types"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +describe("Me Endpoints", () => { + describe("User Authentication", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let schedulesRepositoryFixture: SchedulesRepositoryFixture; + + const userEmail = "me-controller-e2e@api.com"; + let user: User; + + beforeAll(async () => { + const moduleRef = await withAccessTokenAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule, TokensModule], + providers: [SchedulesRepository, SchedulesService], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should get user associated with access token", async () => { + return request(app.getHttpServer()) + .get("/api/v2/me") + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + expect(responseBody.data.id).toEqual(user.id); + expect(responseBody.data.email).toEqual(user.email); + expect(responseBody.data.timeFormat).toEqual(user.timeFormat); + expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId); + expect(responseBody.data.weekStart).toEqual(user.weekStart); + expect(responseBody.data.timeZone).toEqual(user.timeZone); + }); + }); + + it("should update user associated with access token", async () => { + const body: UpdateUserInput = { timeZone: "Europe/Rome" }; + + return request(app.getHttpServer()) + .patch("/api/v2/me") + .send(body) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + expect(responseBody.data.id).toEqual(user.id); + expect(responseBody.data.email).toEqual(user.email); + expect(responseBody.data.timeFormat).toEqual(user.timeFormat); + expect(responseBody.data.defaultScheduleId).toEqual(user.defaultScheduleId); + expect(responseBody.data.weekStart).toEqual(user.weekStart); + expect(responseBody.data.timeZone).toEqual(body.timeZone); + + if (user.defaultScheduleId) { + const defaultSchedule = await schedulesRepositoryFixture.getById(user.defaultScheduleId); + expect(defaultSchedule?.timeZone).toEqual(body.timeZone); + } + }); + }); + + it("should not update user associated with access token given invalid timezone", async () => { + const bodyWithIncorrectTimeZone: UpdateUserInput = { timeZone: "Narnia/Woods" }; + + return request(app.getHttpServer()).patch("/api/v2/me").send(bodyWithIncorrectTimeZone).expect(400); + }); + + it("should not update user associated with access token given invalid time format", async () => { + const bodyWithIncorrectTimeFormat: UpdateUserInput = { timeFormat: 100 }; + + return request(app.getHttpServer()).patch("/api/v2/me").send(bodyWithIncorrectTimeFormat).expect(400); + }); + + it("should not update user associated with access token given invalid week start", async () => { + const bodyWithIncorrectWeekStart: UpdateUserInput = { weekStart: "waba luba dub dub" }; + + return request(app.getHttpServer()).patch("/api/v2/me").send(bodyWithIncorrectWeekStart).expect(400); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/me/me.controller.ts b/apps/api/v2/src/ee/me/me.controller.ts new file mode 100644 index 00000000000000..81cda17469d0e5 --- /dev/null +++ b/apps/api/v2/src/ee/me/me.controller.ts @@ -0,0 +1,56 @@ +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UpdateUserInput } from "@/modules/users/inputs/update-user.input"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { Controller, UseGuards, Get, Patch, Body } from "@nestjs/common"; + +import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { UserResponse, userSchemaResponse } from "@calcom/platform-types"; +import { ApiResponse } from "@calcom/platform-types"; + +@Controller({ + path: "me", + version: "2", +}) +@UseGuards(AccessTokenGuard, PermissionsGuard) +export class MeController { + constructor( + private readonly usersRepository: UsersRepository, + private readonly schedulesRepository: SchedulesService + ) {} + + @Get("/") + @Permissions([PROFILE_READ]) + async getMe(@GetUser() user: UserWithProfile): Promise> { + const me = userSchemaResponse.parse(user); + + return { + status: SUCCESS_STATUS, + data: me, + }; + } + + @Patch("/") + @Permissions([PROFILE_WRITE]) + async updateMe( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: UpdateUserInput + ): Promise> { + const updatedUser = await this.usersRepository.update(user.id, bodySchedule); + if (bodySchedule.timeZone && user.defaultScheduleId) { + await this.schedulesRepository.updateUserSchedule(user.id, user.defaultScheduleId, { + timeZone: bodySchedule.timeZone, + }); + } + + const me = userSchemaResponse.parse(updatedUser); + + return { + status: SUCCESS_STATUS, + data: me, + }; + } +} diff --git a/apps/api/v2/src/ee/me/me.module.ts b/apps/api/v2/src/ee/me/me.module.ts new file mode 100644 index 00000000000000..b8802914f6c7d1 --- /dev/null +++ b/apps/api/v2/src/ee/me/me.module.ts @@ -0,0 +1,11 @@ +import { MeController } from "@/ee/me/me.controller"; +import { SchedulesModule } from "@/ee/schedules/schedules.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [UsersModule, SchedulesModule, TokensModule], + controllers: [MeController], +}) +export class MeModule {} diff --git a/apps/api/v2/src/ee/platform-endpoints-module.ts b/apps/api/v2/src/ee/platform-endpoints-module.ts new file mode 100644 index 00000000000000..fdf3ef34bb995d --- /dev/null +++ b/apps/api/v2/src/ee/platform-endpoints-module.ts @@ -0,0 +1,29 @@ +import { BookingsModule } from "@/ee/bookings/bookings.module"; +import { CalendarsModule } from "@/ee/calendars/calendars.module"; +import { EventTypesModule } from "@/ee/event-types/event-types.module"; +import { GcalModule } from "@/ee/gcal/gcal.module"; +import { MeModule } from "@/ee/me/me.module"; +import { ProviderModule } from "@/ee/provider/provider.module"; +import { SchedulesModule } from "@/ee/schedules/schedules.module"; +import { SlotsModule } from "@/modules/slots/slots.module"; +import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [ + GcalModule, + ProviderModule, + SchedulesModule, + MeModule, + EventTypesModule, + CalendarsModule, + BookingsModule, + SlotsModule, + ], +}) +export class PlatformEndpointsModule implements NestModule { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + configure(_consumer: MiddlewareConsumer) { + // TODO: apply ratelimits + } +} diff --git a/apps/api/v2/src/ee/provider/provider.controller.ts b/apps/api/v2/src/ee/provider/provider.controller.ts new file mode 100644 index 00000000000000..192459e1f6105b --- /dev/null +++ b/apps/api/v2/src/ee/provider/provider.controller.ts @@ -0,0 +1,68 @@ +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + BadRequestException, + Controller, + Get, + HttpCode, + HttpStatus, + Logger, + NotFoundException, + Param, + UnauthorizedException, + UseGuards, +} from "@nestjs/common"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiResponse } from "@calcom/platform-types"; + +@Controller({ + path: "platform/provider", + version: "2", +}) +export class CalProviderController { + private readonly logger = new Logger("Platform Provider Controller"); + + constructor( + private readonly tokensRepository: TokensRepository, + private readonly oauthClientRepository: OAuthClientRepository + ) {} + + @Get("/:clientId") + @HttpCode(HttpStatus.OK) + async verifyClientId(@Param("clientId") clientId: string): Promise { + if (!clientId) { + throw new NotFoundException(); + } + const oAuthClient = await this.oauthClientRepository.getOAuthClient(clientId); + + if (!oAuthClient) throw new UnauthorizedException(); + + return { + status: SUCCESS_STATUS, + }; + } + + @Get("/:clientId/access-token") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard) + async verifyAccessToken( + @Param("clientId") clientId: string, + @GetUser() user: UserWithProfile + ): Promise { + if (!clientId) { + throw new BadRequestException(); + } + + if (!user) { + throw new UnauthorizedException(); + } + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/ee/provider/provider.module.ts b/apps/api/v2/src/ee/provider/provider.module.ts new file mode 100644 index 00000000000000..d96be50d3a6fbb --- /dev/null +++ b/apps/api/v2/src/ee/provider/provider.module.ts @@ -0,0 +1,13 @@ +import { CalProviderController } from "@/ee/provider/provider.controller"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, TokensModule, OAuthClientModule], + providers: [CredentialsRepository], + controllers: [CalProviderController], +}) +export class ProviderModule {} diff --git a/apps/api/v2/src/ee/schedules/controllers/schedules.controller.e2e-spec.ts b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.e2e-spec.ts new file mode 100644 index 00000000000000..253d52a55059fd --- /dev/null +++ b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.e2e-spec.ts @@ -0,0 +1,366 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { SchedulesRepository } from "@/ee/schedules/schedules.repository"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { + ScheduleWithAvailabilities, + ScheduleWithAvailabilitiesForWeb, + UpdateScheduleOutputType, +} from "@calcom/platform-libraries"; +import { ScheduleResponse, UpdateScheduleInput } from "@calcom/platform-types"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +describe("Schedules Endpoints", () => { + describe("User Authentication", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let scheduleRepositoryFixture: SchedulesRepositoryFixture; + + const userEmail = "schedules-controller-e2e@api.com"; + let user: User; + + let createdSchedule: ScheduleResponse; + + beforeAll(async () => { + const moduleRef = await withAccessTokenAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule, TokensModule], + providers: [SchedulesRepository, SchedulesService], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + }); + + it("should create a default schedule", async () => { + const scheduleName = "schedule-name"; + const scheduleTimeZone = "Europe/Rome"; + + const body = { + name: scheduleName, + timeZone: scheduleTimeZone, + }; + + return request(app.getHttpServer()) + .post("/api/v2/schedules") + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.userId).toEqual(user.id); + expect(responseBody.data.name).toEqual(scheduleName); + expect(responseBody.data.timeZone).toEqual(scheduleTimeZone); + + expect(responseBody.data.availability).toBeDefined(); + expect(responseBody.data.availability?.length).toEqual(1); + const defaultAvailabilityDays = [1, 2, 3, 4, 5]; + const defaultAvailabilityStartTime = "09:00:00"; + const defaultAvailabilityEndTime = "17:00:00"; + + expect(responseBody.data.availability?.[0]?.days).toEqual(defaultAvailabilityDays); + expect(responseBody.data.availability?.[0]?.startTime).toEqual(defaultAvailabilityStartTime); + expect(responseBody.data.availability?.[0]?.endTime).toEqual(defaultAvailabilityEndTime); + + const scheduleUser = await userRepositoryFixture.get(responseBody.data.userId); + expect(scheduleUser?.defaultScheduleId).toEqual(responseBody.data.id); + await scheduleRepositoryFixture.deleteById(responseBody.data.id); + await scheduleRepositoryFixture.deleteAvailabilities(responseBody.data.id); + }); + }); + + it("should create a schedule", async () => { + const scheduleName = "schedule-name"; + const scheduleTimeZone = "Europe/Rome"; + const availabilityDays = [1, 2, 3, 4, 5, 6]; + const availabilityStartTime = "11:00:00"; + const availabilityEndTime = "14:00:00"; + + const body = { + name: scheduleName, + timeZone: scheduleTimeZone, + availabilities: [ + { + days: availabilityDays, + startTime: availabilityStartTime, + endTime: availabilityEndTime, + }, + ], + }; + + return request(app.getHttpServer()) + .post("/api/v2/schedules") + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.userId).toEqual(user.id); + expect(responseBody.data.name).toEqual(scheduleName); + expect(responseBody.data.timeZone).toEqual(scheduleTimeZone); + + expect(responseBody.data.availability).toBeDefined(); + expect(responseBody.data.availability?.length).toEqual(1); + expect(responseBody.data.availability?.[0]?.days).toEqual(availabilityDays); + expect(responseBody.data.availability?.[0]?.startTime).toEqual(availabilityStartTime); + expect(responseBody.data.availability?.[0]?.endTime).toEqual(availabilityEndTime); + + createdSchedule = responseBody.data; + + const scheduleUser = await userRepositoryFixture.get(responseBody.data.userId); + expect(scheduleUser?.defaultScheduleId).toEqual(responseBody.data.id); + }); + }); + + it("should get default schedule", async () => { + return request(app.getHttpServer()) + .get("/api/v2/schedules/default") + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.userId).toEqual(createdSchedule.userId); + expect(responseBody.data.name).toEqual(createdSchedule.name); + expect(responseBody.data.timeZone).toEqual(createdSchedule.timeZone); + + expect(responseBody.data.availability).toBeDefined(); + expect(responseBody.data.availability?.length).toEqual(1); + + expect(responseBody.data.availability?.[0]?.days).toEqual(createdSchedule.availability?.[0]?.days); + expect(responseBody.data.availability?.[0]?.startTime).toEqual( + createdSchedule.availability?.[0]?.startTime + ); + expect(responseBody.data.availability?.[0]?.endTime).toEqual( + createdSchedule.availability?.[0]?.endTime + ); + }); + }); + + it("should get schedule", async () => { + return request(app.getHttpServer()) + .get(`/api/v2/schedules/${createdSchedule.id}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect( + (responseBody.data as unknown as ScheduleWithAvailabilitiesForWeb).dateOverrides + ).toBeFalsy(); + expect(responseBody.data.userId).toEqual(createdSchedule.userId); + expect(responseBody.data.name).toEqual(createdSchedule.name); + expect(responseBody.data.timeZone).toEqual(createdSchedule.timeZone); + + expect(responseBody.data.availability).toBeDefined(); + expect(responseBody.data.availability?.length).toEqual(1); + + expect(responseBody.data.availability?.[0]?.days).toEqual(createdSchedule.availability?.[0]?.days); + expect(responseBody.data.availability?.[0]?.startTime).toEqual( + createdSchedule.availability?.[0]?.startTime + ); + expect(responseBody.data.availability?.[0]?.endTime).toEqual( + createdSchedule.availability?.[0]?.endTime + ); + }); + }); + + it("should get schedule for atom", async () => { + const forAtomQueryParam = "?for=atom"; + return request(app.getHttpServer()) + .get(`/api/v2/schedules/${createdSchedule.id}${forAtomQueryParam}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.dateOverrides).toBeDefined(); + expect((responseBody.data as unknown as ScheduleWithAvailabilities).userId).toBeFalsy(); + expect(responseBody.data.name).toEqual(createdSchedule.name); + expect(responseBody.data.timeZone).toEqual(createdSchedule.timeZone); + + expect(responseBody.data.availability).toBeDefined(); + expect(responseBody.data.availability?.length).toEqual(7); + + const dateStart = new Date(responseBody.data.availability?.[1]?.[0]?.start); + const dateEnd = new Date(responseBody.data.availability?.[1]?.[0]?.end); + + const dateStartHours = dateStart.getUTCHours().toString().padStart(2, "0"); + const dateStartMinutes = dateStart.getUTCMinutes().toString().padStart(2, "0"); + const dateStartSeconds = dateStart.getUTCSeconds().toString().padStart(2, "0"); + const dateEndHours = dateEnd.getUTCHours().toString().padStart(2, "0"); + const dateEndMinutes = dateEnd.getUTCMinutes().toString().padStart(2, "0"); + const dateEndSeconds = dateEnd.getUTCSeconds().toString().padStart(2, "0"); + + const expectedStart = `${dateStartHours}:${dateStartMinutes}:${dateStartSeconds}`; + const expectedEnd = `${dateEndHours}:${dateEndMinutes}:${dateEndSeconds}`; + + expect(expectedStart).toEqual(createdSchedule.availability?.[0]?.startTime); + expect(expectedEnd).toEqual(createdSchedule.availability?.[0]?.endTime); + }); + }); + + it("should get schedules", async () => { + return request(app.getHttpServer()) + .get(`/api/v2/schedules`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.length).toEqual(1); + + const fetchedSchedule = responseBody.data[0]; + expect(fetchedSchedule).toBeDefined(); + expect(fetchedSchedule.userId).toEqual(createdSchedule.userId); + expect(fetchedSchedule.name).toEqual(createdSchedule.name); + expect(fetchedSchedule.timeZone).toEqual(createdSchedule.timeZone); + + expect(fetchedSchedule.availability).toBeDefined(); + expect(fetchedSchedule.availability?.length).toEqual(1); + + expect(fetchedSchedule.availability?.[0]?.days).toEqual(createdSchedule.availability?.[0]?.days); + expect(fetchedSchedule.availability?.[0]?.startTime).toEqual( + createdSchedule.availability?.[0]?.startTime + ); + expect(fetchedSchedule.availability?.[0]?.endTime).toEqual( + createdSchedule.availability?.[0]?.endTime + ); + }); + }); + + it("should update schedule name", async () => { + const newScheduleName = "new-schedule-name"; + + const schedule = [ + [], + [ + { + start: new Date("2024-03-05T11:00:00.000Z"), + end: new Date("2024-03-05T14:00:00.000Z"), + }, + ], + [ + { + start: new Date("2024-03-05T11:00:00.000Z"), + end: new Date("2024-03-05T14:00:00.000Z"), + }, + ], + [ + { + start: new Date("2024-03-05T11:00:00.000Z"), + end: new Date("2024-03-05T14:00:00.000Z"), + }, + ], + [ + { + start: new Date("2024-03-05T11:00:00.000Z"), + end: new Date("2024-03-05T14:00:00.000Z"), + }, + ], + [ + { + start: new Date("2024-03-05T11:00:00.000Z"), + end: new Date("2024-03-05T14:00:00.000Z"), + }, + ], + [ + { + start: new Date("2024-03-05T11:00:00.000Z"), + end: new Date("2024-03-05T14:00:00.000Z"), + }, + ], + ]; + + const body: UpdateScheduleInput = { + scheduleId: createdSchedule.id, + name: newScheduleName, + schedule, + }; + + return request(app.getHttpServer()) + .patch(`/api/v2/schedules/${createdSchedule.id}`) + .send(body) + .expect(200) + .then((response: any) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.schedule.id).toBeDefined(); + expect(responseBody.data.schedule.userId).toEqual(createdSchedule.userId); + expect(responseBody.data.schedule.name).toEqual(newScheduleName); + expect(responseBody.data.timeZone).toEqual(createdSchedule.timeZone); + + expect(responseBody.data.availability).toBeDefined(); + const availabilities = responseBody.data?.availability?.filter( + (availability: any[]) => availability.length + ); + expect(availabilities?.length).toEqual(6); + + expect(responseBody.data.schedule.availability?.[0]?.days).toEqual( + createdSchedule.availability?.[0]?.days + ); + expect(responseBody.data.schedule.availability?.[0]?.startTime).toEqual( + `1970-01-01T${createdSchedule.availability?.[0]?.startTime}.000Z` + ); + expect(responseBody.data.schedule.availability?.[0]?.endTime).toEqual( + `1970-01-01T${createdSchedule.availability?.[0]?.endTime}.000Z` + ); + + createdSchedule.name = newScheduleName; + }); + }); + + it("should delete schedule", async () => { + return request(app.getHttpServer()).delete(`/api/v2/schedules/${createdSchedule.id}`).expect(200); + }); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts new file mode 100644 index 00000000000000..9598ef830be770 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/controllers/schedules.controller.ts @@ -0,0 +1,148 @@ +import { ResponseService } from "@/ee/schedules/services/response/response.service"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { ForAtom } from "@/lib/atoms/decorators/for-atom.decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Patch, + UseGuards, +} from "@nestjs/common"; + +import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ScheduleWithAvailabilitiesForWeb } from "@calcom/platform-libraries"; +import type { CityTimezones } from "@calcom/platform-libraries"; +import { updateScheduleHandler } from "@calcom/platform-libraries"; +import type { UpdateScheduleOutputType } from "@calcom/platform-libraries"; +import { ApiSuccessResponse, ScheduleResponse, UpdateScheduleInput } from "@calcom/platform-types"; +import { ApiResponse } from "@calcom/platform-types"; + +import { CreateScheduleInput } from "../inputs/create-schedule.input"; + +@Controller({ + path: "schedules", + version: "2", +}) +@UseGuards(AccessTokenGuard, PermissionsGuard) +export class SchedulesController { + constructor( + private readonly schedulesService: SchedulesService, + private readonly schedulesResponseService: ResponseService + ) {} + + @Post("/") + @Permissions([SCHEDULE_WRITE]) + async createSchedule( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: CreateScheduleInput, + @ForAtom() forAtom: boolean + ): Promise> { + const schedule = await this.schedulesService.createUserSchedule(user.id, bodySchedule); + const scheduleFormatted = await this.schedulesResponseService.formatSchedule(forAtom, user, schedule); + + return { + status: SUCCESS_STATUS, + data: scheduleFormatted, + }; + } + + @Get("/default") + @Permissions([SCHEDULE_READ]) + async getDefaultSchedule( + @GetUser() user: UserWithProfile, + @ForAtom() forAtom: boolean + ): Promise> { + const schedule = await this.schedulesService.getUserScheduleDefault(user.id); + const scheduleFormatted = schedule + ? await this.schedulesResponseService.formatSchedule(forAtom, user, schedule) + : null; + + return { + status: SUCCESS_STATUS, + data: scheduleFormatted, + }; + } + + @Get("/time-zones") + async getTimeZones(): Promise> { + const timeZones = await this.schedulesService.getSchedulePossibleTimeZones(); + + return { + status: SUCCESS_STATUS, + data: timeZones, + }; + } + + @Get("/:scheduleId") + @Permissions([SCHEDULE_READ]) + async getSchedule( + @GetUser() user: UserWithProfile, + @Param("scheduleId") scheduleId: number, + @ForAtom() forAtom: boolean + ): Promise> { + const schedule = await this.schedulesService.getUserSchedule(user.id, scheduleId); + const scheduleFormatted = await this.schedulesResponseService.formatSchedule(forAtom, user, schedule); + + return { + status: SUCCESS_STATUS, + data: scheduleFormatted, + }; + } + + @Get("/") + @Permissions([SCHEDULE_READ]) + async getSchedules( + @GetUser() user: UserWithProfile, + @ForAtom() forAtom: boolean + ): Promise> { + const schedules = await this.schedulesService.getUserSchedules(user.id); + const schedulesFormatted = await this.schedulesResponseService.formatSchedules(forAtom, user, schedules); + + return { + status: SUCCESS_STATUS, + data: schedulesFormatted, + }; + } + + // note(Lauris): currently this endpoint is atoms only + @Patch("/:scheduleId") + @Permissions([SCHEDULE_WRITE]) + async updateSchedule( + @GetUser() user: UserWithProfile, + @Body() bodySchedule: UpdateScheduleInput + ): Promise> { + const updatedSchedule: UpdateScheduleOutputType = await updateScheduleHandler({ + input: bodySchedule, + ctx: { user }, + }); + + return { + status: SUCCESS_STATUS, + data: updatedSchedule, + }; + } + + @Delete("/:scheduleId") + @HttpCode(HttpStatus.OK) + @Permissions([SCHEDULE_WRITE]) + async deleteSchedule( + @GetUser("id") userId: number, + @Param("scheduleId") scheduleId: number + ): Promise { + await this.schedulesService.deleteUserSchedule(userId, scheduleId); + + return { + status: SUCCESS_STATUS, + }; + } +} diff --git a/apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts b/apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts new file mode 100644 index 00000000000000..3c40e768e12126 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/inputs/create-schedule.input.ts @@ -0,0 +1,21 @@ +import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input"; +import { Type } from "class-transformer"; +import { IsArray, IsBoolean, IsTimeZone, IsOptional, IsString, ValidateNested } from "class-validator"; + +export class CreateScheduleInput { + @IsString() + name!: string; + + @IsTimeZone() + timeZone!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateAvailabilityInput) + @IsOptional() + availabilities?: CreateAvailabilityInput[]; + + @IsBoolean() + @IsOptional() + isDefault = true; +} diff --git a/apps/api/v2/src/ee/schedules/inputs/update-schedule.input.ts b/apps/api/v2/src/ee/schedules/inputs/update-schedule.input.ts new file mode 100644 index 00000000000000..6cc740ceb52988 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/inputs/update-schedule.input.ts @@ -0,0 +1,22 @@ +import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input"; +import { Type } from "class-transformer"; +import { IsBoolean, IsOptional, IsString, IsTimeZone, ValidateNested } from "class-validator"; + +export class UpdateScheduleInput { + @IsString() + @IsOptional() + name?: string; + + @IsTimeZone() + @IsOptional() + timeZone?: string; + + @ValidateNested({ each: true }) + @Type(() => CreateAvailabilityInput) + @IsOptional() + availabilities?: CreateAvailabilityInput[]; + + @IsBoolean() + @IsOptional() + isDefault?: boolean; +} diff --git a/apps/api/v2/src/ee/schedules/schedules.module.ts b/apps/api/v2/src/ee/schedules/schedules.module.ts new file mode 100644 index 00000000000000..5e70f1503a28cb --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules.module.ts @@ -0,0 +1,17 @@ +import { SchedulesController } from "@/ee/schedules/controllers/schedules.controller"; +import { SchedulesRepository } from "@/ee/schedules/schedules.repository"; +import { ResponseService } from "@/ee/schedules/services/response/response.service"; +import { SchedulesService } from "@/ee/schedules/services/schedules.service"; +import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, AvailabilitiesModule, UsersModule, TokensModule], + providers: [SchedulesRepository, SchedulesService, ResponseService], + controllers: [SchedulesController], + exports: [SchedulesService], +}) +export class SchedulesModule {} diff --git a/apps/api/v2/src/ee/schedules/schedules.repository.ts b/apps/api/v2/src/ee/schedules/schedules.repository.ts new file mode 100644 index 00000000000000..28a63e49b09635 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/schedules.repository.ts @@ -0,0 +1,141 @@ +import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input"; +import { UpdateScheduleInput } from "@/ee/schedules/inputs/update-schedule.input"; +import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +@Injectable() +export class SchedulesRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createScheduleWithAvailabilities( + userId: number, + schedule: CreateScheduleInput, + availabilities: CreateAvailabilityInput[] + ) { + const createScheduleData: Prisma.ScheduleCreateInput = { + user: { + connect: { + id: userId, + }, + }, + name: schedule.name, + timeZone: schedule.timeZone, + }; + + if (availabilities.length > 0) { + createScheduleData.availability = { + createMany: { + data: availabilities.map((availability) => { + return { + days: availability.days, + startTime: availability.startTime, + endTime: availability.endTime, + userId, + }; + }), + }, + }; + } + + const createdSchedule = await this.dbWrite.prisma.schedule.create({ + data: { + ...createScheduleData, + }, + include: { + availability: true, + }, + }); + + return createdSchedule; + } + + async getScheduleById(scheduleId: number) { + const schedule = await this.dbRead.prisma.schedule.findUnique({ + where: { + id: scheduleId, + }, + include: { + availability: true, + }, + }); + + return schedule; + } + + async getSchedulesByUserId(userId: number) { + const schedules = await this.dbRead.prisma.schedule.findMany({ + where: { + userId, + }, + include: { + availability: true, + }, + }); + + return schedules; + } + + async updateScheduleWithAvailabilities(scheduleId: number, schedule: UpdateScheduleInput) { + const existingSchedule = await this.dbRead.prisma.schedule.findUnique({ + where: { id: scheduleId }, + }); + + if (!existingSchedule) { + throw new BadRequestException(`Schedule with ID=${scheduleId} not found`); + } + + const updatedScheduleData: Prisma.ScheduleUpdateInput = { + user: { + connect: { + id: existingSchedule.userId, + }, + }, + }; + if (schedule.name) updatedScheduleData.name = schedule.name; + if (schedule.timeZone) updatedScheduleData.timeZone = schedule.timeZone; + + if (schedule.availabilities && schedule.availabilities.length > 0) { + await this.dbWrite.prisma.availability.deleteMany({ + where: { scheduleId }, + }); + + updatedScheduleData.availability = { + createMany: { + data: schedule.availabilities.map((availability) => ({ + ...availability, + userId: existingSchedule.userId, + })), + }, + }; + } + + const updatedSchedule = await this.dbWrite.prisma.schedule.update({ + where: { id: scheduleId }, + data: updatedScheduleData, + include: { + availability: true, + }, + }); + + return updatedSchedule; + } + + async deleteScheduleById(scheduleId: number) { + return this.dbWrite.prisma.schedule.delete({ + where: { + id: scheduleId, + }, + }); + } + + async getUserSchedulesCount(userId: number) { + return this.dbRead.prisma.schedule.count({ + where: { + userId, + }, + }); + } +} diff --git a/apps/api/v2/src/ee/schedules/services/response/response.service.ts b/apps/api/v2/src/ee/schedules/services/response/response.service.ts new file mode 100644 index 00000000000000..c070314e5521e2 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/services/response/response.service.ts @@ -0,0 +1,68 @@ +import { SchedulesRepository } from "@/ee/schedules/schedules.repository"; +import { Injectable } from "@nestjs/common"; +import { User } from "@prisma/client"; + +import { + transformWorkingHoursForClient, + transformAvailabilityForClient, + transformDateOverridesForClient, +} from "@calcom/platform-libraries"; +import type { + ScheduleWithAvailabilities, + ScheduleWithAvailabilitiesForWeb, +} from "@calcom/platform-libraries"; +import { ScheduleResponse, schemaScheduleResponse } from "@calcom/platform-types"; + +@Injectable() +export class ResponseService { + constructor(private readonly schedulesRepository: SchedulesRepository) {} + + async formatSchedule( + forAtom: boolean, + user: User, + schedule: ScheduleWithAvailabilities + ): Promise { + if (forAtom) { + const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id); + return this.transformScheduleForAtom(schedule, usersSchedulesCount, user); + } + + return schemaScheduleResponse.parse(schedule); + } + + async formatSchedules( + forAtom: boolean, + user: User, + schedules: ScheduleWithAvailabilities[] + ): Promise { + if (forAtom) { + const usersSchedulesCount = await this.schedulesRepository.getUserSchedulesCount(user.id); + return Promise.all( + schedules.map((schedule) => this.transformScheduleForAtom(schedule, usersSchedulesCount, user)) + ); + } + return schedules.map((schedule) => schemaScheduleResponse.parse(schedule)); + } + + async transformScheduleForAtom( + schedule: ScheduleWithAvailabilities, + userSchedulesCount: number, + user: Pick + ): Promise { + const timeZone = schedule.timeZone || user.timeZone; + + return { + id: schedule.id, + name: schedule.name, + isManaged: schedule.userId !== user.id, + workingHours: transformWorkingHoursForClient(schedule), + schedule: schedule.availability, + availability: transformAvailabilityForClient(schedule), + timeZone, + dateOverrides: transformDateOverridesForClient(schedule, timeZone), + isDefault: user.defaultScheduleId === schedule.id, + isLastSchedule: userSchedulesCount <= 1, + readOnly: schedule.userId !== user.id, + }; + } +} diff --git a/apps/api/v2/src/ee/schedules/services/schedules.service.ts b/apps/api/v2/src/ee/schedules/services/schedules.service.ts new file mode 100644 index 00000000000000..68e787f6f0ee16 --- /dev/null +++ b/apps/api/v2/src/ee/schedules/services/schedules.service.ts @@ -0,0 +1,103 @@ +import { CreateScheduleInput } from "@/ee/schedules/inputs/create-schedule.input"; +import { UpdateScheduleInput } from "@/ee/schedules/inputs/update-schedule.input"; +import { SchedulesRepository } from "@/ee/schedules/schedules.repository"; +import { AvailabilitiesService } from "@/modules/availabilities/availabilities.service"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; +import { Schedule } from "@prisma/client"; + +import { cityTimezonesHandler } from "@calcom/platform-libraries"; + +@Injectable() +export class SchedulesService { + constructor( + private readonly schedulesRepository: SchedulesRepository, + private readonly availabilitiesService: AvailabilitiesService, + private readonly usersRepository: UsersRepository + ) {} + + async createUserSchedule(userId: number, schedule: CreateScheduleInput) { + const availabilities = schedule.availabilities?.length + ? schedule.availabilities + : [this.availabilitiesService.getDefaultAvailabilityInput()]; + + const createdSchedule = await this.schedulesRepository.createScheduleWithAvailabilities( + userId, + schedule, + availabilities + ); + + if (schedule.isDefault) { + await this.usersRepository.setDefaultSchedule(userId, createdSchedule.id); + } + + return createdSchedule; + } + + async getUserScheduleDefault(userId: number) { + const user = await this.usersRepository.findById(userId); + + if (!user?.defaultScheduleId) return null; + + return this.schedulesRepository.getScheduleById(user.defaultScheduleId); + } + + async getUserSchedule(userId: number, scheduleId: number) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + return existingSchedule; + } + + async getUserSchedules(userId: number) { + return this.schedulesRepository.getSchedulesByUserId(userId); + } + + async updateUserSchedule(userId: number, scheduleId: number, schedule: UpdateScheduleInput) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new NotFoundException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + const updatedSchedule = await this.schedulesRepository.updateScheduleWithAvailabilities( + scheduleId, + schedule + ); + + if (schedule.isDefault) { + await this.usersRepository.setDefaultSchedule(userId, updatedSchedule.id); + } + + return updatedSchedule; + } + + async deleteUserSchedule(userId: number, scheduleId: number) { + const existingSchedule = await this.schedulesRepository.getScheduleById(scheduleId); + + if (!existingSchedule) { + throw new BadRequestException(`Schedule with ID=${scheduleId} does not exist.`); + } + + this.checkUserOwnsSchedule(userId, existingSchedule); + + return this.schedulesRepository.deleteScheduleById(scheduleId); + } + + checkUserOwnsSchedule(userId: number, schedule: Pick) { + if (userId !== schedule.userId) { + throw new ForbiddenException(`User with ID=${userId} does not own schedule with ID=${schedule.id}`); + } + } + + async getSchedulePossibleTimeZones() { + return cityTimezonesHandler(); + } +} diff --git a/apps/api/v2/src/env.ts b/apps/api/v2/src/env.ts new file mode 100644 index 00000000000000..97d51d381338c4 --- /dev/null +++ b/apps/api/v2/src/env.ts @@ -0,0 +1,28 @@ +import { logLevels } from "@/lib/logger"; + +export type Environment = { + NODE_ENV: "development" | "production"; + API_PORT: string; + API_URL: string; + DATABASE_READ_URL: string; + DATABASE_WRITE_URL: string; + NEXTAUTH_SECRET: string; + DATABASE_URL: string; + JWT_SECRET: string; + SENTRY_DNS: string; + LOG_LEVEL: keyof typeof logLevels; + REDIS_URL: string; +}; + +export const getEnv = (key: K, fallback?: Environment[K]): Environment[K] => { + const value = process.env[key] as Environment[K] | undefined; + + if (typeof value === "undefined") { + if (typeof fallback !== "undefined") { + return fallback; + } + throw new Error(`Missing environment variable: ${key}.`); + } + + return value; +}; diff --git a/apps/api/v2/src/filters/http-exception.filter.ts b/apps/api/v2/src/filters/http-exception.filter.ts new file mode 100644 index 00000000000000..3cd808a8e29040 --- /dev/null +++ b/apps/api/v2/src/filters/http-exception.filter.ts @@ -0,0 +1,25 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, Logger } from "@nestjs/common"; + +import { ERROR_STATUS } from "@calcom/platform-constants"; +import { Response } from "@calcom/platform-types"; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("HttpExceptionFilter"); + + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const statusCode = exception.getStatus(); + this.logger.error(`Http Exception Filter: ${exception?.message}`, { + exception, + }); + response.status(statusCode).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { code: exception.name, message: exception.message }, + }); + } +} diff --git a/apps/api/v2/src/filters/prisma-exception.filter.ts b/apps/api/v2/src/filters/prisma-exception.filter.ts new file mode 100644 index 00000000000000..bd59b1a9ddf4ab --- /dev/null +++ b/apps/api/v2/src/filters/prisma-exception.filter.ts @@ -0,0 +1,45 @@ +import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"; +import { Catch, HttpStatus, Logger } from "@nestjs/common"; +import { + PrismaClientInitializationError, + PrismaClientKnownRequestError, + PrismaClientRustPanicError, + PrismaClientUnknownRequestError, + PrismaClientValidationError, +} from "@prisma/client/runtime/library"; + +import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants"; +import { Response } from "@calcom/platform-types"; + +type PrismaError = + | PrismaClientInitializationError + | PrismaClientKnownRequestError + | PrismaClientRustPanicError + | PrismaClientUnknownRequestError + | PrismaClientValidationError; + +@Catch( + PrismaClientInitializationError, + PrismaClientKnownRequestError, + PrismaClientRustPanicError, + PrismaClientUnknownRequestError, + PrismaClientValidationError +) +export class PrismaExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("PrismaExceptionFilter"); + + catch(error: PrismaError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + this.logger.error(`PrismaError: ${error.message}`, { + error, + }); + response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { code: INTERNAL_SERVER_ERROR, message: "There was an error, please try again later." }, + }); + } +} diff --git a/apps/api/v2/src/filters/sentry-exception.filter.ts b/apps/api/v2/src/filters/sentry-exception.filter.ts new file mode 100644 index 00000000000000..5cc106a5b77756 --- /dev/null +++ b/apps/api/v2/src/filters/sentry-exception.filter.ts @@ -0,0 +1,32 @@ +import { ArgumentsHost, Catch, Logger, HttpStatus } from "@nestjs/common"; +import { BaseExceptionFilter } from "@nestjs/core"; +import * as Sentry from "@sentry/node"; + +import { ERROR_STATUS, INTERNAL_SERVER_ERROR } from "@calcom/platform-constants"; +import { Response } from "@calcom/platform-types"; + +@Catch() +export class SentryFilter extends BaseExceptionFilter { + private readonly logger = new Logger("SentryExceptionFilter"); + + handleUnknownError(exception: any, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + this.logger.error(`Sentry Exception Filter: ${exception?.message}`, { + exception, + }); + + // capture if client has been init + if (Boolean(Sentry.getCurrentHub().getClient())) { + Sentry.captureException(exception); + } + response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { code: INTERNAL_SERVER_ERROR, message: "Internal server error." }, + }); + } +} diff --git a/apps/api/v2/src/filters/trpc-exception.filter.ts b/apps/api/v2/src/filters/trpc-exception.filter.ts new file mode 100644 index 00000000000000..7316f49c8cb673 --- /dev/null +++ b/apps/api/v2/src/filters/trpc-exception.filter.ts @@ -0,0 +1,51 @@ +import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common"; + +import { ERROR_STATUS } from "@calcom/platform-constants"; +import { TRPCError } from "@calcom/platform-libraries"; +import { Response } from "@calcom/platform-types"; + +@Catch(TRPCError) +export class TRPCExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("TRPCExceptionFilter"); + + catch(exception: TRPCError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + let statusCode = 500; + switch (exception.code) { + case "UNAUTHORIZED": + statusCode = 401; + break; + case "FORBIDDEN": + statusCode = 403; + break; + case "NOT_FOUND": + statusCode = 404; + break; + case "INTERNAL_SERVER_ERROR": + statusCode = 500; + break; + case "BAD_REQUEST": + statusCode = 400; + break; + case "CONFLICT": + statusCode = 409; + break; + case "TOO_MANY_REQUESTS": + statusCode = 429; + default: + statusCode = 500; + break; + } + this.logger.error(`TRPC Exception Filter: ${exception?.message}`, { + exception, + }); + response.status(statusCode).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { code: exception.name, message: exception.message }, + }); + } +} diff --git a/apps/api/v2/src/filters/zod-exception.filter.ts b/apps/api/v2/src/filters/zod-exception.filter.ts new file mode 100644 index 00000000000000..408ba4aea5a124 --- /dev/null +++ b/apps/api/v2/src/filters/zod-exception.filter.ts @@ -0,0 +1,32 @@ +import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"; +import { Catch, HttpStatus, Logger } from "@nestjs/common"; +import { ZodError } from "zod"; + +import { BAD_REQUEST, ERROR_STATUS } from "@calcom/platform-constants"; +import { Response } from "@calcom/platform-types"; + +@Catch(ZodError) +export class ZodExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("ZodExceptionFilter"); + + catch(error: ZodError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + this.logger.error(`ZodError: ${error.message}`, { + error, + }); + response.status(HttpStatus.BAD_REQUEST).json({ + status: ERROR_STATUS, + timestamp: new Date().toISOString(), + path: request.url, + error: { + code: BAD_REQUEST, + message: error.issues.reduce( + (acc, issue) => `${issue.path.join(".")} - ${issue.message}, ${acc}`, + "" + ), + }, + }); + } +} diff --git a/apps/api/v2/src/lib/api-key/index.ts b/apps/api/v2/src/lib/api-key/index.ts new file mode 100644 index 00000000000000..d292bd06e8bdce --- /dev/null +++ b/apps/api/v2/src/lib/api-key/index.ts @@ -0,0 +1,3 @@ +import { createHash } from "crypto"; + +export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex"); diff --git a/apps/api/v2/src/lib/atoms/decorators/for-atom.decorator.ts b/apps/api/v2/src/lib/atoms/decorators/for-atom.decorator.ts new file mode 100644 index 00000000000000..d07bff7f35d324 --- /dev/null +++ b/apps/api/v2/src/lib/atoms/decorators/for-atom.decorator.ts @@ -0,0 +1,6 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; + +export const ForAtom = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.query.for === "atom"; +}); diff --git a/apps/api/v2/src/lib/logger.ts b/apps/api/v2/src/lib/logger.ts new file mode 100644 index 00000000000000..43378b13bc076e --- /dev/null +++ b/apps/api/v2/src/lib/logger.ts @@ -0,0 +1,50 @@ +import type { LoggerOptions } from "winston"; +import { format, transports } from "winston"; + +const formattedTimestamp = format.timestamp({ + format: "YYYY-MM-DD HH:mm:ss.SSS", +}); + +const colorizer = format.colorize({ + colors: { + fatal: "red", + error: "red", + warn: "yellow", + info: "blue", + debug: "white", + trace: "grey", + }, +}); + +const WINSTON_DEV_FORMAT = format.combine( + format.errors({ stack: true }), + colorizer, + formattedTimestamp, + format.simple() +); +const WINSTON_PROD_FORMAT = format.combine(format.errors({ stack: true }), formattedTimestamp, format.json()); + +export const logLevels = { + fatal: 0, + error: 1, + warn: 2, + info: 3, + debug: 4, + trace: 5, +} as const; + +export const loggerConfig = (): LoggerOptions => { + const isProduction = process.env.NODE_ENV === "production"; + + return { + levels: logLevels, + level: process.env.LOG_LEVEL ?? "info", + format: isProduction ? WINSTON_PROD_FORMAT : WINSTON_DEV_FORMAT, + transports: [new transports.Console()], + exceptionHandlers: [new transports.Console()], + rejectionHandlers: [new transports.Console()], + defaultMeta: { + service: "cal-platform-api", + }, + }; +}; diff --git a/apps/api/v2/src/lib/passport/strategies/types.ts b/apps/api/v2/src/lib/passport/strategies/types.ts new file mode 100644 index 00000000000000..4f9667397e899c --- /dev/null +++ b/apps/api/v2/src/lib/passport/strategies/types.ts @@ -0,0 +1,4 @@ +export class BaseStrategy { + success!: (user: unknown) => void; + error!: (error: Error) => void; +} diff --git a/apps/api/v2/src/lib/response/response.dto.ts b/apps/api/v2/src/lib/response/response.dto.ts new file mode 100644 index 00000000000000..e4ec569e6318f3 --- /dev/null +++ b/apps/api/v2/src/lib/response/response.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class BaseApiResponseDto { + @ApiProperty({ example: "success" }) + @IsString() + @IsNotEmpty() + status: string; + + @ApiProperty({ + description: "The payload of the response, which can be any type of data.", + }) + data: T; + + constructor(status: string, data: T) { + this.status = status; + this.data = data; + } +} + +export class OAuthClientDto { + @ApiProperty({ example: "abc123" }) + @IsString() + clientId!: string; + + @ApiProperty({ example: "secretKey123" }) + @IsString() + clientSecret!: string; +} diff --git a/apps/api/v2/src/main.ts b/apps/api/v2/src/main.ts new file mode 100644 index 00000000000000..2226c477b5eb5f --- /dev/null +++ b/apps/api/v2/src/main.ts @@ -0,0 +1,59 @@ +import type { AppConfig } from "@/config/type"; +import { Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { NestFactory } from "@nestjs/core"; +import type { NestExpressApplication } from "@nestjs/platform-express"; +import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; +import "dotenv/config"; +import * as fs from "fs"; +import { Server } from "http"; +import { WinstonModule } from "nest-winston"; + +import { bootstrap } from "./app"; +import { AppModule } from "./app.module"; +import { loggerConfig } from "./lib/logger"; + +const run = async () => { + const app = await NestFactory.create(AppModule, { + logger: WinstonModule.createLogger(loggerConfig()), + }); + + const logger = new Logger("App"); + + try { + bootstrap(app); + const port = app.get(ConfigService).get("api.port", { infer: true }); + generateSwagger(app); + await app.listen(port); + logger.log(`Application started on port: ${port}`); + } catch (error) { + logger.error("Application crashed", { + error, + }); + } +}; + +async function generateSwagger(app: NestExpressApplication) { + const logger = new Logger("App"); + logger.log(`Generating Swagger documentation...\n`); + + const config = new DocumentBuilder().setTitle("Cal.com v2 API").build(); + + const document = SwaggerModule.createDocument(app, config); + + const outputFile = "./swagger/documentation.json"; + + if (fs.existsSync(outputFile)) { + fs.unlinkSync(outputFile); + } + + fs.writeFileSync(outputFile, JSON.stringify(document, null, 2), { encoding: "utf8" }); + SwaggerModule.setup("docs", app, document); + + logger.log(`Swagger documentation available in the "/docs" endpoint\n`); +} + +run().catch((error: Error) => { + console.error("Failed to start Cal Platform API", { error: error.stack }); + process.exit(1); +}); diff --git a/apps/api/v2/src/modules/api-key/api-key.module.ts b/apps/api/v2/src/modules/api-key/api-key.module.ts new file mode 100644 index 00000000000000..6c3d86ba058c7c --- /dev/null +++ b/apps/api/v2/src/modules/api-key/api-key.module.ts @@ -0,0 +1,10 @@ +import { ApiKeyService } from "@/modules/api-key/api-key.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [ApiKeyService], + exports: [ApiKeyService], +}) +export class ApiKeyModule {} diff --git a/apps/api/v2/src/modules/api-key/api-key.service.ts b/apps/api/v2/src/modules/api-key/api-key.service.ts new file mode 100644 index 00000000000000..c381fa99be0dc6 --- /dev/null +++ b/apps/api/v2/src/modules/api-key/api-key.service.ts @@ -0,0 +1,25 @@ +import { hashAPIKey } from "@/lib/api-key"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; +import type { Request } from "express"; + +@Injectable() +export class ApiKeyService { + constructor(private readonly dbRead: PrismaReadService) {} + + async retrieveApiKey(request: Request) { + const apiKey = request.get("Authorization")?.replace("Bearer ", ""); + + if (!apiKey) { + return null; + } + + const hashedKey = hashAPIKey(apiKey.replace("cal_", "")); + + return this.dbRead.prisma.apiKey.findUniqueOrThrow({ + where: { + hashedKey, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/apps/apps.module.ts b/apps/api/v2/src/modules/apps/apps.module.ts new file mode 100644 index 00000000000000..587b18ce8d91cf --- /dev/null +++ b/apps/api/v2/src/modules/apps/apps.module.ts @@ -0,0 +1,14 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +@Module({ + imports: [PrismaModule, TokensModule], + providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository], + exports: [], +}) +export class AppsModule {} diff --git a/apps/api/v2/src/modules/apps/apps.repository.ts b/apps/api/v2/src/modules/apps/apps.repository.ts new file mode 100644 index 00000000000000..c2a74e1421d304 --- /dev/null +++ b/apps/api/v2/src/modules/apps/apps.repository.ts @@ -0,0 +1,13 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { App } from "@prisma/client"; + +@Injectable() +export class AppsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getAppBySlug(slug: string): Promise { + return await this.dbRead.prisma.app.findUnique({ where: { slug } }); + } +} diff --git a/apps/api/v2/src/modules/apps/services/gcal.service.ts b/apps/api/v2/src/modules/apps/services/gcal.service.ts new file mode 100644 index 00000000000000..45b3406363091c --- /dev/null +++ b/apps/api/v2/src/modules/apps/services/gcal.service.ts @@ -0,0 +1,27 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { google } from "googleapis"; +import { z } from "zod"; + +@Injectable() +export class GcalService { + private logger = new Logger("GcalService"); + + constructor(private readonly appsRepository: AppsRepository) {} + + async getOAuthClient(redirectUri: string) { + this.logger.log("Getting Google Calendar OAuth Client"); + const app = await this.appsRepository.getAppBySlug("google-calendar"); + + if (!app) { + throw new NotFoundException(); + } + + const { client_id, client_secret } = z + .object({ client_id: z.string(), client_secret: z.string() }) + .parse(app.keys); + + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri); + return oAuth2Client; + } +} diff --git a/apps/api/v2/src/modules/auth/auth.module.ts b/apps/api/v2/src/modules/auth/auth.module.ts new file mode 100644 index 00000000000000..aae58abb100e8a --- /dev/null +++ b/apps/api/v2/src/modules/auth/auth.module.ts @@ -0,0 +1,26 @@ +import { ApiKeyModule } from "@/modules/api-key/api-key.module"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; +import { AccessTokenStrategy } from "@/modules/auth/strategies/access-token/access-token.strategy"; +import { ApiKeyAuthStrategy } from "@/modules/auth/strategies/api-key-auth/api-key-auth.strategy"; +import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; +import { PassportModule } from "@nestjs/passport"; + +@Module({ + imports: [PassportModule, ApiKeyModule, UsersModule, MembershipsModule, TokensModule], + providers: [ + ApiKeyAuthStrategy, + NextAuthGuard, + NextAuthStrategy, + AccessTokenGuard, + AccessTokenStrategy, + OAuthFlowService, + ], + exports: [NextAuthGuard, AccessTokenGuard], +}) +export class AuthModule {} diff --git a/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts b/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts new file mode 100644 index 00000000000000..28ec9d5e3a34ed --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts @@ -0,0 +1,27 @@ +import { ExecutionContext } from "@nestjs/common"; +import { createParamDecorator } from "@nestjs/common"; +import { User } from "@prisma/client"; + +export const GetUser = createParamDecorator((data, ctx) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user as User; + + if (!user) { + throw new Error("GetUser decorator : User not found"); + } + + if (Array.isArray(data)) { + return data.reduce((prev, curr) => { + return { + ...prev, + [curr]: request.user[curr], + }; + }, {}); + } + + if (data) { + return request.user[data]; + } + + return user; +}); diff --git a/apps/api/v2/src/modules/auth/decorators/permissions/permissions.decorator.ts b/apps/api/v2/src/modules/auth/decorators/permissions/permissions.decorator.ts new file mode 100644 index 00000000000000..425a3006daa6ea --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/permissions/permissions.decorator.ts @@ -0,0 +1,5 @@ +import { Reflector } from "@nestjs/core"; + +import { PERMISSIONS } from "@calcom/platform-constants"; + +export const Permissions = Reflector.createDecorator<(typeof PERMISSIONS)[number][]>(); diff --git a/apps/api/v2/src/modules/auth/decorators/roles/roles.decorator.ts b/apps/api/v2/src/modules/auth/decorators/roles/roles.decorator.ts new file mode 100644 index 00000000000000..d1a4511770d1f0 --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/roles/roles.decorator.ts @@ -0,0 +1,4 @@ +import { Reflector } from "@nestjs/core"; +import { MembershipRole } from "@prisma/client"; + +export const Roles = Reflector.createDecorator(); diff --git a/apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts b/apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts new file mode 100644 index 00000000000000..2543c644549ae1 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts @@ -0,0 +1,7 @@ +import { AuthGuard } from "@nestjs/passport"; + +export class AccessTokenGuard extends AuthGuard("access-token") { + constructor() { + super(); + } +} diff --git a/apps/api/v2/src/modules/auth/guards/access-token/token-expired.exception.ts b/apps/api/v2/src/modules/auth/guards/access-token/token-expired.exception.ts new file mode 100644 index 00000000000000..1e3f4eea837ec1 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/access-token/token-expired.exception.ts @@ -0,0 +1,9 @@ +import { HttpException } from "@nestjs/common"; + +import { ACCESS_TOKEN_EXPIRED, HTTP_CODE_TOKEN_EXPIRED } from "@calcom/platform-constants"; + +export class TokenExpiredException extends HttpException { + constructor() { + super(ACCESS_TOKEN_EXPIRED, HTTP_CODE_TOKEN_EXPIRED); + } +} diff --git a/apps/api/v2/src/modules/auth/guards/next-auth/next-auth.guard.ts b/apps/api/v2/src/modules/auth/guards/next-auth/next-auth.guard.ts new file mode 100644 index 00000000000000..a2597709da7e8d --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/next-auth/next-auth.guard.ts @@ -0,0 +1,7 @@ +import { AuthGuard } from "@nestjs/passport"; + +export class NextAuthGuard extends AuthGuard("next-auth") { + constructor() { + super(); + } +} diff --git a/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts b/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts new file mode 100644 index 00000000000000..1063cec335489f --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts @@ -0,0 +1,32 @@ +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +@Injectable() +export class OrganizationRolesGuard implements CanActivate { + constructor(private reflector: Reflector, private membershipRepository: MembershipsRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredRoles = this.reflector.get(Roles, context.getHandler()); + + if (!requiredRoles?.length || !Object.keys(requiredRoles)?.length) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user: UserWithProfile = request.user; + const organizationId = user?.movedToProfile?.organizationId || user?.organizationId; + + if (!user || !organizationId) { + return false; + } + + const membership = await this.membershipRepository.findOrgUserMembership(organizationId, user.id); + const isAccepted = membership.accepted; + const hasRequiredRole = requiredRoles.includes(membership.role); + + return isAccepted && hasRequiredRole; + } +} diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts new file mode 100644 index 00000000000000..0bd07651deeb6f --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts @@ -0,0 +1,99 @@ +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { createMock } from "@golevelup/ts-jest"; +import { ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +import { APPS_WRITE, SCHEDULE_READ, SCHEDULE_WRITE } from "@calcom/platform-constants"; + +import { PermissionsGuard } from "./permissions.guard"; + +describe("PermissionsGuard", () => { + let guard: PermissionsGuard; + let reflector: Reflector; + + beforeEach(async () => { + reflector = new Reflector(); + guard = new PermissionsGuard(reflector, createMock()); + }); + + it("should be defined", () => { + expect(guard).toBeDefined(); + }); + + describe("when access token is missing", () => { + it("should return false", async () => { + const mockContext = createMockExecutionContext({}); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(0); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + }); + + describe("when access token is provided", () => { + it("should return true for valid permissions", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return true for multiple valid permissions", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + oAuthClientPermissions |= SCHEDULE_READ; + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return true for empty Permissions decorator", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false for invalid permissions", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= APPS_WRITE; + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + it("should return false for a missing permission", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + }); + + function createMockExecutionContext(headers: Record): ExecutionContext { + return createMock({ + switchToHttp: () => ({ + getRequest: () => ({ + headers, + get: (headerName: string) => headers[headerName], + }), + }), + }); + } +}); diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts new file mode 100644 index 00000000000000..4fec7f87009d03 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts @@ -0,0 +1,39 @@ +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; + +import { hasPermissions } from "@calcom/platform-utils"; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private reflector: Reflector, private tokensRepository: TokensRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredPermissions = this.reflector.get(Permissions, context.getHandler()); + + if (!requiredPermissions?.length || !Object.keys(requiredPermissions)?.length) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const accessToken = request.get("Authorization")?.replace("Bearer ", ""); + + if (!accessToken) { + return false; + } + + const oAuthClientPermissions = await this.getOAuthClientPermissions(accessToken); + + if (!oAuthClientPermissions) { + return false; + } + + return hasPermissions(oAuthClientPermissions, [...requiredPermissions]); + } + + async getOAuthClientPermissions(accessToken: string) { + const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken); + return oAuthClient?.permissions; + } +} diff --git a/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts b/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts new file mode 100644 index 00000000000000..53e055d2d29fc3 --- /dev/null +++ b/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts @@ -0,0 +1,58 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import type { Request } from "express"; + +import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants"; + +@Injectable() +export class AccessTokenStrategy extends PassportStrategy(BaseStrategy, "access-token") { + constructor( + private readonly oauthFlowService: OAuthFlowService, + private readonly tokensRepository: TokensRepository, + private readonly userRepository: UsersRepository + ) { + super(); + } + + async authenticate(request: Request) { + try { + const accessToken = request.get("Authorization")?.replace("Bearer ", ""); + const requestOrigin = request.get("Origin"); + + if (!accessToken) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + await this.oauthFlowService.validateAccessToken(accessToken); + + const client = await this.tokensRepository.getAccessTokenClient(accessToken); + if (!client) { + throw new UnauthorizedException("OAuth client not found given the access token"); + } + + if (requestOrigin && !client.redirectUris.some((uri) => uri.startsWith(requestOrigin))) { + throw new UnauthorizedException("Invalid request origin"); + } + + const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!ownerId) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId); + + if (!user) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + return this.success(user); + } catch (error) { + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts new file mode 100644 index 00000000000000..40c7f1c970ca00 --- /dev/null +++ b/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts @@ -0,0 +1,39 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { ApiKeyService } from "@/modules/api-key/api-key.service"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import type { Request } from "express"; + +@Injectable() +export class ApiKeyAuthStrategy extends PassportStrategy(BaseStrategy, "api-key") { + constructor( + private readonly apiKeyService: ApiKeyService, + private readonly userRepository: UsersRepository + ) { + super(); + } + + async authenticate(req: Request) { + try { + const apiKey = await this.apiKeyService.retrieveApiKey(req); + + if (!apiKey) { + throw new UnauthorizedException("Authorization token is missing."); + } + + if (apiKey.expiresAt && new Date() > apiKey.expiresAt) { + throw new UnauthorizedException("The API key is expired."); + } + + const user = await this.userRepository.findById(apiKey.userId); + if (!user) { + throw new NotFoundException("User not found."); + } + + this.success(user); + } catch (error) { + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts new file mode 100644 index 00000000000000..1827e59e9093bd --- /dev/null +++ b/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts @@ -0,0 +1,38 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import type { Request } from "express"; +import { getToken } from "next-auth/jwt"; + +@Injectable() +export class NextAuthStrategy extends PassportStrategy(BaseStrategy, "next-auth") { + constructor(private readonly userRepository: UsersRepository, private readonly config: ConfigService) { + super(); + } + + async authenticate(req: Request) { + try { + const nextAuthSecret = this.config.get("next.authSecret", { infer: true }); + const payload = await getToken({ req, secret: nextAuthSecret }); + + if (!payload) { + throw new UnauthorizedException("Authentication token is missing or invalid."); + } + + if (!payload.email) { + throw new UnauthorizedException("Email not found in the authentication token."); + } + + const user = await this.userRepository.findByEmail(payload.email); + if (!user) { + throw new UnauthorizedException("User associated with the authentication token email not found."); + } + + return this.success(user); + } catch (error) { + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/src/modules/availabilities/availabilities.module.ts b/apps/api/v2/src/modules/availabilities/availabilities.module.ts new file mode 100644 index 00000000000000..f3fe35bf6a7314 --- /dev/null +++ b/apps/api/v2/src/modules/availabilities/availabilities.module.ts @@ -0,0 +1,10 @@ +import { AvailabilitiesService } from "@/modules/availabilities/availabilities.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [AvailabilitiesService], + exports: [AvailabilitiesService], +}) +export class AvailabilitiesModule {} diff --git a/apps/api/v2/src/modules/availabilities/availabilities.service.ts b/apps/api/v2/src/modules/availabilities/availabilities.service.ts new file mode 100644 index 00000000000000..fc7dc14ec494c3 --- /dev/null +++ b/apps/api/v2/src/modules/availabilities/availabilities.service.ts @@ -0,0 +1,16 @@ +import { CreateAvailabilityInput } from "@/modules/availabilities/inputs/create-availability.input"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class AvailabilitiesService { + getDefaultAvailabilityInput(): CreateAvailabilityInput { + const startTime = new Date(new Date().setUTCHours(9, 0, 0, 0)); + const endTime = new Date(new Date().setUTCHours(17, 0, 0, 0)); + + return { + days: [1, 2, 3, 4, 5], + startTime, + endTime, + }; + } +} diff --git a/apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts b/apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts new file mode 100644 index 00000000000000..a3050ae334c16d --- /dev/null +++ b/apps/api/v2/src/modules/availabilities/inputs/create-availability.input.ts @@ -0,0 +1,41 @@ +import { BadRequestException } from "@nestjs/common"; +import { Transform, TransformFnParams } from "class-transformer"; +import { IsArray, IsDate, IsNumber } from "class-validator"; + +export class CreateAvailabilityInput { + @IsArray() + @IsNumber({}, { each: true }) + days!: number[]; + + @IsDate() + @Transform(({ value, key }: TransformFnParams) => transformStringToDate(value, key)) + startTime!: Date; + + @IsDate() + @Transform(({ value, key }: TransformFnParams) => transformStringToDate(value, key)) + endTime!: Date; +} + +function transformStringToDate(value: string, key: string): Date { + const parts = value.split(":"); + + if (parts.length !== 3) { + throw new BadRequestException(`Invalid ${key} format. Expected format: HH:MM:SS. Received ${value}`); + } + + const [hours, minutes, seconds] = parts.map(Number); + + if (hours < 0 || hours > 23) { + throw new BadRequestException(`Invalid ${key} hours. Expected value between 0 and 23`); + } + + if (minutes < 0 || minutes > 59) { + throw new BadRequestException(`Invalid ${key} minutes. Expected value between 0 and 59`); + } + + if (seconds < 0 || seconds > 59) { + throw new BadRequestException(`Invalid ${key} seconds. Expected value between 0 and 59`); + } + + return new Date(new Date().setUTCHours(hours, minutes, seconds, 0)); +} diff --git a/apps/api/v2/src/modules/credentials/credential.module.ts b/apps/api/v2/src/modules/credentials/credential.module.ts new file mode 100644 index 00000000000000..c837e49328880d --- /dev/null +++ b/apps/api/v2/src/modules/credentials/credential.module.ts @@ -0,0 +1,9 @@ +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [], + providers: [CredentialsRepository], + exports: [CredentialsRepository], +}) +export class CredentialsModule {} diff --git a/apps/api/v2/src/modules/credentials/credentials.repository.ts b/apps/api/v2/src/modules/credentials/credentials.repository.ts new file mode 100644 index 00000000000000..6b7a8978761440 --- /dev/null +++ b/apps/api/v2/src/modules/credentials/credentials.repository.ts @@ -0,0 +1,54 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants"; + +@Injectable() +export class CredentialsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + createAppCredential(type: keyof typeof APPS_TYPE_ID_MAPPING, key: Prisma.InputJsonValue, userId: number) { + return this.dbWrite.prisma.credential.create({ + data: { + type, + key, + userId, + appId: APPS_TYPE_ID_MAPPING[type], + }, + }); + } + getByTypeAndUserId(type: string, userId: number) { + return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } }); + } + + getUserCredentialsByIds(userId: number, credentialIds: number[]) { + return this.dbRead.prisma.credential.findMany({ + where: { + id: { + in: credentialIds, + }, + userId: userId, + }, + select: { + id: true, + type: true, + key: true, + userId: true, + teamId: true, + appId: true, + invalid: true, + user: { + select: { + email: true, + }, + }, + }, + }); + } +} + +export type CredentialsWithUserEmail = Awaited< + ReturnType +>; diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts new file mode 100644 index 00000000000000..6f3b72d8e14893 --- /dev/null +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -0,0 +1,15 @@ +import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module"; +import { EventsModule } from "@/modules/events/events.module"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [EventsModule, OAuthClientModule, PlatformEndpointsModule], +}) +export class EndpointsModule implements NestModule { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + configure(_consumer: MiddlewareConsumer) { + // TODO: apply ratelimits + } +} diff --git a/apps/api/v2/src/modules/events/controllers/events.controller.ts b/apps/api/v2/src/modules/events/controllers/events.controller.ts new file mode 100644 index 00000000000000..b1c0c2567dcbd9 --- /dev/null +++ b/apps/api/v2/src/modules/events/controllers/events.controller.ts @@ -0,0 +1,38 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Controller, Get, NotFoundException, InternalServerErrorException, Query } from "@nestjs/common"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { getPublicEvent } from "@calcom/platform-libraries"; +import type { PublicEventType } from "@calcom/platform-libraries"; +import { ApiResponse, GetPublicEventInput } from "@calcom/platform-types"; +import { PrismaClient } from "@calcom/prisma"; + +@Controller({ + path: "events", + version: "2", +}) +export class EventsController { + constructor(private readonly prismaReadService: PrismaReadService) {} + + @Get("/public") + async getPublicEvent(@Query() queryParams: GetPublicEventInput): Promise> { + try { + const event = await getPublicEvent( + queryParams.username, + queryParams.eventSlug, + queryParams.isTeamEvent, + queryParams.org || null, + this.prismaReadService.prisma as unknown as PrismaClient + ); + return { + data: event, + status: SUCCESS_STATUS, + }; + } catch (err) { + if (err instanceof Error) { + throw new NotFoundException(err.message); + } + } + throw new InternalServerErrorException("Could not find public event."); + } +} diff --git a/apps/api/v2/src/modules/events/events.module.ts b/apps/api/v2/src/modules/events/events.module.ts new file mode 100644 index 00000000000000..57c8053271168c --- /dev/null +++ b/apps/api/v2/src/modules/events/events.module.ts @@ -0,0 +1,9 @@ +import { EventsController } from "@/modules/events/controllers/events.controller"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + controllers: [EventsController], +}) +export class EventsModule {} diff --git a/apps/api/v2/src/modules/jwt/jwt.module.ts b/apps/api/v2/src/modules/jwt/jwt.module.ts new file mode 100644 index 00000000000000..ef12816e2fdf73 --- /dev/null +++ b/apps/api/v2/src/modules/jwt/jwt.module.ts @@ -0,0 +1,12 @@ +import { getEnv } from "@/env"; +import { JwtService } from "@/modules/jwt/jwt.service"; +import { Global, Module } from "@nestjs/common"; +import { JwtModule as NestJwtModule } from "@nestjs/jwt"; + +@Global() +@Module({ + imports: [NestJwtModule.register({ secret: getEnv("JWT_SECRET") })], + providers: [JwtService], + exports: [JwtService], +}) +export class JwtModule {} diff --git a/apps/api/v2/src/modules/jwt/jwt.service.ts b/apps/api/v2/src/modules/jwt/jwt.service.ts new file mode 100644 index 00000000000000..c50e53ddb4d9e9 --- /dev/null +++ b/apps/api/v2/src/modules/jwt/jwt.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@nestjs/common"; +import { JwtService as NestJwtService } from "@nestjs/jwt"; + +@Injectable() +export class JwtService { + constructor(private readonly nestJwtService: NestJwtService) {} + + signAccessToken(payload: Payload) { + const accessToken = this.sign({ type: "access_token", ...payload }); + return accessToken; + } + + signRefreshToken(payload: Payload) { + const refreshToken = this.sign({ type: "refresh_token", ...payload }); + return refreshToken; + } + + sign(payload: Payload) { + const issuedAtTime = this.getIssuedAtTime(); + + const token = this.nestJwtService.sign({ ...payload, iat: issuedAtTime }); + return token; + } + + getIssuedAtTime() { + // divided by 1000 because iat (issued at time) is in seconds (not milliseconds) as informed by JWT speficication + return Math.floor(Date.now() / 1000); + } +} + +type Payload = Record; diff --git a/apps/api/v2/src/modules/memberships/memberships.module.ts b/apps/api/v2/src/modules/memberships/memberships.module.ts new file mode 100644 index 00000000000000..418e1f6d1605d7 --- /dev/null +++ b/apps/api/v2/src/modules/memberships/memberships.module.ts @@ -0,0 +1,10 @@ +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [MembershipsRepository], + exports: [MembershipsRepository], +}) +export class MembershipsModule {} diff --git a/apps/api/v2/src/modules/memberships/memberships.repository.ts b/apps/api/v2/src/modules/memberships/memberships.repository.ts new file mode 100644 index 00000000000000..2a8e7d1a47598e --- /dev/null +++ b/apps/api/v2/src/modules/memberships/memberships.repository.ts @@ -0,0 +1,33 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class MembershipsRepository { + constructor(private readonly dbRead: PrismaReadService) {} + + async findOrgUserMembership(organizationId: number, userId: number) { + const membership = await this.dbRead.prisma.membership.findUniqueOrThrow({ + where: { + userId_teamId: { + userId: userId, + teamId: organizationId, + }, + }, + }); + + return membership; + } + + async isUserOrganizationAdmin(userId: number, organizationId: number) { + const adminMembership = await this.dbRead.prisma.membership.findFirst({ + where: { + userId, + teamId: organizationId, + accepted: true, + OR: [{ role: "ADMIN" }, { role: "OWNER" }], + }, + }); + + return !!adminMembership; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts new file mode 100644 index 00000000000000..d1abb8d3aade5d --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts @@ -0,0 +1,228 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/constants/constants"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { + CreateUserResponse, + UserReturned, +} from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller"; +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; +import { UpdateUserInput } from "@/modules/users/inputs/update-user.input"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User } from "@prisma/client"; +import * as request from "supertest"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +const CLIENT_REDIRECT_URI = "http://localhost:4321"; + +describe("OAuth Client Users Endpoints", () => { + describe("Not authenticated", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + describe("secret header not set", () => { + it(`/POST`, () => { + return request(app.getHttpServer()) + .post("/api/v2/oauth-clients/100/users") + .send({ email: "bob@gmail.com" }) + .expect(401); + }); + }); + + describe("Bearer access token not set", () => { + it(`/GET/:id`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients/100/users/200").expect(401); + }); + it(`/PUT/:id`, () => { + return request(app.getHttpServer()).patch("/api/v2/oauth-clients/100/users/200").expect(401); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete("/api/v2/oauth-clients/100/users/200").expect(401); + }); + }); + + afterAll(async () => { + await app.close(); + }); + }); + + describe("User Authenticated", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + + let postResponseData: CreateUserResponse; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + organization = await teamRepositoryFixture.create({ name: "organization" }); + oAuthClient = await createOAuthClient(organization.id); + + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + }); + + it(`should fail /POST with incorrect timeZone`, async () => { + const requestBody: CreateUserInput = { + email: "oauth-client-user@gmail.com", + timeZone: "incorrect-time-zone", + }; + + await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set("x-cal-secret-key", oAuthClient.secret) + .send(requestBody) + .expect(400); + }); + + it(`/POST`, async () => { + const requestBody: CreateUserInput = { + email: "oauth-client-user@gmail.com", + }; + + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set("x-cal-secret-key", oAuthClient.secret) + .send(requestBody) + .expect(201); + + const responseBody: ApiSuccessResponse<{ + user: Omit; + accessToken: string; + refreshToken: string; + }> = response.body; + + postResponseData = responseBody.data; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.user.email).toEqual(requestBody.email); + expect(responseBody.data.accessToken).toBeDefined(); + expect(responseBody.data.refreshToken).toBeDefined(); + + await userConnectedToOAuth(responseBody.data.user.email); + await userHasDefaultEventTypes(responseBody.data.user.id); + }); + + async function userConnectedToOAuth(userEmail: string) { + const oAuthUsers = await oauthClientRepositoryFixture.getUsers(oAuthClient.id); + const newOAuthUser = oAuthUsers?.find((user) => user.email === userEmail); + + expect(oAuthUsers?.length).toEqual(1); + expect(newOAuthUser?.email).toEqual(userEmail); + } + + async function userHasDefaultEventTypes(userId: number) { + const defaultEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId); + + expect(defaultEventTypes?.length).toEqual(2); + expect( + defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.thirtyMinutes.length) + ).toBeTruthy(); + expect( + defaultEventTypes?.find((eventType) => eventType.length === DEFAULT_EVENT_TYPES.sixtyMinutes.length) + ).toBeTruthy(); + } + + it(`/GET/:id`, async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`) + .set("Authorization", `Bearer ${postResponseData.accessToken}`) + .set("Origin", `${CLIENT_REDIRECT_URI}`) + .expect(200); + + const responseBody: ApiSuccessResponse = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.email).toEqual(postResponseData.user.email); + }); + + it(`/PUT/:id`, async () => { + const userUpdatedEmail = "pineapple-pizza@gmail.com"; + const body: UpdateUserInput = { email: userUpdatedEmail }; + + const response = await request(app.getHttpServer()) + .patch(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`) + .set("Authorization", `Bearer ${postResponseData.accessToken}`) + .set("Origin", `${CLIENT_REDIRECT_URI}`) + .send(body) + .expect(200); + + const responseBody: ApiSuccessResponse> = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.email).toEqual(userUpdatedEmail); + }); + + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()) + .delete(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`) + .set("Authorization", `Bearer ${postResponseData.accessToken}`) + .set("Origin", `${CLIENT_REDIRECT_URI}`) + .expect(200); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts new file mode 100644 index 00000000000000..56faad2d37ec1c --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts @@ -0,0 +1,178 @@ +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; +import { UpdateUserInput } from "@/modules/users/inputs/update-user.input"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { + Body, + Controller, + Post, + Logger, + UseGuards, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Patch, + BadRequestException, + Delete, +} from "@nestjs/common"; +import { User } from "@prisma/client"; +import * as crypto from "crypto"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ApiResponse } from "@calcom/platform-types"; + +@Controller({ + path: "oauth-clients/:clientId/users", + version: "2", +}) +export class OAuthClientUsersController { + private readonly logger = new Logger("UserController"); + + constructor( + private readonly userRepository: UsersRepository, + private readonly oAuthClientUsersService: OAuthClientUsersService, + private readonly oauthRepository: OAuthClientRepository + ) {} + + @Post("/") + @UseGuards(OAuthClientCredentialsGuard) + async createUser( + @Param("clientId") oAuthClientId: string, + @Body() body: CreateUserInput + ): Promise> { + this.logger.log( + `Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}` + ); + const existingUser = await this.userRepository.findByEmail(body.email); + + if (existingUser) { + throw new BadRequestException("A user with the provided email already exists."); + } + const client = await this.oauthRepository.getOAuthClient(oAuthClientId); + + const { user, tokens } = await this.oAuthClientUsersService.createOauthClientUser( + oAuthClientId, + body, + client?.organizationId + ); + + return { + status: SUCCESS_STATUS, + data: { + user: { + id: user.id, + email: user.email, + username: user.username, + }, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }, + }; + } + + @Get("/:userId") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard) + async getUserById( + // @Param("clientId") is added to generate OpenAPI schema correctly: clientId is in @Controller path, and unless + // also added here as @Param, then it does not appear in OpenAPI schema. + @Param("clientId") _: string, + @GetUser("id") accessTokenUserId: number, + @Param("userId") userId: number + ): Promise> { + if (accessTokenUserId !== userId) { + throw new BadRequestException("userId parameter does not match access token"); + } + + const user = await this.userRepository.findById(userId); + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found.`); + } + + return { + status: SUCCESS_STATUS, + data: { + id: user.id, + email: user.email, + username: user.username, + }, + }; + } + + @Patch("/:userId") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard) + async updateUser( + // @Param("clientId") is added to generate OpenAPI schema correctly: clientId is in @Controller path, and unless + // also added here as @Param, then it does not appear in OpenAPI schema. + @Param("clientId") _: string, + @GetUser("id") accessTokenUserId: number, + @Param("userId") userId: number, + @Body() body: UpdateUserInput + ): Promise> { + if (accessTokenUserId !== userId) { + throw new BadRequestException("userId parameter does not match access token"); + } + + this.logger.log(`Updating user with ID ${userId}: ${JSON.stringify(body, null, 2)}`); + + const user = await this.userRepository.update(userId, body); + + return { + status: SUCCESS_STATUS, + data: { + id: user.id, + email: user.email, + username: user.username, + }, + }; + } + + @Delete("/:userId") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard) + async deleteUser( + // @Param("clientId") is added to generate OpenAPI schema correctly: clientId is in @Controller path, and unless + // also added here as @Param, then it does not appear in OpenAPI schema. + @Param("clientId") _: string, + @GetUser("id") accessTokenUserId: number, + @Param("userId") userId: number + ): Promise> { + if (accessTokenUserId !== userId) { + throw new BadRequestException("userId parameter does not match access token"); + } + + this.logger.log(`Deleting user with ID: ${userId}`); + + const existingUser = await this.userRepository.findById(userId); + + if (!existingUser) { + throw new NotFoundException(`User with ${userId} does not exist`); + } + + if (existingUser.username) { + throw new BadRequestException("Cannot delete a non manually-managed user"); + } + + const user = await this.userRepository.delete(userId); + + return { + status: SUCCESS_STATUS, + data: { + id: user.id, + email: user.email, + username: user.username, + }, + }; + } +} + +export type UserReturned = Pick; + +export type CreateUserResponse = { user: UserReturned; accessToken: string; refreshToken: string }; diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts new file mode 100644 index 00000000000000..0bebd3c54ed9b8 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts @@ -0,0 +1,311 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { AuthModule } from "@/modules/auth/auth.module"; +import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy"; +import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { Membership, PlatformOAuthClient, Team, User } from "@prisma/client"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { NextAuthMockStrategy } from "test/mocks/next-auth-mock.strategy"; +import { withNextAuth } from "test/utils/withNextAuth"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { CreateOAuthClientInput } from "@calcom/platform-types"; +import { ApiSuccessResponse } from "@calcom/platform-types"; + +describe("OAuth Clients Endpoints", () => { + describe("User Not Authenticated", () => { + let appWithoutAuth: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }).compile(); + appWithoutAuth = moduleRef.createNestApplication(); + bootstrap(appWithoutAuth as NestExpressApplication); + await appWithoutAuth.init(); + }); + + it(`/GET`, () => { + return request(appWithoutAuth.getHttpServer()).get("/api/v2/oauth-clients").expect(401); + }); + it(`/GET/:id`, () => { + return request(appWithoutAuth.getHttpServer()).get("/api/v2/oauth-clients/1234").expect(401); + }); + it(`/POST`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth-clients").expect(401); + }); + it(`/PUT/:id`, () => { + return request(appWithoutAuth.getHttpServer()).patch("/api/v2/oauth-clients/1234").expect(401); + }); + it(`/DELETE/:id`, () => { + return request(appWithoutAuth.getHttpServer()).delete("/api/v2/oauth-clients/1234").expect(401); + }); + + afterAll(async () => { + await appWithoutAuth.close(); + }); + }); + + describe("User Is Authenticated", () => { + let usersFixtures: UserRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let teamFixtures: TeamRepositoryFixture; + let user: User; + let org: Team; + let app: INestApplication; + const userEmail = "test-e2e@api.com"; + + beforeAll(async () => { + const moduleRef = await withNextAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }) + ).compile(); + const strategy = moduleRef.get(NextAuthStrategy); + expect(strategy).toBeInstanceOf(NextAuthMockStrategy); + usersFixtures = new UserRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + teamFixtures = new TeamRepositoryFixture(moduleRef); + user = await usersFixtures.create({ + email: userEmail, + }); + org = await teamFixtures.create({ + name: "apiOrg", + metadata: { + isOrganization: true, + orgAutoAcceptEmail: "api.com", + isOrganizationVerified: true, + isOrganizationConfigured: true, + }, + }); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + describe("User is not in an organization", () => { + it(`/GET`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(403); + }); + it(`/GET/:id`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients/1234").expect(403); + }); + it(`/POST`, () => { + return request(app.getHttpServer()).post("/api/v2/oauth-clients").expect(403); + }); + it(`/PUT/:id`, () => { + return request(app.getHttpServer()).patch("/api/v2/oauth-clients/1234").expect(403); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete("/api/v2/oauth-clients/1234").expect(403); + }); + }); + + describe("User is part of an organization as Member", () => { + let membership: Membership; + beforeAll(async () => { + membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true); + }); + + it(`/GET`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(200); + }); + it(`/GET/:id - oAuth client does not exist`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients/1234").expect(404); + }); + it(`/POST`, () => { + return request(app.getHttpServer()).post("/api/v2/oauth-clients").expect(403); + }); + it(`/PUT/:id`, () => { + return request(app.getHttpServer()).patch("/api/v2/oauth-clients/1234").expect(403); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete("/api/v2/oauth-clients/1234").expect(403); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + }); + }); + + describe("User is part of an organization as Admin", () => { + let membership: Membership; + let client: { clientId: string; clientSecret: string }; + const oAuthClientName = "test-oauth-client-admin"; + + beforeAll(async () => { + membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + }); + + it(`/POST`, () => { + const body: CreateOAuthClientInput = { + name: oAuthClientName, + redirectUris: ["http://test-oauth-client.com"], + permissions: 32, + }; + return request(app.getHttpServer()) + .post("/api/v2/oauth-clients") + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse<{ clientId: string; clientSecret: string }> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.clientId).toBeDefined(); + expect(responseBody.data.clientSecret).toBeDefined(); + client = { + clientId: responseBody.data.clientId, + clientSecret: responseBody.data.clientSecret, + }; + }); + }); + it(`/GET`, () => { + return request(app.getHttpServer()) + .get("/api/v2/oauth-clients") + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data).toBeInstanceOf(Array); + expect(responseBody.data[0].name).toEqual(oAuthClientName); + }); + }); + it(`/GET/:id`, () => { + return request(app.getHttpServer()) + .get(`/api/v2/oauth-clients/${client.clientId}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(oAuthClientName); + }); + }); + it(`/PUT/:id`, () => { + const clientUpdatedName = "test-oauth-client-updated"; + const body: UpdateOAuthClientInput = { name: clientUpdatedName }; + return request(app.getHttpServer()) + .patch(`/api/v2/oauth-clients/${client.clientId}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(clientUpdatedName); + }); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete(`/api/v2/oauth-clients/${client.clientId}`).expect(200); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + }); + }); + + describe("User is part of an organization as Owner", () => { + let membership: Membership; + let client: { clientId: string; clientSecret: string }; + const oAuthClientName = "test-oauth-client-owner"; + const oAuthClientPermissions = 32; + + beforeAll(async () => { + membership = await membershipFixtures.addUserToOrg(user, org, "OWNER", true); + }); + + it(`/POST`, () => { + const body: CreateOAuthClientInput = { + name: oAuthClientName, + redirectUris: ["http://test-oauth-client.com"], + permissions: 32, + }; + return request(app.getHttpServer()) + .post("/api/v2/oauth-clients") + .send(body) + .expect(201) + .then((response) => { + const responseBody: ApiSuccessResponse<{ clientId: string; clientSecret: string }> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.clientId).toBeDefined(); + expect(responseBody.data.clientSecret).toBeDefined(); + client = { + clientId: responseBody.data.clientId, + clientSecret: responseBody.data.clientSecret, + }; + }); + }); + + it(`/GET`, () => { + return request(app.getHttpServer()) + .get("/api/v2/oauth-clients") + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data).toBeInstanceOf(Array); + expect(responseBody.data[0].name).toEqual(oAuthClientName); + expect(responseBody.data[0].permissions).toEqual(oAuthClientPermissions); + }); + }); + it(`/GET/:id`, () => { + return request(app.getHttpServer()) + .get(`/api/v2/oauth-clients/${client.clientId}`) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(oAuthClientName); + expect(responseBody.data.permissions).toEqual(oAuthClientPermissions); + }); + }); + it(`/PUT/:id`, () => { + const clientUpdatedName = "test-oauth-client-updated"; + const body: UpdateOAuthClientInput = { name: clientUpdatedName }; + return request(app.getHttpServer()) + .patch(`/api/v2/oauth-clients/${client.clientId}`) + .send(body) + .expect(200) + .then((response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.name).toEqual(clientUpdatedName); + }); + }); + it(`/DELETE/:id`, () => { + return request(app.getHttpServer()).delete(`/api/v2/oauth-clients/${client.clientId}`).expect(200); + }); + + afterAll(async () => { + await membershipFixtures.delete(membership.id); + }); + }); + + afterAll(async () => { + teamFixtures.delete(org.id); + usersFixtures.delete(user.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts new file mode 100644 index 00000000000000..da7a203ac3726f --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts @@ -0,0 +1,121 @@ +import { getEnv } from "@/env"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; +import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard"; +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 { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto"; +import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { + Body, + Controller, + Get, + Post, + Patch, + Delete, + Param, + HttpCode, + HttpStatus, + Logger, + UseGuards, + NotFoundException, +} from "@nestjs/common"; +import { + ApiTags as DocsTags, + ApiExcludeController as DocsExcludeController, + ApiOperation as DocsOperation, + ApiCreatedResponse as DocsCreatedResponse, +} from "@nestjs/swagger"; +import { MembershipRole } from "@prisma/client"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { CreateOAuthClientInput } from "@calcom/platform-types"; + +const AUTH_DOCUMENTATION = `⚠ First, this endpoint requires \`Cookie: next-auth.session-token=eyJhbGciOiJ\` header. Log into Cal web app using owner of organization that was created after visiting \`/settings/organizations/new\`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard. +Second, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.`; + +@Controller({ + path: "oauth-clients", + version: "2", +}) +@UseGuards(NextAuthGuard, OrganizationRolesGuard) +@DocsExcludeController(getEnv("NODE_ENV") === "production") +@DocsTags("Development only") +export class OAuthClientsController { + private readonly logger = new Logger("OAuthClientController"); + + constructor(private readonly oauthClientRepository: OAuthClientRepository) {} + + @Post("/") + @HttpCode(HttpStatus.CREATED) + @Roles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + @DocsCreatedResponse({ + description: "Create an OAuth client", + type: CreateOAuthClientResponseDto, + }) + async createOAuthClient( + @GetUser("organizationId") organizationId: number, + @Body() body: CreateOAuthClientInput + ): Promise { + this.logger.log( + `For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}` + ); + const { id, secret } = await this.oauthClientRepository.createOAuthClient(organizationId, body); + return { + status: SUCCESS_STATUS, + data: { + clientId: id, + clientSecret: secret, + }, + }; + } + + @Get("/") + @HttpCode(HttpStatus.OK) + @Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + async getOAuthClients( + @GetUser("organizationId") organizationId: number + ): Promise { + const clients = await this.oauthClientRepository.getOrganizationOAuthClients(organizationId); + return { status: SUCCESS_STATUS, data: clients }; + } + + @Get("/:clientId") + @HttpCode(HttpStatus.OK) + @Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + async getOAuthClientById(@Param("clientId") clientId: string): Promise { + const client = await this.oauthClientRepository.getOAuthClient(clientId); + if (!client) { + throw new NotFoundException(`OAuth client with ID ${clientId} not found`); + } + return { status: SUCCESS_STATUS, data: client }; + } + + @Patch("/:clientId") + @HttpCode(HttpStatus.OK) + @Roles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + async updateOAuthClient( + @Param("clientId") clientId: string, + @Body() body: UpdateOAuthClientInput + ): Promise { + this.logger.log(`For client ${clientId} updating OAuth Client with data: ${JSON.stringify(body)}`); + const client = await this.oauthClientRepository.updateOAuthClient(clientId, body); + return { status: SUCCESS_STATUS, data: client }; + } + + @Delete("/:clientId") + @HttpCode(HttpStatus.OK) + @Roles([MembershipRole.ADMIN, MembershipRole.OWNER]) + @DocsOperation({ description: AUTH_DOCUMENTATION }) + async deleteOAuthClient(@Param("clientId") clientId: string): Promise { + this.logger.log(`Deleting OAuth Client with ID: ${clientId}`); + const client = await this.oauthClientRepository.deleteOAuthClient(clientId); + return { status: SUCCESS_STATUS, data: client }; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts new file mode 100644 index 00000000000000..fcee72c195ceea --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsIn, ValidateNested, IsNotEmptyObject, IsString } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class DataDto { + @ApiProperty({ + example: "clsx38nbl0001vkhlwin9fmt0", + }) + @IsString() + clientId!: string; + + @ApiProperty({ + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi", + }) + @IsString() + clientSecret!: string; +} + +export class CreateOAuthClientResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsIn([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + example: { + clientId: "clsx38nbl0001vkhlwin9fmt0", + clientSecret: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi", + }, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DataDto) + data!: DataDto; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto.ts new file mode 100644 index 00000000000000..f019f43fcf04a0 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + ValidateNested, + IsEnum, + IsString, + IsNumber, + IsOptional, + IsDate, + IsNotEmptyObject, +} from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class PlatformOAuthClientDto { + @ApiProperty({ example: "clsx38nbl0001vkhlwin9fmt0" }) + @IsString() + id!: string; + + @ApiProperty({ example: "MyClient" }) + @IsString() + name!: string; + + @ApiProperty({ example: "secretValue" }) + @IsString() + secret!: string; + + @ApiProperty({ example: 3 }) + @IsNumber() + permissions!: number; + + @ApiProperty({ example: "https://example.com/logo.png", required: false }) + @IsOptional() + @IsString() + logo!: string | null; + + @ApiProperty({ example: ["https://example.com/callback"] }) + @IsArray() + @IsString({ each: true }) + redirectUris!: string[]; + + @ApiProperty({ example: 1 }) + @IsNumber() + organizationId!: number; + + @ApiProperty({ example: new Date(), type: Date }) + @IsDate() + createdAt!: Date; +} + +export class GetOAuthClientResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: PlatformOAuthClientDto, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => PlatformOAuthClientDto) + data!: PlatformOAuthClientDto; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto.ts new file mode 100644 index 00000000000000..584729b9f06d3b --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto.ts @@ -0,0 +1,21 @@ +import { PlatformOAuthClientDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsArray, ValidateNested, IsEnum } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class GetOAuthClientsResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: PlatformOAuthClientDto, + isArray: true, + }) + @ValidateNested({ each: true }) + @Type(() => PlatformOAuthClientDto) + @IsArray() + data!: PlatformOAuthClientDto[]; +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts new file mode 100644 index 00000000000000..2081240bbe0c5e --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts @@ -0,0 +1,175 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { ZodExceptionFilter } from "@/filters/zod-exception.filter"; +import { AuthModule } from "@/modules/auth/auth.module"; +import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input"; +import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User } from "@prisma/client"; +import * as request from "supertest"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { withNextAuth } from "test/utils/withNextAuth"; + +import { X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +describe("OAuthFlow Endpoints", () => { + describe("User Not Authenticated", () => { + let appWithoutAuth: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }).compile(); + appWithoutAuth = moduleRef.createNestApplication(); + bootstrap(appWithoutAuth as NestExpressApplication); + await appWithoutAuth.init(); + }); + + it(`POST /oauth/:clientId/authorize missing Cookie with user`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/authorize").expect(401); + }); + + it(`POST /oauth/:clientId/exchange missing Authorization Bearer token`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/exchange").expect(400); + }); + + it(`POST /oauth/:clientId/refresh missing ${X_CAL_SECRET_KEY} header with secret`, () => { + return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/refresh").expect(401); + }); + + afterAll(async () => { + await appWithoutAuth.close(); + }); + }); + + describe("User Authenticated", () => { + let app: INestApplication; + + let usersRepositoryFixtures: UserRepositoryFixture; + let organizationsRepositoryFixture: TeamRepositoryFixture; + let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture; + + let user: User; + let organization: Team; + let oAuthClient: PlatformOAuthClient; + + let authorizationCode: string | null; + let refreshToken: string; + + beforeAll(async () => { + const userEmail = "developer@platform.com"; + + const moduleRef: TestingModule = await withNextAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }) + ).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + usersRepositoryFixtures = new UserRepositoryFixture(moduleRef); + + user = await usersRepositoryFixtures.create({ + email: userEmail, + }); + organization = await organizationsRepositoryFixture.create({ name: "organization" }); + oAuthClient = await createOAuthClient(organization.id); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri.com"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oAuthClientsRepositoryFixture.create(organizationId, data, secret); + return client; + } + + describe("Authorize Endpoint", () => { + it("POST /oauth/:clientId/authorize", async () => { + const body: OAuthAuthorizeInput = { + redirectUri: oAuthClient.redirectUris[0], + }; + + const REDIRECT_STATUS = 302; + + const response = await request(app.getHttpServer()) + .post(`/oauth/${oAuthClient.id}/authorize`) + .send(body) + .expect(REDIRECT_STATUS); + + const baseUrl = "http://www.localhost/"; + const redirectUri = new URL(response.header.location, baseUrl); + authorizationCode = redirectUri.searchParams.get("code"); + + expect(authorizationCode).toBeDefined(); + }); + }); + + describe("Exchange Endpoint", () => { + it("POST /oauth/:clientId/exchange", async () => { + const authorizationToken = `Bearer ${authorizationCode}`; + const body: ExchangeAuthorizationCodeInput = { + clientSecret: oAuthClient.secret, + }; + + const response = await request(app.getHttpServer()) + .post(`/oauth/${oAuthClient.id}/exchange`) + .set("Authorization", authorizationToken) + .send(body) + .expect(200); + + expect(response.body?.data?.accessToken).toBeDefined(); + expect(response.body?.data?.refreshToken).toBeDefined(); + + refreshToken = response.body.data.refreshToken; + }); + }); + + describe("Refresh Token Endpoint", () => { + it("POST /oauth/:clientId/refresh", () => { + const secretKey = oAuthClient.secret; + const body = { + refreshToken, + }; + + return request(app.getHttpServer()) + .post(`/oauth/${oAuthClient.id}/refresh`) + .set("x-cal-secret-key", secretKey) + .send(body) + .expect(200) + .then((response) => { + expect(response.body?.data?.accessToken).toBeDefined(); + expect(response.body?.data?.refreshToken).toBeDefined(); + }); + }); + }); + + afterAll(async () => { + await oAuthClientsRepositoryFixture.delete(oAuthClient.id); + await organizationsRepositoryFixture.delete(organization.id); + await usersRepositoryFixtures.delete(user.id); + + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts new file mode 100644 index 00000000000000..12172dc0b1fb98 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.ts @@ -0,0 +1,157 @@ +import { getEnv } from "@/env"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; +import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; +import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard"; +import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input"; +import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input"; +import { RefreshTokenInput } from "@/modules/oauth-clients/inputs/refresh-token.input"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { + BadRequestException, + Body, + Controller, + Headers, + HttpCode, + HttpStatus, + Param, + Post, + Response, + UseGuards, +} from "@nestjs/common"; +import { + ApiTags as DocsTags, + ApiExcludeController as DocsExcludeController, + ApiOperation as DocsOperation, + ApiOkResponse as DocsOkResponse, + ApiBadRequestResponse as DocsBadRequestResponse, +} from "@nestjs/swagger"; +import { Response as ExpressResponse } from "express"; + +import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +@Controller({ + path: "oauth/:clientId", + version: "2", +}) +@DocsExcludeController(getEnv("NODE_ENV") === "production") +@DocsTags("Development only") +export class OAuthFlowController { + constructor( + private readonly oauthClientRepository: OAuthClientRepository, + private readonly tokensRepository: TokensRepository, + private readonly oAuthFlowService: OAuthFlowService + ) {} + + @Post("/authorize") + @HttpCode(HttpStatus.OK) + @UseGuards(NextAuthGuard) + @DocsOperation({ + summary: "Authorize an OAuth client", + description: + "Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.", + }) + @DocsOkResponse({ + description: + "The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`", + }) + @DocsBadRequestResponse({ + description: + "Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client.", + }) + async authorize( + @Param("clientId") clientId: string, + @Body() body: OAuthAuthorizeInput, + @GetUser("id") userId: number, + @Response() res: ExpressResponse + ): Promise { + const oauthClient = await this.oauthClientRepository.getOAuthClient(clientId); + if (!oauthClient) { + throw new BadRequestException(`OAuth client with ID '${clientId}' not found`); + } + + if (!oauthClient?.redirectUris.includes(body.redirectUri)) { + throw new BadRequestException("Invalid 'redirect_uri' value."); + } + + const alreadyAuthorized = await this.tokensRepository.getAuthorizationTokenByClientUserIds( + clientId, + userId + ); + + if (alreadyAuthorized) { + throw new BadRequestException( + `User with id=${userId} has already authorized client with id=${clientId}.` + ); + } + + const { id } = await this.tokensRepository.createAuthorizationToken(clientId, userId); + + return res.redirect(`${body.redirectUri}?code=${id}`); + } + + @Post("/exchange") + @HttpCode(HttpStatus.OK) + @DocsOperation({ + summary: "Exchange authorization code for access tokens", + description: + "Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.", + }) + @DocsOkResponse({ + type: KeysResponseDto, + description: "Successfully exchanged authorization code for access and refresh tokens.", + }) + @DocsBadRequestResponse({ + description: + "Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match.", + }) + async exchange( + @Headers("Authorization") authorization: string, + @Param("clientId") clientId: string, + @Body() body: ExchangeAuthorizationCodeInput + ): Promise { + const authorizeEndpointCode = authorization.replace("Bearer ", "").trim(); + if (!authorizeEndpointCode) { + throw new BadRequestException("Missing 'Bearer' Authorization header."); + } + + const { accessToken, refreshToken } = await this.oAuthFlowService.exchangeAuthorizationToken( + authorizeEndpointCode, + clientId, + body.clientSecret + ); + + return { + status: SUCCESS_STATUS, + data: { + accessToken, + refreshToken, + }, + }; + } + + @Post("/refresh") + @HttpCode(HttpStatus.OK) + @UseGuards(OAuthClientCredentialsGuard) + async refreshAccessToken( + @Param("clientId") clientId: string, + @Headers(X_CAL_SECRET_KEY) secretKey: string, + @Body() body: RefreshTokenInput + ): Promise { + const { accessToken, refreshToken } = await this.oAuthFlowService.refreshToken( + clientId, + secretKey, + body.refreshToken + ); + + return { + status: SUCCESS_STATUS, + data: { + accessToken: accessToken, + refreshToken: refreshToken, + }, + }; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts new file mode 100644 index 00000000000000..ba5c85b51e5a9b --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { ValidateNested, IsEnum, IsString, IsNotEmptyObject } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +class KeysDto { + @ApiProperty({ + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + }) + @IsString() + accessToken!: string; + + @ApiProperty({ + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + }) + @IsString() + refreshToken!: string; +} + +export class KeysResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: KeysDto, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => KeysDto) + data!: KeysDto; +} diff --git a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.spec.ts b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.spec.ts new file mode 100644 index 00000000000000..285f8661258050 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.spec.ts @@ -0,0 +1,94 @@ +import { AppModule } from "@/app.module"; +import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; +import { createMock } from "@golevelup/ts-jest"; +import { ExecutionContext, UnauthorizedException } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PlatformOAuthClient, Team } from "@prisma/client"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; + +import { X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +import { OAuthClientCredentialsGuard } from "./oauth-client-credentials.guard"; + +describe("OAuthClientCredentialsGuard", () => { + let guard: OAuthClientCredentialsGuard; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let oauthClient: PlatformOAuthClient; + let organization: Team; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [AppModule, OAuthClientModule], + }).compile(); + + guard = module.get(OAuthClientCredentialsGuard); + teamRepositoryFixture = new TeamRepositoryFixture(module); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(module); + + organization = await teamRepositoryFixture.create({ name: "organization" }); + + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["redirect-uri"], + permissions: 32, + }; + const secret = "secret"; + + oauthClient = await oauthClientRepositoryFixture.create(organization.id, data, secret); + }); + + it("should be defined", () => { + expect(guard).toBeDefined(); + expect(oauthClient).toBeDefined(); + }); + + it("should return true if client ID and secret are valid", async () => { + const mockContext = createMockExecutionContext( + { [X_CAL_SECRET_KEY]: oauthClient.secret }, + { clientId: oauthClient.id } + ); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false if client ID is invalid", async () => { + const mockContext = createMockExecutionContext( + { [X_CAL_SECRET_KEY]: oauthClient.secret }, + { clientId: "invalid id" } + ); + + await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); + }); + + it("should return false if secret key is invalid", async () => { + const mockContext = createMockExecutionContext( + { [X_CAL_SECRET_KEY]: "invalid secret" }, + { clientId: oauthClient.id } + ); + + await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oauthClient.id); + await teamRepositoryFixture.delete(organization.id); + }); + + function createMockExecutionContext( + headers: Record, + params: Record + ): ExecutionContext { + return createMock({ + switchToHttp: () => ({ + getRequest: () => ({ + headers, + params, + get: (headerName: string) => headers[headerName], + }), + }), + }); + } +}); diff --git a/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts new file mode 100644 index 00000000000000..e58cdcba5c7fb5 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard.ts @@ -0,0 +1,33 @@ +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; +import { Request } from "express"; + +import { X_CAL_SECRET_KEY } from "@calcom/platform-constants"; + +@Injectable() +export class OAuthClientCredentialsGuard implements CanActivate { + constructor(private readonly oauthRepository: OAuthClientRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const { params } = request; + + const oauthClientId = params.clientId; + const oauthClientSecret = request.get(X_CAL_SECRET_KEY); + + if (!oauthClientId) { + throw new UnauthorizedException("Missing client ID"); + } + if (!oauthClientSecret) { + throw new UnauthorizedException("Missing client secret"); + } + + const client = await this.oauthRepository.getOAuthClient(oauthClientId); + + if (!client || client.secret !== oauthClientSecret) { + throw new UnauthorizedException("Invalid client credentials"); + } + + return true; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/authorize.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/authorize.input.ts new file mode 100644 index 00000000000000..62b7987310320a --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/authorize.input.ts @@ -0,0 +1,6 @@ +import { IsString } from "class-validator"; + +export class OAuthAuthorizeInput { + @IsString() + redirectUri!: string; +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/exchange-code.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/exchange-code.input.ts new file mode 100644 index 00000000000000..938f8db08fe382 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/exchange-code.input.ts @@ -0,0 +1,6 @@ +import { IsString } from "class-validator"; + +export class ExchangeAuthorizationCodeInput { + @IsString() + clientSecret!: string; +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/refresh-token.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/refresh-token.input.ts new file mode 100644 index 00000000000000..1eb25d3950a25c --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/refresh-token.input.ts @@ -0,0 +1,6 @@ +import { IsString } from "class-validator"; + +export class RefreshTokenInput { + @IsString() + refreshToken!: string; +} diff --git a/apps/api/v2/src/modules/oauth-clients/inputs/update-oauth-client.input.ts b/apps/api/v2/src/modules/oauth-clients/inputs/update-oauth-client.input.ts new file mode 100644 index 00000000000000..596651bdd46c0e --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/inputs/update-oauth-client.input.ts @@ -0,0 +1,16 @@ +import { IsArray, IsOptional, IsString } from "class-validator"; + +export class UpdateOAuthClientInput { + @IsOptional() + @IsString() + logo?: string; + + @IsString() + @IsOptional() + name?: string; + + @IsArray() + @IsOptional() + @IsString({ each: true }) + redirectUris?: string[] = []; +} diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts new file mode 100644 index 00000000000000..8debe05a7381e0 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts @@ -0,0 +1,29 @@ +import { EventTypesModule } from "@/ee/event-types/event-types.module"; +import { AuthModule } from "@/modules/auth/auth.module"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller"; +import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller"; +import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller"; +import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oauth-client-credentials/oauth-client-credentials.guard"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersModule } from "@/modules/users/users.module"; +import { Global, Module } from "@nestjs/common"; + +@Global() +@Module({ + imports: [PrismaModule, AuthModule, UsersModule, MembershipsModule, EventTypesModule], + providers: [ + OAuthClientRepository, + OAuthClientCredentialsGuard, + TokensRepository, + OAuthFlowService, + OAuthClientUsersService, + ], + controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController], + exports: [OAuthClientRepository, OAuthClientCredentialsGuard], +}) +export class OAuthClientModule {} diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts new file mode 100644 index 00000000000000..6dc399e2e4aead --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.repository.ts @@ -0,0 +1,102 @@ +import { JwtService } from "@/modules/jwt/jwt.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import type { PlatformOAuthClient } from "@prisma/client"; + +import type { CreateOAuthClientInput } from "@calcom/platform-types"; + +@Injectable() +export class OAuthClientRepository { + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private jwtService: JwtService + ) {} + + async createOAuthClient(organizationId: number, data: CreateOAuthClientInput) { + return this.dbWrite.prisma.platformOAuthClient.create({ + data: { + ...data, + secret: this.jwtService.sign(data), + organizationId, + }, + }); + } + + async getOAuthClient(clientId: string): Promise { + return this.dbRead.prisma.platformOAuthClient.findUnique({ + where: { id: clientId }, + }); + } + + async getOAuthClientWithAuthTokens(tokenId: string, clientId: string, clientSecret: string) { + return this.dbRead.prisma.platformOAuthClient.findUnique({ + where: { + id: clientId, + secret: clientSecret, + authorizationTokens: { + some: { + id: tokenId, + }, + }, + }, + include: { + authorizationTokens: { + where: { + id: tokenId, + }, + include: { + owner: { + select: { + id: true, + }, + }, + }, + }, + }, + }); + } + + async getOAuthClientWithRefreshSecret(clientId: string, clientSecret: string, refreshToken: string) { + return await this.dbRead.prisma.platformOAuthClient.findFirst({ + where: { + id: clientId, + secret: clientSecret, + }, + include: { + refreshToken: { + where: { + secret: refreshToken, + }, + }, + }, + }); + } + + async getOrganizationOAuthClients(organizationId: number): Promise { + return this.dbRead.prisma.platformOAuthClient.findMany({ + where: { + organization: { + id: organizationId, + }, + }, + }); + } + + async updateOAuthClient( + clientId: string, + updateData: Partial + ): Promise { + return this.dbWrite.prisma.platformOAuthClient.update({ + where: { id: clientId }, + data: updateData, + }); + } + + async deleteOAuthClient(clientId: string): Promise { + return this.dbWrite.prisma.platformOAuthClient.delete({ + where: { id: clientId }, + }); + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts new file mode 100644 index 00000000000000..7fc51ebf054489 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts @@ -0,0 +1,83 @@ +import { EventTypesService } from "@/ee/event-types/services/event-types.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; +import { User } from "@prisma/client"; +import * as crypto from "crypto"; + +import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries"; + +@Injectable() +export class OAuthClientUsersService { + constructor( + private readonly userRepository: UsersRepository, + private readonly tokensRepository: TokensRepository, + private readonly eventTypesService: EventTypesService + ) {} + + async createOauthClientUser(oAuthClientId: string, body: CreateUserInput, organizationId?: number) { + let user: User; + if (!organizationId) { + const username = generateShortHash(body.email, oAuthClientId); + user = await this.userRepository.create(body, username, oAuthClientId); + } else { + const [_, emailDomain] = body.email.split("@"); + user = ( + await createNewUsersConnectToOrgIfExists({ + usernamesOrEmails: [body.email], + input: { + teamId: organizationId, + role: "MEMBER", + usernameOrEmail: [body.email], + isOrg: true, + language: "en", + }, + parentId: null, + autoAcceptEmailDomain: emailDomain, + connectionInfoMap: { + [body.email]: { + orgId: organizationId, + autoAccept: true, + }, + }, + }) + )[0]; + await this.userRepository.addToOAuthClient(user.id, oAuthClientId); + await this.userRepository.update(user.id, { name: body.name ?? user.username ?? undefined }); + } + + const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens( + oAuthClientId, + user.id + ); + await this.eventTypesService.createUserDefaultEventTypes(user.id); + + return { + user, + tokens: { + accessToken, + refreshToken, + }, + }; + } +} + +function generateShortHash(email: string, clientId: string): string { + // Get the current timestamp + const timestamp = Date.now().toString(); + + // Concatenate the timestamp and email + const data = timestamp + email + clientId; + + // Create a SHA256 hash + const hash = crypto + .createHash("sha256") + .update(data) + .digest("base64") + .replace("=", "") + .replace("/", "") + .replace("+", ""); + + return hash.toLowerCase(); +} diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts new file mode 100644 index 00000000000000..a26e2bb7190cbf --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts @@ -0,0 +1,117 @@ +import { TokenExpiredException } from "@/modules/auth/guards/access-token/token-expired.exception"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { BadRequestException, Injectable, Logger, UnauthorizedException } from "@nestjs/common"; + +import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants"; + +@Injectable() +export class OAuthFlowService { + private logger = new Logger("OAuthFlowService"); + + constructor( + private readonly tokensRepository: TokensRepository, + private readonly oAuthClientRepository: OAuthClientRepository + ) //private readonly redisService: RedisIOService + {} + + async propagateAccessToken(accessToken: string) { + // this.logger.log("Propagating access token to redis", accessToken); + // TODO propagate + //this.redisService.redis.hset("access_tokens", accessToken,) + return void 0; + } + + async getOwnerId(accessToken: string) { + return this.tokensRepository.getAccessTokenOwnerId(accessToken); + } + + async validateAccessToken(secret: string) { + // status can be "CACHE_HIT" or "CACHE_MISS", MISS will most likely mean the token has expired + // but we need to check the SQL db for it anyways. + const { status } = await this.readFromCache(secret); + + if (status === "CACHE_HIT") { + return true; + } + + const tokenExpiresAt = await this.tokensRepository.getAccessTokenExpiryDate(secret); + + if (!tokenExpiresAt) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + if (new Date() > tokenExpiresAt) { + throw new TokenExpiredException(); + } + + return true; + } + + private async readFromCache(secret: string) { + return { status: "CACHE_MISS" }; + } + + async exchangeAuthorizationToken( + tokenId: string, + clientId: string, + clientSecret: string + ): Promise<{ accessToken: string; refreshToken: string }> { + const oauthClient = await this.oAuthClientRepository.getOAuthClientWithAuthTokens( + tokenId, + clientId, + clientSecret + ); + + if (!oauthClient) { + throw new BadRequestException("Invalid OAuth Client."); + } + + const authorizationToken = oauthClient.authorizationTokens[0]; + + if (!authorizationToken || !authorizationToken.owner.id) { + throw new BadRequestException("Invalid Authorization Token."); + } + + const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens( + clientId, + authorizationToken.owner.id + ); + await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id); + void this.propagateAccessToken(accessToken); // voided as we don't need to await + + return { + accessToken, + refreshToken, + }; + } + + async refreshToken(clientId: string, clientSecret: string, tokenSecret: string) { + const oauthClient = await this.oAuthClientRepository.getOAuthClientWithRefreshSecret( + clientId, + clientSecret, + tokenSecret + ); + + if (!oauthClient) { + throw new BadRequestException("Invalid OAuthClient credentials."); + } + + const currentRefreshToken = oauthClient.refreshToken[0]; + + if (!currentRefreshToken) { + throw new BadRequestException("Invalid refresh token"); + } + + const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens( + clientId, + currentRefreshToken.secret, + currentRefreshToken.userId + ); + + return { + accessToken: accessToken.secret, + refreshToken: refreshToken.secret, + }; + } +} diff --git a/apps/api/v2/src/modules/prisma/prisma-read.service.ts b/apps/api/v2/src/modules/prisma/prisma-read.service.ts new file mode 100644 index 00000000000000..2fbe34f7f44bdd --- /dev/null +++ b/apps/api/v2/src/modules/prisma/prisma-read.service.ts @@ -0,0 +1,25 @@ +import type { OnModuleInit } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PrismaClient } from "@prisma/client"; + +@Injectable() +export class PrismaReadService implements OnModuleInit { + public prisma: PrismaClient; + + constructor(private readonly configService: ConfigService) { + const dbUrl = configService.get("db.readUrl", { infer: true }); + + this.prisma = new PrismaClient({ + datasources: { + db: { + url: dbUrl, + }, + }, + }); + } + + async onModuleInit() { + this.prisma.$connect(); + } +} diff --git a/apps/api/v2/src/modules/prisma/prisma-write.service.ts b/apps/api/v2/src/modules/prisma/prisma-write.service.ts new file mode 100644 index 00000000000000..abcc23273ce5c5 --- /dev/null +++ b/apps/api/v2/src/modules/prisma/prisma-write.service.ts @@ -0,0 +1,25 @@ +import { OnModuleInit } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PrismaClient } from "@prisma/client"; + +@Injectable() +export class PrismaWriteService implements OnModuleInit { + public prisma: PrismaClient; + + constructor(private readonly configService: ConfigService) { + const dbUrl = configService.get("db.writeUrl", { infer: true }); + + this.prisma = new PrismaClient({ + datasources: { + db: { + url: dbUrl, + }, + }, + }); + } + + async onModuleInit() { + this.prisma.$connect(); + } +} diff --git a/apps/api/v2/src/modules/prisma/prisma.module.ts b/apps/api/v2/src/modules/prisma/prisma.module.ts new file mode 100644 index 00000000000000..e9a66a5fc41da9 --- /dev/null +++ b/apps/api/v2/src/modules/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Module } from "@nestjs/common"; + +@Module({ + providers: [PrismaReadService, PrismaWriteService], + exports: [PrismaReadService, PrismaWriteService], +}) +export class PrismaModule {} diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts new file mode 100644 index 00000000000000..ef13910396d57f --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts @@ -0,0 +1,9 @@ +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [], + providers: [SelectedCalendarsRepository], + exports: [SelectedCalendarsRepository], +}) +export class CredentialsModule {} diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts new file mode 100644 index 00000000000000..a4a1bb5e7b44d2 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts @@ -0,0 +1,19 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class SelectedCalendarsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + createSelectedCalendar(externalId: string, credentialId: number, userId: number, integration: string) { + return this.dbWrite.prisma.selectedCalendar.create({ + data: { + userId, + externalId, + credentialId, + integration, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts new file mode 100644 index 00000000000000..a75cf2d0722c52 --- /dev/null +++ b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts @@ -0,0 +1,70 @@ +import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { SlotsService } from "@/modules/slots/services/slots.service"; +import { Query, Body, Controller, Get, Delete, Post, Req, Res, UseGuards } from "@nestjs/common"; +import { Response as ExpressResponse, Request as ExpressRequest } from "express"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { getAvailableSlots } from "@calcom/platform-libraries"; +import type { AvailableSlotsType } from "@calcom/platform-libraries"; +import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types"; +import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types"; + +@Controller({ + path: "slots", + version: "2", +}) +@UseGuards(AccessTokenGuard) +export class SlotsController { + constructor(private readonly slotsService: SlotsService) {} + + @Post("/reserve") + async reserveSlot( + @Body() body: ReserveSlotInput, + @Res({ passthrough: true }) res: ExpressResponse, + @Req() req: ExpressRequest + ): Promise> { + const uid = await this.slotsService.reserveSlot(body, req.cookies?.uid); + + res.cookie("uid", uid); + return { + status: SUCCESS_STATUS, + data: uid, + }; + } + + @Delete("/selected-slot") + async deleteSelectedSlot( + @Query() params: RemoveSelectedSlotInput, + @Req() req: ExpressRequest + ): Promise { + const uid = req.cookies?.uid || params.uid; + + await this.slotsService.deleteSelectedslot(uid); + + return { + status: SUCCESS_STATUS, + }; + } + + @Get("/available") + async getAvailableSlots( + @Query() query: GetAvailableSlotsInput, + @Req() req: ExpressRequest + ): Promise> { + const isTeamEvent = await this.slotsService.checkIfIsTeamEvent(query.eventTypeId); + const availableSlots = await getAvailableSlots({ + input: { + ...query, + isTeamEvent, + }, + ctx: { + req, + }, + }); + + return { + data: availableSlots, + status: "success", + }; + } +} diff --git a/apps/api/v2/src/modules/slots/services/slots.service.ts b/apps/api/v2/src/modules/slots/services/slots.service.ts new file mode 100644 index 00000000000000..1d17340ec8df52 --- /dev/null +++ b/apps/api/v2/src/modules/slots/services/slots.service.ts @@ -0,0 +1,57 @@ +import { EventTypesRepository } from "@/ee/event-types/event-types.repository"; +import { SlotsRepository } from "@/modules/slots/slots.repository"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { v4 as uuid } from "uuid"; + +import { ReserveSlotInput } from "@calcom/platform-types"; + +@Injectable() +export class SlotsService { + constructor( + private readonly eventTypeRepo: EventTypesRepository, + private readonly slotsRepo: SlotsRepository + ) {} + + async reserveSlot(input: ReserveSlotInput, headerUid?: string) { + const uid = headerUid || uuid(); + const eventType = await this.eventTypeRepo.getEventTypeWithSeats(input.eventTypeId); + if (!eventType) { + throw new NotFoundException("Event Type not found"); + } + + let shouldReserveSlot = true; + if (eventType.seatsPerTimeSlot) { + const bookingWithAttendees = await this.slotsRepo.getBookingWithAttendees(input.bookingUid); + const bookingAttendeesLength = bookingWithAttendees?.attendees?.length; + if (bookingAttendeesLength) { + const seatsLeft = eventType.seatsPerTimeSlot - bookingAttendeesLength; + if (seatsLeft < 1) shouldReserveSlot = false; + } else { + shouldReserveSlot = false; + } + } + + if (eventType && shouldReserveSlot) { + await Promise.all( + eventType.users.map((user) => + this.slotsRepo.upsertSelectedSlot(user.id, input, uid, eventType.seatsPerTimeSlot !== null) + ) + ); + } + + return uid; + } + + async deleteSelectedslot(uid?: string) { + if (!uid) return; + + return this.slotsRepo.deleteSelectedSlots(uid); + } + + async checkIfIsTeamEvent(eventTypeId?: number) { + if (!eventTypeId) return false; + + const event = await this.eventTypeRepo.getEventTypeById(eventTypeId); + return !!event?.teamId; + } +} diff --git a/apps/api/v2/src/modules/slots/slots.module.ts b/apps/api/v2/src/modules/slots/slots.module.ts new file mode 100644 index 00000000000000..94e7c0d27fe2a9 --- /dev/null +++ b/apps/api/v2/src/modules/slots/slots.module.ts @@ -0,0 +1,14 @@ +import { EventTypesModule } from "@/ee/event-types/event-types.module"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SlotsController } from "@/modules/slots/controllers/slots.controller"; +import { SlotsService } from "@/modules/slots/services/slots.service"; +import { SlotsRepository } from "@/modules/slots/slots.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule, EventTypesModule], + providers: [SlotsRepository, SlotsService], + controllers: [SlotsController], + exports: [SlotsService], +}) +export class SlotsModule {} diff --git a/apps/api/v2/src/modules/slots/slots.repository.ts b/apps/api/v2/src/modules/slots/slots.repository.ts new file mode 100644 index 00000000000000..8ef589f9f87515 --- /dev/null +++ b/apps/api/v2/src/modules/slots/slots.repository.ts @@ -0,0 +1,53 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { DateTime } from "luxon"; + +import { MINUTES_TO_BOOK } from "@calcom/platform-libraries"; +import { ReserveSlotInput } from "@calcom/platform-types"; + +@Injectable() +export class SlotsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getBookingWithAttendees(bookingUid?: string) { + return this.dbRead.prisma.booking.findUnique({ + where: { uid: bookingUid }, + select: { attendees: true }, + }); + } + + async upsertSelectedSlot(userId: number, input: ReserveSlotInput, uid: string, isSeat: boolean) { + const { slotUtcEndDate, slotUtcStartDate, eventTypeId } = input; + + const releaseAt = DateTime.utc() + .plus({ minutes: parseInt(MINUTES_TO_BOOK) }) + .toISO(); + return this.dbWrite.prisma.selectedSlots.upsert({ + where: { + selectedSlotUnique: { userId, slotUtcStartDate, slotUtcEndDate, uid }, + }, + update: { + slotUtcEndDate, + slotUtcStartDate, + releaseAt, + eventTypeId, + }, + create: { + userId, + eventTypeId, + slotUtcStartDate, + slotUtcEndDate, + uid, + releaseAt, + isSeat, + }, + }); + } + + async deleteSelectedSlots(uid: string) { + return this.dbWrite.prisma.selectedSlots.deleteMany({ + where: { uid: { equals: uid } }, + }); + } +} diff --git a/apps/api/v2/src/modules/tokens/tokens.module.ts b/apps/api/v2/src/modules/tokens/tokens.module.ts new file mode 100644 index 00000000000000..d700cc77541949 --- /dev/null +++ b/apps/api/v2/src/modules/tokens/tokens.module.ts @@ -0,0 +1,10 @@ +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [TokensRepository], + exports: [TokensRepository], +}) +export class TokensModule {} diff --git a/apps/api/v2/src/modules/tokens/tokens.repository.ts b/apps/api/v2/src/modules/tokens/tokens.repository.ts new file mode 100644 index 00000000000000..f70fc570774d2f --- /dev/null +++ b/apps/api/v2/src/modules/tokens/tokens.repository.ts @@ -0,0 +1,145 @@ +import { JwtService } from "@/modules/jwt/jwt.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { PlatformAuthorizationToken } from "@prisma/client"; +import { DateTime } from "luxon"; + +@Injectable() +export class TokensRepository { + constructor( + private readonly dbRead: PrismaReadService, + private readonly dbWrite: PrismaWriteService, + private readonly jwtService: JwtService + ) {} + + async createAuthorizationToken(clientId: string, userId: number): Promise { + return this.dbWrite.prisma.platformAuthorizationToken.create({ + data: { + client: { + connect: { + id: clientId, + }, + }, + owner: { + connect: { + id: userId, + }, + }, + }, + }); + } + + async invalidateAuthorizationToken(tokenId: string) { + return this.dbWrite.prisma.platformAuthorizationToken.delete({ + where: { + id: tokenId, + }, + }); + } + + async getAuthorizationTokenByClientUserIds(clientId: string, userId: number) { + return this.dbRead.prisma.platformAuthorizationToken.findFirst({ + where: { + platformOAuthClientId: clientId, + userId: userId, + }, + }); + } + + async createOAuthTokens(clientId: string, ownerId: number) { + const accessExpiry = DateTime.now().plus({ minute: 1 }).startOf("minute").toJSDate(); + const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); + const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([ + this.dbWrite.prisma.accessToken.create({ + data: { + secret: this.jwtService.signAccessToken({ clientId, ownerId }), + expiresAt: accessExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: ownerId } }, + }, + }), + this.dbWrite.prisma.refreshToken.create({ + data: { + secret: this.jwtService.signRefreshToken({ clientId, ownerId }), + expiresAt: refreshExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: ownerId } }, + }, + }), + ]); + + return { + accessToken: accessToken.secret, + refreshToken: refreshToken.secret, + }; + } + + async getAccessTokenExpiryDate(accessTokenSecret: string) { + const accessToken = await this.dbRead.prisma.accessToken.findFirst({ + where: { + secret: accessTokenSecret, + }, + select: { + expiresAt: true, + }, + }); + return accessToken?.expiresAt; + } + + async getAccessTokenOwnerId(accessTokenSecret: string) { + const accessToken = await this.dbRead.prisma.accessToken.findFirst({ + where: { + secret: accessTokenSecret, + }, + select: { + userId: true, + }, + }); + + return accessToken?.userId; + } + + async refreshOAuthTokens(clientId: string, refreshTokenSecret: string, tokenUserId: number) { + const accessExpiry = DateTime.now().plus({ minute: 1 }).startOf("minute").toJSDate(); + const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, _refresh, accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([ + this.dbWrite.prisma.accessToken.deleteMany({ + where: { client: { id: clientId }, expiresAt: { lte: new Date() } }, + }), + this.dbWrite.prisma.refreshToken.delete({ where: { secret: refreshTokenSecret } }), + this.dbWrite.prisma.accessToken.create({ + data: { + secret: this.jwtService.signAccessToken({ clientId, userId: tokenUserId }), + expiresAt: accessExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: tokenUserId } }, + }, + }), + this.dbWrite.prisma.refreshToken.create({ + data: { + secret: this.jwtService.signRefreshToken({ clientId, userId: tokenUserId }), + expiresAt: refreshExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: tokenUserId } }, + }, + }), + ]); + return { accessToken, refreshToken }; + } + + async getAccessTokenClient(accessToken: string) { + const token = await this.dbRead.prisma.accessToken.findFirst({ + where: { + secret: accessToken, + }, + select: { + client: true, + }, + }); + + return token?.client; + } +} diff --git a/apps/api/v2/src/modules/users/inputs/create-user.input.ts b/apps/api/v2/src/modules/users/inputs/create-user.input.ts new file mode 100644 index 00000000000000..72d8b91901dfa6 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/create-user.input.ts @@ -0,0 +1,30 @@ +import { IsTimeFormat } from "@/modules/users/inputs/validators/is-time-format"; +import { IsWeekStart } from "@/modules/users/inputs/validators/is-week-start"; +import { IsNumber, IsOptional, IsTimeZone, IsString, Validate } from "class-validator"; + +export class CreateUserInput { + @IsString() + email!: string; + + @IsString() + @IsOptional() + name?: string; + + @IsNumber() + @IsOptional() + @Validate(IsTimeFormat) + timeFormat?: number; + + @IsNumber() + @IsOptional() + defaultScheduleId?: number; + + @IsString() + @IsOptional() + @Validate(IsWeekStart) + weekStart?: string; + + @IsTimeZone() + @IsOptional() + timeZone?: string; +} diff --git a/apps/api/v2/src/modules/users/inputs/update-user.input.ts b/apps/api/v2/src/modules/users/inputs/update-user.input.ts new file mode 100644 index 00000000000000..db0caba2901632 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/update-user.input.ts @@ -0,0 +1,31 @@ +import { IsTimeFormat } from "@/modules/users/inputs/validators/is-time-format"; +import { IsWeekStart } from "@/modules/users/inputs/validators/is-week-start"; +import { IsNumber, IsOptional, IsString, IsTimeZone, Validate } from "class-validator"; + +export class UpdateUserInput { + @IsString() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + name?: string; + + @IsNumber() + @IsOptional() + @Validate(IsTimeFormat) + timeFormat?: number; + + @IsNumber() + @IsOptional() + defaultScheduleId?: number; + + @IsString() + @IsOptional() + @Validate(IsWeekStart) + weekStart?: string; + + @IsTimeZone() + @IsOptional() + timeZone?: string; +} diff --git a/apps/api/v2/src/modules/users/inputs/validators/is-time-format.ts b/apps/api/v2/src/modules/users/inputs/validators/is-time-format.ts new file mode 100644 index 00000000000000..2271a7e4f74420 --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/validators/is-time-format.ts @@ -0,0 +1,12 @@ +import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator"; + +@ValidatorConstraint({ name: "isTimeFormat", async: false }) +export class IsTimeFormat implements ValidatorConstraintInterface { + validate(timeFormat: number) { + return timeFormat === 12 || timeFormat === 24; + } + + defaultMessage() { + return "timeFormat must be a number either 12 or 24"; + } +} diff --git a/apps/api/v2/src/modules/users/inputs/validators/is-week-start.ts b/apps/api/v2/src/modules/users/inputs/validators/is-week-start.ts new file mode 100644 index 00000000000000..d21cf1271910ec --- /dev/null +++ b/apps/api/v2/src/modules/users/inputs/validators/is-week-start.ts @@ -0,0 +1,23 @@ +import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator"; + +@ValidatorConstraint({ name: "isWeekStart", async: false }) +export class IsWeekStart implements ValidatorConstraintInterface { + validate(weekStart: string) { + if (!weekStart) return false; + + const lowerCaseWeekStart = weekStart.toLowerCase(); + return ( + lowerCaseWeekStart === "monday" || + lowerCaseWeekStart === "tuesday" || + lowerCaseWeekStart === "wednesday" || + lowerCaseWeekStart === "thursday" || + lowerCaseWeekStart === "friday" || + lowerCaseWeekStart === "saturday" || + lowerCaseWeekStart === "sunday" + ); + } + + defaultMessage() { + return "weekStart must be a string either Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, or Sunday"; + } +} diff --git a/apps/api/v2/src/modules/users/users.module.ts b/apps/api/v2/src/modules/users/users.module.ts new file mode 100644 index 00000000000000..932f4e0ea9c606 --- /dev/null +++ b/apps/api/v2/src/modules/users/users.module.ts @@ -0,0 +1,10 @@ +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [UsersRepository], + exports: [UsersRepository], +}) +export class UsersModule {} diff --git a/apps/api/v2/src/modules/users/users.repository.ts b/apps/api/v2/src/modules/users/users.repository.ts new file mode 100644 index 00000000000000..1557dfe00aab36 --- /dev/null +++ b/apps/api/v2/src/modules/users/users.repository.ts @@ -0,0 +1,127 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; +import { UpdateUserInput } from "@/modules/users/inputs/update-user.input"; +import { Injectable } from "@nestjs/common"; +import type { Profile, User } from "@prisma/client"; + +export type UserWithProfile = User & { + movedToProfile?: Profile | null; +}; + +@Injectable() +export class UsersRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async create(user: CreateUserInput, username: string, oAuthClientId: string) { + this.formatInput(user); + + return this.dbRead.prisma.user.create({ + data: { + ...user, + username, + platformOAuthClients: { + connect: { id: oAuthClientId }, + }, + }, + }); + } + + async addToOAuthClient(userId: number, oAuthClientId: string) { + return this.dbRead.prisma.user.update({ + data: { + platformOAuthClients: { + connect: { id: oAuthClientId }, + }, + }, + where: { id: userId }, + }); + } + + async findById(userId: number) { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + }, + }); + } + + async findByIdWithProfile(userId: number): Promise { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + }, + include: { + movedToProfile: true, + }, + }); + } + + async findByIdWithCalendars(userId: number) { + return this.dbRead.prisma.user.findUnique({ + where: { + id: userId, + }, + include: { + selectedCalendars: true, + destinationCalendar: true, + }, + }); + } + + async findByEmail(email: string) { + return this.dbRead.prisma.user.findUnique({ + where: { + email, + }, + }); + } + + async update(userId: number, updateData: UpdateUserInput) { + this.formatInput(updateData); + + return this.dbWrite.prisma.user.update({ + where: { id: userId }, + data: updateData, + }); + } + + async delete(userId: number): Promise { + return this.dbWrite.prisma.user.delete({ + where: { id: userId }, + }); + } + + formatInput(userInput: CreateUserInput | UpdateUserInput) { + if (userInput.weekStart) { + userInput.weekStart = capitalize(userInput.weekStart); + } + + if (userInput.timeZone) { + userInput.timeZone = capitalizeTimezone(userInput.timeZone); + } + } + + setDefaultSchedule(userId: number, scheduleId: number) { + return this.dbWrite.prisma.user.update({ + where: { id: userId }, + data: { + defaultScheduleId: scheduleId, + }, + }); + } +} + +function capitalizeTimezone(timezone: string) { + const segments = timezone.split("/"); + + const capitalizedSegments = segments.map((segment) => { + return capitalize(segment); + }); + + return capitalizedSegments.join("/"); +} + +function capitalize(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} diff --git a/apps/api/v2/swagger/copy-swagger-module.ts b/apps/api/v2/swagger/copy-swagger-module.ts new file mode 100644 index 00000000000000..8f6f23d3f382ff --- /dev/null +++ b/apps/api/v2/swagger/copy-swagger-module.ts @@ -0,0 +1,28 @@ +import * as fs from "fs-extra"; +import * as path from "path"; + +// First, copyNestSwagger is required to enable "@nestjs/swagger" in the "nest-cli.json", +// because nest-cli.json is resolving "@nestjs/swagger" plugin from +// project's node_modules, but due to dependency hoisting, the "@nestjs/swagger" is located in the root node_modules. +// Second, we need to run this before starting the application using "nest start", because "nest start" is ran by +// "nest-cli" with the "nest-cli.json" file, and for nest cli to be loaded with plugins correctly the "@nestjs/swagger" +// should reside in the project's node_modules already before the "nest start" command is executed. +async function copyNestSwagger() { + const monorepoRoot = path.resolve(__dirname, "../../../../"); + const nodeModulesNestjs = path.resolve(__dirname, "../node_modules/@nestjs"); + const swaggerModulePath = "@nestjs/swagger"; + + const sourceDir = path.join(monorepoRoot, "node_modules", swaggerModulePath); + const targetDir = path.join(nodeModulesNestjs, "swagger"); + + if (!(await fs.pathExists(targetDir))) { + try { + await fs.ensureDir(nodeModulesNestjs); + await fs.copy(sourceDir, targetDir); + } catch (error) { + console.error("Failed to copy @nestjs/swagger:", error); + } + } +} + +copyNestSwagger(); diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json new file mode 100644 index 00000000000000..7f3441eebe9329 --- /dev/null +++ b/apps/api/v2/swagger/documentation.json @@ -0,0 +1,1403 @@ +{ + "openapi": "3.0.0", + "paths": { + "/health": { + "get": { + "operationId": "AppController_getHealth", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/api/v2/events/public": { + "get": { + "operationId": "EventsController_getPublicEvent", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/oauth-clients/{clientId}/users": { + "post": { + "operationId": "OAuthClientUsersController_createUser", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUserInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/oauth-clients/{clientId}/users/{userId}": { + "get": { + "operationId": "OAuthClientUsersController_getUserById", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "patch": { + "operationId": "OAuthClientUsersController_updateUser", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "delete": { + "operationId": "OAuthClientUsersController_deleteUser", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/oauth-clients": { + "post": { + "operationId": "OAuthClientsController_createOAuthClient", + "summary": "", + "description": "⚠ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOAuthClientInput" + } + } + } + }, + "responses": { + "201": { + "description": "Create an OAuth client", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateOAuthClientResponseDto" + } + } + } + } + }, + "tags": ["Development only"] + }, + "get": { + "operationId": "OAuthClientsController_getOAuthClients", + "summary": "", + "description": "⚠ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOAuthClientsResponseDto" + } + } + } + } + }, + "tags": ["Development only"] + } + }, + "/api/v2/oauth-clients/{clientId}": { + "get": { + "operationId": "OAuthClientsController_getOAuthClientById", + "summary": "", + "description": "⚠ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOAuthClientResponseDto" + } + } + } + } + }, + "tags": ["Development only"] + }, + "patch": { + "operationId": "OAuthClientsController_updateOAuthClient", + "summary": "", + "description": "⚠ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOAuthClientInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOAuthClientResponseDto" + } + } + } + } + }, + "tags": ["Development only"] + }, + "delete": { + "operationId": "OAuthClientsController_deleteOAuthClient", + "summary": "", + "description": "⚠ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetOAuthClientResponseDto" + } + } + } + } + }, + "tags": ["Development only"] + } + }, + "/api/v2/oauth/{clientId}/authorize": { + "post": { + "operationId": "OAuthFlowController_authorize", + "summary": "Authorize an OAuth client", + "description": "Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthAuthorizeInput" + } + } + } + }, + "responses": { + "200": { + "description": "The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`" + }, + "400": { + "description": "Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client." + } + }, + "tags": ["Development only"] + } + }, + "/api/v2/oauth/{clientId}/exchange": { + "post": { + "operationId": "OAuthFlowController_exchange", + "summary": "Exchange authorization code for access tokens", + "description": "Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExchangeAuthorizationCodeInput" + } + } + } + }, + "responses": { + "200": { + "description": "Successfully exchanged authorization code for access and refresh tokens.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeysResponseDto" + } + } + } + }, + "400": { + "description": "Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match." + } + }, + "tags": ["Development only"] + } + }, + "/api/v2/oauth/{clientId}/refresh": { + "post": { + "operationId": "OAuthFlowController_refreshAccessToken", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshTokenInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeysResponseDto" + } + } + } + } + }, + "tags": ["Development only"] + } + }, + "/api/v2/event-types": { + "post": { + "operationId": "EventTypesController_createEventType", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEventTypeInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/event-types/{eventTypeId}": { + "get": { + "operationId": "EventTypesController_getEventType", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/platform/gcal/oauth/auth-url": { + "get": { + "operationId": "GcalController_redirect", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/platform/gcal/oauth/save": { + "get": { + "operationId": "GcalController_save", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/platform/gcal/check": { + "get": { + "operationId": "GcalController_check", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/platform/provider/{clientId}": { + "get": { + "operationId": "CalProviderController_verifyClientId", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/platform/provider/{clientId}/access-token": { + "get": { + "operationId": "CalProviderController_verifyAccessToken", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/schedules": { + "post": { + "operationId": "SchedulesController_createSchedule", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateScheduleInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "get": { + "operationId": "SchedulesController_getSchedules", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/schedules/default": { + "get": { + "operationId": "SchedulesController_getDefaultSchedule", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/schedules/time-zones": { + "get": { + "operationId": "SchedulesController_getTimeZones", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/schedules/{scheduleId}": { + "get": { + "operationId": "SchedulesController_getSchedule", + "parameters": [ + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "patch": { + "operationId": "SchedulesController_updateSchedule", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "delete": { + "operationId": "SchedulesController_deleteSchedule", + "parameters": [ + { + "name": "scheduleId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/me": { + "get": { + "operationId": "MeController_getMe", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + }, + "patch": { + "operationId": "MeController_updateMe", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateUserInput" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/ee/calendars/busy-times": { + "get": { + "operationId": "CalendarsController_getBusyTimes", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/ee/calendars": { + "get": { + "operationId": "CalendarsController_getCalendars", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/ee/bookings": { + "post": { + "operationId": "BookingsController_createBooking", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBookingInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/ee/bookings/reccuring": { + "post": { + "operationId": "BookingsController_createReccuringBooking", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/ee/bookings/instant": { + "post": { + "operationId": "BookingsController_createInstantBooking", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateBookingInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/slots/reserve": { + "post": { + "operationId": "SlotsController_reserveSlot", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReserveSlotInput" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/slots/selected-slot": { + "delete": { + "operationId": "SlotsController_deleteSelectedSlot", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/api/v2/slots/available": { + "get": { + "operationId": "SlotsController_getAvailableSlots", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + } + }, + "info": { + "title": "Cal.com v2 API", + "description": "", + "version": "1.0.0", + "contact": {} + }, + "tags": [], + "servers": [], + "components": { + "schemas": { + "CreateUserInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "timeFormat": { + "type": "number" + }, + "defaultScheduleId": { + "type": "number" + }, + "weekStart": { + "type": "string" + }, + "timeZone": { + "type": "string" + } + }, + "required": ["email"] + }, + "UpdateUserInput": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "timeFormat": { + "type": "number" + }, + "defaultScheduleId": { + "type": "number" + }, + "weekStart": { + "type": "string" + }, + "timeZone": { + "type": "string" + } + } + }, + "CreateOAuthClientInput": { + "type": "object", + "properties": {} + }, + "DataDto": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "example": "clsx38nbl0001vkhlwin9fmt0" + }, + "clientSecret": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi" + } + }, + "required": ["clientId", "clientSecret"] + }, + "CreateOAuthClientResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "example": { + "clientId": "clsx38nbl0001vkhlwin9fmt0", + "clientSecret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoib2F1dGgtY2xpZW50Iiwi" + }, + "allOf": [ + { + "$ref": "#/components/schemas/DataDto" + } + ] + } + }, + "required": ["status", "data"] + }, + "PlatformOAuthClientDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "clsx38nbl0001vkhlwin9fmt0" + }, + "name": { + "type": "string", + "example": "MyClient" + }, + "secret": { + "type": "string", + "example": "secretValue" + }, + "permissions": { + "type": "number", + "example": 3 + }, + "logo": { + "type": "object", + "example": "https://example.com/logo.png" + }, + "redirectUris": { + "example": ["https://example.com/callback"], + "type": "array", + "items": { + "type": "string" + } + }, + "organizationId": { + "type": "number", + "example": 1 + }, + "createdAt": { + "format": "date-time", + "type": "string", + "example": "2024-03-08T14:10:38.418Z" + } + }, + "required": ["id", "name", "secret", "permissions", "redirectUris", "organizationId", "createdAt"] + }, + "GetOAuthClientsResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PlatformOAuthClientDto" + } + } + }, + "required": ["status", "data"] + }, + "GetOAuthClientResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/PlatformOAuthClientDto" + } + }, + "required": ["status", "data"] + }, + "UpdateOAuthClientInput": { + "type": "object", + "properties": { + "logo": { + "type": "string" + }, + "name": { + "type": "string" + }, + "redirectUris": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "OAuthAuthorizeInput": { + "type": "object", + "properties": { + "redirectUri": { + "type": "string" + } + }, + "required": ["redirectUri"] + }, + "ExchangeAuthorizationCodeInput": { + "type": "object", + "properties": { + "clientSecret": { + "type": "string" + } + }, + "required": ["clientSecret"] + }, + "KeysDto": { + "type": "object", + "properties": { + "accessToken": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, + "refreshToken": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + } + }, + "required": ["accessToken", "refreshToken"] + }, + "KeysResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/KeysDto" + } + }, + "required": ["status", "data"] + }, + "RefreshTokenInput": { + "type": "object", + "properties": { + "refreshToken": { + "type": "string" + } + }, + "required": ["refreshToken"] + }, + "CreateEventTypeInput": { + "type": "object", + "properties": { + "length": { + "type": "number", + "minimum": 1 + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": ["length", "slug", "title"] + }, + "CreateAvailabilityInput": { + "type": "object", + "properties": { + "days": { + "type": "array", + "items": { + "type": "number" + } + }, + "startTime": { + "format": "date-time", + "type": "string" + }, + "endTime": { + "format": "date-time", + "type": "string" + } + }, + "required": ["days", "startTime", "endTime"] + }, + "CreateScheduleInput": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "availabilities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateAvailabilityInput" + } + }, + "isDefault": { + "type": "object", + "default": true + } + }, + "required": ["name", "timeZone", "isDefault"] + }, + "UpdateScheduleInput": { + "type": "object", + "properties": {} + }, + "CreateBookingInput": { + "type": "object", + "properties": { + "end": { + "type": "string" + }, + "start": { + "type": "string" + }, + "eventTypeId": { + "type": "number" + }, + "eventTypeSlug": { + "type": "string" + }, + "rescheduleUid": { + "type": "string" + }, + "recurringEventId": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "user": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "type": "string" + }, + "bookingUid": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "hasHashedBookingLink": { + "type": "boolean" + }, + "hashedLink": { + "type": "string", + "nullable": true + }, + "seatReferenceUid": { + "type": "string" + } + }, + "required": ["start", "eventTypeId", "timeZone", "language", "metadata", "hashedLink"] + }, + "ReserveSlotInput": { + "type": "object", + "properties": {} + } + } + } +} diff --git a/apps/api/v2/test/fixtures/repository/credentials.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/credentials.repository.fixture.ts new file mode 100644 index 00000000000000..449d1124ec2bd6 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/credentials.repository.fixture.ts @@ -0,0 +1,33 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma } from "@prisma/client"; + +export class CredentialsRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + create(type: string, key: Prisma.InputJsonValue, userId: number, appId: string) { + return this.prismaWriteClient.credential.create({ + data: { + type, + key, + userId, + appId, + }, + }); + } + + delete(id: number) { + return this.prismaWriteClient.credential.delete({ + where: { + id, + }, + }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts new file mode 100644 index 00000000000000..03e98a4dd4e3ca --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/event-types.repository.fixture.ts @@ -0,0 +1,36 @@ +import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { EventType } from "@prisma/client"; + +export class EventTypesRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async getAllUserEventTypes(userId: number) { + return this.prismaWriteClient.eventType.findMany({ + where: { + userId, + }, + }); + } + + async create(data: CreateEventTypeInput, userId: number) { + return this.prismaWriteClient.eventType.create({ + data: { + ...data, + userId, + }, + }); + } + + async delete(eventTypeId: EventType["id"]) { + return this.prismaWriteClient.eventType.delete({ where: { id: eventTypeId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts new file mode 100644 index 00000000000000..21ee461df0f66e --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/membership.repository.fixture.ts @@ -0,0 +1,34 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Membership, MembershipRole, Prisma, Team, User } from "@prisma/client"; + +export class MembershipRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(data: Prisma.MembershipCreateInput) { + return this.prismaWriteClient.membership.create({ data }); + } + + async delete(membershipId: Membership["id"]) { + return this.prismaWriteClient.membership.delete({ where: { id: membershipId } }); + } + + async get(membershipId: Membership["id"]) { + return this.primaReadClient.membership.findFirst({ where: { id: membershipId } }); + } + + async addUserToOrg(user: User, org: Team, role: MembershipRole, accepted: boolean) { + const membership = await this.prismaWriteClient.membership.create({ + data: { teamId: org.id, userId: user.id, role, accepted }, + }); + await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } }); + return membership; + } +} diff --git a/apps/api/v2/test/fixtures/repository/oauth-client.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/oauth-client.repository.fixture.ts new file mode 100644 index 00000000000000..4aeb2868b041dc --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/oauth-client.repository.fixture.ts @@ -0,0 +1,49 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { PlatformOAuthClient } from "@prisma/client"; + +import { CreateOAuthClientInput } from "@calcom/platform-types"; + +export class OAuthClientRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(clientId: PlatformOAuthClient["id"]) { + return this.prismaReadClient.platformOAuthClient.findFirst({ where: { id: clientId } }); + } + + async getUsers(clientId: PlatformOAuthClient["id"]) { + const response = await this.prismaReadClient.platformOAuthClient.findFirst({ + where: { id: clientId }, + include: { + users: true, + }, + }); + + return response?.users; + } + + async create(organizationId: number, data: CreateOAuthClientInput, secret: string) { + return this.prismaWriteClient.platformOAuthClient.create({ + data: { + ...data, + secret, + organizationId, + }, + }); + } + + async delete(clientId: PlatformOAuthClient["id"]) { + return this.prismaWriteClient.platformOAuthClient.delete({ where: { id: clientId } }); + } + + async deleteByClientId(clientId: PlatformOAuthClient["id"]) { + return this.prismaWriteClient.platformOAuthClient.delete({ where: { id: clientId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts new file mode 100644 index 00000000000000..f34be13c9f236b --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/schedules.repository.fixture.ts @@ -0,0 +1,26 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Schedule } from "@prisma/client"; + +export class SchedulesRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async getById(scheduleId: Schedule["id"]) { + return this.primaReadClient.schedule.findFirst({ where: { id: scheduleId } }); + } + + async deleteById(scheduleId: Schedule["id"]) { + return this.prismaWriteClient.schedule.delete({ where: { id: scheduleId } }); + } + + async deleteAvailabilities(scheduleId: Schedule["id"]) { + return this.prismaWriteClient.availability.deleteMany({ where: { scheduleId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts new file mode 100644 index 00000000000000..1d059087318298 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/team.repository.fixture.ts @@ -0,0 +1,26 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma, Team } from "@prisma/client"; + +export class TeamRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(teamId: Team["id"]) { + return this.primaReadClient.team.findFirst({ where: { id: teamId } }); + } + + async create(data: Prisma.TeamCreateInput) { + return this.prismaWriteClient.team.create({ data }); + } + + async delete(teamId: Team["id"]) { + return this.prismaWriteClient.team.delete({ where: { id: teamId } }); + } +} diff --git a/apps/api/v2/test/fixtures/repository/tokens.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/tokens.repository.fixture.ts new file mode 100644 index 00000000000000..6716f17948190a --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/tokens.repository.fixture.ts @@ -0,0 +1,47 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import * as crypto from "crypto"; +import { DateTime } from "luxon"; + +export class TokensRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async createTokens(userId: number, clientId: string) { + const accessExpiry = DateTime.now().plus({ days: 1 }).startOf("day").toJSDate(); + const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate(); + const accessTokenBuffer = crypto.randomBytes(48); + const accessTokenSecret = accessTokenBuffer.toString("hex"); + const refreshTokenBuffer = crypto.randomBytes(48); + const refreshTokenSecret = refreshTokenBuffer.toString("hex"); + const [accessToken, refreshToken] = await this.prismaWriteClient.$transaction([ + this.prismaWriteClient.accessToken.create({ + data: { + secret: accessTokenSecret, + expiresAt: accessExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: userId } }, + }, + }), + this.prismaWriteClient.refreshToken.create({ + data: { + secret: refreshTokenSecret, + expiresAt: refreshExpiry, + client: { connect: { id: clientId } }, + owner: { connect: { id: userId } }, + }, + }), + ]); + + return { + accessToken: accessToken.secret, + refreshToken: refreshToken.secret, + }; + } +} diff --git a/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts new file mode 100644 index 00000000000000..ce0035ab4cc30f --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/users.repository.fixture.ts @@ -0,0 +1,51 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma, User } from "@prisma/client"; + +export class UserRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(userId: User["id"]) { + return this.primaReadClient.user.findFirst({ where: { id: userId } }); + } + + async create(data: Prisma.UserCreateInput) { + try { + // avoid uniq constraint in tests + await this.deleteByEmail(data.email); + } catch {} + + return this.prismaWriteClient.user.create({ data }); + } + + async createOAuthManagedUser(email: Prisma.UserCreateInput["email"], oAuthClientId: string) { + try { + // avoid uniq constraint in tests + await this.deleteByEmail(email); + } catch {} + + return this.prismaWriteClient.user.create({ + data: { + email, + platformOAuthClients: { + connect: { id: oAuthClientId }, + }, + }, + }); + } + + async delete(userId: User["id"]) { + return this.prismaWriteClient.user.delete({ where: { id: userId } }); + } + + async deleteByEmail(email: User["email"]) { + return this.prismaWriteClient.user.delete({ where: { email } }); + } +} diff --git a/apps/api/v2/test/mocks/access-token-mock.strategy.ts b/apps/api/v2/test/mocks/access-token-mock.strategy.ts new file mode 100644 index 00000000000000..f7c89a2beb8ee0 --- /dev/null +++ b/apps/api/v2/test/mocks/access-token-mock.strategy.ts @@ -0,0 +1,25 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; + +@Injectable() +export class AccessTokenMockStrategy extends PassportStrategy(BaseStrategy, "access-token") { + constructor(private readonly email: string, private readonly usersRepository: UsersRepository) { + super(); + } + + async authenticate() { + try { + const user = await this.usersRepository.findByEmail(this.email); + if (!user) { + throw new Error("User with the provided ID not found"); + } + + return this.success(user); + } catch (error) { + console.error(error); + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/test/mocks/next-auth-mock.strategy.ts b/apps/api/v2/test/mocks/next-auth-mock.strategy.ts new file mode 100644 index 00000000000000..96e92b00a8a42f --- /dev/null +++ b/apps/api/v2/test/mocks/next-auth-mock.strategy.ts @@ -0,0 +1,24 @@ +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { Injectable } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; + +@Injectable() +export class NextAuthMockStrategy extends PassportStrategy(BaseStrategy, "next-auth") { + constructor(private readonly email: string, private readonly userRepository: UsersRepository) { + super(); + } + async authenticate() { + try { + const user = await this.userRepository.findByEmail(this.email); + if (!user) { + throw new Error("User with the provided email not found"); + } + + return this.success(user); + } catch (error) { + console.error(error); + if (error instanceof Error) return this.error(error); + } + } +} diff --git a/apps/api/v2/test/setEnvVars.ts b/apps/api/v2/test/setEnvVars.ts new file mode 100644 index 00000000000000..d353423dfa1238 --- /dev/null +++ b/apps/api/v2/test/setEnvVars.ts @@ -0,0 +1,16 @@ +import type { Environment } from "@/env"; + +const env: Partial> = { + API_PORT: "5555", + DATABASE_READ_URL: "postgresql://postgres:@localhost:5450/calendso", + DATABASE_WRITE_URL: "postgresql://postgres:@localhost:5450/calendso", + NEXTAUTH_SECRET: "XF+Hws3A5g2eyWA5uGYYVJ74X+wrCWJ8oWo6kAfU6O8=", + JWT_SECRET: "XF+Hws3A5g2eyWA5uGYYVJ74X+wrCWJ8oWo6kAfU6O8=", + LOG_LEVEL: "trace", + REDIS_URL: "redis://localhost:9199", +}; + +process.env = { + ...env, + ...process.env, +}; diff --git a/apps/api/v2/test/utils/withAccessTokenAuth.ts b/apps/api/v2/test/utils/withAccessTokenAuth.ts new file mode 100644 index 00000000000000..809de3c683b873 --- /dev/null +++ b/apps/api/v2/test/utils/withAccessTokenAuth.ts @@ -0,0 +1,10 @@ +import { AccessTokenStrategy } from "@/modules/auth/strategies/access-token/access-token.strategy"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { TestingModuleBuilder } from "@nestjs/testing"; +import { AccessTokenMockStrategy } from "test/mocks/access-token-mock.strategy"; + +export const withAccessTokenAuth = (email: string, module: TestingModuleBuilder) => + module.overrideProvider(AccessTokenStrategy).useFactory({ + factory: (usersRepository: UsersRepository) => new AccessTokenMockStrategy(email, usersRepository), + inject: [UsersRepository], + }); diff --git a/apps/api/v2/test/utils/withNextAuth.ts b/apps/api/v2/test/utils/withNextAuth.ts new file mode 100644 index 00000000000000..4a96bf7edfd93a --- /dev/null +++ b/apps/api/v2/test/utils/withNextAuth.ts @@ -0,0 +1,10 @@ +import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { TestingModuleBuilder } from "@nestjs/testing"; +import { NextAuthMockStrategy } from "test/mocks/next-auth-mock.strategy"; + +export const withNextAuth = (email: string, module: TestingModuleBuilder) => + module.overrideProvider(NextAuthStrategy).useFactory({ + factory: (userRepository: UsersRepository) => new NextAuthMockStrategy(email, userRepository), + inject: [UsersRepository], + }); diff --git a/apps/api/v2/tsconfig.build.json b/apps/api/v2/tsconfig.build.json new file mode 100644 index 00000000000000..64f86c6bd2bb30 --- /dev/null +++ b/apps/api/v2/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/api/v2/tsconfig.json b/apps/api/v2/tsconfig.json new file mode 100644 index 00000000000000..3e738f626136f8 --- /dev/null +++ b/apps/api/v2/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "resolveJsonModule": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": ".", + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"], + "@prisma/client/*": ["@calcom/prisma/client/*"] + }, + "incremental": true, + "skipLibCheck": true, + "strict": true, + "noImplicitAny": true, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false + }, + "exclude": ["./dist", "next-i18next.config.js"], + "include": ["./**/*.ts", "../../../packages/types/*.d.ts"] +} diff --git a/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientCard.tsx b/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientCard.tsx new file mode 100644 index 00000000000000..d5afcfd42992bf --- /dev/null +++ b/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientCard.tsx @@ -0,0 +1,124 @@ +import { Asterisk, Clipboard } from "lucide-react"; +import React from "react"; + +import { classNames } from "@calcom/lib"; +import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants"; +import type { Avatar } from "@calcom/prisma/client"; +import { Button, showToast } from "@calcom/ui"; + +import { hasPermission } from "../../../../../../../packages/platform/utils/permissions"; + +type OAuthClientCardProps = { + name: string; + logo?: Avatar; + redirectUris: string[]; + permissions: number; + lastItem: boolean; + id: string; + secret: string; + onDelete: (id: string) => Promise; + isLoading: boolean; +}; + +export const OAuthClientCard = ({ + name, + logo, + redirectUris, + permissions, + id, + secret, + lastItem, + onDelete, + isLoading, +}: OAuthClientCardProps) => { + const clientPermissions = Object.values(PERMISSIONS_GROUPED_MAP).map((value, index) => { + let permissionsMessage = ""; + const hasReadPermission = hasPermission(permissions, value.read); + const hasWritePermission = hasPermission(permissions, value.write); + + if (hasReadPermission || hasWritePermission) { + permissionsMessage = hasReadPermission ? "read" : "write"; + } + + if (hasReadPermission && hasWritePermission) { + permissionsMessage = "read/write"; + } + + return ( + !!permissionsMessage && ( +
+  {permissionsMessage} {`${value.label}s`.toLocaleLowerCase()} + {Object.values(PERMISSIONS_GROUPED_MAP).length === index + 1 ? " " : ", "} +
+ ) + ); + }); + + return ( +
+
+
+

+ Client name: {name} +

+
+ {!!logo && ( +
+ <>{logo} +
+ )} +
+
+
Client Id:
+
{id}
+ { + navigator.clipboard.writeText(id); + showToast("Client id copied to clipboard.", "success"); + }} + /> +
+
+
+
Client Secret:
+
+ {[...new Array(20)].map((_, index) => ( + + ))} + { + navigator.clipboard.writeText(secret); + showToast("Client secret copied to clipboard.", "success"); + }} + /> +
+
+
+ Permissions: +
{clientPermissions}
+
+
+ Redirect uris: + {redirectUris.map((item, index) => (redirectUris.length === index + 1 ? `${item}` : `${item}, `))} +
+
+
+ +
+
+ ); +}; diff --git a/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientForm.tsx b/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientForm.tsx new file mode 100644 index 00000000000000..11f341c1c40735 --- /dev/null +++ b/apps/web/components/settings/organizations/platform/oauth-clients/OAuthClientForm.tsx @@ -0,0 +1,228 @@ +import { useRouter } from "next/router"; +import type { FC } from "react"; +import React, { useState, useCallback } from "react"; +import { useForm, useFieldArray } from "react-hook-form"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions"; +import { showToast } from "@calcom/ui"; +import { Meta, Button, TextField, Label } from "@calcom/ui"; +import { Plus, Trash } from "@calcom/ui/components/icon"; + +import { useCreateOAuthClient } from "@lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient"; + +type FormValues = { + name: string; + logo?: string; + permissions: number; + eventTypeRead: boolean; + eventTypeWrite: boolean; + bookingRead: boolean; + bookingWrite: boolean; + scheduleRead: boolean; + scheduleWrite: boolean; + appsRead: boolean; + appsWrite: boolean; + profileRead: boolean; + profileWrite: boolean; + redirectUris: { + uri: string; + }[]; +}; + +export const OAuthClientForm: FC = () => { + const { t } = useLocale(); + const router = useRouter(); + const { register, control, handleSubmit, setValue } = useForm({ + defaultValues: { + redirectUris: [{ uri: "" }], + }, + }); + const { fields, append, remove } = useFieldArray({ + control, + name: "redirectUris", + }); + const [isSelectAllPermissionsChecked, setIsSelectAllPermissionsChecked] = useState(false); + + const selectAllPermissions = useCallback(() => { + Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => { + const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP; + const permissionKey = PERMISSIONS_GROUPED_MAP[entity].key; + + setValue(`${permissionKey}Read`, !isSelectAllPermissionsChecked); + setValue(`${permissionKey}Write`, !isSelectAllPermissionsChecked); + }); + + setIsSelectAllPermissionsChecked((preValue) => !preValue); + }, [isSelectAllPermissionsChecked, setValue]); + + const { mutateAsync, isPending } = useCreateOAuthClient({ + onSuccess: () => { + showToast("OAuth client created successfully", "success"); + router.push("/settings/organizations/platform/oauth-clients"); + }, + onError: () => { + showToast("Internal server error, please try again later", "error"); + }, + }); + + const onSubmit = (data: FormValues) => { + let userPermissions = 0; + const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri); + + Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => { + const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP; + const entityKey = PERMISSIONS_GROUPED_MAP[entity].key; + const read = PERMISSIONS_GROUPED_MAP[entity].read; + const write = PERMISSIONS_GROUPED_MAP[entity].write; + if (data[`${entityKey}Read`]) userPermissions |= read; + if (data[`${entityKey}Write`]) userPermissions |= write; + }); + + mutateAsync({ + name: data.name, + permissions: userPermissions, + // logo: data.logo, + redirectUris: userRedirectUris, + }); + }; + + const permissionsCheckboxes = Object.keys(PERMISSIONS_GROUPED_MAP).map((key) => { + const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP; + const permissionKey = PERMISSIONS_GROUPED_MAP[entity].key; + const permissionLabel = PERMISSIONS_GROUPED_MAP[entity].label; + + return ( +
+

{permissionLabel}

+
+
+ + +
+
+ + +
+
+
+ ); + }); + + return ( +
+ +
+
+ +
+
+ + {fields.map((field, index) => { + return ( +
+
+ +
+
+
+
+ ); + })} +
+ {/**
+ ( + <> + +
+ } + size="sm" + /> +
+ { + setValue("logo", newAvatar); + }} + /> +
+
+ + )} + /> +
*/} +
+
+

Permissions

+ +
+
{permissionsCheckboxes}
+
+ +
+
+ ); +}; diff --git a/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients.ts b/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients.ts new file mode 100644 index 00000000000000..6295ca7579601d --- /dev/null +++ b/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { ApiSuccessResponse } from "@calcom/platform-types"; +import type { PlatformOAuthClient } from "@calcom/prisma/client"; + +export const useOAuthClients = () => { + const query = useQuery>({ + queryKey: ["oauth-clients"], + queryFn: () => { + return fetch("/api/v2/oauth-clients", { + method: "get", + headers: { "Content-type": "application/json" }, + }).then((res) => res.json()); + }, + }); + + return { ...query, data: query.data?.data ?? [] }; +}; + +export const useOAuthClient = (clientId: string) => { + const { + isLoading, + error, + data: response, + } = useQuery>({ + queryKey: ["oauth-client"], + queryFn: () => { + return fetch(`/api/v2/oauth-clients/${clientId}`, { + method: "get", + headers: { "Content-type": "application/json" }, + }).then((res) => res.json()); + }, + }); + + return { isLoading, error, data: response?.data }; +}; diff --git a/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient.ts b/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient.ts new file mode 100644 index 00000000000000..975cacc55ef990 --- /dev/null +++ b/apps/web/lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient.ts @@ -0,0 +1,80 @@ +import { useMutation } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiResponse, CreateOAuthClientInput, DeleteOAuthClientInput } from "@calcom/platform-types"; +import type { OAuthClient } from "@calcom/prisma/client"; + +interface IPersistOAuthClient { + onSuccess?: () => void; + onError?: () => void; +} + +export const useCreateOAuthClient = ( + { onSuccess, onError }: IPersistOAuthClient = { + onSuccess: () => { + return; + }, + onError: () => { + return; + }, + } +) => { + const mutation = useMutation< + ApiResponse<{ clientId: string; clientSecret: string }>, + unknown, + CreateOAuthClientInput + >({ + mutationFn: (data) => { + return fetch("/api/v2/oauth-clients", { + method: "post", + headers: { "Content-type": "application/json" }, + body: JSON.stringify(data), + }).then((res) => res.json()); + }, + onSuccess: (data) => { + if (data.status === SUCCESS_STATUS) { + onSuccess?.(); + } else { + onError?.(); + } + }, + onError: () => { + onError?.(); + }, + }); + + return mutation; +}; + +export const useDeleteOAuthClient = ( + { onSuccess, onError }: IPersistOAuthClient = { + onSuccess: () => { + return; + }, + onError: () => { + return; + }, + } +) => { + const mutation = useMutation, unknown, DeleteOAuthClientInput>({ + mutationFn: (data) => { + const { id } = data; + return fetch(`/api/v2/oauth-clients/${id}`, { + method: "delete", + headers: { "Content-type": "application/json" }, + }).then((res) => res.json()); + }, + onSuccess: (data) => { + if (data.status === SUCCESS_STATUS) { + onSuccess?.(); + } else { + onError?.(); + } + }, + onError: () => { + onError?.(); + }, + }); + + return mutation; +}; diff --git a/apps/web/modules/users/views/users-type-public-view.tsx b/apps/web/modules/users/views/users-type-public-view.tsx index 050c0b13140632..ec1ac12b708c79 100644 --- a/apps/web/modules/users/views/users-type-public-view.tsx +++ b/apps/web/modules/users/views/users-type-public-view.tsx @@ -2,9 +2,9 @@ import { useSearchParams } from "next/navigation"; -import { Booker } from "@calcom/atoms"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; +import { Booker } from "@calcom/platform-atoms"; import { type PageProps } from "./users-type-public-view.getServerSideProps"; diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 32b7ec4cea4d60..c4b169d64d61e1 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -303,6 +303,10 @@ const nextConfig = { ]; let afterFiles = [ + { + source: "/api/v2/:path*", + destination: `${process.env.NEXT_PUBLIC_API_V2_URL}/:path*`, + }, { source: "/org/:slug", destination: "/team/:slug", diff --git a/apps/web/package.json b/apps/web/package.json index 5a4d61a2b4e742..c5725ef2969e4a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -116,6 +116,7 @@ "react-multi-email": "^0.5.3", "react-phone-input-2": "^2.15.1", "react-phone-number-input": "^3.2.7", + "react-query": "^3.39.3", "react-schemaorg": "^2.0.0", "react-select": "^5.7.0", "react-timezone-select": "^1.4.0", diff --git a/apps/web/pages/api/trpc/timezones/[trpc].ts b/apps/web/pages/api/trpc/timezones/[trpc].ts new file mode 100644 index 00000000000000..661af7e50fe29a --- /dev/null +++ b/apps/web/pages/api/trpc/timezones/[trpc].ts @@ -0,0 +1,4 @@ +import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler"; +import { timezonesRouter } from "@calcom/trpc/server/routers/publicViewer/timezones/_router"; + +export default createNextApiHandler(timezonesRouter, true); diff --git a/apps/web/pages/auth/platform/authorize.tsx b/apps/web/pages/auth/platform/authorize.tsx new file mode 100644 index 00000000000000..81163cdca0d1c9 --- /dev/null +++ b/apps/web/pages/auth/platform/authorize.tsx @@ -0,0 +1,130 @@ +import { useRouter } from "next/navigation"; + +import { APP_NAME } from "@calcom/lib/constants"; +import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Avatar, Button } from "@calcom/ui"; +import { Info } from "@calcom/ui/components/icon"; +import { Plus } from "@calcom/ui/components/icon"; + +import PageWrapper from "@components/PageWrapper"; + +import { PERMISSIONS_GROUPED_MAP } from "../../../../../packages/platform/constants/permissions"; +import { hasPermission } from "../../../../../packages/platform/utils/permissions"; + +export default function Authorize() { + const { t } = useLocale(); + const router = useRouter(); + + const searchParams = useCompatSearchParams(); + const queryString = searchParams?.toString(); + + // const { isLoading, error, data: client } = useOAuthClient(queryString); + + const client: { + name: string; + logo?: string; + redirect_uris: string[]; + permissions: number; + } = { + name: "Acme.com", + redirect_uris: ["", ""], + permissions: 7, + }; + + console.log("These are the search params:", queryString); + + const permissions = Object.values(PERMISSIONS_GROUPED_MAP).map((value) => { + let permissionsMessage = ""; + const hasReadPermission = hasPermission(client.permissions, value.read); + const hasWritePermission = hasPermission(client.permissions, value.write); + + if (hasReadPermission || hasWritePermission) { + permissionsMessage = hasReadPermission ? "Read" : "Write"; + } + + if (hasReadPermission && hasWritePermission) { + permissionsMessage = "Read, write"; + } + + return ( + !!permissionsMessage && ( +
  • + + {permissionsMessage} your {`${value.label}s`.toLocaleLowerCase()} +
  • + ) + ); + }); + + return ( +
    +
    +
    + {/* + below is where the client logo will be displayed + first we check if the client has a logo property and display logo if present + else we take logo from user profile pic + */} + {client.logo ? ( + } + className="items-center" + imageSrc={client.logo} + size="lg" + /> + ) : ( + } + className="items-center" + imageSrc="/cal-com-icon.svg" + size="lg" + /> + )} +
    +
    +
    + Logo +
    +
    +
    +
    +

    + {t("access_cal_account", { clientName: client.name, appName: APP_NAME })} +

    +
    + {t("allow_client_to", { clientName: client.name })} +
    +
      {permissions}
    +
    +
    + +
    +
    +
    + {t("allow_client_to_do", { clientName: client.name })} +
    +
    {t("oauth_access_information", { appName: APP_NAME })}
    {" "} +
    +
    +
    +
    + + +
    +
    +
    + ); +} + +Authorize.PageWrapper = PageWrapper; diff --git a/apps/web/pages/availability/[schedule].tsx b/apps/web/pages/availability/[schedule].tsx index 79993835a76124..b57f887a39fa6f 100644 --- a/apps/web/pages/availability/[schedule].tsx +++ b/apps/web/pages/availability/[schedule].tsx @@ -1,42 +1,19 @@ import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; -import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form"; +import { useForm } from "react-hook-form"; -import dayjs from "@calcom/dayjs"; -import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules"; -import Schedule from "@calcom/features/schedules/components/Schedule"; -import Shell from "@calcom/features/shell/Shell"; -import { classNames } from "@calcom/lib"; -import { availabilityAsString } from "@calcom/lib/availability"; import { withErrorFromUnknown } from "@calcom/lib/getClientErrorFromUnknown"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; +import { AvailabilitySettings } from "@calcom/platform-atoms"; import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; -import type { Schedule as ScheduleType, TimeRange, WorkingHours } from "@calcom/types/schedule"; -import { - Button, - ConfirmationDialogContent, - Dialog, - DialogTrigger, - Form, - Label, - showToast, - Skeleton, - SkeletonText, - Switch, - TimezoneSelect, - Tooltip, - VerticalDivider, -} from "@calcom/ui"; -import { ArrowLeft, Info, MoreVertical, Plus, Trash } from "@calcom/ui/components/icon"; +import type { Schedule as ScheduleType, TimeRange } from "@calcom/types/schedule"; +import { showToast } from "@calcom/ui"; import PageWrapper from "@components/PageWrapper"; -import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader"; -import EditableHeading from "@components/ui/EditableHeading"; -export type AvailabilityFormValues = { +type AvailabilityFormValues = { name: string; schedule: ScheduleType; dateOverrides: { ranges: TimeRange[] }[]; @@ -44,148 +21,15 @@ export type AvailabilityFormValues = { isDefault: boolean; }; -const useExcludedDates = () => { - const watchValues = useWatch({ name: "dateOverrides" }) as { - ranges: TimeRange[]; - }[]; - return useMemo(() => { - return watchValues?.map((field) => dayjs(field.ranges[0].start).utc().format("YYYY-MM-DD")); - }, [watchValues]); -}; - -const useSettings = () => { - const { data } = useMeQuery(); - return { - hour12: data?.timeFormat === 12, - timeZone: data?.timeZone, - }; -}; - -const DateOverride = ({ workingHours }: { workingHours: WorkingHours[] }) => { - const { hour12 } = useSettings(); - const { append, replace, fields } = useFieldArray({ - name: "dateOverrides", - }); - const excludedDates = useExcludedDates(); - const { t } = useLocale(); - return ( -
    -

    - {t("date_overrides")}{" "} - - - - - -

    -

    {t("date_overrides_subtitle")}

    -
    - - ranges.forEach((range) => append({ ranges: [range] }))} - Trigger={ - - } - /> -
    -
    - ); -}; - -const DeleteDialogButton = ({ - disabled, - scheduleId, - buttonClassName, - onDeleteConfirmed, -}: { - disabled?: boolean; - onDeleteConfirmed?: () => void; - buttonClassName: string; - scheduleId: number; -}) => { - const { t } = useLocale(); - const router = useRouter(); - const utils = trpc.useUtils(); - const { isPending, mutate } = trpc.viewer.availability.schedule.delete.useMutation({ - onError: withErrorFromUnknown((err) => { - showToast(err.message, "error"); - }), - onSettled: () => { - utils.viewer.availability.list.invalidate(); - }, - onSuccess: () => { - showToast(t("schedule_deleted_successfully"), "success"); - router.push("/availability"); - }, - }); - - return ( - - - - ); -}; - -// Simplify logic by assuming this will never be opened on a large screen -const SmallScreenSideBar = ({ open, children }: { open: boolean; children: JSX.Element }) => { - return ( -
    -
    - {open ? children : null} -
    -
    - ); -}; export default function Availability() { const searchParams = useCompatSearchParams(); - const { t, i18n } = useLocale(); - const utils = trpc.useUtils(); + const { t } = useLocale(); + const router = useRouter(); + const utils = trpc.useContext(); const me = useMeQuery(); const scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1; const fromEventType = searchParams?.get("fromEventType"); const { timeFormat } = me.data || { timeFormat: null }; - const [openSidebar, setOpenSidebar] = useState(false); const { data: schedule, isPending } = trpc.viewer.availability.schedule.get.useQuery( { scheduleId }, { @@ -226,257 +70,54 @@ export default function Availability() { }, }); + const deleteMutation = trpc.viewer.availability.schedule.delete.useMutation({ + onError: withErrorFromUnknown((err) => { + showToast(err.message, "error"); + }), + onSettled: () => { + utils.viewer.availability.list.invalidate(); + }, + onSuccess: () => { + showToast(t("schedule_deleted_successfully"), "success"); + router.push("/availability"); + }, + }); + return ( - ( - - )} - /> - } - subtitle={ - schedule ? ( - schedule.schedule - .filter((availability) => !!availability.days.length) - .map((availability) => ( - - {availabilityAsString(availability, { locale: i18n.language, hour12: timeFormat === 12 })} -
    -
    - )) - ) : ( - - ) + -
    - {!openSidebar ? ( - <> - - {t("set_to_default")} - - ( - - )} - /> - - ) : null} -
    - - - - - - - <> -
    -
    -
    - {t("name")} - ( - - )} - /> -
    - -
    - - {t("set_to_default")} - - ( - - )} - /> -
    - -
    -
    -
    - - {t("timezone")} - - - value ? ( - onChange(timezone.value)} - /> - ) : ( - - ) - } - /> -
    -
    -
    - - {t("something_doesnt_look_right")} - -
    - - {t("launch_troubleshooter")} - -
    -
    -
    -
    - -
    - -
    - -
    - }> -
    -
    { - scheduleId && - updateMutation.mutate({ - scheduleId, - dateOverrides: dateOverrides.flatMap((override) => override.ranges), - ...values, - }); - }} - className="flex flex-col sm:mx-0 xl:flex-row xl:space-x-6"> -
    -
    -
    - {typeof me.data?.weekStart === "string" && ( - - )} -
    -
    -
    - {schedule?.workingHours && } -
    -
    -
    -
    -
    - - {t("timezone")} - - - value ? ( - onChange(timezone.value)} - /> - ) : ( - - ) - } - /> -
    -
    -
    - - {t("something_doesnt_look_right")} - -
    - - {t("launch_troubleshooter")} - -
    -
    -
    -
    -
    -
    -
    + isDeleting={deleteMutation.isPending} + isLoading={isPending} + isSaving={updateMutation.isPending} + timeFormat={timeFormat} + weekStart={me.data?.weekStart || "Sunday"} + backPath={fromEventType ? true : "/availability"} + handleDelete={() => { + scheduleId && deleteMutation.mutate({ scheduleId }); + }} + handleSubmit={async ({ dateOverrides, ...values }) => { + scheduleId && + updateMutation.mutate({ + scheduleId, + dateOverrides: dateOverrides.flatMap((override) => override.ranges), + ...values, + }); + }} + /> ); } diff --git a/apps/web/pages/availability/index.tsx b/apps/web/pages/availability/index.tsx index 4fda487a04119a..30cd99df98afa1 100644 --- a/apps/web/pages/availability/index.tsx +++ b/apps/web/pages/availability/index.tsx @@ -12,6 +12,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; +import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import { EmptyScreen, showToast, ToggleGroup } from "@calcom/ui"; import { Clock } from "@calcom/ui/components/icon"; @@ -172,6 +173,7 @@ export default function AvailabilityPage() { const searchParams = useCompatSearchParams(); const router = useRouter(); const pathname = usePathname(); + const me = useMeQuery(); // Get a new searchParams string by merging the current // searchParams with a provided key/value pair @@ -210,7 +212,11 @@ export default function AvailabilityPage() {
    }> - {searchParams?.get("type") === "team" ? : } + {searchParams?.get("type") === "team" ? ( + + ) : ( + + )} ); diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index 8a52eff77d3a0d..1c7a52c5a619ba 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -1,8 +1,8 @@ "use client"; -import { Booker } from "@calcom/atoms"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; +import { Booker } from "@calcom/platform-atoms"; import { getServerSideProps, type PageProps } from "@lib/d/[link]/[slug]/getServerSideProps"; diff --git a/apps/web/pages/org/[orgSlug]/instant-meeting/team/[slug]/[type].tsx b/apps/web/pages/org/[orgSlug]/instant-meeting/team/[slug]/[type].tsx index b0b46e89cec909..1d391d7eb27afe 100644 --- a/apps/web/pages/org/[orgSlug]/instant-meeting/team/[slug]/[type].tsx +++ b/apps/web/pages/org/[orgSlug]/instant-meeting/team/[slug]/[type].tsx @@ -1,13 +1,13 @@ import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; -import { Booker } from "@calcom/atoms"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; import { getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking"; import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains"; import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import slugify from "@calcom/lib/slugify"; +import { Booker } from "@calcom/platform-atoms"; import prisma from "@calcom/prisma"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; diff --git a/apps/web/pages/settings/organizations/platform/oauth-clients/create.tsx b/apps/web/pages/settings/organizations/platform/oauth-clients/create.tsx new file mode 100644 index 00000000000000..654cbf0a19ffbf --- /dev/null +++ b/apps/web/pages/settings/organizations/platform/oauth-clients/create.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; + +import PageWrapper from "@components/PageWrapper"; +import { OAuthClientForm } from "@components/settings/organizations/platform/oauth-clients/OAuthClientForm"; + +export const CreateOAuthClient = () => { + return ( +
    + +
    + ); +}; + +CreateOAuthClient.getLayout = getLayout; +CreateOAuthClient.PageWrapper = PageWrapper; + +export default CreateOAuthClient; diff --git a/apps/web/pages/settings/organizations/platform/oauth-clients/index.tsx b/apps/web/pages/settings/organizations/platform/oauth-clients/index.tsx new file mode 100644 index 00000000000000..6b0c90f0220755 --- /dev/null +++ b/apps/web/pages/settings/organizations/platform/oauth-clients/index.tsx @@ -0,0 +1,100 @@ +import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; +import { useRouter } from "next/router"; +import React from "react"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; +import { EmptyScreen, showToast } from "@calcom/ui"; +import { Meta, Button } from "@calcom/ui"; +import { Spinner } from "@calcom/ui/components/icon/Spinner"; + +import { useOAuthClients } from "@lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients"; +import { useDeleteOAuthClient } from "@lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient"; + +import PageWrapper from "@components/PageWrapper"; +import { OAuthClientCard } from "@components/settings/organizations/platform/oauth-clients/OAuthClientCard"; + +const queryClient = new QueryClient(); + +export const OAuthClients = () => { + const { data, isLoading, refetch: refetchClients } = useOAuthClients(); + const { mutateAsync, isPending: isDeleting } = useDeleteOAuthClient({ + onSuccess: () => { + showToast("OAuth client deleted successfully", "success"); + refetchClients(); + }, + }); + + const handleDelete = async (id: string) => { + await mutateAsync({ id: id }); + }; + + const NewOAuthClientButton = () => { + const router = useRouter(); + + return ( + + ); + }; + + if (isLoading) { + return ; + } + + return ( + +
    + } + borderInShellHeader={true} + /> +
    + {Array.isArray(data) && data.length ? ( + <> +
    + {data.map((client, index) => { + return ( + + ); + })} +
    + + ) : ( + } + /> + )} +
    +
    +
    + ); +}; + +OAuthClients.getLayout = getLayout; +OAuthClients.PageWrapper = PageWrapper; + +export default OAuthClients; diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index 8b2ee43ad10607..fea7fe49347984 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -2,9 +2,9 @@ import { useSearchParams } from "next/navigation"; -import { Booker } from "@calcom/atoms"; import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses"; import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo"; +import { Booker } from "@calcom/platform-atoms"; import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3acc201ff62096..92c944f4337820 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2190,6 +2190,8 @@ "access_bookings": "Read, edit, delete your bookings", "allow_client_to_do": "Allow {{clientName}} to do this?", "oauth_access_information": "By clicking allow, you allow this app to use your information in accordance with their terms of service and privacy policy. You can remove access in the {{appName}} App Store.", + "oauth_form_title":"OAuth client creation form", + "oauth_form_description":"This is the form to create a new OAuth client", "allow": "Allow", "view_only_edit_availability_not_onboarded": "This user has not completed onboarding. You will not be able to set their availability until they have completed onboarding.", "view_only_edit_availability": "You are viewing this user's availability. You can only edit your own availability.", diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 831bb9eb69036a..8a173ecdc7d1fd 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -15,7 +15,8 @@ "name": "next" } ], - "strictNullChecks": true + "strictNullChecks": true, + "experimentalDecorators": true }, "include": [ /* Find a way to not require this - App files don't belong here. */ diff --git a/package.json b/package.json index 2f7c43d2d67dde..46b04c292eadca 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,14 @@ "private": true, "workspaces": [ "apps/*", + "apps/api/*", "packages/*", "packages/embeds/*", "packages/features/*", "packages/app-store/*", - "packages/app-store/ee/*" + "packages/app-store/ee/*", + "packages/platform/*", + "packages/platform/examples/base" ], "scripts": { "app-store-cli": "yarn workspace @calcom/app-store-cli", @@ -31,11 +34,11 @@ "db-studio": "yarn prisma studio", "deploy": "turbo run deploy", "dev:all": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\" --scope=\"@calcom/console\"", - "dev:ai": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api\" --scope=\"@calcom/ai\"", - "dev:api": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api\"", - "dev:api:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api\" --scope=\"@calcom/console\"", + "dev:ai": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/ai\"", + "dev:api": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\"", + "dev:api:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/console\"", "dev:console": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/console\"", - "dev:swagger": "turbo run dev --scope=\"@calcom/api\" --scope=\"@calcom/swagger\"", + "dev:swagger": "turbo run dev --scope=\"@calcom/api-proxy\" --scope=\"@calcom/api\" --scope=\"@calcom/swagger\"", "dev:website": "turbo run dev --scope=\"@calcom/web\" --scope=\"@calcom/website\"", "dev": "turbo run dev --scope=\"@calcom/web\"", "build-storybook": "turbo run build --scope=\"@calcom/storybook\"", @@ -105,7 +108,6 @@ "vitest-mock-extended": "^1.1.3" }, "dependencies": { - "city-timezones": "^1.2.1", "eslint": "^8.34.0", "lucide-react": "^0.171.0", "turbo": "^1.10.1" diff --git a/packages/app-store/exchange2013calendar/lib/CalendarService.ts b/packages/app-store/exchange2013calendar/lib/CalendarService.ts index 1b89229499b1f3..4f4224a5ff18ec 100644 --- a/packages/app-store/exchange2013calendar/lib/CalendarService.ts +++ b/packages/app-store/exchange2013calendar/lib/CalendarService.ts @@ -144,7 +144,7 @@ export default class ExchangeCalendarService implements Calendar { async getAvailability(dateFrom: string, dateTo: string, selectedCalendars: IntegrationCalendar[]) { try { - const externalCalendars = await this.listCalendars(); + const externalCalendars: IntegrationCalendar[] = await this.listCalendars(); const calendarsToGetAppointmentsFrom = []; for (let i = 0; i < selectedCalendars.length; i++) { //Only select vaild calendars! (We get all all active calendars on the instance! even from different users!) @@ -220,7 +220,7 @@ export default class ExchangeCalendarService implements Calendar { } } return allFolders; - }); + }) as Promise; } catch (reason) { this.log.error(reason); throw reason; diff --git a/packages/app-store/exchange2016calendar/lib/CalendarService.ts b/packages/app-store/exchange2016calendar/lib/CalendarService.ts index 5b4c57fca3b02a..1c33286d14d2cc 100644 --- a/packages/app-store/exchange2016calendar/lib/CalendarService.ts +++ b/packages/app-store/exchange2016calendar/lib/CalendarService.ts @@ -225,7 +225,7 @@ export default class ExchangeCalendarService implements Calendar { } } return allFolders; - }); + }) as Promise; } catch (reason) { this.log.error(reason); throw reason; diff --git a/packages/app-store/test-setup.ts b/packages/app-store/test-setup.ts index eb345413c0a381..039f7e3a318922 100644 --- a/packages/app-store/test-setup.ts +++ b/packages/app-store/test-setup.ts @@ -10,10 +10,23 @@ vi.mock("@calcom/lib/hooks/useLocale", () => ({ useLocale: () => { return { t: (str: string) => str, + isLocaleReady: true, + i18n: { + language: "en", + defaultLocale: "en", + locales: ["en"], + exists: () => false, + }, }; }, })); +vi.mock("@calcom/platform-atoms", () => ({ + useIsPlatform: () => { + return false; + }, +})); + global.ResizeObserver = vi.fn().mockImplementation(() => ({ observe: vi.fn(), unobserve: vi.fn(), diff --git a/packages/app-store/tsconfig.json b/packages/app-store/tsconfig.json index b08597974dbe6d..8e9faeb56eb3a5 100644 --- a/packages/app-store/tsconfig.json +++ b/packages/app-store/tsconfig.json @@ -10,7 +10,8 @@ "@lib/*": ["../../apps/web/lib/*"], "@prisma/client/*": ["@calcom/prisma/client/*"] }, - "resolveJsonModule": true + "resolveJsonModule": true, + "experimentalDecorators": true }, "include": [ "next-env.d.ts", diff --git a/packages/atoms/booker/index.ts b/packages/atoms/booker/index.ts deleted file mode 100644 index 8f1e66dfa0966a..00000000000000 --- a/packages/atoms/booker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BookerWebWrapper as Booker } from "./wrappers/BookerWebWrapper"; diff --git a/packages/atoms/cal-provider/export.ts b/packages/atoms/cal-provider/export.ts deleted file mode 100644 index 4a4e04887cf6db..00000000000000 --- a/packages/atoms/cal-provider/export.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CalProvider } from "./index"; -export * from "../types"; diff --git a/packages/atoms/cal-provider/index.tsx b/packages/atoms/cal-provider/index.tsx deleted file mode 100644 index ded4497ad274cc..00000000000000 --- a/packages/atoms/cal-provider/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ReactNode } from "react"; -import { createContext, useContext } from "react"; - -type CalProviderProps = { - apiKey: string; - children: ReactNode; -}; - -const ApiKeyContext = createContext(""); - -export const useApiKey = () => useContext(ApiKeyContext); - -export function CalProvider({ apiKey, children }: CalProviderProps) { - return {children}; -} diff --git a/packages/atoms/globals.css b/packages/atoms/globals.css deleted file mode 100644 index 12686157f6b945..00000000000000 --- a/packages/atoms/globals.css +++ /dev/null @@ -1,10 +0,0 @@ -/* - * @NOTE: This file is only imported when building the component's CSS file - * When using this component in any Cal project, the globals are automatically imported - * in that project. - */ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@import "../ui/styles/shared-globals.css"; diff --git a/packages/atoms/index.ts b/packages/atoms/index.ts deleted file mode 100644 index 07163614f2dd99..00000000000000 --- a/packages/atoms/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Booker } from "./booker"; -export { CalProvider } from "./cal-provider/index"; diff --git a/packages/atoms/package.json b/packages/atoms/package.json deleted file mode 100644 index 11de78770e584f..00000000000000 --- a/packages/atoms/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@calcom/atoms", - "private": true, - "sideEffects": false, - "type": "module", - "description": "Cal.com Atoms", - "authors": "Cal.com, Inc.", - "version": "1.0.0", - "scripts": { - "build": "node build.mjs" - }, - "devDependencies": { - "@rollup/plugin-node-resolve": "^15.0.1", - "@types/react": "18.0.26", - "@types/react-dom": "^18.0.9", - "@vitejs/plugin-react": "^2.2.0", - "rollup-plugin-node-builtins": "^2.1.2", - "typescript": "^4.9.4", - "vite": "^4.1.2" - }, - "main": "./index" -} diff --git a/packages/atoms/tailwind.config.cjs b/packages/atoms/tailwind.config.cjs deleted file mode 100644 index 55b6825ae45e2d..00000000000000 --- a/packages/atoms/tailwind.config.cjs +++ /dev/null @@ -1,7 +0,0 @@ -const base = require("@calcom/config/tailwind-preset"); - -/** @type {import('tailwindcss').Config} */ -module.exports = { - ...base, - content: ["./bookings/**/*.tsx"], -}; diff --git a/packages/atoms/tsconfig.json b/packages/atoms/tsconfig.json deleted file mode 100644 index b8fb2a6430a461..00000000000000 --- a/packages/atoms/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "@calcom/tsconfig/react-library.json", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "~/*": ["/*"] - }, - "resolveJsonModule": true - }, - "include": [".", "../types/next-auth.d.ts"], - "exclude": ["dist", "build", "node_modules"] -} diff --git a/packages/atoms/vite.config.ts b/packages/atoms/vite.config.ts deleted file mode 100644 index 0950e3ce06b105..00000000000000 --- a/packages/atoms/vite.config.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { resolve } from "path"; -import { defineConfig } from "vite"; - -export default defineConfig({ - build: { - lib: { - entry: [resolve(__dirname, "booker/export.ts")], - name: "CalAtoms", - fileName: "cal-atoms", - }, - rollupOptions: { - external: ["react", "fs", "path", "os", "react-dom"], - output: { - globals: { - react: "React", - "react-dom": "ReactDOM", - }, - }, - }, - }, - resolve: { - alias: { - fs: resolve("../../node_modules/rollup-plugin-node-builtins"), - path: resolve("../../node_modules/rollup-plugin-node-builtins"), - os: resolve("../../node_modules/rollup-plugin-node-builtins"), - }, - }, -}); diff --git a/packages/config/tailwind-preset.js b/packages/config/tailwind-preset.js index 61aea1585ee3c3..64391df41f4229 100644 --- a/packages/config/tailwind-preset.js +++ b/packages/config/tailwind-preset.js @@ -11,7 +11,7 @@ module.exports = { "../../packages/app-store/**/*{components,pages}/**/*.{js,ts,jsx,tsx}", "../../packages/features/**/*.{js,ts,jsx,tsx}", "../../packages/ui/**/*.{js,ts,jsx,tsx}", - "../../packages/atoms/**/*.{js,ts,jsx,tsx}", + "../../packages/platform/atoms/**/*.{js,ts,jsx,tsx}", ], darkMode: "class", theme: { diff --git a/packages/dayjs/index.ts b/packages/dayjs/index.ts index c2c9a000f55e30..c1dd1cbeb3f978 100644 --- a/packages/dayjs/index.ts +++ b/packages/dayjs/index.ts @@ -1,6 +1,5 @@ /* eslint-disable @calcom/eslint/deprecated-imports */ import dayjs from "dayjs"; -import dayjsBusinessTime from "dayjs-business-days2"; import customParseFormat from "dayjs/plugin/customParseFormat"; import duration from "dayjs/plugin/duration"; import isBetween from "dayjs/plugin/isBetween"; @@ -12,8 +11,10 @@ import timeZone from "dayjs/plugin/timezone"; import toArray from "dayjs/plugin/toArray"; import utc from "dayjs/plugin/utc"; +import BusinessDaysPlugin from "./plugins/business-days-plugin"; + dayjs.extend(customParseFormat); -dayjs.extend(dayjsBusinessTime); +dayjs.extend(BusinessDaysPlugin); dayjs.extend(isBetween); dayjs.extend(isToday); dayjs.extend(localizedFormat); diff --git a/packages/dayjs/package.json b/packages/dayjs/package.json index 8aa9fa660282ea..785a9ce265a4a9 100644 --- a/packages/dayjs/package.json +++ b/packages/dayjs/package.json @@ -5,7 +5,6 @@ "version": "1.0.0", "main": "./index.ts", "dependencies": { - "dayjs": "1.11.2", - "dayjs-business-days2": "1.1.0" + "dayjs": "1.11.2" } } diff --git a/packages/dayjs/plugins/business-days-plugin.ts b/packages/dayjs/plugins/business-days-plugin.ts new file mode 100644 index 00000000000000..f20a53ac835c34 --- /dev/null +++ b/packages/dayjs/plugins/business-days-plugin.ts @@ -0,0 +1,242 @@ +import type { Dayjs, PluginFunc } from "dayjs"; + +interface BusinessDaysPluginOptions { + holidays?: string[]; + holidayFormat?: string; + additionalWorkingDays?: string[]; + additionalWorkingDayFormat?: string; + workingWeekdays?: number[]; +} + +const BusinessDaysPlugin: PluginFunc = ( + options = {}, + dayjsClass, + dayjsFactory +) => { + const defaultWorkingWeekdays = [1, 2, 3, 4, 5]; + + dayjsFactory.getWorkingWeekdays = function (): number[] { + return options.workingWeekdays || defaultWorkingWeekdays; + }; + + dayjsFactory.setWorkingWeekdays = function (workingWeekdays: number[]): void { + options.workingWeekdays = workingWeekdays; + }; + + dayjsFactory.getHolidays = function (): string[] { + return options.holidays || []; + }; + + dayjsFactory.setHolidays = function (holidays: string[]): void { + options.holidays = holidays; + }; + + dayjsFactory.getHolidayFormat = function (): string | undefined { + return options.holidayFormat; + }; + + dayjsFactory.setHolidayFormat = function (holidayFormat: string): void { + options.holidayFormat = holidayFormat; + }; + + dayjsFactory.getAdditionalWorkingDays = function (): string[] { + return options.additionalWorkingDays || []; + }; + + dayjsFactory.setAdditionalWorkingDays = function (additionalWorkingDays: string[]): void { + options.additionalWorkingDays = additionalWorkingDays; + }; + + dayjsFactory.getAdditionalWorkingDayFormat = function (): string | undefined { + return options.additionalWorkingDayFormat; + }; + + dayjsFactory.setAdditionalWorkingDayFormat = function (additionalWorkingDayFormat: string): void { + options.additionalWorkingDayFormat = additionalWorkingDayFormat; + }; + + dayjsClass.prototype.isHoliday = function (this: Dayjs): boolean { + if (!options.holidays) { + return false; + } + if (options.holidays.includes(this.format(options.holidayFormat))) { + return true; + } + + return false; + }; + + dayjsClass.prototype.isBusinessDay = function (this: Dayjs): boolean { + const workingWeekdays = options.workingWeekdays || defaultWorkingWeekdays; + + if (this.isHoliday()) { + return false; + } + if (this.isAdditionalWorkingDay()) { + return true; + } + if (workingWeekdays.includes(this.day())) { + return true; + } + + return false; + }; + + dayjsClass.prototype.isAdditionalWorkingDay = function (this: Dayjs): boolean { + if (!options.additionalWorkingDays) { + return false; + } + if (options.additionalWorkingDays.includes(this.format(options.additionalWorkingDayFormat))) { + return true; + } + + return false; + }; + + dayjsClass.prototype.businessDaysAdd = function (this: Dayjs, days: number): Dayjs { + const numericDirection = days < 0 ? -1 : 1; + let currentDay = this.clone(); + let daysRemaining = Math.abs(days); + + while (daysRemaining > 0) { + currentDay = currentDay.add(numericDirection, `d`); + + if (currentDay.isBusinessDay()) { + daysRemaining -= 1; + } + } + + return currentDay; + }; + + dayjsClass.prototype.businessDaysSubtract = function (this: Dayjs, days: number): Dayjs { + let currentDay = this.clone(); + + currentDay = currentDay.businessDaysAdd(days * -1); + + return currentDay; + }; + + dayjsClass.prototype.businessDiff = function (this: Dayjs, date: Dayjs): number { + const day1 = this.clone(); + const day2 = date.clone(); + + const isPositiveDiff = day1 >= day2; + let start = isPositiveDiff ? day2 : day1; + const end = isPositiveDiff ? day1 : day2; + + let daysBetween = 0; + + if (start.isSame(end)) { + return daysBetween; + } + + while (start < end) { + if (start.isBusinessDay()) { + daysBetween += 1; + } + + start = start.add(1, `d`); + } + + return isPositiveDiff ? daysBetween : -daysBetween; + }; + + dayjsClass.prototype.nextBusinessDay = function (this: Dayjs): Dayjs { + const searchLimit = 7; + let currentDay = this.clone(); + + let loopIndex = 1; + while (loopIndex < searchLimit) { + currentDay = currentDay.add(1, `day`); + + if (currentDay.isBusinessDay()) { + break; + } + loopIndex += 1; + } + + return currentDay; + }; + + dayjsClass.prototype.prevBusinessDay = function (this: Dayjs): Dayjs { + const searchLimit = 7; + let currentDay = this.clone(); + + let loopIndex = 1; + while (loopIndex < searchLimit) { + currentDay = currentDay.subtract(1, `day`); + + if (currentDay.isBusinessDay()) { + break; + } + loopIndex += 1; + } + + return currentDay; + }; + + dayjsClass.prototype.businessDaysInMonth = function (this: Dayjs): Dayjs[] { + if (!this.isValid()) { + return []; + } + + let currentDay = this.clone().startOf(`month`); + const monthEnd = this.clone().endOf(`month`); + const businessDays: Dayjs[] = []; + let monthComplete = false; + + while (!monthComplete) { + if (currentDay.isBusinessDay()) { + businessDays.push(currentDay.clone()); + } + + currentDay = currentDay.add(1, `day`); + + if (currentDay.isAfter(monthEnd)) { + monthComplete = true; + } + } + + return businessDays; + }; + + dayjsClass.prototype.lastBusinessDayOfMonth = function (this: Dayjs): Dayjs { + const businessDays = this.businessDaysInMonth(); + const lastBusinessDay = businessDays[businessDays.length - 1]; + return lastBusinessDay; + }; + + dayjsClass.prototype.businessWeeksInMonth = function (this: Dayjs): Dayjs[][] { + if (!this.isValid()) { + return []; + } + + let currentDay = this.clone().startOf(`month`); + const monthEnd = this.clone().endOf(`month`); + const businessWeeks: Dayjs[][] = []; + let businessDays: Dayjs[] = []; + let monthComplete = false; + + while (!monthComplete) { + if (currentDay.isBusinessDay()) { + businessDays.push(currentDay.clone()); + } + + if (currentDay.day() === 5 || currentDay.isSame(monthEnd, `day`)) { + businessWeeks.push(businessDays); + businessDays = []; + } + + currentDay = currentDay.add(1, `day`); + + if (currentDay.isAfter(monthEnd)) { + monthComplete = true; + } + } + + return businessWeeks; + }; +}; + +export default BusinessDaysPlugin; diff --git a/packages/dayjs/tsconfig.json b/packages/dayjs/tsconfig.json new file mode 100644 index 00000000000000..9380508994ae0c --- /dev/null +++ b/packages/dayjs/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@calcom/tsconfig/base.json", + "include": [".", "../types/business-days-plugin.d.ts"] +} diff --git a/packages/embeds/embed-core/package.json b/packages/embeds/embed-core/package.json index 835b5230c9c33b..1508ce97d1469c 100644 --- a/packages/embeds/embed-core/package.json +++ b/packages/embeds/embed-core/package.json @@ -50,6 +50,7 @@ "postcss": "^8.4.18", "tailwindcss": "^3.3.3", "typescript": "^4.9.4", - "vite": "^4.1.2" + "vite": "^4.1.2", + "vite-plugin-environment": "^1.1.3" } } diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index 0cb67b3a05a8f9..a7a0a9604998e2 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -19,8 +19,7 @@ export type Message = { }; // HACK: Redefine and don't import WEBAPP_URL as it causes import statement to be present in built file. // This is happening because we are not able to generate an App and a lib using single Vite Config. -const WEBAPP_URL = - import.meta.env.EMBED_PUBLIC_WEBAPP_URL || `https://${import.meta.env.EMBED_PUBLIC_VERCEL_URL}`; +const WEBAPP_URL = process.env.EMBED_PUBLIC_WEBAPP_URL || `https://${process.env.EMBED_PUBLIC_VERCEL_URL}`; customElements.define("cal-modal-box", ModalBox); customElements.define("cal-floating-button", FloatingButton); @@ -52,7 +51,7 @@ if (!globalCal || !globalCal.q) { // Store Commit Hash to know exactly what version of the code is running // TODO: Ideally it should be the version as per package.json and then it can be renamed to version. // But because it is built on local machine right now, it is much more reliable to have the commit hash. -globalCal.fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string; +globalCal.fingerprint = process.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string; globalCal.__css = allCss; document.head.appendChild(document.createElement("style")).innerHTML = css; diff --git a/packages/embeds/embed-core/src/preview.ts b/packages/embeds/embed-core/src/preview.ts index 8838d76d129ed3..e8a9096e7806fe 100644 --- a/packages/embeds/embed-core/src/preview.ts +++ b/packages/embeds/embed-core/src/preview.ts @@ -43,7 +43,7 @@ if (!bookerUrl || !embedLibUrl) { })(window, embedLibUrl, "init"); const previewWindow = window; -previewWindow.Cal.fingerprint = import.meta.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string; +previewWindow.Cal.fingerprint = process.env.EMBED_PUBLIC_EMBED_FINGER_PRINT as string; previewWindow.Cal("init", { origin: bookerUrl, diff --git a/packages/embeds/embed-core/vite.config.js b/packages/embeds/embed-core/vite.config.js index 9da4a02fcdcc7b..4c63cf230a7c97 100644 --- a/packages/embeds/embed-core/vite.config.js +++ b/packages/embeds/embed-core/vite.config.js @@ -1,3 +1,5 @@ +import EnvironmentPlugin from "vite-plugin-environment"; + import viteBaseConfig from "../vite.config"; const path = require("path"); @@ -15,6 +17,11 @@ module.exports = defineConfig((configEnv) => { embed: path.resolve(__dirname, "src/embed.ts"), }, plugins: [ + EnvironmentPlugin([ + "EMBED_PUBLIC_EMBED_FINGER_PRINT", + "EMBED_PUBLIC_VERCEL_URL", + "EMBED_PUBLIC_WEBAPP_URL", + ]), { generateBundle: (code, bundle) => { // Note: banner/footer doesn't work because it doesn't enclose the entire library code, some variables are still left out. diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 1e5e02de21681a..f1119c0caf4df6 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -9,7 +9,6 @@ import dayjs from "@calcom/dayjs"; import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; import { useNonEmptyScheduleDays } from "@calcom/features/schedules"; import classNames from "@calcom/lib/classNames"; -import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { BookerLayouts } from "@calcom/prisma/zod-utils"; @@ -29,7 +28,6 @@ import { Away, NotFound } from "./components/Unavailable"; import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config"; import { useBookerStore } from "./store"; import type { BookerProps, WrappedBookerProps } from "./types"; -import { useBrandColors } from "./utils/use-brand-colors"; const loadFramerFeatures = () => import("./framer-features").then((res) => res.default); const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy")); @@ -66,8 +64,10 @@ const BookerComponent = ({ bookerLayout, schedule, verifyCode, + isPlatform, orgBannerUrl, }: BookerProps & WrappedBookerProps) => { + const { t } = useLocale(); const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow); const selectedDate = useBookerStore((state) => state.selectedDate); const { @@ -114,14 +114,6 @@ const BookerComponent = ({ const timeslotsRef = useRef(null); const StickyOnDesktop = isMobile ? "div" : StickyBox; - const { t } = useLocale(); - - useBrandColors({ - brandColor: event.data?.profile.brandColor ?? DEFAULT_LIGHT_BRAND_COLOR, - darkBrandColor: event.data?.profile.darkBrandColor ?? DEFAULT_DARK_BRAND_COLOR, - theme: event.data?.profile.theme, - }); - const { bookerFormErrorRef, key, formEmail, bookingForm, errors: formErrors } = bookerForm; const { handleBookEvent, errors, loadingStates, expiryTime } = bookings; @@ -166,27 +158,34 @@ const BookerComponent = ({ bookingForm={bookingForm} eventQuery={event} extraOptions={extraOptions} - rescheduleUid={rescheduleUid}> + rescheduleUid={rescheduleUid} + isPlatform={isPlatform}> <> - - { - onGoBackInstantMeeting(); - }} - /> + {verifyCode ? ( + + ) : ( + <> + )} + {!isPlatform && ( + { + onGoBackInstantMeeting(); + }} + /> + )} ) : ( @@ -214,12 +213,13 @@ const BookerComponent = ({ setEmailVerificationModalVisible, setSeatedEventData, setSelectedTimeslot, - verifyCode.error, - verifyCode.isPending, - verifyCode.resetErrors, - verifyCode.setIsPending, - verifyCode.verifyCodeWithSessionNotRequired, - verifyCode.verifyCodeWithSessionRequired, + verifyCode?.error, + verifyCode?.isPending, + verifyCode?.resetErrors, + verifyCode?.setIsPending, + verifyCode?.verifyCodeWithSessionNotRequired, + verifyCode?.verifyCodeWithSessionRequired, + isPlatform, ]); if (entity.isUnpublished) { @@ -236,7 +236,7 @@ const BookerComponent = ({ return ( <> - {event.data ? : null} + {event.data && !isPlatform ? : <>} {bookerState !== "booking" && event.data?.isInstantEvent && (
    -
    - isEmbed ? ( - <> - ) : ( - <> - { - onOverlayClickNoCalendar(); - }} - /> - - ) - } - /> + {!isPlatform ? ( +
    + isEmbed ? ( + <> + ) : ( + <> + { + onOverlayClickNoCalendar(); + }} + /> + + ) + } + /> + ) : ( + <> + )} )} - {orgBannerUrl && ( + {orgBannerUrl && !isPlatform && ( )} - + {layout !== BookerLayouts.MONTH_VIEW && !(layout === "mobile" && bookerState === "booking") && (
    @@ -413,6 +417,7 @@ const BookerComponent = ({ isLoading={schedule.isPending} seatsPerTimeSlot={event.data?.seatsPerTimeSlot} showAvailableSeatsCount={event.data?.seatsShowAvailabilityCount} + event={event} /> @@ -427,7 +432,7 @@ const BookerComponent = ({ }} /> - {!hideBranding && ( + {!hideBranding && !isPlatform && ( - setSelectedTimeslot(null)} - visible={bookerState === "booking" && shouldShowFormInDialog}> - {EventBooker} - + {!isPlatform ? ( + setSelectedTimeslot(null)} + visible={bookerState === "booking" && shouldShowFormInDialog}> + {EventBooker} + + ) : ( + <> + )} ); }; diff --git a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx index f308ce22f8906d..4d152e4811c357 100644 --- a/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx +++ b/packages/features/bookings/Booker/components/AvailableTimeSlots.tsx @@ -2,6 +2,7 @@ import { useRef } from "react"; import dayjs from "@calcom/dayjs"; import { AvailableTimes, AvailableTimesSkeleton } from "@calcom/features/bookings"; +import type { useEventReturnType } from "@calcom/features/bookings/Booker/utils/event"; import { useNonEmptyScheduleDays } from "@calcom/features/schedules"; import { useSlotsForAvailableDates } from "@calcom/features/schedules/lib/use-schedule/useSlotsForDate"; import { classNames } from "@calcom/lib"; @@ -18,6 +19,7 @@ type AvailableTimeSlotsProps = { isLoading: boolean; seatsPerTimeSlot?: number | null; showAvailableSeatsCount?: boolean | null; + event: useEventReturnType; }; /** @@ -34,6 +36,7 @@ export const AvailableTimeSlots = ({ showAvailableSeatsCount, schedule, isLoading, + event, }: AvailableTimeSlotsProps) => { const selectedDate = useBookerStore((state) => state.selectedDate); const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); @@ -116,6 +119,7 @@ export const AvailableTimeSlots = ({ slots={slots.slots} seatsPerTimeSlot={seatsPerTimeSlot} showAvailableSeatsCount={showAvailableSeatsCount} + event={event} /> ))}
    diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index 317bffa221937f..30e6352f194522 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -27,6 +27,7 @@ type BookEventFormProps = { bookingForm: UseBookingFormReturnType["bookingForm"]; renderConfirmNotVerifyEmailButtonCond: boolean; extraOptions: Record; + isPlatform?: boolean; }; export const BookEventForm = ({ @@ -41,6 +42,7 @@ export const BookEventForm = ({ bookingForm, children, extraOptions, + isPlatform = false, }: Omit & { eventQuery: useEventReturnType; rescheduleUid: string | null; @@ -111,7 +113,7 @@ export const BookEventForm = ({ />
    )} - {IS_CALCOM && ( + {!isPlatform && IS_CALCOM && (
    By proceeding, you agree to our{" "} @@ -172,11 +174,11 @@ const getError = ( t: TFunction, responseVercelIdHeader: string | null ) => { - if (globalError) return globalError.message; + if (globalError) return globalError?.message; const error = dataError; - return error.message ? ( + return error?.message ? ( <> {responseVercelIdHeader ?? ""} {t(error.message)} diff --git a/packages/features/bookings/Booker/components/EventMeta.tsx b/packages/features/bookings/Booker/components/EventMeta.tsx index 7c8d4c63314735..9d07759ca41707 100644 --- a/packages/features/bookings/Booker/components/EventMeta.tsx +++ b/packages/features/bookings/Booker/components/EventMeta.tsx @@ -1,6 +1,6 @@ import { m } from "framer-motion"; import dynamic from "next/dynamic"; -import { useEffect } from "react"; +import { useMemo, useEffect } from "react"; import { shallow } from "zustand/shallow"; import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe"; @@ -9,6 +9,7 @@ import { SeatsAvailabilityText } from "@calcom/features/bookings/components/Seat import { EventMetaBlock } from "@calcom/features/bookings/components/event-meta/Details"; import { useTimePreferences } from "@calcom/features/bookings/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Timezone as PlatformTimezoneSelect } from "@calcom/platform-atoms"; import { Calendar, Globe, User } from "@calcom/ui/components/icon"; import { fadeInUp } from "../config"; @@ -16,7 +17,7 @@ import { useBookerStore } from "../store"; import { FromToTime } from "../utils/dates"; import type { useEventReturnType } from "../utils/event"; -const TimezoneSelect = dynamic( +const WebTimezoneSelect = dynamic( () => import("@calcom/ui/components/form/timezone-select/TimezoneSelect").then((mod) => mod.TimezoneSelect), { ssr: false, @@ -26,9 +27,11 @@ const TimezoneSelect = dynamic( export const EventMeta = ({ event, isPending, + isPlatform = true, }: { event: useEventReturnType["data"]; isPending: useEventReturnType["isPending"]; + isPlatform?: boolean; }) => { const { setTimezone, timeFormat, timezone } = useTimePreferences(); const selectedDuration = useBookerStore((state) => state.selectedDuration); @@ -44,6 +47,10 @@ export const EventMeta = ({ const embedUiConfig = useEmbedUiConfig(); const isEmbed = useIsEmbed(); const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false; + const [TimezoneSelect] = useMemo( + () => (isPlatform ? [PlatformTimezoneSelect] : [WebTimezoneSelect]), + [isPlatform] + ); useEffect(() => { //In case the event has lockTimeZone enabled ,set the timezone to event's attached availability timezone diff --git a/packages/features/bookings/Booker/components/hooks/useBookingForm.ts b/packages/features/bookings/Booker/components/hooks/useBookingForm.ts index 0b05983038644c..ea6e6449539891 100644 --- a/packages/features/bookings/Booker/components/hooks/useBookingForm.ts +++ b/packages/features/bookings/Booker/components/hooks/useBookingForm.ts @@ -12,7 +12,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useInitialFormValues } from "./useInitialFormValues"; export interface IUseBookingForm { - event: useEventReturnType; + event: useEventReturnType["data"]; sessionEmail?: string | null; sessionName?: string | null; sessionUsername?: string | null; @@ -42,9 +42,9 @@ export const useBookingForm = ({ const bookingFormSchema = z .object({ - responses: event?.data + responses: event ? getBookingResponsesSchema({ - bookingFields: event.data.bookingFields, + bookingFields: event.bookingFields, view: rescheduleUid ? "reschedule" : "booking", }) : // Fallback until event is loaded. @@ -62,7 +62,7 @@ export const useBookingForm = ({ const isRescheduling = !!rescheduleUid && !!bookingData; const { initialValues, key } = useInitialFormValues({ - eventType: event.data, + eventType: event, rescheduleUid, isRescheduling, email: sessionEmail, @@ -101,7 +101,7 @@ export const useBookingForm = ({ // It shouldn't be possible that this method is fired without having event data, // but since in theory (looking at the types) it is possible, we still handle that case. - if (!event?.data) { + if (!event) { bookingForm.setError("globalError", { message: t("error_booking_event") }); return; } diff --git a/packages/features/bookings/Booker/components/hooks/useBookings.ts b/packages/features/bookings/Booker/components/hooks/useBookings.ts index 15a2a518e2bc62..e36f646ae0b973 100644 --- a/packages/features/bookings/Booker/components/hooks/useBookings.ts +++ b/packages/features/bookings/Booker/components/hooks/useBookings.ts @@ -8,17 +8,11 @@ import { sdkActionManager } from "@calcom/embed-core/embed-iframe"; import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import type { useEventReturnType } from "@calcom/features/bookings/Booker/utils/event"; import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param"; -import { - createBooking, - createRecurringBooking, - mapBookingToMutationInput, - mapRecurringBookingToMutationInput, - createInstantBooking, - useTimePreferences, -} from "@calcom/features/bookings/lib"; +import { createBooking, createRecurringBooking, createInstantBooking } from "@calcom/features/bookings/lib"; import { getFullName } from "@calcom/features/form-builder/utils"; import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useHandleBookEvent } from "@calcom/platform-atoms"; import { BookingStatus } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc"; @@ -48,20 +42,14 @@ export type UseBookingsReturnType = ReturnType; export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBookings) => { const router = useRouter(); const eventSlug = useBookerStore((state) => state.eventSlug); - const setFormValues = useBookerStore((state) => state.setFormValues); const rescheduleUid = useBookerStore((state) => state.rescheduleUid); const bookingData = useBookerStore((state) => state.bookingData); const timeslot = useBookerStore((state) => state.selectedTimeslot); - const seatedEventData = useBookerStore((state) => state.seatedEventData); - const { t, i18n } = useLocale(); + const { t } = useLocale(); const bookingSuccessRedirect = useBookingSuccessRedirect(); const bookerFormErrorRef = useRef(null); const [instantMeetingTokenExpiryTime, setExpiryTime] = useState(); - const recurringEventCount = useBookerStore((state) => state.recurringEventCount); - const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting); const duration = useBookerStore((state) => state.selectedDuration); - const { timezone } = useTimePreferences(); - const username = useBookerStore((state) => state.username); const isRescheduling = !!rescheduleUid && !!bookingData; @@ -229,56 +217,15 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo }, }); - const handleBookEvent = () => { - const values = bookingForm.getValues(); - if (timeslot) { - // Clears form values stored in store, so old values won't stick around. - setFormValues({}); - bookingForm.clearErrors(); - - // It shouldn't be possible that this method is fired without having event data, - // but since in theory (looking at the types) it is possible, we still handle that case. - if (!event?.data) { - bookingForm.setError("globalError", { message: t("error_booking_event") }); - return; - } - - // Ensures that duration is an allowed value, if not it defaults to the - // default event duration. - const validDuration = event.data.isDynamic - ? duration || event.data.length - : duration && event.data.metadata?.multipleDuration?.includes(duration) - ? duration - : event.data.length; - - const bookingInput = { - values, - duration: validDuration, - event: event.data, - date: timeslot, - timeZone: timezone, - language: i18n.language, - rescheduleUid: rescheduleUid || undefined, - bookingUid: (bookingData && bookingData.uid) || seatedEventData?.bookingUid || undefined, - username: username || "", - metadata: metadata, - hashedLink, - }; - - if (isInstantMeeting) { - createInstantBookingMutation.mutate(mapBookingToMutationInput(bookingInput)); - } else if (event.data?.recurringEvent?.freq && recurringEventCount && !rescheduleUid) { - createRecurringBookingMutation.mutate( - mapRecurringBookingToMutationInput(bookingInput, recurringEventCount) - ); - } else { - createBookingMutation.mutate(mapBookingToMutationInput(bookingInput)); - } - // Clears form values stored in store, so old values won't stick around. - setFormValues({}); - bookingForm.clearErrors(); - } - }; + const handleBookEvent = useHandleBookEvent({ + event, + bookingForm, + hashedLink, + metadata, + handleInstantBooking: createInstantBookingMutation.mutate, + handleRecBooking: createRecurringBookingMutation.mutate, + handleBooking: createBookingMutation.mutate, + }); const errors = { hasDataErrors: Boolean( diff --git a/packages/features/bookings/Booker/types.ts b/packages/features/bookings/Booker/types.ts index 0c851753c6cab2..6586847a4c5131 100644 --- a/packages/features/bookings/Booker/types.ts +++ b/packages/features/bookings/Booker/types.ts @@ -87,7 +87,7 @@ export interface BookerProps { isInstantMeeting?: boolean; } -export type WrappedBookerProps = { +export type WrappedBookerPropsMain = { sessionUsername?: string | null; rescheduleUid: string | null; bookingUid: string | null; @@ -103,14 +103,24 @@ export type WrappedBookerProps = { bookings: UseBookingsReturnType; slots: UseSlotsReturnType; calendars: UseCalendarsReturnType; - verifyEmail: UseVerifyEmailReturnType; bookerForm: UseBookingFormReturnType; event: useEventReturnType; schedule: useScheduleForEventReturnType; bookerLayout: UseBookerLayoutType; + verifyEmail: UseVerifyEmailReturnType; +}; + +export type WrappedBookerPropsForPlatform = WrappedBookerPropsMain & { + isPlatform: true; + verifyCode: undefined; +}; +export type WrappedBookerPropsForWeb = WrappedBookerPropsMain & { + isPlatform: false; verifyCode: UseVerifyCodeReturnType; }; +export type WrappedBookerProps = WrappedBookerPropsForPlatform | WrappedBookerPropsForWeb; + export type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking"; export type BookerLayout = BookerLayouts | "mobile"; export type BookerAreas = "calendar" | "timeslots" | "main" | "meta" | "header"; diff --git a/packages/features/bookings/Booker/utils/event.ts b/packages/features/bookings/Booker/utils/event.ts index 763b85941f75b4..150166d4072979 100644 --- a/packages/features/bookings/Booker/utils/event.ts +++ b/packages/features/bookings/Booker/utils/event.ts @@ -24,10 +24,17 @@ export const useEvent = () => { const isTeamEvent = useBookerStore((state) => state.isTeamEvent); const org = useBookerStore((state) => state.org); - return trpc.viewer.public.event.useQuery( + const event = trpc.viewer.public.event.useQuery( { username: username ?? "", eventSlug: eventSlug ?? "", isTeamEvent, org: org ?? null }, { refetchOnWindowFocus: false, enabled: Boolean(username) && Boolean(eventSlug) } ); + + return { + data: event?.data, + isSuccess: event?.isSuccess, + isError: event?.isError, + isPending: event?.isPending, + }; }; /** @@ -79,7 +86,7 @@ export const useScheduleForEvent = ({ const isTeam = !!event.data?.team?.parentId; - return useSchedule({ + const schedule = useSchedule({ username: usernameFromStore ?? username, eventSlug: eventSlugFromStore ?? eventSlug, eventId: event.data?.id ?? eventId, @@ -94,4 +101,12 @@ export const useScheduleForEvent = ({ isTeamEvent: pathname?.indexOf("/team/") !== -1 || isTeam, orgSlug, }); + + return { + data: schedule?.data, + isPending: schedule?.isPending, + isError: schedule?.isError, + isSuccess: schedule?.isSuccess, + isLoading: schedule?.isLoading, + }; }; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index c91b6157c0a00f..0d9ff9f00da274 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -12,7 +12,7 @@ import { localStorage } from "@calcom/lib/webstorage"; import { Button, SkeletonText } from "@calcom/ui"; import { useBookerStore } from "../Booker/store"; -import { useEvent } from "../Booker/utils/event"; +import type { useEventReturnType } from "../Booker/utils/event"; import { getQueryParam } from "../Booker/utils/query-param"; import { useTimePreferences } from "../lib"; import { useCheckOverlapWithOverlay } from "../lib/useCheckOverlapWithOverlay"; @@ -33,6 +33,7 @@ type AvailableTimesProps = { showTimeFormatToggle?: boolean; className?: string; selectedSlots?: string[]; + event: useEventReturnType; }; const SlotItem = ({ @@ -41,12 +42,14 @@ const SlotItem = ({ selectedSlots, onTimeSelect, showAvailableSeatsCount, + event, }: { slot: Slots[string][number]; seatsPerTimeSlot?: number | null; selectedSlots?: string[]; onTimeSelect: TOnTimeSelect; showAvailableSeatsCount?: boolean | null; + event: useEventReturnType; }) => { const { t } = useLocale(); @@ -55,7 +58,7 @@ const SlotItem = ({ const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]); const bookingData = useBookerStore((state) => state.bookingData); const layout = useBookerStore((state) => state.layout); - const { data: event } = useEvent(); + const { data: eventData } = event; const hasTimeSlots = !!seatsPerTimeSlot; const computedDateWithUsersTimezone = dayjs.utc(slot.time).tz(timezone); @@ -71,7 +74,7 @@ const SlotItem = ({ const { isOverlapping, overlappingTimeEnd, overlappingTimeStart } = useCheckOverlapWithOverlay({ start: computedDateWithUsersTimezone, - selectedDuration: event?.length ?? 0, + selectedDuration: eventData?.length ?? 0, offset, }); @@ -187,6 +190,7 @@ export const AvailableTimes = ({ showTimeFormatToggle = true, className, selectedSlots, + event, }: AvailableTimesProps) => { const { t } = useLocale(); @@ -211,6 +215,7 @@ export const AvailableTimes = ({ selectedSlots={selectedSlots} seatsPerTimeSlot={seatsPerTimeSlot} showAvailableSeatsCount={showAvailableSeatsCount} + event={event} /> ))}
    diff --git a/packages/features/bookings/components/event-meta/AvailableEventLocations.tsx b/packages/features/bookings/components/event-meta/AvailableEventLocations.tsx index 0eca36957cf6cd..bd3264b5dfd289 100644 --- a/packages/features/bookings/components/event-meta/AvailableEventLocations.tsx +++ b/packages/features/bookings/components/event-meta/AvailableEventLocations.tsx @@ -7,8 +7,10 @@ import { getEventLocationType, getTranslatedLocation } from "@calcom/app-store/l import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import invertLogoOnDark from "@calcom/lib/invertLogoOnDark"; +import { useIsPlatform } from "@calcom/platform-atoms"; import { Tooltip } from "@calcom/ui"; import { Link } from "@calcom/ui/components/icon"; +import { Video } from "@calcom/ui/components/icon"; const excludeNullValues = (value: unknown) => !!value; @@ -19,6 +21,12 @@ function RenderIcon({ eventLocationType: DefaultEventLocationType | EventLocationTypeFromApp; isTooltip: boolean; }) { + const isPlatform = useIsPlatform(); + + if (isPlatform) { + return