Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34ef206f8 | ||
|
|
1c3c661726 | ||
|
|
3dbad3113d | ||
|
|
b1173fc8c3 | ||
|
|
1c193cca86 | ||
|
|
17af50bbdc | ||
|
|
5242e419d9 | ||
|
|
5d65a0f091 | ||
|
|
4081d11fbe | ||
|
|
f883dee084 | ||
|
|
e3a9f54ba5 | ||
|
|
c9abc556ba | ||
|
|
9855176948 | ||
|
|
48cbb94af2 | ||
|
|
8238d4ffcd | ||
|
|
0a84ce5c73 | ||
|
|
7e73d675d7 | ||
|
|
648ad72a54 | ||
|
|
f00be08e8c | ||
|
|
f1e8e3fa58 | ||
|
|
4dbe044ecc | ||
|
|
f1ae5f727a | ||
|
|
7ee01c0487 | ||
|
|
05537780b8 | ||
|
|
3e29904d7d | ||
|
|
06f4b25080 | ||
|
|
79fc0c8437 | ||
|
|
a2b25fa615 | ||
|
|
14c151be76 | ||
|
|
1aae57daae | ||
|
|
12e95a1f47 | ||
|
|
60b6086221 | ||
|
|
5d65df9c05 | ||
|
|
66ce20245c | ||
|
|
c321a6c07a | ||
|
|
0eb2c15b39 | ||
|
|
9d29055808 | ||
|
|
74f3608b42 | ||
|
|
9bfa4165b9 | ||
|
|
8a96a45424 | ||
|
|
0787c4890e | ||
|
|
51e902f03e | ||
|
|
9b1bf29ecf | ||
|
|
ad65fbbd81 | ||
|
|
eef47ddefd | ||
|
|
8908a66c23 | ||
|
|
a78a3ffe39 | ||
|
|
a6a428c8cb | ||
|
|
217c6e6a76 | ||
|
|
b1eb5a2809 | ||
|
|
bd5d94a4ba | ||
|
|
3cfe295ed5 | ||
|
|
20dcef6680 | ||
|
|
11b65b295b | ||
|
|
4c73695d3a | ||
|
|
ab4eff1fe1 | ||
|
|
38b43f75ba | ||
|
|
9603831def | ||
|
|
2bc17313fd | ||
|
|
3a7122d613 | ||
|
|
21d28c9747 | ||
|
|
f8a93414a5 | ||
|
|
85e1ed3ab5 | ||
|
|
4647333839 | ||
|
|
9d4cb08c55 | ||
|
|
773fab858f | ||
|
|
ea0c92a267 | ||
|
|
2abefc412c | ||
|
|
9d4b12b8d3 | ||
|
|
c21281a55c | ||
|
|
f5a813f531 | ||
|
|
fa53f54815 | ||
|
|
f07bed1a2a | ||
|
|
227ed64b49 | ||
|
|
237d7e95c4 | ||
|
|
896dfd50fd | ||
|
|
e2119879ae | ||
|
|
7aefefc31f | ||
|
|
1b6b91b0ad | ||
|
|
50c7210d6b | ||
|
|
1bb4b206c6 | ||
|
|
6386b2ec95 | ||
|
|
68abc63fe4 | ||
|
|
bdeb4b1dc9 | ||
|
|
1541730886 | ||
|
|
4c6af5659a | ||
|
|
a9951df501 | ||
|
|
68663231d8 | ||
|
|
d3bbed01f6 | ||
|
|
9dfcb0d482 | ||
|
|
240405f849 | ||
|
|
bc9a9c0c24 | ||
|
|
f8abce44cb | ||
|
|
1368ffe55d | ||
|
|
397bf3cc0c | ||
|
|
98ec38aeaa | ||
|
|
d52e2d9fad | ||
|
|
bf348ee209 | ||
|
|
635c1febb0 | ||
|
|
8beb629b07 | ||
|
|
7cfb053026 | ||
|
|
50c5423ba7 | ||
|
|
7716f8c0a1 | ||
|
|
8a3944f987 | ||
|
|
be0b707296 | ||
|
|
fad64a012a | ||
|
|
14563f664e | ||
|
|
c7e66a3aa9 | ||
|
|
0d55939d13 | ||
|
|
06bb994145 | ||
|
|
b185cd231b | ||
|
|
4c804cdb00 | ||
|
|
98b6d63164 | ||
|
|
bc8ebb9213 | ||
|
|
6089f47d24 | ||
|
|
f6fa24de2b | ||
|
|
b75000ea7b | ||
|
|
dc7b9d91ae | ||
|
|
36713fc12e | ||
|
|
285f6d54a3 | ||
|
|
1e0cf3d0f6 | ||
|
|
681d191537 | ||
|
|
269ed87db4 | ||
|
|
0191f54519 | ||
|
|
dc3e681e01 | ||
|
|
30bba3da15 | ||
|
|
cfa0783ebc | ||
|
|
81e4241f85 | ||
|
|
f4abbb2de1 | ||
|
|
3908cda372 | ||
|
|
db76980644 | ||
|
|
d8e6ecc7a0 | ||
|
|
16eb96e27d | ||
|
|
83c42cfb2b | ||
|
|
589260a7c7 | ||
|
|
ff076d2394 | ||
|
|
ee1748dbee | ||
|
|
0a68a189f2 | ||
|
|
abbc8bbb7b | ||
|
|
11f336811b | ||
|
|
3fd922664f | ||
|
|
ae31e05bec | ||
|
|
431fbe44fc | ||
|
|
c0504993a6 | ||
|
|
c3a8301b0c | ||
|
|
c58e1f4ae5 | ||
|
|
267686704d | ||
|
|
83354fbe18 | ||
|
|
892d53917d | ||
|
|
6f6c27b47d | ||
|
|
9424649d6f | ||
|
|
b03c00aded | ||
|
|
e2e1ba096c | ||
|
|
6a9b1ec3a8 | ||
|
|
8b17df4621 | ||
|
|
edf9cd70dd | ||
|
|
f66fffd13b | ||
|
|
82a4780145 | ||
|
|
0a2d0854e7 | ||
|
|
e04a394e1c |
-57
@@ -1,57 +0,0 @@
|
|||||||
# EAS Build ignore file for monorepo
|
|
||||||
# Only upload the companion app folder
|
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# IGNORE EVERYTHING AT ROOT LEVEL
|
|
||||||
# ===========================================
|
|
||||||
/*
|
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# EXCEPT THE COMPANION APP
|
|
||||||
# ===========================================
|
|
||||||
!companion
|
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# BUT IGNORE THESE INSIDE COMPANION
|
|
||||||
# ===========================================
|
|
||||||
|
|
||||||
# Native folders - EAS generates these during builds (~2.1 GB)
|
|
||||||
companion/ios
|
|
||||||
companion/android
|
|
||||||
|
|
||||||
# Dependencies - EAS installs these during builds (~1.6 GB)
|
|
||||||
companion/node_modules
|
|
||||||
|
|
||||||
# Expo build cache
|
|
||||||
companion/.expo
|
|
||||||
companion/dist
|
|
||||||
companion/web-build
|
|
||||||
|
|
||||||
# Metro
|
|
||||||
companion/.metro-health-check*
|
|
||||||
|
|
||||||
# WXT Chrome Extension outputs (not needed for mobile builds)
|
|
||||||
companion/.output
|
|
||||||
companion/.wxt
|
|
||||||
companion/dev
|
|
||||||
|
|
||||||
# Kotlin cache
|
|
||||||
companion/.kotlin
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
companion/*.jks
|
|
||||||
companion/*.p8
|
|
||||||
companion/*.p12
|
|
||||||
companion/*.key
|
|
||||||
companion/*.mobileprovision
|
|
||||||
|
|
||||||
# Debug logs
|
|
||||||
companion/*.log
|
|
||||||
|
|
||||||
# TypeScript build info
|
|
||||||
companion/*.tsbuildinfo
|
|
||||||
|
|
||||||
# Environment files
|
|
||||||
companion/.env
|
|
||||||
companion/.env.*
|
|
||||||
!companion/.env.example
|
|
||||||
@@ -209,11 +209,13 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
|||||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||||
|
STRIPE_TEAM_ANNUAL_PRICE_ID=
|
||||||
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
|
||||||
ORG_MONTHLY_CREDITS=
|
ORG_MONTHLY_CREDITS=
|
||||||
STRIPE_TEAM_PRODUCT_ID=
|
STRIPE_TEAM_PRODUCT_ID=
|
||||||
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
|
||||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||||
|
STRIPE_ORG_ANNUAL_PRICE_ID=
|
||||||
STRIPE_ORG_PRODUCT_ID=
|
STRIPE_ORG_PRODUCT_ID=
|
||||||
# Used to pass trial days for orgs during the Stripe checkout session
|
# Used to pass trial days for orgs during the Stripe checkout session
|
||||||
STRIPE_ORG_TRIAL_DAYS=
|
STRIPE_ORG_TRIAL_DAYS=
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
/apps/web/lib/core/**/* @calcom/Foundation
|
/apps/web/lib/core/**/* @calcom/Foundation
|
||||||
/apps/web/lib/booking.ts @calcom/Foundation
|
/apps/web/lib/booking.ts @calcom/Foundation
|
||||||
/apps/web/lib/handleOrgRedirect.ts @calcom/Foundation
|
/apps/web/lib/handleOrgRedirect.ts @calcom/Foundation
|
||||||
|
/apps/web/modules/shell/Shell.tsx @calcom/Foundation
|
||||||
/apps/web/proxy.ts @calcom/Foundation
|
/apps/web/proxy.ts @calcom/Foundation
|
||||||
/deploy/**/* @calcom/Foundation
|
/deploy/**/* @calcom/Foundation
|
||||||
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
||||||
@@ -36,3 +37,11 @@
|
|||||||
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||||
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
|
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
|
||||||
|
|
||||||
|
# Exclude test files from requiring foundation/consumer approval
|
||||||
|
**/*.spec.*
|
||||||
|
**/*.e2e.*
|
||||||
|
**/*.e2e-spec.*
|
||||||
|
**/*.test.*
|
||||||
|
**/*.test-suite.*
|
||||||
|
**/*.integration-test.*
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
---
|
|
||||||
name: Companion (iOS/Android/Extension)
|
|
||||||
about: Report bugs or request features for mobile apps and browser extension
|
|
||||||
title: ""
|
|
||||||
labels: ["companion", "🚨 needs approval"]
|
|
||||||
assignees: ""
|
|
||||||
---
|
|
||||||
|
|
||||||
### Issue Type
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Select one:
|
|
||||||
- Bug Report
|
|
||||||
- Feature Request
|
|
||||||
-->
|
|
||||||
|
|
||||||
- [ ] 🐛 Bug Report
|
|
||||||
- [ ] ✨ Feature Request
|
|
||||||
|
|
||||||
### Platform
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Select all that apply:
|
|
||||||
-->
|
|
||||||
|
|
||||||
- [ ] 📱 iOS App
|
|
||||||
- [ ] 🤖 Android App
|
|
||||||
- [ ] 🧩 Browser Extension
|
|
||||||
|
|
||||||
### Platform Details
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Fill in the details for your selected platform(s).
|
|
||||||
Delete sections that don't apply.
|
|
||||||
-->
|
|
||||||
|
|
||||||
#### iOS
|
|
||||||
- **iOS Version**: (e.g., 17.2)
|
|
||||||
- **Device Model**: (e.g., iPhone 15 Pro)
|
|
||||||
- **App Version**: (e.g., 1.0.0)
|
|
||||||
|
|
||||||
#### Android
|
|
||||||
- **Android Version**: (e.g., 14)
|
|
||||||
- **Device Model**: (e.g., Pixel 8)
|
|
||||||
- **App Version**: (e.g., 1.0.0)
|
|
||||||
|
|
||||||
#### Browser Extension
|
|
||||||
- **Browser & Version**: (e.g., Chrome 120, Firefox 121)
|
|
||||||
- **Extension Version**: (e.g., 1.0.0)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Description
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Provide a clear and concise description of the issue or feature.
|
|
||||||
-->
|
|
||||||
|
|
||||||
(Write your description here.)
|
|
||||||
|
|
||||||
### Steps to Reproduce (Bug Reports Only)
|
|
||||||
|
|
||||||
<!--
|
|
||||||
If this is a bug report, provide steps to reproduce the issue.
|
|
||||||
Delete this section for feature requests.
|
|
||||||
-->
|
|
||||||
|
|
||||||
1. Go to...
|
|
||||||
2. Click on...
|
|
||||||
3. See error...
|
|
||||||
|
|
||||||
### Expected Behavior
|
|
||||||
|
|
||||||
<!--
|
|
||||||
What did you expect to happen?
|
|
||||||
-->
|
|
||||||
|
|
||||||
(Describe what you expected.)
|
|
||||||
|
|
||||||
### Actual Behavior (Bug Reports Only)
|
|
||||||
|
|
||||||
<!--
|
|
||||||
What actually happened? Delete this section for feature requests.
|
|
||||||
-->
|
|
||||||
|
|
||||||
(Describe what actually happened.)
|
|
||||||
|
|
||||||
### Proposed Solution (Feature Requests Only)
|
|
||||||
|
|
||||||
<!--
|
|
||||||
If this is a feature request, describe your proposed solution.
|
|
||||||
Delete this section for bug reports.
|
|
||||||
-->
|
|
||||||
|
|
||||||
(Describe your proposed solution.)
|
|
||||||
|
|
||||||
### Screenshots / Evidence
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Add screenshots, screen recordings, or console logs if applicable.
|
|
||||||
You can use tools like Bird Eats Bug for recordings.
|
|
||||||
-->
|
|
||||||
|
|
||||||
(Attach evidence here.)
|
|
||||||
|
|
||||||
### Additional Context
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Any other information that might be helpful.
|
|
||||||
-->
|
|
||||||
|
|
||||||
(Add any additional context here.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
##### House rules
|
|
||||||
|
|
||||||
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves the request by removing this label.
|
|
||||||
- Your ideas are invaluable! However, they undergo review to ensure alignment with the product's direction.
|
|
||||||
- Follow best practices in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
|
||||||
@@ -7,4 +7,11 @@ GET /v2/bookings/{bookingUid} added 'subschema #3, subschema #6' to the 'data' r
|
|||||||
POST /v2/bookings/{bookingUid}/cancel added 'subschema #3, subschema #6' to the 'data' response property 'oneOf' list for the response status '200'
|
POST /v2/bookings/{bookingUid}/cancel added 'subschema #3, subschema #6' to the 'data' response property 'oneOf' list for the response status '200'
|
||||||
POST /v2/bookings/{bookingUid}/confirm added 'subschema #3, subschema #6' to the 'data' response property 'oneOf' list for the response status '200'
|
POST /v2/bookings/{bookingUid}/confirm added 'subschema #3, subschema #6' to the 'data' response property 'oneOf' list for the response status '200'
|
||||||
POST /v2/bookings/{bookingUid}/decline added 'subschema #3, subschema #6' to the 'data' response property 'oneOf' list for the response status '200'
|
POST /v2/bookings/{bookingUid}/decline added 'subschema #3, subschema #6' to the 'data' response property 'oneOf' list for the response status '200'
|
||||||
POST /v2/bookings/{bookingUid}/guests added 'subschema #3, subschema #6' to the 'data' response property 'oneOf' list for the response status '200'
|
POST /v2/bookings/{bookingUid}/guests added 'subschema #3, subschema #6' to the 'data' response property 'oneOf' list for the response status '200'
|
||||||
|
DELETE /v2/event-types/{eventTypeId}/webhooks/{webhookId} added the new path request parameter 'webhookId'
|
||||||
|
GET /v2/event-types/{eventTypeId}/webhooks/{webhookId} added the new path request parameter 'webhookId'
|
||||||
|
DELETE /v2/oauth-clients/{clientId}/webhooks/{webhookId} added the new path request parameter 'webhookId'
|
||||||
|
GET /v2/oauth-clients/{clientId}/webhooks/{webhookId} added the new path request parameter 'webhookId'
|
||||||
|
DELETE /v2/teams/{teamId}/event-types/{eventTypeId}/webhooks/{webhookId} added the new path request parameter 'webhookId'
|
||||||
|
GET /v2/teams/{teamId}/event-types/{eventTypeId}/webhooks/{webhookId} added the new path request parameter 'webhookId'
|
||||||
|
GET /v2/webhooks/{webhookId} added the new path request parameter 'webhookId'
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Companion Builds
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
|
||||||
EXPO_PUBLIC_CAL_API_KEY: ${{ secrets.CI_EXPO_PUBLIC_CAL_API_KEY || vars.CI_EXPO_PUBLIC_CAL_API_KEY || vars.EXPO_PUBLIC_CAL_API_KEY }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build Companion App & Extension
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
timeout-minutes: 30
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
sparse-checkout: .github
|
|
||||||
- uses: ./.github/actions/cache-checkout
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: 1.3.0
|
|
||||||
cache: true
|
|
||||||
cache-dependency-path: companion/bun.lock
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
working-directory: companion
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Build Expo web app
|
|
||||||
working-directory: companion
|
|
||||||
env:
|
|
||||||
EXPO_NO_TELEMETRY: 1
|
|
||||||
CI: true
|
|
||||||
run: bunx expo export --platform web
|
|
||||||
|
|
||||||
- name: Build browser extension
|
|
||||||
working-directory: companion
|
|
||||||
run: bun run ext:build
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
name: Companion Lint
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
name: Lint Companion App
|
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
|
||||||
timeout-minutes: 10
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
sparse-checkout: .github
|
|
||||||
- uses: ./.github/actions/cache-checkout
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: 1.3.0
|
|
||||||
cache: false
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
working-directory: companion
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Run lint
|
|
||||||
working-directory: companion
|
|
||||||
run: bun run lint:all
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
name: Companion Type Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
typecheck:
|
|
||||||
name: Type Check Companion App
|
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
|
||||||
timeout-minutes: 10
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
sparse-checkout: .github
|
|
||||||
- uses: ./.github/actions/cache-checkout
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v1
|
|
||||||
with:
|
|
||||||
bun-version: 1.3.0
|
|
||||||
cache: true
|
|
||||||
cache-dependency-path: companion/bun.lock
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
working-directory: companion
|
|
||||||
run: bun install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
working-directory: companion
|
|
||||||
run: bun run typecheck:all
|
|
||||||
@@ -167,7 +167,6 @@ jobs:
|
|||||||
pull-requests: read
|
pull-requests: read
|
||||||
outputs:
|
outputs:
|
||||||
has-files-requiring-all-checks: ${{ steps.filter-exclusions.outputs.has-files-requiring-all-checks }}
|
has-files-requiring-all-checks: ${{ steps.filter-exclusions.outputs.has-files-requiring-all-checks }}
|
||||||
has-companion: ${{ steps.filter-inclusions.outputs.has-companion }}
|
|
||||||
has-api-v2-changes: ${{ steps.filter-inclusions.outputs.has-api-v2-changes }}
|
has-api-v2-changes: ${{ steps.filter-inclusions.outputs.has-api-v2-changes }}
|
||||||
has-prisma-changes: ${{ steps.filter-inclusions.outputs.has-prisma-changes }}
|
has-prisma-changes: ${{ steps.filter-inclusions.outputs.has-prisma-changes }}
|
||||||
has-agents-changes: ${{ steps.filter-inclusions.outputs.has-agents-changes }}
|
has-agents-changes: ${{ steps.filter-inclusions.outputs.has-agents-changes }}
|
||||||
@@ -197,14 +196,13 @@ jobs:
|
|||||||
filters: |
|
filters: |
|
||||||
has-files-requiring-all-checks:
|
has-files-requiring-all-checks:
|
||||||
- '**'
|
- '**'
|
||||||
- '!companion/**'
|
|
||||||
- '!.vscode/**'
|
- '!.vscode/**'
|
||||||
- '!**/*.md'
|
- '!**/*.md'
|
||||||
- '!**/*.mdx'
|
- '!**/*.mdx'
|
||||||
- '!.github/CODEOWNERS'
|
- '!.github/CODEOWNERS'
|
||||||
- '!docs/**'
|
- '!docs/**'
|
||||||
- '!help/**'
|
- '!help/**'
|
||||||
- '!apps/web/public/static/locales/**/common.json'
|
- '!packages/i18n/locales/**/common.json'
|
||||||
- '!i18n.lock'
|
- '!i18n.lock'
|
||||||
- name: Check for specific path changes
|
- name: Check for specific path changes
|
||||||
uses: dorny/paths-filter@v3
|
uses: dorny/paths-filter@v3
|
||||||
@@ -213,8 +211,6 @@ jobs:
|
|||||||
filters: |
|
filters: |
|
||||||
has-agents-changes:
|
has-agents-changes:
|
||||||
- "agents/**"
|
- "agents/**"
|
||||||
has-companion:
|
|
||||||
- "companion/**"
|
|
||||||
has-api-v2-changes:
|
has-api-v2-changes:
|
||||||
- "apps/api/v2/**"
|
- "apps/api/v2/**"
|
||||||
- "packages/platform-constants/**"
|
- "packages/platform-constants/**"
|
||||||
@@ -387,27 +383,6 @@ jobs:
|
|||||||
uses: ./.github/workflows/docs-build.yml
|
uses: ./.github/workflows/docs-build.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
build-companion:
|
|
||||||
name: Companion builds
|
|
||||||
needs: [prepare]
|
|
||||||
if: needs.prepare.outputs.has-companion == 'true'
|
|
||||||
uses: ./.github/workflows/companion-build.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
typecheck-companion:
|
|
||||||
name: Type Checks
|
|
||||||
needs: [prepare]
|
|
||||||
if: needs.prepare.outputs.has-companion == 'true'
|
|
||||||
uses: ./.github/workflows/companion-typecheck.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
lint-companion:
|
|
||||||
name: Linters
|
|
||||||
needs: [prepare]
|
|
||||||
if: needs.prepare.outputs.has-companion == 'true'
|
|
||||||
uses: ./.github/workflows/companion-lint.yml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Production builds
|
name: Production builds
|
||||||
needs: [prepare]
|
needs: [prepare]
|
||||||
@@ -489,9 +464,6 @@ jobs:
|
|||||||
build-api-v2,
|
build-api-v2,
|
||||||
build-atoms,
|
build-atoms,
|
||||||
build-docs,
|
build-docs,
|
||||||
build-companion,
|
|
||||||
typecheck-companion,
|
|
||||||
lint-companion,
|
|
||||||
setup-db,
|
setup-db,
|
||||||
e2e,
|
e2e,
|
||||||
e2e-api-v2,
|
e2e-api-v2,
|
||||||
@@ -540,14 +512,6 @@ jobs:
|
|||||||
needs.e2e-app-store.result != 'success'
|
needs.e2e-app-store.result != 'success'
|
||||||
)
|
)
|
||||||
) ||
|
) ||
|
||||||
(
|
|
||||||
needs.prepare.outputs.has-companion == 'true' &&
|
|
||||||
(
|
|
||||||
needs.build-companion.result != 'success' ||
|
|
||||||
needs.typecheck-companion.result != 'success' ||
|
|
||||||
needs.lint-companion.result != 'success'
|
|
||||||
)
|
|
||||||
) ||
|
|
||||||
(
|
(
|
||||||
needs.prepare.outputs.has-agents-changes == 'true' &&
|
needs.prepare.outputs.has-agents-changes == 'true' &&
|
||||||
needs.validate-agents-format.result != 'success'
|
needs.validate-agents-format.result != 'success'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync:
|
sync:
|
||||||
|
if: false
|
||||||
name: Sync to Devin Knowledge
|
name: Sync to Devin Knowledge
|
||||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ packages/**/.yarn/ci-cache/
|
|||||||
.claude/plans/
|
.claude/plans/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Spec drafts (rough work, not ready for review)
|
||||||
|
specs/**/_drafts/
|
||||||
|
|
||||||
# trigger.dev
|
# trigger.dev
|
||||||
.trigger
|
.trigger
|
||||||
agents/devin-knowledge.json
|
agents/devin-knowledge.json
|
||||||
|
|||||||
@@ -4,4 +4,10 @@ enableGlobalCache: false
|
|||||||
|
|
||||||
nodeLinker: node-modules
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
# fast-xml-parser 4.4.1 via @boxyhq/saml-jackson → @aws-sdk/core@3.816.0 (transitive).
|
||||||
|
# Only parses trusted AWS API responses, not user input. No practical attack vector.
|
||||||
|
# Upstream fix pending: ory/polis (saml-jackson) has bumped to @aws-sdk@3.994.0 on main but hasn't released yet.
|
||||||
|
npmAuditIgnoreAdvisories:
|
||||||
|
- "1113407"
|
||||||
|
|
||||||
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ You are a senior Cal.com engineer working in a Yarn/Turbo monorepo. You prioriti
|
|||||||
- Create PRs in draft mode by default
|
- Create PRs in draft mode by default
|
||||||
- Run `yarn type-check:ci --force` before concluding CI failures are unrelated to your changes
|
- Run `yarn type-check:ci --force` before concluding CI failures are unrelated to your changes
|
||||||
- Import directly from source files, not barrel files (e.g., `@calcom/ui/components/button` not `@calcom/ui`)
|
- Import directly from source files, not barrel files (e.g., `@calcom/ui/components/button` not `@calcom/ui`)
|
||||||
- Add translations to `apps/web/public/static/locales/en/common.json` for all UI strings
|
- Add translations to `packages/i18n/locales/en/common.json` for all UI strings
|
||||||
- Use `date-fns` or native `Date` instead of Day.js when timezone awareness isn't needed
|
- Use `date-fns` or native `Date` instead of Day.js when timezone awareness isn't needed
|
||||||
- Put permission checks in `page.tsx`, never in `layout.tsx`
|
- Put permission checks in `page.tsx`, never in `layout.tsx`
|
||||||
- Use `ast-grep` for searching if available; otherwise use `rg` (ripgrep), then fall back to `grep`
|
- Use `ast-grep` for searching if available; otherwise use `rg` (ripgrep), then fall back to `grep`
|
||||||
@@ -126,7 +126,7 @@ packages/lib/ # Shared utilities
|
|||||||
- Routes: `apps/web/app/` (App Router)
|
- Routes: `apps/web/app/` (App Router)
|
||||||
- Database schema: `packages/prisma/schema.prisma`
|
- Database schema: `packages/prisma/schema.prisma`
|
||||||
- tRPC routers: `packages/trpc/server/routers/`
|
- tRPC routers: `packages/trpc/server/routers/`
|
||||||
- Translations: `apps/web/public/static/locales/en/common.json`
|
- Translations: `packages/i18n/locales/en/common.json`
|
||||||
- Workflow constants: `packages/features/ee/workflows/lib/constants.ts`
|
- Workflow constants: `packages/features/ee/workflows/lib/constants.ts`
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
@@ -226,6 +226,14 @@ import { ProfileRepository } from "@calcom/features/profile/repositories/Profile
|
|||||||
- Fix type errors before test failures - they're often the root cause
|
- Fix type errors before test failures - they're often the root cause
|
||||||
- Run `yarn prisma generate` if you see missing enum/type errors
|
- Run `yarn prisma generate` if you see missing enum/type errors
|
||||||
|
|
||||||
|
## Spec-Driven Development (Opt-In)
|
||||||
|
|
||||||
|
For complex features, you can use spec-driven development when explicitly requested.
|
||||||
|
|
||||||
|
**To enable:** Tell the AI "use spec-driven development" or "follow the spec workflow"
|
||||||
|
|
||||||
|
See [SPEC-WORKFLOW.md](SPEC-WORKFLOW.md) for the full workflow documentation.
|
||||||
|
|
||||||
## Extended Documentation
|
## Extended Documentation
|
||||||
|
|
||||||
For detailed information, see the `agents/` directory:
|
For detailed information, see the `agents/` directory:
|
||||||
|
|||||||
@@ -149,6 +149,13 @@ Here is what you need to be able to run Cal.com.
|
|||||||
- Duplicate `.env.example` to `.env`
|
- Duplicate `.env.example` to `.env`
|
||||||
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
|
||||||
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
|
||||||
|
|
||||||
|
> **Windows users:** Replace the `packages/prisma/.env` symlink with a real copy to avoid a Prisma error (`unexpected character / in variable name`):
|
||||||
|
>
|
||||||
|
> ```sh
|
||||||
|
> # Git Bash / WSL
|
||||||
|
> rm packages/prisma/.env && cp .env packages/prisma/.env
|
||||||
|
> ```
|
||||||
|
|
||||||
5. Setup Node
|
5. Setup Node
|
||||||
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
|
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
|
||||||
@@ -671,7 +678,7 @@ Certain versions may have trouble creating a user if the field `metadata` is emp
|
|||||||
|
|
||||||
##### CLIENT_FETCH_ERROR
|
##### CLIENT_FETCH_ERROR
|
||||||
|
|
||||||
If you experience this error, it may be the way the default Auth callback in the server is using the WEBAPP_URL as a base url. The container does not necessarily have access to the same DNS as your local machine, and therefor needs to be configured to resolve to itself. You may be able to correct this by configuring `NEXTAUTH_URL=http://localhost:3000/api/auth`, to help the backend loop back to itself.
|
If you experience this error, it may be the way the default Auth callback in the server is using the WEBAPP_URL as a base url. The container does not necessarily have access to the same DNS as your local machine, and therefore needs to be configured to resolve to itself. You may be able to correct this by configuring `NEXTAUTH_URL=http://localhost:3000/api/auth`, to help the backend loop back to itself.
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-calcom-1 | @calcom/web:start: [next-auth][error][CLIENT_FETCH_ERROR]
|
docker-calcom-1 | @calcom/web:start: [next-auth][error][CLIENT_FETCH_ERROR]
|
||||||
@@ -767,11 +774,14 @@ Cal.com, Inc. is a commercial open source company, which means some parts of thi
|
|||||||
|
|
||||||
<img width="100%" src="https://repobeats.axiom.co/api/embed/6bfca2f20f39738048b6e70ca205efde46352c3d.svg" />
|
<img width="100%" src="https://repobeats.axiom.co/api/embed/6bfca2f20f39738048b6e70ca205efde46352c3d.svg" />
|
||||||
|
|
||||||
<!-- CONTRIBUTING -->
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
We ❤️ contributions! Whether it’s fixing a typo, improving documentation, or building new features, your help makes Cal.com better.
|
||||||
|
|
||||||
Please see our [contributing guide](/CONTRIBUTING.md).
|
- Check out our [Contributing Guide](./CONTRIBUTING.md) for detailed steps.
|
||||||
|
- Join the discussion on [GitHub Discussions](https://github.com/calcom/cal.com/discussions) or our community channels.
|
||||||
|
- Please follow our coding standards and commit message conventions to keep the project consistent.
|
||||||
|
|
||||||
|
Even small improvements matter — thank you for helping us grow!
|
||||||
|
|
||||||
### Good First Issues
|
### Good First Issues
|
||||||
|
|
||||||
@@ -984,12 +994,6 @@ An example of good readme is [atoms readme](https://github.com/calcom/cal.com/bl
|
|||||||
1. Follow semantic versioning when using changesets.
|
1. Follow semantic versioning when using changesets.
|
||||||
2. Mark breaking changes using `❗️Breaking change`
|
2. Mark breaking changes using `❗️Breaking change`
|
||||||
|
|
||||||
<!-- LICENSE -->
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Distributed under the [AGPLv3 License](https://github.com/calcom/cal.com/blob/main/LICENSE). See `LICENSE` for more information.
|
|
||||||
|
|
||||||
<!-- ACKNOWLEDGEMENTS -->
|
<!-- ACKNOWLEDGEMENTS -->
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Spec-Driven Development Workflow
|
||||||
|
|
||||||
|
This workflow is **opt-in**. Use it when explicitly requested by saying "use spec-driven development" or "follow the spec workflow".
|
||||||
|
|
||||||
|
## Design Documents
|
||||||
|
|
||||||
|
The `specs/` folder contains design documents for features in development.
|
||||||
|
|
||||||
|
Each spec follows this structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
specs/{feature}/
|
||||||
|
├── CLAUDE.md # Feature-specific instructions (read this first)
|
||||||
|
├── design.md # The specification
|
||||||
|
├── implementation.md # Current status and what's done
|
||||||
|
├── decisions.md # Why decisions were made
|
||||||
|
├── prompts.md # Reusable prompts
|
||||||
|
├── future-work.md # What's deferred
|
||||||
|
└── docs/ # Documentation with screenshots
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
|
||||||
|
1. Read the spec's `CLAUDE.md` for specific instructions
|
||||||
|
2. Read `design.md` to understand what we're building
|
||||||
|
3. Check `implementation.md` for current status
|
||||||
|
4. Find the relevant code in the codebase
|
||||||
|
5. Implement in small pieces, update `implementation.md` after each
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When Implementing Features (Spec Mode)
|
||||||
|
|
||||||
|
1. **Check for design doc** in `specs/` — if it exists, follow it
|
||||||
|
2. **If no spec exists** — ask if you should create one first
|
||||||
|
3. **Look at existing patterns** — find similar code and follow conventions
|
||||||
|
4. **Update implementation.md** — mark what's done after each piece
|
||||||
|
5. **Update decisions.md** — when choosing between approaches
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating a New Spec
|
||||||
|
|
||||||
|
When user asks to build a new feature:
|
||||||
|
|
||||||
|
1. Copy the template: `cp -r specs/_templates specs/{feature-name}`
|
||||||
|
2. Explore the codebase to understand existing patterns
|
||||||
|
3. Write `design.md` with technical spec
|
||||||
|
4. Write `CLAUDE.md` with feature-specific instructions
|
||||||
|
5. Initialize `implementation.md` with "not-started" status
|
||||||
|
6. Ask user to review before implementing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Updating Spec Files
|
||||||
|
|
||||||
|
### implementation.md — After completing each piece:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Status: in-progress
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
- [x] Database schema
|
||||||
|
- [x] Migration file
|
||||||
|
|
||||||
|
## In Progress
|
||||||
|
- tRPC endpoints
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
1. UI components
|
||||||
|
2. Tests
|
||||||
|
|
||||||
|
## Session Notes
|
||||||
|
### 2024-01-15
|
||||||
|
- Done: Added schema, created migration
|
||||||
|
- Next: Implement tRPC router
|
||||||
|
```
|
||||||
|
|
||||||
|
### decisions.md — When choosing between approaches:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## ADR-001: Use Separate Table for Custom Locations
|
||||||
|
|
||||||
|
### Context
|
||||||
|
Need to store user-defined locations.
|
||||||
|
|
||||||
|
### Options
|
||||||
|
1. JSON field — simpler, but harder to query
|
||||||
|
2. Separate table — more flexible, better indexing
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
Separate table for better querying.
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
- Need migration
|
||||||
|
- Need new tRPC router
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't (When Using Spec-Driven Development)
|
||||||
|
|
||||||
|
- Don't implement features without checking for a design doc first
|
||||||
|
- Don't skip updating implementation.md after completing work
|
||||||
|
- Don't make architectural decisions without recording them in decisions.md
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
---
|
||||||
|
title: Prevent Circular Dependencies Between Packages
|
||||||
|
impact: CRITICAL
|
||||||
|
impactDescription: Prevents build failures, type errors, ensures honest implementation, and maintains clean dependency graph
|
||||||
|
tags: architecture, dependencies, packages, imports, circular
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prevent Circular Dependencies Between Packages
|
||||||
|
|
||||||
|
**Impact: CRITICAL**
|
||||||
|
|
||||||
|
Circular dependencies between packages cause build failures, type errors, and make the codebase unmaintainable. The dependency graph must be acyclic, with clear layers from low-level utilities up to high-level features.
|
||||||
|
|
||||||
|
The dependency hierarchy (from lowest to highest):
|
||||||
|
```
|
||||||
|
packages/lib (lowest - no feature dependencies)
|
||||||
|
↓
|
||||||
|
packages/app-store
|
||||||
|
↓
|
||||||
|
packages/features
|
||||||
|
↓
|
||||||
|
packages/trpc
|
||||||
|
↓
|
||||||
|
apps/web (highest - can depend on all packages)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- **Honest implementation**: The dependency graph accurately reflects what code depends on what, with no hidden or circular relationships that create false expectations
|
||||||
|
- **Predictable behavior**: Code behaves as the dependency structure suggests - no surprises from circular imports
|
||||||
|
- **Clear mental model**: Developers can reason about the system without tracking complex circular relationships
|
||||||
|
- **Build reliability**: No circular dependency errors during compilation or bundling
|
||||||
|
- **Easier testing**: Lower-level packages can be tested independently without pulling in the entire codebase
|
||||||
|
|
||||||
|
### `packages/lib`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
1. No files in `packages/lib` import from `@calcom/app-store` or `../app-store/**`
|
||||||
|
2. No files in `packages/lib` import from `@calcom/features` or `../features/**`
|
||||||
|
3. No files in `packages/lib` import from `@calcom/trpc` or `../trpc/**`
|
||||||
|
4. No files in `packages/lib` import from `@trpc/server`
|
||||||
|
5. **BONUS:** No repository files allowed in `packages/lib` (repositories belong in features or app-store)
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad - lib importing from features
|
||||||
|
import { getBooking } from "@calcom/features/bookings";
|
||||||
|
import { EventRepository } from "@calcom/features/events/repositories";
|
||||||
|
|
||||||
|
// Bad - lib importing from app-store
|
||||||
|
import { googleCalendarHandler } from "@calcom/app-store/googlecalendar";
|
||||||
|
|
||||||
|
// Bad - lib importing from trpc
|
||||||
|
import { router } from "@calcom/trpc/server/trpc";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good - lib only imports from other lib files or external packages
|
||||||
|
import { logger } from "@calcom/lib/logger";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
import type { User } from "@prisma/client";
|
||||||
|
```
|
||||||
|
|
||||||
|
### `packages/app-store`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
6. No files in `packages/app-store` import from `@calcom/features` or `../features/**`
|
||||||
|
7. No files in `packages/app-store` import from `@calcom/trpc` or `../trpc/**`
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad - app-store importing from features
|
||||||
|
import { BookingService } from "@calcom/features/bookings/services";
|
||||||
|
|
||||||
|
// Bad - app-store importing from trpc
|
||||||
|
import { publicProcedure } from "@calcom/trpc/server/trpc";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good - app-store imports from lib and prisma
|
||||||
|
import { logger } from "@calcom/lib/logger";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
import type { Credential } from "@prisma/client";
|
||||||
|
```
|
||||||
|
|
||||||
|
### `packages/features`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
8. No files in `packages/features` import from `@calcom/trpc` or `../trpc/**`
|
||||||
|
9. No files in `packages/features` import from `@calcom/web` or `@calcom/web/**`
|
||||||
|
10. No files in `packages/features` import from `../../apps/web/**`
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad - features importing from trpc
|
||||||
|
import { router } from "@calcom/trpc/server/trpc";
|
||||||
|
|
||||||
|
// Bad - features importing from web app
|
||||||
|
import { getServerSession } from "@calcom/web/lib/auth";
|
||||||
|
import { WebComponent } from "../../apps/web/components/WebComponent";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good - features import from lib, app-store, and prisma
|
||||||
|
import { logger } from "@calcom/lib/logger";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
import { googleCalendarHandler } from "@calcom/app-store/googlecalendar";
|
||||||
|
```
|
||||||
|
|
||||||
|
### `packages/trpc`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
11. No files in `packages/trpc` import from `../apps/web/**`
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad - trpc importing from web app
|
||||||
|
import { getServerSession } from "../../apps/web/lib/auth";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good - trpc imports from features, lib, and prisma
|
||||||
|
import { BookingService } from "@calcom/features/bookings/services";
|
||||||
|
import { logger } from "@calcom/lib/logger";
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
```
|
||||||
|
|
||||||
|
### `packages/testing`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
12. No files in `packages/testing` import from `@calcom/web` or `@calcom/web/**`
|
||||||
|
13. No files in `packages/testing` import from `@calcom/features` or `@calcom/features/**`
|
||||||
|
14. No files in `packages/testing` import from `../../apps/web/**`
|
||||||
|
15. No files in `packages/testing` import from `../features/**`
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad - testing importing from web or features
|
||||||
|
import { WebComponent } from "@calcom/web/components";
|
||||||
|
import { BookingService } from "@calcom/features/bookings";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good - testing imports from lib and prisma
|
||||||
|
import { prisma } from "@calcom/prisma";
|
||||||
|
import { logger } from "@calcom/lib/logger";
|
||||||
|
```
|
||||||
|
|
||||||
|
### `packages/platform/atoms`
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
16. No files in `packages/platform/atoms` import from `@calcom/trpc` or `@calcom/trpc/**`
|
||||||
|
17. No files in `packages/platform/atoms` import from `../../trpc` or `../../trpc/**`
|
||||||
|
18. No files in `packages/platform/atoms` import from `@calcom/web`
|
||||||
|
|
||||||
|
**Incorrect:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Bad - platform/atoms importing from trpc or web
|
||||||
|
import { router } from "@calcom/trpc/server/trpc";
|
||||||
|
import { trpc } from "../../trpc";
|
||||||
|
import { WebComponent } from "@calcom/web";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good - platform/atoms imports from lib, features, and ui
|
||||||
|
import { logger } from "@calcom/lib/logger";
|
||||||
|
import { Button } from "@calcom/ui/components/button";
|
||||||
|
import type { Booking } from "@calcom/features/bookings/types";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
These rules should be enforced through:
|
||||||
|
- ESLint rules that detect forbidden import paths
|
||||||
|
- CI checks that fail on circular dependencies
|
||||||
|
- Code review guidelines that flag violations
|
||||||
@@ -33,7 +33,7 @@ tags: reference, navigation, file-locations
|
|||||||
|
|
||||||
## Translations
|
## Translations
|
||||||
|
|
||||||
- English: `apps/web/public/static/locales/en/common.json`
|
- English: `packages/i18n/locales/en/common.json`
|
||||||
|
|
||||||
## App Store
|
## App Store
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
---
|
---
|
||||||
name: calcom-api
|
name: calcom-api
|
||||||
description: Interact with the Cal.com API v2 to manage scheduling, bookings, event types, availability, and calendars. Use this skill when building integrations that need to create or manage bookings, check availability, configure event types, or sync calendars with Cal.com's scheduling infrastructure.
|
description: Interact with the Cal.com API v2 to manage scheduling, bookings, event types, availability, and calendars. Use this skill when building integrations that need to create or manage bookings, check availability, configure event types, or sync calendars with Cal.com's scheduling infrastructure.
|
||||||
|
env:
|
||||||
|
CAL_API_KEY:
|
||||||
|
description: "Cal.com API key (prefixed with cal_live_ or cal_test_). Required for all API requests."
|
||||||
|
required: true
|
||||||
|
CAL_CLIENT_ID:
|
||||||
|
description: "OAuth client ID for platform integrations managing users on behalf of others. Sent as x-cal-client-id header."
|
||||||
|
required: false
|
||||||
|
CAL_SECRET_KEY:
|
||||||
|
description: "OAuth client secret for platform integrations. Sent as x-cal-secret-key header."
|
||||||
|
required: false
|
||||||
|
CAL_WEBHOOK_SECRET:
|
||||||
|
description: "Secret used to verify webhook payload signatures via X-Cal-Signature-256 header."
|
||||||
|
required: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# Cal.com API v2
|
# Cal.com API v2
|
||||||
@@ -14,6 +27,15 @@ All API requests should be made to:
|
|||||||
https://api.cal.com/v2
|
https://api.cal.com/v2
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Required Credentials
|
||||||
|
|
||||||
|
| Environment Variable | Required | Description |
|
||||||
|
|---------------------|----------|-------------|
|
||||||
|
| `CAL_API_KEY` | Yes | Cal.com API key (prefixed with `cal_live_` or `cal_test_`). Used as Bearer token for all API requests. Generate from Settings > Developer > API Keys. |
|
||||||
|
| `CAL_CLIENT_ID` | No | OAuth client ID for platform integrations that manage users on behalf of others. Sent as `x-cal-client-id` header. |
|
||||||
|
| `CAL_SECRET_KEY` | No | OAuth client secret for platform integrations. Sent as `x-cal-secret-key` header. |
|
||||||
|
| `CAL_WEBHOOK_SECRET` | No | Secret for verifying webhook payload signatures via the `X-Cal-Signature-256` header. |
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
All API requests require authentication via Bearer token:
|
All API requests require authentication via Bearer token:
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
const path = require("node:path");
|
|
||||||
const i18nConfig = require("@calcom/config/next-i18next.config");
|
|
||||||
|
|
||||||
/** @type {import("next-i18next").UserConfig} */
|
|
||||||
const config = {
|
|
||||||
...i18nConfig,
|
|
||||||
localePath: path.resolve("../../web/public/static/locales"),
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = config;
|
|
||||||
@@ -82,6 +82,7 @@ async function handler(req: NextApiRequest) {
|
|||||||
return await handleCancelBooking({
|
return await handleCancelBooking({
|
||||||
bookingData: { id, allRemainingBookings, cancellationReason, cancelledBy },
|
bookingData: { id, allRemainingBookings, cancellationReason, cancelledBy },
|
||||||
userId: req.userId,
|
userId: req.userId,
|
||||||
|
actionSource: "API_V1",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// TODO: Fix tests (These test were never running due to the vitest workspace config)
|
|
||||||
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
|
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
|
||||||
|
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
@@ -174,7 +173,7 @@ vi.mock("@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType", () =>
|
|||||||
getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]),
|
getAllWorkflowsFromEventType: vi.fn().mockResolvedValue([]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@calcom/lib/server/i18n", () => {
|
vi.mock("@calcom/i18n/server", () => {
|
||||||
const mockT = (key: string, options?: any) => {
|
const mockT = (key: string, options?: any) => {
|
||||||
if (key === "event_between_users") {
|
if (key === "event_between_users") {
|
||||||
return `${options?.eventName} between ${options?.host} and ${options?.attendeeName}`;
|
return `${options?.eventName} between ${options?.host} and ${options?.attendeeName}`;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import handler from "../../../pages/api/users/_post";
|
|||||||
type CustomNextApiRequest = NextApiRequest & Request;
|
type CustomNextApiRequest = NextApiRequest & Request;
|
||||||
type CustomNextApiResponse = NextApiResponse & Response;
|
type CustomNextApiResponse = NextApiResponse & Response;
|
||||||
|
|
||||||
vi.mock("@calcom/lib/server/i18n", () => {
|
vi.mock("@calcom/i18n/server", () => {
|
||||||
return {
|
return {
|
||||||
getTranslation: (key: string) => {
|
getTranslation: (key: string) => {
|
||||||
return () => key;
|
return () => key;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const TRIGGER_VERSION = '20260209.2';
|
export const TRIGGER_VERSION = '20260216.1';
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ CALENDSO_ENCRYPTION_KEY="Lmqy57DlHKPvwwrtkwjL/8QtxJWxC/OV"
|
|||||||
# Service Account Encryption Key for encrypting/decrypting service account keys
|
# Service Account Encryption Key for encrypting/decrypting service account keys
|
||||||
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY="LDBFYLEVICRZBYQZUZCSTZRJFZUKZMEI"
|
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY="LDBFYLEVICRZBYQZUZCSTZRJFZUKZMEI"
|
||||||
# url to redis instance running in Docker and used to cache data. Redis is setup in apps/api/v2/docker-compose.yaml
|
# url to redis instance running in Docker and used to cache data. Redis is setup in apps/api/v2/docker-compose.yaml
|
||||||
REDIS_URL="rediss://localhost:6379"
|
REDIS_URL="redis://localhost:6379"
|
||||||
|
|
||||||
# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2
|
# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2
|
||||||
NEXT_PUBLIC_SENTRY_DSN=
|
NEXT_PUBLIC_SENTRY_DSN=
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
||||||
const path = require("node:path");
|
|
||||||
const i18nConfig = require("@calcom/config/next-i18next.config");
|
|
||||||
|
|
||||||
/** @type {import("next-i18next").UserConfig} */
|
|
||||||
const config = {
|
|
||||||
...i18nConfig,
|
|
||||||
localePath: path.resolve("../../web/public/static/locales"),
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = config;
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"@sentry/node": "9.46.0",
|
"@sentry/node": "9.46.0",
|
||||||
"@sentry/profiling-node": "9.46.0",
|
"@sentry/profiling-node": "9.46.0",
|
||||||
"@snyk/protect": "latest",
|
"@snyk/protect": "latest",
|
||||||
"axios": "1.13.2",
|
"axios": "1.13.5",
|
||||||
"body-parser": "1.20.3",
|
"body-parser": "1.20.3",
|
||||||
"bull": "4.15.1",
|
"bull": "4.15.1",
|
||||||
"class-transformer": "0.5.1",
|
"class-transformer": "0.5.1",
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ export class BookingsController_2024_04_15 {
|
|||||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||||
|
actionSource: "API_V2",
|
||||||
});
|
});
|
||||||
if (!res.onlyRemovedAttendee && res.isPlatformManagedUserBooking) {
|
if (!res.onlyRemovedAttendee && res.isPlatformManagedUserBooking) {
|
||||||
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
|
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
|
||||||
@@ -611,6 +612,10 @@ export class BookingsController_2024_04_15 {
|
|||||||
...clone.body,
|
...clone.body,
|
||||||
noEmail: oAuthParams === undefined ? false : !oAuthParams.arePlatformEmailsEnabled,
|
noEmail: oAuthParams === undefined ? false : !oAuthParams.arePlatformEmailsEnabled,
|
||||||
creationSource: CreationSource.API_V2,
|
creationSource: CreationSource.API_V2,
|
||||||
|
metadata: {
|
||||||
|
...(clone.body.metadata || {}),
|
||||||
|
...(oAuthClientId && { platformClientId: oAuthClientId }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (oAuthClientId) {
|
if (oAuthClientId) {
|
||||||
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
|
await this.setPlatformAttendeesEmails(clone.body, oAuthClientId);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { BookingAttendeesController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-attendees.controller";
|
||||||
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
|
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
|
||||||
import { BookingLocationController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-location.controller";
|
import { BookingLocationController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-location.controller";
|
||||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||||
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
|
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
|
||||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||||
|
import { BookingAttendeesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-attendees.service";
|
||||||
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||||
import { BookingLocationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location.service";
|
import { BookingLocationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location.service";
|
||||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||||
@@ -12,20 +15,22 @@ import { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-vid
|
|||||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
|
||||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||||
|
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
|
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.module";
|
||||||
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
|
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
|
||||||
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
|
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
|
||||||
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
|
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
|
||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { BillingModule } from "@/modules/billing/billing.module";
|
import { BillingModule } from "@/modules/billing/billing.module";
|
||||||
@@ -49,8 +54,6 @@ import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
|||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.module";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -80,6 +83,7 @@ import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.m
|
|||||||
OAuthClientRepository,
|
OAuthClientRepository,
|
||||||
OAuthClientUsersService,
|
OAuthClientUsersService,
|
||||||
BookingsService_2024_08_13,
|
BookingsService_2024_08_13,
|
||||||
|
BookingAttendeesService_2024_08_13,
|
||||||
BookingGuestsService_2024_08_13,
|
BookingGuestsService_2024_08_13,
|
||||||
InputBookingsService_2024_08_13,
|
InputBookingsService_2024_08_13,
|
||||||
OutputBookingsService_2024_08_13,
|
OutputBookingsService_2024_08_13,
|
||||||
@@ -105,9 +109,11 @@ import { BookingEventHandlerModule } from "@/lib/modules/booking-event-handler.m
|
|||||||
CalVideoOutputService,
|
CalVideoOutputService,
|
||||||
BookingPbacGuard,
|
BookingPbacGuard,
|
||||||
BookingLocationService_2024_08_13,
|
BookingLocationService_2024_08_13,
|
||||||
|
PrismaFeaturesRepository,
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
BookingsController_2024_08_13,
|
BookingsController_2024_08_13,
|
||||||
|
BookingAttendeesController_2024_08_13,
|
||||||
BookingGuestsController_2024_08_13,
|
BookingGuestsController_2024_08_13,
|
||||||
BookingLocationController_2024_08_13,
|
BookingLocationController_2024_08_13,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { AddAttendeeInput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
import { Body, Controller, HttpCode, HttpStatus, Param, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
|
||||||
|
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||||
|
import { AddAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output";
|
||||||
|
import { BookingAttendeesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-attendees.service";
|
||||||
|
import { VERSION_2024_08_13, VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
|
||||||
|
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||||
|
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||||
|
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||||
|
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||||
|
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
|
||||||
|
@Controller({
|
||||||
|
path: "/v2/bookings/:bookingUid/attendees",
|
||||||
|
version: VERSION_2024_08_13_VALUE,
|
||||||
|
})
|
||||||
|
@UseGuards(PermissionsGuard)
|
||||||
|
@DocsTags("Bookings / Attendees")
|
||||||
|
@ApiHeader({
|
||||||
|
name: "cal-api-version",
|
||||||
|
description: `Must be set to ${VERSION_2024_08_13}. This header is required as this endpoint does not exist in older API versions.`,
|
||||||
|
example: VERSION_2024_08_13,
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
export class BookingAttendeesController_2024_08_13 {
|
||||||
|
constructor(private readonly bookingAttendeesService: BookingAttendeesService_2024_08_13) {}
|
||||||
|
|
||||||
|
@Post("/")
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@Permissions([BOOKING_WRITE])
|
||||||
|
@UseGuards(ApiAuthGuard, BookingUidGuard, BookingPbacGuard)
|
||||||
|
@Throttle({
|
||||||
|
limit: 5,
|
||||||
|
ttl: 60000,
|
||||||
|
blockDuration: 60000,
|
||||||
|
name: "booking_attendees_add",
|
||||||
|
})
|
||||||
|
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Add an attendee to a booking",
|
||||||
|
description: `Add a new attendee to an existing booking by its UID.
|
||||||
|
|
||||||
|
**Side effects:**
|
||||||
|
- The booking's attendee list is updated in the database
|
||||||
|
- The calendar event is updated on connected calendars (Google Calendar, Outlook, etc.) to include the new attendee
|
||||||
|
- An email notification is sent to the new attendee with the booking details
|
||||||
|
|
||||||
|
**Permissions:**
|
||||||
|
- The authenticated user must be either the booking organizer, an existing attendee, or have the \`booking.update\` permission for the team
|
||||||
|
|
||||||
|
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
async addAttendee(
|
||||||
|
@Param("bookingUid") bookingUid: string,
|
||||||
|
@Body() body: AddAttendeeInput_2024_08_13,
|
||||||
|
@GetUser() user: ApiAuthGuardUser
|
||||||
|
): Promise<AddAttendeeOutput_2024_08_13> {
|
||||||
|
const attendee = await this.bookingAttendeesService.addAttendee(bookingUid, body, user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SUCCESS_STATUS,
|
||||||
|
data: attendee,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||||
|
import {
|
||||||
|
AttendeeAddGuestsEmail,
|
||||||
|
AttendeeScheduledEmail,
|
||||||
|
OrganizerAddGuestsEmail,
|
||||||
|
} from "@calcom/platform-libraries/emails";
|
||||||
|
import type { BookingOutput_2024_08_13, CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
import type { Team, User } from "@calcom/prisma/client";
|
||||||
|
import { INestApplication } from "@nestjs/common";
|
||||||
|
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import request from "supertest";
|
||||||
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
|
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||||
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
|
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||||
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
|
import { randomString } from "test/utils/randomString";
|
||||||
|
import { mockThrottlerGuard } from "test/utils/withNoThrottler";
|
||||||
|
import { AppModule } from "@/app.module";
|
||||||
|
import { bootstrap } from "@/bootstrap";
|
||||||
|
import { AddAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output";
|
||||||
|
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||||
|
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||||
|
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||||
|
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||||
|
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||||
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
|
||||||
|
const attendeeAddGuestsEmailSpy = jest
|
||||||
|
.spyOn(AttendeeAddGuestsEmail.prototype, "getHtml")
|
||||||
|
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||||
|
const organizerAddGuestsEmailSpy = jest
|
||||||
|
.spyOn(OrganizerAddGuestsEmail.prototype, "getHtml")
|
||||||
|
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||||
|
const attendeeScheduledEmailSpy = jest
|
||||||
|
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
|
||||||
|
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||||
|
|
||||||
|
type TestUser = {
|
||||||
|
user: User;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestSetup = {
|
||||||
|
organizer: TestUser;
|
||||||
|
unrelatedUser: TestUser;
|
||||||
|
eventTypeId: number;
|
||||||
|
bookingUid: string;
|
||||||
|
bookingId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Bookings Endpoints 2024-08-13 add attendee", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let organization: Team;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||||
|
|
||||||
|
let testSetup: TestSetup;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mockThrottlerGuard();
|
||||||
|
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||||
|
})
|
||||||
|
.overrideGuard(PermissionsGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: () => true,
|
||||||
|
})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
|
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
|
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
|
organization = await teamRepositoryFixture.create({
|
||||||
|
name: `add-attendee-2024-08-13-organization-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupTestData();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupTestData() {
|
||||||
|
const oAuthClient = await createOAuthClient(organization.id, true);
|
||||||
|
|
||||||
|
const organizerUser = await userRepositoryFixture.create({
|
||||||
|
email: `add-attendee-2024-08-13-organizer-${randomString()}@api.com`,
|
||||||
|
platformOAuthClients: {
|
||||||
|
connect: { id: oAuthClient.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unrelatedUserData = await userRepositoryFixture.create({
|
||||||
|
email: `add-attendee-2024-08-13-unrelated-${randomString()}@api.com`,
|
||||||
|
platformOAuthClients: {
|
||||||
|
connect: { id: oAuthClient.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizerTokens = await tokensRepositoryFixture.createTokens(organizerUser.id, oAuthClient.id);
|
||||||
|
const unrelatedUserTokens = await tokensRepositoryFixture.createTokens(
|
||||||
|
unrelatedUserData.id,
|
||||||
|
oAuthClient.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const schedule: CreateScheduleInput_2024_04_15 = {
|
||||||
|
name: `add-attendee-2024-08-13-schedule-${randomString()}`,
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
await schedulesService.createUserSchedule(organizerUser.id, schedule);
|
||||||
|
|
||||||
|
const eventType = await eventTypesRepositoryFixture.create(
|
||||||
|
{
|
||||||
|
title: `add-attendee-2024-08-13-event-type-${randomString()}`,
|
||||||
|
slug: `add-attendee-2024-08-13-event-type-${randomString()}`,
|
||||||
|
length: 60,
|
||||||
|
},
|
||||||
|
organizerUser.id
|
||||||
|
);
|
||||||
|
|
||||||
|
testSetup = {
|
||||||
|
organizer: {
|
||||||
|
user: organizerUser,
|
||||||
|
accessToken: organizerTokens.accessToken,
|
||||||
|
refreshToken: organizerTokens.refreshToken,
|
||||||
|
},
|
||||||
|
unrelatedUser: {
|
||||||
|
user: unrelatedUserData,
|
||||||
|
accessToken: unrelatedUserTokens.accessToken,
|
||||||
|
refreshToken: unrelatedUserTokens.refreshToken,
|
||||||
|
},
|
||||||
|
eventTypeId: eventType.id,
|
||||||
|
bookingUid: "",
|
||||||
|
bookingId: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
|
||||||
|
const data = {
|
||||||
|
logo: "logo-url",
|
||||||
|
name: "name",
|
||||||
|
redirectUris: ["http://localhost:5555"],
|
||||||
|
permissions: 32,
|
||||||
|
areEmailsEnabled: emailsEnabled,
|
||||||
|
};
|
||||||
|
const secret = "secret";
|
||||||
|
|
||||||
|
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
attendeeAddGuestsEmailSpy.mockClear();
|
||||||
|
organizerAddGuestsEmailSpy.mockClear();
|
||||||
|
attendeeScheduledEmailSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /v2/bookings/:bookingUid/attendees", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
const createBookingBody: CreateBookingInput_2024_08_13 = {
|
||||||
|
start: new Date(Date.UTC(2030, 0, 10, 13, 0, 0)).toISOString(),
|
||||||
|
eventTypeId: testSetup.eventTypeId,
|
||||||
|
attendee: {
|
||||||
|
name: "Original Attendee",
|
||||||
|
email: "original.attendee@gmail.com",
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
language: "it",
|
||||||
|
},
|
||||||
|
location: "https://meet.google.com/abc-def-ghi",
|
||||||
|
bookingFieldsResponses: {
|
||||||
|
customField: "customValue",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
userId: "100",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBookingResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v2/bookings")
|
||||||
|
.send(createBookingBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body;
|
||||||
|
expect(createBookingResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
|
||||||
|
if (!responseDataIsBooking(createBookingResponseBody.data)) {
|
||||||
|
throw new Error(
|
||||||
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testSetup.bookingUid = createBookingResponseBody.data.uid;
|
||||||
|
testSetup.bookingId = createBookingResponseBody.data.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Authentication", () => {
|
||||||
|
it("should return 401 when adding attendee without authentication", async () => {
|
||||||
|
const addAttendeeBody = {
|
||||||
|
email: "unauthenticated.attendee@example.com",
|
||||||
|
name: "Unauthenticated Attendee",
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||||
|
.send(addAttendeeBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Authorization", () => {
|
||||||
|
it("should allow booking organizer to add an attendee", async () => {
|
||||||
|
const addAttendeeBody = {
|
||||||
|
email: "new.attendee@example.com",
|
||||||
|
name: "New Attendee",
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||||
|
.send(addAttendeeBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: AddAttendeeOutput_2024_08_13 = response.body;
|
||||||
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
|
expect(responseBody.data).toBeDefined();
|
||||||
|
expect(responseBody.data.email).toEqual(addAttendeeBody.email);
|
||||||
|
expect(responseBody.data.name).toEqual(addAttendeeBody.name);
|
||||||
|
expect(responseBody.data.timeZone).toEqual(addAttendeeBody.timeZone);
|
||||||
|
expect(responseBody.data.bookingId).toEqual(testSetup.bookingId);
|
||||||
|
expect(responseBody.data.id).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 403 when unrelated user tries to add attendee", async () => {
|
||||||
|
const addAttendeeBody = {
|
||||||
|
email: "unauthorized.attendee@example.com",
|
||||||
|
name: "Unauthorized Attendee",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||||
|
.send(addAttendeeBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Response format", () => {
|
||||||
|
it("should return attendee with correct structure", async () => {
|
||||||
|
const addAttendeeBody = {
|
||||||
|
email: "structure.test@example.com",
|
||||||
|
name: "Structure Test Attendee",
|
||||||
|
timeZone: "Asia/Tokyo",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||||
|
.send(addAttendeeBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const responseBody: AddAttendeeOutput_2024_08_13 = response.body;
|
||||||
|
|
||||||
|
expect(responseBody).toHaveProperty("status", SUCCESS_STATUS);
|
||||||
|
expect(responseBody).toHaveProperty("data");
|
||||||
|
expect(responseBody.data).toHaveProperty("id");
|
||||||
|
expect(responseBody.data).toHaveProperty("bookingId");
|
||||||
|
expect(responseBody.data).toHaveProperty("name");
|
||||||
|
expect(responseBody.data).toHaveProperty("email");
|
||||||
|
expect(responseBody.data).toHaveProperty("timeZone");
|
||||||
|
|
||||||
|
expect(typeof responseBody.data.id).toBe("number");
|
||||||
|
expect(typeof responseBody.data.bookingId).toBe("number");
|
||||||
|
expect(typeof responseBody.data.name).toBe("string");
|
||||||
|
expect(typeof responseBody.data.email).toBe("string");
|
||||||
|
expect(typeof responseBody.data.timeZone).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Validation", () => {
|
||||||
|
it("should return 400 when email is missing", async () => {
|
||||||
|
const addAttendeeBody = {
|
||||||
|
name: "No Email Attendee",
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||||
|
.send(addAttendeeBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 when email is invalid", async () => {
|
||||||
|
const addAttendeeBody = {
|
||||||
|
email: "invalid-email",
|
||||||
|
name: "Invalid Email Attendee",
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||||
|
.send(addAttendeeBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 when name is missing", async () => {
|
||||||
|
const addAttendeeBody = {
|
||||||
|
email: "no.name@example.com",
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||||
|
.send(addAttendeeBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 when timeZone is missing", async () => {
|
||||||
|
const addAttendeeBody = {
|
||||||
|
email: "no.timezone@example.com",
|
||||||
|
name: "No TimeZone Attendee",
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||||
|
.send(addAttendeeBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 when timeZone is invalid", async () => {
|
||||||
|
const addAttendeeBody = {
|
||||||
|
email: "invalid.timezone@example.com",
|
||||||
|
name: "Invalid TimeZone Attendee",
|
||||||
|
timeZone: "Invalid/TimeZone",
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/${testSetup.bookingUid}/attendees`)
|
||||||
|
.send(addAttendeeBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 403 when booking does not exist", async () => {
|
||||||
|
const addAttendeeBody = {
|
||||||
|
email: "nonexistent.booking@example.com",
|
||||||
|
name: "Nonexistent Booking Attendee",
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/v2/bookings/non-existent-booking-uid/attendees`)
|
||||||
|
.send(addAttendeeBody)
|
||||||
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||||
|
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await teamRepositoryFixture.delete(organization.id);
|
||||||
|
|
||||||
|
await userRepositoryFixture.deleteByEmail(testSetup.organizer.user.email);
|
||||||
|
await userRepositoryFixture.deleteByEmail(testSetup.unrelatedUser.user.email);
|
||||||
|
|
||||||
|
await bookingsRepositoryFixture.deleteAllBookings(
|
||||||
|
testSetup.organizer.user.id,
|
||||||
|
testSetup.organizer.user.email
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||||
|
return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1053,7 +1053,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
expect(response.body.message).toEqual("Cancellation reason is required when you are the host");
|
expect(response.body.message).toEqual("Cancellation reason is required");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should cancel seated booking", async () => {
|
it("should cancel seated booking", async () => {
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||||
expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||||
|
|
||||||
expect(data.metadata).toEqual(body.metadata);
|
expect(data.metadata).toEqual({...body.metadata, platformClientId: oAuthClient.id});
|
||||||
createdBooking = data;
|
createdBooking = data;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -2115,7 +2115,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||||
expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||||
|
|
||||||
expect(data.metadata).toEqual(body.metadata);
|
expect(data.metadata).toEqual({...body.metadata, platformClientId: oAuthClient.id});
|
||||||
createdBooking = data;
|
createdBooking = data;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -2662,16 +2662,22 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("calendar events", () => {
|
describe("calendar events", () => {
|
||||||
|
// Type assertion to access private methods for test spying
|
||||||
|
const eventManagerProto = EventManager.prototype as unknown as Record<
|
||||||
|
"createAllCalendarEvents" | "createAllCRMEvents",
|
||||||
|
(...args: unknown[]) => Promise<unknown[]>
|
||||||
|
>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
jest
|
jest
|
||||||
.spyOn(EventManager.prototype, "create")
|
.spyOn(EventManager.prototype, "create")
|
||||||
.mockImplementation(() => Promise.resolve({ results: [], referencesToCreate: [] }));
|
.mockImplementation(() => Promise.resolve({ results: [], referencesToCreate: [] }));
|
||||||
jest
|
jest
|
||||||
.spyOn(EventManager.prototype, "createAllCalendarEvents")
|
.spyOn(eventManagerProto, "createAllCalendarEvents")
|
||||||
.mockImplementation(() => Promise.resolve([]));
|
.mockImplementation(() => Promise.resolve([]));
|
||||||
jest
|
jest
|
||||||
.spyOn(EventManager.prototype, "createAllCRMEvents")
|
.spyOn(eventManagerProto, "createAllCRMEvents")
|
||||||
.mockImplementation(() => Promise.resolve([]));
|
.mockImplementation(() => Promise.resolve([]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2843,8 +2849,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
expect(data.id).toBeDefined();
|
expect(data.id).toBeDefined();
|
||||||
expect(data.uid).toBeDefined();
|
expect(data.uid).toBeDefined();
|
||||||
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
expect(eventManagerProto.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
||||||
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
expect(eventManagerProto.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
@@ -2874,8 +2880,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||||
expect(responseBody.data).toBeDefined();
|
expect(responseBody.data).toBeDefined();
|
||||||
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
expect(eventManagerProto.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
||||||
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
expect(eventManagerProto.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2909,8 +2915,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
expect(data.id).toBeDefined();
|
expect(data.id).toBeDefined();
|
||||||
expect(data.uid).toBeDefined();
|
expect(data.uid).toBeDefined();
|
||||||
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
expect(eventManagerProto.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
||||||
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
expect(eventManagerProto.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
||||||
bookingThatRequiresConfirmationUid = data.uid;
|
bookingThatRequiresConfirmationUid = data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -2935,8 +2941,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||||
expect(data.id).toBeDefined();
|
expect(data.id).toBeDefined();
|
||||||
expect(data.uid).toBeDefined();
|
expect(data.uid).toBeDefined();
|
||||||
expect(EventManager.prototype.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
expect(eventManagerProto.createAllCalendarEvents).toHaveBeenCalledTimes(0);
|
||||||
expect(EventManager.prototype.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
expect(eventManagerProto.createAllCRMEvents).toHaveBeenCalledTimes(0);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
|
import { BookingAttendee } from "@calcom/platform-types";
|
||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Expose, Type } from "class-transformer";
|
||||||
|
import { IsEnum, IsNumber, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
export class BookingAttendeeOutput_2024_08_13 extends BookingAttendee {
|
||||||
|
@ApiProperty({ type: Number, example: 251 })
|
||||||
|
@IsNumber()
|
||||||
|
@Expose()
|
||||||
|
id!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, example: 313 })
|
||||||
|
@IsNumber()
|
||||||
|
@Expose()
|
||||||
|
bookingId!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddAttendeeOutput_2024_08_13 {
|
||||||
|
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||||
|
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||||
|
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BookingAttendeeOutput_2024_08_13 })
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => BookingAttendeeOutput_2024_08_13)
|
||||||
|
data!: BookingAttendeeOutput_2024_08_13;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { BookingAttendeesService } from "@calcom/platform-libraries/bookings";
|
||||||
|
import type { AddAttendeeInput_2024_08_13 } from "@calcom/platform-types";
|
||||||
|
import { HttpException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { plainToClass } from "class-transformer";
|
||||||
|
import { BookingAttendeeOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-attendee.output";
|
||||||
|
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||||
|
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||||
|
import type { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingAttendeesService_2024_08_13 {
|
||||||
|
private bookingAttendeesService: BookingAttendeesService;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||||
|
private readonly platformBookingsService: PlatformBookingsService
|
||||||
|
) {
|
||||||
|
this.bookingAttendeesService = new BookingAttendeesService();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addAttendee(
|
||||||
|
bookingUid: string,
|
||||||
|
input: AddAttendeeInput_2024_08_13,
|
||||||
|
user: ApiAuthGuardUser
|
||||||
|
): Promise<BookingAttendeeOutput_2024_08_13> {
|
||||||
|
const booking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformClientParams = booking.eventTypeId
|
||||||
|
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||||
|
|
||||||
|
const createdAttendee = await this.bookingAttendeesService.addAttendee({
|
||||||
|
bookingId: booking.id,
|
||||||
|
attendee: {
|
||||||
|
email: input.email,
|
||||||
|
name: input.name,
|
||||||
|
timeZone: input.timeZone,
|
||||||
|
phoneNumber: input.phoneNumber,
|
||||||
|
language: input.language,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
uuid: user.uuid,
|
||||||
|
},
|
||||||
|
emailsEnabled,
|
||||||
|
actionSource: "API_V2",
|
||||||
|
});
|
||||||
|
|
||||||
|
return plainToClass(
|
||||||
|
BookingAttendeeOutput_2024_08_13,
|
||||||
|
{
|
||||||
|
id: createdAttendee.id,
|
||||||
|
bookingId: createdAttendee.bookingId,
|
||||||
|
name: createdAttendee.name,
|
||||||
|
email: createdAttendee.email,
|
||||||
|
displayEmail: this.getDisplayEmail(createdAttendee.email),
|
||||||
|
timeZone: createdAttendee.timeZone,
|
||||||
|
language: createdAttendee.locale ?? undefined,
|
||||||
|
absent: false,
|
||||||
|
phoneNumber: createdAttendee.phoneNumber ?? undefined,
|
||||||
|
},
|
||||||
|
{ strategy: "excludeAll" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDisplayEmail(email: string): string {
|
||||||
|
return email.replace(/\+[a-zA-Z0-9]{25}/, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/reposito
|
|||||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||||
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
|
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
|
||||||
@@ -26,7 +27,8 @@ export class BookingLocationService_2024_08_13 {
|
|||||||
private readonly inputService: InputBookingsService_2024_08_13,
|
private readonly inputService: InputBookingsService_2024_08_13,
|
||||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||||
private readonly eventTypeAccessService: EventTypeAccessService,
|
private readonly eventTypeAccessService: EventTypeAccessService,
|
||||||
private readonly bookingEventHandlerService: BookingEventHandlerService
|
private readonly bookingEventHandlerService: BookingEventHandlerService,
|
||||||
|
private readonly featuresRepository: PrismaFeaturesRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async updateBookingLocation(
|
async updateBookingLocation(
|
||||||
@@ -99,10 +101,15 @@ export class BookingLocationService_2024_08_13 {
|
|||||||
responses: updatedBookingResponses,
|
responses: updatedBookingResponses,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const organizationId = existingBookingHost.organizationId ?? null;
|
||||||
|
const isBookingAuditEnabled = organizationId
|
||||||
|
? await this.featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit")
|
||||||
|
: false;
|
||||||
|
|
||||||
await this.bookingEventHandlerService.onLocationChanged({
|
await this.bookingEventHandlerService.onLocationChanged({
|
||||||
bookingUid: existingBooking.uid,
|
bookingUid: existingBooking.uid,
|
||||||
actor: makeUserActor(user.uuid),
|
actor: makeUserActor(user.uuid),
|
||||||
organizationId: existingBookingHost.organizationId ?? null,
|
organizationId,
|
||||||
source: "API_V2",
|
source: "API_V2",
|
||||||
auditData: {
|
auditData: {
|
||||||
location: {
|
location: {
|
||||||
@@ -110,6 +117,7 @@ export class BookingLocationService_2024_08_13 {
|
|||||||
new: bookingLocation,
|
new: bookingLocation,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
isBookingAuditEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.bookingsService.getBooking(updatedBooking.uid, user);
|
return this.bookingsService.getBooking(updatedBooking.uid, user);
|
||||||
|
|||||||
@@ -190,7 +190,10 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
eventTypeId: inputBooking.eventTypeId,
|
eventTypeId: inputBooking.eventTypeId,
|
||||||
timeZone: inputBooking.attendee.timeZone,
|
timeZone: inputBooking.attendee.timeZone,
|
||||||
language: inputBooking.attendee.language || "en",
|
language: inputBooking.attendee.language || "en",
|
||||||
metadata: inputBooking.metadata || {},
|
metadata: {
|
||||||
|
...(inputBooking.metadata || {}),
|
||||||
|
...(platformClientId && { platformClientId }),
|
||||||
|
},
|
||||||
hasHashedBookingLink: false,
|
hasHashedBookingLink: false,
|
||||||
guests,
|
guests,
|
||||||
verificationCode: inputBooking.emailVerificationCode,
|
verificationCode: inputBooking.emailVerificationCode,
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { Scope } from "@nestjs/common";
|
|||||||
import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service";
|
import { BookingAuditProducerService } from "@/lib/services/booking-audit-producer.service";
|
||||||
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
||||||
import { TaskerService } from "@/lib/services/tasker.service";
|
import { TaskerService } from "@/lib/services/tasker.service";
|
||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
|
import { PrismaWorkerModule } from "@/modules/prisma/prisma-worker.module";
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RegularBookingModule],
|
imports: [RegularBookingModule, PrismaWorkerModule],
|
||||||
providers: [
|
providers: [
|
||||||
RecurringBookingService,
|
RecurringBookingService,
|
||||||
BookingEventHandlerService,
|
BookingEventHandlerService,
|
||||||
@@ -24,6 +26,8 @@ import { TaskerService } from "@/lib/services/tasker.service";
|
|||||||
BookingAuditProducerService,
|
BookingAuditProducerService,
|
||||||
TaskerService,
|
TaskerService,
|
||||||
/** Required by BookingEventHandlerService - Ends **/
|
/** Required by BookingEventHandlerService - Ends **/
|
||||||
|
/** Required by RecurringBookingService **/
|
||||||
|
PrismaFeaturesRepository,
|
||||||
],
|
],
|
||||||
exports: [RecurringBookingService],
|
exports: [RecurringBookingService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import { TaskerService } from "@/lib/services/tasker.service";
|
|||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { Module, Scope } from "@nestjs/common";
|
import { Module, Scope } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { getWebhookProducer } from "@calcom/platform-libraries/bookings";
|
||||||
|
|
||||||
|
import { WEBHOOK_PRODUCER } from "./regular-booking.tokens";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -37,6 +41,10 @@ import { Module, Scope } from "@nestjs/common";
|
|||||||
},
|
},
|
||||||
scope: Scope.TRANSIENT,
|
scope: Scope.TRANSIENT,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: WEBHOOK_PRODUCER,
|
||||||
|
useFactory: () => getWebhookProducer(),
|
||||||
|
},
|
||||||
BookingAuditProducerService,
|
BookingAuditProducerService,
|
||||||
BookingEventHandlerService,
|
BookingEventHandlerService,
|
||||||
CheckBookingAndDurationLimitsService,
|
CheckBookingAndDurationLimitsService,
|
||||||
@@ -51,6 +59,6 @@ import { Module, Scope } from "@nestjs/common";
|
|||||||
TaskerService,
|
TaskerService,
|
||||||
RegularBookingService,
|
RegularBookingService,
|
||||||
],
|
],
|
||||||
exports: [RegularBookingService],
|
exports: [RegularBookingService, WEBHOOK_PRODUCER],
|
||||||
})
|
})
|
||||||
export class RegularBookingModule {}
|
export class RegularBookingModule {}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Injection token for IWebhookProducerService.
|
||||||
|
* Used to bridge the Cal.com DI container into NestJS.
|
||||||
|
*
|
||||||
|
* Defined in a separate file to avoid a circular import between
|
||||||
|
* regular-booking.module.ts and regular-booking.service.ts.
|
||||||
|
*/
|
||||||
|
export const WEBHOOK_PRODUCER = "WEBHOOK_PRODUCER";
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
import { RegularBookingService } from "@/lib/services/regular-booking.service";
|
||||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||||
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { RecurringBookingService as BaseRecurringBookingService } from "@calcom/platform-libraries/bookings";
|
import { RecurringBookingService as BaseRecurringBookingService } from "@calcom/platform-libraries/bookings";
|
||||||
@@ -8,11 +9,13 @@ import { RecurringBookingService as BaseRecurringBookingService } from "@calcom/
|
|||||||
export class RecurringBookingService extends BaseRecurringBookingService {
|
export class RecurringBookingService extends BaseRecurringBookingService {
|
||||||
constructor(
|
constructor(
|
||||||
regularBookingService: RegularBookingService,
|
regularBookingService: RegularBookingService,
|
||||||
bookingEventHandler: BookingEventHandlerService
|
bookingEventHandler: BookingEventHandlerService,
|
||||||
|
featuresRepository: PrismaFeaturesRepository
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
regularBookingService,
|
regularBookingService,
|
||||||
bookingEventHandler,
|
bookingEventHandler,
|
||||||
|
featuresRepository,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import {
|
||||||
|
RegularBookingService as BaseRegularBookingService,
|
||||||
|
type IWebhookProducerService,
|
||||||
|
} from "@calcom/platform-libraries/bookings";
|
||||||
|
import type { PrismaClient } from "@calcom/prisma";
|
||||||
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { WEBHOOK_PRODUCER } from "@/lib/modules/regular-booking.tokens";
|
||||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||||
@@ -7,10 +14,6 @@ import { HashedLinkService } from "@/lib/services/hashed-link.service";
|
|||||||
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
import { LuckyUserService } from "@/lib/services/lucky-user.service";
|
||||||
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
|
import { BookingEmailAndSmsTasker } from "@/lib/services/tasker/booking-emails-sms-tasker.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
|
|
||||||
import { RegularBookingService as BaseRegularBookingService } from "@calcom/platform-libraries/bookings";
|
|
||||||
import type { PrismaClient } from "@calcom/prisma";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RegularBookingService extends BaseRegularBookingService {
|
export class RegularBookingService extends BaseRegularBookingService {
|
||||||
@@ -23,7 +26,8 @@ export class RegularBookingService extends BaseRegularBookingService {
|
|||||||
userRepository: PrismaUserRepository,
|
userRepository: PrismaUserRepository,
|
||||||
bookingEmailAndSmsTasker: BookingEmailAndSmsTasker,
|
bookingEmailAndSmsTasker: BookingEmailAndSmsTasker,
|
||||||
featuresRepository: PrismaFeaturesRepository,
|
featuresRepository: PrismaFeaturesRepository,
|
||||||
bookingEventHandler: BookingEventHandlerService
|
bookingEventHandler: BookingEventHandlerService,
|
||||||
|
@Inject(WEBHOOK_PRODUCER) webhookProducer: IWebhookProducerService
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
checkBookingAndDurationLimitsService,
|
checkBookingAndDurationLimitsService,
|
||||||
@@ -35,6 +39,7 @@ export class RegularBookingService extends BaseRegularBookingService {
|
|||||||
bookingEmailAndSmsTasker,
|
bookingEmailAndSmsTasker,
|
||||||
featuresRepository,
|
featuresRepository,
|
||||||
bookingEventHandler,
|
bookingEventHandler,
|
||||||
|
webhookProducer,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
|
import { getTasker, type Tasker } from "@calcom/platform-libraries/tasker";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
import { getTasker, type Tasker } from "@calcom/platform-libraries";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TaskerService {
|
export class TaskerService {
|
||||||
private readonly tasker: Tasker;
|
private readonly tasker: Tasker;
|
||||||
@@ -14,5 +13,3 @@ export class TaskerService {
|
|||||||
return this.tasker;
|
return this.tasker;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { generateSecret } from "@calcom/platform-libraries";
|
import { generateSecret } from "@calcom/platform-libraries";
|
||||||
import type { Membership, Team, User } from "@calcom/prisma/client";
|
import type { Membership, Team, User } from "@calcom/prisma/client";
|
||||||
import { AccessScope, OAuthClientType } from "@calcom/prisma/enums";
|
import { AccessScope, OAuthClientStatus, OAuthClientType } from "@calcom/prisma/enums";
|
||||||
import { INestApplication } from "@nestjs/common";
|
import type { INestApplication } from "@nestjs/common";
|
||||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import type { TestingModule } from "@nestjs/testing";
|
||||||
import { getToken } from "next-auth/jwt";
|
import { Test } from "@nestjs/testing";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { OAuth2ClientRepositoryFixture } from "test/fixtures/repository/oauth2-client.repository.fixture";
|
import { OAuth2ClientRepositoryFixture } from "test/fixtures/repository/oauth2-client.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
@@ -21,20 +22,16 @@ import { AuthModule } from "@/modules/auth/auth.module";
|
|||||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
|
|
||||||
// Mock next-auth/jwt getToken for ApiAuthStrategy NEXT_AUTH authentication
|
// Mock next-auth/jwt getToken so the NextAuth fallback in ApiAuthStrategy doesn't crash in tests
|
||||||
jest.mock("next-auth/jwt", () => ({
|
jest.mock("next-auth/jwt", () => ({
|
||||||
getToken: jest.fn(),
|
getToken: jest.fn().mockResolvedValue(null),
|
||||||
}));
|
}));
|
||||||
const mockGetToken = getToken as jest.MockedFunction<typeof getToken>;
|
|
||||||
|
|
||||||
describe("OAuth2 Controller Endpoints", () => {
|
describe("OAuth2 Controller Endpoints", () => {
|
||||||
describe("User Not Authenticated", () => {
|
describe("User Not Authenticated", () => {
|
||||||
let appWithoutAuth: INestApplication;
|
let appWithoutAuth: INestApplication;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Mock getToken to return null for unauthenticated tests
|
|
||||||
mockGetToken.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
||||||
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
||||||
@@ -69,20 +66,23 @@ describe("OAuth2 Controller Endpoints", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("User Authenticated", () => {
|
describe("User Authenticated (non-owner)", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let moduleRef: TestingModule;
|
let moduleRef: TestingModule;
|
||||||
|
|
||||||
let userRepositoryFixture: UserRepositoryFixture;
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
|
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
|
||||||
let oAuthService: OAuthService;
|
let oAuthService: OAuthService;
|
||||||
|
|
||||||
let user: User;
|
let clientOwner: User;
|
||||||
|
let authenticatedUser: User;
|
||||||
let team: Team;
|
let team: Team;
|
||||||
let membership: Membership;
|
let membership: Membership;
|
||||||
let oAuthClient: { clientId: string };
|
let oAuthClient: { clientId: string };
|
||||||
|
let apiKeyString: string;
|
||||||
let refreshToken: string;
|
let refreshToken: string;
|
||||||
|
|
||||||
const testClientId = `test-oauth-client-${randomString()}`;
|
const testClientId = `test-oauth-client-${randomString()}`;
|
||||||
@@ -96,7 +96,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const result = await oAuthService.generateAuthorizationCode(
|
const result = await oAuthService.generateAuthorizationCode(
|
||||||
testClientId,
|
testClientId,
|
||||||
user.id,
|
authenticatedUser.id,
|
||||||
testRedirectUri,
|
testRedirectUri,
|
||||||
scopes,
|
scopes,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -107,12 +107,6 @@ describe("OAuth2 Controller Endpoints", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const userEmail = `oauth2-e2e-user-${randomString()}@api.com`;
|
|
||||||
|
|
||||||
// Mock getToken to return the user email for authenticated tests
|
|
||||||
// This is needed because ApiAuthGuard uses ApiAuthStrategy which calls getToken from next-auth/jwt
|
|
||||||
mockGetToken.mockResolvedValue({ email: userEmail });
|
|
||||||
|
|
||||||
moduleRef = await Test.createTestingModule({
|
moduleRef = await Test.createTestingModule({
|
||||||
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
||||||
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
||||||
@@ -125,20 +119,28 @@ describe("OAuth2 Controller Endpoints", () => {
|
|||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
|
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
|
||||||
oAuthService = moduleRef.get(OAuthService);
|
oAuthService = moduleRef.get(OAuthService);
|
||||||
|
|
||||||
user = await userRepositoryFixture.create({
|
clientOwner = await userRepositoryFixture.create({
|
||||||
email: userEmail,
|
email: `oauth2-e2e-owner-${randomString()}@api.com`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
authenticatedUser = await userRepositoryFixture.create({
|
||||||
|
email: `oauth2-e2e-user-${randomString()}@api.com`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(authenticatedUser.id, null);
|
||||||
|
apiKeyString = keyString;
|
||||||
|
|
||||||
team = await teamRepositoryFixture.create({
|
team = await teamRepositoryFixture.create({
|
||||||
name: `oauth2-e2e-team-${randomString()}`,
|
name: `oauth2-e2e-team-${randomString()}`,
|
||||||
slug: `oauth2-e2e-team-${randomString()}`,
|
slug: `oauth2-e2e-team-${randomString()}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
membership = await membershipRepositoryFixture.create({
|
membership = await membershipRepositoryFixture.create({
|
||||||
user: { connect: { id: user.id } },
|
user: { connect: { id: authenticatedUser.id } },
|
||||||
team: { connect: { id: team.id } },
|
team: { connect: { id: team.id } },
|
||||||
role: "OWNER",
|
role: "OWNER",
|
||||||
accepted: true,
|
accepted: true,
|
||||||
@@ -151,6 +153,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
|||||||
redirectUri: testRedirectUri,
|
redirectUri: testRedirectUri,
|
||||||
clientSecret: hashedSecret,
|
clientSecret: hashedSecret,
|
||||||
clientType: OAuthClientType.CONFIDENTIAL,
|
clientType: OAuthClientType.CONFIDENTIAL,
|
||||||
|
userId: clientOwner.id,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,6 +162,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
|||||||
it("should return OAuth client info for valid client ID", async () => {
|
it("should return OAuth client info for valid client ID", async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(`/api/v2/auth/oauth2/clients/${testClientId}`)
|
.get(`/api/v2/auth/oauth2/clients/${testClientId}`)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.status).toBe("success");
|
expect(response.body.status).toBe("success");
|
||||||
@@ -174,6 +178,7 @@ describe("OAuth2 Controller Endpoints", () => {
|
|||||||
it("should return 404 for non-existent client ID", async () => {
|
it("should return 404 for non-existent client ID", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get("/api/v2/auth/oauth2/clients/non-existent-client-id")
|
.get("/api/v2/auth/oauth2/clients/non-existent-client-id")
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -434,7 +439,238 @@ describe("OAuth2 Controller Endpoints", () => {
|
|||||||
await oAuthClientFixture.delete(oAuthClient.clientId);
|
await oAuthClientFixture.delete(oAuthClient.clientId);
|
||||||
await membershipRepositoryFixture.delete(membership.id);
|
await membershipRepositoryFixture.delete(membership.id);
|
||||||
await teamRepositoryFixture.delete(team.id);
|
await teamRepositoryFixture.delete(team.id);
|
||||||
await userRepositoryFixture.delete(user.id);
|
await userRepositoryFixture.delete(authenticatedUser.id);
|
||||||
|
await userRepositoryFixture.delete(clientOwner.id);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Owner can use non-approved OAuth client", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let moduleRef: TestingModule;
|
||||||
|
|
||||||
|
let userRepositoryFixture: UserRepositoryFixture;
|
||||||
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
|
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
|
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
|
||||||
|
let oAuthService: OAuthService;
|
||||||
|
|
||||||
|
let owner: User;
|
||||||
|
let team: Team;
|
||||||
|
let membership: Membership;
|
||||||
|
let pendingClient: { clientId: string };
|
||||||
|
let rejectedClient: { clientId: string };
|
||||||
|
let apiKeyString: string;
|
||||||
|
|
||||||
|
const pendingClientId = `test-pending-client-${randomString()}`;
|
||||||
|
const rejectedClientId = `test-rejected-client-${randomString()}`;
|
||||||
|
const testClientSecret = "test-secret-456";
|
||||||
|
const testRedirectUri = "https://example.com/callback";
|
||||||
|
|
||||||
|
/** Generate a user-scoped authorization code (no teamSlug) so the owner's userId is in the token. */
|
||||||
|
async function generateAuthCodeForClient(
|
||||||
|
clientId: string,
|
||||||
|
scopes: AccessScope[] = [AccessScope.READ_BOOKING]
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await oAuthService.generateAuthorizationCode(
|
||||||
|
clientId,
|
||||||
|
owner.id,
|
||||||
|
testRedirectUri,
|
||||||
|
scopes
|
||||||
|
);
|
||||||
|
const redirectUrl = new URL(result.redirectUrl);
|
||||||
|
return redirectUrl.searchParams.get("code") as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
moduleRef = await Test.createTestingModule({
|
||||||
|
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
|
||||||
|
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
bootstrap(app as NestExpressApplication);
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
|
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
|
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
|
||||||
|
oAuthService = moduleRef.get(OAuthService);
|
||||||
|
|
||||||
|
owner = await userRepositoryFixture.create({
|
||||||
|
email: `oauth2-owner-e2e-${randomString()}@api.com`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { keyString } = await apiKeysRepositoryFixture.createApiKey(owner.id, null);
|
||||||
|
apiKeyString = keyString;
|
||||||
|
|
||||||
|
team = await teamRepositoryFixture.create({
|
||||||
|
name: `oauth2-owner-team-${randomString()}`,
|
||||||
|
slug: `oauth2-owner-team-${randomString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
membership = await membershipRepositoryFixture.create({
|
||||||
|
user: { connect: { id: owner.id } },
|
||||||
|
team: { connect: { id: team.id } },
|
||||||
|
role: "OWNER",
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [hashedSecret] = generateSecret(testClientSecret);
|
||||||
|
|
||||||
|
pendingClient = await oAuthClientFixture.create({
|
||||||
|
clientId: pendingClientId,
|
||||||
|
name: "Pending OAuth Client",
|
||||||
|
redirectUri: testRedirectUri,
|
||||||
|
clientSecret: hashedSecret,
|
||||||
|
clientType: OAuthClientType.CONFIDENTIAL,
|
||||||
|
status: OAuthClientStatus.PENDING,
|
||||||
|
userId: owner.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
rejectedClient = await oAuthClientFixture.create({
|
||||||
|
clientId: rejectedClientId,
|
||||||
|
name: "Rejected OAuth Client",
|
||||||
|
redirectUri: testRedirectUri,
|
||||||
|
clientSecret: hashedSecret,
|
||||||
|
clientType: OAuthClientType.CONFIDENTIAL,
|
||||||
|
status: OAuthClientStatus.REJECTED,
|
||||||
|
userId: owner.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return PENDING client info when authenticated as owner", async () => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/api/v2/auth/oauth2/clients/${pendingClientId}`)
|
||||||
|
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.status).toBe("success");
|
||||||
|
expect(response.body.data.client_id).toBe(pendingClientId);
|
||||||
|
expect(response.body.data.name).toBe("Pending OAuth Client");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should exchange authorization code for tokens with PENDING client owned by user", async () => {
|
||||||
|
const code = await generateAuthCodeForClient(pendingClientId);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/api/v2/auth/oauth2/token")
|
||||||
|
.type("form")
|
||||||
|
.send({
|
||||||
|
client_id: pendingClientId,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
client_secret: testClientSecret,
|
||||||
|
redirect_uri: testRedirectUri,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.access_token).toBeDefined();
|
||||||
|
expect(response.body.refresh_token).toBeDefined();
|
||||||
|
expect(response.body.token_type).toBe("bearer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should refresh tokens with PENDING client owned by user", async () => {
|
||||||
|
const code = await generateAuthCodeForClient(pendingClientId);
|
||||||
|
|
||||||
|
const tokenResponse = await request(app.getHttpServer())
|
||||||
|
.post("/api/v2/auth/oauth2/token")
|
||||||
|
.type("form")
|
||||||
|
.send({
|
||||||
|
client_id: pendingClientId,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
client_secret: testClientSecret,
|
||||||
|
redirect_uri: testRedirectUri,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/api/v2/auth/oauth2/token")
|
||||||
|
.type("form")
|
||||||
|
.send({
|
||||||
|
client_id: pendingClientId,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: tokenResponse.body.refresh_token,
|
||||||
|
client_secret: testClientSecret,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.access_token).toBeDefined();
|
||||||
|
expect(response.body.refresh_token).toBeDefined();
|
||||||
|
expect(response.body.token_type).toBe("bearer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject authorization code generation for REJECTED client even as owner", async () => {
|
||||||
|
await expect(generateAuthCodeForClient(rejectedClientId)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject token exchange when client becomes rejected", async () => {
|
||||||
|
const code = await generateAuthCodeForClient(pendingClientId);
|
||||||
|
|
||||||
|
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.REJECTED);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/api/v2/auth/oauth2/token")
|
||||||
|
.type("form")
|
||||||
|
.send({
|
||||||
|
client_id: pendingClientId,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
client_secret: testClientSecret,
|
||||||
|
redirect_uri: testRedirectUri,
|
||||||
|
})
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
expect(response.body.error).toBe("unauthorized_client");
|
||||||
|
expect(response.body.error_description).toBe("client_rejected");
|
||||||
|
|
||||||
|
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.PENDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject token refresh when client becomes rejected", async () => {
|
||||||
|
const code = await generateAuthCodeForClient(pendingClientId);
|
||||||
|
|
||||||
|
const tokenResponse = await request(app.getHttpServer())
|
||||||
|
.post("/api/v2/auth/oauth2/token")
|
||||||
|
.type("form")
|
||||||
|
.send({
|
||||||
|
client_id: pendingClientId,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
client_secret: testClientSecret,
|
||||||
|
redirect_uri: testRedirectUri,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.REJECTED);
|
||||||
|
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/api/v2/auth/oauth2/token")
|
||||||
|
.type("form")
|
||||||
|
.send({
|
||||||
|
client_id: pendingClientId,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: tokenResponse.body.refresh_token,
|
||||||
|
client_secret: testClientSecret,
|
||||||
|
})
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
expect(response.body.error).toBe("unauthorized_client");
|
||||||
|
expect(response.body.error_description).toBe("client_rejected");
|
||||||
|
|
||||||
|
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.PENDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await oAuthClientFixture.delete(pendingClient.clientId);
|
||||||
|
await oAuthClientFixture.delete(rejectedClient.clientId);
|
||||||
|
await membershipRepositoryFixture.delete(membership.id);
|
||||||
|
await teamRepositoryFixture.delete(team.id);
|
||||||
|
await userRepositoryFixture.delete(owner.id);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,15 +16,16 @@ export class OAuth2HttpExceptionFilter implements ExceptionFilter<OAuth2HttpExce
|
|||||||
const requestId = request.headers["x-request-id"] ?? "unknown-request-id";
|
const requestId = request.headers["x-request-id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
|
|
||||||
const userContext = extractUserContext(request);
|
const { userEmail: _userEmail, ...safeUserContext } = extractUserContext(request);
|
||||||
|
const { Authorization: _auth, ...safeHeaders } = filterReqHeaders(request.headers);
|
||||||
this.logger.error(`OAuth2 Http Exception: ${exception.oAuthErrorData.error}`, {
|
this.logger.error(`OAuth2 Http Exception: ${exception.oAuthErrorData.error}`, {
|
||||||
exception,
|
exception,
|
||||||
body: "[REDACTED]",
|
body: "[REDACTED]",
|
||||||
headers: filterReqHeaders(request.headers),
|
headers: safeHeaders,
|
||||||
url: request.url,
|
url: request.url,
|
||||||
method: request.method,
|
method: request.method,
|
||||||
requestId,
|
requestId,
|
||||||
...userContext,
|
...safeUserContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
response.status(exception.getStatus()).json(exception.oAuthErrorData);
|
response.status(exception.getStatus()).json(exception.oAuthErrorData);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { plainToClass } from "class-transformer";
|
import { plainToClass } from "class-transformer";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
@@ -84,6 +84,7 @@ export class EventTypeWebhooksController {
|
|||||||
|
|
||||||
@Get("/:webhookId")
|
@Get("/:webhookId")
|
||||||
@ApiOperation({ summary: "Get a webhook" })
|
@ApiOperation({ summary: "Get a webhook" })
|
||||||
|
@ApiParam({ name: "webhookId", type: String, required: true })
|
||||||
async getEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
|
async getEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
|
||||||
return {
|
return {
|
||||||
status: SUCCESS_STATUS,
|
status: SUCCESS_STATUS,
|
||||||
@@ -116,6 +117,7 @@ export class EventTypeWebhooksController {
|
|||||||
|
|
||||||
@Delete("/:webhookId")
|
@Delete("/:webhookId")
|
||||||
@ApiOperation({ summary: "Delete a webhook" })
|
@ApiOperation({ summary: "Delete a webhook" })
|
||||||
|
@ApiParam({ name: "webhookId", type: String, required: true })
|
||||||
async deleteEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
|
async deleteEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
|
||||||
await this.webhooksService.deleteWebhook(webhook.id);
|
await this.webhooksService.deleteWebhook(webhook.id);
|
||||||
return {
|
return {
|
||||||
|
|||||||
+3
-1
@@ -3,7 +3,7 @@ import { MembershipRole } from "@calcom/platform-libraries";
|
|||||||
import { SkipTakePagination } from "@calcom/platform-types";
|
import { SkipTakePagination } from "@calcom/platform-types";
|
||||||
import type { Webhook } from "@calcom/prisma/client";
|
import type { Webhook } from "@calcom/prisma/client";
|
||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { plainToClass } from "class-transformer";
|
import { plainToClass } from "class-transformer";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||||
@@ -94,6 +94,7 @@ export class OAuthClientWebhooksController {
|
|||||||
summary: "Get a webhook",
|
summary: "Get a webhook",
|
||||||
description: `<Warning>These endpoints are deprecated and will be removed in the future.</Warning>`,
|
description: `<Warning>These endpoints are deprecated and will be removed in the future.</Warning>`,
|
||||||
})
|
})
|
||||||
|
@ApiParam({ name: "webhookId", type: String, required: true })
|
||||||
@UseGuards(IsOAuthClientWebhookGuard)
|
@UseGuards(IsOAuthClientWebhookGuard)
|
||||||
async getOAuthClientWebhook(@GetWebhook() webhook: Webhook): Promise<OAuthClientWebhookOutputResponseDto> {
|
async getOAuthClientWebhook(@GetWebhook() webhook: Webhook): Promise<OAuthClientWebhookOutputResponseDto> {
|
||||||
return {
|
return {
|
||||||
@@ -135,6 +136,7 @@ export class OAuthClientWebhooksController {
|
|||||||
summary: "Delete a webhook",
|
summary: "Delete a webhook",
|
||||||
description: `<Warning>These endpoints are deprecated and will be removed in the future.</Warning>`,
|
description: `<Warning>These endpoints are deprecated and will be removed in the future.</Warning>`,
|
||||||
})
|
})
|
||||||
|
@ApiParam({ name: "webhookId", type: String, required: true })
|
||||||
@UseGuards(IsOAuthClientWebhookGuard)
|
@UseGuards(IsOAuthClientWebhookGuard)
|
||||||
async deleteOAuthClientWebhook(
|
async deleteOAuthClientWebhook(
|
||||||
@GetWebhook() webhook: Webhook
|
@GetWebhook() webhook: Webhook
|
||||||
|
|||||||
+4
-4
@@ -515,7 +515,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
|
|||||||
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
|
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
|
||||||
const startTime = "2050-09-05T11:00:00.000Z";
|
const startTime = "2050-09-05T11:00:00.000Z";
|
||||||
const booking = await bookingsRepositoryFixture.create({
|
const booking = await bookingsRepositoryFixture.create({
|
||||||
uid: `booking-uid-${seatedEventTypeId}`,
|
uid: `booking-uid-seated-${seatedEventTypeId}-${randomString()}`,
|
||||||
title: "booking title",
|
title: "booking title",
|
||||||
startTime,
|
startTime,
|
||||||
endTime: "2050-09-05T12:00:00.000Z",
|
endTime: "2050-09-05T12:00:00.000Z",
|
||||||
@@ -549,7 +549,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bookingSeatsRepositoryFixture.create({
|
bookingSeatsRepositoryFixture.create({
|
||||||
referenceUid: "100",
|
referenceUid: `seat-${randomString()}`,
|
||||||
data: {},
|
data: {},
|
||||||
booking: {
|
booking: {
|
||||||
connect: {
|
connect: {
|
||||||
@@ -598,7 +598,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
|
|||||||
it("should do a booking for seated event and slot should show attendees count and bookingUid in range format", async () => {
|
it("should do a booking for seated event and slot should show attendees count and bookingUid in range format", async () => {
|
||||||
const startTime = "2050-09-05T11:00:00.000Z";
|
const startTime = "2050-09-05T11:00:00.000Z";
|
||||||
const booking = await bookingsRepositoryFixture.create({
|
const booking = await bookingsRepositoryFixture.create({
|
||||||
uid: `booking-uid-${seatedEventTypeId}`,
|
uid: `booking-uid-seated-${seatedEventTypeId}-${randomString()}`,
|
||||||
title: "booking title",
|
title: "booking title",
|
||||||
startTime,
|
startTime,
|
||||||
endTime: "2050-09-05T12:00:00.000Z",
|
endTime: "2050-09-05T12:00:00.000Z",
|
||||||
@@ -632,7 +632,7 @@ describe("Slots 2024-04-15 Endpoints", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bookingSeatsRepositoryFixture.create({
|
bookingSeatsRepositoryFixture.create({
|
||||||
referenceUid: "100",
|
referenceUid: `seat-${randomString()}`,
|
||||||
data: {},
|
data: {},
|
||||||
booking: {
|
booking: {
|
||||||
connect: {
|
connect: {
|
||||||
|
|||||||
+10
-9
@@ -832,7 +832,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
|||||||
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
|
it("should do a booking for seated event and slot should show attendees count and bookingUid", async () => {
|
||||||
const startTime = "2050-09-05T11:00:00.000Z";
|
const startTime = "2050-09-05T11:00:00.000Z";
|
||||||
const booking = await bookingsRepositoryFixture.create({
|
const booking = await bookingsRepositoryFixture.create({
|
||||||
uid: `booking-uid-${seatedEventType.id}`,
|
uid: `booking-uid-seated-${seatedEventType.id}-${randomString()}`,
|
||||||
title: "booking title",
|
title: "booking title",
|
||||||
startTime,
|
startTime,
|
||||||
endTime: "2050-09-05T12:00:00.000Z",
|
endTime: "2050-09-05T12:00:00.000Z",
|
||||||
@@ -866,7 +866,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bookingSeatsRepositoryFixture.create({
|
bookingSeatsRepositoryFixture.create({
|
||||||
referenceUid: "100",
|
referenceUid: `seat-${randomString()}`,
|
||||||
data: {},
|
data: {},
|
||||||
booking: {
|
booking: {
|
||||||
connect: {
|
connect: {
|
||||||
@@ -961,7 +961,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
|||||||
it("should do a booking for seated event and slot should show attendees count and bookingUid and return range format", async () => {
|
it("should do a booking for seated event and slot should show attendees count and bookingUid and return range format", async () => {
|
||||||
const startTime = "2050-09-05T11:00:00.000Z";
|
const startTime = "2050-09-05T11:00:00.000Z";
|
||||||
const booking = await bookingsRepositoryFixture.create({
|
const booking = await bookingsRepositoryFixture.create({
|
||||||
uid: `booking-uid-${seatedEventType.id}`,
|
uid: `booking-uid-seated-range-${seatedEventType.id}-${randomString()}`,
|
||||||
title: "booking title",
|
title: "booking title",
|
||||||
startTime,
|
startTime,
|
||||||
endTime: "2050-09-05T12:00:00.000Z",
|
endTime: "2050-09-05T12:00:00.000Z",
|
||||||
@@ -995,7 +995,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bookingSeatsRepositoryFixture.create({
|
bookingSeatsRepositoryFixture.create({
|
||||||
referenceUid: "100",
|
referenceUid: `seat-${randomString()}`,
|
||||||
data: {},
|
data: {},
|
||||||
booking: {
|
booking: {
|
||||||
connect: {
|
connect: {
|
||||||
@@ -1348,7 +1348,8 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
|||||||
advanceTo(newDate);
|
advanceTo(newDate);
|
||||||
|
|
||||||
const slotDuration = 60;
|
const slotDuration = 60;
|
||||||
const slotStartTime = "2050-09-05T10:00:00.000Z";
|
// Use 2050-09-06 to avoid overlap with other tests that reserve 2050-09-05
|
||||||
|
const slotStartTime = "2050-09-06T09:00:00.000Z";
|
||||||
const reserveResponse = await request(app.getHttpServer())
|
const reserveResponse = await request(app.getHttpServer())
|
||||||
.post(`/v2/slots/reservations`)
|
.post(`/v2/slots/reservations`)
|
||||||
.send({
|
.send({
|
||||||
@@ -1377,7 +1378,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
|||||||
|
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.get(
|
.get(
|
||||||
`/v2/slots?eventTypeId=${variableLengthEventType.id}&start=2050-09-05&end=2050-09-09&duration=60`
|
`/v2/slots?eventTypeId=${variableLengthEventType.id}&start=2050-09-05&end=2050-09-10&duration=60`
|
||||||
)
|
)
|
||||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
@@ -1390,10 +1391,10 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
|||||||
const days = Object.keys(slots);
|
const days = Object.keys(slots);
|
||||||
expect(days.length).toEqual(5);
|
expect(days.length).toEqual(5);
|
||||||
|
|
||||||
const expectedSlotsUTC2050_09_05 = expectedSlotsUTC["2050-09-05"].filter(
|
const expectedSlotsUTC2050_09_06 = expectedSlotsUTC["2050-09-06"].filter(
|
||||||
(slot) => slot.start !== slotStartTime
|
(slot) => slot.start !== slotStartTime
|
||||||
);
|
);
|
||||||
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
|
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-06": expectedSlotsUTC2050_09_06 });
|
||||||
|
|
||||||
const dbSlot = await selectedSlotRepositoryFixture.getByUid(
|
const dbSlot = await selectedSlotRepositoryFixture.getByUid(
|
||||||
responseReservedVariableSlot.reservationUid
|
responseReservedVariableSlot.reservationUid
|
||||||
@@ -1409,7 +1410,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("request slot contains already existing reserved slot", async () => {
|
it("request slot contains already existing reserved slot", async () => {
|
||||||
// Try to reserve 9:45-12:45 when 10:00-11:00 is taken
|
// Try to reserve 8:45-11:45 when 09:00-10:00 on 2050-09-06 is taken
|
||||||
const newSlotStart = DateTime.fromISO(responseReservedVariableSlot.slotStart)
|
const newSlotStart = DateTime.fromISO(responseReservedVariableSlot.slotStart)
|
||||||
.minus({ minutes: 15 })
|
.minus({ minutes: 15 })
|
||||||
.toISO();
|
.toISO();
|
||||||
|
|||||||
+3
-1
@@ -13,7 +13,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { plainToClass } from "class-transformer";
|
import { plainToClass } from "class-transformer";
|
||||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||||
@@ -87,6 +87,7 @@ export class TeamsEventTypesWebhooksController {
|
|||||||
|
|
||||||
@Get("/:webhookId")
|
@Get("/:webhookId")
|
||||||
@ApiOperation({ summary: "Get a webhook for a team event type" })
|
@ApiOperation({ summary: "Get a webhook for a team event type" })
|
||||||
|
@ApiParam({ name: "webhookId", type: String, required: true })
|
||||||
@Roles("TEAM_MEMBER")
|
@Roles("TEAM_MEMBER")
|
||||||
async getTeamEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
|
async getTeamEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
|
||||||
return {
|
return {
|
||||||
@@ -122,6 +123,7 @@ export class TeamsEventTypesWebhooksController {
|
|||||||
@Delete("/:webhookId")
|
@Delete("/:webhookId")
|
||||||
@Roles("TEAM_ADMIN")
|
@Roles("TEAM_ADMIN")
|
||||||
@ApiOperation({ summary: "Delete a webhook for a team event type" })
|
@ApiOperation({ summary: "Delete a webhook for a team event type" })
|
||||||
|
@ApiParam({ name: "webhookId", type: String, required: true })
|
||||||
async deleteTeamEventTypeWebhook(
|
async deleteTeamEventTypeWebhook(
|
||||||
@GetWebhook() webhook: Webhook
|
@GetWebhook() webhook: Webhook
|
||||||
): Promise<EventTypeWebhookOutputResponseDto> {
|
): Promise<EventTypeWebhookOutputResponseDto> {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe";
|
|||||||
import { UserWebhooksService } from "@/modules/webhooks/services/user-webhooks.service";
|
import { UserWebhooksService } from "@/modules/webhooks/services/user-webhooks.service";
|
||||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||||
import { Controller, Post, Body, UseGuards, Get, Param, Query, Delete, Patch } from "@nestjs/common";
|
import { Controller, Post, Body, UseGuards, Get, Param, Query, Delete, Patch } from "@nestjs/common";
|
||||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
import { plainToClass } from "class-transformer";
|
import { plainToClass } from "class-transformer";
|
||||||
|
|
||||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||||
@@ -75,6 +75,7 @@ export class WebhooksController {
|
|||||||
|
|
||||||
@Get("/:webhookId")
|
@Get("/:webhookId")
|
||||||
@ApiOperation({ summary: "Get a webhook" })
|
@ApiOperation({ summary: "Get a webhook" })
|
||||||
|
@ApiParam({ name: "webhookId", type: String, required: true })
|
||||||
@UseGuards(IsUserWebhookGuard)
|
@UseGuards(IsUserWebhookGuard)
|
||||||
async getWebhook(@GetWebhook() webhook: Webhook): Promise<UserWebhookOutputResponseDto> {
|
async getWebhook(@GetWebhook() webhook: Webhook): Promise<UserWebhookOutputResponseDto> {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -114,7 +114,11 @@ export class WorkflowsRepository {
|
|||||||
) {
|
) {
|
||||||
await updateWorkflow({
|
await updateWorkflow({
|
||||||
ctx: {
|
ctx: {
|
||||||
user: { ...user, locale: user?.locale ?? "en" },
|
user: {
|
||||||
|
...user,
|
||||||
|
locale: user?.locale ?? "en",
|
||||||
|
organizationId: user.profiles?.[0]?.organization?.id ?? null,
|
||||||
|
},
|
||||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||||
},
|
},
|
||||||
input: data,
|
input: data,
|
||||||
@@ -132,7 +136,11 @@ export class WorkflowsRepository {
|
|||||||
) {
|
) {
|
||||||
await updateWorkflow({
|
await updateWorkflow({
|
||||||
ctx: {
|
ctx: {
|
||||||
user: { ...user, locale: user?.locale ?? "en" },
|
user: {
|
||||||
|
...user,
|
||||||
|
locale: user?.locale ?? "en",
|
||||||
|
organizationId: user.profiles?.[0]?.organization?.id ?? null,
|
||||||
|
},
|
||||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||||
},
|
},
|
||||||
input: data,
|
input: data,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class OAuth2ClientRepositoryFixture {
|
|||||||
status?: OAuthClientStatus;
|
status?: OAuthClientStatus;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
isTrusted?: boolean;
|
isTrusted?: boolean;
|
||||||
|
userId?: number;
|
||||||
}) {
|
}) {
|
||||||
return this.prismaWriteClient.oAuthClient.create({
|
return this.prismaWriteClient.oAuthClient.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -32,10 +33,18 @@ export class OAuth2ClientRepositoryFixture {
|
|||||||
status: data.status || OAuthClientStatus.APPROVED,
|
status: data.status || OAuthClientStatus.APPROVED,
|
||||||
logo: data.logo,
|
logo: data.logo,
|
||||||
isTrusted: data.isTrusted || false,
|
isTrusted: data.isTrusted || false,
|
||||||
|
...(data.userId && { user: { connect: { id: data.userId } } }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateStatus(clientId: string, status: OAuthClientStatus) {
|
||||||
|
return this.prismaWriteClient.oAuthClient.update({
|
||||||
|
where: { clientId },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async delete(clientId: string) {
|
async delete(clientId: string) {
|
||||||
return this.prismaWriteClient.oAuthClient.delete({
|
return this.prismaWriteClient.oAuthClient.delete({
|
||||||
where: { clientId },
|
where: { clientId },
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const TRIGGER_VERSION = '20260209.2';
|
export const TRIGGER_VERSION = '20260216.1';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
|||||||
import type { PageProps as ServerPageProps } from "app/_types";
|
import type { PageProps as ServerPageProps } from "app/_types";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
|
||||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
import { loadTranslations } from "@calcom/i18n/server";
|
||||||
|
|
||||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { generateMeetingMetadata } from "app/_utils";
|
|||||||
import { headers, cookies } from "next/headers";
|
import { headers, cookies } from "next/headers";
|
||||||
|
|
||||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
import { loadTranslations } from "@calcom/i18n/server";
|
||||||
|
|
||||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
|||||||
import type { PageProps as ServerPageProps } from "app/_types";
|
import type { PageProps as ServerPageProps } from "app/_types";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
|
||||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
import { loadTranslations } from "@calcom/i18n/server";
|
||||||
|
|
||||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { _generateMetadata } from "app/_utils";
|
|||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
|
||||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
import { loadTranslations } from "@calcom/i18n/server";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
|||||||
import type { PageProps as ServerPageProps } from "app/_types";
|
import type { PageProps as ServerPageProps } from "app/_types";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
|
||||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
import { loadTranslations } from "@calcom/i18n/server";
|
||||||
|
|
||||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||||
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { generateMeetingMetadata } from "app/_utils";
|
|||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
|
||||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
import { loadTranslations } from "@calcom/i18n/server";
|
||||||
|
|
||||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||||
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
|||||||
import type { PageProps as ServerPageProps } from "app/_types";
|
import type { PageProps as ServerPageProps } from "app/_types";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
|
||||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
import { loadTranslations } from "@calcom/i18n/server";
|
||||||
|
|
||||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||||
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { cookies, headers } from "next/headers";
|
|||||||
|
|
||||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
import { loadTranslations } from "@calcom/i18n/server";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { getOrgFullOrigin, orgDomainConfig } from "@calcom/features/ee/organizat
|
|||||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||||
import type { TeamData } from "@calcom/features/ee/teams/lib/getTeamData";
|
import type { TeamData } from "@calcom/features/ee/teams/lib/getTeamData";
|
||||||
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
|
import { shouldHideBrandingForTeamEvent } from "@calcom/features/profile/lib/hideBranding";
|
||||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
import { loadTranslations } from "@calcom/i18n/server";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
|
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Added as a separate route for now to ease the testing of the audit logs feature
|
// Added as a separate route for now to ease the testing of the audit logs feature
|
||||||
// It partially matches the figma design - https://www.figma.com/design/wleA2SR6rn60EK7ORxAfMy/Cal.com-New-Features?node-id=5641-6732&p=f
|
// It partially matches the figma design - https://www.figma.com/design/wleA2SR6rn60EK7ORxAfMy/Cal.com-New-Features?node-id=5641-6732&p=f
|
||||||
// TOOD: Move it to the booking page side bar later
|
// TOOD: Move it to the booking page side bar later
|
||||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||||
import type { PageProps } from "app/_types";
|
import type { PageProps } from "app/_types";
|
||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||||
@@ -14,35 +14,34 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
|||||||
import { BookingHistoryPage } from "@calcom/web/modules/booking-audit/components/BookingHistoryPage";
|
import { BookingHistoryPage } from "@calcom/web/modules/booking-audit/components/BookingHistoryPage";
|
||||||
|
|
||||||
export const generateMetadata = async ({ params }: { params: Promise<{ uid: string }> }) =>
|
export const generateMetadata = async ({ params }: { params: Promise<{ uid: string }> }) =>
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
(t) => t("booking_history"),
|
(t) => t("booking_history"),
|
||||||
(t) => t("booking_history_description"),
|
(t) => t("booking_history_description"),
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
`/booking/${(await params).uid}/logs`
|
`/booking/${(await params).uid}/logs`
|
||||||
);
|
);
|
||||||
|
|
||||||
const Page = async ({ params }: PageProps) => {
|
const Page = async ({ params }: PageProps) => {
|
||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
const bookingUid = resolvedParams.uid;
|
const bookingUid = resolvedParams.uid;
|
||||||
|
|
||||||
if (!bookingUid || typeof bookingUid !== "string") {
|
if (!bookingUid || typeof bookingUid !== "string") {
|
||||||
redirect("/bookings/upcoming");
|
redirect("/bookings/upcoming");
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
redirect("/auth/login");
|
redirect("/auth/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShellMainAppDir heading={t("booking_history")} subtitle={t("booking_history_description")}>
|
<ShellMainAppDir heading={t("booking_history")} subtitle={t("booking_history_description")}>
|
||||||
<BookingHistoryPage bookingUid={bookingUid} />
|
<BookingHistoryPage bookingUid={bookingUid} />
|
||||||
</ShellMainAppDir>
|
</ShellMainAppDir>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import type { RouterOutputs } from "@calcom/trpc/react";
|
|||||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
|
import { createRouterCaller, getTRPCContext } from "app/_trpc/context";
|
||||||
import type {
|
import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_types";
|
||||||
PageProps,
|
|
||||||
ReadonlyHeaders,
|
|
||||||
ReadonlyRequestCookies,
|
|
||||||
} from "app/_types";
|
|
||||||
import { _generateMetadata } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
import { unstable_cache } from "next/cache";
|
import { unstable_cache } from "next/cache";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
@@ -26,26 +22,25 @@ const getCachedEventGroups: (
|
|||||||
userIds?: number[] | undefined;
|
userIds?: number[] | undefined;
|
||||||
upIds?: string[] | undefined;
|
upIds?: string[] | undefined;
|
||||||
}
|
}
|
||||||
) => Promise<RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"]> =
|
) => Promise<RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"]> = unstable_cache(
|
||||||
unstable_cache(
|
async (
|
||||||
async (
|
headers: ReadonlyHeaders,
|
||||||
headers: ReadonlyHeaders,
|
cookies: ReadonlyRequestCookies,
|
||||||
cookies: ReadonlyRequestCookies,
|
filters?: {
|
||||||
filters?: {
|
teamIds?: number[] | undefined;
|
||||||
teamIds?: number[] | undefined;
|
userIds?: number[] | undefined;
|
||||||
userIds?: number[] | undefined;
|
upIds?: string[] | undefined;
|
||||||
upIds?: string[] | undefined;
|
}
|
||||||
}
|
): Promise<RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"]> => {
|
||||||
): Promise<RouterOutputs["viewer"]["eventTypes"]["getUserEventGroups"]> => {
|
const eventTypesCaller = await createRouterCaller(
|
||||||
const eventTypesCaller = await createRouterCaller(
|
eventTypesRouter,
|
||||||
eventTypesRouter,
|
await getTRPCContext(headers, cookies)
|
||||||
await getTRPCContext(headers, cookies)
|
);
|
||||||
);
|
return await eventTypesCaller.getUserEventGroups({ filters });
|
||||||
return await eventTypesCaller.getUserEventGroups({ filters });
|
},
|
||||||
},
|
["viewer.eventTypes.getUserEventGroups"],
|
||||||
["viewer.eventTypes.getUserEventGroups"],
|
{ revalidate: 3600 } // seconds
|
||||||
{ revalidate: 3600 } // seconds
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const Page = async ({ searchParams }: PageProps): Promise<ReactElement> => {
|
const Page = async ({ searchParams }: PageProps): Promise<ReactElement> => {
|
||||||
const _searchParams = await searchParams;
|
const _searchParams = await searchParams;
|
||||||
@@ -71,23 +66,12 @@ const Page = async ({ searchParams }: PageProps): Promise<ReactElement> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filters = getTeamsFiltersFromQuery(_searchParams);
|
const filters = getTeamsFiltersFromQuery(_searchParams);
|
||||||
const userEventGroupsData = await getCachedEventGroups(
|
const userEventGroupsData = await getCachedEventGroups(_headers, _cookies, filters);
|
||||||
_headers,
|
|
||||||
_cookies,
|
|
||||||
filters
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return <EventTypesWrapper userEventGroupsData={userEventGroupsData} user={session.user} />;
|
||||||
<EventTypesWrapper
|
|
||||||
userEventGroupsData={userEventGroupsData}
|
|
||||||
user={session.user}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateMetadata = async (): Promise<
|
export const generateMetadata = async (): Promise<ReturnType<typeof _generateMetadata>> =>
|
||||||
ReturnType<typeof _generateMetadata>
|
|
||||||
> =>
|
|
||||||
await _generateMetadata(
|
await _generateMetadata(
|
||||||
(t) => t("event_types_page_title"),
|
(t) => t("event_types_page_title"),
|
||||||
(t) => t("event_types_page_subtitle"),
|
(t) => t("event_types_page_subtitle"),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const TeamsCTA = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
posthog.capture("add_team_button_clicked")
|
posthog.capture("add_team_button_clicked");
|
||||||
}}
|
}}
|
||||||
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
href={`${WEBAPP_URL}/settings/teams/new?returnTo=${WEBAPP_URL}/teams`}>
|
||||||
{t("new")}
|
{t("new")}
|
||||||
|
|||||||
@@ -38,12 +38,15 @@ const ServerPage = async () => {
|
|||||||
const categoryQuery = appStore.map(({ categories }) => ({
|
const categoryQuery = appStore.map(({ categories }) => ({
|
||||||
categories: categories || [],
|
categories: categories || [],
|
||||||
}));
|
}));
|
||||||
const categories = categoryQuery.reduce((c, app) => {
|
const categories = categoryQuery.reduce(
|
||||||
for (const category of app.categories) {
|
(c, app) => {
|
||||||
c[category] = c[category] ? c[category] + 1 : 1;
|
for (const category of app.categories) {
|
||||||
}
|
c[category] = c[category] ? c[category] + 1 : 1;
|
||||||
return c;
|
}
|
||||||
}, {} as Record<string, number>);
|
return c;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
);
|
||||||
|
|
||||||
const props = {
|
const props = {
|
||||||
categories: Object.entries(categories)
|
categories: Object.entries(categories)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { FieldTypes } from "@calcom/app-store/routing-forms/lib/FieldTypes";
|
import { FieldTypes } from "@calcom/app-store/routing-forms/lib/FieldTypes";
|
||||||
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
||||||
|
import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink";
|
||||||
import { getFieldIdentifier } from "@calcom/features/form-builder/utils/getFieldIdentifier";
|
import { getFieldIdentifier } from "@calcom/features/form-builder/utils/getFieldIdentifier";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
@@ -14,10 +15,10 @@ import {
|
|||||||
SelectField,
|
SelectField,
|
||||||
TextField,
|
TextField,
|
||||||
} from "@calcom/ui/components/form";
|
} from "@calcom/ui/components/form";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
|
||||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
import type { getServerSidePropsForSingleFormView as getServerSideProps } from "@calcom/web/lib/apps/routing-forms/[...pages]/getServerSidePropsSingleForm";
|
import type { getServerSidePropsForSingleFormView as getServerSideProps } from "@calcom/web/lib/apps/routing-forms/[...pages]/getServerSidePropsSingleForm";
|
||||||
import SingleForm from "@components/apps/routing-forms/SingleForm";
|
import SingleForm from "@components/apps/routing-forms/SingleForm";
|
||||||
|
import { ChevronDownIcon, MenuIcon } from "@coss/ui/icons";
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
import type { UseFormReturn } from "react-hook-form";
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
@@ -93,8 +94,7 @@ function Field({
|
|||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
deleteField={router ? null : deleteField}
|
deleteField={router ? null : deleteField}>
|
||||||
>
|
|
||||||
<FormCardBody>
|
<FormCardBody>
|
||||||
<div className="mb-3 w-full">
|
<div className="mb-3 w-full">
|
||||||
<TextField
|
<TextField
|
||||||
@@ -114,17 +114,12 @@ function Field({
|
|||||||
hookForm.setValue(`${hookFieldNamespace}.label`, newLabel, {
|
hookForm.setValue(`${hookFieldNamespace}.label`, newLabel, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
const currentIdentifier = hookForm.getValues(
|
const currentIdentifier = hookForm.getValues(`${hookFieldNamespace}.identifier`);
|
||||||
`${hookFieldNamespace}.identifier`
|
|
||||||
);
|
|
||||||
// Only auto-update identifier if it was auto-generated from the previous label
|
// Only auto-update identifier if it was auto-generated from the previous label
|
||||||
// This preserves manual identifier changes
|
// This preserves manual identifier changes
|
||||||
const isIdentifierGeneratedFromPreviousLabel =
|
const isIdentifierGeneratedFromPreviousLabel =
|
||||||
currentIdentifier === getFieldIdentifier(previousLabel).toLowerCase();
|
currentIdentifier === getFieldIdentifier(previousLabel).toLowerCase();
|
||||||
if (
|
if (!currentIdentifier || isIdentifierGeneratedFromPreviousLabel) {
|
||||||
!currentIdentifier ||
|
|
||||||
isIdentifierGeneratedFromPreviousLabel
|
|
||||||
) {
|
|
||||||
hookForm.setValue(
|
hookForm.setValue(
|
||||||
`${hookFieldNamespace}.identifier`,
|
`${hookFieldNamespace}.identifier`,
|
||||||
getFieldIdentifier(newLabel).toLowerCase(),
|
getFieldIdentifier(newLabel).toLowerCase(),
|
||||||
@@ -138,23 +133,21 @@ function Field({
|
|||||||
<TextField
|
<TextField
|
||||||
disabled={!!router}
|
disabled={!!router}
|
||||||
label={t("identifier_url_parameter")}
|
label={t("identifier_url_parameter")}
|
||||||
hint={t("identifier_url_parameter_hint")}
|
hint={
|
||||||
|
<LearnMoreLink
|
||||||
|
t={t}
|
||||||
|
i18nKey="identifier_url_parameter_hint"
|
||||||
|
href="https://cal.com/help/routing/connect-routing-form-to-booking-questions"
|
||||||
|
/>
|
||||||
|
}
|
||||||
name={`${hookFieldNamespace}.identifier`}
|
name={`${hookFieldNamespace}.identifier`}
|
||||||
required
|
required
|
||||||
placeholder={t("identifies_name_field")}
|
placeholder={t("identifies_name_field")}
|
||||||
value={
|
value={identifier || routerField?.identifier || label || routerField?.label || ""}
|
||||||
identifier ||
|
|
||||||
routerField?.identifier ||
|
|
||||||
label ||
|
|
||||||
routerField?.label ||
|
|
||||||
""
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
hookForm.setValue(
|
hookForm.setValue(`${hookFieldNamespace}.identifier`, e.target.value.toLowerCase(), {
|
||||||
`${hookFieldNamespace}.identifier`,
|
shouldDirty: true,
|
||||||
e.target.value.toLowerCase(),
|
});
|
||||||
{ shouldDirty: true }
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,9 +157,7 @@ function Field({
|
|||||||
control={hookForm.control}
|
control={hookForm.control}
|
||||||
defaultValue={routerField?.type}
|
defaultValue={routerField?.type}
|
||||||
render={({ field: { value, onChange } }) => {
|
render={({ field: { value, onChange } }) => {
|
||||||
const defaultValue = FieldTypes.find(
|
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
|
||||||
(fieldType) => fieldType.value === value
|
|
||||||
);
|
|
||||||
if (disableTypeChange) {
|
if (disableTypeChange) {
|
||||||
return (
|
return (
|
||||||
<div className="data-testid-field-type">
|
<div className="data-testid-field-type">
|
||||||
@@ -179,15 +170,9 @@ function Field({
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
"h-8 w-full justify-between text-left text-sm",
|
"h-8 w-full justify-between text-left text-sm",
|
||||||
!!router && "bg-subtle cursor-not-allowed"
|
!!router && "bg-subtle cursor-not-allowed"
|
||||||
)}
|
)}>
|
||||||
>
|
<span className="text-default">{defaultValue?.label || t("select_field_type")}</span>
|
||||||
<span className="text-default">
|
<ChevronDownIcon className="text-default h-4 w-4" />
|
||||||
{defaultValue?.label || "Select field type"}
|
|
||||||
</span>
|
|
||||||
<Icon
|
|
||||||
name="chevron-down"
|
|
||||||
className="text-default h-4 w-4"
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,9 +289,7 @@ const FormEdit = ({
|
|||||||
<div className="w-full py-4 lg:py-8">
|
<div className="w-full py-4 lg:py-8">
|
||||||
<div ref={animationRef} className="flex w-full flex-col rounded-md">
|
<div ref={animationRef} className="flex w-full flex-col rounded-md">
|
||||||
{hookFormFields.map((field, key) => {
|
{hookFormFields.map((field, key) => {
|
||||||
const existingField = Boolean(
|
const existingField = Boolean((form.fields || []).find((f) => f.id === field.id));
|
||||||
(form.fields || []).find((f) => f.id === field.id)
|
|
||||||
);
|
|
||||||
const hasFormResponses = (form._count?.responses ?? 0) > 0;
|
const hasFormResponses = (form._count?.responses ?? 0) > 0;
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
@@ -343,13 +326,7 @@ const FormEdit = ({
|
|||||||
</div>
|
</div>
|
||||||
{hookFormFields.length ? (
|
{hookFormFields.length ? (
|
||||||
<div className={classNames("flex")}>
|
<div className={classNames("flex")}>
|
||||||
<Button
|
<Button data-testid="add-field" type="button" StartIcon="plus" color="secondary" onClick={addField}>
|
||||||
data-testid="add-field"
|
|
||||||
type="button"
|
|
||||||
StartIcon="plus"
|
|
||||||
color="secondary"
|
|
||||||
onClick={addField}
|
|
||||||
>
|
|
||||||
Add question
|
Add question
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,7 +340,7 @@ const FormEdit = ({
|
|||||||
{/* Icon card - Top */}
|
{/* Icon card - Top */}
|
||||||
<div className="bg-default border-subtle z-30 col-start-1 col-end-1 row-start-1 row-end-1 h-10 w-10 transform rounded-md border shadow-sm">
|
<div className="bg-default border-subtle z-30 col-start-1 col-end-1 row-start-1 row-end-1 h-10 w-10 transform rounded-md border shadow-sm">
|
||||||
<div className="text-emphasis flex h-full items-center justify-center">
|
<div className="text-emphasis flex h-full items-center justify-center">
|
||||||
<Icon name="menu" className="text-emphasis h-4 w-4" />
|
<MenuIcon className="text-emphasis h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Left fanned card */}
|
{/* Left fanned card */}
|
||||||
@@ -389,12 +366,7 @@ const FormEdit = ({
|
|||||||
Fields are the form fields that the booker would see.
|
Fields are the form fields that the booker would see.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button data-testid="add-field" onClick={addField} StartIcon="plus" className="mt-6">
|
||||||
data-testid="add-field"
|
|
||||||
onClick={addField}
|
|
||||||
StartIcon="plus"
|
|
||||||
className="mt-6"
|
|
||||||
>
|
|
||||||
Add question
|
Add question
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,9 +386,7 @@ export default function FormEditPage({
|
|||||||
{...props}
|
{...props}
|
||||||
appUrl={appUrl}
|
appUrl={appUrl}
|
||||||
permissions={permissions}
|
permissions={permissions}
|
||||||
Page={({ hookForm, form }) => (
|
Page={({ hookForm, form }) => <FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />}
|
||||||
<FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+19
-24
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import type z from "zod";
|
|
||||||
|
|
||||||
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
||||||
import { WhenToWriteToRecord, SalesforceFieldType } from "@calcom/app-store/salesforce/lib/enums";
|
import { SalesforceFieldType, WhenToWriteToRecord } from "@calcom/app-store/salesforce/lib/enums";
|
||||||
import type { writeToRecordDataSchema as salesforceWriteToRecordDataSchema } from "@calcom/app-store/salesforce/zod";
|
import type { writeToRecordDataSchema as salesforceWriteToRecordDataSchema } from "@calcom/app-store/salesforce/zod";
|
||||||
import { routingFormIncompleteBookingDataSchema as salesforceRoutingFormIncompleteBookingDataSchema } from "@calcom/app-store/salesforce/zod";
|
import { routingFormIncompleteBookingDataSchema as salesforceRoutingFormIncompleteBookingDataSchema } from "@calcom/app-store/salesforce/zod";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -12,15 +9,13 @@ import { IncompleteBookingActionType } from "@calcom/prisma/enums";
|
|||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { Switch } from "@calcom/ui/components/form";
|
import { InputField, Label, Select, Switch } from "@calcom/ui/components/form";
|
||||||
import { InputField } from "@calcom/ui/components/form";
|
|
||||||
import { Select } from "@calcom/ui/components/form";
|
|
||||||
import { Label } from "@calcom/ui/components/form";
|
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
import type { getServerSidePropsForSingleFormView as getServerSideProps } from "@calcom/web/lib/apps/routing-forms/[...pages]/getServerSidePropsSingleForm";
|
import type { getServerSidePropsForSingleFormView as getServerSideProps } from "@calcom/web/lib/apps/routing-forms/[...pages]/getServerSidePropsSingleForm";
|
||||||
|
|
||||||
import SingleForm from "@components/apps/routing-forms/SingleForm";
|
import SingleForm from "@components/apps/routing-forms/SingleForm";
|
||||||
|
import { GlobeIcon } from "@coss/ui/icons";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type z from "zod";
|
||||||
|
|
||||||
function Page({ form }: { form: RoutingFormWithResponseCount }) {
|
function Page({ form }: { form: RoutingFormWithResponseCount }) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -88,8 +83,8 @@ function Page({ form }: { form: RoutingFormWithResponseCount }) {
|
|||||||
|
|
||||||
setSelectedCredential(
|
setSelectedCredential(
|
||||||
credentialOptions
|
credentialOptions
|
||||||
? credentialOptions.find((option) => option.value === salesforceAction?.credentialId) ??
|
? (credentialOptions.find((option) => option.value === salesforceAction?.credentialId) ??
|
||||||
selectedCredential
|
selectedCredential)
|
||||||
: selectedCredential
|
: selectedCredential
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -105,7 +100,7 @@ function Page({ form }: { form: RoutingFormWithResponseCount }) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<div className="border-subtle rounded-lg border p-1">
|
<div className="border-subtle rounded-lg border p-1">
|
||||||
<Icon name="globe" className="text-subtle h-4 w-4" />
|
<GlobeIcon className="text-subtle h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-emphasis ml-2 text-sm font-medium">
|
<span className="text-emphasis ml-2 text-sm font-medium">
|
||||||
@@ -128,17 +123,17 @@ function Page({ form }: { form: RoutingFormWithResponseCount }) {
|
|||||||
<div className="bg-default mt-2 rounded-xl px-2 py-2">
|
<div className="bg-default mt-2 rounded-xl px-2 py-2">
|
||||||
<Label>Credential to use</Label>
|
<Label>Credential to use</Label>
|
||||||
<Select
|
<Select
|
||||||
size="sm"
|
size="sm"
|
||||||
options={credentialOptions}
|
options={credentialOptions}
|
||||||
value={selectedCredential}
|
value={selectedCredential}
|
||||||
onChange={(option) => {
|
onChange={(option) => {
|
||||||
if (!option) {
|
if (!option) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedCredential(option);
|
setSelectedCredential(option);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import React, { useCallback, useState, useEffect } from "react";
|
|
||||||
import { Query, Builder, Utils as QbUtils } from "react-awesome-query-builder";
|
|
||||||
import type { ImmutableTree, BuilderProps, Config } from "react-awesome-query-builder";
|
|
||||||
import type { JsonTree } from "react-awesome-query-builder";
|
|
||||||
import type { UseFormReturn } from "react-hook-form";
|
|
||||||
import { Toaster } from "sonner";
|
|
||||||
|
|
||||||
import { buildEmptyQueryValue } from "@calcom/app-store/_utils/raqb/raqbUtils.client";
|
import { buildEmptyQueryValue } from "@calcom/app-store/_utils/raqb/raqbUtils.client";
|
||||||
import { raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils.server";
|
import { raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils.server";
|
||||||
import { routingFormAppComponents } from "@calcom/app-store/routing-forms/appComponents";
|
import { routingFormAppComponents } from "@calcom/app-store/routing-forms/appComponents";
|
||||||
import DynamicAppComponent from "@calcom/app-store/routing-forms/components/DynamicAppComponent";
|
|
||||||
import { EmptyState } from "@calcom/app-store/routing-forms/components/_components/EmptyState";
|
import { EmptyState } from "@calcom/app-store/routing-forms/components/_components/EmptyState";
|
||||||
import { RoutingSkeleton } from "@calcom/app-store/routing-forms/components/_components/RoutingSkeleton";
|
import { RoutingSkeleton } from "@calcom/app-store/routing-forms/components/_components/RoutingSkeleton";
|
||||||
|
import DynamicAppComponent from "@calcom/app-store/routing-forms/components/DynamicAppComponent";
|
||||||
import {
|
import {
|
||||||
withRaqbSettingsAndWidgets,
|
|
||||||
ConfigFor,
|
ConfigFor,
|
||||||
|
withRaqbSettingsAndWidgets,
|
||||||
} from "@calcom/app-store/routing-forms/components/react-awesome-query-builder/config/uiConfig";
|
} from "@calcom/app-store/routing-forms/components/react-awesome-query-builder/config/uiConfig";
|
||||||
import { RoutingPages } from "@calcom/app-store/routing-forms/lib/RoutingPages";
|
|
||||||
import { createFallbackRoute } from "@calcom/app-store/routing-forms/lib/createFallbackRoute";
|
import { createFallbackRoute } from "@calcom/app-store/routing-forms/lib/createFallbackRoute";
|
||||||
import getEventTypeAppMetadata from "@calcom/app-store/routing-forms/lib/getEventTypeAppMetadata";
|
import getEventTypeAppMetadata from "@calcom/app-store/routing-forms/lib/getEventTypeAppMetadata";
|
||||||
import {
|
import {
|
||||||
getQueryBuilderConfigForFormFields,
|
|
||||||
getQueryBuilderConfigForAttributes,
|
|
||||||
type FormFieldsQueryBuilderConfigWithRaqbFields,
|
|
||||||
type AttributesQueryBuilderConfigWithRaqbFields,
|
type AttributesQueryBuilderConfigWithRaqbFields,
|
||||||
|
type FormFieldsQueryBuilderConfigWithRaqbFields,
|
||||||
|
getQueryBuilderConfigForAttributes,
|
||||||
|
getQueryBuilderConfigForFormFields,
|
||||||
isDynamicOperandField,
|
isDynamicOperandField,
|
||||||
} from "@calcom/app-store/routing-forms/lib/getQueryBuilderConfig";
|
} from "@calcom/app-store/routing-forms/lib/getQueryBuilderConfig";
|
||||||
import isRouter from "@calcom/app-store/routing-forms/lib/isRouter";
|
import isRouter from "@calcom/app-store/routing-forms/lib/isRouter";
|
||||||
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
import { RoutingPages } from "@calcom/app-store/routing-forms/lib/RoutingPages";
|
||||||
import type {
|
import type {
|
||||||
|
Attribute,
|
||||||
|
AttributeRoutingConfig,
|
||||||
|
EditFormRoute,
|
||||||
GlobalRoute,
|
GlobalRoute,
|
||||||
LocalRoute,
|
LocalRoute,
|
||||||
|
RoutingFormWithResponseCount,
|
||||||
SerializableRoute,
|
SerializableRoute,
|
||||||
Attribute,
|
|
||||||
EditFormRoute,
|
|
||||||
AttributeRoutingConfig,
|
|
||||||
} from "@calcom/app-store/routing-forms/types/types";
|
} from "@calcom/app-store/routing-forms/types/types";
|
||||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||||
@@ -52,15 +43,25 @@ import classNames from "@calcom/ui/classNames";
|
|||||||
import { Badge } from "@calcom/ui/components/badge";
|
import { Badge } from "@calcom/ui/components/badge";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { FormCard } from "@calcom/ui/components/card";
|
import { FormCard } from "@calcom/ui/components/card";
|
||||||
import { SelectWithValidation as Select, TextArea } from "@calcom/ui/components/form";
|
import {
|
||||||
import { TextField } from "@calcom/ui/components/form";
|
SelectWithValidation as Select,
|
||||||
import { SelectField } from "@calcom/ui/components/form";
|
SelectField,
|
||||||
import { Switch } from "@calcom/ui/components/form";
|
Switch,
|
||||||
|
TextArea,
|
||||||
|
TextField,
|
||||||
|
} from "@calcom/ui/components/form";
|
||||||
import type { IconName } from "@calcom/ui/components/icon";
|
import type { IconName } from "@calcom/ui/components/icon";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
import type { getServerSidePropsForSingleFormView as getServerSideProps } from "@calcom/web/lib/apps/routing-forms/[...pages]/getServerSidePropsSingleForm";
|
import type { getServerSidePropsForSingleFormView as getServerSideProps } from "@calcom/web/lib/apps/routing-forms/[...pages]/getServerSidePropsSingleForm";
|
||||||
|
|
||||||
import SingleForm from "@components/apps/routing-forms/SingleForm";
|
import SingleForm from "@components/apps/routing-forms/SingleForm";
|
||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { BuilderProps, Config, ImmutableTree, JsonTree } from "react-awesome-query-builder";
|
||||||
|
import { Builder, Utils as QbUtils, Query } from "react-awesome-query-builder";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
type Form = inferSSRProps<typeof getServerSideProps>["form"];
|
type Form = inferSSRProps<typeof getServerSideProps>["form"];
|
||||||
|
|
||||||
@@ -342,6 +343,165 @@ const WeightedAttributesSelector = ({
|
|||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RouteActionSelectorProps = {
|
||||||
|
action: LocalRoute["action"] | undefined;
|
||||||
|
onActionChange: (action: LocalRoute["action"]) => void;
|
||||||
|
onEventTypeChange?: (action: LocalRoute["action"]) => void;
|
||||||
|
eventTypeOptions: { label: string; value: string; eventTypeId: number }[];
|
||||||
|
selectedEventTypeOption:
|
||||||
|
| { label: string; value: string; eventTypeId: number }
|
||||||
|
| { label: string; value: "custom"; eventTypeId: 0 }
|
||||||
|
| undefined;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
showCustomEventTypeInput?: boolean;
|
||||||
|
customEventTypeSlug?: string;
|
||||||
|
onCustomEventTypeSlugChange?: (slug: string) => void;
|
||||||
|
eventTypePrefix?: string;
|
||||||
|
fieldIdentifiers?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const RouteActionSelector = ({
|
||||||
|
action,
|
||||||
|
onActionChange,
|
||||||
|
onEventTypeChange,
|
||||||
|
eventTypeOptions,
|
||||||
|
selectedEventTypeOption,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
showCustomEventTypeInput = false,
|
||||||
|
customEventTypeSlug = "",
|
||||||
|
onCustomEventTypeSlugChange,
|
||||||
|
eventTypePrefix = "",
|
||||||
|
fieldIdentifiers = [],
|
||||||
|
}: RouteActionSelectorProps) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
return (
|
||||||
|
<div className={classNames("flex w-full flex-col gap-2 text-sm lg:flex-row", className)}>
|
||||||
|
<div className="flex grow items-center gap-2">
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
isDisabled={disabled}
|
||||||
|
className="data-testid-select-routing-action block w-full grow"
|
||||||
|
required
|
||||||
|
value={RoutingPages.find((page) => page.value === action?.type)}
|
||||||
|
onChange={(item) => {
|
||||||
|
if (!item) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newAction: LocalRoute["action"] = {
|
||||||
|
type: item.value,
|
||||||
|
value: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newAction.type === "customPageMessage") {
|
||||||
|
newAction.value = t("default_custom_page_message");
|
||||||
|
} else {
|
||||||
|
newAction.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
onActionChange(newAction);
|
||||||
|
}}
|
||||||
|
options={RoutingPages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{action?.type ? (
|
||||||
|
action?.type === "customPageMessage" ? (
|
||||||
|
<TextArea
|
||||||
|
required
|
||||||
|
disabled={disabled}
|
||||||
|
name="customPageMessage"
|
||||||
|
className="border-default flex grow lg:w-fit"
|
||||||
|
style={{
|
||||||
|
minHeight: "38px",
|
||||||
|
}}
|
||||||
|
value={action.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onActionChange({ ...action, value: e.target.value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : action?.type === "externalRedirectUrl" ? (
|
||||||
|
<TextField
|
||||||
|
size="sm"
|
||||||
|
disabled={disabled}
|
||||||
|
name="externalRedirectUrl"
|
||||||
|
className="border-default flex grow text-sm"
|
||||||
|
containerClassName="grow"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
labelSrOnly
|
||||||
|
value={action.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
onActionChange({ ...action, value: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grow">
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
required
|
||||||
|
className="data-testid-eventTypeRedirectUrl-select"
|
||||||
|
isDisabled={disabled}
|
||||||
|
options={eventTypeOptions}
|
||||||
|
onChange={(option) => {
|
||||||
|
if (!option) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newAction = {
|
||||||
|
...action,
|
||||||
|
value: option.value,
|
||||||
|
eventTypeId: option.eventTypeId,
|
||||||
|
};
|
||||||
|
if (onEventTypeChange) {
|
||||||
|
onEventTypeChange(newAction);
|
||||||
|
} else {
|
||||||
|
onActionChange(newAction);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={selectedEventTypeOption}
|
||||||
|
/>
|
||||||
|
{showCustomEventTypeInput &&
|
||||||
|
eventTypeOptions.length !== 0 &&
|
||||||
|
action.value !== "" &&
|
||||||
|
(action.value === "custom" ||
|
||||||
|
!eventTypeOptions.find((eventOption) => eventOption.value === action.value) ||
|
||||||
|
customEventTypeSlug.length) ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled}
|
||||||
|
className="border-default flex w-full grow text-sm"
|
||||||
|
containerClassName="grow mt-2"
|
||||||
|
addOnLeading={eventTypePrefix}
|
||||||
|
required
|
||||||
|
value={customEventTypeSlug}
|
||||||
|
onChange={(e) => {
|
||||||
|
onCustomEventTypeSlugChange?.(e.target.value);
|
||||||
|
onActionChange({
|
||||||
|
...action,
|
||||||
|
value: `${eventTypePrefix}${e.target.value}`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="event-url"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 ">
|
||||||
|
<p className="text-subtle text-xs">
|
||||||
|
{fieldIdentifiers.length
|
||||||
|
? t("field_identifiers_as_variables_with_example", {
|
||||||
|
variable: `{${fieldIdentifiers[0]}}`,
|
||||||
|
})
|
||||||
|
: t("field_identifiers_as_variables")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Route = ({
|
const Route = ({
|
||||||
form,
|
form,
|
||||||
route,
|
route,
|
||||||
@@ -395,10 +555,20 @@ const Route = ({
|
|||||||
? eventOptions[0].value.substring(0, eventOptions[0].value.lastIndexOf("/") + 1)
|
? eventOptions[0].value.substring(0, eventOptions[0].value.lastIndexOf("/") + 1)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const getCustomSlug = (actionValue: string | undefined) => {
|
||||||
|
if (!actionValue) return "";
|
||||||
|
const isCustom = !eventOptions.find((eventOption) => eventOption.value === actionValue);
|
||||||
|
return isCustom ? (actionValue.split("/").pop() ?? "") : "";
|
||||||
|
};
|
||||||
|
|
||||||
const [customEventTypeSlug, setCustomEventTypeSlug] = useState<string>(() => {
|
const [customEventTypeSlug, setCustomEventTypeSlug] = useState<string>(() => {
|
||||||
const isCustom =
|
if (isRouter(route)) return "";
|
||||||
!isRouter(route) && !eventOptions.find((eventOption) => eventOption.value === route.action.value);
|
return getCustomSlug(route.action.value);
|
||||||
return isCustom && !isRouter(route) ? route.action.value.split("/").pop() ?? "" : "";
|
});
|
||||||
|
|
||||||
|
const [customFallbackEventTypeSlug, setCustomFallbackEventTypeSlug] = useState<string>(() => {
|
||||||
|
if (isRouter(route)) return "";
|
||||||
|
return getCustomSlug(route.fallbackAction?.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEnsureEventTypeIdInRedirectUrlAction({
|
useEnsureEventTypeIdInRedirectUrlAction({
|
||||||
@@ -558,7 +728,7 @@ const Route = ({
|
|||||||
const attributesQueryBuilder =
|
const attributesQueryBuilder =
|
||||||
// team member attributes are only available for organization teams
|
// team member attributes are only available for organization teams
|
||||||
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm && isOrganization ? (
|
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm && isOrganization ? (
|
||||||
<div className="mt-4">
|
<div>
|
||||||
{/* TODO: */}
|
{/* TODO: */}
|
||||||
{eventTypeRedirectUrlSelectedOption?.eventTypeAppMetadata &&
|
{eventTypeRedirectUrlSelectedOption?.eventTypeAppMetadata &&
|
||||||
"salesforce" in eventTypeRedirectUrlSelectedOption.eventTypeAppMetadata ? (
|
"salesforce" in eventTypeRedirectUrlSelectedOption.eventTypeAppMetadata ? (
|
||||||
@@ -573,13 +743,13 @@ const Route = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="bg-default border-subtle cal-query-builder-container mt-2 rounded-2xl border p-2">
|
<div className="cal-query-builder-container mt-4" data-testid="attributes-query-builder">
|
||||||
<div className="ml-2 flex items-center gap-0.5">
|
<div className="ml-2 flex items-center gap-0.5">
|
||||||
<div className="border-subtle rounded-lg border p-1">
|
<div className="border-subtle rounded-lg border p-1">
|
||||||
<Icon name="user-check" className="text-subtle h-4 w-4" />
|
<Icon name="user-check" className="text-subtle h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-emphasis ml-2 text-sm font-medium">
|
<span className="text-emphasis ml-2 text-sm font-medium">
|
||||||
And connect with specific team members
|
{t("connect_with_specific_team_members")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{route.attributesQueryBuilderState && attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
|
{route.attributesQueryBuilderState && attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
|
||||||
@@ -600,30 +770,70 @@ const Route = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const fallbackAttributesQueryBuilder =
|
const fallbackActionOptions = eventTypeRedirectUrlOptions;
|
||||||
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
|
const fallbackActionSelectedOption = route.fallbackAction?.eventTypeId
|
||||||
|
? eventTypeRedirectUrlOptions.find((option) => option.eventTypeId === route.fallbackAction?.eventTypeId)
|
||||||
|
: eventTypeRedirectUrlOptions.find((option) => option.value === route.fallbackAction?.value);
|
||||||
|
|
||||||
|
// Only show fallback section when main action has a valid event type selected
|
||||||
|
// This prevents showing an empty fallback action selector before user selects an event type
|
||||||
|
const hasValidEventTypeSelected =
|
||||||
|
route.action?.type === RouteActionType.EventTypeRedirectUrl &&
|
||||||
|
(route.action?.eventTypeId || route.action?.value);
|
||||||
|
|
||||||
|
const matchingMembersFallbackRoute =
|
||||||
|
hasValidEventTypeSelected && isTeamForm ? (
|
||||||
<div className="bg-default border-subtle cal-query-builder-container mt-2 rounded-2xl border p-2">
|
<div className="bg-default border-subtle cal-query-builder-container mt-2 rounded-2xl border p-2">
|
||||||
<div className="ml-2 flex items-center gap-0.5">
|
<div className="ml-2 flex items-center gap-0.5">
|
||||||
<div className="border-subtle rounded-lg border p-1">
|
<div className="border-subtle rounded-lg border p-1">
|
||||||
<Icon name="blocks" className="text-subtle h-4 w-4" />
|
<Icon name="blocks" className="text-subtle h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-emphasis ml-2 text-sm font-medium">Fallback</span>
|
<span className="text-emphasis ml-2 text-sm font-medium">{t("fallback_action")}</span>
|
||||||
</div>
|
</div>
|
||||||
{route.fallbackAttributesQueryBuilderState &&
|
<div className="bg-cal-muted mt-2 rounded-xl p-2">
|
||||||
attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
|
<RouteActionSelector
|
||||||
<Query
|
action={route.fallbackAction}
|
||||||
{...attributesQueryBuilderConfigWithRaqbSettingsAndWidgets}
|
onActionChange={(newAction) => setRoute(route.id, { fallbackAction: newAction })}
|
||||||
value={route.fallbackAttributesQueryBuilderState.tree}
|
eventTypeOptions={fallbackActionOptions}
|
||||||
onChange={(immutableTree, attributesQueryBuilderConfig) => {
|
selectedEventTypeOption={fallbackActionSelectedOption}
|
||||||
onChangeFallbackTeamMembersQuery(
|
disabled={disabled}
|
||||||
route,
|
showCustomEventTypeInput
|
||||||
immutableTree,
|
customEventTypeSlug={customFallbackEventTypeSlug}
|
||||||
attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
|
onCustomEventTypeSlugChange={setCustomFallbackEventTypeSlug}
|
||||||
);
|
eventTypePrefix={eventTypePrefix}
|
||||||
}}
|
fieldIdentifiers={fieldIdentifiers}
|
||||||
renderBuilder={renderBuilder}
|
/>
|
||||||
/>
|
</div>
|
||||||
)}
|
{isOrganization && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{route.fallbackAction?.type === RouteActionType.EventTypeRedirectUrl &&
|
||||||
|
route.fallbackAttributesQueryBuilderState &&
|
||||||
|
attributesQueryBuilderConfigWithRaqbSettingsAndWidgets && (
|
||||||
|
<div>
|
||||||
|
<div className="ml-2 flex items-center gap-0.5">
|
||||||
|
<div className="border-subtle rounded-lg border p-1">
|
||||||
|
<Icon name="user-check" className="text-subtle h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-emphasis ml-2 text-sm font-medium">
|
||||||
|
{t("connect_with_specific_team_members")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Query
|
||||||
|
{...attributesQueryBuilderConfigWithRaqbSettingsAndWidgets}
|
||||||
|
value={route.fallbackAttributesQueryBuilderState.tree}
|
||||||
|
onChange={(immutableTree, attributesQueryBuilderConfig) => {
|
||||||
|
onChangeFallbackTeamMembersQuery(
|
||||||
|
route,
|
||||||
|
immutableTree,
|
||||||
|
attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderBuilder={renderBuilder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
@@ -659,283 +869,85 @@ const Route = ({
|
|||||||
{formFieldsQueryBuilder}
|
{formFieldsQueryBuilder}
|
||||||
<div>
|
<div>
|
||||||
{route.isFallback ? (
|
{route.isFallback ? (
|
||||||
<div className="flex w-full flex-col gap-2 text-sm lg:flex-row">
|
<RouteActionSelector
|
||||||
<div className="flex grow items-center gap-2">
|
action={route.action}
|
||||||
{/* <div className="flex grow-0 whitespace-nowrap">
|
onActionChange={(newAction) => setRoute(route.id, { action: newAction })}
|
||||||
<span>{t("send_booker_to")}</span>
|
onEventTypeChange={(newAction) => {
|
||||||
</div> */}
|
// Initialize fallbackAction with the same event type if not already set with a valid event type
|
||||||
<Select
|
// Check if fallbackAction has a valid event type (eventTypeId or non-empty value)
|
||||||
size="sm"
|
const hasValidFallbackEventType =
|
||||||
isDisabled={disabled}
|
route.fallbackAction?.eventTypeId || route.fallbackAction?.value;
|
||||||
className="data-testid-select-routing-action block w-full grow"
|
const fallbackAction = hasValidFallbackEventType
|
||||||
required
|
? route.fallbackAction
|
||||||
value={RoutingPages.find((page) => page.value === route.action?.type)}
|
: newAction.type === RouteActionType.EventTypeRedirectUrl
|
||||||
onChange={(item) => {
|
? {
|
||||||
if (!item) {
|
type: newAction.type,
|
||||||
return;
|
value: newAction.value,
|
||||||
}
|
eventTypeId: newAction.eventTypeId,
|
||||||
const action: LocalRoute["action"] = {
|
}
|
||||||
type: item.value,
|
: undefined;
|
||||||
value: "",
|
setRoute(route.id, { action: newAction, attributeRoutingConfig: {}, fallbackAction });
|
||||||
};
|
setCustomEventTypeSlug("");
|
||||||
|
}}
|
||||||
if (action.type === "customPageMessage") {
|
eventTypeOptions={eventTypeRedirectUrlOptions}
|
||||||
action.value = "We are not ready for you yet :(";
|
selectedEventTypeOption={eventTypeRedirectUrlSelectedOption}
|
||||||
} else {
|
disabled={disabled}
|
||||||
action.value = "";
|
showCustomEventTypeInput
|
||||||
}
|
customEventTypeSlug={customEventTypeSlug}
|
||||||
|
onCustomEventTypeSlugChange={setCustomEventTypeSlug}
|
||||||
setRoute(route.id, { action });
|
eventTypePrefix={eventTypePrefix}
|
||||||
}}
|
fieldIdentifiers={fieldIdentifiers}
|
||||||
options={RoutingPages}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{route.action?.type ? (
|
|
||||||
route.action?.type === "customPageMessage" ? (
|
|
||||||
<TextArea
|
|
||||||
required
|
|
||||||
disabled={disabled}
|
|
||||||
name="customPageMessage"
|
|
||||||
className="border-default flex grow lg:w-fit"
|
|
||||||
style={{
|
|
||||||
minHeight: "38px",
|
|
||||||
}}
|
|
||||||
value={route.action.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : route.action?.type === "externalRedirectUrl" ? (
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
name="externalRedirectUrl"
|
|
||||||
className="border-default flex grow text-sm"
|
|
||||||
containerClassName="grow"
|
|
||||||
type="url"
|
|
||||||
required
|
|
||||||
labelSrOnly
|
|
||||||
value={route.action.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
|
|
||||||
}}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="grow">
|
|
||||||
<Select
|
|
||||||
size="sm"
|
|
||||||
required
|
|
||||||
className="data-testid-eventTypeRedirectUrl-select"
|
|
||||||
isDisabled={disabled}
|
|
||||||
options={eventTypeRedirectUrlOptions}
|
|
||||||
onChange={(option) => {
|
|
||||||
if (!option) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (option.value !== "custom") {
|
|
||||||
setRoute(route.id, {
|
|
||||||
action: {
|
|
||||||
...route.action,
|
|
||||||
value: option.value,
|
|
||||||
eventTypeId: option.eventTypeId,
|
|
||||||
},
|
|
||||||
attributeRoutingConfig: {},
|
|
||||||
});
|
|
||||||
setCustomEventTypeSlug("");
|
|
||||||
} else {
|
|
||||||
setRoute(route.id, {
|
|
||||||
action: { ...route.action, value: "custom", eventTypeId: 0 },
|
|
||||||
attributeRoutingConfig: {},
|
|
||||||
});
|
|
||||||
setCustomEventTypeSlug("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={eventTypeRedirectUrlSelectedOption}
|
|
||||||
/>
|
|
||||||
{eventOptions.length !== 0 &&
|
|
||||||
route.action.value !== "" &&
|
|
||||||
(!eventOptions.find((eventOption) => eventOption.value === route.action.value) ||
|
|
||||||
customEventTypeSlug.length) ? (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
className="border-default flex w-full grow text-sm"
|
|
||||||
containerClassName="grow mt-2"
|
|
||||||
addOnLeading={eventTypePrefix}
|
|
||||||
required
|
|
||||||
value={customEventTypeSlug}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomEventTypeSlug(e.target.value);
|
|
||||||
setRoute(route.id, {
|
|
||||||
action: { ...route.action, value: `${eventTypePrefix}${e.target.value}` },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="event-url"
|
|
||||||
/>
|
|
||||||
<div className="mt-2 ">
|
|
||||||
<p className="text-subtle text-xs">
|
|
||||||
{fieldIdentifiers.length
|
|
||||||
? t("field_identifiers_as_variables_with_example", {
|
|
||||||
variable: `{${fieldIdentifiers[0]}}`,
|
|
||||||
})
|
|
||||||
: t("field_identifiers_as_variables")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-default border-subtle my-3 rounded-xl border p-2">
|
<div className="bg-default border-subtle my-3 rounded-xl border p-2">
|
||||||
<div className="mb-2 ml-2 flex items-center gap-0.5">
|
<div className="mb-2 ml-2 flex items-center gap-0.5">
|
||||||
<div className="border-subtle rounded-lg border p-1">
|
<div className="border-subtle rounded-lg border p-1">
|
||||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-emphasis ml-2 text-sm font-medium">Send booker to</span>
|
<span className="text-emphasis ml-2 text-sm font-medium">{t("send_booker_to")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-cal-muted flex w-full flex-col gap-2 rounded-xl p-2 text-sm lg:flex-row">
|
<div className="bg-cal-muted rounded-xl p-2">
|
||||||
<div className="flex grow items-center gap-2">
|
<RouteActionSelector
|
||||||
<Select
|
action={route.action}
|
||||||
size="sm"
|
onActionChange={(newAction) => setRoute(route.id, { action: newAction })}
|
||||||
isDisabled={disabled}
|
onEventTypeChange={(newAction) => {
|
||||||
className="data-testid-select-routing-action block w-full grow"
|
// Initialize fallbackAction with the same event type if not already set with a valid event type
|
||||||
required
|
// Check if fallbackAction has a valid event type (eventTypeId or non-empty value)
|
||||||
value={RoutingPages.find((page) => page.value === route.action?.type)}
|
const hasValidFallbackEventType =
|
||||||
onChange={(item) => {
|
route.fallbackAction?.eventTypeId || route.fallbackAction?.value;
|
||||||
if (!item) {
|
const fallbackAction = hasValidFallbackEventType
|
||||||
return;
|
? route.fallbackAction
|
||||||
}
|
: newAction.type === RouteActionType.EventTypeRedirectUrl
|
||||||
const action: LocalRoute["action"] = {
|
? {
|
||||||
type: item.value,
|
type: newAction.type,
|
||||||
value: "",
|
value: newAction.value,
|
||||||
};
|
eventTypeId: newAction.eventTypeId,
|
||||||
|
|
||||||
if (action.type === "customPageMessage") {
|
|
||||||
action.value = "We are not ready for you yet :(";
|
|
||||||
} else {
|
|
||||||
action.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
setRoute(route.id, { action });
|
|
||||||
}}
|
|
||||||
options={RoutingPages}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{route.action?.type ? (
|
|
||||||
route.action?.type === "customPageMessage" ? (
|
|
||||||
<TextArea
|
|
||||||
required
|
|
||||||
disabled={disabled}
|
|
||||||
name="customPageMessage"
|
|
||||||
className="border-default flex grow lg:w-fit"
|
|
||||||
style={{
|
|
||||||
minHeight: "38px",
|
|
||||||
}}
|
|
||||||
value={route.action.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : route.action?.type === "externalRedirectUrl" ? (
|
|
||||||
<TextField
|
|
||||||
size="sm"
|
|
||||||
disabled={disabled}
|
|
||||||
name="externalRedirectUrl"
|
|
||||||
className="border-default flex grow text-sm"
|
|
||||||
containerClassName="grow"
|
|
||||||
type="url"
|
|
||||||
required
|
|
||||||
labelSrOnly
|
|
||||||
value={route.action.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setRoute(route.id, { action: { ...route.action, value: e.target.value } });
|
|
||||||
}}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="grow">
|
|
||||||
<Select
|
|
||||||
size="sm"
|
|
||||||
required
|
|
||||||
className="data-testid-eventTypeRedirectUrl-select"
|
|
||||||
isDisabled={disabled}
|
|
||||||
options={eventTypeRedirectUrlOptions}
|
|
||||||
onChange={(option) => {
|
|
||||||
if (!option) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (option.value !== "custom") {
|
: undefined;
|
||||||
setRoute(route.id, {
|
setRoute(route.id, { action: newAction, attributeRoutingConfig: {}, fallbackAction });
|
||||||
action: {
|
setCustomEventTypeSlug("");
|
||||||
...route.action,
|
}}
|
||||||
value: option.value,
|
eventTypeOptions={eventTypeRedirectUrlOptions}
|
||||||
eventTypeId: option.eventTypeId,
|
selectedEventTypeOption={eventTypeRedirectUrlSelectedOption}
|
||||||
},
|
disabled={disabled}
|
||||||
attributeRoutingConfig: {},
|
showCustomEventTypeInput
|
||||||
});
|
customEventTypeSlug={customEventTypeSlug}
|
||||||
setCustomEventTypeSlug("");
|
onCustomEventTypeSlugChange={setCustomEventTypeSlug}
|
||||||
} else {
|
eventTypePrefix={eventTypePrefix}
|
||||||
setRoute(route.id, {
|
fieldIdentifiers={fieldIdentifiers}
|
||||||
action: { ...route.action, value: "custom", eventTypeId: 0 },
|
/>
|
||||||
attributeRoutingConfig: {},
|
|
||||||
});
|
|
||||||
setCustomEventTypeSlug("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={eventTypeRedirectUrlSelectedOption}
|
|
||||||
/>
|
|
||||||
{eventOptions.length !== 0 &&
|
|
||||||
route.action.value !== "" &&
|
|
||||||
(!eventOptions.find((eventOption) => eventOption.value === route.action.value) ||
|
|
||||||
customEventTypeSlug.length) ? (
|
|
||||||
<>
|
|
||||||
<TextField
|
|
||||||
disabled={disabled}
|
|
||||||
className="border-default flex w-full grow text-sm"
|
|
||||||
containerClassName="grow mt-2"
|
|
||||||
addOnLeading={eventTypePrefix}
|
|
||||||
required
|
|
||||||
value={customEventTypeSlug}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomEventTypeSlug(e.target.value);
|
|
||||||
setRoute(route.id, {
|
|
||||||
action: { ...route.action, value: `${eventTypePrefix}${e.target.value}` },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="event-url"
|
|
||||||
/>
|
|
||||||
<div className="mt-2 ">
|
|
||||||
<p className="text-subtle text-xs">
|
|
||||||
{fieldIdentifiers.length
|
|
||||||
? t("field_identifiers_as_variables_with_example", {
|
|
||||||
variable: `{${fieldIdentifiers[0]}}`,
|
|
||||||
})
|
|
||||||
: t("field_identifiers_as_variables")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
{attributesQueryBuilder}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{attributesQueryBuilder}
|
|
||||||
<WeightedAttributesSelector
|
<WeightedAttributesSelector
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
route={route}
|
route={route}
|
||||||
eventTypeRedirectUrlSelectedOption={eventTypeRedirectUrlSelectedOption}
|
eventTypeRedirectUrlSelectedOption={eventTypeRedirectUrlSelectedOption}
|
||||||
setRoute={setRoute}
|
setRoute={setRoute}
|
||||||
/>
|
/>
|
||||||
{fallbackAttributesQueryBuilder ? <>{fallbackAttributesQueryBuilder}</> : null}
|
{matchingMembersFallbackRoute ? <>{matchingMembersFallbackRoute}</> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -952,7 +964,7 @@ const buildState = <
|
|||||||
| {
|
| {
|
||||||
queryValue: AttributesQueryValue;
|
queryValue: AttributesQueryValue;
|
||||||
config: AttributesQueryBuilderConfigWithRaqbFields;
|
config: AttributesQueryBuilderConfigWithRaqbFields;
|
||||||
}
|
},
|
||||||
>({
|
>({
|
||||||
queryValue,
|
queryValue,
|
||||||
config,
|
config,
|
||||||
@@ -1026,6 +1038,16 @@ function useRoutes({
|
|||||||
if (!r.fallbackAttributesQueryValue) {
|
if (!r.fallbackAttributesQueryValue) {
|
||||||
r.fallbackAttributesQueryValue = getEmptyQueryValue() as LocalRoute["fallbackAttributesQueryValue"];
|
r.fallbackAttributesQueryValue = getEmptyQueryValue() as LocalRoute["fallbackAttributesQueryValue"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize fallbackAction with the main action for backwards compatibility
|
||||||
|
// This ensures existing routes show the correct default (same event type as main route)
|
||||||
|
if (!r.fallbackAction && r.action?.type === RouteActionType.EventTypeRedirectUrl) {
|
||||||
|
r.fallbackAction = {
|
||||||
|
type: r.action.type,
|
||||||
|
value: r.action.value,
|
||||||
|
eventTypeId: r.action.eventTypeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return _routes;
|
return _routes;
|
||||||
};
|
};
|
||||||
@@ -1056,6 +1078,17 @@ function useRoutes({
|
|||||||
if (isRouter(route)) {
|
if (isRouter(route)) {
|
||||||
return route;
|
return route;
|
||||||
}
|
}
|
||||||
|
// Initialize fallbackAction from action if not set and action is EventTypeRedirectUrl
|
||||||
|
// This handles the case where user creates a new route and changes action to an event type
|
||||||
|
const fallbackAction =
|
||||||
|
route.fallbackAction ??
|
||||||
|
(route.action?.type === RouteActionType.EventTypeRedirectUrl
|
||||||
|
? {
|
||||||
|
type: route.action.type,
|
||||||
|
value: route.action.value,
|
||||||
|
eventTypeId: route.action.eventTypeId,
|
||||||
|
}
|
||||||
|
: undefined);
|
||||||
return {
|
return {
|
||||||
id: route.id,
|
id: route.id,
|
||||||
name: route.name,
|
name: route.name,
|
||||||
@@ -1066,6 +1099,7 @@ function useRoutes({
|
|||||||
attributesQueryValue: route.attributesQueryValue,
|
attributesQueryValue: route.attributesQueryValue,
|
||||||
fallbackAttributesQueryValue: route.fallbackAttributesQueryValue,
|
fallbackAttributesQueryValue: route.fallbackAttributesQueryValue,
|
||||||
attributeIdForWeights: route.attributeIdForWeights,
|
attributeIdForWeights: route.attributeIdForWeights,
|
||||||
|
fallbackAction,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1087,10 +1121,21 @@ const useCreateRoute = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const createRoute = useCallback(() => {
|
const createRoute = useCallback(() => {
|
||||||
const newEmptyRoute = getEmptyRoute();
|
const newEmptyRoute = getEmptyRoute();
|
||||||
|
// Initialize fallbackAction with the main action for new routes
|
||||||
|
// This ensures new routes have a valid fallbackAction when saved
|
||||||
|
const fallbackAction =
|
||||||
|
newEmptyRoute.action?.type === RouteActionType.EventTypeRedirectUrl
|
||||||
|
? {
|
||||||
|
type: newEmptyRoute.action.type,
|
||||||
|
value: newEmptyRoute.action.value,
|
||||||
|
eventTypeId: newEmptyRoute.action.eventTypeId,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
const newRoutes = [
|
const newRoutes = [
|
||||||
...routes,
|
...routes,
|
||||||
{
|
{
|
||||||
...newEmptyRoute,
|
...newEmptyRoute,
|
||||||
|
fallbackAction,
|
||||||
formFieldsQueryBuilderState: buildState({
|
formFieldsQueryBuilderState: buildState({
|
||||||
queryValue: newEmptyRoute.queryValue,
|
queryValue: newEmptyRoute.queryValue,
|
||||||
config: formFieldsQueryBuilderConfig,
|
config: formFieldsQueryBuilderConfig,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
|||||||
import { routingFormsComponents } from "./app-routing.client-config";
|
import { routingFormsComponents } from "./app-routing.client-config";
|
||||||
|
|
||||||
const normalizePages = (pages: string[] | string | undefined) => {
|
const normalizePages = (pages: string[] | string | undefined) => {
|
||||||
const normalizedPages = Array.isArray(pages) ? pages : pages?.split("/") ?? [];
|
const normalizedPages = Array.isArray(pages) ? pages : (pages?.split("/") ?? []);
|
||||||
return normalizedPages[0];
|
return normalizedPages[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
|
||||||
import posthog from "posthog-js";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useFormContext } from "react-hook-form";
|
|
||||||
|
|
||||||
import { isFallbackRoute } from "@calcom/app-store/routing-forms/lib/isFallbackRoute";
|
import { isFallbackRoute } from "@calcom/app-store/routing-forms/lib/isFallbackRoute";
|
||||||
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
import type { RoutingFormWithResponseCount } from "@calcom/app-store/routing-forms/types/types";
|
||||||
import LicenseRequired from "~/ee/common/components/LicenseRequired";
|
|
||||||
import { FilterResults } from "~/filters/components/FilterResults";
|
import { FilterResults } from "~/filters/components/FilterResults";
|
||||||
import { TeamsFilter } from "~/filters/components/TeamsFilter";
|
import { TeamsFilter } from "~/filters/components/TeamsFilter";
|
||||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||||
@@ -21,22 +15,33 @@ import { Badge } from "@calcom/ui/components/badge";
|
|||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
|
||||||
import { List, ListLinkItem } from "@calcom/ui/components/list";
|
import { List, ListLinkItem } from "@calcom/ui/components/list";
|
||||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
import type {
|
import type {
|
||||||
SetNewFormDialogState,
|
|
||||||
NewFormDialogState,
|
NewFormDialogState,
|
||||||
|
SetNewFormDialogState,
|
||||||
} from "@calcom/web/components/apps/routing-forms/FormActions";
|
} from "@calcom/web/components/apps/routing-forms/FormActions";
|
||||||
import {
|
import {
|
||||||
FormAction,
|
FormAction,
|
||||||
FormActionsDropdown,
|
FormActionsDropdown,
|
||||||
FormActionsProvider,
|
FormActionsProvider,
|
||||||
} from "@calcom/web/components/apps/routing-forms/FormActions";
|
} from "@calcom/web/components/apps/routing-forms/FormActions";
|
||||||
|
import {
|
||||||
|
ChartBarIcon,
|
||||||
|
CircleCheckIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
MailIcon,
|
||||||
|
ShuffleIcon,
|
||||||
|
} from "@coss/ui/icons";
|
||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
|
import posthog from "posthog-js";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useFormContext } from "react-hook-form";
|
||||||
import { useHasPaidPlan } from "~/billing/hooks/useHasPaidPlan";
|
import { useHasPaidPlan } from "~/billing/hooks/useHasPaidPlan";
|
||||||
import SkeletonLoaderTeamList from "~/ee/teams/components/SkeletonloaderTeamList";
|
import LicenseRequired from "~/ee/common/components/LicenseRequired";
|
||||||
import { CreateButtonWithTeamsList } from "~/ee/teams/components/createButton/CreateButtonWithTeamsList";
|
import { CreateButtonWithTeamsList } from "~/ee/teams/components/createButton/CreateButtonWithTeamsList";
|
||||||
|
import SkeletonLoaderTeamList from "~/ee/teams/components/SkeletonloaderTeamList";
|
||||||
import { ShellMain } from "~/shell/Shell";
|
import { ShellMain } from "~/shell/Shell";
|
||||||
import { UpgradeTip } from "~/shell/UpgradeTip";
|
import { UpgradeTip } from "~/shell/UpgradeTip";
|
||||||
|
|
||||||
@@ -92,32 +97,32 @@ export default function RoutingForms({ appUrl }: { appUrl: string }) {
|
|||||||
const forms = queryRes.data?.filtered;
|
const forms = queryRes.data?.filtered;
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
icon: <Icon name="file-text" className="h-5 w-5 text-orange-500" />,
|
icon: <FileTextIcon className="h-5 w-5 text-orange-500" />,
|
||||||
title: t("create_your_first_form"),
|
title: t("create_your_first_form"),
|
||||||
description: t("create_your_first_form_description"),
|
description: t("create_your_first_form_description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Icon name="shuffle" className="h-5 w-5 text-lime-500" />,
|
icon: <ShuffleIcon className="h-5 w-5 text-lime-500" />,
|
||||||
title: t("create_your_first_route"),
|
title: t("create_your_first_route"),
|
||||||
description: t("route_to_the_right_person"),
|
description: t("route_to_the_right_person"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Icon name="chart-bar" className="h-5 w-5 text-blue-500" />,
|
icon: <ChartBarIcon className="h-5 w-5 text-blue-500" />,
|
||||||
title: t("reporting"),
|
title: t("reporting"),
|
||||||
description: t("reporting_feature"),
|
description: t("reporting_feature"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Icon name="circle-check" className="h-5 w-5 text-teal-500" />,
|
icon: <CircleCheckIcon className="h-5 w-5 text-teal-500" />,
|
||||||
title: t("test_routing_form"),
|
title: t("test_routing_form"),
|
||||||
description: t("test_preview_description"),
|
description: t("test_preview_description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Icon name="mail" className="h-5 w-5 text-yellow-500" />,
|
icon: <MailIcon className="h-5 w-5 text-yellow-500" />,
|
||||||
title: t("routing_forms_send_email_owner"),
|
title: t("routing_forms_send_email_owner"),
|
||||||
description: t("routing_forms_send_email_owner_description"),
|
description: t("routing_forms_send_email_owner_description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Icon name="download" className="h-5 w-5 text-violet-500" />,
|
icon: <DownloadIcon className="h-5 w-5 text-violet-500" />,
|
||||||
title: t("download_responses"),
|
title: t("download_responses"),
|
||||||
description: t("download_responses_description"),
|
description: t("download_responses_description"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
import { XIcon } from "@coss/ui/icons";
|
||||||
|
|
||||||
import AuthContainer from "@components/ui/AuthContainer";
|
import AuthContainer from "@components/ui/AuthContainer";
|
||||||
|
|
||||||
@@ -42,10 +42,10 @@ const ServerPage = async ({ searchParams }: PageProps) => {
|
|||||||
provider === IdentityProvider.GOOGLE
|
provider === IdentityProvider.GOOGLE
|
||||||
? "Google"
|
? "Google"
|
||||||
: provider === IdentityProvider.CAL
|
: provider === IdentityProvider.CAL
|
||||||
? "Email and Password"
|
? "Email and Password"
|
||||||
: provider === IdentityProvider.SAML
|
: provider === IdentityProvider.SAML
|
||||||
? "SAML (like Okta)"
|
? "SAML (like Okta)"
|
||||||
: "your original login method";
|
: "your original login method";
|
||||||
return t("account_managed_by_identity_provider_error", { provider: providerName });
|
return t("account_managed_by_identity_provider_error", { provider: providerName });
|
||||||
} else if (error === "saml-idp-not-authoritative") {
|
} else if (error === "saml-idp-not-authoritative") {
|
||||||
return t("saml_idp_not_authoritative_error");
|
return t("saml_idp_not_authoritative_error");
|
||||||
@@ -61,7 +61,7 @@ const ServerPage = async ({ searchParams }: PageProps) => {
|
|||||||
<AuthContainer>
|
<AuthContainer>
|
||||||
<div>
|
<div>
|
||||||
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||||
<Icon name="x" className="h-6 w-6 text-red-600" />
|
<XIcon className="h-6 w-6 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-title">
|
<h3 className="text-emphasis text-lg font-medium leading-6" id="modal-title">
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
import React, { Suspense } from "react";
|
import React, { Suspense } from "react";
|
||||||
|
|
||||||
import { ErrorBoundary } from "@calcom/ui/components/errorBoundary";
|
import { ErrorBoundary } from "@calcom/ui/components/errorBoundary";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
import { LoaderIcon } from "@coss/ui/icons";
|
||||||
|
|
||||||
export default function TroubleshooterLayout({ children }: { children: React.ReactNode }) {
|
export default function TroubleshooterLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-1 *:flex-1">
|
<div className="flex flex-1 *:flex-1">
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
|
<Suspense fallback={<LoaderIcon />}>{children}</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
import { RefreshCcwIcon, UserPlusIcon, UsersIcon } from "@coss/ui/icons";
|
||||||
|
|
||||||
import { UpgradeTip } from "~/shell/UpgradeTip";
|
import { UpgradeTip } from "~/shell/UpgradeTip";
|
||||||
|
|
||||||
@@ -22,17 +22,17 @@ export default function UpgradeTipWrapper({ children }: { children: React.ReactN
|
|||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
icon: <Icon name="users" className="h-5 w-5 text-red-500" />,
|
icon: <UsersIcon className="h-5 w-5 text-red-500" />,
|
||||||
title: t("view_bookings_across"),
|
title: t("view_bookings_across"),
|
||||||
description: t("view_bookings_across_description"),
|
description: t("view_bookings_across_description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Icon name="refresh-ccw" className="h-5 w-5 text-blue-500" />,
|
icon: <RefreshCcwIcon className="h-5 w-5 text-blue-500" />,
|
||||||
title: t("identify_booking_trends"),
|
title: t("identify_booking_trends"),
|
||||||
description: t("identify_booking_trends_description"),
|
description: t("identify_booking_trends_description"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Icon name="user-plus" className="h-5 w-5 text-green-500" />,
|
icon: <UserPlusIcon className="h-5 w-5 text-green-500" />,
|
||||||
title: t("spot_popular_event_types"),
|
title: t("spot_popular_event_types"),
|
||||||
description: t("spot_popular_event_types_description"),
|
description: t("spot_popular_event_types_description"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { _generateMetadata } from "app/_utils";
|
||||||
|
|
||||||
|
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
|
import InsightsWrongRoutingPage from "~/insights/views/insights-wrong-routing-view";
|
||||||
|
|
||||||
|
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
|
||||||
|
|
||||||
|
export const generateMetadata = async () =>
|
||||||
|
await _generateMetadata(
|
||||||
|
(t) => t("wrong_routing"),
|
||||||
|
(t) => t("wrong_routing_description"),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"/insights/wrong-routing"
|
||||||
|
);
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const session = await checkInsightsPagePermission();
|
||||||
|
|
||||||
|
const userRepository = new UserRepository(prisma);
|
||||||
|
const user = await userRepository.getTimeZoneAndDefaultScheduleId({ userId: session?.user.id ?? -1 });
|
||||||
|
|
||||||
|
return <InsightsWrongRoutingPage timeZone={user?.timeZone ?? "UTC"} />;
|
||||||
|
}
|
||||||
@@ -27,7 +27,9 @@ const ServerPage = async () => {
|
|||||||
|
|
||||||
// If user has any team membership (pending or accepted), redirect them directly to personal onboarding
|
// If user has any team membership (pending or accepted), redirect them directly to personal onboarding
|
||||||
// This handles the case where users sign up with an invite token (membership is auto-accepted)
|
// This handles the case where users sign up with an invite token (membership is auto-accepted)
|
||||||
const hasTeamMembership = await MembershipRepository.hasAnyTeamMembershipByUserId({ userId: session.user.id });
|
const hasTeamMembership = await MembershipRepository.hasAnyTeamMembershipByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
if (hasTeamMembership) {
|
if (hasTeamMembership) {
|
||||||
return redirect("/onboarding/personal/settings");
|
return redirect("/onboarding/personal/settings");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,4 +32,3 @@ const ServerPage = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default ServerPage;
|
export default ServerPage;
|
||||||
|
|
||||||
|
|||||||
@@ -32,4 +32,3 @@ const ServerPage = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default ServerPage;
|
export default ServerPage;
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ const ServerPage = async (props: { searchParams: Promise<{ fromTeamOnboarding?:
|
|||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const fromTeamOnboarding = searchParams?.fromTeamOnboarding === "true";
|
const fromTeamOnboarding = searchParams?.fromTeamOnboarding === "true";
|
||||||
|
|
||||||
return <PersonalSettingsView userEmail={userEmail} userName={userName} fromTeamOnboarding={fromTeamOnboarding} />;
|
return (
|
||||||
|
<PersonalSettingsView userEmail={userEmail} userName={userName} fromTeamOnboarding={fromTeamOnboarding} />
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ServerPage;
|
export default ServerPage;
|
||||||
|
|||||||
@@ -32,4 +32,3 @@ const ServerPage = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default ServerPage;
|
export default ServerPage;
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
|
|||||||
<div className="col-span-2 mb-6">
|
<div className="col-span-2 mb-6">
|
||||||
{date.locale(i18n.language ?? "en").format("dddd, DD MMMM YYYY")}
|
{date.locale(i18n.language ?? "en").format("dddd, DD MMMM YYYY")}
|
||||||
<br />
|
<br />
|
||||||
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} mins{" "}
|
{date.format(is24h ? "H:mm" : "h:mma")} - {props.eventType.length} {t("minute_timeUnit")}{" "}
|
||||||
<span className="text-subtle">({timezone})</span>
|
<span className="text-subtle">({timezone})</span>
|
||||||
</div>
|
</div>
|
||||||
{props.booking.location && (
|
{props.booking.location && (
|
||||||
|
|||||||
+1
-4
@@ -42,10 +42,7 @@ const Page = async ({ params }: { params: Params }) => {
|
|||||||
const org = await organizationRepository.adminFindById({ id: input.data.id });
|
const org = await organizationRepository.adminFindById({ id: input.data.id });
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
return (
|
return (
|
||||||
<SettingsHeader
|
<SettingsHeader title={`${t("editing_org")}: ${org.name}`} description={t("admin_orgs_edit_description")}>
|
||||||
title={`${t("editing_org")}: ${org.name}`}
|
|
||||||
description={t("admin_orgs_edit_description")}
|
|
||||||
>
|
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
<OrgForm org={org} />
|
<OrgForm org={org} />
|
||||||
</LicenseRequired>
|
</LicenseRequired>
|
||||||
|
|||||||
+1
-4
@@ -17,10 +17,7 @@ export const generateMetadata = async () =>
|
|||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
return (
|
return (
|
||||||
<SettingsHeader
|
<SettingsHeader title={t("organizations")} description={t("orgs_page_description")}>
|
||||||
title={t("organizations")}
|
|
||||||
description={t("orgs_page_description")}
|
|
||||||
>
|
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
<AdminOrgTable />
|
<AdminOrgTable />
|
||||||
</LicenseRequired>
|
</LicenseRequired>
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,8 @@
|
|||||||
import { useReactTable, getCoreRowModel, createColumnHelper } from "@tanstack/react-table";
|
import { useReactTable, getCoreRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { ColumnFilterType, DataTableProvider } from "@calcom/features/data-table";
|
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||||
|
import { DataTableProvider } from "~/data-table/DataTableProvider";
|
||||||
import type { DateRangeFilterOptions } from "@calcom/features/data-table/lib/types";
|
import type { DateRangeFilterOptions } from "@calcom/features/data-table/lib/types";
|
||||||
import { DateRangeFilter } from "~/data-table/components/filters/DateRangeFilter";
|
import { DateRangeFilter } from "~/data-table/components/filters/DateRangeFilter";
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { Calendar } from "@calcom/web/modules/calendars/weeklyview/components/Calendar";
|
import { Calendar } from "@calcom/features/calendars/weeklyview/components/Calendar";
|
||||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||||
import type { CalendarComponentProps, Hours } from "@calcom/features/calendars/weeklyview/types/state";
|
import type { CalendarComponentProps, Hours } from "@calcom/features/calendars/weeklyview/types/state";
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -16,10 +16,7 @@ export const generateMetadata = async () =>
|
|||||||
const Page = async () => {
|
const Page = async () => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
return (
|
return (
|
||||||
<SettingsHeader
|
<SettingsHeader title={t("workspace_platforms")} description={t("workspace_platforms_description")}>
|
||||||
title={t("workspace_platforms")}
|
|
||||||
description={t("workspace_platforms_description")}
|
|
||||||
>
|
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
<WorkspacePlatformsPage />
|
<WorkspacePlatformsPage />
|
||||||
</LicenseRequired>
|
</LicenseRequired>
|
||||||
|
|||||||
+17
-23
@@ -31,6 +31,7 @@ import { Badge } from "@calcom/ui/components/badge";
|
|||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { ErrorBoundary } from "@calcom/ui/components/errorBoundary";
|
import { ErrorBoundary } from "@calcom/ui/components/errorBoundary";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
|
import { ArrowLeftIcon, ChevronDownIcon, ChevronRightIcon } from "@coss/ui/icons";
|
||||||
import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
|
import type { VerticalTabItemProps } from "@calcom/ui/components/navigation";
|
||||||
import { VerticalTabItem } from "@calcom/ui/components/navigation";
|
import { VerticalTabItem } from "@calcom/ui/components/navigation";
|
||||||
import { Skeleton } from "@calcom/ui/components/skeleton";
|
import { Skeleton } from "@calcom/ui/components/skeleton";
|
||||||
@@ -153,9 +154,10 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
|||||||
trackingMetadata: { section: "developer", page: "api_keys" },
|
trackingMetadata: { section: "developer", page: "api_keys" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "admin_api",
|
name: "api_docs",
|
||||||
href: "/settings/organizations/admin-api",
|
href: "https://cal.com/docs/api-reference/v2/introduction",
|
||||||
trackingMetadata: { section: "developer", page: "admin_api" },
|
isExternalLink: true,
|
||||||
|
trackingMetadata: { section: "developer", page: "api_docs" },
|
||||||
},
|
},
|
||||||
// TODO: Add profile level for embeds
|
// TODO: Add profile level for embeds
|
||||||
// { name: "embeds", href: "/v2/settings/developer/embeds" },
|
// { name: "embeds", href: "/v2/settings/developer/embeds" },
|
||||||
@@ -205,10 +207,10 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
|||||||
trackingMetadata: { section: "organization", page: "directory_sync" },
|
trackingMetadata: { section: "organization", page: "directory_sync" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "admin_api",
|
name: "api_docs",
|
||||||
href: "https://cal.com/docs/enterprise-features/api/api-reference/bookings#admin-access",
|
href: "https://cal.com/docs/api-reference/v2/introduction",
|
||||||
isExternalLink: true,
|
isExternalLink: true,
|
||||||
trackingMetadata: { section: "organization", page: "admin_api" },
|
trackingMetadata: { section: "organization", page: "api_docs" },
|
||||||
},
|
},
|
||||||
...(HAS_ORG_OPT_IN_FEATURES
|
...(HAS_ORG_OPT_IN_FEATURES
|
||||||
? [
|
? [
|
||||||
@@ -449,7 +451,7 @@ const useTabs = ({
|
|||||||
return { ...tab, children: filtered };
|
return { ...tab, children: filtered };
|
||||||
} else if (tab.href === "/settings/developer") {
|
} else if (tab.href === "/settings/developer") {
|
||||||
const filtered = tab?.children?.filter(
|
const filtered = tab?.children?.filter(
|
||||||
(childTab) => permissions?.canUpdateOrganization || childTab.name !== "admin_api"
|
(childTab) => permissions?.canUpdateOrganization || childTab.name !== "api_docs"
|
||||||
);
|
);
|
||||||
return { ...tab, children: filtered };
|
return { ...tab, children: filtered };
|
||||||
}
|
}
|
||||||
@@ -475,10 +477,7 @@ const BackButtonInSidebar = ({ name }: { name: string }) => {
|
|||||||
href="/event-types"
|
href="/event-types"
|
||||||
className="hover:bg-subtle todesktop:mt-10 [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-emphasis group my-6 flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2 text-sm font-medium leading-4 transition"
|
className="hover:bg-subtle todesktop:mt-10 [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-emphasis group my-6 flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2 text-sm font-medium leading-4 transition"
|
||||||
data-testid={`vertical-tab-${name}`}>
|
data-testid={`vertical-tab-${name}`}>
|
||||||
<Icon
|
<ArrowLeftIcon className="h-4 w-4 stroke-[2px] ltr:mr-[10px] rtl:ml-[10px] rtl:rotate-180 md:mt-0" />
|
||||||
name="arrow-left"
|
|
||||||
className="h-4 w-4 stroke-[2px] ltr:mr-[10px] rtl:ml-[10px] rtl:rotate-180 md:mt-0"
|
|
||||||
/>
|
|
||||||
<Skeleton title={name} as="p" className="min-h-4 max-w-36 truncate" loadingClassName="ms-3">
|
<Skeleton title={name} as="p" className="min-h-4 max-w-36 truncate" loadingClassName="ms-3">
|
||||||
{name}
|
{name}
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
@@ -602,9 +601,9 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
|
|||||||
}`}>
|
}`}>
|
||||||
<div className="me-3">
|
<div className="me-3">
|
||||||
{teamMenuState[index].teamMenuOpen ? (
|
{teamMenuState[index].teamMenuOpen ? (
|
||||||
<Icon name="chevron-down" className="h-4 w-4" />
|
<ChevronDownIcon className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Icon name="chevron-right" className="h-4 w-4" />
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{}
|
{}
|
||||||
@@ -826,7 +825,7 @@ const SettingsSidebarContainer = ({
|
|||||||
href={child.href || "/"}
|
href={child.href || "/"}
|
||||||
trackingMetadata={child.trackingMetadata}
|
trackingMetadata={child.trackingMetadata}
|
||||||
textClassNames="text-emphasis font-medium text-sm"
|
textClassNames="text-emphasis font-medium text-sm"
|
||||||
className={`px-2! py-1! min-h-7 h-auto w-fit ${
|
className={`px-2! py-1! min-h-7 h-auto w-full ${
|
||||||
tab.children && index === tab.children?.length - 1 && "mb-3!"
|
tab.children && index === tab.children?.length - 1 && "mb-3!"
|
||||||
}`}
|
}`}
|
||||||
disableChevron
|
disableChevron
|
||||||
@@ -949,9 +948,9 @@ const SettingsSidebarContainer = ({
|
|||||||
}`}>
|
}`}>
|
||||||
<div className="me-3">
|
<div className="me-3">
|
||||||
{otherTeamMenuState[index].teamMenuOpen ? (
|
{otherTeamMenuState[index].teamMenuOpen ? (
|
||||||
<Icon name="chevron-down" className="h-4 w-4" />
|
<ChevronDownIcon className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Icon name="chevron-right" className="h-4 w-4" />
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{}
|
{}
|
||||||
@@ -1036,7 +1035,7 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
|
|||||||
<button
|
<button
|
||||||
className="hover:bg-emphasis flex items-center space-x-2 rounded-md px-3 py-1 rtl:space-x-reverse"
|
className="hover:bg-emphasis flex items-center space-x-2 rounded-md px-3 py-1 rtl:space-x-reverse"
|
||||||
onClick={() => router.back()}>
|
onClick={() => router.back()}>
|
||||||
<Icon name="arrow-left" className="text-default h-4 w-4" />
|
<ArrowLeftIcon className="text-default h-4 w-4" />
|
||||||
<p className="text-emphasis font-semibold">{t("settings")}</p>
|
<p className="text-emphasis font-semibold">{t("settings")}</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1052,12 +1051,7 @@ type SettingsLayoutProps = {
|
|||||||
permissions?: SettingsPermissions;
|
permissions?: SettingsPermissions;
|
||||||
} & ComponentProps<typeof Shell>;
|
} & ComponentProps<typeof Shell>;
|
||||||
|
|
||||||
function SettingsLayoutAppDirClient({
|
function SettingsLayoutAppDirClient({ children, teamFeatures, permissions, ...rest }: SettingsLayoutProps) {
|
||||||
children,
|
|
||||||
teamFeatures,
|
|
||||||
permissions,
|
|
||||||
...rest
|
|
||||||
}: SettingsLayoutProps) {
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const state = useState(false);
|
const state = useState(false);
|
||||||
const [sideContainerOpen, setSideContainerOpen] = state;
|
const [sideContainerOpen, setSideContainerOpen] = state;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const Page = async () => {
|
|||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={t("billing")}
|
title={t("billing")}
|
||||||
description={t("manage_billing_description")}
|
description={t("manage_billing_description")}
|
||||||
borderInShellHeader={false}>
|
borderInShellHeader={true}>
|
||||||
<BillingView />
|
<BillingView />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ const Page = async () => {
|
|||||||
redirect("/auth/login?callbackUrl=/settings/developer/oauth");
|
redirect("/auth/login?callbackUrl=/settings/developer/oauth");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <OAuthClientsView />;
|
||||||
<OAuthClientsView />
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ const Page = async () => {
|
|||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={t("billing")}
|
title={t("billing")}
|
||||||
description={t("manage_billing_description")}
|
description={t("manage_billing_description")}
|
||||||
borderInShellHeader={false}>
|
borderInShellHeader={true}>
|
||||||
<BillingView />
|
<BillingView />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-29
@@ -1,33 +1,7 @@
|
|||||||
import { getTranslate, _generateMetadata } from "app/_utils";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
const Page = () => {
|
||||||
|
redirect("https://cal.com/docs/api-reference/v2/introduction");
|
||||||
import { AdminAPIView } from "~/ee/organizations/admin-api";
|
|
||||||
|
|
||||||
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
|
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
|
||||||
await _generateMetadata(
|
|
||||||
(t) => `${t("admin")} ${t("api_reference")}`,
|
|
||||||
(t) => t("leverage_our_api"),
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
"/settings/organizations/admin-api"
|
|
||||||
);
|
|
||||||
|
|
||||||
const Page = async () => {
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
await validateUserHasOrg();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsHeader
|
|
||||||
title={`${t("admin")} ${t("api_reference")}`}
|
|
||||||
description={t("leverage_our_api")}
|
|
||||||
borderInShellHeader={false}>
|
|
||||||
<AdminAPIView />
|
|
||||||
</SettingsHeader>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
+7
-8
@@ -11,8 +11,8 @@ import {
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
import { Checkbox, Label } from "@calcom/ui/components/form";
|
import { Checkbox, Label } from "@calcom/ui/components/form";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
|
||||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
import { ChevronRightIcon, InfoIcon } from "@coss/ui/icons";
|
||||||
|
|
||||||
import { usePermissions } from "./usePermissions";
|
import { usePermissions } from "./usePermissions";
|
||||||
|
|
||||||
@@ -54,10 +54,10 @@ export function AdvancedPermissionGroup({
|
|||||||
const allPermissions = isAllResources
|
const allPermissions = isAllResources
|
||||||
? ["*.*"]
|
? ["*.*"]
|
||||||
: resourceConfig
|
: resourceConfig
|
||||||
? Object.entries(resourceConfig)
|
? Object.entries(resourceConfig)
|
||||||
.filter(([action]) => action !== INTERNAL_DATAACCESS_KEY)
|
.filter(([action]) => action !== INTERNAL_DATAACCESS_KEY)
|
||||||
.map(([action]) => `${resource}.${action}`)
|
.map(([action]) => `${resource}.${action}`)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Check if all permissions for this resource are selected
|
// Check if all permissions for this resource are selected
|
||||||
const isAllSelected = isAllResources
|
const isAllSelected = isAllResources
|
||||||
@@ -98,8 +98,7 @@ export function AdvancedPermissionGroup({
|
|||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<div className="flex items-center gap-1.5" onClick={() => setIsExpanded(!isExpanded)}>
|
<div className="flex items-center gap-1.5" onClick={() => setIsExpanded(!isExpanded)}>
|
||||||
<Icon
|
<ChevronRightIcon
|
||||||
name="chevron-right"
|
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"h-4 w-4 transition-transform",
|
"h-4 w-4 transition-transform",
|
||||||
isExpanded && !isAllResources ? "rotate-90" : ""
|
isExpanded && !isAllResources ? "rotate-90" : ""
|
||||||
@@ -173,7 +172,7 @@ export function AdvancedPermissionGroup({
|
|||||||
</span>
|
</span>
|
||||||
{isAutoEnabled && (
|
{isAutoEnabled && (
|
||||||
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
|
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
|
||||||
<Icon name="info" className="text-muted-foreground h-3 w-3" />
|
<InfoIcon className="text-muted-foreground h-3 w-3" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-5
@@ -7,8 +7,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
|
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
|
import { LockIcon, ShieldCheckIcon, ShieldIcon, UsersIcon } from "@coss/ui/icons";
|
||||||
|
|
||||||
import { FingerprintAnimation } from "./FingerprintAnimation";
|
import { FingerprintAnimation } from "./FingerprintAnimation";
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export function PbacOptInModal({ open, onOpenChange, revalidateRolesPath }: Pbac
|
|||||||
<div className="bg-subtle border-subtle w-full stack-y-px overflow-hidden rounded-xl border">
|
<div className="bg-subtle border-subtle w-full stack-y-px overflow-hidden rounded-xl border">
|
||||||
<div className="bg-default flex gap-3 p-3">
|
<div className="bg-default flex gap-3 p-3">
|
||||||
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
||||||
<Icon name="shield-check" className="text-subtle h-5 w-5" />
|
<ShieldCheckIcon className="text-subtle h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-emphasis text-sm font-semibold">{t("pbac_opt_in_custom_roles_title")}</p>
|
<p className="text-emphasis text-sm font-semibold">{t("pbac_opt_in_custom_roles_title")}</p>
|
||||||
@@ -69,7 +69,7 @@ export function PbacOptInModal({ open, onOpenChange, revalidateRolesPath }: Pbac
|
|||||||
|
|
||||||
<div className="bg-default flex gap-3 p-3">
|
<div className="bg-default flex gap-3 p-3">
|
||||||
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
||||||
<Icon name="lock" className="text-subtle h-5 w-5" />
|
<LockIcon className="text-subtle h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-emphasis text-sm font-semibold">
|
<p className="text-emphasis text-sm font-semibold">
|
||||||
@@ -81,7 +81,7 @@ export function PbacOptInModal({ open, onOpenChange, revalidateRolesPath }: Pbac
|
|||||||
|
|
||||||
<div className="bg-default flex gap-3 p-3">
|
<div className="bg-default flex gap-3 p-3">
|
||||||
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
||||||
<Icon name="users" className="text-subtle h-5 w-5" />
|
<UsersIcon className="text-subtle h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-emphasis text-sm font-semibold">
|
<p className="text-emphasis text-sm font-semibold">
|
||||||
@@ -93,7 +93,7 @@ export function PbacOptInModal({ open, onOpenChange, revalidateRolesPath }: Pbac
|
|||||||
|
|
||||||
<div className="bg-default flex gap-3 p-3">
|
<div className="bg-default flex gap-3 p-3">
|
||||||
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
<div className="bg-subtle flex h-8 w-8 shrink-0 items-center justify-center rounded-md">
|
||||||
<Icon name="shield" className="text-subtle h-5 w-5" />
|
<ShieldIcon className="text-subtle h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-emphasis text-sm font-semibold">
|
<p className="text-emphasis text-sm font-semibold">
|
||||||
|
|||||||
+3
-3
@@ -27,9 +27,9 @@ const Page = async () => {
|
|||||||
const organizationRepository = getOrganizationRepository();
|
const organizationRepository = getOrganizationRepository();
|
||||||
const otherTeams = organizationId
|
const otherTeams = organizationId
|
||||||
? await organizationRepository.findTeamsInOrgIamNotPartOf({
|
? await organizationRepository.findTeamsInOrgIamNotPartOf({
|
||||||
userId: session?.user.id,
|
userId: session?.user.id,
|
||||||
parentId: organizationId,
|
parentId: organizationId,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+1
-3
@@ -1,8 +1,6 @@
|
|||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||||
|
|
||||||
import LegacyPage, {
|
import LegacyPage, { TeamMembersCTA } from "~/ee/organizations/other-team-members-view";
|
||||||
TeamMembersCTA,
|
|
||||||
} from "~/ee/organizations/other-team-members-view";
|
|
||||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||||
|
|
||||||
import { validateUserHasOrg } from "../../../../actions/validateUserHasOrg";
|
import { validateUserHasOrg } from "../../../../actions/validateUserHasOrg";
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ const getData = withAppDirSsr<inferSSRProps<typeof getServerSideProps>>(getServe
|
|||||||
|
|
||||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||||
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
|
await getData(buildLegacyCtx(await headers(), await cookies(), await params, await searchParams));
|
||||||
return (
|
return <SettingsNewView />;
|
||||||
<SettingsNewView />
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ServerPage;
|
export default ServerPage;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
|
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
|
||||||
|
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||||
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
import { _generateMetadata } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import LicenseRequired from "~/ee/common/components/LicenseRequired";
|
import LicenseRequired from "~/ee/common/components/LicenseRequired";
|
||||||
|
|
||||||
import LegacyPage, { LayoutWrapper } from "~/ee/organizations/new/create-new-view";
|
import LegacyPage, { LayoutWrapper } from "~/ee/organizations/new/create-new-view";
|
||||||
|
|
||||||
export const generateMetadata = async () =>
|
export const generateMetadata = async () =>
|
||||||
@@ -14,6 +18,19 @@ export const generateMetadata = async () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ServerPage = async () => {
|
const ServerPage = async () => {
|
||||||
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||||
|
const userEmail = session.user.email || "";
|
||||||
|
|
||||||
|
if (!isAdmin && !isCompanyEmail(userEmail)) {
|
||||||
|
return redirect("/settings/my-account/profile");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutWrapper>
|
<LayoutWrapper>
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
|
|||||||
@@ -8,13 +8,7 @@ interface GeoContextValue {
|
|||||||
|
|
||||||
const GeoContext = createContext<GeoContextValue | undefined>(undefined);
|
const GeoContext = createContext<GeoContextValue | undefined>(undefined);
|
||||||
|
|
||||||
export function GeoProvider({
|
export function GeoProvider({ country, children }: { country: string; children: React.ReactNode }) {
|
||||||
country,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
country: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return <GeoContext.Provider value={{ country }}>{children}</GeoContext.Provider>;
|
return <GeoContext.Provider value={{ country }}>{children}</GeoContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ const url =
|
|||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
? "/api/trpc"
|
? "/api/trpc"
|
||||||
: process.env.VERCEL_URL
|
: process.env.VERCEL_URL
|
||||||
? `https://${process.env.VERCEL_URL}/api/trpc`
|
? `https://${process.env.VERCEL_URL}/api/trpc`
|
||||||
: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;
|
: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;
|
||||||
|
|
||||||
export const trpcClient = trpc.createClient({
|
export const trpcClient = trpc.createClient({
|
||||||
links: [
|
links: [
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { constructAppImage, constructGenericImage, constructMeetingImage } from
|
|||||||
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants";
|
import { IS_CALCOM, WEBAPP_URL, APP_NAME, SEO_IMG_OGIMG, CAL_URL } from "@calcom/lib/constants";
|
||||||
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
|
import { getCalcomUrl } from "@calcom/lib/getCalcomUrl";
|
||||||
import { buildCanonical } from "@calcom/lib/next-seo.config";
|
import { buildCanonical } from "@calcom/lib/next-seo.config";
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { getTranslation } from "@calcom/i18n/server";
|
||||||
import { truncateOnWord } from "@calcom/lib/text";
|
import { truncateOnWord } from "@calcom/lib/text";
|
||||||
|
|
||||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user