Compare commits
53
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 |
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
apps/api/v2/dist
|
||||
packages/platform/**/dist/*
|
||||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
packages/prisma/zod
|
||||
apps/web/public/embed
|
||||
packages/ui/components/icon/dynamicIconImports.tsx
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
extends: ["./packages/config/eslint-preset.js"],
|
||||
plugins: ["import"],
|
||||
rules: {
|
||||
"import/no-cycle": ["warn", { maxDepth: Infinity }],
|
||||
},
|
||||
overrides: [
|
||||
// WARN: features must not be imported by app-store or lib
|
||||
{
|
||||
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}", "packages/lib/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
// Catch all relative paths into features
|
||||
"**/features",
|
||||
"**/features/*",
|
||||
// Catch all alias imports
|
||||
"@calcom/features",
|
||||
"@calcom/features/*",
|
||||
],
|
||||
message: "Avoid importing @calcom/features from @calcom/app-store or @calcom/lib.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// WARN: lib must not import app-store or features
|
||||
{
|
||||
files: ["packages/lib/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"warn",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
// Catch all relative paths into app-store
|
||||
"**/app-store",
|
||||
"**/app-store/*",
|
||||
// Catch all relative paths into features
|
||||
"**/features",
|
||||
"**/features/*",
|
||||
// Catch alias imports
|
||||
"@calcom/app-store",
|
||||
"@calcom/app-store/*",
|
||||
"@calcom/features",
|
||||
"@calcom/features/*",
|
||||
],
|
||||
message: "@calcom/lib should not import @calcom/app-store or @calcom/features.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// ERROR: app-store must not import trpc
|
||||
{
|
||||
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
// Catch all relative paths into trpc
|
||||
"**/trpc",
|
||||
"**/trpc/*",
|
||||
// Catch alias imports
|
||||
"@calcom/trpc",
|
||||
"@calcom/trpc/*",
|
||||
"@trpc",
|
||||
"@trpc/*",
|
||||
],
|
||||
message:
|
||||
"@calcom/app-store must not import trpc. Move UI to apps/web/components/apps or introduce an API boundary.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// ERROR: prisma must not import `features` package
|
||||
{
|
||||
files: ["packages/prisma/**/*.{ts,tsx,js,jsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
// Catch all relative paths into features
|
||||
"**/features",
|
||||
"**/features/*",
|
||||
// Catch all alias imports
|
||||
"@calcom/features",
|
||||
"@calcom/features/*",
|
||||
],
|
||||
message: "Avoid importing @calcom/features from @calcom/prisma.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
/**/package.json @calcom/Foundation
|
||||
/apps/api/**/* @calcom/Platform @calcom/Foundation
|
||||
/apps/api/**/* @calcom/API
|
||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
|
||||
/apps/web/lib/csp.ts @calcom/Foundation
|
||||
|
||||
@@ -74,14 +74,8 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-atoms:
|
||||
name: Tests
|
||||
needs: [lint, build, build-atoms, build-api-v2]
|
||||
uses: ./.github/workflows/e2e-atoms.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
|
||||
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||
if: always()
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: E2E
|
||||
name: Check breaking changes and run E2E
|
||||
on:
|
||||
workflow_call:
|
||||
env:
|
||||
@@ -23,6 +23,10 @@ env:
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }}
|
||||
VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }}
|
||||
JWT_SECRET: ${{ secrets.CI_JWT_SECRET }}
|
||||
NODE_ENV: ${{ vars.CI_NODE_ENV }}
|
||||
jobs:
|
||||
e2e:
|
||||
timeout-minutes: 20
|
||||
@@ -68,6 +72,18 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/cache-db
|
||||
|
||||
- name: Generate Swagger
|
||||
working-directory: apps/api/v2
|
||||
run: yarn generate-swagger
|
||||
|
||||
- name: Check breaking changes
|
||||
uses: oasdiff/oasdiff-action/breaking@main
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
|
||||
revision: docs/api-reference/v2/openapi.json
|
||||
fail-on: WARN
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: apps/api/v2
|
||||
run: |
|
||||
@@ -75,6 +91,7 @@ jobs:
|
||||
EXIT_CODE=$?
|
||||
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, teams, webhooks, workflows, zapier"
|
||||
ignore-labels: "admin, app-store, ai, api, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, sre, teams, webhooks, workflows, zapier"
|
||||
apply-labels-from-issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -195,13 +195,6 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-atoms:
|
||||
name: Tests
|
||||
needs: [changes, check-label, build, build-atoms, build-api-v2]
|
||||
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-atoms.yml
|
||||
secrets: inherit
|
||||
|
||||
analyze:
|
||||
name: Analyze Build
|
||||
needs: [build]
|
||||
@@ -211,7 +204,7 @@ jobs:
|
||||
merge-reports:
|
||||
name: Merge reports
|
||||
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
|
||||
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||
uses: ./.github/workflows/merge-reports.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -255,8 +248,7 @@ jobs:
|
||||
e2e-api-v2,
|
||||
e2e-embed,
|
||||
e2e-embed-react,
|
||||
e2e-app-store,
|
||||
e2e-atoms,
|
||||
e2e-app-store
|
||||
]
|
||||
if: always()
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
|
||||
@@ -16,5 +16,4 @@ public
|
||||
packages/prisma/zod
|
||||
packages/prisma/enums
|
||||
apps/web/public/embed
|
||||
apps/api/v2/swagger/documentation.json
|
||||
packages/ui/components/icon/dynamicIconImports.tsx
|
||||
|
||||
Vendored
Vendored
@@ -13,5 +13,8 @@
|
||||
"connect": "^3.7.0",
|
||||
"http": "^0.0.1-security",
|
||||
"http-proxy-middleware": "^2.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/eslint-config": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { nextJsConfig } from "@calcom/eslint-config/next-js";
|
||||
|
||||
export default nextJsConfig;
|
||||
+11
-10
@@ -10,7 +10,7 @@
|
||||
"build": "next build",
|
||||
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
|
||||
"dev": "PORT=3003 next dev",
|
||||
"lint": "eslint . --ignore-path .gitignore",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"start": "PORT=3003 next start",
|
||||
"docker-start-api": "PORT=80 next start",
|
||||
@@ -18,18 +18,19 @@
|
||||
"type-check:ci": "tsc-absolute --pretty --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/tsconfig": "*",
|
||||
"@calcom/types": "*",
|
||||
"@calcom/eslint-config": "workspace:*",
|
||||
"@calcom/tsconfig": "workspace:*",
|
||||
"@calcom/types": "workspace:*",
|
||||
"node-mocks-http": "^1.11.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/app-store": "*",
|
||||
"@calcom/dayjs": "*",
|
||||
"@calcom/emails": "*",
|
||||
"@calcom/features": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/trpc": "*",
|
||||
"@calcom/app-store": "workspace:*",
|
||||
"@calcom/dayjs": "workspace:*",
|
||||
"@calcom/emails": "workspace:*",
|
||||
"@calcom/features": "workspace:*",
|
||||
"@calcom/lib": "workspace:*",
|
||||
"@calcom/prisma": "workspace:*",
|
||||
"@calcom/trpc": "workspace:*",
|
||||
"@prisma/nextjs-monorepo-workaround-plugin": "^6.16.1",
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function getHandler(req: NextApiRequest) {
|
||||
};
|
||||
}
|
||||
|
||||
const [total, data] = await prisma.$transaction([
|
||||
const [total, data] = await Promise.all([
|
||||
prisma.user.count({ where }),
|
||||
prisma.user.findMany({ where, take, skip }),
|
||||
]);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
module.exports = {
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: "module",
|
||||
},
|
||||
plugins: ["@typescript-eslint/eslint-plugin"],
|
||||
extends: ["plugin:@typescript-eslint/recommended"],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: [".eslintrc.js", "next-i18next.config.js"],
|
||||
rules: {
|
||||
"@typescript-eslint/interface-name-prefix": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["./src/**/*.controller.ts"],
|
||||
excludedFiles: "*.spec.js",
|
||||
rules: {
|
||||
"@typescript-eslint/explicit-function-return-type": "error",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -16,7 +16,7 @@
|
||||
"_dev:build:watch:enums": "yarn workspace @calcom/platform-enums build:watch",
|
||||
"_dev:build:watch:utils": "yarn workspace @calcom/platform-utils build:watch",
|
||||
"_dev:build:watch:types": "yarn workspace @calcom/platform-types build:watch",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
|
||||
"dev": "yarn dev:build && ts-node scripts/docker-start.ts && yarn copy-swagger-module && yarn start --watch",
|
||||
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
@@ -30,18 +30,19 @@
|
||||
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
|
||||
"prisma": "yarn workspace @calcom/prisma prisma",
|
||||
"generate-schemas": "yarn prisma generate && yarn prisma format",
|
||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
|
||||
"copy-swagger-module": "ts-node -r tsconfig-paths/register src/swagger/copy-swagger-module.ts",
|
||||
"generate-swagger": "yarn copy-swagger-module && yarn build && node ./dist/apps/api/v2/src/swagger/generate-swagger-script.js",
|
||||
"prepare": "yarn run snyk-protect",
|
||||
"snyk-protect": "snyk-protect"
|
||||
},
|
||||
"dependencies": {
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "*",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/platform-constants": "workspace:*",
|
||||
"@calcom/platform-enums": "workspace:*",
|
||||
"@calcom/platform-libraries": "workspace:*",
|
||||
"@calcom/platform-types": "workspace:*",
|
||||
"@calcom/platform-utils": "workspace:*",
|
||||
"@calcom/prisma": "workspace:*",
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||
@@ -88,6 +89,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/eslint-config": "workspace:*",
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(ReassignBookingOutputData_2024_08_13)
|
||||
export class ReassignBookingOutput_2024_08_13 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
BookingOutput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
|
||||
+12
-108
@@ -1,39 +1,29 @@
|
||||
import type { AppConfig } from "@/config/type";
|
||||
import { getEnv } from "@/env";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
||||
import {
|
||||
PathItemObject,
|
||||
PathsObject,
|
||||
OperationObject,
|
||||
TagObject,
|
||||
} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
|
||||
import "dotenv/config";
|
||||
import * as fs from "fs";
|
||||
import { Server } from "http";
|
||||
import { WinstonModule } from "nest-winston";
|
||||
|
||||
import { bootstrap } from "./app";
|
||||
import { AppModule } from "./app.module";
|
||||
import { loggerConfig } from "./lib/logger";
|
||||
import { generateSwaggerForApp } from "./swagger/generate-swagger";
|
||||
|
||||
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
|
||||
|
||||
const run = async () => {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: WinstonModule.createLogger(loggerConfig()),
|
||||
bodyParser: false,
|
||||
});
|
||||
run().catch((error: Error) => {
|
||||
console.error("Failed to start Cal Platform API", { error: error.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const app = await createNestApp();
|
||||
const logger = new Logger("App");
|
||||
|
||||
try {
|
||||
bootstrap(app);
|
||||
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
|
||||
void generateSwagger(app);
|
||||
generateSwaggerForApp(app);
|
||||
await app.listen(port);
|
||||
logger.log(`Application started on port: ${port}`);
|
||||
} catch (error) {
|
||||
@@ -42,97 +32,11 @@ const run = async () => {
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function customTagSort(a: string, b: string): number {
|
||||
const platformPrefix = "Platform";
|
||||
const orgsPrefix = "Orgs";
|
||||
|
||||
if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function isOperationObject(obj: any): obj is OperationObject {
|
||||
return obj && typeof obj === "object" && "tags" in obj;
|
||||
}
|
||||
|
||||
function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject {
|
||||
const groupedPaths: { [key: string]: PathsObject } = {};
|
||||
|
||||
Object.keys(paths).forEach((pathKey) => {
|
||||
const pathItem = paths[pathKey];
|
||||
|
||||
HttpMethods.forEach((method) => {
|
||||
const operation = pathItem[method];
|
||||
|
||||
if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) {
|
||||
const firstTag = operation.tags[0];
|
||||
|
||||
if (!groupedPaths[firstTag]) {
|
||||
groupedPaths[firstTag] = {};
|
||||
}
|
||||
|
||||
groupedPaths[firstTag][pathKey] = pathItem;
|
||||
}
|
||||
});
|
||||
export async function createNestApp() {
|
||||
return NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: WinstonModule.createLogger(loggerConfig()),
|
||||
bodyParser: false,
|
||||
});
|
||||
|
||||
const sortedTags = Object.keys(groupedPaths).sort(customTagSort);
|
||||
const sortedPaths: PathsObject = {};
|
||||
|
||||
sortedTags.forEach((tag) => {
|
||||
Object.assign(sortedPaths, groupedPaths[tag]);
|
||||
});
|
||||
|
||||
return sortedPaths;
|
||||
}
|
||||
|
||||
async function generateSwagger(app: NestExpressApplication<Server>) {
|
||||
const logger = new Logger("App");
|
||||
logger.log(`Generating Swagger documentation...\n`);
|
||||
|
||||
const config = new DocumentBuilder().setTitle("Cal.com API v2").build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
document.paths = groupAndSortPathsByFirstTag(document.paths);
|
||||
|
||||
const swaggerOutputFile = "./swagger/documentation.json";
|
||||
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
|
||||
const stringifiedContents = JSON.stringify(document, null, 2);
|
||||
|
||||
if (fs.existsSync(swaggerOutputFile)) {
|
||||
fs.unlinkSync(swaggerOutputFile);
|
||||
}
|
||||
|
||||
fs.writeFileSync(swaggerOutputFile, stringifiedContents, { encoding: "utf8" });
|
||||
|
||||
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
|
||||
fs.unlinkSync(docsOutputFile);
|
||||
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
|
||||
}
|
||||
|
||||
if (!process.env.DOCS_URL) {
|
||||
SwaggerModule.setup("docs", app, document, {
|
||||
customCss: ".swagger-ui .topbar { display: none }",
|
||||
});
|
||||
|
||||
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error: Error) => {
|
||||
console.error("Failed to start Cal Platform API", { error: error.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import {
|
||||
NO_AUTH_PROVIDED_MESSAGE,
|
||||
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
|
||||
ONLY_CLIENT_SECRET_PROVIDED_MESSAGE,
|
||||
} from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
|
||||
export class OptionalApiAuthGuard extends ApiAuthGuard {
|
||||
@@ -9,11 +10,16 @@ export class OptionalApiAuthGuard extends ApiAuthGuard {
|
||||
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
|
||||
const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
|
||||
const onlyClientIdProvided = error && error.message.includes(ONLY_CLIENT_ID_PROVIDED_MESSAGE);
|
||||
const onlyClientSecretProvided = error && error.message.includes(ONLY_CLIENT_SECRET_PROVIDED_MESSAGE);
|
||||
|
||||
if (onlyClientIdProvided) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onlyClientSecretProvided) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (user || noAuthProvided || !error) {
|
||||
return user || null;
|
||||
} else {
|
||||
|
||||
@@ -32,7 +32,11 @@ import { randomString } from "test/utils/randomString";
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import type { PlatformOAuthClient, Team, User } from "@calcom/prisma/client";
|
||||
|
||||
import { ApiAuthGuardRequest, ONLY_CLIENT_ID_PROVIDED_MESSAGE } from "./api-auth.strategy";
|
||||
import {
|
||||
ApiAuthGuardRequest,
|
||||
ONLY_CLIENT_ID_PROVIDED_MESSAGE,
|
||||
ONLY_CLIENT_SECRET_PROVIDED_MESSAGE,
|
||||
} from "./api-auth.strategy";
|
||||
import { ApiAuthStrategy } from "./api-auth.strategy";
|
||||
|
||||
describe("ApiAuthStrategy", () => {
|
||||
@@ -267,6 +271,29 @@ describe("ApiAuthStrategy", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw 401 if only OAuth Client Secret is provided", async () => {
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({
|
||||
headers: {
|
||||
[X_CAL_SECRET_KEY]: `${oAuthClient.secret}gibberish`,
|
||||
},
|
||||
get: (key: string) => ({ origin: "http://localhost:3000" }[key]),
|
||||
}),
|
||||
}),
|
||||
} as ExecutionContext;
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
try {
|
||||
await strategy.authenticate(request);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException) {
|
||||
expect(error.getStatus()).toEqual(401);
|
||||
expect(error.message).toContain(ONLY_CLIENT_SECRET_PROVIDED_MESSAGE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should throw 401 if OAuth ID is invalid", async () => {
|
||||
const context: ExecutionContext = {
|
||||
switchToHttp: () => ({
|
||||
|
||||
@@ -29,11 +29,11 @@ export type ApiAuthGuardRequest = Request & {
|
||||
user: ApiAuthGuardUser;
|
||||
allowedAuthMethods?: AllowedAuthMethod[];
|
||||
};
|
||||
export const NO_AUTH_PROVIDED_MESSAGE =
|
||||
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
|
||||
export const NO_AUTH_PROVIDED_MESSAGE = `No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as '${X_CAL_SECRET_KEY}' and '${X_CAL_CLIENT_ID}' headers`;
|
||||
|
||||
export const ONLY_CLIENT_ID_PROVIDED_MESSAGE =
|
||||
"Only 'x-cal-client-id' header provided. Please also provide 'x-cal-secret-key' header or Auth bearer token as 'Authentication' header";
|
||||
export const ONLY_CLIENT_ID_PROVIDED_MESSAGE = `Only '${X_CAL_CLIENT_ID}' header provided. Please also provide '${X_CAL_SECRET_KEY}' header or Auth bearer token as 'Authentication' header`;
|
||||
|
||||
export const ONLY_CLIENT_SECRET_PROVIDED_MESSAGE = `Only '${X_CAL_SECRET_KEY}' header provided. Please also provide '${X_CAL_CLIENT_ID}' header or Auth bearer token as 'Authentication' header`;
|
||||
|
||||
@Injectable()
|
||||
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
||||
@@ -121,6 +121,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
|
||||
const noAuthProvided = !oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||
const onlyClientIdProvided = !!oAuthClientId && !oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||
const onlyClientSecretProvided =
|
||||
!oAuthClientId && !!oAuthClientSecret && !bearerToken && !nextAuthToken;
|
||||
|
||||
if (noAuthProvided) {
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||
}
|
||||
@@ -129,6 +132,10 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_ID_PROVIDED_MESSAGE}`);
|
||||
}
|
||||
|
||||
if (onlyClientSecretProvided) {
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${ONLY_CLIENT_SECRET_PROVIDED_MESSAGE}`);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(
|
||||
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${
|
||||
allowedMethods && allowedMethods.length > 0 ? allowedMethods.join(", ") : "Any supported method"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings
|
||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
|
||||
@@ -9,6 +10,7 @@ import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
@@ -18,6 +20,7 @@ import { Module } from "@nestjs/common";
|
||||
imports: [
|
||||
PrismaModule,
|
||||
StripeModule,
|
||||
RedisModule,
|
||||
MembershipsModule,
|
||||
OrganizationsModule,
|
||||
BullModule.registerQueue({
|
||||
@@ -32,6 +35,10 @@ import { Module } from "@nestjs/common";
|
||||
providers: [
|
||||
BillingConfigService,
|
||||
BillingService,
|
||||
{
|
||||
provide: "IBillingService",
|
||||
useClass: BillingServiceCachingProxy,
|
||||
},
|
||||
BillingRepository,
|
||||
BillingProcessor,
|
||||
ManagedOrganizationsBillingService,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
@@ -16,7 +17,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
import { withNextAuth } from "test/utils/withNextAuth";
|
||||
|
||||
import type { Team, PlatformBilling } from "@calcom/prisma/client";
|
||||
|
||||
@@ -31,9 +32,8 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
|
||||
let organization: Team;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
const moduleRef = await withNextAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
@@ -44,8 +44,10 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({
|
||||
name: `billing-organization-${randomString()}`,
|
||||
isPlatform: true,
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
@@ -87,6 +89,17 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("/billing/webhook (GET) should not get billing plan for org since it's not set yet", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/billing/${organization.id}/check`)
|
||||
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const data = res.body.data as CheckPlatformBillingResponseDto;
|
||||
expect(data?.plan).toEqual("FREE");
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/webhook (POST) should set billing free plan for org", () => {
|
||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||
() =>
|
||||
@@ -119,6 +132,17 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
expect(billing?.plan).toEqual("FREE");
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/webhook (GET) should get billing plan for org", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/billing/${organization.id}/check`)
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const data = res.body.data as CheckPlatformBillingResponseDto;
|
||||
expect(data?.plan).toEqual("FREE");
|
||||
});
|
||||
});
|
||||
|
||||
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
|
||||
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
|
||||
() =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles
|
||||
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
|
||||
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
|
||||
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import {
|
||||
Body,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
Delete,
|
||||
ParseIntPipe,
|
||||
@@ -40,7 +41,7 @@ export class BillingController {
|
||||
private logger = new Logger("Billing Controller");
|
||||
|
||||
constructor(
|
||||
private readonly billingService: BillingService,
|
||||
@Inject("IBillingService") private readonly billingService: IBillingService,
|
||||
public readonly stripeService: StripeService,
|
||||
private readonly configService: ConfigService<AppConfig>
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import type { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import Stripe from "stripe";
|
||||
import { PlatformBilling, Team } from "@calcom/prisma/client";
|
||||
|
||||
export type BillingData = {
|
||||
team: (Team & { platformBilling: PlatformBilling | null }) | null;
|
||||
status: "valid" | "no_subscription" | "no_billing";
|
||||
plan: string;
|
||||
};
|
||||
|
||||
export interface IBillingService {
|
||||
getBillingData(teamId: number): Promise<BillingData>;
|
||||
createTeamBilling(teamId: number): Promise<string>;
|
||||
redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string): Promise<string>;
|
||||
updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string>;
|
||||
cancelTeamSubscription(teamId: number): Promise<void>;
|
||||
handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void>;
|
||||
handleStripePaymentSuccess(event: Stripe.Event): Promise<void>;
|
||||
handleStripePaymentFailed(event: Stripe.Event): Promise<void>;
|
||||
handleStripePaymentPastDue(event: Stripe.Event): Promise<void>;
|
||||
handleStripeCheckoutEvents(event: Stripe.Event): Promise<void>;
|
||||
handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void>;
|
||||
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null;
|
||||
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null;
|
||||
stripeService: StripeService;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { IBillingService, BillingData } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import Stripe from "stripe";
|
||||
|
||||
export const REDIS_BILLING_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:billing`;
|
||||
export const BILLING_CACHE_TTL_MS = 3_600_000; // 1 hour
|
||||
|
||||
@Injectable()
|
||||
export class BillingServiceCachingProxy implements IBillingService {
|
||||
constructor(private readonly billingService: BillingService, private readonly redisService: RedisService) {}
|
||||
|
||||
async getBillingData(teamId: number) {
|
||||
const cachedBillingData = await this.getBillingCache(teamId);
|
||||
if (cachedBillingData) {
|
||||
return cachedBillingData;
|
||||
}
|
||||
|
||||
const billingData = await this.billingService.getBillingData(teamId);
|
||||
await this.setBillingCache(teamId, billingData);
|
||||
return billingData;
|
||||
}
|
||||
|
||||
private async deleteBillingCache(teamId: number) {
|
||||
await this.redisService.del(REDIS_BILLING_CACHE_KEY(teamId));
|
||||
}
|
||||
|
||||
private async getBillingCache(teamId: number) {
|
||||
const cachedResult = await this.redisService.get<BillingData>(REDIS_BILLING_CACHE_KEY(teamId));
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
private async setBillingCache(teamId: number, billingData: BillingData): Promise<void> {
|
||||
await this.redisService.set<BillingData>(REDIS_BILLING_CACHE_KEY(teamId), billingData, {
|
||||
ttl: BILLING_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async createTeamBilling(teamId: number): Promise<string> {
|
||||
return this.billingService.createTeamBilling(teamId);
|
||||
}
|
||||
|
||||
async redirectToSubscribeCheckout(
|
||||
teamId: number,
|
||||
plan: PlatformPlan,
|
||||
customerId?: string
|
||||
): Promise<string> {
|
||||
return this.billingService.redirectToSubscribeCheckout(teamId, plan, customerId);
|
||||
}
|
||||
|
||||
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string> {
|
||||
return this.billingService.updateSubscriptionForTeam(teamId, plan);
|
||||
}
|
||||
|
||||
async cancelTeamSubscription(teamId: number): Promise<void> {
|
||||
await this.billingService.cancelTeamSubscription(teamId);
|
||||
await this.deleteBillingCache(teamId);
|
||||
}
|
||||
|
||||
async handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void> {
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const teamId = subscription?.metadata?.teamId;
|
||||
if (teamId) {
|
||||
await this.deleteBillingCache(Number.parseInt(teamId));
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentSuccess(event: Stripe.Event): Promise<void> {
|
||||
await this.billingService.handleStripePaymentSuccess(event);
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
if (subscriptionId) {
|
||||
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
|
||||
subscriptionId
|
||||
);
|
||||
if (teamBilling?.id) {
|
||||
await this.deleteBillingCache(teamBilling.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentFailed(event: Stripe.Event): Promise<void> {
|
||||
await this.billingService.handleStripePaymentFailed(event);
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
|
||||
if (subscriptionId) {
|
||||
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
|
||||
subscriptionId
|
||||
);
|
||||
if (teamBilling?.id) {
|
||||
await this.deleteBillingCache(teamBilling.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripePaymentPastDue(event: Stripe.Event): Promise<void> {
|
||||
await this.billingService.handleStripePaymentPastDue(event);
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const subscriptionId = subscription.id;
|
||||
if (subscriptionId) {
|
||||
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
|
||||
subscriptionId
|
||||
);
|
||||
if (teamBilling?.id) {
|
||||
await this.deleteBillingCache(teamBilling.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripeCheckoutEvents(event: Stripe.Event): Promise<void> {
|
||||
await this.billingService.handleStripeCheckoutEvents(event);
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||
const teamId = checkoutSession.metadata?.teamId;
|
||||
if (teamId) {
|
||||
await this.deleteBillingCache(Number.parseInt(teamId));
|
||||
}
|
||||
}
|
||||
|
||||
async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void> {
|
||||
return this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||
}
|
||||
|
||||
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||
return this.billingService.getSubscriptionIdFromInvoice(invoice);
|
||||
}
|
||||
|
||||
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||
return this.billingService.getCustomerIdFromInvoice(invoice);
|
||||
}
|
||||
|
||||
get stripeService() {
|
||||
return this.billingService.stripeService;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { AppConfig } from "@/config/type";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
|
||||
import { PlatformPlan } from "@/modules/billing/types";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
@@ -23,14 +24,14 @@ import { DateTime } from "luxon";
|
||||
import Stripe from "stripe";
|
||||
|
||||
@Injectable()
|
||||
export class BillingService implements OnModuleDestroy {
|
||||
export class BillingService implements IBillingService, OnModuleDestroy {
|
||||
private logger = new Logger("BillingService");
|
||||
private readonly webAppUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly teamsRepository: OrganizationsRepository,
|
||||
public readonly stripeService: StripeService,
|
||||
private readonly billingRepository: BillingRepository,
|
||||
public readonly billingRepository: BillingRepository,
|
||||
private readonly configService: ConfigService<AppConfig>,
|
||||
private readonly billingConfigService: BillingConfigService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
@@ -43,18 +44,23 @@ export class BillingService implements OnModuleDestroy {
|
||||
|
||||
async getBillingData(teamId: number) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
|
||||
if (teamWithBilling?.platformBilling) {
|
||||
if (!teamWithBilling?.platformBilling.subscriptionId) {
|
||||
return { team: teamWithBilling, status: "no_subscription", plan: "none" };
|
||||
return { team: teamWithBilling, status: "no_subscription" as const, plan: "none" };
|
||||
} else {
|
||||
return {
|
||||
team: teamWithBilling,
|
||||
status: "valid" as const,
|
||||
plan: teamWithBilling.platformBilling.plan,
|
||||
};
|
||||
}
|
||||
|
||||
return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan };
|
||||
} else {
|
||||
return { team: teamWithBilling, status: "no_billing", plan: "none" };
|
||||
return { team: teamWithBilling, status: "no_billing" as const, plan: "none" };
|
||||
}
|
||||
}
|
||||
|
||||
async createTeamBilling(teamId: number) {
|
||||
async createTeamBilling(teamId: number): Promise<string> {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
let customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
@@ -67,7 +73,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
return customerId;
|
||||
return customerId!;
|
||||
}
|
||||
|
||||
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
|
||||
|
||||
+7
-1
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsISO8601, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
@@ -297,6 +297,12 @@ export class DateTimeWithZone {
|
||||
timeZone!: string;
|
||||
}
|
||||
|
||||
@ApiExtraModels(
|
||||
CalendarEventVideoLocation,
|
||||
CalendarEventPhoneLocation,
|
||||
CalendarEventSipLocation,
|
||||
CalendarEventMoreLocation
|
||||
)
|
||||
export class UnifiedCalendarEventOutput {
|
||||
@ValidateNested()
|
||||
@Type(() => DateTimeWithZone)
|
||||
|
||||
+3
-1
@@ -4,9 +4,11 @@ import {
|
||||
MicrosoftServiceAccountKeyInput,
|
||||
} from "@/modules/organizations/delegation-credentials/inputs/service-account-key.input";
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, IsNotEmpty, ValidateNested, Validate } from "class-validator";
|
||||
import { IsString, IsNotEmpty, Validate } from "class-validator";
|
||||
|
||||
@ApiExtraModels(GoogleServiceAccountKeyInput, MicrosoftServiceAccountKeyInput)
|
||||
export class CreateDelegationCredentialInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
+4
-3
@@ -3,10 +3,11 @@ import {
|
||||
MicrosoftServiceAccountKeyInput,
|
||||
ServiceAccountKeyValidator,
|
||||
} from "@/modules/organizations/delegation-credentials/inputs/service-account-key.input";
|
||||
import { ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Expose, plainToClass, Transform, Type } from "class-transformer";
|
||||
import { IsBoolean, IsOptional, Validate, ValidateNested } from "class-validator";
|
||||
import { ApiExtraModels, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsBoolean, IsOptional, Validate } from "class-validator";
|
||||
|
||||
@ApiExtraModels(GoogleServiceAccountKeyInput, MicrosoftServiceAccountKeyInput)
|
||||
export class UpdateDelegationCredentialInput {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
|
||||
@@ -174,4 +174,14 @@ export class OrganizationsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamByPlatformBillingId(billingId: number) {
|
||||
return this.dbRead.prisma.team.findFirst({
|
||||
where: {
|
||||
platformBilling: {
|
||||
id: billingId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { OrganizationsTeamsController } from "@/modules/organizations/teams/inde
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service";
|
||||
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
|
||||
import { OrganizationsTeamsInviteController } from "@/modules/organizations/teams/invite/organizations-teams-invite.controller";
|
||||
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
|
||||
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
|
||||
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
|
||||
@@ -183,6 +184,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsMembershipsController,
|
||||
OrganizationsEventTypesController,
|
||||
OrganizationsTeamsMembershipsController,
|
||||
OrganizationsTeamsInviteController,
|
||||
OrganizationsAttributesController,
|
||||
OrganizationsAttributesOptionsController,
|
||||
OrganizationsWebhooksController,
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ export class ManagedOrganizationsRepository {
|
||||
managedOrganization: managedOrganizationFilter,
|
||||
};
|
||||
|
||||
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([
|
||||
const [totalItems, linkRows] = await Promise.all([
|
||||
this.dbRead.prisma.managedOrganization.count({ where }),
|
||||
this.dbRead.prisma.managedOrganization.findMany({
|
||||
where,
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
@@ -75,6 +75,7 @@ class Routing {
|
||||
crmOwnerRecordType?: string;
|
||||
}
|
||||
|
||||
@ApiExtraModels(SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04)
|
||||
export class CreateRoutingFormResponseOutputData {
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Teams Invite Endpoints", () => {
|
||||
describe("User Authentication - User is Org Team Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
let nonOrgTeam: Team;
|
||||
|
||||
const userEmail = `organizations-teams-invite-admin-${randomString()}@api.com`;
|
||||
|
||||
let user: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
org = await organizationsRepositoryFixture.create({
|
||||
name: `organizations-teams-invite-organization-${randomString()}`,
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
orgTeam = await teamsRepositoryFixture.create({
|
||||
name: `organizations-teams-invite-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
nonOrgTeam = await teamsRepositoryFixture.create({
|
||||
name: `organizations-teams-invite-non-org-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
});
|
||||
|
||||
// Admin of the org team
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
|
||||
// Also a member of the organization
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should create a team invite", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data.token.length).toBeGreaterThan(0);
|
||||
expect(response.body.data.inviteLink).toEqual(expect.any(String));
|
||||
expect(response.body.data.inviteLink).toContain(response.body.data.token);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a new invite on each request", async () => {
|
||||
const first = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
|
||||
.expect(200);
|
||||
const firstToken = first.body.data.token as string;
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/invite`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data.token).not.toEqual(firstToken);
|
||||
expect(response.body.data.inviteLink).toEqual(expect.any(String));
|
||||
expect(response.body.data.inviteLink).toContain(response.body.data.token);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail for team not in organization", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/invite`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await teamsRepositoryFixture.delete(nonOrgTeam.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { OPTIONAL_API_KEY_HEADER, OPTIONAL_X_CAL_CLIENT_ID_HEADER, OPTIONAL_X_CAL_SECRET_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { Controller, UseGuards, Post, Param, ParseIntPipe, HttpCode, HttpStatus } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import { TeamService } from "@calcom/platform-libraries";
|
||||
|
||||
|
||||
import { CreateInviteOutputDto } from "./outputs/invite.output";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Teams / Invite")
|
||||
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsTeamsInviteController {
|
||||
@Post("/invite")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@ApiOperation({ summary: "Create team invite link" })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async createInvite(
|
||||
@Param("orgId", ParseIntPipe) _orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<CreateInviteOutputDto> {
|
||||
const result = await TeamService.createInvite(teamId);
|
||||
return { status: SUCCESS_STATUS, data: result };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class InviteDataDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Unique invitation token for this team. Share this token with prospective members to allow them to join the team/organization.",
|
||||
example: "f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2",
|
||||
})
|
||||
token!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Complete invitation URL that can be shared with prospective members. Opens the signup page with the token and redirects to getting started after signup.",
|
||||
example: "http://app.cal.com/signup?token=f6a5c8b1d2e34c7f90a1b2c3d4e5f6a5b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2&callbackUrl=/getting-started",
|
||||
})
|
||||
inviteLink!: string;
|
||||
}
|
||||
|
||||
export class CreateInviteOutputDto {
|
||||
@ApiProperty({ example: "success" })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ type: InviteDataDto })
|
||||
data!: InviteDataDto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(TeamEventTypeOutput_2024_06_14)
|
||||
export class CreateTeamEventTypeOutput extends ApiResponseWithoutData {
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(TeamEventTypeOutput_2024_06_14)
|
||||
export class UpdateTeamEventTypeOutput extends ApiResponseWithoutData {
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsEnum, ValidateNested, IsString, IsUrl } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { TeamOutputDto } from "@calcom/platform-types";
|
||||
|
||||
class Output {
|
||||
class CreateTeamOutputData {
|
||||
@Expose()
|
||||
@IsString()
|
||||
message!: string;
|
||||
@@ -19,16 +19,17 @@ class Output {
|
||||
pendingTeam!: TeamOutputDto;
|
||||
}
|
||||
|
||||
@ApiExtraModels(TeamOutputDto, CreateTeamOutputData)
|
||||
export class CreateTeamOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
oneOf: [{ $ref: getSchemaPath(Output) }, { $ref: getSchemaPath(TeamOutputDto) }],
|
||||
oneOf: [{ $ref: getSchemaPath(CreateTeamOutputData) }, { $ref: getSchemaPath(TeamOutputDto) }],
|
||||
description: "Either an Output object or a TeamOutputDto.",
|
||||
})
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
data!: Output | TeamOutputDto;
|
||||
data!: CreateTeamOutputData | TeamOutputDto;
|
||||
}
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ import * as path from "path";
|
||||
// "nest-cli" with the "nest-cli.json" file, and for nest cli to be loaded with plugins correctly the "@nestjs/swagger"
|
||||
// should reside in the project's node_modules already before the "nest start" command is executed.
|
||||
async function copyNestSwagger() {
|
||||
const monorepoRoot = path.resolve(__dirname, "../../../../");
|
||||
const nodeModulesNestjs = path.resolve(__dirname, "../node_modules/@nestjs");
|
||||
const monorepoRoot = path.resolve(__dirname, "../../../../../");
|
||||
const nodeModulesNestjs = path.resolve(__dirname, "../../node_modules/@nestjs");
|
||||
const swaggerModulePath = "@nestjs/swagger";
|
||||
|
||||
const sourceDir = path.join(monorepoRoot, "node_modules", swaggerModulePath);
|
||||
@@ -0,0 +1,29 @@
|
||||
import "dotenv/config";
|
||||
|
||||
import { bootstrap } from "../app";
|
||||
import { createNestApp } from "../main";
|
||||
import { generateSwaggerForApp } from "../swagger/generate-swagger";
|
||||
|
||||
generateSwagger()
|
||||
.then(() => {
|
||||
console.log("✅ Swagger generation completed successfully");
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
console.error("❌ Failed to generate swagger", { error: error.stack });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function generateSwagger() {
|
||||
const app = await createNestApp();
|
||||
|
||||
try {
|
||||
bootstrap(app);
|
||||
await generateSwaggerForApp(app);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
||||
import {
|
||||
PathItemObject,
|
||||
PathsObject,
|
||||
OperationObject,
|
||||
} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
|
||||
import "dotenv/config";
|
||||
import * as fs from "fs";
|
||||
import { Server } from "http";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
|
||||
|
||||
export async function generateSwaggerForApp(app: NestExpressApplication<Server>) {
|
||||
const logger = new Logger("App");
|
||||
logger.log(`Generating Swagger documentation...\n`);
|
||||
|
||||
const config = new DocumentBuilder().setTitle("Cal.com API v2").build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
document.paths = groupAndSortPathsByFirstTag(document.paths);
|
||||
|
||||
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
|
||||
const stringifiedContents = JSON.stringify(document, null, 2);
|
||||
|
||||
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
|
||||
fs.unlinkSync(docsOutputFile);
|
||||
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
|
||||
spawnSync("npx", ["prettier", docsOutputFile, "--write"], { stdio: "inherit" });
|
||||
}
|
||||
|
||||
if (!process.env.DOCS_URL) {
|
||||
SwaggerModule.setup("docs", app, document, {
|
||||
customCss: ".swagger-ui .topbar { display: none }",
|
||||
});
|
||||
|
||||
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject {
|
||||
const groupedPaths: { [key: string]: PathsObject } = {};
|
||||
|
||||
Object.keys(paths).forEach((pathKey) => {
|
||||
const pathItem = paths[pathKey];
|
||||
|
||||
HttpMethods.forEach((method) => {
|
||||
const operation = pathItem[method];
|
||||
|
||||
if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) {
|
||||
const firstTag = operation.tags[0];
|
||||
|
||||
if (!groupedPaths[firstTag]) {
|
||||
groupedPaths[firstTag] = {};
|
||||
}
|
||||
|
||||
groupedPaths[firstTag][pathKey] = pathItem;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sortedTags = Object.keys(groupedPaths).sort(customTagSort);
|
||||
const sortedPaths: PathsObject = {};
|
||||
|
||||
sortedTags.forEach((tag) => {
|
||||
Object.assign(sortedPaths, groupedPaths[tag]);
|
||||
});
|
||||
|
||||
return sortedPaths;
|
||||
}
|
||||
|
||||
function customTagSort(a: string, b: string): number {
|
||||
const platformPrefix = "Platform";
|
||||
const orgsPrefix = "Orgs";
|
||||
|
||||
if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) {
|
||||
return -1;
|
||||
}
|
||||
if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function isOperationObject(obj: any): obj is OperationObject {
|
||||
return obj && typeof obj === "object" && "tags" in obj;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ export class PlatformBillingRepositoryFixture {
|
||||
id: orgId,
|
||||
customerId: `cus_123_${randomString}`,
|
||||
subscriptionId: `sub_123_${randomString}`,
|
||||
plan: plan || "STARTER",
|
||||
plan: plan || "FREE",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { nextJsConfig } from "@calcom/eslint-config/next-js";
|
||||
|
||||
export default nextJsConfig;
|
||||
@@ -6,12 +6,12 @@
|
||||
"dev": "next dev --turbopack --port=1337",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/config": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/ui": "*",
|
||||
"@calcom/config": "workspace:*",
|
||||
"@calcom/lib": "workspace:*",
|
||||
"@calcom/ui": "workspace:*",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"airtable": "^0.12.2",
|
||||
"fumadocs-core": "^14.5.4",
|
||||
@@ -24,14 +24,12 @@
|
||||
"react-hot-toast": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/tsconfig": "*",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@calcom/eslint-config": "workspace:*",
|
||||
"@calcom/tsconfig": "workspace:*",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.9.0-beta"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
public/embed
|
||||
@@ -6,6 +6,8 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -33,9 +35,30 @@ const Page = async ({ params }: PageProps) => {
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
let canReadOthersBookings = false;
|
||||
if (session?.user?.id) {
|
||||
const permissionService = new PermissionCheckService();
|
||||
const userId = session.user.id;
|
||||
|
||||
const teamIdsWithPermission = await permissionService.getTeamIdsWithPermission({
|
||||
userId,
|
||||
permission: "booking.read",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
// We check if teamIdsWithPermission.length > 0.
|
||||
// While this may not be entirely accurate, it's acceptable
|
||||
// because we perform a thorough validation on the server side for the actual filter values.
|
||||
// This variable is primarily for UI purposes.
|
||||
canReadOthersBookings = teamIdsWithPermission.length > 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<BookingsList status={parsed.data.status} userId={session?.user?.id} />
|
||||
<BookingsList
|
||||
status={parsed.data.status}
|
||||
userId={session?.user?.id}
|
||||
permissions={{ canReadOthersBookings }}
|
||||
/>
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export const TeamsCTA = () => {
|
||||
data-testid="new-team-btn"
|
||||
variant="fab"
|
||||
StartIcon="plus"
|
||||
size="sm"
|
||||
type="button"
|
||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||
{t("new")}
|
||||
|
||||
+33
-18
@@ -101,10 +101,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
name: "privacy",
|
||||
href: "/settings/organizations/privacy",
|
||||
},
|
||||
{
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
},
|
||||
|
||||
{ name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" },
|
||||
{
|
||||
name: "SSO",
|
||||
@@ -173,17 +170,11 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
// The following keys are assigned to admin only
|
||||
const adminRequiredKeys = ["admin"];
|
||||
const organizationRequiredKeys = ["organization"];
|
||||
const organizationAdminKeys = [
|
||||
"privacy",
|
||||
"billing",
|
||||
"OAuth Clients",
|
||||
"SSO",
|
||||
"directory_sync",
|
||||
"delegation_credential",
|
||||
];
|
||||
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
|
||||
|
||||
export interface SettingsPermissions {
|
||||
canViewRoles?: boolean;
|
||||
canViewOrganizationBilling?: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
// This prevents showing the menu item when user has no organization permissions
|
||||
if (isPbacEnabled && permissions?.canViewRoles) {
|
||||
newArray.push({
|
||||
name: "roles_and_permissions",
|
||||
href: "/settings/organizations/roles",
|
||||
});
|
||||
if (isPbacEnabled) {
|
||||
if (permissions?.canViewRoles) {
|
||||
newArray.push({
|
||||
name: "roles_and_permissions",
|
||||
href: "/settings/organizations/roles",
|
||||
});
|
||||
}
|
||||
|
||||
if (permissions?.canViewOrganizationBilling) {
|
||||
newArray.push({
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (isOrgAdminOrOwner) {
|
||||
newArray.push({
|
||||
name: "billing",
|
||||
href: "/settings/organizations/billing",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -272,7 +279,7 @@ const useTabs = ({
|
||||
if (isAdmin) return true;
|
||||
return !adminRequiredKeys.includes(tab.name);
|
||||
});
|
||||
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled]);
|
||||
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user, isDelegationCredentialEnabled, isPbacEnabled, permissions]);
|
||||
|
||||
return processTabsMemod;
|
||||
};
|
||||
@@ -431,6 +438,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("profile")}
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
)}
|
||||
@@ -438,6 +446,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("members")}
|
||||
href={`/settings/teams/${team.id}/members`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
{/* Show roles only for sub-teams with PBAC-enabled parent */}
|
||||
@@ -458,6 +467,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("appearance")}
|
||||
href={`/settings/teams/${team.id}/appearance`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
{/* Hide if there is a parent ID */}
|
||||
@@ -467,6 +477,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("billing")}
|
||||
href={`/settings/teams/${team.id}/billing`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
</>
|
||||
@@ -475,6 +486,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
||||
name={t("settings")}
|
||||
href={`/settings/teams/${team.id}/settings`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
</>
|
||||
@@ -636,6 +648,7 @@ const SettingsSidebarContainer = ({
|
||||
name={t("add_a_team")}
|
||||
href={`${WEBAPP_URL}/settings/teams/new`}
|
||||
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
icon="plus"
|
||||
disableChevron
|
||||
/>
|
||||
@@ -737,12 +750,14 @@ const SettingsSidebarContainer = ({
|
||||
name={t("profile")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
<VerticalTabItem
|
||||
name={t("members")}
|
||||
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
className="me-5 h-7 w-auto !px-2"
|
||||
disableChevron
|
||||
/>
|
||||
<>
|
||||
|
||||
@@ -21,7 +21,7 @@ const Page = async () => {
|
||||
<SettingsHeader
|
||||
title={t("billing")}
|
||||
description={t("manage_billing_description")}
|
||||
borderInShellHeader={true}>
|
||||
borderInShellHeader={false}>
|
||||
<BillingView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,12 @@ import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { TeamFeatures } from "@calcom/features/flags/config";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
|
||||
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { Resource, CrudAction, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
@@ -45,13 +46,16 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
|
||||
let teamFeatures: Record<number, TeamFeatures> | null = null;
|
||||
let canViewRoles = false;
|
||||
let canViewOrganizationBilling = false;
|
||||
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
||||
|
||||
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
|
||||
if (orgId) {
|
||||
const [features, rolePermissions] = await Promise.all([
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session.user.org?.role);
|
||||
const [features, rolePermissions, organizationPermissions] = await Promise.all([
|
||||
getTeamFeatures(orgId),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Role),
|
||||
getCachedResourcePermissions(userId, orgId, Resource.Organization),
|
||||
]);
|
||||
|
||||
if (features) {
|
||||
@@ -62,6 +66,8 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
// Check if user has permission to read roles
|
||||
const roleActions = PermissionMapper.toActionMap(rolePermissions, Resource.Role);
|
||||
canViewRoles = roleActions[CrudAction.Read] ?? false;
|
||||
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
|
||||
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +76,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
|
||||
<SettingsLayoutAppDirClient
|
||||
{...props}
|
||||
teamFeatures={teamFeatures ?? {}}
|
||||
permissions={{ canViewRoles }}
|
||||
permissions={{ canViewRoles, canViewOrganizationBilling }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+6
-3
@@ -2,10 +2,11 @@ import { _generateMetadata } from "app/_utils";
|
||||
import { getTranslate } from "app/_utils";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import BillingView from "~/settings/billing/billing-view";
|
||||
|
||||
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
|
||||
import { validateUserHasOrgPerms } from "../../actions/validateUserHasOrgPerms";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -18,9 +19,11 @@ export const generateMetadata = async () =>
|
||||
|
||||
const Page = async () => {
|
||||
const t = await getTranslate();
|
||||
await validateUserHasOrgAdmin();
|
||||
|
||||
// TODO(SEAN): Add PBAC to this page in the next PR
|
||||
await validateUserHasOrgPerms({
|
||||
permission: "organization.manageBilling",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
|
||||
-20
@@ -1,24 +1,4 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const userProfile = session?.user?.profile;
|
||||
const userId = session?.user?.id;
|
||||
const orgRole =
|
||||
session?.user?.org?.role ??
|
||||
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
|
||||
|
||||
if (!isOrgAdminOrOwner) {
|
||||
return redirect("/settings/organizations/profile");
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { validateUserHasOrg } from "./validateUserHasOrg";
|
||||
|
||||
export const validateUserHasOrgPerms = async ({
|
||||
redirectTo,
|
||||
fallbackRoles,
|
||||
permission,
|
||||
}: {
|
||||
redirectTo?: string;
|
||||
permission: PermissionString;
|
||||
fallbackRoles: MembershipRole[];
|
||||
}) => {
|
||||
const session = await validateUserHasOrg();
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: session.user.id,
|
||||
teamId: session.user.org.id,
|
||||
permission,
|
||||
fallbackRoles,
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
redirect(redirectTo || "/settings/my-account/profile");
|
||||
}
|
||||
|
||||
return session;
|
||||
};
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import CreateTeamEventType, { LayoutWrapper } from "~/settings/teams/[id]/event-types-view";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { CreateTeamEventType, LayoutWrapper } from "~/settings/teams/[id]/event-types-view";
|
||||
|
||||
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
|
||||
await _generateMetadata(
|
||||
@@ -11,10 +21,34 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
`/settings/teams/${(await params).id}/event-type`
|
||||
);
|
||||
|
||||
const ServerPage = async () => {
|
||||
const querySchema = z.object({
|
||||
id: z.coerce.number().refine((val) => !Number.isNaN(val), {
|
||||
message: "id must be a string that can be cast to a number",
|
||||
}),
|
||||
});
|
||||
|
||||
const ServerPage = async ({ params }: ServerPageProps) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const parsed = querySchema.safeParse(await params);
|
||||
if (!parsed.success) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const permissionService = new PermissionCheckService();
|
||||
const canCreateEventType = await permissionService.checkPermission({
|
||||
userId: session.user.id,
|
||||
teamId: parsed.data.id,
|
||||
permission: "eventType.create",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<CreateTeamEventType />
|
||||
<CreateTeamEventType permissions={{ canCreateEventType }} />
|
||||
</LayoutWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,13 +3,15 @@ import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans";
|
||||
import type { Contact } from "@calcom/features/ee/support/lib/intercom/intercom";
|
||||
import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -43,6 +45,11 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { user } = session;
|
||||
|
||||
const membershipRepository = new MembershipRepository(prisma);
|
||||
const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId: user.id });
|
||||
const billingPlanService = new BillingPlanService();
|
||||
const plan = await billingPlanService.getUserPlanByMemberships(memberships);
|
||||
|
||||
if (!existingContact.data) {
|
||||
const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id });
|
||||
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
|
||||
@@ -72,6 +79,7 @@ export async function POST(req: NextRequest) {
|
||||
sum_of_teams: additionalUserInfo?._count?.teams,
|
||||
sum_of_event_types: additionalUserInfo?._count?.eventTypes,
|
||||
sum_of_team_event_types: sumOfTeamEventTypes,
|
||||
Plan: plan,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -213,7 +213,6 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
|
||||
);
|
||||
|
||||
const [submit, setSubmit] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const allUpdated = updatedEventTypesStatus.every((item) => item.every((iitem) => iitem.updated));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -223,11 +222,7 @@ const ConfigureStepCardContent: FC<ConfigureStepCardProps> = (props) => {
|
||||
}
|
||||
}, [submit, allUpdated, mainForSubmitRef]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!formPortalRef?.current || !mounted) {
|
||||
if (!formPortalRef?.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
|
||||
|
||||
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
|
||||
|
||||
const isAttendee = !!userSeat;
|
||||
|
||||
const paymentAppData = getPaymentAppData(booking.eventType);
|
||||
|
||||
const location = booking.location as ReturnType<typeof getEventLocationValue>;
|
||||
@@ -223,10 +227,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
|
||||
const getSeatReferenceUid = () => {
|
||||
if (!booking.seatsReferences[0]) {
|
||||
return undefined;
|
||||
}
|
||||
return booking.seatsReferences[0].referenceUid;
|
||||
return userSeat?.referenceUid;
|
||||
};
|
||||
|
||||
const actionContext: BookingActionContext = {
|
||||
@@ -250,6 +251,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
booking.location === "integrations:daily" ||
|
||||
(typeof booking.location === "string" && booking.location.trim() === ""),
|
||||
showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid,
|
||||
isAttendee,
|
||||
cardCharged,
|
||||
attendeeList,
|
||||
getSeatReferenceUid,
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface BookingActionContext {
|
||||
isDisabledRescheduling: boolean;
|
||||
isCalVideoLocation: boolean;
|
||||
showPendingPayment: boolean;
|
||||
isAttendee: boolean;
|
||||
cardCharged: boolean;
|
||||
attendeeList: Array<{
|
||||
name: string;
|
||||
@@ -102,6 +103,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
isDisabledRescheduling,
|
||||
isBookingFromRoutingForm,
|
||||
getSeatReferenceUid,
|
||||
isAttendee,
|
||||
t,
|
||||
} = context;
|
||||
|
||||
@@ -111,7 +113,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
|
||||
icon: "clock",
|
||||
label: t("reschedule_booking"),
|
||||
href: `/reschedule/${booking.uid}${
|
||||
booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||
booking.seatsReferences.length && isAttendee ? `?seatReferenceUid=${getSeatReferenceUid()}` : ""
|
||||
}`,
|
||||
disabled:
|
||||
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("EditLocationDialog", () => {
|
||||
},
|
||||
{
|
||||
value: "integrations:daily",
|
||||
label: "Cal Video (Global)",
|
||||
label: "Cal Video (Default)",
|
||||
disabled: false,
|
||||
icon: "/app-store/dailyvideo/icon.svg",
|
||||
slug: "daily-video",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { nextJsConfig } from "@calcom/eslint-config/next-js";
|
||||
|
||||
export default [...nextJsConfig];
|
||||
@@ -5,8 +5,8 @@ import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { determineReschedulePreventionRedirect } from "@calcom/features/bookings/lib/reschedule/determineReschedulePreventionRedirect";
|
||||
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
||||
import { getDefaultEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||
import { buildEventUrlFromBooking } from "@calcom/lib/bookings/buildEventUrlFromBooking";
|
||||
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
|
||||
@@ -39,7 +39,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
uid,
|
||||
seatReferenceUid: maybeSeatReferenceUid,
|
||||
bookingSeat,
|
||||
} = await maybeGetBookingUidFromSeat(prisma, bookingUid);
|
||||
} = await maybeGetBookingUidFromSeat(prisma, seatReferenceUid ? seatReferenceUid : bookingUid);
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -310,7 +310,6 @@ const OnboardingPage = ({
|
||||
/>
|
||||
)}
|
||||
{currentStep === AppOnboardingSteps.CONFIGURE_STEP &&
|
||||
formPortalRef.current &&
|
||||
eventTypeGroups && (
|
||||
<ConfigureStepCard
|
||||
slug={appMetadata.slug}
|
||||
|
||||
@@ -57,6 +57,9 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
type BookingsProps = {
|
||||
status: (typeof validStatuses)[number];
|
||||
userId?: number;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function useSystemSegments(userId?: number) {
|
||||
@@ -111,7 +114,7 @@ type RowData =
|
||||
type: "today" | "next";
|
||||
};
|
||||
|
||||
function BookingsContent({ status }: BookingsProps) {
|
||||
function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -154,7 +157,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
|
||||
"data-testid": tabConfig["data-testid"],
|
||||
}));
|
||||
}, [searchParams?.toString()]);
|
||||
}, [searchParams]);
|
||||
|
||||
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
@@ -215,7 +218,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: user?.isTeamAdminOrOwner ?? false,
|
||||
enableColumnFilter: permissions.canReadOthersBookings,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
@@ -314,7 +317,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [user, status, t]);
|
||||
}, [user, status, t, permissions.canReadOthersBookings]);
|
||||
|
||||
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
|
||||
|
||||
@@ -352,7 +355,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
isToday: false,
|
||||
})) || []
|
||||
);
|
||||
}, [query.data]);
|
||||
}, [query.data, status, user?.timeZone]);
|
||||
|
||||
const bookingsToday = useMemo<RowData[]>(() => {
|
||||
return (
|
||||
@@ -371,7 +374,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
isToday: true,
|
||||
})) ?? []
|
||||
);
|
||||
}, [query.data]);
|
||||
}, [query.data, user?.timeZone]);
|
||||
|
||||
const finalData = useMemo<RowData[]>(() => {
|
||||
if (status !== "upcoming") {
|
||||
|
||||
@@ -556,7 +556,9 @@ export default function Success(props: PageProps) {
|
||||
<div className="font-medium">
|
||||
{isCancelled ? t("reason") : t("reschedule_reason")}
|
||||
</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
<p className="break-words">{cancellationReason}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isCancelled && bookingInfo?.cancelledBy && (
|
||||
|
||||
@@ -12,7 +12,10 @@ import { useOrgBranding } from "@calcom/features/ee/organizations/context/provid
|
||||
import { CreateButton } from "@calcom/features/ee/teams/components/createButton/CreateButton";
|
||||
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
||||
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 { InfiniteSkeletonLoader } from "@calcom/features/eventtypes/components/SkeletonLoader";
|
||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
@@ -846,17 +849,7 @@ const CreateFirstEventTypeView = ({ slug, searchTerm }: { slug: string; searchTe
|
||||
);
|
||||
};
|
||||
|
||||
const CTA = ({
|
||||
profileOptions,
|
||||
}: {
|
||||
profileOptions: {
|
||||
teamId: number | null | undefined;
|
||||
label: string | null;
|
||||
image: string;
|
||||
membershipRole: MembershipRole | null | undefined;
|
||||
slug: string | null;
|
||||
}[];
|
||||
}) => {
|
||||
const CTA = ({ profileOptions }: { profileOptions: ProfileOption[] }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!profileOptions.length) return null;
|
||||
@@ -951,7 +944,7 @@ type Props = {
|
||||
|
||||
export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
|
||||
const profileOptions =
|
||||
userEventGroupsData?.profiles
|
||||
userEventGroupsData.profiles
|
||||
?.filter((profile) => !profile.readOnly)
|
||||
?.filter((profile) => !profile.eventTypesLockedByOrg)
|
||||
?.filter((profile) => {
|
||||
@@ -973,12 +966,20 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
|
||||
);
|
||||
})
|
||||
?.map((profile) => {
|
||||
const permissions = profile.teamId
|
||||
? userEventGroupsData.teamPermissions[profile.teamId]
|
||||
: {
|
||||
// always can create eventType on personal level
|
||||
canCreateEventType: true,
|
||||
};
|
||||
|
||||
return {
|
||||
teamId: profile.teamId,
|
||||
label: profile.name || profile.slug,
|
||||
image: profile.image,
|
||||
membershipRole: profile.membershipRole,
|
||||
slug: profile.slug,
|
||||
permissions,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
|
||||
@@ -30,16 +30,18 @@ declare global {
|
||||
export default function PlatformBillingUpgrade() {
|
||||
const pathname = usePathname();
|
||||
const { t } = useLocale();
|
||||
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, userBillingData, isPaidUser, userOrgId } =
|
||||
useGetUserAttributes();
|
||||
|
||||
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 () => {
|
||||
if (window.Support) {
|
||||
window.Support.open();
|
||||
}
|
||||
};
|
||||
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, userBillingData, isPaidUser, userOrgId } =
|
||||
useGetUserAttributes();
|
||||
|
||||
const { mutateAsync: removeTeamSubscription, isPending: isRemoveTeamSubscriptionLoading } =
|
||||
useUnsubscribeTeamToStripe({
|
||||
|
||||
@@ -8,11 +8,15 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { useCreateEventType } from "@calcom/lib/hooks/useCreateEventType";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { WizardLayout } from "@calcom/ui/components/layout";
|
||||
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 { t } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -53,7 +57,7 @@ export const CreateTeamEventType = () => {
|
||||
<TeamEventTypeForm
|
||||
teamSlug={team?.slug}
|
||||
teamId={teamId}
|
||||
isTeamAdminOrOwner={true}
|
||||
permissions={permissions}
|
||||
urlPrefix={urlPrefix}
|
||||
isPending={createMutation.isPending}
|
||||
form={form}
|
||||
@@ -82,5 +86,3 @@ export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
</WizardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTeamEventType;
|
||||
|
||||
@@ -426,6 +426,7 @@ export default function Signup({
|
||||
id="signup-email"
|
||||
{...register("email")}
|
||||
label={t("email")}
|
||||
placeholder="john@doe.com"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
disabled={prepopulateFormValues?.email}
|
||||
@@ -522,8 +523,8 @@ export default function Signup({
|
||||
usernameTaken
|
||||
}>
|
||||
{premiumUsername && !usernameTaken
|
||||
? `${t("create_account")} (${getPremiumPlanPriceValue()})`
|
||||
: t("create_account")}
|
||||
? `${t("get_started")} (${getPremiumPlanPriceValue()})`
|
||||
: t("get_started")}
|
||||
</Button>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||
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;
|
||||
}
|
||||
|
||||
export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMembersViewProps) => {
|
||||
const { t } = useLocale();
|
||||
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
|
||||
const isTeamAdminOrOwner = checkAdminOrOwner(team.membership.role);
|
||||
const canLoggedInUserSeeMembers = permissions?.canListMembers ?? (!team.isPrivate || isTeamAdminOrOwner);
|
||||
// Use PBAC permissions - server-side permission check should be done in parent component
|
||||
const canLoggedInUserSeeMembers = permissions?.canListMembers ?? false;
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
|
||||
+17
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.7.3",
|
||||
"version": "5.7.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -20,8 +20,8 @@
|
||||
"copy-app-store-static": "node scripts/copy-app-store-static.js",
|
||||
"build": "turbo run copy-app-store-static && next build && yarn sentry:release",
|
||||
"start": "next start",
|
||||
"lint": "eslint . --ignore-path .gitignore",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/web.json",
|
||||
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts",
|
||||
"translate-locales": "ts-node scripts/check-missing-translations.ts",
|
||||
@@ -32,20 +32,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "1.52.2",
|
||||
"@calcom/app-store": "*",
|
||||
"@calcom/app-store-cli": "*",
|
||||
"@calcom/dayjs": "*",
|
||||
"@calcom/app-store": "workspace:*",
|
||||
"@calcom/app-store-cli": "workspace:*",
|
||||
"@calcom/dayjs": "workspace:*",
|
||||
"@calcom/embed-core": "workspace:*",
|
||||
"@calcom/embed-react": "workspace:*",
|
||||
"@calcom/embed-snippet": "workspace:*",
|
||||
"@calcom/features": "*",
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/trpc": "*",
|
||||
"@calcom/tsconfig": "*",
|
||||
"@calcom/ui": "*",
|
||||
"@calcom/features": "workspace:*",
|
||||
"@calcom/lib": "workspace:*",
|
||||
"@calcom/platform-enums": "workspace:*",
|
||||
"@calcom/platform-types": "workspace:*",
|
||||
"@calcom/prisma": "workspace:*",
|
||||
"@calcom/trpc": "workspace:*",
|
||||
"@calcom/tsconfig": "workspace:*",
|
||||
"@calcom/ui": "workspace:*",
|
||||
"@daily-co/daily-js": "^0.83.1",
|
||||
"@daily-co/daily-react": "^0.23.2",
|
||||
"@dub/analytics": "^0.0.27",
|
||||
@@ -158,8 +158,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.6",
|
||||
"@calcom/config": "*",
|
||||
"@calcom/types": "*",
|
||||
"@calcom/config": "workspace:*",
|
||||
"@calcom/eslint-config": "workspace:*",
|
||||
"@calcom/types": "workspace:*",
|
||||
"@microsoft/microsoft-graph-types-beta": "0.15.0-preview",
|
||||
"@playwright/test": "^1.45.3",
|
||||
"@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);
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
@@ -60,7 +60,6 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
});
|
||||
|
||||
// To prevent early timeouts
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "inline",
|
||||
@@ -98,7 +97,6 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
});
|
||||
|
||||
// To prevent early timeouts
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "floating-popup",
|
||||
@@ -136,7 +134,6 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
});
|
||||
|
||||
// To prevent early timeouts
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "element-click",
|
||||
@@ -176,7 +173,6 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
});
|
||||
|
||||
// To prevent early timeouts
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "inline",
|
||||
@@ -234,7 +230,6 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
});
|
||||
|
||||
// To prevent early timeouts
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "inline",
|
||||
@@ -275,7 +270,6 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
});
|
||||
|
||||
// To prevent early timeouts
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "floating-popup",
|
||||
@@ -315,7 +309,6 @@ test.describe("Embed Code Generator Tests", () => {
|
||||
});
|
||||
|
||||
// To prevent early timeouts
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await expectToContainValidPreviewIframe(page, {
|
||||
embedType: "element-click",
|
||||
@@ -333,8 +326,7 @@ function chooseEmbedType(page: Page, embedType: EmbedType) {
|
||||
}
|
||||
|
||||
async function goToReactCodeTab(page: Page) {
|
||||
// To prevent early timeouts
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
// To prevent early timeo
|
||||
await page.waitForTimeout(1000);
|
||||
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 embedUrl = await embedButton.getAttribute("data-test-embed-url");
|
||||
embedButton.click();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
return embedUrl!;
|
||||
}
|
||||
|
||||
@@ -439,18 +431,24 @@ async function expectValidHtmlEmbedSnippet(
|
||||
}
|
||||
|
||||
function assertThatCodeIsValidVanillaJsCode(code: string) {
|
||||
const lintResult = linter.verify(code, {
|
||||
env: {
|
||||
browser: true,
|
||||
const lintResult = linter.verify(code, [
|
||||
{
|
||||
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) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
@@ -459,23 +457,35 @@ function assertThatCodeIsValidVanillaJsCode(code: string) {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
expect(lintResult.length).toBe(0);
|
||||
}
|
||||
|
||||
function assertThatCodeIsValidReactCode(code: string) {
|
||||
const lintResult = linter.verify(code, {
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 2021,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
const lintResult = linter.verify(code, [
|
||||
{
|
||||
languageOptions: {
|
||||
ecmaVersion: 2021,
|
||||
sourceType: "module",
|
||||
parserOptions: {
|
||||
ecmaFeatures: { 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) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
@@ -484,6 +494,7 @@ function assertThatCodeIsValidReactCode(code: string) {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
expect(lintResult.length).toBe(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ test.describe("Event Types tests", () => {
|
||||
await gotoFirstEventType(page);
|
||||
|
||||
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 gotoBookingPage(page);
|
||||
@@ -282,7 +282,7 @@ test.describe("Event Types tests", () => {
|
||||
await selectAttendeePhoneNumber(page);
|
||||
|
||||
// Add Cal Video location
|
||||
await addAnotherLocation(page, "Cal Video (Global)");
|
||||
await addAnotherLocation(page, "Cal Video (Default)");
|
||||
|
||||
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 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
|
||||
.getByTestId("event-interface-language")
|
||||
.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 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.locator(`text="Deutsch"`).click();
|
||||
await saveEventType(page);
|
||||
@@ -574,6 +580,9 @@ test.describe("Event Types tests", () => {
|
||||
await page.goto("/event-types");
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
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("select-option-es").click();
|
||||
await saveEventType(page);
|
||||
|
||||
@@ -198,7 +198,7 @@ export async function expectSegmentCleared(page: Page) {
|
||||
// Check that no segment is selected (button shows default text)
|
||||
const segmentSelect = page.getByTestId("filter-segment-select");
|
||||
const buttonText = await segmentSelect.textContent();
|
||||
expect(buttonText?.trim()).toEqual("Segment");
|
||||
expect(buttonText?.trim()).toEqual("Saved filters");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -199,6 +199,7 @@ export function createBookingPageFixture(page: Page) {
|
||||
await expect(eventTypePage.getByTestId("time")).toHaveCount(count);
|
||||
},
|
||||
getAITranslationToggleDisabled: async () => {
|
||||
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
|
||||
await page.waitForSelector('[data-testid="ai_translation_toggle"]', {
|
||||
timeout: 5000,
|
||||
state: "attached",
|
||||
@@ -207,6 +208,7 @@ export function createBookingPageFixture(page: Page) {
|
||||
return (await toggle.getAttribute("disabled")) !== null;
|
||||
},
|
||||
toggleAITranslation: async () => {
|
||||
await page.click("[data-testid=vertical-tab-event_advanced_tab_title]");
|
||||
await page.getByTestId("ai_translation_toggle").click();
|
||||
},
|
||||
updateEventTypeDescription: async (description: string) => {
|
||||
|
||||
@@ -81,10 +81,13 @@ test.describe("Managed Event Types", () => {
|
||||
await page.goto(`/event-types/${managedEvent.id}?tabName=setup`);
|
||||
await expect(page.getByTestId("vertical-tab-basics")).toHaveAttribute("aria-current", "page"); // fix the race condition
|
||||
await expect(page.getByTestId("vertical-tab-basics")).toContainText("Basics"); //fix the race condition
|
||||
|
||||
await page.locator("#location-select").click();
|
||||
const optionText = await getByKey(page, "organizer_default_conferencing_app");
|
||||
await expect(optionText).toBeVisible();
|
||||
await optionText.click();
|
||||
const targetText = (await localize("en"))("organizer_default_conferencing_app");
|
||||
const options = page.locator('[data-testid="location-select-item-conferencing"]');
|
||||
const targetOption = options.locator(`text=${targetText}`);
|
||||
await expect(targetOption).toBeVisible();
|
||||
await targetOption.click();
|
||||
await saveAndWaitForResponse(page);
|
||||
|
||||
await page.getByTestId("vertical-tab-assignment").click();
|
||||
@@ -190,7 +193,7 @@ test.describe("Managed Event Types", () => {
|
||||
});
|
||||
|
||||
const MANAGED_EVENT_TABS: { slug: string; locator: (page: Page) => Locator | Promise<Locator> }[] = [
|
||||
{ slug: "setup", locator: (page) => getByKey(page, "translate_description_button") },
|
||||
{ slug: "setup", locator: (page) => getByKey(page, "allow_multiple_durations") },
|
||||
{
|
||||
slug: "team",
|
||||
locator: (page) => getByKey(page, "automatically_add_all_team_members"),
|
||||
|
||||
@@ -209,7 +209,7 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
data: {
|
||||
identifier: userToCreate.email,
|
||||
token,
|
||||
expires: new Date(new Date().setHours(168)), // +1 week
|
||||
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +1 week
|
||||
team: {
|
||||
create: {
|
||||
name: "Rick's Team",
|
||||
|
||||
@@ -321,6 +321,7 @@
|
||||
"secret_meeting": "Secret Meeting",
|
||||
"login_instead": "Login instead",
|
||||
"create_account": "Create Account",
|
||||
"get_started": "Get started",
|
||||
"confirm_password": "Confirm password",
|
||||
"reset_your_password": "Set your new password with the instructions sent to your email address.",
|
||||
"org_banner_instructions": "Please upload image of {{width}} width and {{height}} height.",
|
||||
@@ -958,7 +959,7 @@
|
||||
"light_event_type_color": "Event Type Color (Light Theme)",
|
||||
"dark_event_type_color": "Event Type Color (Dark Theme)",
|
||||
"file_not_named": "File is not named [idOrSlug]/[user]",
|
||||
"create_team": "Create Team",
|
||||
"create_team": "Create team",
|
||||
"name": "Name",
|
||||
"nameless_team": "Nameless Team",
|
||||
"oauth_clients": "OAuth Clients",
|
||||
@@ -1019,6 +1020,7 @@
|
||||
"attendees": "Attendees",
|
||||
"add_attendees": "Add attendees",
|
||||
"show_advanced_settings": "Show advanced settings",
|
||||
"hide_advanced_settings": "Hide advanced settings",
|
||||
"event_name": "Event Name",
|
||||
"event_name_in_calendar": "Calendar event name",
|
||||
"event_name_tooltip": "The name that will appear in calendars",
|
||||
@@ -1231,7 +1233,7 @@
|
||||
"contact_sales": "Contact Sales",
|
||||
"error_404": "Error 404",
|
||||
"default": "Default",
|
||||
"set_to_default": "Set to Default",
|
||||
"set_to_default": "Set as Default",
|
||||
"new_schedule_btn": "New schedule",
|
||||
"add_new_schedule": "Add a new schedule",
|
||||
"add_new_calendar": "Add a new calendar",
|
||||
@@ -1329,7 +1331,7 @@
|
||||
"make_team_private_description": "Your team members won't be able to see other team members when this is turned on.",
|
||||
"you_cannot_see_team_members": "You cannot see all the team members of a private team.",
|
||||
"you_cannot_see_teams_of_org": "You cannot see teams of a private organization.",
|
||||
"allow_booker_to_select_duration": "Allow booker to select from multiple durations",
|
||||
"allow_multiple_durations": "Allow multiple durations",
|
||||
"impersonate_user_tip": "All uses of this feature is audited.",
|
||||
"impersonating_user_warning": "Impersonating username \"{{user}}\".",
|
||||
"impersonating_stop_instructions": "Click here to stop",
|
||||
@@ -2689,7 +2691,7 @@
|
||||
"enterprise": "Enterprise",
|
||||
"enterprise_description": "Upgrade to Enterprise to create your Organization",
|
||||
"create_your_org": "Create your Organization",
|
||||
"create_org": "Create Organization",
|
||||
"create_org": "Create organization",
|
||||
"create_your_org_description": "Upgrade to Organizations and receive a subdomain, unified billing, Insights, extensive whitelabeling and more",
|
||||
"create_your_enterprise_description": "Upgrade to Enterprise and get access to Active Directory Sync, SCIM Automatic User provisioning, Cal.ai Voice Agents, Admin APIs and more!",
|
||||
"other_payment_app_enabled": "You can only enable one payment app per event type",
|
||||
@@ -2923,6 +2925,7 @@
|
||||
"proceeding_agreement": "By proceeding, you agree to our",
|
||||
"terms": "Terms",
|
||||
"always_show_x_days": "Always {{x}} days available",
|
||||
"always_show_x_days_description": "Automatically moves the {{x}}-day booking window forward each day, ensuring your calendar always shows the next {{x}} days of available slots.",
|
||||
"team_subscription_cancelled_successfully": "Team subscription cancelled successfully",
|
||||
"team_subscription_cancellation_error": "Error while cancelling the team subscription",
|
||||
"unable_to_subscribe_to_the_platform": "An error occurred while trying to subscribe to the platform plan, please try again later",
|
||||
@@ -3144,7 +3147,8 @@
|
||||
"filter_operator_ends_with": "Ends with",
|
||||
"filter_operator_is_empty": "Is empty",
|
||||
"filter_operator_not_empty": "Not empty",
|
||||
"translate_description_button": "Translate title/description to the visitor's browser language using AI",
|
||||
"translate_description_button": "Auto translate title and description",
|
||||
"translate_description_button_description": "Automatically translate titles and descriptions to the visitor’s browser language using AI.",
|
||||
"rr_distribution_method": "Distribution",
|
||||
"rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.",
|
||||
"rr_distribution_method_availability_title": "Maximize availability",
|
||||
@@ -3310,6 +3314,7 @@
|
||||
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
|
||||
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
|
||||
"segment": "Segment",
|
||||
"saved_filters": "Saved filters",
|
||||
"rename": "Rename",
|
||||
"rename_segment": "Rename Segment",
|
||||
"delete_segment": "Delete Segment",
|
||||
@@ -3339,6 +3344,7 @@
|
||||
"picklist": "Picklist",
|
||||
"most_cancelled_bookings": "Most Cancelled",
|
||||
"interface_language": "Interface Language",
|
||||
"interface_language_description": "Set your preferred language for the booking interface",
|
||||
"visitors_browser_language": "Visitor's browser language",
|
||||
"name_or_email": "Name or Email",
|
||||
"salesforce_round_robin_skip_fallback_to_lead_owner": "If no contact is found, fallback to lead owner if it exists",
|
||||
@@ -3510,10 +3516,20 @@
|
||||
"pbac_desc_update_workflows": "Edit and modify workflow settings",
|
||||
"pbac_desc_delete_workflows": "Remove workflows from the system",
|
||||
"pbac_resource_webhook": "Webhook",
|
||||
"pbac_resource_availability": "Availability",
|
||||
"pbac_resource_out_of_office": "Out of Office",
|
||||
"pbac_desc_create_webhooks": "Create webhooks",
|
||||
"pbac_desc_view_webhooks": "View webhooks",
|
||||
"pbac_desc_update_webhooks": "Update webhooks",
|
||||
"pbac_desc_delete_webhooks": "Delete webhooks",
|
||||
"pbac_desc_create_availability": "Create availability",
|
||||
"pbac_desc_view_availability": "View availability",
|
||||
"pbac_desc_update_availability": "Update availability",
|
||||
"pbac_desc_delete_availability": "Delete availability",
|
||||
"pbac_desc_create_out_of_office": "Create out of office",
|
||||
"pbac_desc_view_out_of_office": "View out of office",
|
||||
"pbac_desc_update_out_of_office": "Update out of office",
|
||||
"pbac_desc_delete_out_of_office": "Delete out of office",
|
||||
"pbac_desc_manage_workflows": "Full management access to all workflows",
|
||||
"pbac_desc_create_event_types": "Create event types",
|
||||
"pbac_desc_view_event_types": "View event types",
|
||||
@@ -3561,8 +3577,8 @@
|
||||
"rr_load_balancing_disabled_with_groups": "Load Balancing is not available with Round Robin Groups",
|
||||
"round_robin_settings_description": "Customize the default round robin settings for this team",
|
||||
"round_robin_settings_updated_successfully": "Round Robin settings updated successfully",
|
||||
"booking_creation_time": "Booking Creation Time",
|
||||
"meeting_start_time": "Meeting Start Time",
|
||||
"booking_creation_time": "Booking creation time",
|
||||
"meeting_start_time": "Meeting start time",
|
||||
"members_affected_by_disabling_delegation_credential": "Affected members",
|
||||
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
|
||||
"download_expense_log": "Download Expense Log",
|
||||
|
||||
@@ -647,3 +647,8 @@ select:focus {
|
||||
[data-radix-popper-content-wrapper] {
|
||||
border: none;
|
||||
}
|
||||
|
||||
html[dir="rtl"] .react-tel-input .flag-dropdown {
|
||||
left: auto !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
@@ -9,6 +7,13 @@ import {
|
||||
} from "@calcom/lib/intervalLimits/server/checkDurationLimits";
|
||||
import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder";
|
||||
|
||||
const mockGetTotalBookingDuration = vi.fn();
|
||||
vi.mock("@calcom/lib/server/repository/booking", () => ({
|
||||
BookingRepository: vi.fn().mockImplementation(() => ({
|
||||
getTotalBookingDuration: mockGetTotalBookingDuration,
|
||||
})),
|
||||
}));
|
||||
|
||||
type MockData = {
|
||||
id: number;
|
||||
startDate: Date;
|
||||
@@ -22,19 +27,19 @@ const MOCK_DATA: MockData = {
|
||||
// Path: apps/web/test/lib/checkDurationLimits.ts
|
||||
describe("Check Duration Limits Tests", () => {
|
||||
it("Should return no errors if limit is not reached", async () => {
|
||||
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 0 }]);
|
||||
mockGetTotalBookingDuration.mockResolvedValue(0);
|
||||
await expect(
|
||||
checkDurationLimits({ PER_DAY: 60 }, MOCK_DATA.startDate, MOCK_DATA.id)
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
it("Should throw an error if limit is reached", async () => {
|
||||
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
|
||||
mockGetTotalBookingDuration.mockResolvedValue(60);
|
||||
await expect(
|
||||
checkDurationLimits({ PER_DAY: 60 }, MOCK_DATA.startDate, MOCK_DATA.id)
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
it("Should pass with multiple duration limits", async () => {
|
||||
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 30 }]);
|
||||
mockGetTotalBookingDuration.mockResolvedValue(30);
|
||||
await expect(
|
||||
checkDurationLimits(
|
||||
{
|
||||
@@ -47,7 +52,7 @@ describe("Check Duration Limits Tests", () => {
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
it("Should pass with multiple duration limits with one undefined", async () => {
|
||||
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 30 }]);
|
||||
mockGetTotalBookingDuration.mockResolvedValue(30);
|
||||
await expect(
|
||||
checkDurationLimits(
|
||||
{
|
||||
@@ -60,7 +65,7 @@ describe("Check Duration Limits Tests", () => {
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
it("Should return no errors if limit is not reached with multiple bookings", async () => {
|
||||
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
|
||||
mockGetTotalBookingDuration.mockResolvedValue(60);
|
||||
await expect(
|
||||
checkDurationLimits(
|
||||
{
|
||||
@@ -73,7 +78,7 @@ describe("Check Duration Limits Tests", () => {
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
it("Should throw an error if one of the limit is reached with multiple bookings", async () => {
|
||||
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 90 }]);
|
||||
mockGetTotalBookingDuration.mockResolvedValue(90);
|
||||
await expect(
|
||||
checkDurationLimits(
|
||||
{
|
||||
@@ -90,7 +95,7 @@ describe("Check Duration Limits Tests", () => {
|
||||
// Path: apps/web/test/lib/checkDurationLimits.ts
|
||||
describe("Check Duration Limit Tests", () => {
|
||||
it("Should return no busyTimes and no error if limit is not reached", async () => {
|
||||
prismaMock.$queryRaw.mockResolvedValue([{ totalMinutes: 60 }]);
|
||||
mockGetTotalBookingDuration.mockResolvedValue(60);
|
||||
await expect(
|
||||
checkDurationLimit({
|
||||
key: "PER_DAY",
|
||||
|
||||
@@ -143,9 +143,10 @@ describe("handleChildrenEventTypes", () => {
|
||||
profileId: null,
|
||||
updatedValues: {},
|
||||
});
|
||||
const { createdAt, updatedAt, ...expectedEvType } = evType;
|
||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...evType,
|
||||
...expectedEvType,
|
||||
parentId: 1,
|
||||
users: { connect: [{ id: 4 }] },
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
@@ -206,7 +207,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
bookingLimits: undefined,
|
||||
},
|
||||
});
|
||||
const { profileId, autoTranslateDescriptionEnabled, ...rest } = evType;
|
||||
const { profileId, autoTranslateDescriptionEnabled, createdAt, updatedAt, ...rest } = evType;
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...rest,
|
||||
@@ -317,9 +318,10 @@ describe("handleChildrenEventTypes", () => {
|
||||
profileId: null,
|
||||
updatedValues: {},
|
||||
});
|
||||
const { createdAt, updatedAt, ...expectedEvType } = evType;
|
||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...evType,
|
||||
...expectedEvType,
|
||||
parentId: 1,
|
||||
users: { connect: [{ id: 4 }] },
|
||||
bookingLimits: undefined,
|
||||
@@ -383,7 +385,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
length: 30,
|
||||
},
|
||||
});
|
||||
const { profileId, autoTranslateDescriptionEnabled, ...rest } = evType;
|
||||
const { profileId, autoTranslateDescriptionEnabled, createdAt, updatedAt, ...rest } = evType;
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...rest,
|
||||
@@ -464,7 +466,11 @@ describe("handleChildrenEventTypes", () => {
|
||||
...evType,
|
||||
};
|
||||
|
||||
prismaMock.eventType.update.mockResolvedValue(mockUpdatedEventType);
|
||||
prismaMock.eventType.update.mockResolvedValue({
|
||||
...mockUpdatedEventType,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
@@ -480,9 +486,10 @@ describe("handleChildrenEventTypes", () => {
|
||||
updatedValues: {},
|
||||
});
|
||||
|
||||
const { createdAt, updatedAt, ...expectedEvType } = evType;
|
||||
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
...evType,
|
||||
...expectedEvType,
|
||||
bookingLimits: undefined,
|
||||
durationLimits: undefined,
|
||||
recurringEvent: undefined,
|
||||
@@ -507,7 +514,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
});
|
||||
const { profileId, rrSegmentQueryValue, ...rest } = evType;
|
||||
const { profileId, rrSegmentQueryValue, createdAt: _, updatedAt: __, ...rest } = evType;
|
||||
if ("workflows" in rest) delete rest.workflows;
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
data: {
|
||||
|
||||
@@ -1534,6 +1534,7 @@ export function getOrganizer({
|
||||
completedOnboarding,
|
||||
username,
|
||||
locked,
|
||||
emailVerified,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -1551,6 +1552,7 @@ export function getOrganizer({
|
||||
completedOnboarding?: boolean;
|
||||
username?: string;
|
||||
locked?: boolean;
|
||||
emailVerified?: Date | null;
|
||||
}) {
|
||||
username = username ?? TestData.users.example.username;
|
||||
return {
|
||||
@@ -1572,7 +1574,8 @@ export function getOrganizer({
|
||||
smsLockState,
|
||||
completedOnboarding,
|
||||
locked,
|
||||
};
|
||||
emailVerified,
|
||||
};
|
||||
}
|
||||
|
||||
export function getScenarioData(
|
||||
|
||||
+393
-468
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -213,7 +213,8 @@
|
||||
"platform/atoms/event-type",
|
||||
"platform/atoms/calendar-settings",
|
||||
"platform/atoms/payment-form",
|
||||
"platform/atoms/conferencing-apps"
|
||||
"platform/atoms/conferencing-apps",
|
||||
"platform/atoms/calendar-view"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "Calendar view"
|
||||
---
|
||||
|
||||
The calendar view atom is basically a weekly calendar view wherein users can see entire week at a glance, clearly showing both available and unavailable time slots. This view is designed to provide a comprehensive and easy-to-read schedule, helping users quickly identify open slots for booking or planning.
|
||||
|
||||
## Individual event type
|
||||
|
||||
Below code snippet can be used to render the calendar view atom for an individual event
|
||||
|
||||
```js
|
||||
import { CalendarView } from "@calcom/atoms";
|
||||
|
||||
export default function Booker( props : BookerProps ) {
|
||||
return (
|
||||
<div>
|
||||
<CalendarView username={props.calUsername} eventSlug={props.eventSlug} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Team event type
|
||||
|
||||
Below code snippet can be used to render the calendar view atom for a team event
|
||||
|
||||
```js
|
||||
import { CalendarView } from "@calcom/atoms";
|
||||
|
||||
export default function Booker( props : BookerProps ) {
|
||||
return (
|
||||
<div>
|
||||
<CalendarView isTeamEvent={true} teamId={props.teamId} eventSlug={props.eventSlug} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
For a demonstration of the calendar view atom, please refer to the video below.
|
||||
|
||||
<p></p>
|
||||
|
||||
<iframe
|
||||
height="315"
|
||||
style={{ width: "100%", maxWidth: "560px" }}
|
||||
src="https://www.loom.com/embed/40ebab9efaa149019ea487e35ff8f656?sid=c6a748c3-0564-4400-ac7c-a17a404c9af6"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen="true"
|
||||
></iframe>
|
||||
|
||||
<p></p>
|
||||
|
||||
Below is a list of props that can be passed to the create event type atom
|
||||
|
||||
| Name | Required | Description |
|
||||
|:----------------------------|:----------|:-------------------------------------------------------------------------------------------------------|
|
||||
| username | Yes | Username of the person whose schedule is to be displayed |
|
||||
| eventSlug | Yes | Unique slug created for a particular event | |
|
||||
| isTeamEvent | No | Boolean indicating if it is a team event, to be passed only for team events |
|
||||
| teamId | No | The id of the team for which the event is created, to be passed only for team events |
|
||||
@@ -332,7 +332,7 @@ Below video shows a demonstration of the all the options you get in the advanced
|
||||
<iframe
|
||||
height="315"
|
||||
style={{ width: "100%", maxWidth: "560px" }}
|
||||
src="https://www.loom.com/embed/e0121b51920544eea92251dba99245fa?sid=ac627de7-5673-41b4-aa78-c6c9e439bdb5"
|
||||
src="https://www.loom.com/embed/7a03c4a453ca401ab787b2db98728f31?sid=8cdc6398-0017-41c8-a0a1-8fa31f80616c"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen="true"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { config } from "@calcom/eslint-config/base";
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { nextJsConfig } from "@calcom/eslint-config/next-js";
|
||||
|
||||
export default nextJsConfig;
|
||||
@@ -6,10 +6,11 @@
|
||||
"dev": "PORT=5100 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/atoms": "*",
|
||||
"@calcom/atoms": "workspace:*",
|
||||
"@calcom/eslint-config": "workspace:*",
|
||||
"@prisma/client": "6.7.0",
|
||||
"next": "14.0.4",
|
||||
"prisma": "^6.7.0",
|
||||
@@ -22,8 +23,6 @@
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-config-next": "14.0.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^4.9.4"
|
||||
|
||||
+10
-9
@@ -8,6 +8,7 @@
|
||||
"packages/*",
|
||||
"packages/embeds/*",
|
||||
"packages/features/*",
|
||||
"packages/app-store",
|
||||
"packages/app-store/*",
|
||||
"packages/platform/*",
|
||||
"packages/platform/examples/base",
|
||||
@@ -53,9 +54,9 @@
|
||||
"env-check:common": "dotenv-checker --schema .env.example --env .env",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"heroku-postbuild": "turbo run @calcom/web#build",
|
||||
"lint:fix": "turbo run lint:fix",
|
||||
"lint:report": "turbo run lint:report",
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo lint:fix",
|
||||
"lint:report": "turbo lint:report",
|
||||
"lint": "turbo lint",
|
||||
"postinstall": "husky install && turbo run post-install",
|
||||
"pre-commit": "lint-staged",
|
||||
"predev": "echo 'Checking env files'",
|
||||
@@ -71,8 +72,8 @@
|
||||
"test-e2e:embed": "yarn db-seed && yarn e2e:embed",
|
||||
"test-e2e:embed-react": "yarn db-seed && yarn e2e:embed-react",
|
||||
"test-playwright": "yarn playwright test --config=playwright.config.ts",
|
||||
"test": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test": "TZ=UTC vitest run",
|
||||
"test:ui": "TZ=UTC vitest --ui",
|
||||
"type-check": "turbo run type-check",
|
||||
"type-check:ci": "turbo run type-check:ci --log-prefix=none",
|
||||
"web": "yarn workspace @calcom/web",
|
||||
@@ -84,6 +85,7 @@
|
||||
"changesets-release": "NODE_OPTIONS='--max_old_space_size=12288' turbo run build --filter=@calcom/atoms && yarn changeset publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/eslint-config": "workspace:*",
|
||||
"@changesets/changelog-github": "^0.5.1",
|
||||
"@changesets/cli": "2.29.4",
|
||||
"@faker-js/faker": "9.2.0",
|
||||
@@ -96,7 +98,7 @@
|
||||
"c8": "^7.13.0",
|
||||
"checkly": "latest",
|
||||
"dotenv-checker": "^1.1.5",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint": "9.36.0",
|
||||
"husky": "^8.0.0",
|
||||
"i18n-unused": "^0.13.0",
|
||||
"jest-diff": "^29.5.0",
|
||||
@@ -110,6 +112,7 @@
|
||||
"prismock": "1.35.3",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"tsc-absolute": "^1.0.0",
|
||||
"turbo": "^2.5.5",
|
||||
"typescript": "5.9.0-beta",
|
||||
"vitest": "^2.1.9",
|
||||
"vitest-fetch-mock": "^0.3.0",
|
||||
@@ -122,9 +125,7 @@
|
||||
"@vercel/functions": "^1.4.0",
|
||||
"city-timezones": "^1.2.1",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"eslint": "^8.34.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"turbo": "^2.5.5"
|
||||
"p-limit": "^6.2.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"types-ramda": "0.29.4",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"dist/cli.js"
|
||||
],
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/lib": "workspace:*",
|
||||
"ink": "^3.2.0",
|
||||
"ink-select-input": "^4.2.1",
|
||||
"ink-text-input": "^4.0.3",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"version": "0.0.0",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*",
|
||||
"@calcom/lib": "workspace:*",
|
||||
"@getalby/lightning-tools": "^5.1.2",
|
||||
"@getalby/sdk": "^4.0.0",
|
||||
"@webbtc/webln-types": "^3.0.0",
|
||||
@@ -13,7 +13,7 @@
|
||||
"svix": "^0.85.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
"@calcom/types": "workspace:*"
|
||||
},
|
||||
"description": "Your Bitcoin & Nostr companion for the web. Use Alby to charge Satoshi for your Cal.com meetings.\r"
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
"main": "./index.ts",
|
||||
"description": "The joyful productivity app\r\r",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
"@calcom/lib": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
"@calcom/types": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
"main": "./index.ts",
|
||||
"description": "Apple calendar runs both the macOS and iOS mobile operating systems. Offering online cloud backup of calendars using Apple’s iCloud service, it can sync with Google Calendar and Microsoft Exchange Server. Users can schedule events in their day that include time, location, duration, and extra notes.",
|
||||
"dependencies": {
|
||||
"@calcom/prisma": "*",
|
||||
"@calcom/prisma": "workspace:*",
|
||||
"react-hook-form": "^7.43.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
"@calcom/types": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user