Compare commits
75
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
55df0e3c9d | ||
|
|
730aab3e3d | ||
|
|
63740c02c7 | ||
|
|
c5942bcfcd | ||
|
|
c4547c51a2 | ||
|
|
cf89046ede | ||
|
|
d5d44fb64b | ||
|
|
4bb092c345 | ||
|
|
e1f7e94e36 | ||
|
|
2e9d5f7df1 | ||
|
|
8c943e8817 | ||
|
|
56821553fe | ||
|
|
b512ceb217 | ||
|
|
cfeb18de3f | ||
|
|
b407be933d | ||
|
|
4e1920471d | ||
|
|
6ee752aed8 | ||
|
|
a1922efab5 | ||
|
|
76332a759b |
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"@calcom/atoms": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix: EventTypeSettings Checkbox booking field label
|
|
||||||
@@ -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
|
/**/package.json @calcom/Foundation
|
||||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
/apps/api/**/* @calcom/API
|
||||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/lib/csp.ts @calcom/Foundation
|
/apps/web/lib/csp.ts @calcom/Foundation
|
||||||
|
|||||||
@@ -74,14 +74,8 @@ jobs:
|
|||||||
uses: ./.github/workflows/e2e-embed-react.yml
|
uses: ./.github/workflows/e2e-embed-react.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
e2e-atoms:
|
|
||||||
name: Tests
|
|
||||||
needs: [lint, build, build-atoms, build-api-v2]
|
|
||||||
uses: ./.github/workflows/e2e-atoms.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
required:
|
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()
|
if: always()
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: E2E
|
name: Check breaking changes and run E2E
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
env:
|
env:
|
||||||
@@ -23,6 +23,10 @@ env:
|
|||||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||||
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
|
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:
|
jobs:
|
||||||
e2e:
|
e2e:
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
@@ -68,6 +72,18 @@ jobs:
|
|||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
- uses: ./.github/actions/yarn-install
|
- uses: ./.github/actions/yarn-install
|
||||||
- uses: ./.github/actions/cache-db
|
- 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
|
- name: Run Tests
|
||||||
working-directory: apps/api/v2
|
working-directory: apps/api/v2
|
||||||
run: |
|
run: |
|
||||||
@@ -75,6 +91,7 @@ jobs:
|
|||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
|
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
|
||||||
exit $EXIT_CODE
|
exit $EXIT_CODE
|
||||||
|
|
||||||
- name: Upload Test Results
|
- name: Upload Test Results
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||||
organization-name: calcom
|
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:
|
apply-labels-from-issue:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
|||||||
@@ -195,13 +195,6 @@ jobs:
|
|||||||
uses: ./.github/workflows/e2e-embed-react.yml
|
uses: ./.github/workflows/e2e-embed-react.yml
|
||||||
secrets: inherit
|
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:
|
analyze:
|
||||||
name: Analyze Build
|
name: Analyze Build
|
||||||
needs: [build]
|
needs: [build]
|
||||||
@@ -211,7 +204,7 @@ jobs:
|
|||||||
merge-reports:
|
merge-reports:
|
||||||
name: Merge reports
|
name: Merge reports
|
||||||
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
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
|
uses: ./.github/workflows/merge-reports.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
@@ -255,8 +248,7 @@ jobs:
|
|||||||
e2e-api-v2,
|
e2e-api-v2,
|
||||||
e2e-embed,
|
e2e-embed,
|
||||||
e2e-embed-react,
|
e2e-embed-react,
|
||||||
e2e-app-store,
|
e2e-app-store
|
||||||
e2e-atoms,
|
|
||||||
]
|
]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||||
|
|||||||
@@ -16,5 +16,4 @@ public
|
|||||||
packages/prisma/zod
|
packages/prisma/zod
|
||||||
packages/prisma/enums
|
packages/prisma/enums
|
||||||
apps/web/public/embed
|
apps/web/public/embed
|
||||||
apps/api/v2/swagger/documentation.json
|
|
||||||
packages/ui/components/icon/dynamicIconImports.tsx
|
packages/ui/components/icon/dynamicIconImports.tsx
|
||||||
|
|||||||
Vendored
Vendored
Vendored
@@ -13,5 +13,8 @@
|
|||||||
"connect": "^3.7.0",
|
"connect": "^3.7.0",
|
||||||
"http": "^0.0.1-security",
|
"http": "^0.0.1-security",
|
||||||
"http-proxy-middleware": "^2.0.6"
|
"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",
|
"build": "next build",
|
||||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||||
"dev": "PORT=3003 next dev",
|
"dev": "PORT=3003 next dev",
|
||||||
"lint": "eslint . --ignore-path .gitignore",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||||
"start": "PORT=3003 next start",
|
"start": "PORT=3003 next start",
|
||||||
"docker-start-api": "PORT=80 next start",
|
"docker-start-api": "PORT=80 next start",
|
||||||
@@ -18,18 +18,19 @@
|
|||||||
"type-check:ci": "tsc-absolute --pretty --noEmit"
|
"type-check:ci": "tsc-absolute --pretty --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@calcom/tsconfig": "*",
|
"@calcom/eslint-config": "workspace:*",
|
||||||
"@calcom/types": "*",
|
"@calcom/tsconfig": "workspace:*",
|
||||||
|
"@calcom/types": "workspace:*",
|
||||||
"node-mocks-http": "^1.11.0"
|
"node-mocks-http": "^1.11.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calcom/app-store": "*",
|
"@calcom/app-store": "workspace:*",
|
||||||
"@calcom/dayjs": "*",
|
"@calcom/dayjs": "workspace:*",
|
||||||
"@calcom/emails": "*",
|
"@calcom/emails": "workspace:*",
|
||||||
"@calcom/features": "*",
|
"@calcom/features": "workspace:*",
|
||||||
"@calcom/lib": "*",
|
"@calcom/lib": "workspace:*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "workspace:*",
|
||||||
"@calcom/trpc": "*",
|
"@calcom/trpc": "workspace:*",
|
||||||
"@prisma/nextjs-monorepo-workaround-plugin": "^6.16.1",
|
"@prisma/nextjs-monorepo-workaround-plugin": "^6.16.1",
|
||||||
"@sentry/nextjs": "^9.15.0",
|
"@sentry/nextjs": "^9.15.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
|
||||||
import {
|
import {
|
||||||
getRecordingsOfCalVideoByRoomName,
|
getRecordingsOfCalVideoByRoomName,
|
||||||
getDownloadLinkOfCalVideoByRecordingId,
|
getDownloadLinkOfCalVideoByRecordingId,
|
||||||
} from "@calcom/lib/videoClient";
|
} from "@calcom/app-store/videoClient";
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { RecordingItemSchema } from "@calcom/prisma/zod-utils";
|
import type { RecordingItemSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { PartialReference } from "@calcom/types/EventManager";
|
import type { PartialReference } from "@calcom/types/EventManager";
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
|
||||||
import {
|
import {
|
||||||
getTranscriptsAccessLinkFromRecordingId,
|
getTranscriptsAccessLinkFromRecordingId,
|
||||||
checkIfRoomNameMatchesInRecording,
|
checkIfRoomNameMatchesInRecording,
|
||||||
} from "@calcom/lib/videoClient";
|
} from "@calcom/app-store/videoClient";
|
||||||
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { PartialReference } from "@calcom/types/EventManager";
|
import type { PartialReference } from "@calcom/types/EventManager";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
|
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/lib/videoClient";
|
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { PartialReference } from "@calcom/types/EventManager";
|
import type { PartialReference } from "@calcom/types/EventManager";
|
||||||
|
|
||||||
|
|||||||
@@ -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.count({ where }),
|
||||||
prisma.user.findMany({ where, take, skip }),
|
prisma.user.findMany({ where, take, skip }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { buildBooking } from "@calcom/lib/test/builder";
|
|||||||
import {
|
import {
|
||||||
getRecordingsOfCalVideoByRoomName,
|
getRecordingsOfCalVideoByRoomName,
|
||||||
getDownloadLinkOfCalVideoByRecordingId,
|
getDownloadLinkOfCalVideoByRecordingId,
|
||||||
} from "@calcom/lib/videoClient";
|
} from "@calcom/app-store/videoClient";
|
||||||
|
|
||||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ type CustomNextApiResponse = NextApiResponse & Response;
|
|||||||
const adminUserId = 1;
|
const adminUserId = 1;
|
||||||
const memberUserId = 10;
|
const memberUserId = 10;
|
||||||
|
|
||||||
vi.mock("@calcom/lib/videoClient", () => {
|
vi.mock("@calcom/app-store/videoClient", () => {
|
||||||
return {
|
return {
|
||||||
getRecordingsOfCalVideoByRoomName: vi.fn(),
|
getRecordingsOfCalVideoByRoomName: vi.fn(),
|
||||||
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { createMocks } from "node-mocks-http";
|
import { createMocks } from "node-mocks-http";
|
||||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
import { buildBooking } from "@calcom/lib/test/builder";
|
|
||||||
import {
|
import {
|
||||||
getTranscriptsAccessLinkFromRecordingId,
|
getTranscriptsAccessLinkFromRecordingId,
|
||||||
checkIfRoomNameMatchesInRecording,
|
checkIfRoomNameMatchesInRecording,
|
||||||
} from "@calcom/lib/videoClient";
|
} from "@calcom/app-store/videoClient";
|
||||||
|
import { buildBooking } from "@calcom/lib/test/builder";
|
||||||
|
|
||||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ import handler from "../../../../../../pages/api/bookings/[id]/transcripts/[reco
|
|||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
vi.mock("@calcom/lib/videoClient", () => {
|
vi.mock("@calcom/app-store/videoClient", () => {
|
||||||
return {
|
return {
|
||||||
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
|
getTranscriptsAccessLinkFromRecordingId: vi.fn(),
|
||||||
checkIfRoomNameMatchesInRecording: vi.fn(),
|
checkIfRoomNameMatchesInRecording: vi.fn(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createMocks } from "node-mocks-http";
|
|||||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
import { buildBooking } from "@calcom/lib/test/builder";
|
import { buildBooking } from "@calcom/lib/test/builder";
|
||||||
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/lib/videoClient";
|
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/app-store/videoClient";
|
||||||
|
|
||||||
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get";
|
|||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
vi.mock("@calcom/lib/videoClient", () => {
|
vi.mock("@calcom/app-store/videoClient", () => {
|
||||||
return {
|
return {
|
||||||
getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
|
getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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:enums": "yarn workspace @calcom/platform-enums build:watch",
|
||||||
"_dev:build:watch:utils": "yarn workspace @calcom/platform-utils 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: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": "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",
|
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
|
||||||
"start:debug": "nest start --debug --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",
|
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
|
||||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
"copy-swagger-module": "ts-node -r tsconfig-paths/register 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",
|
"prepare": "yarn run snyk-protect",
|
||||||
"snyk-protect": "snyk-protect"
|
"snyk-protect": "snyk-protect"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "workspace:*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "workspace:*",
|
||||||
"@calcom/platform-libraries": "*",
|
"@calcom/platform-libraries": "workspace:*",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "workspace:*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "workspace:*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "workspace:*",
|
||||||
"@golevelup/ts-jest": "^0.4.0",
|
"@golevelup/ts-jest": "^0.4.0",
|
||||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||||
@@ -88,6 +89,7 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@calcom/eslint-config": "workspace:*",
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
"@nestjs/schematics": "^10.0.0",
|
"@nestjs/schematics": "^10.0.0",
|
||||||
"@nestjs/testing": "^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 { Type } from "class-transformer";
|
||||||
import { IsEnum, ValidateNested } from "class-validator";
|
import { IsEnum, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||||
import { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types";
|
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 {
|
export class ReassignBookingOutput_2024_08_13 {
|
||||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import {
|
|||||||
BookingOutput_2024_08_13,
|
BookingOutput_2024_08_13,
|
||||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||||
CreateSeatedBookingOutput_2024_08_13,
|
CreateSeatedBookingOutput_2024_08_13,
|
||||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
|
||||||
GetSeatedBookingOutput_2024_08_13,
|
|
||||||
RecurringBookingOutput_2024_08_13,
|
RecurringBookingOutput_2024_08_13,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
|
|
||||||
|
|||||||
+12
-108
@@ -1,39 +1,29 @@
|
|||||||
import type { AppConfig } from "@/config/type";
|
import type { AppConfig } from "@/config/type";
|
||||||
import { getEnv } from "@/env";
|
|
||||||
import { Logger } from "@nestjs/common";
|
import { Logger } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
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 "dotenv/config";
|
||||||
import * as fs from "fs";
|
|
||||||
import { Server } from "http";
|
|
||||||
import { WinstonModule } from "nest-winston";
|
import { WinstonModule } from "nest-winston";
|
||||||
|
|
||||||
import { bootstrap } from "./app";
|
import { bootstrap } from "./app";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
import { loggerConfig } from "./lib/logger";
|
import { loggerConfig } from "./lib/logger";
|
||||||
|
import { generateSwaggerForApp } from "./swagger/generate-swagger";
|
||||||
|
|
||||||
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
|
run().catch((error: Error) => {
|
||||||
|
console.error("Failed to start Cal Platform API", { error: error.stack });
|
||||||
const run = async () => {
|
process.exit(1);
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
});
|
||||||
logger: WinstonModule.createLogger(loggerConfig()),
|
|
||||||
bodyParser: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const app = await createNestApp();
|
||||||
const logger = new Logger("App");
|
const logger = new Logger("App");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
bootstrap(app);
|
bootstrap(app);
|
||||||
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
|
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
|
||||||
void generateSwagger(app);
|
generateSwaggerForApp(app);
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
logger.log(`Application started on port: ${port}`);
|
logger.log(`Application started on port: ${port}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -42,97 +32,11 @@ const run = async () => {
|
|||||||
error,
|
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 {
|
export async function createNestApp() {
|
||||||
return obj && typeof obj === "object" && "tags" in obj;
|
return NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
}
|
logger: WinstonModule.createLogger(loggerConfig()),
|
||||||
|
bodyParser: false,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
import {
|
||||||
NO_AUTH_PROVIDED_MESSAGE,
|
NO_AUTH_PROVIDED_MESSAGE,
|
||||||
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
|
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
|
||||||
|
ONLY_CLIENT_SECRET_PROVIDED_MESSAGE,
|
||||||
} from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
} from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
|
||||||
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
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.
|
// 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 noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
|
||||||
const onlyClientIdProvided = error && error.message.includes(ONLY_CLIENT_ID_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) {
|
if (onlyClientIdProvided) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onlyClientSecretProvided) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (user || noAuthProvided || !error) {
|
if (user || noAuthProvided || !error) {
|
||||||
return user || null;
|
return user || null;
|
||||||
} else {
|
} 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 { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||||
import type { PlatformOAuthClient, Team, User } from "@calcom/prisma/client";
|
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";
|
import { ApiAuthStrategy } from "./api-auth.strategy";
|
||||||
|
|
||||||
describe("ApiAuthStrategy", () => {
|
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 () => {
|
it("should throw 401 if OAuth ID is invalid", async () => {
|
||||||
const context: ExecutionContext = {
|
const context: ExecutionContext = {
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ export type ApiAuthGuardRequest = Request & {
|
|||||||
user: ApiAuthGuardUser;
|
user: ApiAuthGuardUser;
|
||||||
allowedAuthMethods?: AllowedAuthMethod[];
|
allowedAuthMethods?: AllowedAuthMethod[];
|
||||||
};
|
};
|
||||||
export const NO_AUTH_PROVIDED_MESSAGE =
|
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`;
|
||||||
"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 =
|
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`;
|
||||||
"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()
|
@Injectable()
|
||||||
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
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 noAuthProvided = !oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||||
const onlyClientIdProvided = !!oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
const onlyClientIdProvided = !!oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||||
|
const onlyClientSecretProvided =
|
||||||
|
!oAuthClientId && !!oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||||
|
|
||||||
if (noAuthProvided) {
|
if (noAuthProvided) {
|
||||||
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
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}`);
|
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_ID_PROVIDED_MESSAGE}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onlyClientSecretProvided) {
|
||||||
|
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_SECRET_PROVIDED_MESSAGE}`);
|
||||||
|
}
|
||||||
|
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${
|
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${
|
||||||
allowedMethods && allowedMethods.length > 0 ? allowedMethods.join(", ") : "Any supported method"
|
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 { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
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 { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||||
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.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 { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { BullModule } from "@nestjs/bull";
|
import { BullModule } from "@nestjs/bull";
|
||||||
@@ -18,6 +20,7 @@ import { Module } from "@nestjs/common";
|
|||||||
imports: [
|
imports: [
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
StripeModule,
|
StripeModule,
|
||||||
|
RedisModule,
|
||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
@@ -32,6 +35,10 @@ import { Module } from "@nestjs/common";
|
|||||||
providers: [
|
providers: [
|
||||||
BillingConfigService,
|
BillingConfigService,
|
||||||
BillingService,
|
BillingService,
|
||||||
|
{
|
||||||
|
provide: "IBillingService",
|
||||||
|
useClass: BillingServiceCachingProxy,
|
||||||
|
},
|
||||||
BillingRepository,
|
BillingRepository,
|
||||||
BillingProcessor,
|
BillingProcessor,
|
||||||
ManagedOrganizationsBillingService,
|
ManagedOrganizationsBillingService,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { bootstrap } from "@/app";
|
import { bootstrap } from "@/app";
|
||||||
import { AppModule } from "@/app.module";
|
import { AppModule } from "@/app.module";
|
||||||
|
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
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 { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { randomString } from "test/utils/randomString";
|
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";
|
import type { Team, PlatformBilling } from "@calcom/prisma/client";
|
||||||
|
|
||||||
@@ -31,9 +32,8 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||||
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
||||||
let organization: Team;
|
let organization: Team;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await withNextAuth(
|
||||||
userEmail,
|
userEmail,
|
||||||
Test.createTestingModule({
|
Test.createTestingModule({
|
||||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||||
@@ -44,8 +44,10 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
|
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
organization = await organizationsRepositoryFixture.create({
|
organization = await organizationsRepositoryFixture.create({
|
||||||
name: `billing-organization-${randomString()}`,
|
name: `billing-organization-${randomString()}`,
|
||||||
|
isPlatform: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
user = await userRepositoryFixture.create({
|
user = await userRepositoryFixture.create({
|
||||||
@@ -87,6 +89,17 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
await app.close();
|
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", () => {
|
it("/billing/webhook (POST) should set billing free plan for org", () => {
|
||||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
@@ -119,6 +132,17 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
expect(billing?.plan).toEqual("FREE");
|
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", () => {
|
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
|
||||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
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 { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
|
||||||
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||||
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.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 { StripeService } from "@/modules/stripe/stripe.service";
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Headers,
|
Headers,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
Logger,
|
Logger,
|
||||||
Delete,
|
Delete,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
@@ -40,7 +41,7 @@ export class BillingController {
|
|||||||
private logger = new Logger("Billing Controller");
|
private logger = new Logger("Billing Controller");
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly billingService: BillingService,
|
@Inject("IBillingService") private readonly billingService: IBillingService,
|
||||||
public readonly stripeService: StripeService,
|
public readonly stripeService: StripeService,
|
||||||
private readonly configService: ConfigService<AppConfig>
|
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 { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
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 { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||||
import { PlatformPlan } from "@/modules/billing/types";
|
import { PlatformPlan } from "@/modules/billing/types";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
@@ -23,14 +24,14 @@ import { DateTime } from "luxon";
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService implements OnModuleDestroy {
|
export class BillingService implements IBillingService, OnModuleDestroy {
|
||||||
private logger = new Logger("BillingService");
|
private logger = new Logger("BillingService");
|
||||||
private readonly webAppUrl: string;
|
private readonly webAppUrl: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly teamsRepository: OrganizationsRepository,
|
private readonly teamsRepository: OrganizationsRepository,
|
||||||
public readonly stripeService: StripeService,
|
public readonly stripeService: StripeService,
|
||||||
private readonly billingRepository: BillingRepository,
|
public readonly billingRepository: BillingRepository,
|
||||||
private readonly configService: ConfigService<AppConfig>,
|
private readonly configService: ConfigService<AppConfig>,
|
||||||
private readonly billingConfigService: BillingConfigService,
|
private readonly billingConfigService: BillingConfigService,
|
||||||
private readonly usersRepository: UsersRepository,
|
private readonly usersRepository: UsersRepository,
|
||||||
@@ -43,18 +44,23 @@ export class BillingService implements OnModuleDestroy {
|
|||||||
|
|
||||||
async getBillingData(teamId: number) {
|
async getBillingData(teamId: number) {
|
||||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||||
|
|
||||||
if (teamWithBilling?.platformBilling) {
|
if (teamWithBilling?.platformBilling) {
|
||||||
if (!teamWithBilling?.platformBilling.subscriptionId) {
|
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 {
|
} 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);
|
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||||
let customerId = teamWithBilling?.platformBilling?.customerId;
|
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) {
|
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 { Type } from "class-transformer";
|
||||||
import { IsEnum, IsISO8601, IsOptional, IsString, ValidateNested } from "class-validator";
|
import { IsEnum, IsISO8601, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
@@ -297,6 +297,12 @@ export class DateTimeWithZone {
|
|||||||
timeZone!: string;
|
timeZone!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiExtraModels(
|
||||||
|
CalendarEventVideoLocation,
|
||||||
|
CalendarEventPhoneLocation,
|
||||||
|
CalendarEventSipLocation,
|
||||||
|
CalendarEventMoreLocation
|
||||||
|
)
|
||||||
export class UnifiedCalendarEventOutput {
|
export class UnifiedCalendarEventOutput {
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@Type(() => DateTimeWithZone)
|
@Type(() => DateTimeWithZone)
|
||||||
|
|||||||
+3
-1
@@ -4,9 +4,11 @@ import {
|
|||||||
MicrosoftServiceAccountKeyInput,
|
MicrosoftServiceAccountKeyInput,
|
||||||
} from "@/modules/organizations/delegation-credentials/inputs/service-account-key.input";
|
} from "@/modules/organizations/delegation-credentials/inputs/service-account-key.input";
|
||||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||||
|
import { ApiExtraModels } from "@nestjs/swagger";
|
||||||
import { Expose, Type } from "class-transformer";
|
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 {
|
export class CreateDelegationCredentialInput {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
+4
-3
@@ -3,10 +3,11 @@ import {
|
|||||||
MicrosoftServiceAccountKeyInput,
|
MicrosoftServiceAccountKeyInput,
|
||||||
ServiceAccountKeyValidator,
|
ServiceAccountKeyValidator,
|
||||||
} from "@/modules/organizations/delegation-credentials/inputs/service-account-key.input";
|
} from "@/modules/organizations/delegation-credentials/inputs/service-account-key.input";
|
||||||
import { ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
import { ApiExtraModels, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||||
import { Expose, plainToClass, Transform, Type } from "class-transformer";
|
import { Expose, Type } from "class-transformer";
|
||||||
import { IsBoolean, IsOptional, Validate, ValidateNested } from "class-validator";
|
import { IsBoolean, IsOptional, Validate } from "class-validator";
|
||||||
|
|
||||||
|
@ApiExtraModels(GoogleServiceAccountKeyInput, MicrosoftServiceAccountKeyInput)
|
||||||
export class UpdateDelegationCredentialInput {
|
export class UpdateDelegationCredentialInput {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
+1231
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -35,7 +35,7 @@ import type {
|
|||||||
import type { User, Team } from "@calcom/prisma/client";
|
import type { User, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
describe("Organizations Event Types Endpoints", () => {
|
describe("Organizations Event Types Endpoints", () => {
|
||||||
describe("User Authentication - User is Org Admin", () => {
|
describe("User Authentication - User is Org member and team admin", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
|
|
||||||
let userRepositoryFixture: UserRepositoryFixture;
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
@@ -172,7 +172,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await membershipsRepositoryFixture.create({
|
await membershipsRepositoryFixture.create({
|
||||||
role: "ADMIN",
|
role: "MEMBER",
|
||||||
user: { connect: { id: userAdmin.id } },
|
user: { connect: { id: userAdmin.id } },
|
||||||
team: { connect: { id: org.id } },
|
team: { connect: { id: org.id } },
|
||||||
accepted: true,
|
accepted: true,
|
||||||
@@ -247,7 +247,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types`)
|
.post(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types`)
|
||||||
.send(body)
|
.send(body)
|
||||||
.expect(404);
|
.expect(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not be able to create event-type for user outside org", async () => {
|
it("should not be able to create event-type for user outside org", async () => {
|
||||||
@@ -511,7 +511,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
it("should not get an event-type of team outside org", async () => {
|
it("should not get an event-type of team outside org", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
|
.get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
|
||||||
.expect(404);
|
.expect(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not get a non existing event-type", async () => {
|
it("should not get a non existing event-type", async () => {
|
||||||
@@ -576,7 +576,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.patch(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
|
.patch(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
|
||||||
.send(body)
|
.send(body)
|
||||||
.expect(404);
|
.expect(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not be able to update non existing event-type", async () => {
|
it("should not be able to update non existing event-type", async () => {
|
||||||
@@ -950,7 +950,7 @@ describe("Organizations Event Types Endpoints", () => {
|
|||||||
it("should not delete event-type of team outside org", async () => {
|
it("should not delete event-type of team outside org", async () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.delete(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
|
.delete(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
|
||||||
.expect(404);
|
.expect(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should delete event-type not part of the team", async () => {
|
it("should delete event-type not part of the team", async () => {
|
||||||
@@ -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 { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||||
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
|
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
|
||||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
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 { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
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";
|
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
||||||
@@ -183,6 +184,7 @@ import { Module } from "@nestjs/common";
|
|||||||
OrganizationsMembershipsController,
|
OrganizationsMembershipsController,
|
||||||
OrganizationsEventTypesController,
|
OrganizationsEventTypesController,
|
||||||
OrganizationsTeamsMembershipsController,
|
OrganizationsTeamsMembershipsController,
|
||||||
|
OrganizationsTeamsInviteController,
|
||||||
OrganizationsAttributesController,
|
OrganizationsAttributesController,
|
||||||
OrganizationsAttributesOptionsController,
|
OrganizationsAttributesOptionsController,
|
||||||
OrganizationsWebhooksController,
|
OrganizationsWebhooksController,
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@ export class ManagedOrganizationsRepository {
|
|||||||
managedOrganization: managedOrganizationFilter,
|
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.count({ where }),
|
||||||
this.dbRead.prisma.managedOrganization.findMany({
|
this.dbRead.prisma.managedOrganization.findMany({
|
||||||
where,
|
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 { Type } from "class-transformer";
|
||||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
@@ -75,6 +75,7 @@ class Routing {
|
|||||||
crmOwnerRecordType?: string;
|
crmOwnerRecordType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiExtraModels(SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04)
|
||||||
export class CreateRoutingFormResponseOutputData {
|
export class CreateRoutingFormResponseOutputData {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: Number,
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@ApiExtraModels(TeamEventTypeOutput_2024_06_14)
|
||||||
export class CreateTeamEventTypeOutput extends ApiResponseWithoutData {
|
export class CreateTeamEventTypeOutput extends ApiResponseWithoutData {
|
||||||
@IsNotEmptyObject()
|
@IsNotEmptyObject()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
@ApiExtraModels(TeamEventTypeOutput_2024_06_14)
|
||||||
export class UpdateTeamEventTypeOutput extends ApiResponseWithoutData {
|
export class UpdateTeamEventTypeOutput extends ApiResponseWithoutData {
|
||||||
@IsNotEmptyObject()
|
@IsNotEmptyObject()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||||
import { Expose } from "class-transformer";
|
import { Expose } from "class-transformer";
|
||||||
import { IsEnum, ValidateNested, IsString, IsUrl } from "class-validator";
|
import { IsEnum, ValidateNested, IsString, IsUrl } from "class-validator";
|
||||||
|
|
||||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
import { TeamOutputDto } from "@calcom/platform-types";
|
import { TeamOutputDto } from "@calcom/platform-types";
|
||||||
|
|
||||||
class Output {
|
class CreateTeamOutputData {
|
||||||
@Expose()
|
@Expose()
|
||||||
@IsString()
|
@IsString()
|
||||||
message!: string;
|
message!: string;
|
||||||
@@ -19,16 +19,17 @@ class Output {
|
|||||||
pendingTeam!: TeamOutputDto;
|
pendingTeam!: TeamOutputDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiExtraModels(TeamOutputDto, CreateTeamOutputData)
|
||||||
export class CreateTeamOutput {
|
export class CreateTeamOutput {
|
||||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
oneOf: [{ $ref: getSchemaPath(Output) }, { $ref: getSchemaPath(TeamOutputDto) }],
|
oneOf: [{ $ref: getSchemaPath(CreateTeamOutputData) }, { $ref: getSchemaPath(TeamOutputDto) }],
|
||||||
description: "Either an Output object or a TeamOutputDto.",
|
description: "Either an Output object or a TeamOutputDto.",
|
||||||
})
|
})
|
||||||
@Expose()
|
@Expose()
|
||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
data!: Output | TeamOutputDto;
|
data!: CreateTeamOutputData | TeamOutputDto;
|
||||||
}
|
}
|
||||||
|
|||||||
+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"
|
// "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.
|
// should reside in the project's node_modules already before the "nest start" command is executed.
|
||||||
async function copyNestSwagger() {
|
async function copyNestSwagger() {
|
||||||
const monorepoRoot = path.resolve(__dirname, "../../../../");
|
const monorepoRoot = path.resolve(__dirname, "../../../../../");
|
||||||
const nodeModulesNestjs = path.resolve(__dirname, "../node_modules/@nestjs");
|
const nodeModulesNestjs = path.resolve(__dirname, "../../node_modules/@nestjs");
|
||||||
const swaggerModulePath = "@nestjs/swagger";
|
const swaggerModulePath = "@nestjs/swagger";
|
||||||
|
|
||||||
const sourceDir = path.join(monorepoRoot, "node_modules", swaggerModulePath);
|
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,
|
id: orgId,
|
||||||
customerId: `cus_123_${randomString}`,
|
customerId: `cus_123_${randomString}`,
|
||||||
subscriptionId: `sub_123_${randomString}`,
|
subscriptionId: `sub_123_${randomString}`,
|
||||||
plan: plan || "STARTER",
|
plan: plan || "FREE",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"dev": "next dev --turbopack --port=1337",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@calcom/config": "*",
|
"@calcom/config": "workspace:*",
|
||||||
"@calcom/lib": "*",
|
"@calcom/lib": "workspace:*",
|
||||||
"@calcom/ui": "*",
|
"@calcom/ui": "workspace:*",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"airtable": "^0.12.2",
|
"airtable": "^0.12.2",
|
||||||
"fumadocs-core": "^14.5.4",
|
"fumadocs-core": "^14.5.4",
|
||||||
@@ -24,14 +24,12 @@
|
|||||||
"react-hot-toast": "^2.5.2"
|
"react-hot-toast": "^2.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@calcom/tsconfig": "*",
|
"@calcom/eslint-config": "workspace:*",
|
||||||
"@eslint/eslintrc": "^3",
|
"@calcom/tsconfig": "workspace:*",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^8.34.0",
|
|
||||||
"eslint-config-next": "15.1.6",
|
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5.9.0-beta"
|
"typescript": "^5.9.0-beta"
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
node_modules
|
|
||||||
public/embed
|
|
||||||
@@ -6,6 +6,8 @@ import { redirect } from "next/navigation";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
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 { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -33,9 +35,30 @@ const Page = async ({ params }: PageProps) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
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 (
|
return (
|
||||||
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
|
<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>
|
</ShellMainAppDir>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const TeamsCTA = () => {
|
|||||||
data-testid="new-team-btn"
|
data-testid="new-team-btn"
|
||||||
variant="fab"
|
variant="fab"
|
||||||
StartIcon="plus"
|
StartIcon="plus"
|
||||||
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||||
{t("new")}
|
{t("new")}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ import type {
|
|||||||
import type { zodRoutes } from "@calcom/app-store/routing-forms/zod";
|
import type { zodRoutes } from "@calcom/app-store/routing-forms/zod";
|
||||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
|
import type { EventTypesByViewer } from "@calcom/features/eventtypes/lib/getEventTypesByViewer";
|
||||||
import { areTheySiblingEntities } from "@calcom/lib/entityPermissionUtils.shared";
|
import { areTheySiblingEntities } from "@calcom/lib/entityPermissionUtils.shared";
|
||||||
import type { EventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/lib/raqb/raqbUtils";
|
import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/lib/raqb/raqbUtils";
|
||||||
import type { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
import { hasInsightsPermission } from "@calcom/features/insights/server/hasInsightsPermission";
|
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
@@ -21,14 +20,5 @@ export async function checkInsightsPagePermission() {
|
|||||||
redirect("/auth/login");
|
redirect("/auth/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasPermission = await hasInsightsPermission({
|
|
||||||
userId: session.user.id,
|
|
||||||
organizationId: session.user.org?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasPermission) {
|
|
||||||
redirect("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-18
@@ -101,10 +101,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
|||||||
name: "privacy",
|
name: "privacy",
|
||||||
href: "/settings/organizations/privacy",
|
href: "/settings/organizations/privacy",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "billing",
|
|
||||||
href: "/settings/organizations/billing",
|
|
||||||
},
|
|
||||||
{ name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" },
|
{ name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" },
|
||||||
{
|
{
|
||||||
name: "SSO",
|
name: "SSO",
|
||||||
@@ -173,17 +170,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
|||||||
// The following keys are assigned to admin only
|
// The following keys are assigned to admin only
|
||||||
const adminRequiredKeys = ["admin"];
|
const adminRequiredKeys = ["admin"];
|
||||||
const organizationRequiredKeys = ["organization"];
|
const organizationRequiredKeys = ["organization"];
|
||||||
const organizationAdminKeys = [
|
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
|
||||||
"privacy",
|
|
||||||
"billing",
|
|
||||||
"OAuth Clients",
|
|
||||||
"SSO",
|
|
||||||
"directory_sync",
|
|
||||||
"delegation_credential",
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface SettingsPermissions {
|
export interface SettingsPermissions {
|
||||||
canViewRoles?: boolean;
|
canViewRoles?: boolean;
|
||||||
|
canViewOrganizationBilling?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useTabs = ({
|
const useTabs = ({
|
||||||
@@ -232,11 +223,27 @@ const useTabs = ({
|
|||||||
|
|
||||||
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
|
// 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
|
// This prevents showing the menu item when user has no organization permissions
|
||||||
if (isPbacEnabled && permissions?.canViewRoles) {
|
if (isPbacEnabled) {
|
||||||
newArray.push({
|
if (permissions?.canViewRoles) {
|
||||||
name: "roles_and_permissions",
|
newArray.push({
|
||||||
href: "/settings/organizations/roles",
|
name: "roles_and_permissions",
|
||||||
});
|
href: "/settings/organizations/roles",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissions?.canViewOrganizationBilling) {
|
||||||
|
newArray.push({
|
||||||
|
name: "billing",
|
||||||
|
href: "/settings/organizations/billing",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isOrgAdminOrOwner) {
|
||||||
|
newArray.push({
|
||||||
|
name: "billing",
|
||||||
|
href: "/settings/organizations/billing",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -272,7 +279,7 @@ const useTabs = ({
|
|||||||
if (isAdmin) return true;
|
if (isAdmin) return true;
|
||||||
return !adminRequiredKeys.includes(tab.name);
|
return !adminRequiredKeys.includes(tab.name);
|
||||||
});
|
});
|
||||||
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled]);
|
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
|
||||||
|
|
||||||
return processTabsMemod;
|
return processTabsMemod;
|
||||||
};
|
};
|
||||||
@@ -431,6 +438,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
|||||||
name={t("profile")}
|
name={t("profile")}
|
||||||
href={`/settings/teams/${team.id}/profile`}
|
href={`/settings/teams/${team.id}/profile`}
|
||||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||||
|
className="me-5 h-7 w-auto !px-2"
|
||||||
disableChevron
|
disableChevron
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -438,6 +446,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
|||||||
name={t("members")}
|
name={t("members")}
|
||||||
href={`/settings/teams/${team.id}/members`}
|
href={`/settings/teams/${team.id}/members`}
|
||||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||||
|
className="me-5 h-7 w-auto !px-2"
|
||||||
disableChevron
|
disableChevron
|
||||||
/>
|
/>
|
||||||
{/* Show roles only for sub-teams with PBAC-enabled parent */}
|
{/* Show roles only for sub-teams with PBAC-enabled parent */}
|
||||||
@@ -458,6 +467,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
|||||||
name={t("appearance")}
|
name={t("appearance")}
|
||||||
href={`/settings/teams/${team.id}/appearance`}
|
href={`/settings/teams/${team.id}/appearance`}
|
||||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||||
|
className="me-5 h-7 w-auto !px-2"
|
||||||
disableChevron
|
disableChevron
|
||||||
/>
|
/>
|
||||||
{/* Hide if there is a parent ID */}
|
{/* Hide if there is a parent ID */}
|
||||||
@@ -467,6 +477,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
|||||||
name={t("billing")}
|
name={t("billing")}
|
||||||
href={`/settings/teams/${team.id}/billing`}
|
href={`/settings/teams/${team.id}/billing`}
|
||||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||||
|
className="me-5 h-7 w-auto !px-2"
|
||||||
disableChevron
|
disableChevron
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -475,6 +486,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
|||||||
name={t("settings")}
|
name={t("settings")}
|
||||||
href={`/settings/teams/${team.id}/settings`}
|
href={`/settings/teams/${team.id}/settings`}
|
||||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||||
|
className="me-5 h-7 w-auto !px-2"
|
||||||
disableChevron
|
disableChevron
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -636,6 +648,7 @@ const SettingsSidebarContainer = ({
|
|||||||
name={t("add_a_team")}
|
name={t("add_a_team")}
|
||||||
href={`${WEBAPP_URL}/settings/teams/new`}
|
href={`${WEBAPP_URL}/settings/teams/new`}
|
||||||
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
|
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
|
||||||
|
className="me-5 h-7 w-auto !px-2"
|
||||||
icon="plus"
|
icon="plus"
|
||||||
disableChevron
|
disableChevron
|
||||||
/>
|
/>
|
||||||
@@ -737,12 +750,14 @@ const SettingsSidebarContainer = ({
|
|||||||
name={t("profile")}
|
name={t("profile")}
|
||||||
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
|
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
|
||||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||||
|
className="me-5 h-7 w-auto !px-2"
|
||||||
disableChevron
|
disableChevron
|
||||||
/>
|
/>
|
||||||
<VerticalTabItem
|
<VerticalTabItem
|
||||||
name={t("members")}
|
name={t("members")}
|
||||||
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
|
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
|
||||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||||
|
className="me-5 h-7 w-auto !px-2"
|
||||||
disableChevron
|
disableChevron
|
||||||
/>
|
/>
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const Page = async () => {
|
|||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={t("billing")}
|
title={t("billing")}
|
||||||
description={t("manage_billing_description")}
|
description={t("manage_billing_description")}
|
||||||
borderInShellHeader={true}>
|
borderInShellHeader={false}>
|
||||||
<BillingView />
|
<BillingView />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { cookies, headers } from "next/headers";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import type { TeamFeatures } from "@calcom/features/flags/config";
|
import type { TeamFeatures } from "@calcom/features/flags/config";
|
||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||||
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
import { Resource, CrudAction, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -45,13 +46,16 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
|||||||
|
|
||||||
let teamFeatures: Record<number, TeamFeatures> | null = null;
|
let teamFeatures: Record<number, TeamFeatures> | null = null;
|
||||||
let canViewRoles = false;
|
let canViewRoles = false;
|
||||||
|
let canViewOrganizationBilling = false;
|
||||||
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
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
|
// 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) {
|
if (orgId) {
|
||||||
const [features, rolePermissions] = await Promise.all([
|
const isOrgAdminOrOwner = checkAdminOrOwner(session.user.org?.role);
|
||||||
|
const [features, rolePermissions, organizationPermissions] = await Promise.all([
|
||||||
getTeamFeatures(orgId),
|
getTeamFeatures(orgId),
|
||||||
getCachedResourcePermissions(userId, orgId, Resource.Role),
|
getCachedResourcePermissions(userId, orgId, Resource.Role),
|
||||||
|
getCachedResourcePermissions(userId, orgId, Resource.Organization),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (features) {
|
if (features) {
|
||||||
@@ -62,6 +66,8 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
|||||||
// Check if user has permission to read roles
|
// Check if user has permission to read roles
|
||||||
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
|
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
|
||||||
canViewRoles = roleActions[CrudAction.Read] ?? false;
|
canViewRoles = roleActions[CrudAction.Read] ?? false;
|
||||||
|
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
|
||||||
|
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +76,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
|||||||
<SettingsLayoutAppDirClient
|
<SettingsLayoutAppDirClient
|
||||||
{...props}
|
{...props}
|
||||||
teamFeatures={teamFeatures ?? {}}
|
teamFeatures={teamFeatures ?? {}}
|
||||||
permissions={{ canViewRoles }}
|
permissions={{ canViewRoles, canViewOrganizationBilling }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+7
-4
@@ -2,10 +2,11 @@ import { _generateMetadata } from "app/_utils";
|
|||||||
import { getTranslate } from "app/_utils";
|
import { getTranslate } from "app/_utils";
|
||||||
|
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import BillingView from "~/settings/billing/billing-view";
|
import BillingView from "~/settings/billing/billing-view";
|
||||||
|
|
||||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
import { validateUserHasOrgPerms } from "../../actions/validateUserHasOrgPerms";
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -18,15 +19,17 @@ export const generateMetadata = async () =>
|
|||||||
|
|
||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const t = await getTranslate();
|
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 (
|
return (
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={t("billing")}
|
title={t("billing")}
|
||||||
description={t("manage_billing_description")}
|
description={t("manage_billing_description")}
|
||||||
borderInShellHeader={true}>
|
borderInShellHeader={false}>
|
||||||
<BillingView />
|
<BillingView />
|
||||||
</SettingsHeader>
|
</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 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;
|
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;
|
||||||
|
};
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
|
import type { PageProps as ServerPageProps } from "app/_types";
|
||||||
import { _generateMetadata } from "app/_utils";
|
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 }> }) =>
|
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
@@ -11,10 +21,34 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
|||||||
`/settings/teams/${(await params).id}/event-type`
|
`/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 (
|
return (
|
||||||
<LayoutWrapper>
|
<LayoutWrapper>
|
||||||
<CreateTeamEventType />
|
<CreateTeamEventType permissions={{ canCreateEventType }} />
|
||||||
</LayoutWrapper>
|
</LayoutWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { safeStringify } from "@calcom/lib/safeStringify";
|
|||||||
import {
|
import {
|
||||||
getAllTranscriptsAccessLinkFromMeetingId,
|
getAllTranscriptsAccessLinkFromMeetingId,
|
||||||
submitBatchProcessorTranscriptionJob,
|
submitBatchProcessorTranscriptionJob,
|
||||||
} from "@calcom/lib/videoClient";
|
} from "@calcom/app-store/videoClient";
|
||||||
import { generateVideoToken } from "@calcom/lib/videoTokens";
|
import { generateVideoToken } from "@calcom/lib/videoTokens";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
|
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import type { NextRequest } from "next/server";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
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 type { Contact } from "@calcom/features/ee/support/lib/intercom/intercom";
|
||||||
import { intercom } 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 { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
|
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||||
import prisma from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -43,6 +45,11 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const { user } = session;
|
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) {
|
if (!existingContact.data) {
|
||||||
const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id });
|
const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id });
|
||||||
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
|
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
|
||||||
@@ -72,6 +79,7 @@ export async function POST(req: NextRequest) {
|
|||||||
sum_of_teams: additionalUserInfo?._count?.teams,
|
sum_of_teams: additionalUserInfo?._count?.teams,
|
||||||
sum_of_event_types: additionalUserInfo?._count?.eventTypes,
|
sum_of_event_types: additionalUserInfo?._count?.eventTypes,
|
||||||
sum_of_team_event_types: sumOfTeamEventTypes,
|
sum_of_team_event_types: sumOfTeamEventTypes,
|
||||||
|
Plan: plan,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
|
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/app-store/videoClient";
|
||||||
import { verifyVideoToken } from "@calcom/lib/videoTokens";
|
import { verifyVideoToken } from "@calcom/lib/videoTokens";
|
||||||
|
|
||||||
import { GET } from "../route";
|
import { GET } from "../route";
|
||||||
|
|
||||||
vi.mock("@calcom/lib/videoClient", () => ({
|
vi.mock("@calcom/app-store/videoClient", () => ({
|
||||||
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/lib/videoClient";
|
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/app-store/videoClient";
|
||||||
import { verifyVideoToken } from "@calcom/lib/videoTokens";
|
import { verifyVideoToken } from "@calcom/lib/videoTokens";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { useForm } from "react-hook-form";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { LocationObject } from "@calcom/app-store/locations";
|
import type { LocationObject } from "@calcom/app-store/locations";
|
||||||
|
import { locationsResolver } from "@calcom/app-store/locations";
|
||||||
import NoSSR from "@calcom/lib/components/NoSSR";
|
import NoSSR from "@calcom/lib/components/NoSSR";
|
||||||
import { locationsResolver } from "@calcom/lib/event-types/utils/locationsResolver";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { AppCategories } from "@calcom/prisma/enums";
|
import type { AppCategories } from "@calcom/prisma/enums";
|
||||||
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||||
@@ -213,7 +213,6 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [submit, setSubmit] = useState(false);
|
const [submit, setSubmit] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
|
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -223,11 +222,7 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}, [submit, allUpdated, mainForSubmitRef]);
|
}, [submit, allUpdated, mainForSubmitRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
if (!formPortalRef?.current) {
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!formPortalRef?.current || !mounted) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, type FC } from "react";
|
|||||||
|
|
||||||
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
|
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
|
||||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||||
import useAppsData from "@calcom/lib/hooks/useAppsData";
|
import useAppsData from "@calcom/features/apps/hooks/useAppsData";
|
||||||
|
|
||||||
import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard";
|
import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard";
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,10 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||||
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
|
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
|
||||||
|
|
||||||
|
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
|
||||||
|
|
||||||
|
const isAttendee = !!userSeat;
|
||||||
|
|
||||||
const paymentAppData = getPaymentAppData(booking.eventType);
|
const paymentAppData = getPaymentAppData(booking.eventType);
|
||||||
|
|
||||||
const location = booking.location as ReturnType<typeof getEventLocationValue>;
|
const location = booking.location as ReturnType<typeof getEventLocationValue>;
|
||||||
@@ -223,10 +227,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getSeatReferenceUid = () => {
|
const getSeatReferenceUid = () => {
|
||||||
if (!booking.seatsReferences[0]) {
|
return userSeat?.referenceUid;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return booking.seatsReferences[0].referenceUid;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionContext: BookingActionContext = {
|
const actionContext: BookingActionContext = {
|
||||||
@@ -250,6 +251,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||||||
booking.location === "integrations:daily" ||
|
booking.location === "integrations:daily" ||
|
||||||
(typeof booking.location === "string" && booking.location.trim() === ""),
|
(typeof booking.location === "string" && booking.location.trim() === ""),
|
||||||
showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid,
|
showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid,
|
||||||
|
isAttendee,
|
||||||
cardCharged,
|
cardCharged,
|
||||||
attendeeList,
|
attendeeList,
|
||||||
getSeatReferenceUid,
|
getSeatReferenceUid,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface BookingActionContext {
|
|||||||
isDisabledRescheduling: boolean;
|
isDisabledRescheduling: boolean;
|
||||||
isCalVideoLocation: boolean;
|
isCalVideoLocation: boolean;
|
||||||
showPendingPayment: boolean;
|
showPendingPayment: boolean;
|
||||||
|
isAttendee: boolean;
|
||||||
cardCharged: boolean;
|
cardCharged: boolean;
|
||||||
attendeeList: Array<{
|
attendeeList: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -102,6 +103,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
|||||||
isDisabledRescheduling,
|
isDisabledRescheduling,
|
||||||
isBookingFromRoutingForm,
|
isBookingFromRoutingForm,
|
||||||
getSeatReferenceUid,
|
getSeatReferenceUid,
|
||||||
|
isAttendee,
|
||||||
t,
|
t,
|
||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
|||||||
icon: "clock",
|
icon: "clock",
|
||||||
label: t("reschedule_booking"),
|
label: t("reschedule_booking"),
|
||||||
href: `/reschedule/${booking.uid}${
|
href: `/reschedule/${booking.uid}${
|
||||||
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
booking.seatsReferences.length && isAttendee ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||||
}`,
|
}`,
|
||||||
disabled:
|
disabled:
|
||||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
|
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ describe("EditLocationDialog", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "integrations:daily",
|
value: "integrations:daily",
|
||||||
label: "Cal Video (Global)",
|
label: "Cal Video (Default)",
|
||||||
disabled: false,
|
disabled: false,
|
||||||
icon: "/app-store/dailyvideo/icon.svg",
|
icon: "/app-store/dailyvideo/icon.svg",
|
||||||
slug: "daily-video",
|
slug: "daily-video",
|
||||||
|
|||||||
@@ -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 { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
|
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
|
||||||
|
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||||
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
||||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
|
||||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||||
@@ -39,7 +39,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
uid,
|
uid,
|
||||||
seatReferenceUid: maybeSeatReferenceUid,
|
seatReferenceUid: maybeSeatReferenceUid,
|
||||||
bookingSeat,
|
bookingSeat,
|
||||||
} = await maybeGetBookingUidFromSeat(prisma, bookingUid);
|
} = await maybeGetBookingUidFromSeat(prisma, seatReferenceUid ? seatReferenceUid : bookingUid);
|
||||||
|
|
||||||
const booking = await prisma.booking.findUnique({
|
const booking = await prisma.booking.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -310,7 +310,6 @@ const OnboardingPage = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentStep === AppOnboardingSteps.CONFIGURE_STEP &&
|
{currentStep === AppOnboardingSteps.CONFIGURE_STEP &&
|
||||||
formPortalRef.current &&
|
|
||||||
eventTypeGroups && (
|
eventTypeGroups && (
|
||||||
<ConfigureStepCard
|
<ConfigureStepCard
|
||||||
slug={appMetadata.slug}
|
slug={appMetadata.slug}
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
|
|||||||
type BookingsProps = {
|
type BookingsProps = {
|
||||||
status: (typeof validStatuses)[number];
|
status: (typeof validStatuses)[number];
|
||||||
userId?: number;
|
userId?: number;
|
||||||
|
permissions: {
|
||||||
|
canReadOthersBookings: boolean;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function useSystemSegments(userId?: number) {
|
function useSystemSegments(userId?: number) {
|
||||||
@@ -111,7 +114,7 @@ type RowData =
|
|||||||
type: "today" | "next";
|
type: "today" | "next";
|
||||||
};
|
};
|
||||||
|
|
||||||
function BookingsContent({ status }: BookingsProps) {
|
function BookingsContent({ status, permissions }: BookingsProps) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const user = useMeQuery().data;
|
const user = useMeQuery().data;
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -154,7 +157,7 @@ function BookingsContent({ status }: BookingsProps) {
|
|||||||
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
|
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
|
||||||
"data-testid": tabConfig["data-testid"],
|
"data-testid": tabConfig["data-testid"],
|
||||||
}));
|
}));
|
||||||
}, [searchParams?.toString()]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||||
const teamIds = useFilterValue("teamId", 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, {
|
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
|
||||||
id: "userId",
|
id: "userId",
|
||||||
header: t("member"),
|
header: t("member"),
|
||||||
enableColumnFilter: user?.isTeamAdminOrOwner ?? false,
|
enableColumnFilter: permissions.canReadOthersBookings,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: () => null,
|
cell: () => null,
|
||||||
meta: {
|
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]);
|
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
|
||||||
|
|
||||||
@@ -352,7 +355,7 @@ function BookingsContent({ status }: BookingsProps) {
|
|||||||
isToday: false,
|
isToday: false,
|
||||||
})) || []
|
})) || []
|
||||||
);
|
);
|
||||||
}, [query.data]);
|
}, [query.data, status, user?.timeZone]);
|
||||||
|
|
||||||
const bookingsToday = useMemo<RowData[]>(() => {
|
const bookingsToday = useMemo<RowData[]>(() => {
|
||||||
return (
|
return (
|
||||||
@@ -371,7 +374,7 @@ function BookingsContent({ status }: BookingsProps) {
|
|||||||
isToday: true,
|
isToday: true,
|
||||||
})) ?? []
|
})) ?? []
|
||||||
);
|
);
|
||||||
}, [query.data]);
|
}, [query.data, user?.timeZone]);
|
||||||
|
|
||||||
const finalData = useMemo<RowData[]>(() => {
|
const finalData = useMemo<RowData[]>(() => {
|
||||||
if (status !== "upcoming") {
|
if (status !== "upcoming") {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-util
|
|||||||
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
|
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
||||||
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
|
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||||
import { shouldHideBrandingForEvent } from "@calcom/lib/hideBranding";
|
import { shouldHideBrandingForEvent } from "@calcom/lib/hideBranding";
|
||||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||||
|
|||||||
@@ -556,7 +556,9 @@ export default function Success(props: PageProps) {
|
|||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{isCancelled ? t("reason") : t("reschedule_reason")}
|
{isCancelled ? t("reason") : t("reschedule_reason")}
|
||||||
</div>
|
</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 && (
|
{isCancelled && bookingInfo?.cancelledBy && (
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { useOrgBranding } from "@calcom/features/ee/organizations/context/provid
|
|||||||
import { CreateButton } from "@calcom/features/ee/teams/components/createButton/CreateButton";
|
import { CreateButton } from "@calcom/features/ee/teams/components/createButton/CreateButton";
|
||||||
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
||||||
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
|
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
import {
|
||||||
|
CreateEventTypeDialog,
|
||||||
|
type ProfileOption,
|
||||||
|
} from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||||
import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
|
import { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
|
||||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
@@ -846,17 +849,7 @@ const CreateFirstEventTypeView = ({ slug, searchTerm }: { slug: string; searchTe
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CTA = ({
|
const CTA = ({ profileOptions }: { profileOptions: ProfileOption[] }) => {
|
||||||
profileOptions,
|
|
||||||
}: {
|
|
||||||
profileOptions: {
|
|
||||||
teamId: number | null | undefined;
|
|
||||||
label: string | null;
|
|
||||||
image: string;
|
|
||||||
membershipRole: MembershipRole | null | undefined;
|
|
||||||
slug: string | null;
|
|
||||||
}[];
|
|
||||||
}) => {
|
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
if (!profileOptions.length) return null;
|
if (!profileOptions.length) return null;
|
||||||
@@ -951,7 +944,7 @@ type Props = {
|
|||||||
|
|
||||||
export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
|
export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
|
||||||
const profileOptions =
|
const profileOptions =
|
||||||
userEventGroupsData?.profiles
|
userEventGroupsData.profiles
|
||||||
?.filter((profile) => !profile.readOnly)
|
?.filter((profile) => !profile.readOnly)
|
||||||
?.filter((profile) => !profile.eventTypesLockedByOrg)
|
?.filter((profile) => !profile.eventTypesLockedByOrg)
|
||||||
?.filter((profile) => {
|
?.filter((profile) => {
|
||||||
@@ -973,12 +966,20 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
?.map((profile) => {
|
?.map((profile) => {
|
||||||
|
const permissions = profile.teamId
|
||||||
|
? userEventGroupsData.teamPermissions[profile.teamId]
|
||||||
|
: {
|
||||||
|
// always can create eventType on personal level
|
||||||
|
canCreateEventType: true,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
teamId: profile.teamId,
|
teamId: profile.teamId,
|
||||||
label: profile.name || profile.slug,
|
label: profile.name || profile.slug,
|
||||||
image: profile.image,
|
image: profile.image,
|
||||||
membershipRole: profile.membershipRole,
|
membershipRole: profile.membershipRole,
|
||||||
slug: profile.slug,
|
slug: profile.slug,
|
||||||
|
permissions,
|
||||||
};
|
};
|
||||||
}) ?? [];
|
}) ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -77,21 +77,26 @@ const BillingView = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-8 text-sm sm:space-y-8">
|
<div className="bg-muted border-muted mt-5 rounded-xl border p-1">
|
||||||
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
|
<div className="bg-default border-muted flex rounded-[10px] border px-5 py-4">
|
||||||
<Button color="primary" href={billingHref} target="_blank" EndIcon="external-link">
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<h3 className="text-emphasis text-sm font-semibold leading-none">{t("manage_billing")}</h3>
|
||||||
|
<p className="text-subtle text-sm font-medium leading-tight">
|
||||||
|
{t("view_and_manage_billing_details")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button color="primary" href={billingHref} target="_blank" size="sm" EndIcon="external-link">
|
||||||
{t("billing_portal")}
|
{t("billing_portal")}
|
||||||
</Button>
|
</Button>
|
||||||
</CtaRow>
|
</div>
|
||||||
</div>
|
<div className="flex items-center justify-between px-4 py-5">
|
||||||
<BillingCredits />
|
<p className="text-subtle text-sm font-medium leading-tight">{t("need_help")}</p>
|
||||||
<div className="border-subtle mt-6 space-y-6 rounded-lg border px-6 py-8 text-sm sm:space-y-8">
|
<Button color="secondary" size="sm" onClick={onContactSupportClick}>
|
||||||
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
|
|
||||||
<Button color="secondary" onClick={onContactSupportClick}>
|
|
||||||
{t("contact_support")}
|
{t("contact_support")}
|
||||||
</Button>
|
</Button>
|
||||||
</CtaRow>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<BillingCredits />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import { useForm } from "react-hook-form";
|
|||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
|
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||||
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
||||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import classNames from "@calcom/ui/classNames";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { Select } from "@calcom/ui/components/form";
|
import { Select } from "@calcom/ui/components/form";
|
||||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||||
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
import { ProgressBar } from "@calcom/ui/components/progress-bar";
|
import { ProgressBar } from "@calcom/ui/components/progress-bar";
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
|
||||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||||
|
|
||||||
@@ -29,6 +33,39 @@ type MonthOption = {
|
|||||||
endDate: string;
|
endDate: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CreditRowProps = {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
isBold?: boolean;
|
||||||
|
underline?: "dashed" | "solid";
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreditRow = ({ label, value, isBold = false, underline, className = "" }: CreditRowProps) => {
|
||||||
|
const numberFormatter = new Intl.NumberFormat();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
`my-1 flex justify-between`,
|
||||||
|
underline === "dashed"
|
||||||
|
? "border-subtle border-b border-dashed"
|
||||||
|
: underline === "solid"
|
||||||
|
? "border-subtle border-b border-solid"
|
||||||
|
: "mt-1",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<span
|
||||||
|
className={classNames("text-sm", isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={classNames(`text-sm`, isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
|
||||||
|
{numberFormatter.format(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getMonthOptions = (): MonthOption[] => {
|
const getMonthOptions = (): MonthOption[] => {
|
||||||
const options: MonthOption[] = [];
|
const options: MonthOption[] = [];
|
||||||
const minDate = dayjs.utc("2025-05-01");
|
const minDate = dayjs.utc("2025-05-01");
|
||||||
@@ -60,6 +97,7 @@ export default function BillingCredits() {
|
|||||||
const monthOptions = useMemo(() => getMonthOptions(), []);
|
const monthOptions = useMemo(() => getMonthOptions(), []);
|
||||||
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -71,6 +109,7 @@ export default function BillingCredits() {
|
|||||||
|
|
||||||
const params = useParamsWithFallback();
|
const params = useParamsWithFallback();
|
||||||
const orgId = session.data?.user?.org?.id;
|
const orgId = session.data?.user?.org?.id;
|
||||||
|
const orgSlug = session.data?.user?.org?.slug;
|
||||||
|
|
||||||
const parsedTeamId = Number(params.id);
|
const parsedTeamId = Number(params.id);
|
||||||
const teamId: number | undefined = Number.isFinite(parsedTeamId)
|
const teamId: number | undefined = Number.isFinite(parsedTeamId)
|
||||||
@@ -132,117 +171,153 @@ export default function BillingCredits() {
|
|||||||
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
|
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const teamCreditsPercentageUsed =
|
const totalCredits = creditsData.credits.totalMonthlyCredits ?? 0;
|
||||||
creditsData.credits.totalMonthlyCredits > 0
|
const totalUsed = creditsData.credits.totalCreditsUsedThisMonth ?? 0;
|
||||||
? (creditsData.credits.totalRemainingMonthlyCredits / creditsData.credits.totalMonthlyCredits) * 100
|
|
||||||
: 0;
|
const teamCreditsPercentageUsed = totalCredits > 0 ? (totalUsed / totalCredits) * 100 : 0;
|
||||||
|
const numberFormatter = new Intl.NumberFormat();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 pb-6 text-sm sm:space-y-8">
|
<>
|
||||||
<div>
|
<div className="bg-muted border-muted mt-5 rounded-xl border p-1">
|
||||||
<h2 className="text-base font-semibold">{t("credits")}</h2>
|
<div className="flex flex-col gap-1 px-4 py-5">
|
||||||
<ServerTrans
|
<h2 className="text-default text-base font-semibold leading-none">{t("credits")}</h2>
|
||||||
t={t}
|
<p className="text-subtle text-sm font-medium leading-tight">{t("view_and_manage_credits")}</p>
|
||||||
i18nKey="view_and_manage_credits_description"
|
|
||||||
components={[
|
|
||||||
<Link
|
|
||||||
key="Credit System"
|
|
||||||
className="underline underline-offset-2"
|
|
||||||
target="_blank"
|
|
||||||
href="https://cal.com/help/billing-and-usage/messaging-credits">
|
|
||||||
Learn more
|
|
||||||
</Link>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<div className="-mx-6 mt-6">
|
|
||||||
<hr className="border-subtle" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="bg-default border-muted flex w-full rounded-[10px] border px-5 py-4">
|
||||||
{creditsData.credits.totalMonthlyCredits > 0 ? (
|
<div className="w-full">
|
||||||
<div className="mb-4">
|
{totalCredits > 0 ? (
|
||||||
<Label>{t("monthly_credits")}</Label>
|
<>
|
||||||
<ProgressBar
|
<div className="mb-4">
|
||||||
color="green"
|
<CreditRow
|
||||||
percentageValue={teamCreditsPercentageUsed}
|
label={t("monthly_credits")}
|
||||||
label={`${Math.max(0, Math.round(teamCreditsPercentageUsed))}%`}
|
value={totalCredits}
|
||||||
/>
|
isBold={true}
|
||||||
<div className="text-subtle">
|
underline="dashed"
|
||||||
<div>
|
/>
|
||||||
{t("total_credits", {
|
<CreditRow label={t("credits_used")} value={totalUsed} underline="solid" />
|
||||||
totalCredits: creditsData.credits.totalMonthlyCredits,
|
<CreditRow
|
||||||
})}
|
label={t("total_credits_remaining")}
|
||||||
|
value={creditsData.credits.totalRemainingMonthlyCredits}
|
||||||
|
/>
|
||||||
|
<div className="mt-4">
|
||||||
|
<ProgressBar color="green" percentageValue={100 - teamCreditsPercentageUsed} />
|
||||||
|
</div>
|
||||||
|
{/*750 credits per tip*/}
|
||||||
|
<div className="mt-4 flex flex-1 items-center justify-between">
|
||||||
|
<p className="text-subtle text-sm font-medium leading-tight">
|
||||||
|
{orgSlug ? t("credits_per_tip_org") : t("credits_per_tip_teams")}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setShowMemberInvitationModal(true)} size="sm" color="secondary">
|
||||||
|
{t("add_members_no_ellipsis")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="-mx-5 mt-5">
|
||||||
{t("remaining_credits", {
|
<hr className="border-subtle" />
|
||||||
remainingCredits: creditsData.credits.totalRemainingMonthlyCredits,
|
</div>
|
||||||
})}
|
</>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*Auto Top-Up goes here when we have it*/}
|
||||||
|
{/*<div className="-mx-5 mt-5">
|
||||||
|
<hr className="border-subtle" />
|
||||||
|
</div>*/}
|
||||||
|
{/*Additional Credits*/}
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="mt-4 flex">
|
||||||
|
<div className="-mb-1 mr-auto w-full">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Label>{t("additional_credits")}</Label>
|
||||||
|
<div className="mb-2 flex items-center gap-1">
|
||||||
|
<p className="text-sm font-semibold leading-none">
|
||||||
|
<span className="text-subtle font-medium">{t("current_balance")}</span>{" "}
|
||||||
|
{numberFormatter.format(creditsData.credits.additionalCredits)}
|
||||||
|
</p>
|
||||||
|
<Tooltip content={t("view_additional_credits_expense_tip")}>
|
||||||
|
<Icon name="info" className="text-emphasis h-3 w-3" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
<TextField
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
{...register("quantity", {
|
||||||
|
required: t("error_required_field"),
|
||||||
|
min: { value: 50, message: t("minimum_of_credits_required") },
|
||||||
|
valueAsNumber: true,
|
||||||
|
})}
|
||||||
|
label=""
|
||||||
|
containerClassName="w-full -mt-1"
|
||||||
|
size="sm"
|
||||||
|
onChange={(e) => setValue("quantity", Number(e.target.value))}
|
||||||
|
min={50}
|
||||||
|
addOnSuffix={<>{t("credits")}</>}
|
||||||
|
/>
|
||||||
|
<Button color="secondary" target="_blank" size="sm" type="submit" data-testid="buy-credits">
|
||||||
|
{t("buy")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
<Label>
|
|
||||||
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
|
|
||||||
</Label>
|
|
||||||
<div className="mt-2 text-sm">{creditsData.credits.additionalCredits}</div>
|
|
||||||
<div className="-mx-6 mb-6 mt-6">
|
|
||||||
<hr className="border-subtle mb-3 mt-3" />
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="flex">
|
|
||||||
<div className="-mb-1 mr-auto">
|
|
||||||
<Label>{t("buy_additional_credits")}</Label>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<TextField
|
|
||||||
required
|
|
||||||
type="number"
|
|
||||||
{...register("quantity", {
|
|
||||||
required: t("error_required_field"),
|
|
||||||
min: { value: 50, message: t("minimum_of_credits_required") },
|
|
||||||
valueAsNumber: true,
|
|
||||||
})}
|
|
||||||
label=""
|
|
||||||
containerClassName="w-60"
|
|
||||||
onChange={(e) => setValue("quantity", Number(e.target.value))}
|
|
||||||
min={50}
|
|
||||||
addOnSuffix={<>{t("credits")}</>}
|
|
||||||
/>
|
|
||||||
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
|
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="-mx-5 mt-5">
|
||||||
|
<hr className="border-subtle" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto">
|
{/*Download Expense Log*/}
|
||||||
<Button
|
<div className="mt-4 flex">
|
||||||
color="primary"
|
<div className="mr-auto w-full">
|
||||||
target="_blank"
|
<Label className="mb-4">{t("download_expense_log")}</Label>
|
||||||
EndIcon="external-link"
|
<div className="mr-2 mt-1">
|
||||||
type="submit"
|
<Select
|
||||||
data-testid="buy-credits">
|
size="sm"
|
||||||
{t("buy_credits")}
|
className="w-full"
|
||||||
</Button>
|
innerClassNames={{
|
||||||
</div>
|
control: "font-medium text-emphasis",
|
||||||
</form>
|
}}
|
||||||
<div className="-mx-6 mb-6 mt-6">
|
options={monthOptions}
|
||||||
<hr className="border-subtle mb-3 mt-3" />
|
value={selectedMonth}
|
||||||
</div>
|
onChange={(option) => option && setSelectedMonth(option)}
|
||||||
<div className="flex">
|
/>
|
||||||
<div className="mr-auto">
|
</div>
|
||||||
<Label className="mb-4">{t("download_expense_log")}</Label>
|
</div>
|
||||||
<div className="mt-2 flex flex-col">
|
<div className="mt-auto">
|
||||||
<Select
|
<Button onClick={handleDownload} loading={isDownloading} color="secondary" size="sm">
|
||||||
options={monthOptions}
|
{t("download")}
|
||||||
value={selectedMonth}
|
</Button>
|
||||||
onChange={(option) => option && setSelectedMonth(option)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="mt-auto">
|
|
||||||
<Button onClick={handleDownload} loading={isDownloading} StartIcon="file-down">
|
|
||||||
{t("download")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/*Credit Worth Section*/}
|
||||||
|
<div className="text-subtle px-5 py-4 text-sm font-medium leading-tight">
|
||||||
|
<ServerTrans
|
||||||
|
t={t}
|
||||||
|
i18nKey="credit_worth_description"
|
||||||
|
components={[
|
||||||
|
<Link
|
||||||
|
key="Credit System"
|
||||||
|
className="underline underline-offset-2"
|
||||||
|
target="_blank"
|
||||||
|
href="https://cal.com/help/billing-and-usage/messaging-credits">
|
||||||
|
Learn more
|
||||||
|
</Link>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{teamId && (
|
||||||
|
<MemberInvitationModalWithoutMembers
|
||||||
|
teamId={teamId}
|
||||||
|
showMemberInvitationModal={showMemberInvitationModal}
|
||||||
|
hideInvitationModal={() => setShowMemberInvitationModal(false)}
|
||||||
|
onSettingsOpen={() => {
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,77 +3,76 @@ import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/comp
|
|||||||
export function BillingCreditsSkeleton() {
|
export function BillingCreditsSkeleton() {
|
||||||
return (
|
return (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 text-sm sm:space-y-8">
|
<div className="bg-muted border-muted mt-5 rounded-xl p-1">
|
||||||
{/* Title and Description */}
|
<div className="flex flex-col gap-1 px-4 py-5">
|
||||||
<div>
|
<SkeletonText className="h-4 w-20" /> {/* Credits title */}
|
||||||
<div>
|
<SkeletonText className="h-4 w-48" /> {/* View and manage credits description */}
|
||||||
<SkeletonText className="mb-2 h-4 w-20" /> {/* Credits title */}
|
</div>
|
||||||
</div>
|
<div className="bg-default border-muted flex w-full rounded-[10px] px-5 py-4">
|
||||||
<div>
|
<div className="w-full">
|
||||||
<SkeletonText className="h-4 w-72" /> {/* Description */}
|
{/* Credits section */}
|
||||||
</div>
|
<div className="mb-4">
|
||||||
|
{/* Monthly credits row */}
|
||||||
<div className="-mx-6 mt-6">
|
<div className="mt-1 flex justify-between">
|
||||||
<hr className="border-subtle" />
|
<SkeletonText className="h-4 w-32" />
|
||||||
</div>
|
<SkeletonText className="h-4 w-20" />
|
||||||
{/* Monthly credits */}
|
|
||||||
<div className="mt-[20px]">
|
|
||||||
<SkeletonText className="mb-4 h-4 w-32" /> {/* Monthly credits label */}
|
|
||||||
<div className="h-2 w-full rounded-md bg-gray-200" /> {/* Progress bar */}
|
|
||||||
<div className="text-subtle mt-2">
|
|
||||||
<div>
|
|
||||||
<SkeletonText className="h-4 w-40" /> {/* Total credits */}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{/* Additional credits row */}
|
||||||
<SkeletonText className="h-4 w-40" /> {/* Remaining credits */}
|
<div className="mt-1 flex justify-between">
|
||||||
|
<SkeletonText className="h-4 w-36" />
|
||||||
|
<SkeletonText className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
{/* Remaining row */}
|
||||||
|
<div className="mt-1 flex justify-between">
|
||||||
|
<SkeletonText className="h-4 w-24" />
|
||||||
|
<SkeletonText className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="h-2 w-full rounded-md bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
{/* Credits per tip */}
|
||||||
|
<div className="mt-4 flex flex-1 justify-between">
|
||||||
|
<SkeletonText className="h-4 w-32" />
|
||||||
|
<SkeletonButton className="h-8 w-24 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="-mx-5 mt-5">
|
||||||
|
<hr className="border-subtle" />
|
||||||
|
</div>
|
||||||
|
{/* Additional Credits form */}
|
||||||
|
<div className="mt-4 flex">
|
||||||
|
<div className="-mb-1 mr-auto w-full">
|
||||||
|
<SkeletonText className="mb-2 h-4 w-32" /> {/* Additional credits label */}
|
||||||
|
<div className="flex w-full">
|
||||||
|
<SkeletonText className="h-9 w-full rounded-md" /> {/* Input field */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 mt-auto">
|
||||||
|
<SkeletonButton className="h-9 w-12 rounded-md" /> {/* Buy button */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="-mx-5 mt-5">
|
||||||
|
<hr className="border-subtle" />
|
||||||
|
</div>
|
||||||
|
{/* Download Expense Log */}
|
||||||
|
<div className="mt-4 flex">
|
||||||
|
<div className="mr-auto w-full">
|
||||||
|
<SkeletonText className="mb-4 h-4 w-40" /> {/* Download expense log label */}
|
||||||
|
<div className="mr-2 mt-1">
|
||||||
|
<SkeletonText className="h-9 w-full rounded-md" /> {/* Select dropdown */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto">
|
||||||
|
<SkeletonButton className="h-9 w-20 rounded-md" /> {/* Download button */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Additional credits */}
|
</div>
|
||||||
<div className="mt-4">
|
{/* Credit Worth Section */}
|
||||||
<div>
|
<div className="text-subtle px-5 py-4">
|
||||||
<SkeletonText className="mb-2 h-4 w-40" /> {/* Additional credits label */}
|
<SkeletonText className="h-4 w-full" />
|
||||||
</div>
|
<SkeletonText className="mt-1 h-4 w-3/4" />
|
||||||
<div>
|
|
||||||
<SkeletonText className="h-4 w-24" /> {/* Additional credits value */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="-mx-6 mb-6 mt-4">
|
|
||||||
<hr className="border-subtle mb-3 mt-3" />
|
|
||||||
</div>
|
|
||||||
{/* Buy credits form */}
|
|
||||||
<div className="flex">
|
|
||||||
<div className="mr-auto">
|
|
||||||
<div>
|
|
||||||
<SkeletonText className="mb-2 h-4 w-[268px]" /> {/* Buy credits label */}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto ">
|
|
||||||
<SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="-mx-6 mb-6 mt-6">
|
|
||||||
<hr className="border-subtle mb-3 mt-3" />
|
|
||||||
</div>
|
|
||||||
{/* Download expense log */}
|
|
||||||
<div className="flex">
|
|
||||||
<div className="mr-auto mt-1">
|
|
||||||
<div>
|
|
||||||
<SkeletonText className="mb-2 h-4 w-[156px]" /> {/* Label */}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<SkeletonText className="mt-2 h-8 w-[156px] rounded-md" /> {/* Select */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto ">
|
|
||||||
<SkeletonButton className="h-9 w-[109px] rounded-md" /> {/* Download button */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SkeletonContainer>
|
</SkeletonContainer>
|
||||||
|
|||||||
@@ -30,16 +30,18 @@ declare global {
|
|||||||
export default function PlatformBillingUpgrade() {
|
export default function PlatformBillingUpgrade() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, userBillingData, isPaidUser, userOrgId } =
|
||||||
|
useGetUserAttributes();
|
||||||
|
|
||||||
const returnTo = pathname;
|
const returnTo = pathname;
|
||||||
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
|
const teamId = `teamId=${userOrgId}`;
|
||||||
|
const billingHref = `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}&${teamId}`;
|
||||||
|
|
||||||
const onContactSupportClick = async () => {
|
const onContactSupportClick = async () => {
|
||||||
if (window.Support) {
|
if (window.Support) {
|
||||||
window.Support.open();
|
window.Support.open();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, userBillingData, isPaidUser, userOrgId } =
|
|
||||||
useGetUserAttributes();
|
|
||||||
|
|
||||||
const { mutateAsync: removeTeamSubscription, isPending: isRemoveTeamSubscriptionLoading } =
|
const { mutateAsync: removeTeamSubscription, isPending: isRemoveTeamSubscriptionLoading } =
|
||||||
useUnsubscribeTeamToStripe({
|
useUnsubscribeTeamToStripe({
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
|||||||
import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType";
|
import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { WizardLayout } from "@calcom/ui/components/layout";
|
import { WizardLayout } from "@calcom/ui/components/layout";
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
|
||||||
|
|
||||||
export const CreateTeamEventType = () => {
|
type CreateTeamEventTypeProps = {
|
||||||
|
permissions: { canCreateEventType: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateTeamEventType = ({ permissions }: CreateTeamEventTypeProps) => {
|
||||||
const searchParams = useCompatSearchParams();
|
const searchParams = useCompatSearchParams();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -53,7 +57,7 @@ export const CreateTeamEventType = () => {
|
|||||||
<TeamEventTypeForm
|
<TeamEventTypeForm
|
||||||
teamSlug={team?.slug}
|
teamSlug={team?.slug}
|
||||||
teamId={teamId}
|
teamId={teamId}
|
||||||
isTeamAdminOrOwner={true}
|
permissions={permissions}
|
||||||
urlPrefix={urlPrefix}
|
urlPrefix={urlPrefix}
|
||||||
isPending={createMutation.isPending}
|
isPending={createMutation.isPending}
|
||||||
form={form}
|
form={form}
|
||||||
@@ -82,5 +86,3 @@ export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
|
|||||||
</WizardLayout>
|
</WizardLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CreateTeamEventType;
|
|
||||||
|
|||||||
@@ -426,6 +426,7 @@ export default function Signup({
|
|||||||
id="signup-email"
|
id="signup-email"
|
||||||
{...register("email")}
|
{...register("email")}
|
||||||
label={t("email")}
|
label={t("email")}
|
||||||
|
placeholder="john@doe.com"
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={prepopulateFormValues?.email}
|
disabled={prepopulateFormValues?.email}
|
||||||
@@ -522,8 +523,8 @@ export default function Signup({
|
|||||||
usernameTaken
|
usernameTaken
|
||||||
}>
|
}>
|
||||||
{premiumUsername && !usernameTaken
|
{premiumUsername && !usernameTaken
|
||||||
? `${t("create_account")} (${getPremiumPlanPriceValue()})`
|
? `${t("get_started")} (${getPremiumPlanPriceValue()})`
|
||||||
: t("create_account")}
|
: t("get_started")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
|
||||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||||
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||||
import MemberList from "@calcom/features/ee/teams/components/MemberList";
|
import MemberList from "@calcom/features/ee/teams/components/MemberList";
|
||||||
@@ -23,18 +22,23 @@ interface TeamMembersViewProps {
|
|||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
attributes?: any[];
|
attributes?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
options: {
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
permissions: MemberPermissions;
|
permissions: MemberPermissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMembersViewProps) => {
|
export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMembersViewProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||||
const [showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
|
const [_showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
|
||||||
|
|
||||||
// Use PBAC permissions if available, otherwise fall back to role-based check
|
// Use PBAC permissions - server-side permission check should be done in parent component
|
||||||
const isTeamAdminOrOwner = checkAdminOrOwner(team.membership.role);
|
const canLoggedInUserSeeMembers = permissions?.canListMembers ?? false;
|
||||||
const canLoggedInUserSeeMembers = permissions?.canListMembers ?? (!team.isPrivate || isTeamAdminOrOwner);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
|
|||||||
+17
-16
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.7.2",
|
"version": "5.7.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
"copy-app-store-static": "node scripts/copy-app-store-static.js",
|
"copy-app-store-static": "node scripts/copy-app-store-static.js",
|
||||||
"build": "turbo run copy-app-store-static && next build && yarn sentry:release",
|
"build": "turbo run copy-app-store-static && next build && yarn sentry:release",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint . --ignore-path .gitignore",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"lint:report": "eslint . --format json --output-file ../../lint-results/web.json",
|
"lint:report": "eslint . --format json --output-file ../../lint-results/web.json",
|
||||||
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts",
|
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts",
|
||||||
"translate-locales": "ts-node scripts/check-missing-translations.ts",
|
"translate-locales": "ts-node scripts/check-missing-translations.ts",
|
||||||
@@ -32,20 +32,20 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boxyhq/saml-jackson": "1.52.2",
|
"@boxyhq/saml-jackson": "1.52.2",
|
||||||
"@calcom/app-store": "*",
|
"@calcom/app-store": "workspace:*",
|
||||||
"@calcom/app-store-cli": "*",
|
"@calcom/app-store-cli": "workspace:*",
|
||||||
"@calcom/dayjs": "*",
|
"@calcom/dayjs": "workspace:*",
|
||||||
"@calcom/embed-core": "workspace:*",
|
"@calcom/embed-core": "workspace:*",
|
||||||
"@calcom/embed-react": "workspace:*",
|
"@calcom/embed-react": "workspace:*",
|
||||||
"@calcom/embed-snippet": "workspace:*",
|
"@calcom/embed-snippet": "workspace:*",
|
||||||
"@calcom/features": "*",
|
"@calcom/features": "workspace:*",
|
||||||
"@calcom/lib": "*",
|
"@calcom/lib": "workspace:*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "workspace:*",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "workspace:*",
|
||||||
"@calcom/prisma": "*",
|
"@calcom/prisma": "workspace:*",
|
||||||
"@calcom/trpc": "*",
|
"@calcom/trpc": "workspace:*",
|
||||||
"@calcom/tsconfig": "*",
|
"@calcom/tsconfig": "workspace:*",
|
||||||
"@calcom/ui": "*",
|
"@calcom/ui": "workspace:*",
|
||||||
"@daily-co/daily-js": "^0.83.1",
|
"@daily-co/daily-js": "^0.83.1",
|
||||||
"@daily-co/daily-react": "^0.23.2",
|
"@daily-co/daily-react": "^0.23.2",
|
||||||
"@dub/analytics": "^0.0.27",
|
"@dub/analytics": "^0.0.27",
|
||||||
@@ -158,8 +158,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.19.6",
|
"@babel/core": "^7.19.6",
|
||||||
"@calcom/config": "*",
|
"@calcom/config": "workspace:*",
|
||||||
"@calcom/types": "*",
|
"@calcom/eslint-config": "workspace:*",
|
||||||
|
"@calcom/types": "workspace:*",
|
||||||
"@microsoft/microsoft-graph-types-beta": "0.15.0-preview",
|
"@microsoft/microsoft-graph-types-beta": "0.15.0-preview",
|
||||||
"@playwright/test": "^1.45.3",
|
"@playwright/test": "^1.45.3",
|
||||||
"@testing-library/react": "^13.3.0",
|
"@testing-library/react": "^13.3.0",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
import { test } from "./lib/fixtures";
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "parallel" });
|
||||||
|
|
||||||
|
test.afterEach(({ users }) => users.deleteAll());
|
||||||
|
|
||||||
|
test.describe("Booking Filters", () => {
|
||||||
|
test("Member role should not see the member filter", async ({ page, users }) => {
|
||||||
|
const teamMateName = "team mate 1";
|
||||||
|
await users.create(undefined, {
|
||||||
|
hasTeam: true,
|
||||||
|
isOrg: true,
|
||||||
|
teammates: [{ name: teamMateName }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const allUsers = await users.get();
|
||||||
|
const memberUser = allUsers.find((user) => user.name === teamMateName);
|
||||||
|
|
||||||
|
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||||
|
if (!memberUser) {
|
||||||
|
throw new Error("user should exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
await memberUser.apiLogin();
|
||||||
|
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||||
|
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||||
|
);
|
||||||
|
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
||||||
|
await bookingsGetResponse;
|
||||||
|
await page.locator('[data-testid="add-filter-button"]').click();
|
||||||
|
await expect(page.locator('[data-testid="add-filter-item-userId"]')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Admin role should see the member filter", async ({ page, users }) => {
|
||||||
|
const owner = await users.create(undefined, {
|
||||||
|
hasTeam: true,
|
||||||
|
isOrg: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await owner.apiLogin();
|
||||||
|
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||||
|
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||||
|
);
|
||||||
|
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
||||||
|
await bookingsGetResponse;
|
||||||
|
await page.locator('[data-testid="add-filter-button"]').click();
|
||||||
|
await expect(page.locator('[data-testid="add-filter-item-userId"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -458,5 +458,128 @@ test.describe("Reschedule for booking with seats", () => {
|
|||||||
await expect(page.locator('[data-testid="confirm-reschedule-button"]')).toHaveCount(1);
|
await expect(page.locator('[data-testid="confirm-reschedule-button"]')).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Host reschedule from /upcoming page should have rescheduleUid parameter set to bookingUid", async ({
|
||||||
|
page,
|
||||||
|
users,
|
||||||
|
bookings,
|
||||||
|
}) => {
|
||||||
|
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||||
|
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
]);
|
||||||
|
await user.apiLogin();
|
||||||
|
|
||||||
|
const bookingAttendees = await prisma.attendee.findMany({
|
||||||
|
where: { bookingId: booking.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingSeats = bookingAttendees.map((attendee) => ({
|
||||||
|
bookingId: booking.id,
|
||||||
|
attendeeId: attendee.id,
|
||||||
|
referenceUid: uuidv4(),
|
||||||
|
data: {
|
||||||
|
responses: {
|
||||||
|
name: attendee.name,
|
||||||
|
email: attendee.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await prisma.bookingSeat.createMany({
|
||||||
|
data: bookingSeats,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/bookings/upcoming");
|
||||||
|
await page.waitForSelector('[data-testid="bookings"]');
|
||||||
|
|
||||||
|
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
|
||||||
|
await page.locator('[data-testid="reschedule"]').click();
|
||||||
|
|
||||||
|
await page.waitForURL((url) => {
|
||||||
|
const rescheduleUid = url.searchParams.get("rescheduleUid");
|
||||||
|
return !!rescheduleUid && rescheduleUid === booking.uid;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Second attendee reschedule from /upcoming page should use correct seatReferenceUid and show attendee info", async ({
|
||||||
|
page,
|
||||||
|
users,
|
||||||
|
bookings,
|
||||||
|
}) => {
|
||||||
|
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||||
|
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bookingAttendees = await prisma.attendee.findMany({
|
||||||
|
where: { bookingId: booking.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingSeats = bookingAttendees.map((attendee) => ({
|
||||||
|
bookingId: booking.id,
|
||||||
|
attendeeId: attendee.id,
|
||||||
|
referenceUid: uuidv4(),
|
||||||
|
data: {
|
||||||
|
responses: {
|
||||||
|
name: attendee.name,
|
||||||
|
email: attendee.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await prisma.bookingSeat.createMany({
|
||||||
|
data: bookingSeats,
|
||||||
|
});
|
||||||
|
|
||||||
|
const references = await prisma.bookingSeat.findMany({
|
||||||
|
where: { bookingId: booking.id },
|
||||||
|
orderBy: { id: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondUser = await users.create({
|
||||||
|
name: "Jane Second",
|
||||||
|
email: "second+seats@cal.com",
|
||||||
|
});
|
||||||
|
await secondUser.apiLogin();
|
||||||
|
|
||||||
|
await page.goto("/bookings/upcoming");
|
||||||
|
await page.waitForSelector('[data-testid="bookings"]');
|
||||||
|
|
||||||
|
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
|
||||||
|
await page.locator('[data-testid="reschedule"]').click();
|
||||||
|
|
||||||
|
await page.waitForURL((url) => {
|
||||||
|
const rescheduleUid = url.searchParams.get("rescheduleUid");
|
||||||
|
return !!rescheduleUid && rescheduleUid === references[1].referenceUid;
|
||||||
|
});
|
||||||
|
|
||||||
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
|
|
||||||
|
const nameElement = page.locator("input[name=name]");
|
||||||
|
const name = await nameElement.inputValue();
|
||||||
|
expect(name).toBe("Jane Second");
|
||||||
|
|
||||||
|
const emailElement = page.locator("input[name=email]");
|
||||||
|
const email = await emailElement.inputValue();
|
||||||
|
expect(email).toBe("second+seats@cal.com");
|
||||||
|
|
||||||
|
// Complete the reschedule
|
||||||
|
await confirmReschedule(page);
|
||||||
|
|
||||||
|
// Verify successful reschedule
|
||||||
|
await page.waitForURL(/\/booking\/.*/);
|
||||||
|
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||||
|
});
|
||||||
|
|
||||||
// @TODO: force 404 when rescheduleUid is not found
|
// @TODO: force 404 when rescheduleUid is not found
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// To prevent early timeouts
|
// To prevent early timeouts
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "inline",
|
embedType: "inline",
|
||||||
@@ -98,7 +97,6 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// To prevent early timeouts
|
// To prevent early timeouts
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "floating-popup",
|
embedType: "floating-popup",
|
||||||
@@ -136,7 +134,6 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// To prevent early timeouts
|
// To prevent early timeouts
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "element-click",
|
embedType: "element-click",
|
||||||
@@ -176,7 +173,6 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// To prevent early timeouts
|
// To prevent early timeouts
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "inline",
|
embedType: "inline",
|
||||||
@@ -234,7 +230,6 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// To prevent early timeouts
|
// To prevent early timeouts
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "inline",
|
embedType: "inline",
|
||||||
@@ -275,7 +270,6 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// To prevent early timeouts
|
// To prevent early timeouts
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "floating-popup",
|
embedType: "floating-popup",
|
||||||
@@ -315,7 +309,6 @@ test.describe("Embed Code Generator Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// To prevent early timeouts
|
// To prevent early timeouts
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await expectToContainValidPreviewIframe(page, {
|
await expectToContainValidPreviewIframe(page, {
|
||||||
embedType: "element-click",
|
embedType: "element-click",
|
||||||
@@ -333,8 +326,7 @@ function chooseEmbedType(page: Page, embedType: EmbedType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function goToReactCodeTab(page: Page) {
|
async function goToReactCodeTab(page: Page) {
|
||||||
// To prevent early timeouts
|
// To prevent early timeo
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await page.locator("[data-testid=horizontal-tab-react]").click();
|
await page.locator("[data-testid=horizontal-tab-react]").click();
|
||||||
}
|
}
|
||||||
@@ -343,7 +335,7 @@ async function clickEmbedButton(page: Page) {
|
|||||||
const embedButton = page.locator("[data-testid=embed]");
|
const embedButton = page.locator("[data-testid=embed]");
|
||||||
const embedUrl = await embedButton.getAttribute("data-test-embed-url");
|
const embedUrl = await embedButton.getAttribute("data-test-embed-url");
|
||||||
embedButton.click();
|
embedButton.click();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
return embedUrl!;
|
return embedUrl!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,18 +431,24 @@ async function expectValidHtmlEmbedSnippet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assertThatCodeIsValidVanillaJsCode(code: string) {
|
function assertThatCodeIsValidVanillaJsCode(code: string) {
|
||||||
const lintResult = linter.verify(code, {
|
const lintResult = linter.verify(code, [
|
||||||
env: {
|
{
|
||||||
browser: true,
|
languageOptions: {
|
||||||
|
ecmaVersion: 2021,
|
||||||
|
sourceType: "module",
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
globals: {
|
||||||
|
window: "readonly",
|
||||||
|
document: "readonly",
|
||||||
|
navigator: "readonly",
|
||||||
|
Cal: "readonly",
|
||||||
|
console: "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: eslintRules,
|
||||||
},
|
},
|
||||||
parserOptions: {
|
]);
|
||||||
ecmaVersion: 2021,
|
|
||||||
},
|
|
||||||
globals: {
|
|
||||||
Cal: "readonly",
|
|
||||||
},
|
|
||||||
rules: eslintRules,
|
|
||||||
});
|
|
||||||
if (lintResult.length) {
|
if (lintResult.length) {
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -459,23 +457,35 @@ function assertThatCodeIsValidVanillaJsCode(code: string) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(lintResult.length).toBe(0);
|
expect(lintResult.length).toBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertThatCodeIsValidReactCode(code: string) {
|
function assertThatCodeIsValidReactCode(code: string) {
|
||||||
const lintResult = linter.verify(code, {
|
const lintResult = linter.verify(code, [
|
||||||
env: {
|
{
|
||||||
browser: true,
|
languageOptions: {
|
||||||
},
|
ecmaVersion: 2021,
|
||||||
parserOptions: {
|
sourceType: "module",
|
||||||
ecmaVersion: 2021,
|
parserOptions: {
|
||||||
ecmaFeatures: {
|
ecmaFeatures: { jsx: true },
|
||||||
jsx: true,
|
},
|
||||||
|
globals: {
|
||||||
|
window: "readonly",
|
||||||
|
document: "readonly",
|
||||||
|
navigator: "readonly",
|
||||||
|
console: "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...eslintRules,
|
||||||
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
"no-undef": "off",
|
||||||
|
semi: "off",
|
||||||
},
|
},
|
||||||
sourceType: "module",
|
|
||||||
},
|
},
|
||||||
rules: eslintRules,
|
]);
|
||||||
});
|
|
||||||
if (lintResult.length) {
|
if (lintResult.length) {
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -484,6 +494,7 @@ function assertThatCodeIsValidReactCode(code: string) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(lintResult.length).toBe(0);
|
expect(lintResult.length).toBe(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ test.describe("Event Types tests", () => {
|
|||||||
await gotoFirstEventType(page);
|
await gotoFirstEventType(page);
|
||||||
|
|
||||||
await page.getByTestId("location-select").click();
|
await page.getByTestId("location-select").click();
|
||||||
await page.locator(`text="Cal Video (Global)"`).click();
|
await page.locator(`text="Cal Video (Default)"`).click();
|
||||||
|
|
||||||
await saveEventType(page);
|
await saveEventType(page);
|
||||||
await gotoBookingPage(page);
|
await gotoBookingPage(page);
|
||||||
@@ -282,7 +282,7 @@ test.describe("Event Types tests", () => {
|
|||||||
await selectAttendeePhoneNumber(page);
|
await selectAttendeePhoneNumber(page);
|
||||||
|
|
||||||
// Add Cal Video location
|
// Add Cal Video location
|
||||||
await addAnotherLocation(page, "Cal Video (Global)");
|
await addAnotherLocation(page, "Cal Video (Default)");
|
||||||
|
|
||||||
await saveEventType(page);
|
await saveEventType(page);
|
||||||
|
|
||||||
@@ -496,6 +496,9 @@ test.describe("Event Types tests", () => {
|
|||||||
});
|
});
|
||||||
await test.step("should open first eventType and check Interface Language", async () => {
|
await test.step("should open first eventType and check Interface Language", async () => {
|
||||||
await gotoFirstEventType(page);
|
await gotoFirstEventType(page);
|
||||||
|
// Go to Advanced tab
|
||||||
|
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
|
||||||
|
await page.click("[data-testid=event-interface-language-toggle]");
|
||||||
const interfaceLanguageValue = page
|
const interfaceLanguageValue = page
|
||||||
.getByTestId("event-interface-language")
|
.getByTestId("event-interface-language")
|
||||||
.locator('div[class$="-singleValue"]');
|
.locator('div[class$="-singleValue"]');
|
||||||
@@ -518,6 +521,9 @@ test.describe("Event Types tests", () => {
|
|||||||
|
|
||||||
await test.step("should open first eventType and change Interface Language to Deutsche", async () => {
|
await test.step("should open first eventType and change Interface Language to Deutsche", async () => {
|
||||||
await gotoFirstEventType(page);
|
await gotoFirstEventType(page);
|
||||||
|
// Go to Advanced tab and enable offerSeats
|
||||||
|
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
|
||||||
|
await page.click("[data-testid=event-interface-language-toggle]");
|
||||||
await page.getByTestId("event-interface-language").click();
|
await page.getByTestId("event-interface-language").click();
|
||||||
await page.locator(`text="Deutsch"`).click();
|
await page.locator(`text="Deutsch"`).click();
|
||||||
await saveEventType(page);
|
await saveEventType(page);
|
||||||
@@ -574,6 +580,9 @@ test.describe("Event Types tests", () => {
|
|||||||
await page.goto("/event-types");
|
await page.goto("/event-types");
|
||||||
await page.waitForSelector('[data-testid="event-types"]');
|
await page.waitForSelector('[data-testid="event-types"]');
|
||||||
await gotoFirstEventType(page);
|
await gotoFirstEventType(page);
|
||||||
|
// Go to Advanced tab and enable offerSeats
|
||||||
|
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
|
||||||
|
await page.click("[data-testid=event-interface-language-toggle]");
|
||||||
await page.getByTestId("event-interface-language").click();
|
await page.getByTestId("event-interface-language").click();
|
||||||
await page.getByTestId("select-option-es").click();
|
await page.getByTestId("select-option-es").click();
|
||||||
await saveEventType(page);
|
await saveEventType(page);
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export async function expectSegmentCleared(page: Page) {
|
|||||||
// Check that no segment is selected (button shows default text)
|
// Check that no segment is selected (button shows default text)
|
||||||
const segmentSelect = page.getByTestId("filter-segment-select");
|
const segmentSelect = page.getByTestId("filter-segment-select");
|
||||||
const buttonText = await segmentSelect.textContent();
|
const buttonText = await segmentSelect.textContent();
|
||||||
expect(buttonText?.trim()).toEqual("Segment");
|
expect(buttonText?.trim()).toEqual("Saved filters");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user