Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46f67bba9b | ||
|
|
d9806da049 | ||
|
|
42fc49488d | ||
|
|
e030fe0e07 | ||
|
|
51428087ef | ||
|
|
3942df369d | ||
|
|
1d309a6f09 | ||
|
|
56f31eee7a | ||
|
|
2dae0bfb8c | ||
|
|
917c7b0764 | ||
|
|
fd64b0f468 | ||
|
|
b1909337db | ||
|
|
039bf6d4e3 | ||
|
|
83e15c4ce1 | ||
|
|
7d6ecf7201 | ||
|
|
bc42c27230 | ||
|
|
57abb26c31 | ||
|
|
a136511c8a | ||
|
|
c65dca9486 | ||
|
|
816d31918d | ||
|
|
097a69d7d7 | ||
|
|
c2a07e221d | ||
|
|
6d8fbfc49a | ||
|
|
304ef29618 | ||
|
|
b8890c841c | ||
|
|
2f5768b74f | ||
|
|
c1416173e8 | ||
|
|
726af4970c | ||
|
|
d15b7d8168 | ||
|
|
c5bd45fdfb | ||
|
|
5d7b2a12a7 | ||
|
|
7c90d1ed76 | ||
|
|
5ca10d1e83 | ||
|
|
f0e59c2331 | ||
|
|
f812d92045 | ||
|
|
23a17c4e8a | ||
|
|
5beb039003 | ||
|
|
63dd357664 | ||
|
|
b6a57381c7 | ||
|
|
7cf840723b | ||
|
|
bc0c40d260 | ||
|
|
89612a7a68 | ||
|
|
a2620a0e50 | ||
|
|
cfcf5b168c | ||
|
|
3f4d9962a7 | ||
|
|
83917e8655 | ||
|
|
f4bd0dbcc9 | ||
|
|
c87b8b43f9 | ||
|
|
b2baaea84c | ||
|
|
0e431ba7c6 | ||
|
|
b62f6eee3c | ||
|
|
2d0cac0dad | ||
|
|
0c3d1b3034 | ||
|
|
74f6d7686b | ||
|
|
49601bed6f | ||
|
|
b9659051d4 | ||
|
|
9c96507af2 | ||
|
|
2352babe93 | ||
|
|
1467815c3f | ||
|
|
7254cd97e2 | ||
|
|
4cb77abfd7 | ||
|
|
8deedfd361 | ||
|
|
3a0c766ed5 | ||
|
|
4f57e3201d | ||
|
|
e404f7eb88 | ||
|
|
5c2b561a5c | ||
|
|
57b404f721 | ||
|
|
df9a6621cf | ||
|
|
c478f73327 | ||
|
|
88f4dd2446 | ||
|
|
97b2816897 | ||
|
|
00372b6b3b | ||
|
|
6c8fced247 | ||
|
|
52813b01e6 | ||
|
|
77a98d96c8 | ||
|
|
676eac357f | ||
|
|
5bafd1b264 | ||
|
|
0391bfe887 | ||
|
|
c18407d75a | ||
|
|
a631286770 | ||
|
|
dd78741158 | ||
|
|
8f69811e25 | ||
|
|
3a63a8b228 | ||
|
|
ef98105503 |
@@ -2,29 +2,18 @@
|
||||
|
||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||
|
||||
Fixes # (issue)
|
||||
- Fixes #XXXX (GitHub issue number)
|
||||
- Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)
|
||||
|
||||
<!-- Please provide a loom video for visual changes to speed up reviews
|
||||
Loom Video: https://www.loom.com/
|
||||
-->
|
||||
|
||||
## Requirement/Documentation
|
||||
## Mandatory Tasks (DO NOT REMOVE)
|
||||
|
||||
<!-- Please provide all documents that are important to understand the reason of that PR. -->
|
||||
|
||||
- If there is a requirement document, please, share it here.
|
||||
- If there is a UI/UX design document, please, share it here.
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please delete bullets that are not relevant. -->
|
||||
|
||||
- Bug fix (non-breaking change which fixes an issue)
|
||||
- Chore (refactoring code, technical debt, workflow improvements)
|
||||
- New feature (non-breaking change which adds functionality)
|
||||
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- Tests (Unit/Integration/E2E or any other test)
|
||||
- This change requires a documentation update
|
||||
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected)
|
||||
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com)
|
||||
- [ ] I have added or modified automated tests that prove my fix is effective or that my feature works (PRs might be rejected if logical changes are not properly tested)
|
||||
|
||||
## How should this be tested?
|
||||
|
||||
@@ -35,10 +24,6 @@ Fixes # (issue)
|
||||
- What is expected (happy path) to have (input and output)?
|
||||
- Any other important info that could help to test that PR
|
||||
|
||||
## Mandatory Tasks
|
||||
|
||||
- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
|
||||
|
||||
## Checklist
|
||||
|
||||
<!-- Remove bullet points below that don't apply to you -->
|
||||
@@ -46,7 +31,4 @@ Fixes # (issue)
|
||||
- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||
- My code doesn't follow the style guidelines of this project
|
||||
- I haven't commented my code, particularly in hard-to-understand areas
|
||||
- I haven't checked if my PR needs changes to the documentation
|
||||
- I haven't checked if my changes generate no new warnings
|
||||
- I haven't added tests that prove my fix is effective or that my feature works
|
||||
- I haven't checked if new and existing unit tests pass locally with my changes
|
||||
|
||||
@@ -11,11 +11,14 @@ runs:
|
||||
uses: buildjet/cache@v3
|
||||
id: cache-build
|
||||
env:
|
||||
# WARN: Don't touch this cache key. Currently github.sha refers to the latest commit in main
|
||||
# and not the branch that's attempting to merge to main, which causes CI errors when merged
|
||||
# to main.
|
||||
# TODO: Fix this problem if intending to modify this cache key.
|
||||
cache-name: prod-build
|
||||
key-1: ${{ inputs.node_version }}-${{ hashFiles('yarn.lock') }}
|
||||
key-2: ${{ hashFiles('apps/**/**.[jt]s', 'apps/**/**.[jt]sx', 'packages/**/**.[jt]s', 'packages/**/**.[jt]sx', '!**/node_modules') }}
|
||||
key-3: ${{ github.event.pull_request.number || github.ref }}
|
||||
# Ensures production-build.yml will always be fresh
|
||||
key-4: ${{ github.sha }}
|
||||
with:
|
||||
path: |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Production Build
|
||||
name: Production Builds
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -18,7 +18,10 @@ env:
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
@@ -33,13 +36,10 @@ env:
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build API V1
|
||||
name: Build API v1
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Production Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build API v2
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: calendso
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
yarn workspace @calcom/api-v2 run generate-schemas
|
||||
rm -rf apps/api/v2/node_modules
|
||||
yarn install
|
||||
yarn workspace @calcom/api-v2 run build
|
||||
shell: bash
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
echo "Fetching list of cache key"
|
||||
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
|
||||
|
||||
## Setting this to not fail the workflow while deleting cache keys.
|
||||
## Setting this to not fail the workflow while deleting cache keys.
|
||||
set +e
|
||||
echo "Deleting caches..."
|
||||
for cacheKey in $cacheKeysForPR
|
||||
|
||||
@@ -3,6 +3,41 @@ on:
|
||||
workflow_call:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
jobs:
|
||||
e2e-app-store:
|
||||
timeout-minutes: 20
|
||||
@@ -59,40 +94,6 @@ jobs:
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn e2e:app-store --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }}
|
||||
E2E_TEST_CALCOM_QA_PASSWORD: ${{ secrets.E2E_TEST_CALCOM_QA_PASSWORD }}
|
||||
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ secrets.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
|
||||
E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -3,6 +3,41 @@ on:
|
||||
workflow_call:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
jobs:
|
||||
e2e-embed:
|
||||
timeout-minutes: 20
|
||||
@@ -46,35 +81,6 @@ jobs:
|
||||
run: |
|
||||
yarn e2e:embed-react --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
yarn workspace @calcom/embed-react packaged:tests
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -3,6 +3,41 @@ on:
|
||||
workflow_call:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
jobs:
|
||||
e2e-embed:
|
||||
timeout-minutes: 20
|
||||
@@ -52,36 +87,6 @@ jobs:
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn e2e:embed --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+36
-35
@@ -3,11 +3,46 @@ on:
|
||||
workflow_call:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
jobs:
|
||||
e2e:
|
||||
timeout-minutes: 20
|
||||
name: E2E tests (${{ matrix.shard }}/${{ strategy.job-total }})
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13
|
||||
@@ -50,40 +85,6 @@ jobs:
|
||||
- uses: ./.github/actions/cache-build
|
||||
- name: Run Tests
|
||||
run: yarn e2e --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
- name: Upload Test Results
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -7,16 +7,53 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
|
||||
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
|
||||
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
|
||||
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Production build
|
||||
name: Production builds
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
uses: ./.github/workflows/production-build.yml
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
secrets: inherit
|
||||
analyze:
|
||||
needs: build
|
||||
if: always()
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
+17
-17
@@ -14,7 +14,7 @@ concurrency:
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
@@ -49,22 +49,22 @@ jobs:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production build
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-api-v1:
|
||||
name: Production build
|
||||
name: Production builds
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/api-v1-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build-without-database:
|
||||
name: Production build (without database)
|
||||
build-api-v2:
|
||||
name: Production builds
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/api-v2-production-build.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production builds
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
@@ -72,28 +72,28 @@ jobs:
|
||||
|
||||
e2e:
|
||||
name: E2E tests
|
||||
needs: [changes, lint, build]
|
||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-app-store:
|
||||
name: E2E App Store tests
|
||||
needs: [changes, lint, build]
|
||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-app-store.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed:
|
||||
name: E2E embeds tests
|
||||
needs: [changes, lint, build]
|
||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-embed.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-embed-react:
|
||||
name: E2E React embeds tests
|
||||
needs: [changes, lint, build]
|
||||
needs: [changes, lint, build, build-api-v1, build-api-v2]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
@@ -106,9 +106,9 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [changes, lint, type-check, test, build, build-api-v1, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||
needs: [changes, lint, type-check, test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
|
||||
if: always()
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: fail if conditional jobs failed
|
||||
if: needs.changes.outputs.has-files-requiring-all-checks == 'true' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled'))
|
||||
|
||||
@@ -39,8 +39,8 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Production build
|
||||
uses: ./.github/workflows/production-build.yml
|
||||
name: Production builds
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e:
|
||||
@@ -67,15 +67,10 @@ jobs:
|
||||
uses: ./.github/workflows/e2e-embed-react.yml
|
||||
secrets: inherit
|
||||
|
||||
build-without-database:
|
||||
name: Production build (without database)
|
||||
uses: ./.github/workflows/production-build-without-database.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [e2e, e2e-app-store, e2e-embed, e2e-embed-react, build-without-database]
|
||||
needs: [changes, lint, build, e2e, e2e-app-store, e2e-embed, e2e-embed-react]
|
||||
if: always()
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: fail if conditional jobs failed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
name: Production Build (without database)
|
||||
|
||||
name: Production Builds
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
@@ -37,8 +36,8 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
name: Web App
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
name: Production Build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
|
||||
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }}
|
||||
E2E_TEST_CALCOM_QA_PASSWORD: ${{ secrets.E2E_TEST_CALCOM_QA_PASSWORD }}
|
||||
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ secrets.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
|
||||
E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }}
|
||||
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
|
||||
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
|
||||
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
|
||||
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
|
||||
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
|
||||
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
|
||||
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
|
||||
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
|
||||
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
|
||||
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
|
||||
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
|
||||
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
|
||||
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
|
||||
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: calendso
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/cache-db
|
||||
- uses: ./.github/actions/cache-build
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 20
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://go.cal.com/discord"><img src="https://img.shields.io/badge/Discord-go.cal.com%2Fdiscord-%234A154B" alt="Join Cal.com Discord"></a>
|
||||
<a href="https://producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||
<a href="https://www.producthunt.com/products/cal-com"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
|
||||
<a href="https://status.cal.com"><img height="20px" src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
|
||||
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
|
||||
<a href="https://news.ycombinator.com/item?id=34507672"><img src="https://img.shields.io/badge/Hacker%20News-%231-%23FF6600" alt="Hacker News"></a>
|
||||
@@ -33,7 +33,7 @@
|
||||
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-Free-brightgreen" alt="Pricing"></a>
|
||||
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
|
||||
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
|
||||
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
|
||||
<a href="https://hub.docker.com/r/calcom/cal.com"><img src="https://img.shields.io/docker/pulls/calcom/cal.com"></a>
|
||||
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
|
||||
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
|
||||
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
|
||||
|
||||
@@ -82,4 +82,5 @@ export const schemaBookingReadPublic = Booking.extend({
|
||||
metadata: true,
|
||||
status: true,
|
||||
responses: true,
|
||||
fromReschedule: true,
|
||||
});
|
||||
|
||||
@@ -57,6 +57,11 @@ const swaggerHandler = withSwagger({
|
||||
type: "string",
|
||||
example: "Europe/London",
|
||||
},
|
||||
fromReschedule: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
format: "uuid",
|
||||
},
|
||||
attendees: {
|
||||
type: "array",
|
||||
items: {
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
const checkoutSession = await purchaseTeamOrOrgSubscription({
|
||||
teamId: _team.id,
|
||||
seats: _team.members.length,
|
||||
seatsUsed: _team.members.length,
|
||||
userId,
|
||||
pricePerSeat: null,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ module.exports = {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: [".eslintrc.js"],
|
||||
ignorePatterns: [".eslintrc.js", "next-i18next.config.js"],
|
||||
rules: {
|
||||
"@typescript-eslint/interface-name-prefix": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const path = require("path");
|
||||
const i18nConfig = require("@calcom/config/next-i18next.config");
|
||||
|
||||
/** @type {import("next-i18next").UserConfig} */
|
||||
const config = {
|
||||
...i18nConfig,
|
||||
localePath: path.resolve("../../web/public/static/locales"),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -13,7 +13,7 @@
|
||||
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
|
||||
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "SKIP_DOCS_GENERATION=1 node ./dist/apps/api/v2/src/main.js",
|
||||
"start:prod": "node ./dist/apps/api/v2/src/main.js",
|
||||
"test": "yarn dev:build && jest",
|
||||
"test:watch": "yarn dev:build && jest --watch",
|
||||
"test:cov": "yarn dev:build && jest --coverage",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
|
||||
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
|
||||
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
|
||||
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
|
||||
import { ResponseInterceptor } from "@/middleware/request-ids/request-id.interceptor";
|
||||
import { RequestIdMiddleware } from "@/middleware/request-ids/request-id.middleware";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { EndpointsModule } from "@/modules/endpoints.module";
|
||||
import { JwtModule } from "@/modules/jwt/jwt.module";
|
||||
@@ -11,7 +13,7 @@ import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { RouterModule } from "@nestjs/core";
|
||||
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
|
||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||
|
||||
@@ -52,6 +54,12 @@ import { AppController } from "./app.controller";
|
||||
RouterModule.register([{ path: "/v2", module: EndpointsModule }]),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ResponseInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
@@ -63,6 +71,8 @@ export class AppModule implements NestModule {
|
||||
})
|
||||
.apply(JsonBodyMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(RequestIdMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(AppLoggerMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(RewriterMiddleware)
|
||||
|
||||
@@ -150,7 +150,9 @@ export class BookingsController {
|
||||
@Headers(X_CAL_CLIENT_ID) clientId?: string
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
const oAuthClientId = clientId?.toString();
|
||||
const locationUrl = body.locationUrl;
|
||||
|
||||
const { orgSlug, locationUrl } = body;
|
||||
req.headers["x-cal-force-slug"] = orgSlug;
|
||||
try {
|
||||
const booking = await handleNewBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
|
||||
|
||||
@@ -99,6 +99,10 @@ export class CreateBookingInput {
|
||||
@Type(() => Response)
|
||||
responses!: Response;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
orgSlug?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
locationUrl?: string;
|
||||
|
||||
@@ -131,6 +131,7 @@ describe("Event types Endpoints", () => {
|
||||
description: "A description of the test event type.",
|
||||
length: 60,
|
||||
hidden: false,
|
||||
disableGuests: true,
|
||||
locations: [
|
||||
{
|
||||
type: "Online",
|
||||
@@ -148,6 +149,7 @@ describe("Event types Endpoints", () => {
|
||||
const responseBody: ApiSuccessResponse<EventType> = response.body;
|
||||
expect(responseBody.data).toHaveProperty("id");
|
||||
expect(responseBody.data.title).toEqual(body.title);
|
||||
expect(responseBody.data.disableGuests).toEqual(body.disableGuests);
|
||||
eventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
@@ -157,14 +159,19 @@ describe("Event types Endpoints", () => {
|
||||
|
||||
const body: UpdateEventTypeInput = {
|
||||
title: newTitle,
|
||||
disableGuests: false,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async () => {
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventType> = response.body;
|
||||
expect(responseBody.data.title).toEqual(newTitle);
|
||||
expect(responseBody.data.disableGuests).toEqual(body.disableGuests);
|
||||
eventType.title = newTitle;
|
||||
eventType.disableGuests = responseBody.data.disableGuests ?? false;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ export class CreateEventTypeInput {
|
||||
@IsArray()
|
||||
locations?: EventTypeLocation[];
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
disableGuests?: boolean;
|
||||
|
||||
// @ApiHideProperty()
|
||||
// @IsOptional()
|
||||
// @IsNumber()
|
||||
|
||||
@@ -12,5 +12,5 @@ export class GetPublicEventTypeQueryParams {
|
||||
@ApiProperty({ required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
org?: string;
|
||||
org?: string | null;
|
||||
}
|
||||
|
||||
@@ -322,9 +322,9 @@ export class UpdateEventTypeInput {
|
||||
// @IsOptional()
|
||||
// recurringEvent?: RecurringEvent;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// disableGuests?: boolean;
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
disableGuests?: boolean;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
|
||||
@@ -26,16 +26,12 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { google } from "googleapis";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
APPS_READ,
|
||||
GOOGLE_CALENDAR_ID,
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
SUCCESS_STATUS,
|
||||
} from "@calcom/platform-constants";
|
||||
import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
const CALENDAR_SCOPES = [
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
@@ -106,10 +102,11 @@ export class GcalController {
|
||||
|
||||
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
|
||||
const token = await oAuth2Client.getToken(parsedCode);
|
||||
const key = token.res?.data;
|
||||
// Google oAuth Credentials are stored in token.tokens
|
||||
const key = token.tokens;
|
||||
const credential = await this.credentialRepository.createAppCredential(
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
key,
|
||||
key as Prisma.InputJsonValue,
|
||||
ownerId
|
||||
);
|
||||
|
||||
@@ -129,7 +126,7 @@ export class GcalController {
|
||||
primaryCal.id,
|
||||
credential.id,
|
||||
ownerId,
|
||||
GOOGLE_CALENDAR_ID
|
||||
GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,18 +13,22 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const statusCode = exception.getStatus();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
});
|
||||
|
||||
response.status(statusCode).json({
|
||||
status: ERROR_STATUS,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
error: { code: exception.name, message: exception.message },
|
||||
error: { code: exception.name, message: exception.message, details: exception.getResponse() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,15 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
this.logger.error(`PrismaError: ${error.message}`, {
|
||||
error,
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
});
|
||||
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
status: ERROR_STATUS,
|
||||
|
||||
@@ -14,6 +14,7 @@ export class SentryFilter extends BaseExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
this.logger.error(`Sentry Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
@@ -21,6 +22,7 @@ export class SentryFilter extends BaseExceptionFilter {
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
});
|
||||
|
||||
// capture if client has been init
|
||||
|
||||
@@ -41,12 +41,15 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
break;
|
||||
}
|
||||
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
});
|
||||
|
||||
response.status(statusCode).json({
|
||||
|
||||
@@ -14,6 +14,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
this.logger.error(`ZodError: ${error.message}`, {
|
||||
error,
|
||||
@@ -21,6 +22,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
headers: request.headers,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
requestId,
|
||||
});
|
||||
|
||||
response.status(HttpStatus.BAD_REQUEST).json({
|
||||
|
||||
@@ -24,9 +24,7 @@ const run = async () => {
|
||||
try {
|
||||
bootstrap(app);
|
||||
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
|
||||
if (process.env.SKIP_DOCS_GENERATION !== "1") {
|
||||
void generateSwagger(app);
|
||||
}
|
||||
void generateSwagger(app);
|
||||
await app.listen(port);
|
||||
logger.log(`Application started on port: ${port}`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler) {
|
||||
const ctx = context.switchToHttp();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Injectable, NestMiddleware } from "@nestjs/common";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(req: Request, res: Response, next: NextFunction) {
|
||||
req.headers["X-Request-Id"] = uuid();
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export class BillingController {
|
||||
|
||||
@Get("/:teamId/check")
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
|
||||
@Roles(["OWNER", "ADMIN"])
|
||||
@Roles(["OWNER", "ADMIN", "MEMBER"])
|
||||
async checkTeamBilling(
|
||||
@Param("teamId") teamId: number
|
||||
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
|
||||
@@ -104,10 +104,19 @@ export class BillingController {
|
||||
|
||||
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
if (!subscription.metadata?.teamId) {
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
const teamId = Number.parseInt(subscription.metadata.teamId);
|
||||
const plan = subscription.metadata.plan;
|
||||
if (!plan || !teamId) {
|
||||
throw new Error("Invalid webhook received.");
|
||||
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
await this.billingService.setSubscriptionForTeam(
|
||||
@@ -121,6 +130,8 @@ export class BillingController {
|
||||
};
|
||||
}
|
||||
|
||||
throw new BadRequestException(`Unhandled event type ${event.type}`);
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ export class BillingService {
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
|
||||
cancel_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
|
||||
success_url: `${this.webAppUrl}/settings/platform/`,
|
||||
cancel_url: `${this.webAppUrl}/settings/platform/`,
|
||||
mode: "subscription",
|
||||
metadata: {
|
||||
teamId: teamId.toString(),
|
||||
@@ -99,22 +99,36 @@ export class BillingService {
|
||||
}
|
||||
|
||||
async increaseUsageForTeam(teamId: number) {
|
||||
// TODO - if we support multiple subscription items per team, we may need to track which plan they're
|
||||
// subscribed to so we can do one less query.
|
||||
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
|
||||
if (!billingSubscription || !billingSubscription?.subscriptionId) {
|
||||
throw new Error(`Failed to increase usage for team ${teamId}`);
|
||||
}
|
||||
try {
|
||||
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
|
||||
if (!billingSubscription || !billingSubscription?.subscriptionId) {
|
||||
this.logger.error("Team did not have stripe subscription associated to it", {
|
||||
teamId,
|
||||
});
|
||||
return void 0;
|
||||
}
|
||||
|
||||
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
|
||||
billingSubscription.subscriptionId
|
||||
);
|
||||
const items = stripeSubscription.items.data[0]; // first (and only) subscription item.
|
||||
await this.stripeService.stripe.subscriptionItems.createUsageRecord(items.id, {
|
||||
action: "increment",
|
||||
quantity: 1,
|
||||
timestamp: "now",
|
||||
});
|
||||
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
|
||||
billingSubscription.subscriptionId
|
||||
);
|
||||
const item = stripeSubscription.items.data[0];
|
||||
// legacy plans are licensed, we cannot create usage records against them
|
||||
if (item.price?.recurring?.usage_type === "licensed") {
|
||||
return void 0;
|
||||
}
|
||||
|
||||
await this.stripeService.stripe.subscriptionItems.createUsageRecord(item.id, {
|
||||
action: "increment",
|
||||
quantity: 1,
|
||||
timestamp: "now",
|
||||
});
|
||||
} catch (error) {
|
||||
// don't fail the request, log it.
|
||||
this.logger.error("Failed to increase usage for team", {
|
||||
teamId: teamId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async increaseUsageByClientId(clientId: string) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum PlatformPlan {
|
||||
STARTER = "STARTER",
|
||||
ESSENTIALS = "ESSENTIALS",
|
||||
SCALE = "SCALE",
|
||||
ENTERPRISE = "ENTERPRISE",
|
||||
}
|
||||
|
||||
+5
@@ -139,6 +139,8 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
const requestBody: CreateManagedUserInput = {
|
||||
email: userEmail,
|
||||
timeZone: userTimeZone,
|
||||
weekStart: "Monday",
|
||||
timeFormat: 24,
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@@ -158,6 +160,9 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
|
||||
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
|
||||
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
|
||||
+38
@@ -3,6 +3,8 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
|
||||
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
|
||||
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
|
||||
@@ -10,9 +12,11 @@ import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oa
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Query,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
@@ -32,9 +36,11 @@ import {
|
||||
ApiCreatedResponse as DocsCreatedResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { User } from "@prisma/client";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { CreateOAuthClientInput } from "@calcom/platform-types";
|
||||
import { Pagination } from "@calcom/platform-types";
|
||||
|
||||
const AUTH_DOCUMENTATION = `⚠️ First, this endpoint requires \`Cookie: next-auth.session-token=eyJhbGciOiJ\` header. Log into Cal web app using owner of organization that was created after visiting \`/settings/organizations/new\`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.
|
||||
Second, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.`;
|
||||
@@ -51,6 +57,7 @@ export class OAuthClientsController {
|
||||
|
||||
constructor(
|
||||
private readonly oauthClientRepository: OAuthClientRepository,
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly teamsRepository: OrganizationsRepository
|
||||
) {}
|
||||
|
||||
@@ -109,6 +116,24 @@ export class OAuthClientsController {
|
||||
return { status: SUCCESS_STATUS, data: client };
|
||||
}
|
||||
|
||||
@Get("/:clientId/managed-users")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@DocsOperation({ description: AUTH_DOCUMENTATION })
|
||||
async getOAuthClientManagedUsersById(
|
||||
@Param("clientId") clientId: string,
|
||||
@Query() queryParams: Pagination
|
||||
): Promise<GetManagedUsersOutput> {
|
||||
const { offset, limit } = queryParams;
|
||||
const existingManagedUsers = await this.userRepository.findManagedUsersByOAuthClientId(
|
||||
clientId,
|
||||
offset ?? 0,
|
||||
limit ?? 50
|
||||
);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: existingManagedUsers.map((user) => this.getResponseUser(user)) };
|
||||
}
|
||||
|
||||
@Patch("/:clientId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
@@ -131,4 +156,17 @@ export class OAuthClientsController {
|
||||
const client = await this.oauthClientRepository.deleteOAuthClient(clientId);
|
||||
return { status: SUCCESS_STATUS, data: client };
|
||||
}
|
||||
|
||||
private getResponseUser(user: User): ManagedUserOutput {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
timeZone: user.timeZone,
|
||||
weekStart: user.weekStart,
|
||||
createdDate: user.createdDate,
|
||||
timeFormat: user.timeFormat,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ export class OAuthClientUsersService {
|
||||
},
|
||||
},
|
||||
isPlatformManaged,
|
||||
timeFormat: body.timeFormat,
|
||||
weekStart: body.weekStart,
|
||||
timeZone: body.timeZone,
|
||||
})
|
||||
)[0];
|
||||
await this.userRepository.addToOAuthClient(user.id, oAuthClientId);
|
||||
|
||||
@@ -40,7 +40,7 @@ export class OAuthFlowService {
|
||||
}
|
||||
|
||||
async getOwnerId(accessToken: string) {
|
||||
const cacheKey = this._generateActKey(accessToken);
|
||||
const cacheKey = this._generateOwnerIdKey(accessToken);
|
||||
|
||||
try {
|
||||
const ownerId = await this.redisService.redis.get(cacheKey);
|
||||
@@ -165,4 +165,8 @@ export class OAuthFlowService {
|
||||
private _generateActKey(accessToken: string) {
|
||||
return `act_${accessToken}`;
|
||||
}
|
||||
|
||||
private _generateOwnerIdKey(accessToken: string) {
|
||||
return `owner_${accessToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { cityTimezonesHandler } from "@calcom/platform-libraries";
|
||||
import type { CityTimezones } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class TimezonesService {
|
||||
async getCityTimeZones() {
|
||||
return cityTimezonesHandler();
|
||||
private cacheKey = "cityTimezones";
|
||||
|
||||
constructor(private readonly redisService: RedisService) {}
|
||||
|
||||
async getCityTimeZones(): Promise<CityTimezones> {
|
||||
const cachedTimezones = await this.redisService.redis.get(this.cacheKey);
|
||||
if (!cachedTimezones) {
|
||||
const timezones = await cityTimezonesHandler();
|
||||
await this.redisService.redis.set(this.cacheKey, JSON.stringify(timezones), "EX", 60 * 60 * 24);
|
||||
|
||||
return timezones;
|
||||
} else {
|
||||
return JSON.parse(cachedTimezones) as CityTimezones;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TimezonesController } from "@/modules/timezones/controllers/timezones.controller";
|
||||
import { TimezonesService } from "@/modules/timezones/services/timezones.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [RedisModule],
|
||||
providers: [TimezonesService],
|
||||
controllers: [TimezonesController],
|
||||
exports: [TimezonesService],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { IsTimeFormat } from "@/modules/users/inputs/validators/is-time-format";
|
||||
import { IsWeekStart } from "@/modules/users/inputs/validators/is-week-start";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNumber, IsOptional, IsTimeZone, IsString, Validate } from "class-validator";
|
||||
import { IsOptional, IsTimeZone, IsString } from "class-validator";
|
||||
|
||||
export type WeekDay = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
|
||||
export type TimeFormat = 12 | 24;
|
||||
export class CreateManagedUserInput {
|
||||
@IsString()
|
||||
@ApiProperty({ example: "alice@example.com" })
|
||||
@@ -12,17 +12,17 @@ export class CreateManagedUserInput {
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Validate(IsTimeFormat)
|
||||
@ApiProperty({ example: 12 })
|
||||
timeFormat?: number;
|
||||
@ApiProperty({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
|
||||
timeFormat?: TimeFormat;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Validate(IsWeekStart)
|
||||
@ApiProperty({ example: "Sunday" })
|
||||
weekStart?: string;
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
example: "Monday",
|
||||
enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
|
||||
})
|
||||
weekStart?: WeekDay;
|
||||
|
||||
@IsTimeZone()
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IsTimeFormat } from "@/modules/users/inputs/validators/is-time-format";
|
||||
import { IsWeekStart } from "@/modules/users/inputs/validators/is-week-start";
|
||||
import { IsNumber, IsOptional, IsString, IsTimeZone, Validate } from "class-validator";
|
||||
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsNumber, IsOptional, IsString, IsTimeZone } from "class-validator";
|
||||
|
||||
export class UpdateManagedUserInput {
|
||||
@IsString()
|
||||
@@ -11,19 +11,21 @@ export class UpdateManagedUserInput {
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Validate(IsTimeFormat)
|
||||
timeFormat?: number;
|
||||
@ApiProperty({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
|
||||
timeFormat?: TimeFormat;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
defaultScheduleId?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Validate(IsWeekStart)
|
||||
weekStart?: string;
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
example: "Monday",
|
||||
enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
|
||||
})
|
||||
weekStart?: WeekDay;
|
||||
|
||||
@IsTimeZone()
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "isTimeFormat", async: false })
|
||||
export class IsTimeFormat implements ValidatorConstraintInterface {
|
||||
validate(timeFormat: number) {
|
||||
return timeFormat === 12 || timeFormat === 24;
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "timeFormat must be a number either 12 or 24";
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator";
|
||||
|
||||
@ValidatorConstraint({ name: "isWeekStart", async: false })
|
||||
export class IsWeekStart implements ValidatorConstraintInterface {
|
||||
validate(weekStart: string) {
|
||||
if (!weekStart) return false;
|
||||
|
||||
const lowerCaseWeekStart = weekStart.toLowerCase();
|
||||
return (
|
||||
lowerCaseWeekStart === "monday" ||
|
||||
lowerCaseWeekStart === "tuesday" ||
|
||||
lowerCaseWeekStart === "wednesday" ||
|
||||
lowerCaseWeekStart === "thursday" ||
|
||||
lowerCaseWeekStart === "friday" ||
|
||||
lowerCaseWeekStart === "saturday" ||
|
||||
lowerCaseWeekStart === "sunday"
|
||||
);
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return "weekStart must be a string either Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, or Sunday";
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export class UsersRepository {
|
||||
|
||||
formatInput(userInput: CreateManagedUserInput | UpdateManagedUserInput) {
|
||||
if (userInput.weekStart) {
|
||||
userInput.weekStart = capitalize(userInput.weekStart);
|
||||
userInput.weekStart = userInput.weekStart;
|
||||
}
|
||||
|
||||
if (userInput.timeZone) {
|
||||
|
||||
@@ -404,6 +404,38 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/oauth-clients/{clientId}/managed-users": {
|
||||
"get": {
|
||||
"operationId": "OAuthClientsController_getOAuthClientManagedUsersById",
|
||||
"summary": "",
|
||||
"description": "⚠️ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "clientId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GetManagedUsersOutput"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"OAuth - development only"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/oauth/{clientId}/authorize": {
|
||||
"post": {
|
||||
"operationId": "OAuthFlowController_authorize",
|
||||
@@ -722,6 +754,7 @@
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -1650,11 +1683,25 @@
|
||||
},
|
||||
"timeFormat": {
|
||||
"type": "number",
|
||||
"example": 12
|
||||
"example": 12,
|
||||
"enum": [
|
||||
12,
|
||||
24
|
||||
],
|
||||
"description": "Must be 12 or 24"
|
||||
},
|
||||
"weekStart": {
|
||||
"type": "string",
|
||||
"example": "Sunday"
|
||||
"example": "Monday",
|
||||
"enum": [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday"
|
||||
]
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string",
|
||||
@@ -1730,21 +1777,37 @@
|
||||
"UpdateManagedUserInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timeFormat": {
|
||||
"type": "number",
|
||||
"example": 12,
|
||||
"enum": [
|
||||
12,
|
||||
24
|
||||
],
|
||||
"description": "Must be 12 or 24"
|
||||
},
|
||||
"weekStart": {
|
||||
"type": "string",
|
||||
"example": "Monday",
|
||||
"enum": [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday"
|
||||
]
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeFormat": {
|
||||
"type": "number"
|
||||
},
|
||||
"defaultScheduleId": {
|
||||
"type": "number"
|
||||
},
|
||||
"weekStart": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -2036,6 +2099,9 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/EventTypeLocation"
|
||||
}
|
||||
},
|
||||
"disableGuests": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2718,6 +2784,9 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/EventTypeLocation"
|
||||
}
|
||||
},
|
||||
"disableGuests": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4226,6 +4295,9 @@
|
||||
"responses": {
|
||||
"$ref": "#/components/schemas/Response"
|
||||
},
|
||||
"orgSlug": {
|
||||
"type": "string"
|
||||
},
|
||||
"locationUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
|
||||
JWT_SECRET: "XF+Hws3A5g2eyWA5uGYYVJ74X+wrCWJ8oWo6kAfU6O8=",
|
||||
LOG_LEVEL: "trace",
|
||||
REDIS_URL: "redis://localhost:9199",
|
||||
STRIPE_API_KEY: "sk_test_51J4",
|
||||
STRIPE_WEBHOOK_SECRET: "whsec_51J4",
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# @calcom/web
|
||||
|
||||
## 4.0.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @calcom/embed-core@1.5.0
|
||||
- @calcom/embed-react@1.5.0
|
||||
- @calcom/embed-snippet@1.3.0
|
||||
|
||||
## 3.9.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -85,7 +85,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
`}</style>
|
||||
</head>
|
||||
<body
|
||||
className="dark:bg-darkgray-50 todesktop:!bg-transparent bg-subtle antialiased"
|
||||
className="dark:bg-darkgray-50 bg-subtle antialiased"
|
||||
style={
|
||||
isEmbed
|
||||
? {
|
||||
|
||||
@@ -321,6 +321,20 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
type="text"
|
||||
{...formMethods.register("successRedirectUrl")}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Controller
|
||||
name="forwardParamsSuccessRedirect"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CheckboxField
|
||||
description={t("forward_params_redirect")}
|
||||
disabled={successRedirectUrlLocked.disabled}
|
||||
onChange={(e) => onChange(e)}
|
||||
checked={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"p-1 text-sm text-orange-600",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { SingleValue } from "react-select";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { getDefinedBufferTimes } from "@calcom/features/eventtypes/lib/getDefinedBufferTimes";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
@@ -172,7 +173,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
label: t("event_buffer_default"),
|
||||
value: 0,
|
||||
},
|
||||
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
|
||||
...getDefinedBufferTimes().map((minutes) => ({
|
||||
label: `${minutes} ${t("minutes")}`,
|
||||
value: minutes,
|
||||
})),
|
||||
|
||||
@@ -122,10 +122,21 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
||||
{webhooks.length ? (
|
||||
<>
|
||||
<div className="border-subtle mb-2 rounded-md border p-8">
|
||||
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
|
||||
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("add_webhook_description", { appName: APP_NAME })}
|
||||
</p>
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
|
||||
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("add_webhook_description", { appName: APP_NAME })}
|
||||
</p>
|
||||
</div>
|
||||
{isChildrenManagedEventType && !isManagedEventType ? (
|
||||
<Button StartIcon="lock" color="secondary" disabled>
|
||||
{t("locked_by_team_admin")}
|
||||
</Button>
|
||||
) : (
|
||||
<NewWebhookButton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-subtle my-8 rounded-md border">
|
||||
{webhooks.map((webhook, index) => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
|
||||
import { OAuthClientsDropdown } from "@components/settings/platform/dashboard/oauth-client-dropdown";
|
||||
|
||||
type ManagedUserHeaderProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
initialClientName: string;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const ManagedUserHeader = ({
|
||||
oauthClients,
|
||||
initialClientName,
|
||||
handleChange,
|
||||
}: ManagedUserHeaderProps) => {
|
||||
return (
|
||||
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
Managed Users
|
||||
</h1>
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
|
||||
See all the managed users created by your OAuth client.
|
||||
</p>
|
||||
</div>
|
||||
<OAuthClientsDropdown
|
||||
oauthClients={oauthClients}
|
||||
initialClientName={initialClientName}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
|
||||
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
|
||||
|
||||
import { ManagedUserHeader } from "@components/settings/platform/dashboard/managed-user-header";
|
||||
import { ManagedUserTable } from "@components/settings/platform/dashboard/managed-user-table";
|
||||
|
||||
type ManagedUserListProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
managedUsers?: ManagedUser[];
|
||||
initialClientName: string;
|
||||
initialClientId: string;
|
||||
isManagedUserLoading: boolean;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const ManagedUserList = ({
|
||||
initialClientName,
|
||||
initialClientId,
|
||||
oauthClients,
|
||||
managedUsers,
|
||||
isManagedUserLoading,
|
||||
handleChange,
|
||||
}: ManagedUserListProps) => {
|
||||
return (
|
||||
<div>
|
||||
<ManagedUserHeader
|
||||
oauthClients={oauthClients}
|
||||
initialClientName={initialClientName}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<ManagedUserTable
|
||||
managedUsers={managedUsers}
|
||||
isManagedUserLoading={isManagedUserLoading}
|
||||
initialClientId={initialClientId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { EmptyScreen } from "@calcom/ui";
|
||||
|
||||
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
|
||||
|
||||
type ManagedUserTableProps = {
|
||||
managedUsers?: ManagedUser[];
|
||||
isManagedUserLoading: boolean;
|
||||
initialClientId: string;
|
||||
};
|
||||
|
||||
export const ManagedUserTable = ({
|
||||
managedUsers,
|
||||
isManagedUserLoading,
|
||||
initialClientId,
|
||||
}: ManagedUserTableProps) => {
|
||||
const showUsers = !isManagedUserLoading && managedUsers?.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showUsers ? (
|
||||
<>
|
||||
<table className="w-[100%] rounded-lg">
|
||||
<colgroup className="border-subtle overflow-hidden rounded-b-lg border border-b-0" span={3} />
|
||||
<tr>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Id</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Username</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">Email</td>
|
||||
</tr>
|
||||
{managedUsers.map((user) => {
|
||||
return (
|
||||
<tr key={user.id} className="">
|
||||
<td className="border-subtle overflow-hidden border px-4 py-3 md:text-center">{user.id}</td>
|
||||
<td className="border-subtle border px-4 py-3 md:text-center">{user.username}</td>
|
||||
<td className="border-subtle overflow-hidden border px-4 py-3 md:overflow-auto md:text-center">
|
||||
{user.email}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
limitWidth={false}
|
||||
headline={
|
||||
initialClientId == undefined
|
||||
? "OAuth client is missing. You need to create an OAuth client first in order to create a managed user."
|
||||
: `OAuth client ${initialClientId} does not have a managed user present.`
|
||||
}
|
||||
description={
|
||||
initialClientId == undefined
|
||||
? "Refer to the Platform Docs from the sidebar in order to create an OAuth client."
|
||||
: "Refer to the Platform Docs from the sidebar in order to create a managed user."
|
||||
}
|
||||
className="items-center border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type OAuthClientsDropdownProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
initialClientName: string;
|
||||
handleChange: (clientId: string, clientName: string) => void;
|
||||
};
|
||||
|
||||
export const OAuthClientsDropdown = ({
|
||||
oauthClients,
|
||||
initialClientName,
|
||||
handleChange,
|
||||
}: OAuthClientsDropdownProps) => {
|
||||
return (
|
||||
<div>
|
||||
{Array.isArray(oauthClients) && oauthClients.length > 0 ? (
|
||||
<Dropdown modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button color="secondary">{initialClientName}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{oauthClients.map((client) => {
|
||||
return (
|
||||
<div key={client.id}>
|
||||
{initialClientName !== client.name ? (
|
||||
<DropdownMenuItem className="outline-none">
|
||||
<DropdownItem type="button" onClick={() => handleChange(client.id, client.name)}>
|
||||
{client.name}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
import { EmptyScreen, Button } from "@calcom/ui";
|
||||
|
||||
import { OAuthClientCard } from "@components/settings/platform/oauth-clients/OAuthClientCard";
|
||||
|
||||
type OAuthClientsListProps = {
|
||||
oauthClients: PlatformOAuthClient[];
|
||||
isDeleting: boolean;
|
||||
handleDelete: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAuthClientsListProps) => {
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
OAuth Clients
|
||||
</h1>
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
|
||||
Connect your platform to cal.com with OAuth
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />
|
||||
</div>
|
||||
</div>
|
||||
{Array.isArray(oauthClients) && oauthClients.length ? (
|
||||
<>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0">
|
||||
{oauthClients.map((client, index) => {
|
||||
return (
|
||||
<OAuthClientCard
|
||||
name={client.name}
|
||||
redirectUris={client.redirectUris}
|
||||
bookingRedirectUri={client.bookingRedirectUri}
|
||||
bookingRescheduleRedirectUri={client.bookingRescheduleRedirectUri}
|
||||
bookingCancelRedirectUri={client.bookingCancelRedirectUri}
|
||||
permissions={client.permissions}
|
||||
key={index}
|
||||
lastItem={oauthClients.length === index + 1}
|
||||
id={client.id}
|
||||
secret={client.secret}
|
||||
isLoading={isDeleting}
|
||||
onDelete={handleDelete}
|
||||
areEmailsEnabled={client.areEmailsEnabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
headline="Create your first OAuth client"
|
||||
description="OAuth clients facilitate access to Cal.com on behalf of users"
|
||||
Icon="plus"
|
||||
className=""
|
||||
buttonRaw={<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NewOAuthClientButton = ({ redirectLink, label }: { redirectLink: string; label?: string }) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.push(redirectLink);
|
||||
}}
|
||||
color="secondary"
|
||||
StartIcon="plus">
|
||||
{!!label ? label : "Add"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useCheckTeamBilling } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
export const useGetUserAttributes = () => {
|
||||
const { data: user, isLoading: isUserLoading } = useMeQuery();
|
||||
const { data: userBillingData, isFetching: isUserBillingDataLoading } = useCheckTeamBilling(
|
||||
user?.organizationId
|
||||
);
|
||||
const isPlatformUser = user?.organization.isPlatform;
|
||||
const isPaidUser = userBillingData?.valid;
|
||||
const userOrgId = user?.organizationId;
|
||||
|
||||
return { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId };
|
||||
};
|
||||
+3
-3
@@ -6,7 +6,7 @@ import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants";
|
||||
import type { Avatar } from "@calcom/prisma/client";
|
||||
import { Button, Icon, showToast } from "@calcom/ui";
|
||||
|
||||
import { hasPermission } from "../../../../../../../packages/platform/utils/permissions";
|
||||
import { hasPermission } from "../../../../../../packages/platform/utils/permissions";
|
||||
|
||||
type OAuthClientCardProps = {
|
||||
name: string;
|
||||
@@ -115,7 +115,7 @@ export const OAuthClientCard = ({
|
||||
</div>
|
||||
<div className="border-subtle flex text-sm">
|
||||
<span className="font-semibold">Permissions: </span>
|
||||
<div className="flex">{clientPermissions}</div>
|
||||
{permissions ? <div className="flex">{clientPermissions}</div> : <> Disabled</>}
|
||||
</div>
|
||||
<div className="flex gap-1 text-sm">
|
||||
<span className="font-semibold">Redirect uris: </span>
|
||||
@@ -145,7 +145,7 @@ export const OAuthClientCard = ({
|
||||
className="bg-subtle hover:bg-emphasis text-white"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={() => router.push(`/settings/organizations/platform/oauth-clients/create?clientId=${id}`)}>
|
||||
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
+30
-140
@@ -1,33 +1,18 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
|
||||
import { showToast } from "@calcom/ui";
|
||||
import { Meta, Button, TextField, Label, Tooltip } from "@calcom/ui";
|
||||
import { TextField, Tooltip, Button, Label } from "@calcom/ui";
|
||||
|
||||
import { useOAuthClient } from "@lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients";
|
||||
import {
|
||||
useCreateOAuthClient,
|
||||
useUpdateOAuthClient,
|
||||
} from "@lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient";
|
||||
type OAuthClientFormProps = {
|
||||
defaultValues?: Partial<FormValues>;
|
||||
isPending?: boolean;
|
||||
isFormDisabled?: boolean;
|
||||
onSubmit: (data: FormValues) => void;
|
||||
};
|
||||
|
||||
import {
|
||||
hasAppsReadPermission,
|
||||
hasAppsWritePermission,
|
||||
hasBookingReadPermission,
|
||||
hasBookingWritePermission,
|
||||
hasEventTypeReadPermission,
|
||||
hasEventTypeWritePermission,
|
||||
hasProfileReadPermission,
|
||||
hasProfileWritePermission,
|
||||
hasScheduleReadPermission,
|
||||
hasScheduleWritePermission,
|
||||
} from "../../../../../../../packages/platform/utils/permissions";
|
||||
|
||||
type FormValues = {
|
||||
export type FormValues = {
|
||||
name: string;
|
||||
logo?: string;
|
||||
permissions: number;
|
||||
@@ -50,45 +35,20 @@ type FormValues = {
|
||||
areEmailsEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
export const OAuthClientForm = ({
|
||||
defaultValues,
|
||||
isPending,
|
||||
isFormDisabled,
|
||||
onSubmit,
|
||||
}: OAuthClientFormProps) => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { data, isFetched, isError, refetch } = useOAuthClient(clientId);
|
||||
const { register, control, handleSubmit, setValue } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
redirectUris: [{ uri: "" }],
|
||||
},
|
||||
defaultValues: { redirectUris: [{ uri: "" }], ...defaultValues },
|
||||
});
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "redirectUris",
|
||||
});
|
||||
useEffect(() => {
|
||||
if (isFetched && data && !isError) {
|
||||
setValue("name", data.name);
|
||||
data.bookingRedirectUri && setValue("bookingRedirectUri", data.bookingRedirectUri);
|
||||
data.bookingCancelRedirectUri && setValue("bookingCancelRedirectUri", data.bookingCancelRedirectUri);
|
||||
data.bookingRescheduleRedirectUri &&
|
||||
setValue("bookingRescheduleRedirectUri", data.bookingRescheduleRedirectUri);
|
||||
setValue("areEmailsEnabled", data?.areEmailsEnabled);
|
||||
data.redirectUris?.forEach?.((uri: string, index: number) => {
|
||||
index === 0 && setValue(`redirectUris.${index}.uri`, uri);
|
||||
index !== 0 && append({ uri });
|
||||
});
|
||||
if (!data.permissions) return;
|
||||
if (hasAppsReadPermission(data.permissions)) setValue("appsRead", true);
|
||||
if (hasAppsWritePermission(data.permissions)) setValue("appsWrite", true);
|
||||
if (hasBookingReadPermission(data.permissions)) setValue("bookingRead", true);
|
||||
if (hasBookingWritePermission(data.permissions)) setValue("bookingWrite", true);
|
||||
if (hasEventTypeReadPermission(data.permissions)) setValue("eventTypeRead", true);
|
||||
if (hasEventTypeWritePermission(data.permissions)) setValue("eventTypeWrite", true);
|
||||
if (hasProfileReadPermission(data.permissions)) setValue("profileRead", true);
|
||||
if (hasProfileWritePermission(data.permissions)) setValue("profileWrite", true);
|
||||
if (hasScheduleReadPermission(data.permissions)) setValue("scheduleRead", true);
|
||||
if (hasScheduleWritePermission(data.permissions)) setValue("scheduleWrite", true);
|
||||
}
|
||||
}, [isFetched, data]);
|
||||
const disabledForm = Boolean(clientId && !isFetched && isError);
|
||||
|
||||
const [isSelectAllPermissionsChecked, setIsSelectAllPermissionsChecked] = useState(false);
|
||||
|
||||
@@ -104,67 +64,6 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
setIsSelectAllPermissionsChecked((preValue) => !preValue);
|
||||
}, [isSelectAllPermissionsChecked, setValue]);
|
||||
|
||||
const { mutateAsync: save, isPending: isSaving } = useCreateOAuthClient({
|
||||
onSuccess: () => {
|
||||
showToast("OAuth client created successfully", "success");
|
||||
refetch();
|
||||
router.push("/settings/organizations/platform/oauth-clients");
|
||||
},
|
||||
onError: () => {
|
||||
showToast("Internal server error, please try again later", "error");
|
||||
},
|
||||
});
|
||||
const { mutateAsync: update, isPending: isUpdating } = useUpdateOAuthClient({
|
||||
onSuccess: () => {
|
||||
showToast("OAuth client updated successfully", "success");
|
||||
refetch();
|
||||
router.push("/settings/organizations/platform/oauth-clients");
|
||||
},
|
||||
onError: () => {
|
||||
showToast("Internal server error, please try again later", "error");
|
||||
},
|
||||
clientId,
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
let userPermissions = 0;
|
||||
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
|
||||
|
||||
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
|
||||
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
|
||||
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
|
||||
const read = PERMISSIONS_GROUPED_MAP[entity].read;
|
||||
const write = PERMISSIONS_GROUPED_MAP[entity].write;
|
||||
if (data[`${entityKey}Read`]) userPermissions |= read;
|
||||
if (data[`${entityKey}Write`]) userPermissions |= write;
|
||||
});
|
||||
|
||||
if (clientId) {
|
||||
// don't update permissions if client is already created
|
||||
update({
|
||||
name: data.name,
|
||||
// logo: data.logo,
|
||||
redirectUris: userRedirectUris,
|
||||
bookingRedirectUri: data.bookingRedirectUri,
|
||||
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
|
||||
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
|
||||
areEmailsEnabled: data.areEmailsEnabled,
|
||||
});
|
||||
} else {
|
||||
save({
|
||||
name: data.name,
|
||||
permissions: userPermissions,
|
||||
// logo: data.logo,
|
||||
redirectUris: userRedirectUris,
|
||||
bookingRedirectUri: data.bookingRedirectUri,
|
||||
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
|
||||
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
|
||||
areEmailsEnabled: data.areEmailsEnabled,
|
||||
});
|
||||
}
|
||||
};
|
||||
const isPending = isSaving || isUpdating;
|
||||
|
||||
const permissionsCheckboxes = Object.keys(PERMISSIONS_GROUPED_MAP).map((key) => {
|
||||
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
|
||||
const permissionKey = PERMISSIONS_GROUPED_MAP[entity].key;
|
||||
@@ -180,7 +79,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
id={`${permissionKey}Read`}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={Boolean(clientId)}
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
<label htmlFor={`${permissionKey}Read`} className="cursor-pointer text-sm">
|
||||
Read
|
||||
@@ -192,7 +91,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
id={`${permissionKey}Write`}
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={Boolean(clientId)}
|
||||
disabled={!!defaultValues}
|
||||
/>
|
||||
<label htmlFor={`${permissionKey}Write`} className="cursor-pointer text-sm">
|
||||
Write
|
||||
@@ -205,16 +104,11 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Meta
|
||||
title={t("oauth_form_title") + (clientId ? " - Update" : "")}
|
||||
description={t("oauth_form_description") + (clientId ? " - Update" : "")}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<form
|
||||
className="border-subtle rounded-b-lg border border-t-0 px-4 pb-8 pt-2"
|
||||
onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mt-6">
|
||||
<TextField disabled={disabledForm} required={true} label="Client name" {...register("name")} />
|
||||
<TextField disabled={isFormDisabled} required={true} label="Client name" {...register("name")} />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Label>Redirect uris</Label>
|
||||
@@ -227,7 +121,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
required={index === 0}
|
||||
className="w-[100%]"
|
||||
label=""
|
||||
disabled={disabledForm}
|
||||
disabled={isFormDisabled}
|
||||
{...register(`redirectUris.${index}.uri` as const)}
|
||||
/>
|
||||
</div>
|
||||
@@ -239,10 +133,10 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
variant="icon"
|
||||
StartIcon="plus"
|
||||
className="text-default mx-2 mb-2"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => {
|
||||
append({ uri: "" });
|
||||
}}
|
||||
disabled={disabledForm}
|
||||
/>
|
||||
{index > 0 && (
|
||||
<Button
|
||||
@@ -252,10 +146,10 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
variant="icon"
|
||||
StartIcon="trash"
|
||||
className="text-default mx-2 mb-2"
|
||||
disabled={isFormDisabled}
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
disabled={disabledForm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -300,7 +194,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
label="Booking redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingRedirectUri")}
|
||||
disabled={disabledForm}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -311,7 +205,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
label="Booking cancel redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingCancelRedirectUri")}
|
||||
disabled={disabledForm}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -322,7 +216,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
label="Booking reschedule redirect uri"
|
||||
className="w-[100%]"
|
||||
{...register("bookingRescheduleRedirectUri")}
|
||||
disabled={disabledForm}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -332,27 +226,23 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
|
||||
id="areEmailsEnabled"
|
||||
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
type="checkbox"
|
||||
disabled={disabledForm}
|
||||
disabled={isFormDisabled}
|
||||
/>
|
||||
<label htmlFor="areEmailsEnabled" className="cursor-pointer px-2 text-base font-semibold">
|
||||
Enable emails
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex justify-between">
|
||||
<Tooltip side="right" content="Permissions once set cannot be modified">
|
||||
<h1 className="text-base font-semibold underline">Permissions</h1>
|
||||
</Tooltip>
|
||||
<Button type="button" onClick={selectAllPermissions} disabled={disabledForm || Boolean(clientId)}>
|
||||
<h1 className="text-base font-semibold underline">Permissions</h1>
|
||||
<Button type="button" onClick={selectAllPermissions} disabled={!!defaultValues || isFormDisabled}>
|
||||
{!isSelectAllPermissionsChecked ? "Select all" : "Discard all"}
|
||||
</Button>
|
||||
</div>
|
||||
<div>{permissionsCheckboxes}</div>
|
||||
</div>
|
||||
|
||||
<Button className="mt-6" type="submit" loading={isPending} disabled={disabledForm}>
|
||||
{clientId ? "Update" : "Submit"}
|
||||
<Button className="mt-6" type="submit" loading={isPending}>
|
||||
{defaultValues ? "Update" : "Submit"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
type IndividualPlatformPlan = {
|
||||
plan: string;
|
||||
description: string;
|
||||
pricing?: number;
|
||||
includes: string[];
|
||||
};
|
||||
|
||||
// if pricing or plans change in future modify this
|
||||
export const platformPlans: IndividualPlatformPlan[] = [
|
||||
{
|
||||
plan: "Starter",
|
||||
description:
|
||||
"Perfect for just getting started with community support and access to hosted platform APIs, Cal.com Atoms (React components) and more.",
|
||||
pricing: 99,
|
||||
includes: [
|
||||
"Up to 100 bookings a month",
|
||||
"Community Support",
|
||||
"Cal Atoms (React Library)",
|
||||
"Platform APIs",
|
||||
"Admin APIs",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Essentials",
|
||||
description:
|
||||
"Your essential package with sophisticated support, hosted platform APIs, Cal.com Atoms (React components) and more.",
|
||||
pricing: 299,
|
||||
includes: [
|
||||
"Up to 500 bookings a month. $0,60 overage beyond",
|
||||
"Everything in Starter",
|
||||
"Cal Atoms (React Library)",
|
||||
"User Management and Analytics",
|
||||
"Technical Account Manager and Onboarding Support",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Scale",
|
||||
description:
|
||||
"The best all-in-one plan to scale your company. Everything you need to provide scheduling for the masses, without breaking things.",
|
||||
pricing: 2499,
|
||||
includes: [
|
||||
"Up to 5000 bookings a month. $0.50 overage beyond",
|
||||
"Everything in Essentials",
|
||||
"Credential import from other platforms",
|
||||
"Compliance Check SOC2, HIPAA",
|
||||
"One-on-one developer calls",
|
||||
"Help with Credentials Verification (Zoom, Google App Store)",
|
||||
"Expedited features and integrations",
|
||||
"SLA (99.999% uptime)",
|
||||
],
|
||||
},
|
||||
{
|
||||
plan: "Enterprise",
|
||||
description: "Everything in Scale with generous volume discounts beyond 50,000 bookings a month.",
|
||||
includes: ["Beyond 50,000 bookings a month", "Everything in Scale", "Up to 50% discount on overages"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
type PlatformBillingCardProps = {
|
||||
plan: string;
|
||||
description: string;
|
||||
pricing?: number;
|
||||
includes: string[];
|
||||
isLoading?: boolean;
|
||||
handleSubscribe?: () => void;
|
||||
};
|
||||
|
||||
export const PlatformBillingCard = ({
|
||||
plan,
|
||||
description,
|
||||
pricing,
|
||||
includes,
|
||||
isLoading,
|
||||
handleSubscribe,
|
||||
}: PlatformBillingCardProps) => {
|
||||
return (
|
||||
<div className="border-subtle w-auto rounded-md border p-5 lg:w-[350px]">
|
||||
<div className="pb-5">
|
||||
<h1 className="pb-3 text-xl font-semibold">{plan}</h1>
|
||||
<p className="pb-5 text-base">{description}</p>
|
||||
<h1 className="text-3xl font-semibold">
|
||||
{pricing && (
|
||||
<>
|
||||
US${pricing} <span className="text-sm">per month</span>
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
onClick={handleSubscribe}
|
||||
className="flex w-[100%] items-center justify-center">
|
||||
{pricing ? "Subscribe" : "Schedule a time"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<p>This includes:</p>
|
||||
{includes.map((feature) => {
|
||||
return (
|
||||
<div key={feature} className="my-2 flex">
|
||||
<div className="pr-2">•</div>
|
||||
<div>{feature}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import { useSubscribeTeamToStripe } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
import { platformPlans } from "@components/settings/platform/platformUtils";
|
||||
import { PlatformBillingCard } from "@components/settings/platform/pricing/billing-card";
|
||||
|
||||
type PlatformPricingProps = { teamId?: number | null };
|
||||
|
||||
export const PlatformPricing = ({ teamId }: PlatformPricingProps) => {
|
||||
const router = useRouter();
|
||||
const { mutateAsync, isPending } = useSubscribeTeamToStripe({
|
||||
onSuccess: (redirectUrl: string) => {
|
||||
router.push(redirectUrl);
|
||||
},
|
||||
onError: () => {
|
||||
showToast(ErrorCode.UnableToSubscribeToThePlatform, "error");
|
||||
},
|
||||
teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-auto flex-col items-center justify-center px-5 py-5 md:px-10 md:py-0 lg:h-[100vh]">
|
||||
<div className="mb-5 text-center text-2xl font-semibold">
|
||||
<h1>Subscribe to Platform</h1>
|
||||
</div>
|
||||
<div className="flex flex-col flex-wrap gap-x-0 gap-y-5 md:flex-row md:gap-x-5 md:gap-y-5 lg:flex-nowrap">
|
||||
{platformPlans.map((plan) => {
|
||||
return (
|
||||
<div key={plan.plan}>
|
||||
<PlatformBillingCard
|
||||
plan={plan.plan}
|
||||
description={plan.description}
|
||||
pricing={plan.pricing}
|
||||
includes={plan.includes}
|
||||
isLoading={isPending}
|
||||
handleSubscribe={() => {
|
||||
!!teamId &&
|
||||
(plan.plan === "Enterprise"
|
||||
? router.push("https://i.cal.com/sales/exploration")
|
||||
: mutateAsync({ plan: plan.plan.toLocaleUpperCase() }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+29
@@ -3,6 +3,17 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import type { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import type { PlatformOAuthClient } from "@calcom/prisma/client";
|
||||
|
||||
export type ManagedUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string | null;
|
||||
timeZone: string;
|
||||
weekStart: string;
|
||||
createdDate: Date;
|
||||
timeFormat: number | null;
|
||||
defaultScheduleId: number | null;
|
||||
};
|
||||
|
||||
export const useOAuthClients = () => {
|
||||
const query = useQuery<ApiSuccessResponse<PlatformOAuthClient[]>>({
|
||||
queryKey: ["oauth-clients"],
|
||||
@@ -52,3 +63,21 @@ export const useOAuthClient = (clientId?: string) => {
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
export const useGetOAuthClientManagedUsers = (clientId: string) => {
|
||||
const {
|
||||
isLoading,
|
||||
error,
|
||||
data: response,
|
||||
refetch,
|
||||
} = useQuery<ApiSuccessResponse<ManagedUser[]>>({
|
||||
queryKey: ["oauth-client-managed-users", clientId],
|
||||
queryFn: () => {
|
||||
return fetch(`/api/v2/oauth-clients/${clientId}/managed-users`, {
|
||||
method: "get",
|
||||
headers: { "Content-type": "application/json" },
|
||||
}).then((res) => res.json());
|
||||
},
|
||||
});
|
||||
|
||||
return { isLoading, error, data: response?.data, refetch };
|
||||
};
|
||||
+63
-2
@@ -1,7 +1,12 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { ApiResponse, CreateOAuthClientInput, DeleteOAuthClientInput } from "@calcom/platform-types";
|
||||
import type {
|
||||
ApiResponse,
|
||||
CreateOAuthClientInput,
|
||||
DeleteOAuthClientInput,
|
||||
SubscribeTeamInput,
|
||||
} from "@calcom/platform-types";
|
||||
import type { OAuthClient } from "@calcom/prisma/client";
|
||||
|
||||
interface IPersistOAuthClient {
|
||||
@@ -113,3 +118,59 @@ export const useDeleteOAuthClient = (
|
||||
|
||||
return mutation;
|
||||
};
|
||||
|
||||
export const useCheckTeamBilling = (teamId?: number | null) => {
|
||||
const QUERY_KEY = "check-team-billing";
|
||||
const isTeamBilledAlready = useQuery({
|
||||
queryKey: [QUERY_KEY, teamId],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/v2/billing/${teamId}/check`, {
|
||||
method: "get",
|
||||
headers: { "Content-type": "application/json" },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
return data.data;
|
||||
},
|
||||
enabled: !!teamId,
|
||||
});
|
||||
|
||||
return isTeamBilledAlready;
|
||||
};
|
||||
|
||||
export const useSubscribeTeamToStripe = (
|
||||
{
|
||||
onSuccess,
|
||||
onError,
|
||||
teamId,
|
||||
}: { teamId?: number | null; onSuccess: (redirectUrl: string) => void; onError: () => void } = {
|
||||
onSuccess: () => {
|
||||
return;
|
||||
},
|
||||
onError: () => {
|
||||
return;
|
||||
},
|
||||
}
|
||||
) => {
|
||||
const mutation = useMutation<ApiResponse<{ action: string; url: string }>, unknown, SubscribeTeamInput>({
|
||||
mutationFn: (data) => {
|
||||
return fetch(`/api/v2/billing/${teamId}/subscribe`, {
|
||||
method: "post",
|
||||
headers: { "Content-type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}).then((res) => res?.json());
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.status === SUCCESS_STATUS) {
|
||||
onSuccess?.(data.data?.url);
|
||||
} else {
|
||||
onError?.();
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
onError?.();
|
||||
},
|
||||
});
|
||||
|
||||
return mutation;
|
||||
};
|
||||
@@ -413,6 +413,10 @@ export default function Success(props: PageProps) {
|
||||
imageSrc={`${bookingInfo.user.avatarUrl}`}
|
||||
/>
|
||||
)}
|
||||
{giphyImage && !needsConfirmation && !isCancelled && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={giphyImage} className="w-full rounded-lg" alt="Gif from Giphy" />
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"mx-auto flex h-12 w-12 items-center justify-center rounded-full",
|
||||
@@ -422,10 +426,6 @@ export default function Success(props: PageProps) {
|
||||
!giphyImage && !isCancelled && needsConfirmation ? "bg-subtle" : "",
|
||||
isCancelled ? "bg-error" : ""
|
||||
)}>
|
||||
{giphyImage && !needsConfirmation && !isCancelled && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={giphyImage} alt="Gif from Giphy" />
|
||||
)}
|
||||
{!giphyImage && !needsConfirmation && !isCancelled && (
|
||||
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
|
||||
import { asStringOrThrow } from "@lib/asStringOrNull";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
@@ -40,6 +42,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { eventType } = await ssr.viewer.eventTypes.get.fetch({ id: eventTypeId });
|
||||
return eventType;
|
||||
} catch (e: unknown) {
|
||||
logger.error(safeStringify(e));
|
||||
// reject, user has no access to this event type.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
metadata,
|
||||
hosts: eventType.hosts,
|
||||
successRedirectUrl: eventType.successRedirectUrl || "",
|
||||
forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect,
|
||||
users: eventType.users,
|
||||
useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail,
|
||||
secondaryEmailId: eventType?.secondaryEmailId || -1,
|
||||
|
||||
@@ -104,11 +104,13 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
|
||||
if (isDynamicGroup) {
|
||||
const destinationUrl = `/${usernameList.join("+")}/dynamic`;
|
||||
const originalQueryString = new URLSearchParams(context.query as Record<string, string>).toString();
|
||||
const destinationWithQuery = `${destinationUrl}?${originalQueryString}`;
|
||||
log.debug(`Dynamic group detected, redirecting to ${destinationUrl}`);
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: destinationUrl,
|
||||
destination: destinationWithQuery,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { DehydratedState } from "@tanstack/react-query";
|
||||
import { type GetServerSidePropsContext } from "next";
|
||||
import type { Session } from "next-auth";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
@@ -16,6 +20,74 @@ import { type EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
type Props = {
|
||||
eventData: Pick<
|
||||
NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>,
|
||||
"id" | "length" | "metadata" | "entity"
|
||||
>;
|
||||
booking?: GetBookingType;
|
||||
rescheduleUid: string | null;
|
||||
bookingUid: string | null;
|
||||
user: string;
|
||||
slug: string;
|
||||
trpcState: DehydratedState;
|
||||
isBrandingHidden: boolean;
|
||||
isSEOIndexable: boolean | null;
|
||||
themeBasis: null | string;
|
||||
orgBannerUrl: null;
|
||||
};
|
||||
|
||||
async function processReschedule({
|
||||
props,
|
||||
rescheduleUid,
|
||||
session,
|
||||
}: {
|
||||
props: Props;
|
||||
session: Session | null;
|
||||
rescheduleUid: string | string[] | undefined;
|
||||
}) {
|
||||
if (!rescheduleUid) return;
|
||||
const booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
// if no booking found, no eventTypeId (dynamic) or it matches this eventData - return void (success).
|
||||
if (booking === null || !booking.eventTypeId || booking?.eventTypeId === props.eventData?.id) {
|
||||
props.booking = booking;
|
||||
props.rescheduleUid = Array.isArray(rescheduleUid) ? rescheduleUid[0] : rescheduleUid;
|
||||
return;
|
||||
}
|
||||
// handle redirect response
|
||||
const redirectEventTypeTarget = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: booking.eventTypeId,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
if (!redirectEventTypeTarget) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: redirectEventTypeTarget.slug,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function processSeatedEvent({
|
||||
props,
|
||||
bookingUid,
|
||||
}: {
|
||||
props: Props;
|
||||
bookingUid: string | string[] | undefined;
|
||||
}) {
|
||||
if (!bookingUid) return;
|
||||
props.booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
props.bookingUid = Array.isArray(bookingUid) ? bookingUid[0] : bookingUid;
|
||||
}
|
||||
|
||||
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
@@ -51,13 +123,6 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
} else if (bookingUid) {
|
||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
}
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
@@ -73,27 +138,38 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
eventData: {
|
||||
entity: eventData.entity,
|
||||
length: eventData.length,
|
||||
metadata: {
|
||||
...eventData.metadata,
|
||||
multipleDuration: [15, 30, 60],
|
||||
},
|
||||
const props: Props = {
|
||||
eventData: {
|
||||
id: eventData.id,
|
||||
entity: eventData.entity,
|
||||
length: eventData.length,
|
||||
metadata: {
|
||||
...eventData.metadata,
|
||||
multipleDuration: [15, 30, 60],
|
||||
},
|
||||
booking,
|
||||
user: usernames.join("+"),
|
||||
slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: false,
|
||||
isSEOIndexable: true,
|
||||
themeBasis: null,
|
||||
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
|
||||
orgBannerUrl: null,
|
||||
},
|
||||
user: usernames.join("+"),
|
||||
slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: false,
|
||||
isSEOIndexable: true,
|
||||
themeBasis: null,
|
||||
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||
rescheduleUid: null,
|
||||
orgBannerUrl: null,
|
||||
};
|
||||
|
||||
if (rescheduleUid) {
|
||||
const processRescheduleResult = await processReschedule({ props, rescheduleUid, session });
|
||||
if (processRescheduleResult) {
|
||||
return processRescheduleResult;
|
||||
}
|
||||
} else if (bookingUid) {
|
||||
await processSeatedEvent({ props, bookingUid });
|
||||
}
|
||||
|
||||
return {
|
||||
props,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,13 +207,6 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
} else if (bookingUid) {
|
||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
}
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we can show a 404 otherwise.
|
||||
@@ -154,24 +223,35 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
booking,
|
||||
eventData: {
|
||||
entity: eventData.entity,
|
||||
length: eventData.length,
|
||||
metadata: eventData.metadata,
|
||||
},
|
||||
user: username,
|
||||
slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: user?.hideBranding,
|
||||
isSEOIndexable: user?.allowSEOIndexing,
|
||||
themeBasis: username,
|
||||
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
|
||||
orgBannerUrl: eventData?.owner?.profile?.organization?.bannerUrl ?? null,
|
||||
const props: Props = {
|
||||
eventData: {
|
||||
id: eventData.id,
|
||||
entity: eventData.entity,
|
||||
length: eventData.length,
|
||||
metadata: eventData.metadata,
|
||||
},
|
||||
user: username,
|
||||
slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: user?.hideBranding,
|
||||
isSEOIndexable: user?.allowSEOIndexing,
|
||||
themeBasis: username,
|
||||
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||
rescheduleUid: null,
|
||||
orgBannerUrl: eventData?.owner?.profile?.organization?.bannerUrl ?? null,
|
||||
};
|
||||
|
||||
if (rescheduleUid) {
|
||||
const processRescheduleResult = await processReschedule({ props, rescheduleUid, session });
|
||||
if (processRescheduleResult) {
|
||||
return processRescheduleResult;
|
||||
}
|
||||
} else if (bookingUid) {
|
||||
await processSeatedEvent({ props, bookingUid });
|
||||
}
|
||||
|
||||
return {
|
||||
props,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function JoinCall(props: PageProps) {
|
||||
customTrayButtons: {
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Enable transcription powered by AI",
|
||||
tooltip: "Toggle real time transcription powered by AI",
|
||||
iconPath: `${WEBAPP_URL}/sparkles.svg`,
|
||||
iconPathDarkMode: `${WEBAPP_URL}/sparkles.svg`,
|
||||
},
|
||||
@@ -101,12 +101,12 @@ export default function JoinCall(props: PageProps) {
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
className="fixed z-10 hidden sm:inline-block"
|
||||
className="fixed z-10 inline-block h-4"
|
||||
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`}
|
||||
alt="Logo"
|
||||
style={{
|
||||
top: 32,
|
||||
left: 32,
|
||||
top: 9,
|
||||
left: "calc(50% - 45px)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -537,6 +537,11 @@ const nextConfig = {
|
||||
destination: "/apps/installed/calendar",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/settings/organizations/platform/:path*",
|
||||
destination: "/settings/platform",
|
||||
permanent: true,
|
||||
},
|
||||
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
|
||||
...(process.env.NODE_ENV === "development" &&
|
||||
// Safer to enable the redirect only when the user is opting to test out organizations
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.0.5",
|
||||
"version": "4.0.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -65,6 +65,7 @@
|
||||
"@tremor/react": "^2.0.0",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@unkey/ratelimit": "^0.1.1",
|
||||
"@upstash/redis": "^1.21.0",
|
||||
"@vercel/edge-config": "^0.1.1",
|
||||
"@vercel/edge-functions-ui": "^0.2.1",
|
||||
"@vercel/og": "^0.5.0",
|
||||
|
||||
@@ -88,7 +88,7 @@ class MyDocument extends Document<Props> {
|
||||
</Head>
|
||||
|
||||
<body
|
||||
className="dark:bg-darkgray-50 todesktop:!bg-transparent bg-subtle antialiased"
|
||||
className="dark:bg-darkgray-50 bg-subtle antialiased"
|
||||
style={
|
||||
isEmbed
|
||||
? {
|
||||
|
||||
@@ -110,43 +110,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
teamId: null,
|
||||
},
|
||||
],
|
||||
createdAt: {
|
||||
gte: dayjs(firstDateOfMonth).toISOString(),
|
||||
lte: dayjs(new Date()).toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const promisesResult = await Promise.all([
|
||||
EventsInsights.getCreatedEventsInTimeRange(
|
||||
{
|
||||
start: dayjs(firstDateOfMonth),
|
||||
end: dayjs(new Date()),
|
||||
},
|
||||
whereConditional
|
||||
),
|
||||
EventsInsights.getCompletedEventsInTimeRange(
|
||||
{
|
||||
start: dayjs(firstDateOfMonth),
|
||||
end: dayjs(new Date()),
|
||||
},
|
||||
whereConditional
|
||||
),
|
||||
EventsInsights.getRescheduledEventsInTimeRange(
|
||||
{
|
||||
start: dayjs(firstDateOfMonth),
|
||||
end: dayjs(new Date()),
|
||||
},
|
||||
whereConditional
|
||||
),
|
||||
EventsInsights.getCancelledEventsInTimeRange(
|
||||
{
|
||||
start: dayjs(firstDateOfMonth),
|
||||
end: dayjs(new Date()),
|
||||
},
|
||||
whereConditional
|
||||
),
|
||||
]);
|
||||
const countGroupedByStatus = await EventsInsights.countGroupedByStatus(whereConditional);
|
||||
|
||||
EventData["Created"] = promisesResult[0];
|
||||
EventData["Completed"] = promisesResult[1];
|
||||
EventData["Rescheduled"] = promisesResult[2];
|
||||
EventData["Cancelled"] = promisesResult[3];
|
||||
EventData["Created"] = countGroupedByStatus["_all"];
|
||||
EventData["Completed"] = countGroupedByStatus["completed"];
|
||||
EventData["Rescheduled"] = countGroupedByStatus["rescheduled"];
|
||||
EventData["Cancelled"] = countGroupedByStatus["cancelled"];
|
||||
|
||||
// Most Booked Event Type
|
||||
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
|
||||
|
||||
@@ -94,8 +94,8 @@ export default function InsightsPage() {
|
||||
<MostBookedTeamMembersTable />
|
||||
<LeastBookedTeamMembersTable />
|
||||
</div>
|
||||
<RecentFeedbackTable />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<RecentFeedbackTable />
|
||||
<HighestNoShowHostTable />
|
||||
<HighestRatedMembersTable />
|
||||
<LowestRatedMembersTable />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRef } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Meta, TextField } from "@calcom/ui";
|
||||
|
||||
@@ -12,6 +14,19 @@ import { getLayout } from "@components/auth/layouts/AdminLayout";
|
||||
function AdminView() {
|
||||
const { t } = useLocale();
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const username = searchParams?.get("username")?.toLowerCase();
|
||||
|
||||
useEffect(() => {
|
||||
if (username) {
|
||||
const enteredUsername = username.toLowerCase();
|
||||
signIn("impersonation-auth", {
|
||||
username: enteredUsername,
|
||||
callbackUrl: `${WEBAPP_URL}/event-types`,
|
||||
});
|
||||
}
|
||||
}, [username]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -21,7 +36,10 @@ function AdminView() {
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const enteredUsername = usernameRef.current?.value.toLowerCase();
|
||||
signIn("impersonation-auth", { username: enteredUsername });
|
||||
signIn("impersonation-auth", {
|
||||
username: enteredUsername,
|
||||
callbackUrl: `${WEBAPP_URL}/event-types`,
|
||||
});
|
||||
}}>
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<TextField
|
||||
|
||||
@@ -124,6 +124,15 @@ const ProfileView = () => {
|
||||
}
|
||||
},
|
||||
});
|
||||
const unlinkConnectedAccountMutation = trpc.viewer.unlinkConnectedAccount.useMutation({
|
||||
onSuccess: async (res) => {
|
||||
showToast(t(res.message), "success");
|
||||
utils.viewer.me.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
showToast(t(e.message), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const addSecondaryEmailMutation = trpc.viewer.addSecondaryEmail.useMutation({
|
||||
onSuccess: (res) => {
|
||||
@@ -439,10 +448,7 @@ const ProfileView = () => {
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
updateProfileMutation.mutate({
|
||||
...tempFormValues,
|
||||
unlinkConnectedAccount: true,
|
||||
});
|
||||
unlinkConnectedAccountMutation.mutate();
|
||||
setShowAccountDisconnectWarning(false);
|
||||
}}>
|
||||
{t("confirm")}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import AdminAPIView from "@calcom/features/ee/organizations/pages/settings/admin-api";
|
||||
|
||||
import type { CalPageWrapper } from "@components/PageWrapper";
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
const Page = AdminAPIView as CalPageWrapper;
|
||||
Page.PageWrapper = PageWrapper;
|
||||
|
||||
export default Page;
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { OAuthClientForm } from "@components/settings/organizations/platform/oauth-clients/OAuthClientForm";
|
||||
|
||||
export const CreateOAuthClient = () => {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const clientId = searchParams?.get("clientId") || "";
|
||||
return (
|
||||
<div>
|
||||
<OAuthClientForm clientId={clientId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CreateOAuthClient.getLayout = getLayout;
|
||||
CreateOAuthClient.PageWrapper = PageWrapper;
|
||||
|
||||
export default CreateOAuthClient;
|
||||
@@ -1,103 +0,0 @@
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { EmptyScreen, showToast } from "@calcom/ui";
|
||||
import { Meta, Button } from "@calcom/ui";
|
||||
import { Spinner } from "@calcom/ui/components/icon/Spinner";
|
||||
|
||||
import { useOAuthClients } from "@lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients";
|
||||
import { useDeleteOAuthClient } from "@lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { OAuthClientCard } from "@components/settings/organizations/platform/oauth-clients/OAuthClientCard";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export const OAuthClients = () => {
|
||||
const { data, isLoading, refetch: refetchClients } = useOAuthClients();
|
||||
const { mutateAsync, isPending: isDeleting } = useDeleteOAuthClient({
|
||||
onSuccess: () => {
|
||||
showToast("OAuth client deleted successfully", "success");
|
||||
refetchClients();
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await mutateAsync({ id: id });
|
||||
};
|
||||
|
||||
const NewOAuthClientButton = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.push("/settings/organizations/platform/oauth-clients/create");
|
||||
}}
|
||||
color="secondary"
|
||||
StartIcon="plus">
|
||||
Add
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner className="mx-auto mt-12 h-10 w-10" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div>
|
||||
<Meta
|
||||
title="OAuth Clients"
|
||||
description="Connect your platform to cal.com with oAuth"
|
||||
CTA={<NewOAuthClientButton />}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<div>
|
||||
{Array.isArray(data) && data.length ? (
|
||||
<>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0">
|
||||
{data.map((client, index) => {
|
||||
return (
|
||||
<OAuthClientCard
|
||||
name={client.name}
|
||||
redirectUris={client.redirectUris}
|
||||
bookingRedirectUri={client.bookingRedirectUri}
|
||||
bookingRescheduleRedirectUri={client.bookingRescheduleRedirectUri}
|
||||
bookingCancelRedirectUri={client.bookingCancelRedirectUri}
|
||||
permissions={client.permissions}
|
||||
key={index}
|
||||
lastItem={data.length === index + 1}
|
||||
id={client.id}
|
||||
secret={client.secret}
|
||||
isLoading={isDeleting}
|
||||
onDelete={handleDelete}
|
||||
areEmailsEnabled={client.areEmailsEnabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
headline="Create your first OAuth client"
|
||||
description="OAuth clients facilitate access to Cal.com on behalf of users"
|
||||
Icon="plus"
|
||||
className="rounded-b-lg rounded-t-none border-t-0"
|
||||
buttonRaw={<NewOAuthClientButton />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
OAuthClients.getLayout = getLayout;
|
||||
OAuthClients.PageWrapper = PageWrapper;
|
||||
|
||||
export default OAuthClients;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import {
|
||||
useOAuthClients,
|
||||
useGetOAuthClientManagedUsers,
|
||||
} from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
|
||||
import { useDeleteOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { ManagedUserList } from "@components/settings/platform/dashboard/managed-user-list";
|
||||
import { OAuthClientsList } from "@components/settings/platform/dashboard/oauth-clients-list";
|
||||
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
|
||||
import { PlatformPricing } from "@components/settings/platform/pricing/platform-pricing";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function Platform() {
|
||||
const [initialClientId, setInitialClientId] = useState("");
|
||||
const [initialClientName, setInitialClientName] = useState("");
|
||||
|
||||
const { data, isLoading: isOAuthClientLoading, refetch: refetchClients } = useOAuthClients();
|
||||
const {
|
||||
isLoading: isManagedUserLoading,
|
||||
data: managedUserData,
|
||||
refetch: refetchManagedUsers,
|
||||
} = useGetOAuthClientManagedUsers(initialClientId);
|
||||
|
||||
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId } =
|
||||
useGetUserAttributes();
|
||||
|
||||
const { mutateAsync, isPending: isDeleting } = useDeleteOAuthClient({
|
||||
onSuccess: () => {
|
||||
showToast("OAuth client deleted successfully", "success");
|
||||
refetchClients();
|
||||
refetchManagedUsers();
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await mutateAsync({ id: id });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setInitialClientId(data[0]?.id);
|
||||
setInitialClientName(data[0]?.name);
|
||||
}, [data]);
|
||||
|
||||
if (isUserLoading || isOAuthClientLoading) return <div className="m-5">Loading...</div>;
|
||||
|
||||
if (isUserBillingDataLoading && !userBillingData) {
|
||||
return <div className="m-5">Loading...</div>;
|
||||
}
|
||||
|
||||
if (isPlatformUser && !isPaidUser) return <PlatformPricing teamId={userOrgId} />;
|
||||
|
||||
if (isPlatformUser) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div>
|
||||
<Shell
|
||||
heading="Platform"
|
||||
title="Platform"
|
||||
hideHeadingOnMobile
|
||||
withoutMain={false}
|
||||
subtitle="Manage everything related to platform."
|
||||
isPlatformUser={true}>
|
||||
<OAuthClientsList oauthClients={data} isDeleting={isDeleting} handleDelete={handleDelete} />
|
||||
<ManagedUserList
|
||||
oauthClients={data}
|
||||
managedUsers={managedUserData}
|
||||
isManagedUserLoading={isManagedUserLoading}
|
||||
initialClientName={initialClientName}
|
||||
initialClientId={initialClientId}
|
||||
handleChange={(id: string, name: string) => {
|
||||
setInitialClientId(id);
|
||||
setInitialClientName(name);
|
||||
refetchManagedUsers();
|
||||
}}
|
||||
/>
|
||||
</Shell>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Shell
|
||||
// we want to hide org banner and have different sidebar tabs for platform clients
|
||||
// hence we pass isPlatformUser boolean as prop
|
||||
isPlatformUser={true}
|
||||
hideHeadingOnMobile
|
||||
withoutMain={false}
|
||||
SidebarContainer={<></>}>
|
||||
You are not subscribed to a Platform plan.
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Platform.PageWrapper = PageWrapper;
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { CreateANewPlatformForm } from "@calcom/features/ee/platform/components/index";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { WizardLayout, Meta, WizardLayoutAppDir } from "@calcom/ui";
|
||||
|
||||
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
const CreateNewOrganizationPage = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<LicenseRequired>
|
||||
<Meta
|
||||
title={t("set_up_your_platform_organization")}
|
||||
description={t("platform_organization_description")}
|
||||
/>
|
||||
<CreateANewPlatformForm />
|
||||
</LicenseRequired>
|
||||
);
|
||||
};
|
||||
const LayoutWrapper = (page: React.ReactElement) => {
|
||||
return (
|
||||
<WizardLayout currentStep={1} maxSteps={1}>
|
||||
{page}
|
||||
</WizardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export const LayoutWrapperAppDir = (page: React.ReactElement) => {
|
||||
return (
|
||||
<WizardLayoutAppDir currentStep={1} maxSteps={2}>
|
||||
{page}
|
||||
</WizardLayoutAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
CreateNewOrganizationPage.getLayout = LayoutWrapper;
|
||||
CreateNewOrganizationPage.PageWrapper = PageWrapper;
|
||||
|
||||
export default CreateNewOrganizationPage;
|
||||
|
||||
export { getServerSideProps };
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import { useOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
|
||||
import { useUpdateOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
|
||||
import type { FormValues } from "@components/settings/platform/oauth-clients/oauth-client-form";
|
||||
import { OAuthClientForm as EditOAuthClientForm } from "@components/settings/platform/oauth-clients/oauth-client-form";
|
||||
|
||||
import {
|
||||
hasAppsReadPermission,
|
||||
hasAppsWritePermission,
|
||||
hasBookingReadPermission,
|
||||
hasBookingWritePermission,
|
||||
hasEventTypeReadPermission,
|
||||
hasEventTypeWritePermission,
|
||||
hasProfileReadPermission,
|
||||
hasProfileWritePermission,
|
||||
hasScheduleReadPermission,
|
||||
hasScheduleWritePermission,
|
||||
} from "../../../../../../../../packages/platform/utils/permissions";
|
||||
|
||||
export default function EditOAuthClient() {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ clientId: string }>();
|
||||
const clientId = params?.clientId || "";
|
||||
|
||||
const { isUserLoading, isPlatformUser, isPaidUser } = useGetUserAttributes();
|
||||
|
||||
const { data, isFetched, isFetching, isError, refetch } = useOAuthClient(clientId);
|
||||
const { mutateAsync: update, isPending: isUpdating } = useUpdateOAuthClient({
|
||||
onSuccess: () => {
|
||||
showToast("OAuth client updated successfully", "success");
|
||||
refetch();
|
||||
router.push("/settings/platform/");
|
||||
},
|
||||
onError: () => {
|
||||
showToast(ErrorCode.UpdatingOauthClientError, "error");
|
||||
},
|
||||
clientId,
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
let userPermissions = 0;
|
||||
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
|
||||
|
||||
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
|
||||
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
|
||||
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
|
||||
const read = PERMISSIONS_GROUPED_MAP[entity].read;
|
||||
const write = PERMISSIONS_GROUPED_MAP[entity].write;
|
||||
if (data[`${entityKey}Read`]) userPermissions |= read;
|
||||
if (data[`${entityKey}Write`]) userPermissions |= write;
|
||||
});
|
||||
|
||||
update({
|
||||
name: data.name,
|
||||
// logo: data.logo,
|
||||
redirectUris: userRedirectUris,
|
||||
bookingRedirectUri: data.bookingRedirectUri,
|
||||
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
|
||||
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
|
||||
areEmailsEnabled: data.areEmailsEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
if (isUserLoading) return <div className="m-5">Loading...</div>;
|
||||
|
||||
if (isPlatformUser && isPaidUser) {
|
||||
return (
|
||||
<div>
|
||||
<Shell title="OAuth client updation form" isPlatformUser={true}>
|
||||
<div className="m-2 md:mx-14 md:mx-5">
|
||||
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
OAuth client updation form
|
||||
</h1>
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
|
||||
This is the form to edit an existing OAuth client
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{(!Boolean(clientId) || (isFetched && !data)) && <p>OAuth Client not found.</p>}
|
||||
{isFetched && !!data && (
|
||||
<EditOAuthClientForm
|
||||
defaultValues={{
|
||||
name: data?.name ?? "",
|
||||
areEmailsEnabled: data.areEmailsEnabled ?? false,
|
||||
redirectUris: data?.redirectUris?.map((uri) => ({ uri })) ?? [{ uri: "" }],
|
||||
bookingRedirectUri: data?.bookingRedirectUri ?? "",
|
||||
bookingCancelRedirectUri: data?.bookingCancelRedirectUri ?? "",
|
||||
bookingRescheduleRedirectUri: data?.bookingRescheduleRedirectUri ?? "",
|
||||
appsRead: hasAppsReadPermission(data?.permissions),
|
||||
appsWrite: hasAppsWritePermission(data?.permissions),
|
||||
bookingRead: hasBookingReadPermission(data?.permissions),
|
||||
bookingWrite: hasBookingWritePermission(data?.permissions),
|
||||
eventTypeRead: hasEventTypeReadPermission(data?.permissions),
|
||||
eventTypeWrite: hasEventTypeWritePermission(data?.permissions),
|
||||
profileRead: hasProfileReadPermission(data?.permissions),
|
||||
profileWrite: hasProfileWritePermission(data?.permissions),
|
||||
scheduleRead: hasScheduleReadPermission(data?.permissions),
|
||||
scheduleWrite: hasScheduleWritePermission(data?.permissions),
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
isPending={isUpdating}
|
||||
/>
|
||||
)}
|
||||
{isFetching && <p>Loading...</p>}
|
||||
{isError && <p>Something went wrong.</p>}
|
||||
</div>
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Shell isPlatformUser={true} hideHeadingOnMobile withoutMain={false} SidebarContainer={<></>}>
|
||||
You are not subscribed to a Platform plan.
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EditOAuthClient.PageWrapper = PageWrapper;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
||||
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import { useCreateOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
|
||||
import type { FormValues } from "@components/settings/platform/oauth-clients/oauth-client-form";
|
||||
import { OAuthClientForm } from "@components/settings/platform/oauth-clients/oauth-client-form";
|
||||
|
||||
export default function CreateOAuthClient() {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const router = useRouter();
|
||||
const clientId = searchParams?.get("clientId") || "";
|
||||
|
||||
const { isUserLoading, isPlatformUser, isPaidUser } = useGetUserAttributes();
|
||||
|
||||
const { mutateAsync: save, isPending: isSaving } = useCreateOAuthClient({
|
||||
onSuccess: () => {
|
||||
showToast("OAuth client created successfully", "success");
|
||||
router.push("/settings/platform/");
|
||||
},
|
||||
onError: () => {
|
||||
showToast(ErrorCode.CreatingOauthClientError, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
let userPermissions = 0;
|
||||
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
|
||||
|
||||
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
|
||||
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
|
||||
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
|
||||
const read = PERMISSIONS_GROUPED_MAP[entity].read;
|
||||
const write = PERMISSIONS_GROUPED_MAP[entity].write;
|
||||
if (data[`${entityKey}Read`]) userPermissions |= read;
|
||||
if (data[`${entityKey}Write`]) userPermissions |= write;
|
||||
});
|
||||
|
||||
save({
|
||||
name: data.name,
|
||||
permissions: userPermissions,
|
||||
// logo: data.logo,
|
||||
redirectUris: userRedirectUris,
|
||||
bookingRedirectUri: data.bookingRedirectUri,
|
||||
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
|
||||
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
|
||||
areEmailsEnabled: data.areEmailsEnabled,
|
||||
});
|
||||
};
|
||||
|
||||
if (isUserLoading) return <div className="m-5">Loading...</div>;
|
||||
|
||||
if (isPlatformUser && isPaidUser) {
|
||||
return (
|
||||
<div>
|
||||
<Shell title={`OAuth client ${!!clientId ? "updation" : "creation"} form`} isPlatformUser={true}>
|
||||
<div className="m-2 md:mx-14 md:mx-5">
|
||||
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
|
||||
OAuth client creation form
|
||||
</h1>
|
||||
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
|
||||
This is the form to create a new OAuth client
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<OAuthClientForm isPending={isSaving} onSubmit={onSubmit} />
|
||||
</div>
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Shell isPlatformUser={true} hideHeadingOnMobile withoutMain={false} SidebarContainer={<></>}>
|
||||
You are not subscribed to a Platform plan.
|
||||
</Shell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CreateOAuthClient.PageWrapper = PageWrapper;
|
||||
@@ -22,6 +22,9 @@ test.describe("Availablity", () => {
|
||||
|
||||
test("Date Overrides", async ({ page }) => {
|
||||
await page.getByTestId("schedules").first().click();
|
||||
await page.locator('[data-testid="Sunday-switch"]').first().click();
|
||||
await page.locator('[data-testid="Saturday-switch"]').first().click();
|
||||
|
||||
await page.getByTestId("add-override").click();
|
||||
await page.locator('[id="modal-title"]').waitFor();
|
||||
await page.getByTestId("incrementMonth").click();
|
||||
|
||||
@@ -126,6 +126,21 @@ testBothFutureAndLegacyRoutes.describe("pro user", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("it redirects when a rescheduleUid does not match the current event type", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const [pro] = users.get();
|
||||
const [eventType] = pro.eventTypes;
|
||||
const bookingFixture = await bookings.create(pro.id, pro.username, eventType.id);
|
||||
|
||||
// open the wrong eventType (rescheduleUid created for /30min event)
|
||||
await page.goto(`${pro.username}/${pro.eventTypes[1].slug}?rescheduleUid=${bookingFixture.uid}`);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${pro.username}/${eventType.slug}`));
|
||||
});
|
||||
|
||||
test("Can cancel the recently created booking and rebook the same timeslot", async ({
|
||||
page,
|
||||
users,
|
||||
|
||||
@@ -62,6 +62,36 @@ test("dynamic booking", async ({ page, users }) => {
|
||||
});
|
||||
});
|
||||
|
||||
test("dynamic booking info prefilled by query params", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
|
||||
let duration = 15;
|
||||
const free = await users.create({ username: "free.example" });
|
||||
await page.goto(`/${pro.username}+${free.username}?duration=${duration}`);
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const badgeByDurationTestId = (duration: number) => `multiple-choice-${duration}mins`;
|
||||
|
||||
let badgeLocator = await page.getByTestId(badgeByDurationTestId(duration));
|
||||
let activeState = await badgeLocator.getAttribute("data-active");
|
||||
|
||||
expect(activeState).toEqual("true");
|
||||
|
||||
duration = 30;
|
||||
await page.goto(`/${pro.username}+${free.username}?duration=${duration}`);
|
||||
badgeLocator = await page.getByTestId(badgeByDurationTestId(duration));
|
||||
activeState = await badgeLocator.getAttribute("data-active");
|
||||
|
||||
expect(activeState).toEqual("true");
|
||||
|
||||
// Check another badge just to ensure its not selected
|
||||
badgeLocator = await page.getByTestId(badgeByDurationTestId(15));
|
||||
activeState = await badgeLocator.getAttribute("data-active");
|
||||
expect(activeState).toEqual("false");
|
||||
});
|
||||
|
||||
test.describe("Organization:", () => {
|
||||
test.afterEach(({ orgs, users }) => {
|
||||
orgs.deleteAll();
|
||||
|
||||
@@ -29,7 +29,8 @@ test.describe("Users can impersonate", async () => {
|
||||
await page.getByTestId("impersonation-submit").click();
|
||||
|
||||
// // Wait for sign in to complete
|
||||
await page.waitForURL("/settings/my-account/profile");
|
||||
await page.waitForURL("/event-types");
|
||||
await page.goto("/settings/profile");
|
||||
|
||||
const stopImpersonatingButton = page.getByTestId("stop-impersonating-button");
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
testName,
|
||||
} from "../lib/testUtils";
|
||||
import { expectExistingUserToBeInvitedToOrganization } from "../team/expects";
|
||||
import { gotoPathAndExpectRedirectToOrgDomain } from "./lib/gotoPathAndExpectRedirectToOrgDomain";
|
||||
import { acceptTeamOrOrgInvite, inviteExistingUserToOrganization } from "./lib/inviteUser";
|
||||
|
||||
test.describe("Bookings", () => {
|
||||
@@ -380,11 +380,11 @@ test.describe("Bookings", () => {
|
||||
|
||||
await test.step("Booking through old link redirects to new link on org domain", async () => {
|
||||
const event = await userOutsideOrganization.getFirstEventAsOwner();
|
||||
await expectRedirectToOrgDomain({
|
||||
await gotoPathAndExpectRedirectToOrgDomain({
|
||||
page,
|
||||
org,
|
||||
eventSlug: `/${usernameOutsideOrg}/${event.slug}`,
|
||||
expectedEventSlug: `/${usernameInOrg}/${event.slug}`,
|
||||
path: `/${usernameOutsideOrg}/${event.slug}`,
|
||||
expectedPath: `/${usernameInOrg}/${event.slug}`,
|
||||
});
|
||||
// As the redirection correctly happens, the booking would work too which we have verified in previous step. But we can't test that with org domain as that domain doesn't exist.
|
||||
});
|
||||
@@ -463,39 +463,3 @@ async function expectPageToBeNotFound({ page, url }: { page: Page; url: string }
|
||||
await page.goto(`${url}`);
|
||||
await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectRedirectToOrgDomain({
|
||||
page,
|
||||
org,
|
||||
eventSlug,
|
||||
expectedEventSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
org: { slug: string | null };
|
||||
eventSlug: string;
|
||||
expectedEventSlug: string;
|
||||
}) {
|
||||
if (!org.slug) {
|
||||
throw new Error("Org slug is not defined");
|
||||
}
|
||||
page.goto(eventSlug).catch((e) => {
|
||||
console.log("Expected navigation error to happen");
|
||||
});
|
||||
|
||||
const orgSlug = org.slug;
|
||||
|
||||
const orgRedirectUrl = await new Promise(async (resolve) => {
|
||||
page.on("request", (request) => {
|
||||
if (request.isNavigationRequest()) {
|
||||
const requestedUrl = request.url();
|
||||
console.log("Requested navigation to", requestedUrl);
|
||||
// Resolve on redirection to org domain
|
||||
if (requestedUrl.includes(orgSlug)) {
|
||||
resolve(requestedUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(orgRedirectUrl).toContain(`${getOrgFullOrigin(org.slug)}${expectedEventSlug}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
|
||||
export async function gotoPathAndExpectRedirectToOrgDomain({
|
||||
page,
|
||||
org,
|
||||
path,
|
||||
expectedPath,
|
||||
}: {
|
||||
page: Page;
|
||||
org: { slug: string | null };
|
||||
path: string;
|
||||
expectedPath: string;
|
||||
}) {
|
||||
if (!org.slug) {
|
||||
throw new Error("Org slug is not defined");
|
||||
}
|
||||
page.goto(path).catch((e) => {
|
||||
console.log("Expected navigation error to happen");
|
||||
});
|
||||
|
||||
const orgSlug = org.slug;
|
||||
|
||||
const orgRedirectUrl = await new Promise(async (resolve) => {
|
||||
page.on("request", (request) => {
|
||||
if (request.isNavigationRequest()) {
|
||||
const requestedUrl = request.url();
|
||||
console.log("Requested navigation to", requestedUrl);
|
||||
// Resolve on redirection to org domain
|
||||
if (requestedUrl.includes(orgSlug)) {
|
||||
resolve(requestedUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(orgRedirectUrl).toContain(`${getOrgFullOrigin(org.slug)}${expectedPath}`);
|
||||
}
|
||||
@@ -1,11 +1,94 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Messages } from "mailhog";
|
||||
import path from "path";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import type { createEmailsFixture } from "../fixtures/emails";
|
||||
import { test } from "../lib/fixtures";
|
||||
import { fillStripeTestCheckout } from "../lib/testUtils";
|
||||
import { getEmailsReceivedByUser } from "../lib/testUtils";
|
||||
import { gotoPathAndExpectRedirectToOrgDomain } from "./lib/gotoPathAndExpectRedirectToOrgDomain";
|
||||
|
||||
async function expectEmailWithSubject(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string,
|
||||
subject: string
|
||||
) {
|
||||
if (!emails) return null;
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
|
||||
const allEmails = (receivedEmails as Messages).items;
|
||||
const email = allEmails.find((email) => email.subject === subject);
|
||||
if (!email) {
|
||||
throw new Error(`Email with subject ${subject} not found`);
|
||||
}
|
||||
const dom = new JSDOM(email.html);
|
||||
return dom;
|
||||
}
|
||||
|
||||
export async function expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
orgSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
emails: ReturnType<typeof createEmailsFixture>;
|
||||
userEmail: string;
|
||||
orgSlug: string;
|
||||
}) {
|
||||
const dom = await expectEmailWithSubject(page, emails, userEmail, "Your organization has been created");
|
||||
const document = dom?.window?.document;
|
||||
expect(document?.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function expectOrganizationCreationEmailToBeSentWithLinks({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
oldUsername,
|
||||
newUsername,
|
||||
orgSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
emails: ReturnType<typeof createEmailsFixture>;
|
||||
userEmail: string;
|
||||
oldUsername: string;
|
||||
newUsername: string;
|
||||
orgSlug: string;
|
||||
}) {
|
||||
const dom = await expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
orgSlug,
|
||||
});
|
||||
const document = dom?.window.document;
|
||||
const links = document?.querySelectorAll(`[data-testid="organization-link-info"] [href]`);
|
||||
if (!links) {
|
||||
throw new Error(`data-testid="organization-link-info doesn't have links`);
|
||||
}
|
||||
expect((links[0] as unknown as HTMLAnchorElement).href).toContain(oldUsername);
|
||||
expect((links[1] as unknown as HTMLAnchorElement).href).toContain(newUsername);
|
||||
}
|
||||
|
||||
export async function expectEmailVerificationEmailToBeSent(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string
|
||||
) {
|
||||
const subject = "Cal.com: Verify your account";
|
||||
return expectEmailWithSubject(page, emails, userEmail, subject);
|
||||
}
|
||||
|
||||
test.afterAll(({ users, orgs }) => {
|
||||
users.deleteAll();
|
||||
@@ -20,26 +103,33 @@ function capitalize(text: string) {
|
||||
}
|
||||
|
||||
test.describe("Organization", () => {
|
||||
test("Admin should be able to create an org for a target user", async ({ page, users, emails }) => {
|
||||
test("Admin should be able to create an org where an existing user is made an owner", async ({
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const appLevelAdmin = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
await appLevelAdmin.apiLogin();
|
||||
const stringUUID = uuid();
|
||||
|
||||
const orgOwnerUsername = `owner-${stringUUID}`;
|
||||
const orgOwnerUsernamePrefix = "owner";
|
||||
|
||||
const targetOrgEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
const orgOwnerEmail = users.trackEmail({
|
||||
username: orgOwnerUsernamePrefix,
|
||||
domain: `example.com`,
|
||||
});
|
||||
|
||||
const orgOwnerUser = await users.create({
|
||||
username: orgOwnerUsername,
|
||||
email: targetOrgEmail,
|
||||
username: orgOwnerUsernamePrefix,
|
||||
email: orgOwnerEmail,
|
||||
role: "ADMIN",
|
||||
});
|
||||
|
||||
const orgName = capitalize(`${orgOwnerUsername}`);
|
||||
const orgOwnerUsernameOutsideOrg = orgOwnerUser.username;
|
||||
const orgOwnerUsernameInOrg = orgOwnerEmail.split("@")[0];
|
||||
const orgName = capitalize(`${orgOwnerUser.username}`);
|
||||
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
|
||||
await page.goto("/settings/organizations/new");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
@@ -49,17 +139,16 @@ test.describe("Organization", () => {
|
||||
await expect(page.locator(".text-red-700")).toHaveCount(3);
|
||||
|
||||
// Happy path
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
|
||||
// Since we are admin fill in this infomation instead of deriving it
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgOwnerUsername);
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
});
|
||||
|
||||
// Fill in seat infomation
|
||||
await page.locator("input[name=seats]").fill("30");
|
||||
await page.locator("input[name=pricePerSeat]").fill("30");
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expectOrganizationCreationEmailToBeSentWithLinks({
|
||||
page,
|
||||
emails,
|
||||
userEmail: orgOwnerEmail,
|
||||
oldUsername: orgOwnerUsernameOutsideOrg || "",
|
||||
newUsername: orgOwnerUsernameInOrg,
|
||||
orgSlug,
|
||||
});
|
||||
|
||||
await test.step("About the organization", async () => {
|
||||
@@ -149,6 +238,56 @@ test.describe("Organization", () => {
|
||||
|
||||
await expect(upgradeButtonHidden).toBeHidden();
|
||||
});
|
||||
|
||||
// Verify that the owner's old username redirect is properly set
|
||||
await gotoPathAndExpectRedirectToOrgDomain({
|
||||
page,
|
||||
org: {
|
||||
slug: orgSlug,
|
||||
},
|
||||
path: `/${orgOwnerUsernameOutsideOrg}`,
|
||||
expectedPath: `/${orgOwnerUsernameInOrg}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("Admin should be able to create an org where the owner doesn't exist yet", async ({
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const appLevelAdmin = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
await appLevelAdmin.apiLogin();
|
||||
const orgOwnerUsername = `owner`;
|
||||
const orgName = capitalize(`${orgOwnerUsername}`);
|
||||
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
|
||||
const orgOwnerEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.locator(".text-red-700")).toHaveCount(3);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
});
|
||||
|
||||
const dom = await expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail: orgOwnerEmail,
|
||||
orgSlug,
|
||||
});
|
||||
expect(dom?.window.document.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
|
||||
await expectEmailVerificationEmailToBeSent(page, emails, orgOwnerEmail);
|
||||
// Rest of the steps remain same as org creation with existing user as owner. So skipping them
|
||||
});
|
||||
|
||||
test("User can create and upgrade a org", async ({ page, users, emails }) => {
|
||||
@@ -426,3 +565,24 @@ test.describe("Organization", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function fillAndSubmitFirstStepAsAdmin(
|
||||
page: Page,
|
||||
targetOrgEmail: string,
|
||||
orgName: string,
|
||||
orgSlug: string
|
||||
) {
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
|
||||
// Since we are admin fill in this infomation instead of deriving it
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
// Fill in seat infomation
|
||||
await page.locator("input[name=seats]").fill("30");
|
||||
await page.locator("input[name=pricePerSeat]").fill("30");
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/trpc/organizations/create**"),
|
||||
page.locator("button[type=submit]").click(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/client";
|
||||
|
||||
@@ -427,7 +428,7 @@ test.describe("BOOKING_RESCHEDULED", async () => {
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking?.uid } })!;
|
||||
@@ -494,7 +495,7 @@ test.describe("BOOKING_RESCHEDULED", async () => {
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
|
||||
const newBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
@@ -543,6 +544,131 @@ test.describe("BOOKING_RESCHEDULED", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("MEETING_ENDED, MEETING_STARTED", async () => {
|
||||
test("should create/remove scheduledWebhookTriggers for existing bookings", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}, _testInfo) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
const tomorrow = dayjs().add(1, "day");
|
||||
const [eventType] = user.eventTypes;
|
||||
bookings.create(user.id, user.name, eventType.id);
|
||||
bookings.create(user.id, user.name, eventType.id, { startTime: dayjs().add(2, "day").toDate() });
|
||||
|
||||
//create a new webhook with meeting ended trigger here
|
||||
await page.goto("/settings/developer/webhooks");
|
||||
// --- add webhook
|
||||
await page.click('[data-testid="new_webhook"]');
|
||||
|
||||
await page.fill('[name="subscriberUrl"]', "https://www.example.com");
|
||||
|
||||
await Promise.all([
|
||||
page.click("[type=submit]"),
|
||||
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
|
||||
]);
|
||||
|
||||
const scheduledTriggers = await prisma.webhookScheduledTriggers.findMany({
|
||||
where: {
|
||||
webhook: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
payload: true,
|
||||
webhook: {
|
||||
select: {
|
||||
userId: true,
|
||||
id: true,
|
||||
subscriberUrl: true,
|
||||
},
|
||||
},
|
||||
startAfter: true,
|
||||
},
|
||||
});
|
||||
|
||||
const existingUserBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
startTime: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const meetingStartedTriggers = scheduledTriggers.filter((trigger) =>
|
||||
trigger.payload.includes("MEETING_STARTED")
|
||||
);
|
||||
const meetingEndedTriggers = scheduledTriggers.filter((trigger) =>
|
||||
trigger.payload.includes("MEETING_ENDED")
|
||||
);
|
||||
|
||||
expect(meetingStartedTriggers.length).toBe(existingUserBookings.length);
|
||||
expect(meetingEndedTriggers.length).toBe(existingUserBookings.length);
|
||||
|
||||
expect(meetingStartedTriggers.map((trigger) => trigger.startAfter)).toEqual(
|
||||
expect.arrayContaining(existingUserBookings.map((booking) => booking.startTime))
|
||||
);
|
||||
expect(meetingEndedTriggers.map((trigger) => trigger.startAfter)).toEqual(
|
||||
expect.arrayContaining(existingUserBookings.map((booking) => booking.endTime))
|
||||
);
|
||||
|
||||
page.reload();
|
||||
|
||||
// edit webhook and remove trigger meeting ended trigger
|
||||
await page.click('[data-testid="webhook-edit-button"]');
|
||||
await page.getByRole("button", { name: "Remove Meeting Ended" }).click();
|
||||
|
||||
await Promise.all([
|
||||
page.click("[type=submit]"),
|
||||
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
|
||||
]);
|
||||
|
||||
const scheduledTriggersAfterRemovingTrigger = await prisma.webhookScheduledTriggers.findMany({
|
||||
where: {
|
||||
webhook: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newMeetingStartedTriggers = scheduledTriggersAfterRemovingTrigger.filter((trigger) =>
|
||||
trigger.payload.includes("MEETING_STARTED")
|
||||
);
|
||||
const newMeetingEndedTriggers = scheduledTriggersAfterRemovingTrigger.filter((trigger) =>
|
||||
trigger.payload.includes("MEETING_ENDED")
|
||||
);
|
||||
|
||||
expect(newMeetingStartedTriggers.length).toBe(existingUserBookings.length);
|
||||
expect(newMeetingEndedTriggers.length).toBe(0);
|
||||
|
||||
// disable webhook
|
||||
await page.click('[data-testid="webhook-switch"]');
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const scheduledTriggersAfterDisabling = await prisma.webhookScheduledTriggers.findMany({
|
||||
where: {
|
||||
webhook: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
payload: true,
|
||||
webhook: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
startAfter: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(scheduledTriggersAfterDisabling.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("FORM_SUBMITTED", async () => {
|
||||
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
|
||||
const webhookReceiver = createHttpServer();
|
||||
@@ -578,6 +704,8 @@ test.describe("FORM_SUBMITTED", async () => {
|
||||
],
|
||||
});
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await gotoRoutingLink({ page, formId: form.id });
|
||||
const fieldName = "name";
|
||||
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
|
||||
@@ -637,6 +765,8 @@ test.describe("FORM_SUBMITTED", async () => {
|
||||
],
|
||||
});
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await gotoRoutingLink({ page, formId: form.id });
|
||||
const fieldName = "name";
|
||||
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user