Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2ade568c8 | ||
|
|
27820ce897 | ||
|
|
fea7e66996 | ||
|
|
474d1571ae | ||
|
|
2ef627b87e | ||
|
|
2dfb82b623 | ||
|
|
ccd7fdfd52 | ||
|
|
f92e59ed34 | ||
|
|
d29477349a | ||
|
|
259e480baa | ||
|
|
0055dd1142 | ||
|
|
61c44780f0 | ||
|
|
9710d3b15c | ||
|
|
04cefebd75 | ||
|
|
dbd927c002 | ||
|
|
e6b2116a2b | ||
|
|
855a02091f | ||
|
|
358434429e | ||
|
|
b00061d85e | ||
|
|
022f08c05e | ||
|
|
6ff5c8ce5f | ||
|
|
3be7fb9dd8 | ||
|
|
72dd970b6e | ||
|
|
f4d09ece43 | ||
|
|
239e6085ba | ||
|
|
b17eab3aeb | ||
|
|
752f7fcde0 | ||
|
|
f6b061f787 | ||
|
|
cc8a1290ee | ||
|
|
b7cf893169 | ||
|
|
1458cacdbb | ||
|
|
a48a1bd5d8 | ||
|
|
1acd0a14ba | ||
|
|
6c9ac7636b | ||
|
|
d261a23dbc | ||
|
|
8b7947a172 | ||
|
|
89789b1e3b | ||
|
|
43dd24c480 | ||
|
|
ae6b1d402a | ||
|
|
3054ad33b2 | ||
|
|
a4dbb1a90b | ||
|
|
689150d78a | ||
|
|
fba096f23d | ||
|
|
7d5a3391f1 | ||
|
|
dcef99bf49 | ||
|
|
805fcc22a5 | ||
|
|
c5b7da8ffd | ||
|
|
97f1f7b130 | ||
|
|
0ac460fce3 | ||
|
|
82dfca8272 | ||
|
|
d1c5576d4f | ||
|
|
7090e3ed60 | ||
|
|
9926fd2e79 | ||
|
|
7793cb20d1 | ||
|
|
8ed0fb6a07 | ||
|
|
f5a1222fc5 | ||
|
|
2b892296b0 | ||
|
|
f796802ed3 | ||
|
|
b2ac227877 | ||
|
|
f0aec4efdc | ||
|
|
8d7cdf9d3b | ||
|
|
2ed0380e4f | ||
|
|
752cfa6bf9 | ||
|
|
f11c4dd7ec | ||
|
|
386f60ca6d | ||
|
|
e98f279f64 | ||
|
|
fe190f20f3 | ||
|
|
96855ea391 | ||
|
|
57150d992e | ||
|
|
8b75bf347f | ||
|
|
788fbdbe5e | ||
|
|
b2239374bc | ||
|
|
77c61abef8 | ||
|
|
c2472cc05a | ||
|
|
608fe1c586 | ||
|
|
4413295b27 | ||
|
|
bcc433ba9a | ||
|
|
2ba571d230 | ||
|
|
4a0a1ef053 | ||
|
|
7e882eedf8 | ||
|
|
224e606439 | ||
|
|
14b3344a41 | ||
|
|
a538ba64d2 | ||
|
|
b3bfad7fea | ||
|
|
f80b45e36a | ||
|
|
f3d60b141f | ||
|
|
5463dc261c |
@@ -144,6 +144,12 @@ GOOGLE_WEBHOOK_TOKEN=
|
||||
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
|
||||
GOOGLE_WEBHOOK_URL=
|
||||
|
||||
# Token to verify incoming webhooks from Microsoft Calendar
|
||||
MICROSOFT_WEBHOOK_TOKEN=
|
||||
|
||||
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
|
||||
MICROSOFT_WEBHOOK_URL=
|
||||
|
||||
# Inbox to send user feedback
|
||||
SEND_FEEDBACK_EMAIL=
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
apps/api/v2/dist
|
||||
packages/platform/**/dist/*
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
packages/prisma/zod
|
||||
apps/web/public/embed
|
||||
packages/ui/components/icon/dynamicIconImports.tsx
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ["./packages/config/eslint-preset.js"],
|
||||
plugins: ["import"],
|
||||
rules: {
|
||||
"import/no-cycle": ["warn", { maxDepth: Infinity }],
|
||||
},
|
||||
overrides: [
|
||||
// WARN: features must not be imported by app-store or lib
|
||||
{
|
||||
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}", "packages/lib/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
// Catch all relative paths into features
|
||||
"**/features",
|
||||
"**/features/*",
|
||||
// Catch all alias imports
|
||||
"@calcom/features",
|
||||
"@calcom/features/*",
|
||||
],
|
||||
message: "Avoid importing @calcom/features from @calcom/app-store or @calcom/lib.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// WARN: lib must not import app-store or features
|
||||
{
|
||||
files: ["packages/lib/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
// Catch all relative paths into app-store
|
||||
"**/app-store",
|
||||
"**/app-store/*",
|
||||
// Catch all relative paths into features
|
||||
"**/features",
|
||||
"**/features/*",
|
||||
// Catch alias imports
|
||||
"@calcom/app-store",
|
||||
"@calcom/app-store/*",
|
||||
"@calcom/features",
|
||||
"@calcom/features/*",
|
||||
],
|
||||
message: "@calcom/lib should not import @calcom/app-store or @calcom/features.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// ERROR: app-store must not import trpc
|
||||
{
|
||||
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
// Catch all relative paths into trpc
|
||||
"**/trpc",
|
||||
"**/trpc/*",
|
||||
// Catch alias imports
|
||||
"@calcom/trpc",
|
||||
"@calcom/trpc/*",
|
||||
"@trpc",
|
||||
"@trpc/*",
|
||||
],
|
||||
message:
|
||||
"@calcom/app-store must not import trpc. Move UI to apps/web/components/apps or introduce an API boundary.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ERROR: prisma must not import `features` package
|
||||
{
|
||||
files: ["packages/prisma/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
// Catch all relative paths into features
|
||||
"**/features",
|
||||
"**/features/*",
|
||||
// Catch all alias imports
|
||||
"@calcom/features",
|
||||
"@calcom/features/*",
|
||||
],
|
||||
message: "Avoid importing @calcom/features from @calcom/prisma.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
/**/package.json @calcom/Foundation
|
||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||
/apps/api/**/* @calcom/API
|
||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/lib/csp.ts @calcom/Foundation
|
||||
|
||||
@@ -74,14 +74,8 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-atoms:
|
||||
name: Tests
|
||||
needs: [lint, build, build-atoms, build-api-v2]
|
||||
uses: ./.github/workflows/e2e-atoms.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
|
||||
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||
if: always()
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: E2E
|
||||
name: Check breaking changes and run E2E
|
||||
on:
|
||||
workflow_call:
|
||||
env:
|
||||
@@ -23,6 +23,10 @@ env:
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }}
|
||||
VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }}
|
||||
JWT_SECRET: ${{ secrets.CI_JWT_SECRET }}
|
||||
NODE_ENV: ${{ vars.CI_NODE_ENV }}
|
||||
jobs:
|
||||
e2e:
|
||||
timeout-minutes: 20
|
||||
@@ -68,6 +72,18 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/cache-db
|
||||
|
||||
- name: Generate Swagger
|
||||
working-directory: apps/api/v2
|
||||
run: yarn generate-swagger
|
||||
|
||||
- name: Check breaking changes
|
||||
uses: oasdiff/oasdiff-action/breaking@main
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
|
||||
revision: docs/api-reference/v2/openapi.json
|
||||
fail-on: WARN
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: apps/api/v2
|
||||
run: |
|
||||
@@ -75,6 +91,7 @@ jobs:
|
||||
EXIT_CODE=$?
|
||||
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, teams, webhooks, workflows, zapier"
|
||||
ignore-labels: "admin, app-store, ai, api, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, sre, teams, webhooks, workflows, zapier"
|
||||
apply-labels-from-issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -195,13 +195,6 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-atoms:
|
||||
name: Tests
|
||||
needs: [changes, check-label, build, build-atoms, build-api-v2]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-atoms.yml
|
||||
secrets: inherit
|
||||
|
||||
analyze:
|
||||
name: Analyze Build
|
||||
needs: [build]
|
||||
@@ -211,7 +204,7 @@ jobs:
|
||||
merge-reports:
|
||||
name: Merge reports
|
||||
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
|
||||
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||
uses: ./.github/workflows/merge-reports.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -255,8 +248,7 @@ jobs:
|
||||
e2e-api-v2,
|
||||
e2e-embed,
|
||||
e2e-embed-react,
|
||||
e2e-app-store,
|
||||
e2e-atoms,
|
||||
e2e-app-store
|
||||
]
|
||||
if: always()
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
|
||||
@@ -16,5 +16,4 @@ public
|
||||
packages/prisma/zod
|
||||
packages/prisma/enums
|
||||
apps/web/public/embed
|
||||
apps/api/v2/swagger/documentation.json
|
||||
packages/ui/components/icon/dynamicIconImports.tsx
|
||||
|
||||
Vendored
Vendored
Vendored
Vendored
Vendored
@@ -13,5 +13,8 @@
|
||||
"connect": "^3.7.0",
|
||||
"http": "^0.0.1-security",
|
||||
"http-proxy-middleware": "^2.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/eslint-config": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { nextJsConfig } from "@calcom/eslint-config/next-js";
|
||||
|
||||
export default nextJsConfig;
|
||||
+11
-10
@@ -10,7 +10,7 @@
|
||||
"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": "eslint .",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"start": "PORT=3003 next start",
|
||||
"docker-start-api": "PORT=80 next start",
|
||||
@@ -18,18 +18,19 @@
|
||||
"type-check:ci": "tsc-absolute --pretty --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/tsconfig": "*",
|
||||
"@calcom/types": "*",
|
||||
"@calcom/eslint-config": "workspace:*",
|
||||
"@calcom/tsconfig": "workspace:*",
|
||||
"@calcom/types": "workspace:*",
|
||||
"node-mocks-http": "^1.11.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/app-store": "*",
|
||||
"@calcom/dayjs": "*",
|
||||
"@calcom/emails": "*",
|
||||
"@calcom/features": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/trpc": "*",
|
||||
"@calcom/app-store": "workspace:*",
|
||||
"@calcom/dayjs": "workspace:*",
|
||||
"@calcom/emails": "workspace:*",
|
||||
"@calcom/features": "workspace:*",
|
||||
"@calcom/lib": "workspace:*",
|
||||
"@calcom/prisma": "workspace:*",
|
||||
"@calcom/trpc": "workspace:*",
|
||||
"@prisma/nextjs-monorepo-workaround-plugin": "^6.16.1",
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function getHandler(req: NextApiRequest) {
|
||||
};
|
||||
}
|
||||
|
||||
const [total, data] = await prisma.$transaction([
|
||||
const [total, data] = await Promise.all([
|
||||
prisma.user.count({ where }),
|
||||
prisma.user.findMany({ where, take, skip }),
|
||||
]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
|
||||
import handler from "../../../pages/api/selected-calendars/_post";
|
||||
|
||||
@@ -72,7 +73,7 @@ describe("POST /api/selected-calendars", () => {
|
||||
|
||||
prismaMock.user.findFirstOrThrow.mockResolvedValue({
|
||||
id: 444444,
|
||||
} as any);
|
||||
} as User);
|
||||
|
||||
prismaMock.selectedCalendar.create.mockResolvedValue({
|
||||
credentialId: 1,
|
||||
@@ -95,6 +96,16 @@ describe("POST /api/selected-calendars", () => {
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
channelId: null,
|
||||
channelKind: null,
|
||||
channelResourceId: null,
|
||||
channelResourceUri: null,
|
||||
channelExpiration: null,
|
||||
syncSubscribedAt: null,
|
||||
syncToken: null,
|
||||
syncedAt: null,
|
||||
syncErrorAt: null,
|
||||
syncErrorCount: null,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
@@ -140,6 +151,16 @@ describe("POST /api/selected-calendars", () => {
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
channelId: null,
|
||||
channelKind: null,
|
||||
channelResourceId: null,
|
||||
channelResourceUri: null,
|
||||
channelExpiration: null,
|
||||
syncSubscribedAt: null,
|
||||
syncToken: null,
|
||||
syncedAt: null,
|
||||
syncErrorAt: null,
|
||||
syncErrorCount: null,
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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", "next-i18next.config.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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -16,7 +16,7 @@
|
||||
"_dev:build:watch:enums": "yarn workspace @calcom/platform-enums build:watch",
|
||||
"_dev:build:watch:utils": "yarn workspace @calcom/platform-utils build:watch",
|
||||
"_dev:build:watch:types": "yarn workspace @calcom/platform-types build:watch",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
|
||||
"dev": "yarn dev:build && ts-node scripts/docker-start.ts && yarn copy-swagger-module && yarn start --watch",
|
||||
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
@@ -30,18 +30,19 @@
|
||||
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
|
||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register src/swagger/copy-swagger-module.ts",
|
||||
"generate-swagger": "yarn copy-swagger-module && yarn build && node ./dist/apps/api/v2/src/swagger/generate-swagger-script.js",
|
||||
"prepare": "yarn run snyk-protect",
|
||||
"snyk-protect": "snyk-protect"
|
||||
},
|
||||
"dependencies": {
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "*",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/platform-constants": "workspace:*",
|
||||
"@calcom/platform-enums": "workspace:*",
|
||||
"@calcom/platform-libraries": "workspace:*",
|
||||
"@calcom/platform-types": "workspace:*",
|
||||
"@calcom/platform-utils": "workspace:*",
|
||||
"@calcom/prisma": "workspace:*",
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||
@@ -88,6 +89,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/eslint-config": "workspace:*",
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(ReassignBookingOutputData_2024_08_13)
|
||||
export class ReassignBookingOutput_2024_08_13 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
BookingOutput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
|
||||
+51
-9
@@ -38,6 +38,7 @@ import type {
|
||||
GuestsDefaultFieldOutput_2024_06_14,
|
||||
NameDefaultFieldInput_2024_06_14,
|
||||
NotesDefaultFieldInput_2024_06_14,
|
||||
SplitNameDefaultFieldOutput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
|
||||
@@ -1447,23 +1448,23 @@ describe("Event types Endpoints", () => {
|
||||
try {
|
||||
await eventTypesRepositoryFixture.delete(eventType.id);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(falseTestUser.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
try {
|
||||
await userRepositoryFixture.delete(orgUser.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
@@ -2187,6 +2188,47 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("split name booking field", () => {
|
||||
it("should create an event type with split name booking field", async () => {
|
||||
const splitNameBookingField: SplitNameDefaultFieldOutput_2024_06_14 = {
|
||||
isDefault: true,
|
||||
type: "splitName",
|
||||
slug: "splitName",
|
||||
firstNameLabel: "First name",
|
||||
firstNamePlaceholder: "",
|
||||
lastNameLabel: "last name",
|
||||
lastNamePlaceholder: "",
|
||||
lastNameRequired: false,
|
||||
disableOnPrefill: false,
|
||||
};
|
||||
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class 9",
|
||||
slug: "coding-class-9",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
bookingFields: [splitNameBookingField],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
|
||||
const splitNameBookingFieldResponse = createdEventType.bookingFields.find(
|
||||
(field) => field.type === "splitName"
|
||||
) as SplitNameDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(splitNameBookingFieldResponse).toEqual(splitNameBookingField);
|
||||
|
||||
await eventTypesRepositoryFixture.delete(createdEventType.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
@@ -2194,12 +2236,12 @@ describe("Event types Endpoints", () => {
|
||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
|
||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
@@ -2379,7 +2421,7 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
+expect(updatedEventType.hidden).toBe(false);
|
||||
expect(updatedEventType.hidden).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2389,12 +2431,12 @@ describe("Event types Endpoints", () => {
|
||||
try {
|
||||
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
|
||||
} catch (e) {
|
||||
// Event type might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await userRepositoryFixture.delete(user.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
console.log(e);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
+12
-108
@@ -1,39 +1,29 @@
|
||||
import type { AppConfig } from "@/config/type";
|
||||
import { getEnv } from "@/env";
|
||||
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 {
|
||||
PathItemObject,
|
||||
PathsObject,
|
||||
OperationObject,
|
||||
TagObject,
|
||||
} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
|
||||
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";
|
||||
import { generateSwaggerForApp } from "./swagger/generate-swagger";
|
||||
|
||||
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
|
||||
|
||||
const run = async () => {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: WinstonModule.createLogger(loggerConfig()),
|
||||
bodyParser: false,
|
||||
});
|
||||
run().catch((error: Error) => {
|
||||
console.error("Failed to start Cal Platform API", { error: error.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const app = await createNestApp();
|
||||
const logger = new Logger("App");
|
||||
|
||||
try {
|
||||
bootstrap(app);
|
||||
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
|
||||
void generateSwagger(app);
|
||||
generateSwaggerForApp(app);
|
||||
await app.listen(port);
|
||||
logger.log(`Application started on port: ${port}`);
|
||||
} catch (error) {
|
||||
@@ -42,97 +32,11 @@ const run = async () => {
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function customTagSort(a: string, b: string): number {
|
||||
const platformPrefix = "Platform";
|
||||
const orgsPrefix = "Orgs";
|
||||
|
||||
if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function isOperationObject(obj: any): obj is OperationObject {
|
||||
return obj && typeof obj === "object" && "tags" in obj;
|
||||
}
|
||||
|
||||
function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject {
|
||||
const groupedPaths: { [key: string]: PathsObject } = {};
|
||||
|
||||
Object.keys(paths).forEach((pathKey) => {
|
||||
const pathItem = paths[pathKey];
|
||||
|
||||
HttpMethods.forEach((method) => {
|
||||
const operation = pathItem[method];
|
||||
|
||||
if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) {
|
||||
const firstTag = operation.tags[0];
|
||||
|
||||
if (!groupedPaths[firstTag]) {
|
||||
groupedPaths[firstTag] = {};
|
||||
}
|
||||
|
||||
groupedPaths[firstTag][pathKey] = pathItem;
|
||||
}
|
||||
});
|
||||
export async function createNestApp() {
|
||||
return NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: WinstonModule.createLogger(loggerConfig()),
|
||||
bodyParser: false,
|
||||
});
|
||||
|
||||
const sortedTags = Object.keys(groupedPaths).sort(customTagSort);
|
||||
const sortedPaths: PathsObject = {};
|
||||
|
||||
sortedTags.forEach((tag) => {
|
||||
Object.assign(sortedPaths, groupedPaths[tag]);
|
||||
});
|
||||
|
||||
return sortedPaths;
|
||||
}
|
||||
|
||||
async function generateSwagger(app: NestExpressApplication<Server>) {
|
||||
const logger = new Logger("App");
|
||||
logger.log(`Generating Swagger documentation...\n`);
|
||||
|
||||
const config = new DocumentBuilder().setTitle("Cal.com API v2").build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
document.paths = groupAndSortPathsByFirstTag(document.paths);
|
||||
|
||||
const swaggerOutputFile = "./swagger/documentation.json";
|
||||
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
|
||||
const stringifiedContents = JSON.stringify(document, null, 2);
|
||||
|
||||
if (fs.existsSync(swaggerOutputFile)) {
|
||||
fs.unlinkSync(swaggerOutputFile);
|
||||
}
|
||||
|
||||
fs.writeFileSync(swaggerOutputFile, stringifiedContents, { encoding: "utf8" });
|
||||
|
||||
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
|
||||
fs.unlinkSync(docsOutputFile);
|
||||
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
|
||||
}
|
||||
|
||||
if (!process.env.DOCS_URL) {
|
||||
SwaggerModule.setup("docs", app, document, {
|
||||
customCss: ".swagger-ui .topbar { display: none }",
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import {
|
||||
NO_AUTH_PROVIDED_MESSAGE,
|
||||
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
|
||||
ONLY_CLIENT_SECRET_PROVIDED_MESSAGE,
|
||||
} from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
|
||||
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
||||
@@ -9,11 +10,16 @@ export class OptionalApiAuthGuard extends ApiAuthGuard {
|
||||
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
|
||||
const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
|
||||
const onlyClientIdProvided = error && error.message.includes(ONLY_CLIENT_ID_PROVIDED_MESSAGE);
|
||||
const onlyClientSecretProvided = error && error.message.includes(ONLY_CLIENT_SECRET_PROVIDED_MESSAGE);
|
||||
|
||||
if (onlyClientIdProvided) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onlyClientSecretProvided) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (user || noAuthProvided || !error) {
|
||||
return user || null;
|
||||
} else {
|
||||
|
||||
@@ -32,7 +32,11 @@ import { randomString } from "test/utils/randomString";
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import type { PlatformOAuthClient, Team, User } from "@calcom/prisma/client";
|
||||
|
||||
import { ApiAuthGuardRequest, ONLY_CLIENT_ID_PROVIDED_MESSAGE } from "./api-auth.strategy";
|
||||
import {
|
||||
ApiAuthGuardRequest,
|
||||
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
|
||||
ONLY_CLIENT_SECRET_PROVIDED_MESSAGE,
|
||||
} from "./api-auth.strategy";
|
||||
import { ApiAuthStrategy } from "./api-auth.strategy";
|
||||
|
||||
describe("ApiAuthStrategy", () => {
|
||||
@@ -267,6 +271,29 @@ describe("ApiAuthStrategy", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw 401 if only OAuth Client Secret is provided", async () => {
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
headers: {
|
||||
[X_CAL_SECRET_KEY]: `${oAuthClient.secret}gibberish`,
|
||||
},
|
||||
get: (key: string) => ({ origin: "http://localhost:3000" }[key]),
|
||||
}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
try {
|
||||
await strategy.authenticate(request);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
expect(error.getStatus()).toEqual(401);
|
||||
expect(error.message).toContain(ONLY_CLIENT_SECRET_PROVIDED_MESSAGE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw 401 if OAuth ID is invalid", async () => {
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
|
||||
@@ -29,11 +29,11 @@ export type ApiAuthGuardRequest = Request & {
|
||||
user: ApiAuthGuardUser;
|
||||
allowedAuthMethods?: AllowedAuthMethod[];
|
||||
};
|
||||
export const NO_AUTH_PROVIDED_MESSAGE =
|
||||
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
|
||||
export const NO_AUTH_PROVIDED_MESSAGE = `No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as '${X_CAL_SECRET_KEY}' and '${X_CAL_CLIENT_ID}' headers`;
|
||||
|
||||
export const ONLY_CLIENT_ID_PROVIDED_MESSAGE =
|
||||
"Only 'x-cal-client-id' header provided. Please also provide 'x-cal-secret-key' header or Auth bearer token as 'Authentication' header";
|
||||
export const ONLY_CLIENT_ID_PROVIDED_MESSAGE = `Only '${X_CAL_CLIENT_ID}' header provided. Please also provide '${X_CAL_SECRET_KEY}' header or Auth bearer token as 'Authentication' header`;
|
||||
|
||||
export const ONLY_CLIENT_SECRET_PROVIDED_MESSAGE = `Only '${X_CAL_SECRET_KEY}' header provided. Please also provide '${X_CAL_CLIENT_ID}' header or Auth bearer token as 'Authentication' header`;
|
||||
|
||||
@Injectable()
|
||||
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
||||
@@ -121,6 +121,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
|
||||
const noAuthProvided = !oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||
const onlyClientIdProvided = !!oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||
const onlyClientSecretProvided =
|
||||
!oAuthClientId && !!oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||
|
||||
if (noAuthProvided) {
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||
}
|
||||
@@ -129,6 +132,10 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_ID_PROVIDED_MESSAGE}`);
|
||||
}
|
||||
|
||||
if (onlyClientSecretProvided) {
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_SECRET_PROVIDED_MESSAGE}`);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(
|
||||
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${
|
||||
allowedMethods && allowedMethods.length > 0 ? allowedMethods.join(", ") : "Any supported method"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings
|
||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
|
||||
@@ -9,6 +10,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
@@ -18,6 +20,7 @@ import { Module } from "@nestjs/common";
|
||||
imports: [
|
||||
PrismaModule,
|
||||
StripeModule,
|
||||
RedisModule,
|
||||
MembershipsModule,
|
||||
OrganizationsModule,
|
||||
BullModule.registerQueue({
|
||||
@@ -32,6 +35,10 @@ import { Module } from "@nestjs/common";
|
||||
providers: [
|
||||
BillingConfigService,
|
||||
BillingService,
|
||||
{
|
||||
provide: "IBillingService",
|
||||
useClass: BillingServiceCachingProxy,
|
||||
},
|
||||
BillingRepository,
|
||||
BillingProcessor,
|
||||
ManagedOrganizationsBillingService,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
@@ -16,7 +17,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
import { withNextAuth } from "test/utils/withNextAuth";
|
||||
|
||||
import type { Team, PlatformBilling } from "@calcom/prisma/client";
|
||||
|
||||
@@ -31,9 +32,8 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
||||
let organization: Team;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
const moduleRef = await withNextAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
@@ -44,8 +44,10 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: `billing-organization-${randomString()}`,
|
||||
isPlatform: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
@@ -87,6 +89,17 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("/billing/webhook (GET) should not get billing plan for org since it's not set yet", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/billing/${organization.id}/check`)
|
||||
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const data = res.body.data as CheckPlatformBillingResponseDto;
|
||||
expect(data?.plan).toEqual("FREE");
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/webhook (POST) should set billing free plan for org", () => {
|
||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||
() =>
|
||||
@@ -119,6 +132,17 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
expect(billing?.plan).toEqual("FREE");
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/webhook (GET) should get billing plan for org", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/billing/${organization.id}/check`)
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const data = res.body.data as CheckPlatformBillingResponseDto;
|
||||
expect(data?.plan).toEqual("FREE");
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
|
||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||
() =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles
|
||||
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
|
||||
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import {
|
||||
Body,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
Delete,
|
||||
ParseIntPipe,
|
||||
@@ -40,7 +41,7 @@ export class BillingController {
|
||||
private logger = new Logger("Billing Controller");
|
||||
|
||||
constructor(
|
||||
private readonly billingService: BillingService,
|
||||
@Inject("IBillingService") private readonly billingService: IBillingService,
|
||||
public readonly stripeService: StripeService,
|
||||
private readonly configService: ConfigService<AppConfig>
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import type { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import Stripe from "stripe";
|
||||
import { PlatformBilling, Team } from "@calcom/prisma/client";
|
||||
|
||||
export type BillingData = {
|
||||
team: (Team & { platformBilling: PlatformBilling | null }) | null;
|
||||
status: "valid" | "no_subscription" | "no_billing";
|
||||
plan: string;
|
||||
};
|
||||
|
||||
export interface IBillingService {
|
||||
getBillingData(teamId: number): Promise<BillingData>;
|
||||
createTeamBilling(teamId: number): Promise<string>;
|
||||
redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string): Promise<string>;
|
||||
updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string>;
|
||||
cancelTeamSubscription(teamId: number): Promise<void>;
|
||||
handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void>;
|
||||
handleStripePaymentSuccess(event: Stripe.Event): Promise<void>;
|
||||
handleStripePaymentFailed(event: Stripe.Event): Promise<void>;
|
||||
handleStripePaymentPastDue(event: Stripe.Event): Promise<void>;
|
||||
handleStripeCheckoutEvents(event: Stripe.Event): Promise<void>;
|
||||
handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void>;
|
||||
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null;
|
||||
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null;
|
||||
stripeService: StripeService;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { IBillingService, BillingData } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import Stripe from "stripe";
|
||||
|
||||
export const REDIS_BILLING_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:billing`;
|
||||
export const BILLING_CACHE_TTL_MS = 3_600_000; // 1 hour
|
||||
|
||||
@Injectable()
|
||||
export class BillingServiceCachingProxy implements IBillingService {
|
||||
constructor(private readonly billingService: BillingService, private readonly redisService: RedisService) {}
|
||||
|
||||
async getBillingData(teamId: number) {
|
||||
const cachedBillingData = await this.getBillingCache(teamId);
|
||||
if (cachedBillingData) {
|
||||
return cachedBillingData;
|
||||
}
|
||||
|
||||
const billingData = await this.billingService.getBillingData(teamId);
|
||||
await this.setBillingCache(teamId, billingData);
|
||||
return billingData;
|
||||
}
|
||||
|
||||
private async deleteBillingCache(teamId: number) {
|
||||
await this.redisService.del(REDIS_BILLING_CACHE_KEY(teamId));
|
||||
}
|
||||
|
||||
private async getBillingCache(teamId: number) {
|
||||
const cachedResult = await this.redisService.get<BillingData>(REDIS_BILLING_CACHE_KEY(teamId));
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
private async setBillingCache(teamId: number, billingData: BillingData): Promise<void> {
|
||||
await this.redisService.set<BillingData>(REDIS_BILLING_CACHE_KEY(teamId), billingData, {
|
||||
ttl: BILLING_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async createTeamBilling(teamId: number): Promise<string> {
|
||||
return this.billingService.createTeamBilling(teamId);
|
||||
}
|
||||
|
||||
async redirectToSubscribeCheckout(
|
||||
teamId: number,
|
||||
plan: PlatformPlan,
|
||||
customerId?: string
|
||||
): Promise<string> {
|
||||
return this.billingService.redirectToSubscribeCheckout(teamId, plan, customerId);
|
||||
}
|
||||
|
||||
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string> {
|
||||
return this.billingService.updateSubscriptionForTeam(teamId, plan);
|
||||
}
|
||||
|
||||
async cancelTeamSubscription(teamId: number): Promise<void> {
|
||||
await this.billingService.cancelTeamSubscription(teamId);
|
||||
await this.deleteBillingCache(teamId);
|
||||
}
|
||||
|
||||
async handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void> {
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const teamId = subscription?.metadata?.teamId;
|
||||
if (teamId) {
|
||||
await this.deleteBillingCache(Number.parseInt(teamId));
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentSuccess(event: Stripe.Event): Promise<void> {
|
||||
await this.billingService.handleStripePaymentSuccess(event);
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
if (subscriptionId) {
|
||||
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
|
||||
subscriptionId
|
||||
);
|
||||
if (teamBilling?.id) {
|
||||
await this.deleteBillingCache(teamBilling.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentFailed(event: Stripe.Event): Promise<void> {
|
||||
await this.billingService.handleStripePaymentFailed(event);
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
if (subscriptionId) {
|
||||
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
|
||||
subscriptionId
|
||||
);
|
||||
if (teamBilling?.id) {
|
||||
await this.deleteBillingCache(teamBilling.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentPastDue(event: Stripe.Event): Promise<void> {
|
||||
await this.billingService.handleStripePaymentPastDue(event);
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const subscriptionId = subscription.id;
|
||||
if (subscriptionId) {
|
||||
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
|
||||
subscriptionId
|
||||
);
|
||||
if (teamBilling?.id) {
|
||||
await this.deleteBillingCache(teamBilling.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripeCheckoutEvents(event: Stripe.Event): Promise<void> {
|
||||
await this.billingService.handleStripeCheckoutEvents(event);
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||
const teamId = checkoutSession.metadata?.teamId;
|
||||
if (teamId) {
|
||||
await this.deleteBillingCache(Number.parseInt(teamId));
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void> {
|
||||
return this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||
}
|
||||
|
||||
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||
return this.billingService.getSubscriptionIdFromInvoice(invoice);
|
||||
}
|
||||
|
||||
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||
return this.billingService.getCustomerIdFromInvoice(invoice);
|
||||
}
|
||||
|
||||
get stripeService() {
|
||||
return this.billingService.stripeService;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { AppConfig } from "@/config/type";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
@@ -23,14 +24,14 @@ import { DateTime } from "luxon";
|
||||
import Stripe from "stripe";
|
||||
|
||||
@Injectable()
|
||||
export class BillingService implements OnModuleDestroy {
|
||||
export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
private logger = new Logger("BillingService");
|
||||
private readonly webAppUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly teamsRepository: OrganizationsRepository,
|
||||
public readonly stripeService: StripeService,
|
||||
private readonly billingRepository: BillingRepository,
|
||||
public readonly billingRepository: BillingRepository,
|
||||
private readonly configService: ConfigService<AppConfig>,
|
||||
private readonly billingConfigService: BillingConfigService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
@@ -43,18 +44,23 @@ export class BillingService implements OnModuleDestroy {
|
||||
|
||||
async getBillingData(teamId: number) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
|
||||
if (teamWithBilling?.platformBilling) {
|
||||
if (!teamWithBilling?.platformBilling.subscriptionId) {
|
||||
return { team: teamWithBilling, status: "no_subscription", plan: "none" };
|
||||
return { team: teamWithBilling, status: "no_subscription" as const, plan: "none" };
|
||||
} else {
|
||||
return {
|
||||
team: teamWithBilling,
|
||||
status: "valid" as const,
|
||||
plan: teamWithBilling.platformBilling.plan,
|
||||
};
|
||||
}
|
||||
|
||||
return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan };
|
||||
} else {
|
||||
return { team: teamWithBilling, status: "no_billing", plan: "none" };
|
||||
return { team: teamWithBilling, status: "no_billing" as const, plan: "none" };
|
||||
}
|
||||
}
|
||||
|
||||
async createTeamBilling(teamId: number) {
|
||||
async createTeamBilling(teamId: number): Promise<string> {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
let customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
@@ -67,7 +73,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
return customerId;
|
||||
return customerId!;
|
||||
}
|
||||
|
||||
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
|
||||
|
||||
+7
-1
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsISO8601, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
@@ -297,6 +297,12 @@ export class DateTimeWithZone {
|
||||
timeZone!: string;
|
||||
}
|
||||
|
||||
@ApiExtraModels(
|
||||
CalendarEventVideoLocation,
|
||||
CalendarEventPhoneLocation,
|
||||
CalendarEventSipLocation,
|
||||
CalendarEventMoreLocation
|
||||
)
|
||||
export class UnifiedCalendarEventOutput {
|
||||
@ValidateNested()
|
||||
@Type(() => DateTimeWithZone)
|
||||
|
||||
+3
-1
@@ -4,9 +4,11 @@ import {
|
||||
MicrosoftServiceAccountKeyInput,
|
||||
} from "@/modules/organizations/delegation-credentials/inputs/service-account-key.input";
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, IsNotEmpty, ValidateNested, Validate } from "class-validator";
|
||||
import { IsString, IsNotEmpty, Validate } from "class-validator";
|
||||
|
||||
@ApiExtraModels(GoogleServiceAccountKeyInput, MicrosoftServiceAccountKeyInput)
|
||||
export class CreateDelegationCredentialInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
+4
-3
@@ -3,10 +3,11 @@ import {
|
||||
MicrosoftServiceAccountKeyInput,
|
||||
ServiceAccountKeyValidator,
|
||||
} from "@/modules/organizations/delegation-credentials/inputs/service-account-key.input";
|
||||
import { ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Expose, plainToClass, Transform, Type } from "class-transformer";
|
||||
import { IsBoolean, IsOptional, Validate, ValidateNested } from "class-validator";
|
||||
import { ApiExtraModels, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsBoolean, IsOptional, Validate } from "class-validator";
|
||||
|
||||
@ApiExtraModels(GoogleServiceAccountKeyInput, MicrosoftServiceAccountKeyInput)
|
||||
export class UpdateDelegationCredentialInput {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
|
||||
@@ -174,4 +174,14 @@ export class OrganizationsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamByPlatformBillingId(billingId: number) {
|
||||
return this.dbRead.prisma.team.findFirst({
|
||||
where: {
|
||||
platformBilling: {
|
||||
id: billingId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { OrganizationsTeamsController } from "@/modules/organizations/teams/inde
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsInviteController } from "@/modules/organizations/teams/invite/organizations-teams-invite.controller";
|
||||
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
||||
@@ -183,6 +184,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsMembershipsController,
|
||||
OrganizationsEventTypesController,
|
||||
OrganizationsTeamsMembershipsController,
|
||||
OrganizationsTeamsInviteController,
|
||||
OrganizationsAttributesController,
|
||||
OrganizationsAttributesOptionsController,
|
||||
OrganizationsWebhooksController,
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ export class ManagedOrganizationsRepository {
|
||||
managedOrganization: managedOrganizationFilter,
|
||||
};
|
||||
|
||||
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([
|
||||
const [totalItems, linkRows] = await Promise.all([
|
||||
this.dbRead.prisma.managedOrganization.count({ where }),
|
||||
this.dbRead.prisma.managedOrganization.findMany({
|
||||
where,
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
@@ -75,6 +75,7 @@ class Routing {
|
||||
crmOwnerRecordType?: string;
|
||||
}
|
||||
|
||||
@ApiExtraModels(SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04)
|
||||
export class CreateRoutingFormResponseOutputData {
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.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 * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Teams Invite Endpoints", () => {
|
||||
describe("User Authentication - User is Org Team Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
let nonOrgTeam: Team;
|
||||
|
||||
const userEmail = `organizations-teams-invite-admin-${randomString()}@api.com`;
|
||||
|
||||
let user: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: `organizations-teams-invite-organization-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
orgTeam = await teamsRepositoryFixture.create({
|
||||
name: `organizations-teams-invite-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
nonOrgTeam = await teamsRepositoryFixture.create({
|
||||
name: `organizations-teams-invite-non-org-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
});
|
||||
|
||||
// Admin of the org team
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
|
||||
// Also a member of the organization
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should create a team invite", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data.token.length).toBeGreaterThan(0);
|
||||
expect(response.body.data.inviteLink).toEqual(expect.any(String));
|
||||
expect(response.body.data.inviteLink).toContain(response.body.data.token);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a new invite on each request", async () => {
|
||||
const first = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
|
||||
.expect(200);
|
||||
const firstToken = first.body.data.token as string;
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data.token).not.toEqual(firstToken);
|
||||
expect(response.body.data.inviteLink).toEqual(expect.any(String));
|
||||
expect(response.body.data.inviteLink).toContain(response.body.data.token);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail for team not in organization", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/invite`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await teamsRepositoryFixture.delete(nonOrgTeam.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { OPTIONAL_API_KEY_HEADER, OPTIONAL_X_CAL_CLIENT_ID_HEADER, OPTIONAL_X_CAL_SECRET_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { Controller, UseGuards, Post, Param, ParseIntPipe, HttpCode, HttpStatus } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import { TeamService } from "@calcom/platform-libraries";
|
||||
|
||||
|
||||
import { CreateInviteOutputDto } from "./outputs/invite.output";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Teams / Invite")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsTeamsInviteController {
|
||||
@Post("/invite")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@ApiOperation({ summary: "Create team invite link" })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async createInvite(
|
||||
@Param("orgId", ParseIntPipe) _orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<CreateInviteOutputDto> {
|
||||
const result = await TeamService.createInvite(teamId);
|
||||
return { status: SUCCESS_STATUS, data: result };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class InviteDataDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique invitation token for this team. Share this token with prospective members to allow them to join the team/organization.",
|
||||
example: "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2",
|
||||
})
|
||||
token!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Complete invitation URL that can be shared with prospective members. Opens the signup page with the token and redirects to getting started after signup.",
|
||||
example: "http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started",
|
||||
})
|
||||
inviteLink!: string;
|
||||
}
|
||||
|
||||
export class CreateInviteOutputDto {
|
||||
@ApiProperty({ example: "success" })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ type: InviteDataDto })
|
||||
data!: InviteDataDto;
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -351,6 +351,7 @@ describe("Organizations Users Endpoints", () => {
|
||||
email: `organizations-users-new-member-${randomString()}@api.com`,
|
||||
bio,
|
||||
metadata,
|
||||
timeZone: "Europe/Rome",
|
||||
};
|
||||
|
||||
const emailSpy = jest
|
||||
@@ -367,6 +368,7 @@ describe("Organizations Users Endpoints", () => {
|
||||
expect(userData.email).toBe(newOrgUser.email);
|
||||
expect(userData.bio).toBe(newOrgUser.bio);
|
||||
expect(userData.metadata).toEqual(newOrgUser.metadata);
|
||||
expect(userData.timeZone).toBe(newOrgUser.timeZone);
|
||||
expect(emailSpy).toHaveBeenCalledWith({
|
||||
usernameOrEmail: newOrgUser.email,
|
||||
orgName: org.name,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(TeamEventTypeOutput_2024_06_14)
|
||||
export class CreateTeamEventTypeOutput extends ApiResponseWithoutData {
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(TeamEventTypeOutput_2024_06_14)
|
||||
export class UpdateTeamEventTypeOutput extends ApiResponseWithoutData {
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsEnum, ValidateNested, IsString, IsUrl } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { TeamOutputDto } from "@calcom/platform-types";
|
||||
|
||||
class Output {
|
||||
class CreateTeamOutputData {
|
||||
@Expose()
|
||||
@IsString()
|
||||
message!: string;
|
||||
@@ -19,16 +19,17 @@ class Output {
|
||||
pendingTeam!: TeamOutputDto;
|
||||
}
|
||||
|
||||
@ApiExtraModels(TeamOutputDto, CreateTeamOutputData)
|
||||
export class CreateTeamOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
oneOf: [{ $ref: getSchemaPath(Output) }, { $ref: getSchemaPath(TeamOutputDto) }],
|
||||
oneOf: [{ $ref: getSchemaPath(CreateTeamOutputData) }, { $ref: getSchemaPath(TeamOutputDto) }],
|
||||
description: "Either an Output object or a TeamOutputDto.",
|
||||
})
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
data!: Output | TeamOutputDto;
|
||||
data!: CreateTeamOutputData | TeamOutputDto;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Expose, Transform } from "class-transformer";
|
||||
import {
|
||||
@@ -112,6 +113,7 @@ export class CreateUserInput {
|
||||
@ApiProperty({ type: String, required: false, description: "Time zone", example: "America/New_York" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@CapitalizeTimeZone()
|
||||
@Validate(TimeZoneValidator)
|
||||
@Expose()
|
||||
timeZone?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ValidatorConstraintInterface } from "class-validator";
|
||||
import { ValidatorConstraint } from "class-validator";
|
||||
import tzdata from "tzdata";
|
||||
import * as tzdata from "tzdata";
|
||||
|
||||
@ValidatorConstraint({ name: "timezoneValidator", async: false })
|
||||
export class TimeZoneValidator implements ValidatorConstraintInterface {
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ import * as path from "path";
|
||||
// "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 monorepoRoot = path.resolve(__dirname, "../../../../../");
|
||||
const nodeModulesNestjs = path.resolve(__dirname, "../../node_modules/@nestjs");
|
||||
const swaggerModulePath = "@nestjs/swagger";
|
||||
|
||||
const sourceDir = path.join(monorepoRoot, "node_modules", swaggerModulePath);
|
||||
@@ -0,0 +1,29 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import { bootstrap } from "../app";
|
||||
import { createNestApp } from "../main";
|
||||
import { generateSwaggerForApp } from "../swagger/generate-swagger";
|
||||
|
||||
generateSwagger()
|
||||
.then(() => {
|
||||
console.log("✅ Swagger generation completed successfully");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
console.error("❌ Failed to generate swagger", { error: error.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function generateSwagger() {
|
||||
const app = await createNestApp();
|
||||
|
||||
try {
|
||||
bootstrap(app);
|
||||
await generateSwaggerForApp(app);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
||||
import {
|
||||
PathItemObject,
|
||||
PathsObject,
|
||||
OperationObject,
|
||||
} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
|
||||
import "dotenv/config";
|
||||
import * as fs from "fs";
|
||||
import { Server } from "http";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
|
||||
|
||||
export async function generateSwaggerForApp(app: NestExpressApplication<Server>) {
|
||||
const logger = new Logger("App");
|
||||
logger.log(`Generating Swagger documentation...\n`);
|
||||
|
||||
const config = new DocumentBuilder().setTitle("Cal.com API v2").build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
document.paths = groupAndSortPathsByFirstTag(document.paths);
|
||||
|
||||
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
|
||||
const stringifiedContents = JSON.stringify(document, null, 2);
|
||||
|
||||
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
|
||||
fs.unlinkSync(docsOutputFile);
|
||||
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
|
||||
spawnSync("npx", ["prettier", docsOutputFile, "--write"], { stdio: "inherit" });
|
||||
}
|
||||
|
||||
if (!process.env.DOCS_URL) {
|
||||
SwaggerModule.setup("docs", app, document, {
|
||||
customCss: ".swagger-ui .topbar { display: none }",
|
||||
});
|
||||
|
||||
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject {
|
||||
const groupedPaths: { [key: string]: PathsObject } = {};
|
||||
|
||||
Object.keys(paths).forEach((pathKey) => {
|
||||
const pathItem = paths[pathKey];
|
||||
|
||||
HttpMethods.forEach((method) => {
|
||||
const operation = pathItem[method];
|
||||
|
||||
if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) {
|
||||
const firstTag = operation.tags[0];
|
||||
|
||||
if (!groupedPaths[firstTag]) {
|
||||
groupedPaths[firstTag] = {};
|
||||
}
|
||||
|
||||
groupedPaths[firstTag][pathKey] = pathItem;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sortedTags = Object.keys(groupedPaths).sort(customTagSort);
|
||||
const sortedPaths: PathsObject = {};
|
||||
|
||||
sortedTags.forEach((tag) => {
|
||||
Object.assign(sortedPaths, groupedPaths[tag]);
|
||||
});
|
||||
|
||||
return sortedPaths;
|
||||
}
|
||||
|
||||
function customTagSort(a: string, b: string): number {
|
||||
const platformPrefix = "Platform";
|
||||
const orgsPrefix = "Orgs";
|
||||
|
||||
if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function isOperationObject(obj: any): obj is OperationObject {
|
||||
return obj && typeof obj === "object" && "tags" in obj;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ export class PlatformBillingRepositoryFixture {
|
||||
id: orgId,
|
||||
customerId: `cus_123_${randomString}`,
|
||||
subscriptionId: `sub_123_${randomString}`,
|
||||
plan: plan || "STARTER",
|
||||
plan: plan || "FREE",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -636,10 +636,6 @@ select:focus {
|
||||
@apply !border-r-default left-0.5 !border-y-0 !border-l-0;
|
||||
}
|
||||
|
||||
.intercom-lightweight-app {
|
||||
@apply z-40 !important;
|
||||
}
|
||||
|
||||
@import "../../web/styles/tokens.css";
|
||||
|
||||
@layer base {
|
||||
@@ -1027,10 +1023,6 @@ select:focus {
|
||||
@apply !border-r-default left-0.5 !border-y-0 !border-l-0;
|
||||
}
|
||||
|
||||
.intercom-lightweight-app {
|
||||
@apply z-40 !important;
|
||||
}
|
||||
|
||||
.prose :where(a:not([data-card])):not(:where([class~="not-prose"] *)) {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { nextJsConfig } from "@calcom/eslint-config/next-js";
|
||||
|
||||
export default nextJsConfig;
|
||||
@@ -6,12 +6,12 @@
|
||||
"dev": "next dev --turbopack --port=1337",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/config": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/ui": "*",
|
||||
"@calcom/config": "workspace:*",
|
||||
"@calcom/lib": "workspace:*",
|
||||
"@calcom/ui": "workspace:*",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"airtable": "^0.12.2",
|
||||
"fumadocs-core": "^14.5.4",
|
||||
@@ -24,14 +24,12 @@
|
||||
"react-hot-toast": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/tsconfig": "*",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@calcom/eslint-config": "workspace:*",
|
||||
"@calcom/tsconfig": "workspace:*",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.9.0-beta"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
public/embed
|
||||
@@ -5,10 +5,11 @@ import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -72,8 +73,14 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
orgId: organizationId,
|
||||
})
|
||||
: false;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
|
||||
const canViewTeamAvailability = isOrgAdminOrOwner || !isOrgPrivate;
|
||||
|
||||
const permissionService = new PermissionCheckService();
|
||||
const teamIdsWithPermission = await permissionService.getTeamIdsWithPermission({
|
||||
userId: session.user.id,
|
||||
permission: "availability.read",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
const canViewTeamAvailability = teamIdsWithPermission.length > 0 || !isOrgPrivate;
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
|
||||
@@ -6,6 +6,8 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -33,9 +35,30 @@ const Page = async ({ params }: PageProps) => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
let canReadOthersBookings = false;
|
||||
if (session?.user?.id) {
|
||||
const permissionService = new PermissionCheckService();
|
||||
const userId = session.user.id;
|
||||
|
||||
const teamIdsWithPermission = await permissionService.getTeamIdsWithPermission({
|
||||
userId,
|
||||
permission: "booking.read",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
// We check if teamIdsWithPermission.length > 0.
|
||||
// While this may not be entirely accurate, it's acceptable
|
||||
// because we perform a thorough validation on the server side for the actual filter values.
|
||||
// This variable is primarily for UI purposes.
|
||||
canReadOthersBookings = teamIdsWithPermission.length > 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<BookingsList status={parsed.data.status} userId={session?.user?.id} />
|
||||
<BookingsList
|
||||
status={parsed.data.status}
|
||||
userId={session?.user?.id}
|
||||
permissions={{ canReadOthersBookings }}
|
||||
/>
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export const TeamsCTA = () => {
|
||||
data-testid="new-team-btn"
|
||||
variant="fab"
|
||||
StartIcon="plus"
|
||||
size="sm"
|
||||
type="button"
|
||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||
{t("new")}
|
||||
|
||||
@@ -23,7 +23,8 @@ const ServerPage = async ({ searchParams: _searchParams }: ServerPageProps) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const searchParams = await _searchParams;
|
||||
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
|
||||
const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}` : null;
|
||||
const autoAccept = Array.isArray(searchParams?.autoAccept) ? searchParams.autoAccept[0] : searchParams?.autoAccept;
|
||||
const callbackUrl = token ? `/teams?token=${encodeURIComponent(token)}${autoAccept ? `&autoAccept=${encodeURIComponent(autoAccept)}` : ''}` : null;
|
||||
|
||||
if (!session) {
|
||||
redirect(callbackUrl ? `/auth/login?callbackUrl=${callbackUrl}` : "/auth/login");
|
||||
|
||||
@@ -2,11 +2,12 @@ import type { SearchParams } from "app/_types";
|
||||
import type { Session } from "next-auth";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { TeamService } from "@calcom/lib/server/service/teamService";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -32,14 +33,21 @@ export const ServerTeamsListing = async ({
|
||||
session: Session;
|
||||
}) => {
|
||||
const token = Array.isArray(searchParams?.token) ? searchParams.token[0] : searchParams?.token;
|
||||
const autoAccept = Array.isArray(searchParams?.autoAccept) ? searchParams.autoAccept[0] : searchParams?.autoAccept;
|
||||
const userId = session.user.id;
|
||||
let invitationAccepted = false;
|
||||
|
||||
let teamNameFromInvite,
|
||||
errorMsgFromInvite = null;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
teamNameFromInvite = await TeamService.inviteMemberByToken(token, userId);
|
||||
if (autoAccept === "true") {
|
||||
await TeamService.acceptInvitationByToken(token, userId);
|
||||
invitationAccepted = true;
|
||||
} else {
|
||||
teamNameFromInvite = await TeamService.inviteMemberByToken(token, userId);
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsgFromInvite = "Error while fetching teams";
|
||||
if (e instanceof TRPCError) errorMsgFromInvite = e.message;
|
||||
@@ -49,21 +57,30 @@ export const ServerTeamsListing = async ({
|
||||
const teams = await getCachedTeams(userId);
|
||||
const userProfile = session?.user?.profile;
|
||||
const orgId = userProfile?.organizationId ?? session?.user.org?.id;
|
||||
const orgRole =
|
||||
session?.user?.org?.role ??
|
||||
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const canCreateTeam = orgId
|
||||
? await permissionCheckService.checkPermission({
|
||||
userId: session.user.id,
|
||||
teamId: orgId,
|
||||
permission: "team.create",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
})
|
||||
: false;
|
||||
|
||||
return {
|
||||
Main: (
|
||||
<TeamsListing
|
||||
invitationAccepted={invitationAccepted}
|
||||
teams={teams}
|
||||
orgId={orgId ?? null}
|
||||
isOrgAdmin={isOrgAdminOrOwner}
|
||||
permissions={{
|
||||
canCreateTeam: canCreateTeam,
|
||||
}}
|
||||
teamNameFromInvite={teamNameFromInvite ?? null}
|
||||
errorMsgFromInvite={errorMsgFromInvite}
|
||||
/>
|
||||
),
|
||||
CTA: !orgId || isOrgAdminOrOwner ? <TeamsCTA /> : null,
|
||||
CTA: !orgId || canCreateTeam ? <TeamsCTA /> : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { ArrowButton } from "@calcom/ui/components/arrow-button";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
@@ -46,6 +47,10 @@ function NewFormButton({ setNewFormDialogState }: { setNewFormDialogState: SetNe
|
||||
createFunction={(teamId) => {
|
||||
setNewFormDialogState({ action: "new", target: teamId ? String(teamId) : "" });
|
||||
}}
|
||||
withPermission={{
|
||||
permission: "routingForm.create",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+40
-27
@@ -101,10 +101,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
name: "privacy",
|
||||
href: "/settings/organizations/privacy",
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
},
|
||||
|
||||
{ name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" },
|
||||
{
|
||||
name: "SSO",
|
||||
@@ -173,17 +170,12 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
// The following keys are assigned to admin only
|
||||
const adminRequiredKeys = ["admin"];
|
||||
const organizationRequiredKeys = ["organization"];
|
||||
const organizationAdminKeys = [
|
||||
"privacy",
|
||||
"billing",
|
||||
"OAuth Clients",
|
||||
"SSO",
|
||||
"directory_sync",
|
||||
"delegation_credential",
|
||||
];
|
||||
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
|
||||
|
||||
export interface SettingsPermissions {
|
||||
canViewRoles?: boolean;
|
||||
canViewOrganizationBilling?: boolean;
|
||||
canUpdateOrganization?: boolean;
|
||||
}
|
||||
|
||||
const useTabs = ({
|
||||
@@ -199,7 +191,6 @@ const useTabs = ({
|
||||
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
|
||||
const orgBranding = useOrgBranding();
|
||||
const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgBranding?.role);
|
||||
|
||||
const processTabsMemod = useMemo(() => {
|
||||
const processedTabs = getTabs(orgBranding).map((tab) => {
|
||||
@@ -212,10 +203,10 @@ const useTabs = ({
|
||||
};
|
||||
} else if (tab.href === "/settings/organizations") {
|
||||
const newArray = (tab?.children ?? []).filter(
|
||||
(child) => isOrgAdminOrOwner || !organizationAdminKeys.includes(child.name)
|
||||
(child) => permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name)
|
||||
);
|
||||
|
||||
if (isOrgAdminOrOwner) {
|
||||
if (permissions?.canUpdateOrganization) {
|
||||
newArray.splice(4, 0, {
|
||||
name: "attributes",
|
||||
href: "/settings/organizations/attributes",
|
||||
@@ -232,11 +223,27 @@ const useTabs = ({
|
||||
|
||||
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
|
||||
// This prevents showing the menu item when user has no organization permissions
|
||||
if (isPbacEnabled && permissions?.canViewRoles) {
|
||||
newArray.push({
|
||||
name: "roles_and_permissions",
|
||||
href: "/settings/organizations/roles",
|
||||
});
|
||||
if (isPbacEnabled) {
|
||||
if (permissions?.canViewRoles) {
|
||||
newArray.push({
|
||||
name: "roles_and_permissions",
|
||||
href: "/settings/organizations/roles",
|
||||
});
|
||||
}
|
||||
|
||||
if (permissions?.canViewOrganizationBilling) {
|
||||
newArray.push({
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (permissions?.canUpdateOrganization) {
|
||||
newArray.push({
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -257,7 +264,7 @@ const useTabs = ({
|
||||
return { ...tab, children: filtered };
|
||||
} else if (tab.href === "/settings/developer") {
|
||||
const filtered = tab?.children?.filter(
|
||||
(childTab) => isOrgAdminOrOwner || childTab.name !== "admin_api"
|
||||
(childTab) => permissions?.canUpdateOrganization || childTab.name !== "admin_api"
|
||||
);
|
||||
return { ...tab, children: filtered };
|
||||
}
|
||||
@@ -267,12 +274,12 @@ const useTabs = ({
|
||||
// check if name is in adminRequiredKeys
|
||||
return processedTabs.filter((tab) => {
|
||||
if (organizationRequiredKeys.includes(tab.name)) return !!orgBranding;
|
||||
if (tab.name === "other_teams" && !isOrgAdminOrOwner) return false;
|
||||
if (tab.name === "other_teams" && !permissions?.canUpdateOrganization) return false;
|
||||
|
||||
if (isAdmin) return true;
|
||||
return !adminRequiredKeys.includes(tab.name);
|
||||
});
|
||||
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled]);
|
||||
}, [isAdmin, orgBranding, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
|
||||
|
||||
return processTabsMemod;
|
||||
};
|
||||
@@ -431,6 +438,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("profile")}
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
@@ -438,6 +446,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("members")}
|
||||
href={`/settings/teams/${team.id}/members`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
{/* Show roles only for sub-teams with PBAC-enabled parent */}
|
||||
@@ -458,6 +467,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("appearance")}
|
||||
href={`/settings/teams/${team.id}/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
{/* Hide if there is a parent ID */}
|
||||
@@ -467,6 +477,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("billing")}
|
||||
href={`/settings/teams/${team.id}/billing`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
</>
|
||||
@@ -475,6 +486,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("settings")}
|
||||
href={`/settings/teams/${team.id}/settings`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
</>
|
||||
@@ -548,8 +560,6 @@ const SettingsSidebarContainer = ({
|
||||
}
|
||||
}, [searchParams?.get("id"), otherTeams]);
|
||||
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgBranding?.role);
|
||||
|
||||
return (
|
||||
<nav
|
||||
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
|
||||
@@ -626,16 +636,17 @@ const SettingsSidebarContainer = ({
|
||||
as="p"
|
||||
className="text-subtle truncate text-sm font-medium leading-5"
|
||||
loadingClassName="ms-3">
|
||||
{t(isOrgAdminOrOwner ? "my_teams" : tab.name)}
|
||||
{t("my_teams")}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</Link>
|
||||
<TeamListCollapsible teamFeatures={teamFeatures} />
|
||||
{(!orgBranding?.id || isOrgAdminOrOwner) && (
|
||||
{(!orgBranding?.id || permissions?.canUpdateOrganization) && (
|
||||
<VerticalTabItem
|
||||
name={t("add_a_team")}
|
||||
href={`${WEBAPP_URL}/settings/teams/new`}
|
||||
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
icon="plus"
|
||||
disableChevron
|
||||
/>
|
||||
@@ -737,12 +748,14 @@ const SettingsSidebarContainer = ({
|
||||
name={t("profile")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
<VerticalTabItem
|
||||
name={t("members")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
<>
|
||||
|
||||
@@ -21,7 +21,7 @@ const Page = async () => {
|
||||
<SettingsHeader
|
||||
title={t("billing")}
|
||||
description={t("manage_billing_description")}
|
||||
borderInShellHeader={true}>
|
||||
borderInShellHeader={false}>
|
||||
<BillingView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,12 @@ import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { TeamFeatures } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { Resource, CrudAction, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
@@ -45,13 +46,17 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
|
||||
let teamFeatures: Record<number, TeamFeatures> | null = null;
|
||||
let canViewRoles = false;
|
||||
let canViewOrganizationBilling = false;
|
||||
let canUpdateOrganization = false;
|
||||
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
||||
|
||||
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
|
||||
if (orgId) {
|
||||
const [features, rolePermissions] = await Promise.all([
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session.user.org?.role);
|
||||
const [features, rolePermissions, organizationPermissions] = await Promise.all([
|
||||
getTeamFeatures(orgId),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Role),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Organization),
|
||||
]);
|
||||
|
||||
if (features) {
|
||||
@@ -62,6 +67,13 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
// Check if user has permission to read roles
|
||||
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
|
||||
canViewRoles = roleActions[CrudAction.Read] ?? false;
|
||||
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
|
||||
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
|
||||
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
|
||||
} else {
|
||||
canViewRoles = false;
|
||||
canViewOrganizationBilling = isOrgAdminOrOwner;
|
||||
canUpdateOrganization = isOrgAdminOrOwner;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +82,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
<SettingsLayoutAppDirClient
|
||||
{...props}
|
||||
teamFeatures={teamFeatures ?? {}}
|
||||
permissions={{ canViewRoles }}
|
||||
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+6
-3
@@ -2,10 +2,11 @@ import { _generateMetadata } from "app/_utils";
|
||||
import { getTranslate } from "app/_utils";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import BillingView from "~/settings/billing/billing-view";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
import { validateUserHasOrgPerms } from "../../actions/validateUserHasOrgPerms";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -18,9 +19,11 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
await validateUserHasOrgAdmin();
|
||||
|
||||
// TODO(SEAN): Add PBAC to this page in the next PR
|
||||
await validateUserHasOrgPerms({
|
||||
permission: "organization.manageBilling",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
|
||||
-20
@@ -1,24 +1,4 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const userProfile = session?.user?.profile;
|
||||
const userId = session?.user?.id;
|
||||
const orgRole =
|
||||
session?.user?.org?.role ??
|
||||
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
|
||||
|
||||
if (!isOrgAdminOrOwner) {
|
||||
return redirect("/settings/organizations/profile");
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrg } from "./validateUserHasOrg";
|
||||
|
||||
export const validateUserHasOrgPerms = async ({
|
||||
redirectTo,
|
||||
fallbackRoles,
|
||||
permission,
|
||||
}: {
|
||||
redirectTo?: string;
|
||||
permission: PermissionString;
|
||||
fallbackRoles: MembershipRole[];
|
||||
}) => {
|
||||
const session = await validateUserHasOrg();
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.org.id,
|
||||
permission,
|
||||
fallbackRoles,
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
redirect(redirectTo || "/settings/my-account/profile");
|
||||
}
|
||||
|
||||
return session;
|
||||
};
|
||||
+3
-1
@@ -22,6 +22,7 @@ interface AdvancedPermissionGroupProps {
|
||||
onChange: (permissions: string[]) => void;
|
||||
disabled?: boolean;
|
||||
scope?: Scope;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
const INTERNAL_DATAACCESS_KEY = "_resource";
|
||||
@@ -32,10 +33,11 @@ export function AdvancedPermissionGroup({
|
||||
onChange,
|
||||
disabled,
|
||||
scope = Scope.Organization,
|
||||
isPrivate = false,
|
||||
}: AdvancedPermissionGroupProps) {
|
||||
const { t } = useLocale();
|
||||
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope);
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
|
||||
const resourceConfig = scopedRegistry[resource];
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
|
||||
+14
-3
@@ -56,9 +56,17 @@ interface RoleSheetProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
teamId: number;
|
||||
scope?: Scope;
|
||||
isPrivate?: boolean; // Add isPrivate prop to control permission visibility
|
||||
}
|
||||
|
||||
export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Organization }: RoleSheetProps) {
|
||||
export function RoleSheet({
|
||||
role,
|
||||
open,
|
||||
onOpenChange,
|
||||
teamId,
|
||||
scope = Scope.Organization,
|
||||
isPrivate = false
|
||||
}: RoleSheetProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const isEditing = Boolean(role);
|
||||
@@ -108,7 +116,8 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
const { isAdvancedMode, permissions, color } = form.watch();
|
||||
|
||||
const { filteredResources, scopedRegistry } = useMemo(() => {
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
// Use privacy-aware filtering if we have privacy information
|
||||
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
|
||||
const filteredResources = Object.keys(scopedRegistry).filter((resource) =>
|
||||
t(
|
||||
scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]]
|
||||
@@ -118,7 +127,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
return { filteredResources, scopedRegistry };
|
||||
}, [searchQuery, t, scope]);
|
||||
}, [searchQuery, t, scope, isPrivate]);
|
||||
|
||||
const createMutation = trpc.viewer.pbac.createRole.useMutation({
|
||||
onSuccess: async () => {
|
||||
@@ -221,6 +230,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
|
||||
disabled={isSystemRole}
|
||||
scope={scope}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
))}{" "}
|
||||
</div>
|
||||
@@ -245,6 +255,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
|
||||
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
|
||||
disabled={isSystemRole}
|
||||
scope={scope}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
))}
|
||||
</div>{" "}
|
||||
|
||||
+3
@@ -48,6 +48,7 @@ interface RolesListProps {
|
||||
initialSheetOpen?: boolean;
|
||||
teamId: number;
|
||||
scope?: Scope;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
export function RolesList({
|
||||
@@ -57,6 +58,7 @@ export function RolesList({
|
||||
initialSheetOpen,
|
||||
teamId,
|
||||
scope,
|
||||
isPrivate,
|
||||
}: RolesListProps) {
|
||||
const { t } = useLocale();
|
||||
const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates(
|
||||
@@ -113,6 +115,7 @@ export function RolesList({
|
||||
onOpenChange={handleSheetOpenChange}
|
||||
teamId={teamId}
|
||||
scope={scope}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+3
-1
@@ -18,6 +18,7 @@ interface SimplePermissionItemProps {
|
||||
onChange: (permissions: string[]) => void;
|
||||
disabled?: boolean;
|
||||
scope?: Scope;
|
||||
isPrivate?: boolean;
|
||||
}
|
||||
|
||||
export function SimplePermissionItem({
|
||||
@@ -26,10 +27,11 @@ export function SimplePermissionItem({
|
||||
onChange,
|
||||
disabled,
|
||||
scope = Scope.Organization,
|
||||
isPrivate = false,
|
||||
}: SimplePermissionItemProps) {
|
||||
const { t } = useLocale();
|
||||
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
|
||||
const scopedRegistry = getPermissionsForScope(scope);
|
||||
const scopedRegistry = getPermissionsForScope(scope, isPrivate);
|
||||
|
||||
const registry = scopedRegistry || PERMISSION_REGISTRY;
|
||||
const isAllResources = resource === "*";
|
||||
|
||||
+15
-1
@@ -44,6 +44,18 @@ const getCachedResourcePermissions = unstable_cache(
|
||||
{ revalidate: 3600 }
|
||||
);
|
||||
|
||||
const getCachedTeamPrivacy = unstable_cache(
|
||||
async (teamId: number) => {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { isPrivate: true },
|
||||
});
|
||||
return team?.isPrivate ?? false;
|
||||
},
|
||||
["team-privacy"],
|
||||
{ revalidate: 3600 }
|
||||
);
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("roles_and_permissions"),
|
||||
@@ -69,9 +81,10 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
|
||||
|
||||
roleSearchParamsCache.parse(searchParams);
|
||||
|
||||
const [roles, rolePermissions] = await Promise.all([
|
||||
const [roles, rolePermissions, isPrivate] = await Promise.all([
|
||||
getCachedTeamRoles(session.user.org.id),
|
||||
getCachedResourcePermissions(session.user.id, session.user.org.id, Resource.Role),
|
||||
getCachedTeamPrivacy(session.user.org.id),
|
||||
]);
|
||||
|
||||
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
|
||||
@@ -108,6 +121,7 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
|
||||
}}
|
||||
initialSelectedRole={selectedRole}
|
||||
initialSheetOpen={isSheetOpen}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
+7
-1
@@ -102,6 +102,7 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
CustomAction.ChangeMemberRole,
|
||||
CustomAction.Remove,
|
||||
CustomAction.ListMembers,
|
||||
CustomAction.ListMembersPrivate,
|
||||
CustomAction.Impersonate,
|
||||
],
|
||||
fallbackRoles: {
|
||||
@@ -120,12 +121,17 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
[CustomAction.Impersonate]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
[CustomAction.ListMembersPrivate]: {
|
||||
roles: fallbackRolesCanListMembers,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Map specific permissions to member actions
|
||||
const memberPermissions = {
|
||||
canListMembers: permissions[CustomAction.ListMembers],
|
||||
canListMembers: team.isPrivate
|
||||
? permissions[CustomAction.ListMembersPrivate]
|
||||
: permissions[CustomAction.ListMembers],
|
||||
canInvite: permissions[CustomAction.Invite],
|
||||
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
|
||||
canRemove: permissions[CustomAction.Remove],
|
||||
|
||||
+17
-2
@@ -4,6 +4,7 @@ import { cookies, headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||
@@ -11,7 +12,6 @@ import { Resource, CrudAction, Scope } from "@calcom/features/pbac/domain/types/
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { RoleService } from "@calcom/features/pbac/services/role.service";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -51,6 +51,19 @@ const getCachedResourcePermissions = (userId: number, teamId: number, resource:
|
||||
{ revalidate: 3600, tags: [`resource-permissions-${teamId}`] }
|
||||
);
|
||||
|
||||
const getCachedTeamPrivacy = (teamId: number) =>
|
||||
unstable_cache(
|
||||
async () => {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { isPrivate: true },
|
||||
});
|
||||
return team?.isPrivate ?? false;
|
||||
},
|
||||
[`team-privacy-${teamId}`],
|
||||
{ revalidate: 3600, tags: [`team-privacy-${teamId}`] }
|
||||
);
|
||||
|
||||
const getCachedTeam = (teamId: string, userId: number) =>
|
||||
unstable_cache(
|
||||
async () => {
|
||||
@@ -111,9 +124,10 @@ const Page = async ({
|
||||
|
||||
roleSearchParamsCache.parse(searchParams);
|
||||
|
||||
const [roles, rolePermissions] = await Promise.all([
|
||||
const [roles, rolePermissions, isPrivate] = await Promise.all([
|
||||
getCachedTeamRoles(team.id)(),
|
||||
getCachedResourcePermissions(session.user.id, team.id, Resource.Role)(),
|
||||
getCachedTeamPrivacy(team.id)(),
|
||||
]);
|
||||
|
||||
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
|
||||
@@ -153,6 +167,7 @@ const Page = async ({
|
||||
initialSelectedRole={selectedRole}
|
||||
initialSheetOpen={isSheetOpen}
|
||||
scope={Scope.Team}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
+7
-1
@@ -70,6 +70,7 @@ const Page = async () => {
|
||||
userRole: session.user.org.role,
|
||||
actions: [
|
||||
CustomAction.ListMembers,
|
||||
CustomAction.ListMembersPrivate,
|
||||
CustomAction.Invite,
|
||||
CustomAction.ChangeMemberRole,
|
||||
CustomAction.Remove,
|
||||
@@ -79,6 +80,9 @@ const Page = async () => {
|
||||
[CustomAction.ListMembers]: {
|
||||
roles: fallbackRolesThatCanSeeMembers,
|
||||
},
|
||||
[CustomAction.ListMembersPrivate]: {
|
||||
roles: fallbackRolesThatCanSeeMembers,
|
||||
},
|
||||
[CustomAction.Invite]: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
@@ -96,7 +100,9 @@ const Page = async () => {
|
||||
|
||||
// Map specific permissions to member actions
|
||||
const memberPermissions = {
|
||||
canListMembers: permissions[CustomAction.ListMembers],
|
||||
canListMembers: org.isPrivate
|
||||
? permissions[CustomAction.ListMembersPrivate]
|
||||
: permissions[CustomAction.ListMembers],
|
||||
canInvite: permissions[CustomAction.Invite],
|
||||
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
|
||||
canRemove: permissions[CustomAction.Remove],
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import CreateTeamEventType, { LayoutWrapper } from "~/settings/teams/[id]/event-types-view";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { CreateTeamEventType, LayoutWrapper } from "~/settings/teams/[id]/event-types-view";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
await _generateMetadata(
|
||||
@@ -11,10 +21,34 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
`/settings/teams/${(await params).id}/event-type`
|
||||
);
|
||||
|
||||
const ServerPage = async () => {
|
||||
const querySchema = z.object({
|
||||
id: z.coerce.number().refine((val) => !Number.isNaN(val), {
|
||||
message: "id must be a string that can be cast to a number",
|
||||
}),
|
||||
});
|
||||
|
||||
const ServerPage = async ({ params }: ServerPageProps) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const parsed = querySchema.safeParse(await params);
|
||||
if (!parsed.success) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const permissionService = new PermissionCheckService();
|
||||
const canCreateEventType = await permissionService.checkPermission({
|
||||
userId: session.user.id,
|
||||
teamId: parsed.data.id,
|
||||
permission: "eventType.create",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<CreateTeamEventType />
|
||||
<CreateTeamEventType permissions={{ canCreateEventType }} />
|
||||
</LayoutWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextRequest: class MockNextRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
nextUrl: { searchParams: URLSearchParams };
|
||||
private _headers: Map<string, string>;
|
||||
|
||||
constructor(url: string, options: { method?: string } = {}) {
|
||||
this.url = url;
|
||||
this.method = options.method || "GET";
|
||||
this._headers = new Map();
|
||||
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
|
||||
}
|
||||
|
||||
headers = {
|
||||
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
|
||||
set: (key: string, value: string): void => {
|
||||
this._headers.set(key.toLowerCase(), value);
|
||||
},
|
||||
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
|
||||
};
|
||||
},
|
||||
NextResponse: {
|
||||
json: vi.fn((body, init) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
status: init?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository");
|
||||
vi.mock("@calcom/lib/logger", () => ({
|
||||
default: {
|
||||
getSubLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
vi.mock("@calcom/lib/server/perfObserver", () => ({
|
||||
performance: {
|
||||
mark: vi.fn(),
|
||||
measure: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
wrapApiHandlerWithSentry: vi.fn((handler) => handler),
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
vi.mock("@calcom/lib/server/getServerErrorFromUnknown", () => ({
|
||||
getServerErrorFromUnknown: vi.fn((error) => ({
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
statusCode: 500,
|
||||
url: "test-url",
|
||||
method: "GET",
|
||||
})),
|
||||
}));
|
||||
vi.mock("../../defaultResponderForAppDir", () => ({
|
||||
defaultResponderForAppDir: vi.fn((handler) => handler),
|
||||
}));
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
const mockCalendarCacheEventService = vi.mocked(CalendarCacheEventService);
|
||||
|
||||
describe("/api/cron/calendar-subscriptions-cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv("CRON_API_KEY", "test-cron-key");
|
||||
vi.stubEnv("CRON_SECRET", "test-cron-secret");
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
test("should return 403 when no API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test("should return 403 when invalid API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "invalid-key");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test("should accept CRON_API_KEY in authorization header", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should accept CRON_SECRET as Bearer token", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "Bearer test-cron-secret");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should accept API key as query parameter", async () => {
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/cron/calendar-subscriptions-cleanup?apiKey=test-cron-key"
|
||||
);
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cleanup functionality", () => {
|
||||
test("should successfully cleanup stale cache", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCleanupStaleCache).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("should handle cleanup errors gracefully", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockError = new Error("Database connection failed");
|
||||
const mockCleanupStaleCache = vi.fn().mockRejectedValue(mockError);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Database connection failed");
|
||||
});
|
||||
|
||||
test("should handle non-Error exceptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockRejectedValue("String error");
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Service instantiation", () => {
|
||||
test("should instantiate CalendarCacheEventService with correct dependencies", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions-cleanup");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockCleanupStaleCache = vi.fn().mockResolvedValue(undefined);
|
||||
mockCalendarCacheEventService.prototype.cleanupStaleCache = mockCleanupStaleCache;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(mockCalendarCacheEventService).toHaveBeenCalledWith({
|
||||
calendarCacheEventRepository: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
/**
|
||||
* Cron webhook
|
||||
* Cleanup stale calendar cache
|
||||
*
|
||||
* @param request
|
||||
* @returns
|
||||
*/
|
||||
async function getHandler(request: NextRequest) {
|
||||
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
|
||||
|
||||
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
|
||||
return NextResponse.json({ message: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// instantiate dependencies
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
const calendarCacheEventService = new CalendarCacheEventService({
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
|
||||
try {
|
||||
await calendarCacheEventService.cleanupStaleCache();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : "Unknown error";
|
||||
console.error(`[calendar-subscriptions-cleanup] ${message}:`, e);
|
||||
return NextResponse.json({ message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
@@ -0,0 +1,229 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextRequest: class MockNextRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
nextUrl: { searchParams: URLSearchParams };
|
||||
private _headers: Map<string, string>;
|
||||
|
||||
constructor(url: string, options: { method?: string } = {}) {
|
||||
this.url = url;
|
||||
this.method = options.method || "GET";
|
||||
this._headers = new Map();
|
||||
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
|
||||
}
|
||||
|
||||
headers = {
|
||||
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
|
||||
set: (key: string, value: string): void => {
|
||||
this._headers.set(key.toLowerCase(), value);
|
||||
},
|
||||
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
|
||||
};
|
||||
},
|
||||
NextResponse: {
|
||||
json: vi.fn((body, init) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
status: init?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/CalendarSubscriptionService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/sync/CalendarSyncService");
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
const mockCalendarSubscriptionService = vi.mocked(CalendarSubscriptionService);
|
||||
|
||||
describe("/api/cron/calendar-subscriptions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.stubEnv("CRON_API_KEY", "test-cron-key");
|
||||
vi.stubEnv("CRON_SECRET", "test-cron-secret");
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
test("should return 403 when no API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbiden");
|
||||
}, 10000);
|
||||
|
||||
test("should return 403 when invalid API key is provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "invalid-key");
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Forbiden");
|
||||
});
|
||||
|
||||
test("should accept valid API key", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feature flag checks", () => {
|
||||
test("should return early when cache AND sync are disabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockCheckForNewSubscriptions = vi.fn();
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCheckForNewSubscriptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should proceed when both cache and sync are enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCheckForNewSubscriptions).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Subscription checking functionality", () => {
|
||||
test("should successfully check for new subscriptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(mockCheckForNewSubscriptions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("should handle subscription checking errors gracefully", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockError = new Error("Subscription service unavailable");
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockRejectedValue(mockError);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Subscription service unavailable");
|
||||
});
|
||||
|
||||
test("should handle non-Error exceptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockRejectedValue("String error");
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
const response = await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Service instantiation", () => {
|
||||
test("should instantiate all services with correct dependencies", async () => {
|
||||
const request = new NextRequest("http://localhost/api/cron/calendar-subscriptions");
|
||||
request.headers.set("authorization", "test-cron-key");
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockCheckForNewSubscriptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.checkForNewSubscriptions = mockCheckForNewSubscriptions;
|
||||
|
||||
const { GET } = await import("../route");
|
||||
await GET(request, { params: Promise.resolve({}) });
|
||||
|
||||
expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({
|
||||
adapterFactory: expect.any(Object),
|
||||
selectedCalendarRepository: expect.any(Object),
|
||||
featuresRepository: expect.any(Object),
|
||||
calendarSyncService: expect.any(Object),
|
||||
calendarCacheEventService: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
/**
|
||||
* Cron webhook
|
||||
* Checks for new calendar subscriptions (rollouts)
|
||||
*
|
||||
* @param request
|
||||
* @returns
|
||||
*/
|
||||
async function getHandler(request: NextRequest) {
|
||||
const apiKey = request.headers.get("authorization") || request.nextUrl.searchParams.get("apiKey");
|
||||
|
||||
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
|
||||
return NextResponse.json({ message: "Forbiden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// instantiate dependencies
|
||||
const bookingRepository = new BookingRepository(prisma);
|
||||
const calendarSyncService = new CalendarSyncService({
|
||||
bookingRepository,
|
||||
});
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
const calendarCacheEventService = new CalendarCacheEventService({
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
|
||||
const calendarSubscriptionService = new CalendarSubscriptionService({
|
||||
adapterFactory: new DefaultAdapterFactory(),
|
||||
selectedCalendarRepository: new SelectedCalendarRepository(prisma),
|
||||
featuresRepository: new FeaturesRepository(prisma),
|
||||
calendarSyncService,
|
||||
calendarCacheEventService,
|
||||
});
|
||||
|
||||
// are features globally enabled
|
||||
const [isCacheEnabled, isSyncEnabled] = await Promise.all([
|
||||
calendarSubscriptionService.isCacheEnabled(),
|
||||
calendarSubscriptionService.isSyncEnabled(),
|
||||
]);
|
||||
|
||||
if (!isCacheEnabled && !isSyncEnabled) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await calendarSubscriptionService.checkForNewSubscriptions();
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Unknown error";
|
||||
return NextResponse.json({ message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = defaultResponderForAppDir(getHandler);
|
||||
@@ -1,5 +1,3 @@
|
||||
import { wrapApiHandlerWithSentry } from "@sentry/nextjs";
|
||||
import { captureException } from "@sentry/nextjs";
|
||||
import type { Params } from "app/_types";
|
||||
import { ApiError } from "next/dist/server/api-utils";
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -21,10 +19,13 @@ export const defaultResponderForAppDir = <T extends NextResponse | Response = Ne
|
||||
let ok = false;
|
||||
try {
|
||||
performance.mark("Start");
|
||||
|
||||
const result = endpointRoute
|
||||
? await wrapApiHandlerWithSentry(async () => await handler(req, { params }), endpointRoute)()
|
||||
: await handler(req, { params });
|
||||
let result: T | undefined;
|
||||
if (process.env.NODE_ENV === "development" || !endpointRoute) {
|
||||
result = await handler(req, { params });
|
||||
} else {
|
||||
const { wrapApiHandlerWithSentry } = await import("@sentry/nextjs");
|
||||
result = await wrapApiHandlerWithSentry(async () => await handler(req, { params }), endpointRoute)();
|
||||
}
|
||||
|
||||
ok = true;
|
||||
if (result) {
|
||||
@@ -49,6 +50,7 @@ export const defaultResponderForAppDir = <T extends NextResponse | Response = Ne
|
||||
// Don't report 400-499 errors to Sentry/console
|
||||
if (!(serverError.statusCode >= 400 && serverError.statusCode < 500)) {
|
||||
console.error(serverError);
|
||||
const { captureException } = await import("@sentry/nextjs");
|
||||
captureException(error);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans";
|
||||
import type { Contact } from "@calcom/features/ee/support/lib/intercom/intercom";
|
||||
import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -43,6 +45,11 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { user } = session;
|
||||
|
||||
const membershipRepository = new MembershipRepository(prisma);
|
||||
const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId: user.id });
|
||||
const billingPlanService = new BillingPlanService();
|
||||
const plan = await billingPlanService.getUserPlanByMemberships(memberships);
|
||||
|
||||
if (!existingContact.data) {
|
||||
const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id });
|
||||
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
|
||||
@@ -72,6 +79,7 @@ export async function POST(req: NextRequest) {
|
||||
sum_of_teams: additionalUserInfo?._count?.teams,
|
||||
sum_of_event_types: additionalUserInfo?._count?.eventTypes,
|
||||
sum_of_team_event_types: sumOfTeamEventTypes,
|
||||
Plan: plan,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextRequest: class MockNextRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
nextUrl: { searchParams: URLSearchParams };
|
||||
private _headers: Map<string, string>;
|
||||
|
||||
constructor(url: string, options: { method?: string } = {}) {
|
||||
this.url = url;
|
||||
this.method = options.method || "POST";
|
||||
this._headers = new Map();
|
||||
this.nextUrl = { searchParams: new URLSearchParams(url.split("?")[1] || "") };
|
||||
}
|
||||
|
||||
headers = {
|
||||
get: (key: string): string | null => this._headers.get(key.toLowerCase()) || null,
|
||||
set: (key: string, value: string): void => {
|
||||
this._headers.set(key.toLowerCase(), value);
|
||||
},
|
||||
has: (key: string): boolean => this._headers.has(key.toLowerCase()),
|
||||
};
|
||||
},
|
||||
NextResponse: {
|
||||
json: vi.fn((body, init) => ({
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
status: init?.status || 200,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/CalendarSubscriptionService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService");
|
||||
vi.mock("@calcom/features/calendar-subscription/lib/sync/CalendarSyncService");
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma: {},
|
||||
}));
|
||||
|
||||
const mockCalendarSubscriptionService = vi.mocked(CalendarSubscriptionService);
|
||||
|
||||
describe("/api/webhooks/calendar-subscription/[provider]", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Provider validation", () => {
|
||||
test("should accept google_calendar provider", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
}, 10000);
|
||||
|
||||
test("should accept office365_calendar provider", async () => {
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/webhooks/calendar-subscription/office365_calendar",
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "office365_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("office365_calendar", request);
|
||||
});
|
||||
|
||||
test("should reject unsupported provider", async () => {
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/webhooks/calendar-subscription/unsupported_calendar",
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "unsupported_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unsupported provider");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feature flag handling", () => {
|
||||
test("should return 200 when neither cache nor sync is enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn();
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("No cache or sync enabled");
|
||||
expect(mockProcessWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should process webhook when cache is enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook processed");
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
});
|
||||
|
||||
test("should process webhook when sync is enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook processed");
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
});
|
||||
|
||||
test("should process webhook when both cache and sync are enabled", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook processed");
|
||||
expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
test("should handle webhook processing errors gracefully", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockError = new Error("Webhook validation failed");
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockRejectedValue(mockError);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Webhook validation failed");
|
||||
});
|
||||
|
||||
test("should handle non-Error exceptions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockRejectedValue("String error");
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Unknown error");
|
||||
});
|
||||
|
||||
test("should handle feature flag check errors", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockError = new Error("Feature flag service unavailable");
|
||||
const mockIsCacheEnabled = vi.fn().mockRejectedValue(mockError);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Feature flag service unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Service instantiation", () => {
|
||||
test("should instantiate all services with correct dependencies", async () => {
|
||||
const request = new NextRequest("http://localhost/api/webhooks/calendar-subscription/google_calendar", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const mockIsCacheEnabled = vi.fn().mockResolvedValue(true);
|
||||
const mockIsSyncEnabled = vi.fn().mockResolvedValue(false);
|
||||
const mockProcessWebhook = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockCalendarSubscriptionService.prototype.isCacheEnabled = mockIsCacheEnabled;
|
||||
mockCalendarSubscriptionService.prototype.isSyncEnabled = mockIsSyncEnabled;
|
||||
mockCalendarSubscriptionService.prototype.processWebhook = mockProcessWebhook;
|
||||
|
||||
const { POST } = await import("../route");
|
||||
await POST(request, {
|
||||
params: Promise.resolve({ provider: "google_calendar" }),
|
||||
});
|
||||
|
||||
expect(mockCalendarSubscriptionService).toHaveBeenCalledWith({
|
||||
adapterFactory: expect.any(Object),
|
||||
selectedCalendarRepository: expect.any(Object),
|
||||
featuresRepository: expect.any(Object),
|
||||
calendarSyncService: expect.any(Object),
|
||||
calendarCacheEventService: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Params } from "app/_types";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import type { CalendarSubscriptionProvider } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
|
||||
import { DefaultAdapterFactory } from "@calcom/features/calendar-subscription/adapters/AdaptersFactory";
|
||||
import { CalendarSubscriptionService } from "@calcom/features/calendar-subscription/lib/CalendarSubscriptionService";
|
||||
import { CalendarCacheEventRepository } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventRepository";
|
||||
import { CalendarCacheEventService } from "@calcom/features/calendar-subscription/lib/cache/CalendarCacheEventService";
|
||||
import { CalendarSyncService } from "@calcom/features/calendar-subscription/lib/sync/CalendarSyncService";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/SelectedCalendarRepository";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { defaultResponderForAppDir } from "@calcom/web/app/api/defaultResponderForAppDir";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["calendar-webhook"] });
|
||||
|
||||
function extractAndValidateProviderFromParams(params: Params): CalendarSubscriptionProvider | null {
|
||||
if (!("provider" in params)) {
|
||||
return null;
|
||||
}
|
||||
const { provider } = params;
|
||||
if (provider === "google_calendar" || provider === "office365_calendar") {
|
||||
return provider;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming POST requests for calendar webhooks.
|
||||
* It processes the webhook based on the calendar provider specified in the URL.
|
||||
* If the provider is unsupported, it returns a 400 response.
|
||||
* If the webhook is processed successfully, it returns a 200 response.
|
||||
* In case of errors during processing, it returns a 500 response with the error message.
|
||||
* @param {NextRequest} request - The incoming request object.
|
||||
* @param {Object} context - The context object containing route parameters.
|
||||
* @param {Promise<Params>} context.params - A promise that resolves to the route parameters.
|
||||
* @returns {Promise<NextResponse>} - A promise that resolves to the response object.
|
||||
*/
|
||||
async function postHandler(request: NextRequest, ctx: { params: Promise<Params> }) {
|
||||
const providerFromParams = extractAndValidateProviderFromParams(await ctx.params);
|
||||
if (!providerFromParams) {
|
||||
return NextResponse.json({ message: "Unsupported provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// instantiate dependencies
|
||||
const bookingRepository = new BookingRepository(prisma);
|
||||
const calendarSyncService = new CalendarSyncService({
|
||||
bookingRepository,
|
||||
});
|
||||
const calendarCacheEventRepository = new CalendarCacheEventRepository(prisma);
|
||||
const calendarCacheEventService = new CalendarCacheEventService({
|
||||
calendarCacheEventRepository,
|
||||
});
|
||||
|
||||
const calendarSubscriptionService = new CalendarSubscriptionService({
|
||||
adapterFactory: new DefaultAdapterFactory(),
|
||||
selectedCalendarRepository: new SelectedCalendarRepository(prisma),
|
||||
featuresRepository: new FeaturesRepository(prisma),
|
||||
calendarSyncService,
|
||||
calendarCacheEventService,
|
||||
});
|
||||
|
||||
// are features globally enabled
|
||||
const [isCacheEnabled, isSyncEnabled] = await Promise.all([
|
||||
calendarSubscriptionService.isCacheEnabled(),
|
||||
calendarSubscriptionService.isSyncEnabled(),
|
||||
]);
|
||||
|
||||
if (!isCacheEnabled && !isSyncEnabled) {
|
||||
log.debug("No cache or sync enabled");
|
||||
return NextResponse.json({ message: "No cache or sync enabled" }, { status: 200 });
|
||||
}
|
||||
|
||||
await calendarSubscriptionService.processWebhook(providerFromParams, request);
|
||||
return NextResponse.json({ message: "Webhook processed" }, { status: 200 });
|
||||
} catch (error) {
|
||||
log.error("Error processing webhook", { error });
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = defaultResponderForAppDir(postHandler);
|
||||
@@ -213,7 +213,6 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
|
||||
);
|
||||
|
||||
const [submit, setSubmit] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -223,11 +222,7 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
|
||||
}
|
||||
}, [submit, allUpdated, mainForSubmitRef]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!formPortalRef?.current || !mounted) {
|
||||
if (!formPortalRef?.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
|
||||
|
||||
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
|
||||
|
||||
const isAttendee = !!userSeat;
|
||||
|
||||
const paymentAppData = getPaymentAppData(booking.eventType);
|
||||
|
||||
const location = booking.location as ReturnType<typeof getEventLocationValue>;
|
||||
@@ -223,10 +227,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
|
||||
const getSeatReferenceUid = () => {
|
||||
if (!booking.seatsReferences[0]) {
|
||||
return undefined;
|
||||
}
|
||||
return booking.seatsReferences[0].referenceUid;
|
||||
return userSeat?.referenceUid;
|
||||
};
|
||||
|
||||
const actionContext: BookingActionContext = {
|
||||
@@ -250,6 +251,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
booking.location === "integrations:daily" ||
|
||||
(typeof booking.location === "string" && booking.location.trim() === ""),
|
||||
showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid,
|
||||
isAttendee,
|
||||
cardCharged,
|
||||
attendeeList,
|
||||
getSeatReferenceUid,
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface BookingActionContext {
|
||||
isDisabledRescheduling: boolean;
|
||||
isCalVideoLocation: boolean;
|
||||
showPendingPayment: boolean;
|
||||
isAttendee: boolean;
|
||||
cardCharged: boolean;
|
||||
attendeeList: Array<{
|
||||
name: string;
|
||||
@@ -102,6 +103,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
isDisabledRescheduling,
|
||||
isBookingFromRoutingForm,
|
||||
getSeatReferenceUid,
|
||||
isAttendee,
|
||||
t,
|
||||
} = context;
|
||||
|
||||
@@ -111,7 +113,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
icon: "clock",
|
||||
label: t("reschedule_booking"),
|
||||
href: `/reschedule/${booking.uid}${
|
||||
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||
booking.seatsReferences.length && isAttendee ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||
}`,
|
||||
disabled:
|
||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("EditLocationDialog", () => {
|
||||
},
|
||||
{
|
||||
value: "integrations:daily",
|
||||
label: "Cal Video (Global)",
|
||||
label: "Cal Video (Default)",
|
||||
disabled: false,
|
||||
icon: "/app-store/dailyvideo/icon.svg",
|
||||
slug: "daily-video",
|
||||
|
||||
Regular → Executable
+2
-2
@@ -23,9 +23,9 @@ try {
|
||||
"*/5 * * * * *",
|
||||
async function () {
|
||||
await Promise.allSettled([
|
||||
fetchCron("/calendar-cache/cron"),
|
||||
fetchCron("/cron/calendar-subscriptions"),
|
||||
// fetchCron("/calendar-cache/cron"),
|
||||
// fetchCron("/cron/calVideoNoShowWebhookTriggers"),
|
||||
//
|
||||
fetchCron("/tasks/cron"),
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { nextJsConfig } from "@calcom/eslint-config/next-js";
|
||||
|
||||
export default [...nextJsConfig];
|
||||
@@ -5,8 +5,8 @@ import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
|
||||
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
||||
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
@@ -39,7 +39,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
uid,
|
||||
seatReferenceUid: maybeSeatReferenceUid,
|
||||
bookingSeat,
|
||||
} = await maybeGetBookingUidFromSeat(prisma, bookingUid);
|
||||
} = await maybeGetBookingUidFromSeat(prisma, seatReferenceUid ? seatReferenceUid : bookingUid);
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
getOrganizationSettings,
|
||||
getVerifiedDomain,
|
||||
} from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { IS_CALCOM } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -310,7 +310,6 @@ const OnboardingPage = ({
|
||||
/>
|
||||
)}
|
||||
{currentStep === AppOnboardingSteps.CONFIGURE_STEP &&
|
||||
formPortalRef.current &&
|
||||
eventTypeGroups && (
|
||||
<ConfigureStepCard
|
||||
slug={appMetadata.slug}
|
||||
|
||||
@@ -57,6 +57,9 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
type BookingsProps = {
|
||||
status: (typeof validStatuses)[number];
|
||||
userId?: number;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function useSystemSegments(userId?: number) {
|
||||
@@ -111,7 +114,7 @@ type RowData =
|
||||
type: "today" | "next";
|
||||
};
|
||||
|
||||
function BookingsContent({ status }: BookingsProps) {
|
||||
function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -154,7 +157,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
|
||||
"data-testid": tabConfig["data-testid"],
|
||||
}));
|
||||
}, [searchParams?.toString()]);
|
||||
}, [searchParams]);
|
||||
|
||||
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
@@ -215,7 +218,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: user?.isTeamAdminOrOwner ?? false,
|
||||
enableColumnFilter: permissions.canReadOthersBookings,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
@@ -314,7 +317,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [user, status, t]);
|
||||
}, [user, status, t, permissions.canReadOthersBookings]);
|
||||
|
||||
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
|
||||
|
||||
@@ -352,7 +355,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
isToday: false,
|
||||
})) || []
|
||||
);
|
||||
}, [query.data]);
|
||||
}, [query.data, status, user?.timeZone]);
|
||||
|
||||
const bookingsToday = useMemo<RowData[]>(() => {
|
||||
return (
|
||||
@@ -371,7 +374,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
isToday: true,
|
||||
})) ?? []
|
||||
);
|
||||
}, [query.data]);
|
||||
}, [query.data, user?.timeZone]);
|
||||
|
||||
const finalData = useMemo<RowData[]>(() => {
|
||||
if (status !== "upcoming") {
|
||||
|
||||
@@ -556,7 +556,9 @@ export default function Success(props: PageProps) {
|
||||
<div className="font-medium">
|
||||
{isCancelled ? t("reason") : t("reschedule_reason")}
|
||||
</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
<p className="break-words">{cancellationReason}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isCancelled && bookingInfo?.cancelledBy && (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user